diff --git a/ROADMAP.md b/ROADMAP.md index 9a38880..d8e115d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -179,9 +179,15 @@ dimension measured side by side. PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and - background blurs as images; checked by rendering the PDFs back against PNG exports. - *Left:* .fig import (best effort), variable mode lookups in prototype expressions, PDF print - options (bleed, CMYK), SVG filters other than shadows and blurs (left out with a warning). 10–20 h. + background blurs as images; checked by rendering the PDFs back against PNG exports. PDF print + options in the Export panel (per PDF export setting), the Export dialog, `file.export`, + `file.exportLayers`, `file.exportFramesPdf` and `figcraft-cli export`: a bleed showing what lies + past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed + marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic + naive conversion; images and rasterised effects too). + *Left:* .fig import (best effort), variable mode lookups in prototype expressions, a CMYK + output intent (no CMYK ICC profile can ship in the repository), SVG filters other than shadows + and blurs (left out with a warning). 8–16 h. 7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to @@ -310,7 +316,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Components & variants | 9 | 76% | QA with larger real-world libraries | 1 | | Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 | | Prototyping | 6 | 89% | dragging actions to reorder them, QA against larger prototypes with conditionals | 2–4 | -| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK), SVG filters beyond shadows and blurs and rotated repeating patterns | 10–20 | +| Import/export | 6 | 71% | .fig import (best effort), variable-mode lookups in prototype expressions, SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 | | Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 | | Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index cdaaed0..372082c 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -59,6 +59,18 @@ impl Args { } } + /// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`, + /// `--bleed-marks`, `--cmyk`. + pub fn print(&self) -> CliResult { + let bleed = match self.values.get("bleed") { + None => 0.0, + Some(s) => figcraft_doc::PrintSettings::parse_length(s) + .filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED) + .ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?, + }; + Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") }) + } + pub fn scale(&self) -> CliResult { match self.values.get("scale") { None => Ok(1.0), diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 8632595..d82c0f6 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::process::ExitCode; -use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind}; +use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind, PrintSettings}; use figcraft_engine::Session; use serde_json::{Value, json}; @@ -106,18 +106,18 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> { .ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))? .into_bytes(), "png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?, - "pdf" => page_pdf(doc, page)?, + "pdf" => page_pdf(doc, page, &PrintSettings::default())?, other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))), }; write(out, &bytes) } /// A page as PDF: its top-level frames one page each, or (without frames) everything on one page. -fn page_pdf(doc: &Document, page: NodeId) -> CliResult> { +fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult> { let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) { - figcraft_engine::cmd::file::frames_pdf(doc, page).map(|(bytes, _)| bytes) + figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes) } else { - figcraft_engine::cmd::file::export_bytes(doc, &children(doc, page), ExportFormat::Pdf, 1.0) + figcraft_engine::cmd::file::export_bytes_with(doc, &children(doc, page), ExportFormat::Pdf, 1.0, print) }; r.map_err(|e| CliError::Failed(e.to_string())) } @@ -181,20 +181,32 @@ fn sanitize(name: &str) -> String { if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } } -pub fn export(file: &str, out: &str, format: Option<&str>, scale: f64, page: Option<&str>, node: Option<&str>) -> CliResult { +/// What `export` exports and how. +pub struct Target { + pub format: Option, + pub scale: f64, + pub page: Option, + pub node: Option, + /// PDF only. + pub print: PrintSettings, +} + +pub fn export(file: &str, out: &str, t: &Target) -> CliResult { let doc = load(file)?; - let page = page_named(&doc, page)?; - let fmt = match format { + let page = page_named(&doc, t.page.as_deref())?; + let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print); + let fmt = match t.format.as_deref() { Some(f) => image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf)")))?, None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), }; let target = node.map(|q| find_node(&doc, page, q)).transpose()?; let single = image_format(&ext(out)).is_some(); - let bytes_of = |ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes(&doc, ids, fmt, scale).map_err(|e| CliError::Failed(e.to_string())); + let bytes_of = + |ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes_with(&doc, ids, fmt, scale, print).map_err(|e| CliError::Failed(e.to_string())); if single { let bytes = match target { Some(t) => bytes_of(&[t])?, - None if fmt == ExportFormat::Pdf => page_pdf(&doc, page)?, + None if fmt == ExportFormat::Pdf => page_pdf(&doc, page, print)?, None => bytes_of(&children(&doc, page))?, }; write(out, &bytes)?; diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index e87326a..edd71eb 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,6 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] figcraft-cli export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] + [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] @@ -27,7 +28,8 @@ USAGE: Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file per top-level frame into the directory, or one file when -has an image extension or --node is given. `run` executes a JSON array (or JSON lines) of +has an image extension or --node is given; PDFs take print options (a bleed like 3mm or 0.125in, +crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of {\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. See docs/cli.md and docs/mcp.md."; @@ -79,10 +81,12 @@ fn run(argv: &[String]) -> CliResult { files::info(&file, a.flag("json")) } "export" => { - let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out"], &[])?; + let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; - files::export(&file, &out, a.value("format").as_deref(), a.scale()?, a.value("page").as_deref(), a.value("node").as_deref()) + let target = + files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; + files::export(&file, &out, &target) } "convert" => { let a = Args::parse(rest, &[], &[])?; diff --git a/apps/figcraft-cli/tests/pdf_print.rs b/apps/figcraft-cli/tests/pdf_print.rs new file mode 100644 index 0000000..b2ce3c9 --- /dev/null +++ b/apps/figcraft-cli/tests/pdf_print.rs @@ -0,0 +1,39 @@ +//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::process::Command; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) +} + +#[test] +fn pdf_export_with_bleed_marks_and_cmyk() { + let d = std::env::temp_dir().join(format!("figcraft-cli-print-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("s.jsonl"); + std::fs::write(&script, r##"{"command":"frame.create","params":{"x":0,"y":0,"width":100,"height":50,"name":"A","props":{"fill":"#ff0000"}}}"##) + .unwrap(); + let file = d.join("doc.figcraft"); + assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).status().unwrap().success()); + + let pdf = d.join("print.pdf"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&pdf).args(["--bleed", "0.125in", "--crop-marks", "--cmyk"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let s = String::from_utf8_lossy(&std::fs::read(&pdf).unwrap()).into_owned(); + // 9 pt of bleed, then 3 + 18 + 3 pt for the marks. + assert!(s.contains("/MediaBox[0 0 166 116]") && s.contains("/BleedBox[24 24 142 92]") && s.contains("/TrimBox[33 33 133 83]"), "{s}"); + // Without options: the frame's size, no boxes. + let plain = d.join("plain.pdf"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&plain).status().unwrap().success()); + let s = String::from_utf8_lossy(&std::fs::read(&plain).unwrap()).into_owned(); + assert!(s.contains("/MediaBox[0 0 100 50]") && !s.contains("/TrimBox"), "{s}"); + // A directory of PDFs gets them too. + let dir = d.join("out"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "pdf", "--bleed", "3mm"]).status().unwrap().success()); + assert!(String::from_utf8_lossy(&std::fs::read(dir.join("A.pdf")).unwrap()).contains("/TrimBox[")); + // A bleed that isn't a length is a usage error. + let out = bin().arg("export").arg(&file).arg("-o").arg(&pdf).args(["--bleed", "lots"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{}", String::from_utf8_lossy(&out.stderr)); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 69d6ad5..a065969 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -849,6 +849,8 @@ mod diff_tests; #[cfg(test)] mod effects_tests; #[cfg(test)] +mod print_tests; +#[cfg(test)] mod proto_expr_parse_tests; #[cfg(test)] mod proto_expr_tests; diff --git a/crates/doc/src/print_tests.rs b/crates/doc/src/print_tests.rs new file mode 100644 index 0000000..9aeea08 --- /dev/null +++ b/crates/doc/src/print_tests.rs @@ -0,0 +1,55 @@ +//! PDF print settings on export settings: lengths with units, safe values, saved only when set. + +use crate::{ExportFormat, ExportSetting, PrintSettings}; + +#[test] +fn lengths_read_with_their_units() { + let pt = |s: &str| PrintSettings::parse_length(s); + assert_eq!(pt("9"), Some(9.0)); + assert_eq!(pt("9pt"), Some(9.0)); + assert_eq!(pt(" 9 PX "), Some(9.0)); + assert_eq!(pt("0.125in"), Some(9.0)); + assert_eq!(pt("0.125\""), Some(9.0)); + assert!((pt("3mm").unwrap() - 8.503_937).abs() < 1e-6); + assert!((pt("3 mm").unwrap() - 8.503_937).abs() < 1e-6); + assert!((pt("0.3cm").unwrap() - 8.503_937).abs() < 1e-6); + for bad in ["", "mm", "3 furlongs", "-1mm", "NaN", "inf", "1e400", "3mm4"] { + assert_eq!(pt(bad), None, "{bad}"); + } +} + +#[test] +fn lengths_label_in_millimetres_when_whole() { + assert_eq!(PrintSettings::length_label(72.0 / 25.4 * 3.0), "3 mm"); + assert_eq!(PrintSettings::length_label(72.0 / 25.4 * 2.5), "2.5 mm"); + assert_eq!(PrintSettings::length_label(9.0), "9 pt"); + assert_eq!(PrintSettings::length_label(0.0), "0 mm"); +} + +#[test] +fn hostile_bleeds_are_made_safe() { + let b = |bleed: f64| PrintSettings { bleed, ..Default::default() }.bleed(); + assert_eq!(b(f64::NAN), 0.0); + assert_eq!(b(-3.0), 0.0); + assert_eq!(b(f64::INFINITY), 0.0); + assert_eq!(b(1e9), PrintSettings::MAX_BLEED); + // Bleed marks need a bleed; crop marks don't. + assert!(!PrintSettings { bleed_marks: true, ..Default::default() }.extends_page()); + assert!(PrintSettings { crop_marks: true, ..Default::default() }.extends_page()); + assert!(PrintSettings { bleed: 9.0, ..Default::default() }.extends_page()); + assert!(!PrintSettings { cmyk: true, ..Default::default() }.extends_page()); +} + +#[test] +fn print_settings_save_only_when_set() { + let plain = ExportSetting { format: ExportFormat::Pdf, ..Default::default() }; + let json = serde_json::to_value(&plain).unwrap(); + assert!(json.get("print").is_none(), "{json}"); + let print = ExportSetting { print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true }, ..plain }; + let json = serde_json::to_value(&print).unwrap(); + assert_eq!(json["print"], serde_json::json!({"bleed": 9.0, "cropMarks": true, "bleedMarks": true, "cmyk": true})); + assert_eq!(serde_json::from_value::(json).unwrap(), print); + // Files from before have none. + let old: ExportSetting = serde_json::from_value(serde_json::json!({"format": "pdf", "suffix": ""})).unwrap(); + assert!(old.print.is_default()); +} diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d7506d0..98ca553 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1121,6 +1121,72 @@ impl ExportSize { } } +/// Print settings of a PDF export: bleed, printer's marks and CMYK colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintSettings { + /// Bleed around each page, in points (one point per pixel): what lies past the page's edge + /// is printed this far out, to be trimmed off. + pub bleed: f64, + /// Crop marks outside the bleed, at the trim's corners. + pub crop_marks: bool, + /// Bleed marks outside the bleed, at the bleed's corners. + pub bleed_marks: bool, + /// CMYK colour (converted from RGB) instead of RGB. + pub cmyk: bool, +} + +impl PrintSettings { + /// Largest bleed, in points (two inches). + pub const MAX_BLEED: f64 = 144.0; + + pub fn is_default(&self) -> bool { + *self == PrintSettings::default() + } + + /// The bleed made safe: finite, within `0..=MAX_BLEED`. + pub fn bleed(&self) -> f64 { + if self.bleed.is_finite() { self.bleed.clamp(0.0, Self::MAX_BLEED) } else { 0.0 } + } + + pub fn marks(&self) -> bool { + self.crop_marks || (self.bleed_marks && self.bleed() > 0.0) + } + + /// Whether the page gets a bleed or marks around its trim. + pub fn extends_page(&self) -> bool { + self.bleed() > 0.0 || self.marks() + } + + /// A length in points: a number (points), or with a unit: `pt`, `px` (a point), `mm`, `cm`, + /// `in` or `"` ("3mm", "0.125 in", "9pt"). `None` unless finite and not negative. + pub fn parse_length(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + let split = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e')).unwrap_or(s.len()); + let (num, unit) = (s.get(..split)?.trim(), s.get(split..)?.trim()); + let v: f64 = num.parse().ok()?; + let k = match unit { + "" | "pt" | "px" => 1.0, + "mm" => 72.0 / 25.4, + "cm" => 72.0 / 2.54, + "in" | "\"" => 72.0, + _ => return None, + }; + let pt = v * k; + (pt.is_finite() && pt >= 0.0).then_some(pt) + } + + /// A length for people: in millimetres when it's a whole tenth of one, else in points. + pub fn length_label(pt: f64) -> String { + let mm = pt * 25.4 / 72.0; + if ((mm * 10.0).round() - mm * 10.0).abs() < 1e-6 { + format!("{} mm", crate::text::trim_num((mm * 10.0).round() / 10.0)) + } else { + format!("{} pt", crate::text::trim_num(pt)) + } + } +} + /// One row of a node's Export section. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] @@ -1128,6 +1194,9 @@ pub struct ExportSetting { pub size: ExportSize, pub suffix: String, pub format: ExportFormat, + /// PDF only: bleed, marks and colour. + #[serde(skip_serializing_if = "PrintSettings::is_default")] + pub print: PrintSettings, } impl ExportSetting { @@ -1137,7 +1206,7 @@ impl ExportSetting { match last.map(|e| e.size) { Some(ExportSize::Scale { value }) => { let size = ExportSize::Scale { value: (value.floor() + 1.0).min(ExportSize::MAX_SCALE) }; - ExportSetting { suffix: format!("@{}", size.label()), size, format: ExportFormat::Png } + ExportSetting { suffix: format!("@{}", size.label()), size, format: ExportFormat::Png, ..Default::default() } } _ => ExportSetting::default(), } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ac445e4..67b906f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -259,7 +259,7 @@ pub fn specs() -> Vec { spec!( "export.add", "Add export setting", - "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", + "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp, bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; @@ -277,7 +277,7 @@ pub fn specs() -> Vec { spec!( "export.update", "Edit export setting", - "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp)}", + "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", true, |s, p| { let cmd = "export.update"; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 670182e..8453aa8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -31,14 +31,14 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`)}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", false, export ), spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), dir? (folder to write into; else each file comes back as base64 `data`)} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", false, export_layers ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { spec!( "file.exportFramesPdf", "Export frames to PDF", - "{path?, page? (page id, default the current page)} every top-level frame in reading order, one PDF page each; returns base64 `data` without a path", + "{path?, page? (page id, default the current page), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)} every top-level frame in reading order, one PDF page each; returns base64 `data` without a path", false, export_frames_pdf ), @@ -610,6 +610,11 @@ fn save_as(s: &mut Session, p: &Value) -> Result { /// Encode `ids` (or the page) in `format` at `scale`. pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { + export_bytes_with(doc, ids, format, scale, &PrintSettings::default()) +} + +/// [`export_bytes`], PDFs with `print`'s bleed, marks and colour. +pub fn export_bytes_with(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64, print: &PrintSettings) -> Result> { if ids.is_empty() { return Err(EngineError::Disabled("file.export".into(), "nothing to export".into())); } @@ -623,7 +628,7 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: // Frames export one page each; anything else shares one page. let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let groups: Vec> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![super::edit::z_sorted(doc, ids)] }; - pdf_bytes(doc, &groups) + pdf_bytes(doc, &groups, print) } _ => { let pix = if ids.len() == 1 { @@ -663,7 +668,8 @@ fn export(s: &mut Session, p: &Value) -> Result { Some(size) => export_scale(doc, &ids, size), None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), }; - let bytes = export_bytes(doc, &ids, format, scale)?; + let print = print_param("file.export", p, PrintSettings::default())?; + let bytes = export_bytes_with(doc, &ids, format, scale, &print)?; match string(p, "path") { Some(path) => { write(&path, &bytes)?; @@ -690,9 +696,39 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu if let Some(f) = string(p, "format") { e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?; } + e.print = print_param(cmd, p, e.print)?; Ok(e) } +/// Whether `p` sets any PDF print option. +fn has_print_param(p: &Value) -> bool { + ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) +} + +/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or +/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. +pub(super) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { + match get(p, "bleed") { + None | Some(Value::Null) => {} + Some(v) => { + let pt = match v { + Value::String(s) => PrintSettings::parse_length(s), + v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), + }; + base.bleed = pt + .filter(|b| *b <= PrintSettings::MAX_BLEED) + .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; + } + } + for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(base) +} + /// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. fn size_param(cmd: &str, p: &Value) -> Result> { let size = match get(p, "size") { @@ -761,10 +797,12 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // Print options passed here stand in for every PDF setting's own. + let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); for job in &jobs { let scale = export_scale(doc, &[job.id], job.setting.size); - let bytes = export_bytes(doc, &[job.id], job.setting.format, scale)?; + let bytes = export_bytes_with(doc, &[job.id], job.setting.format, scale, print.as_ref().unwrap_or(&job.setting.print))?; let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); match &dir { Some(d) => { @@ -781,20 +819,20 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { } /// A PDF with one page per group of layers (an sRGB document), titled after the file. -fn pdf_bytes(doc: &Document, groups: &[Vec]) -> Result> { - let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), ..Default::default() }; +fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings) -> Result> { + let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, ..Default::default() }; figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) } /// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. -pub fn frames_pdf(doc: &Document, page: NodeId) -> Result<(Vec, usize)> { +pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { let mut frames = doc.top_frames(page); frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); if frames.is_empty() { return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); } let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); - Ok((pdf_bytes(&doc.in_srgb(), &groups)?, frames.len())) + Ok((pdf_bytes(&doc.in_srgb(), &groups, print)?, frames.len())) } /// Every top-level frame of a page (reading order), one PDF page each. @@ -805,7 +843,8 @@ fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), None => s.page()?, }; - let (bytes, pages) = frames_pdf(doc, page)?; + let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; + let (bytes, pages) = frames_pdf(doc, page, &print)?; match string(p, "path") { Some(path) => { write(&path, &bytes)?; diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index 7867ceb..f74699d 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -149,7 +149,10 @@ fn export_rows_add_step_up_and_update() { s.execute("export.update", &json!({"ids": [r], "index": 1, "size": "512w", "format": "webp", "suffix": "-wide"})).unwrap(); s.execute("export.update", &json!({"ids": [r], "index": 2, "size": {"by": "height", "value": 64}, "format": "pdf"})).unwrap(); let rows = exports(&s, r); - assert_eq!(rows[1], ExportSetting { size: ExportSize::Width { value: 512.0 }, suffix: "-wide".into(), format: ExportFormat::Webp }); + assert_eq!( + rows[1], + ExportSetting { size: ExportSize::Width { value: 512.0 }, suffix: "-wide".into(), format: ExportFormat::Webp, ..Default::default() } + ); assert_eq!((rows[2].size, rows[2].format), (ExportSize::Height { value: 64.0 }, ExportFormat::Pdf)); for bad in [ json!({"size": "0x"}), @@ -223,7 +226,7 @@ fn export_sizes_parse_and_label() { for bad in ["", "x", "0x", "-1x", "NaNx", "infx", "65x", "20000w", "2q", "2xx"] { assert_eq!(ExportSize::parse(bad), None, "{bad}"); } - let e = |size, suffix: &str, format| ExportSetting { size, suffix: suffix.into(), format }; + let e = |size, suffix: &str, format| ExportSetting { size, suffix: suffix.into(), format, ..Default::default() }; assert_eq!(e(ExportSize::Scale { value: 2.0 }, "", ExportFormat::Png).file_suffix(), "@2x"); assert_eq!(e(ExportSize::Scale { value: 2.0 }, "", ExportFormat::Svg).file_suffix(), ""); assert_eq!(e(ExportSize::Scale { value: 2.0 }, "-big", ExportFormat::Png).file_suffix(), "-big"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cc51555..96dabd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -993,6 +993,8 @@ mod paint_reorder_tests; #[cfg(test)] mod paint_tests; #[cfg(test)] +mod pdf_print_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod progressive_blur_tests; diff --git a/crates/engine/src/pdf_print_tests.rs b/crates/engine/src/pdf_print_tests.rs new file mode 100644 index 0000000..e6df61f --- /dev/null +++ b/crates/engine/src/pdf_print_tests.rs @@ -0,0 +1,92 @@ +//! PDF print options through the export commands: one-off exports, layers' own export settings +//! (stored, edited, undone), the frames PDF, and bad values refused. + +use base64::Engine as _; +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +/// A session with a 200 × 100 frame (selected). +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Poster"})).unwrap(); + let f = id_of(&r["id"]).unwrap(); + s.set_selection(vec![f]); + (s, f) +} + +fn pdf_text(r: &Value) -> String { + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn one_off_pdf_exports_take_print_options() { + let (mut s, f) = session(); + let plain = pdf_text(&s.execute("file.export", &json!({"ids": [f], "format": "pdf"})).unwrap()); + assert!(plain.contains("/MediaBox[0 0 200 100]") && !plain.contains("/TrimBox"), "{plain}"); + // 0.125 in = 9 pt of bleed and crop marks. + let r = s.execute("file.export", &json!({"ids": [f], "format": "pdf", "bleed": "0.125in", "cropMarks": true, "cmyk": true})).unwrap(); + let t = pdf_text(&r); + assert!(t.contains("/TrimBox[33 33 233 133]") && t.contains("/BleedBox[24 24 242 142]"), "{t}"); + // Numbers are points; other formats ignore the options. + let t = pdf_text(&s.execute("file.export", &json!({"ids": [f], "format": "pdf", "bleed": 9})).unwrap()); + assert!(t.contains("/TrimBox[9 9 209 109]"), "{t}"); + assert!(s.execute("file.export", &json!({"ids": [f], "format": "png", "bleed": "3mm"})).is_ok()); +} + +#[test] +fn bad_print_options_are_refused() { + let (mut s, f) = session(); + for bad in [ + json!({"bleed": "3 furlongs"}), + json!({"bleed": -1}), + json!({"bleed": "3in"}), + json!({"bleed": true}), + json!({"cropMarks": "maybe"}), + json!({"cmyk": [1]}), + ] { + let mut p = bad.clone(); + p["ids"] = json!([f]); + p["format"] = json!("pdf"); + assert!(s.execute("file.export", &p).is_err(), "{bad}"); + assert!(s.execute("export.add", &json!({"ids": [f], "setting": bad})).is_err(), "{bad}"); + } + assert!(s.doc().unwrap().node(f).unwrap().exports.is_empty(), "nothing added by a refused setting"); +} + +#[test] +fn export_settings_keep_print_options() { + let (mut s, f) = session(); + s.execute("export.add", &json!({"ids": [f], "setting": {"format": "pdf", "bleed": "3mm", "cmyk": true}})).unwrap(); + let print = |s: &Session| s.doc().unwrap().node(f).unwrap().exports[0].print; + assert!((print(&s).bleed - 72.0 / 25.4 * 3.0).abs() < 1e-9 && print(&s).cmyk && !print(&s).crop_marks); + s.execute("export.update", &json!({"ids": [f], "index": 0, "cropMarks": true})).unwrap(); + assert!(print(&s).crop_marks && print(&s).cmyk, "the rest stays"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!print(&s).crop_marks); + // "Export layers" uses each setting's own options ... + let r = s.execute("file.exportLayers", &json!({"ids": [f]})).unwrap(); + let t = pdf_text(&r["files"][0]); + assert!(t.contains("/TrimBox[") && !t.contains("0 0 0 RG"), "a bleed, no marks: {t}"); + // ... unless options are passed for all of them. + let r = s.execute("file.exportLayers", &json!({"ids": [f], "bleed": 0})).unwrap(); + assert!(!pdf_text(&r["files"][0]).contains("/TrimBox[")); + // Saved with the file and read back. + let json = serde_json::to_value(&s.doc().unwrap().node(f).unwrap().exports).unwrap(); + assert_eq!(json[0]["print"]["cmyk"], json!(true)); +} + +#[test] +fn the_frames_pdf_takes_print_options() { + let (mut s, _) = session(); + s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 50, "height": 50, "name": "Card"})).unwrap(); + let r = s.execute("file.exportFramesPdf", &json!({"bleed": "3mm", "bleedMarks": true})).unwrap(); + assert_eq!(r["pages"], json!(2)); + let t = pdf_text(&r); + assert_eq!(t.matches("/TrimBox[").count(), 2, "{t}"); + assert_eq!(t.matches("/BleedBox[").count(), 2, "{t}"); +} diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 8f868ff..9c3ad3e 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -514,6 +514,7 @@ pub(super) fn export_settings(v: &Value) -> Vec { }, suffix: clip_str(text(e, "suffix").unwrap_or("")).to_string(), format: text(e, "format").and_then(ExportFormat::from_ext).unwrap_or_default(), + ..Default::default() } }) .collect() diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b758d34..12c25cd 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,7 +196,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale`. With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.", + "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale` (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.", obj( json!({ "ids": ids_schema("Layers to export (several are composited into one image)"), @@ -204,6 +204,10 @@ pub fn tool_definitions() -> Vec { "scale": num("Pixel scale for raster formats (default 1, max 64)"), "size": string("Instead of `scale`: `2x`, or a width or height in pixels like `512w` / `512h`"), "path": string("Output file path"), + "bleed": {"type": ["number", "string"], "description": "PDF: bleed around each page, in points or as a length like `3mm` or `0.125in`"}, + "cropMarks": boolean("PDF: crop marks"), + "bleedMarks": boolean("PDF: bleed marks"), + "cmyk": boolean("PDF: CMYK colour instead of RGB"), }), &[], ), @@ -646,6 +650,12 @@ fn export(b: &mut dyn Backend, a: &Map) -> Result { pub links: Vec<(Rect, String)>, /// Above zero while drawing into a soft mask (nothing there is visible or clickable). pub(crate) masking: usize, + /// The page's frame and the bleed its fills and clip reach out to. + pub bleed_root: Option<(NodeId, f64)>, pub(crate) fonts: HashMap>, images: HashMap>, } @@ -78,11 +80,6 @@ pub(crate) fn norm(v: f32) -> NormalizedF32 { NormalizedF32::new(v).unwrap_or(NormalizedF32::ONE) } -fn rgb(c: Color) -> rgb::Color { - let [r, g, b, _] = c.sanitized().to_rgba8(); - rgb::Color::new(r, g, b) -} - /// The PDF blend mode for a non-normal blend (`None` = normal / pass through). pub(crate) fn blend(b: BlendMode) -> Option { use krilla::blend::BlendMode as K; @@ -133,8 +130,9 @@ fn handle_frame(g: &Gradient, w: f64, h: f64) -> Affine { Affine::new([t.0 - f.0, t.1 - f.1, k.0 - f.0, k.1 - f.1, f.0, f.1]) } -/// A paint PDF can express directly (solid, linear, radial) on a w×h box, with its fill opacity. -pub(crate) fn vector_paint(p: &Paint, w: f64, h: f64) -> Option<(krilla::paint::Paint, NormalizedF32)> { +/// A paint PDF can express directly (solid, linear, radial) on a w×h box, in `ink`, with its fill +/// opacity. +pub(crate) fn vector_paint(p: &Paint, w: f64, h: f64, ink: Ink) -> Option<(krilla::paint::Paint, NormalizedF32)> { if !p.visible || !above(p.opacity, 0.0) { return None; } @@ -142,7 +140,7 @@ pub(crate) fn vector_paint(p: &Paint, w: f64, h: f64) -> Option<(krilla::paint:: match &p.kind { PaintKind::Solid { color } => { let a = color.a * op; - (a > 0.0).then(|| (rgb(*color).into(), norm(a))) + (a > 0.0).then(|| (ink.color(*color).into(), norm(a))) } PaintKind::Gradient(g) if matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => { let frame = handle_frame(g, w.max(1e-6), h.max(1e-6)); @@ -152,7 +150,7 @@ pub(crate) fn vector_paint(p: &Paint, w: f64, h: f64) -> Option<(krilla::paint:: let mut stops: Vec = g .sorted_stops() .into_iter() - .map(|s| Stop { offset: norm(s.position), color: rgb(s.color).into(), opacity: norm(s.color.a * op) }) + .map(|s| Stop { offset: norm(s.position), color: ink.color(s.color), opacity: norm(s.color.a * op) }) .collect(); if stops.len() == 1 && let Some(only) = stops.first().cloned() @@ -217,11 +215,18 @@ impl<'a> Exporter<'a> { page_ids: Vec::new(), links: Vec::new(), masking: 0, + bleed_root: None, fonts: HashMap::new(), images: HashMap::new(), } } + /// The colour model of what's drawn now: CMYK when printing in it, but never in soft masks + /// (they aren't printed). + pub(crate) fn ink(&self) -> Ink { + if self.opts.print.cmyk && self.masking == 0 { Ink::Cmyk } else { Ink::Rgb } + } + /// Draw node `id`; `parent_xf` maps its parent's space to PDF page space. `as_mask` draws a /// mask layer itself (into a soft mask). pub(crate) fn node(&mut self, s: &mut Surface, id: NodeId, parent_xf: Affine, depth: usize, as_mask: bool) { @@ -286,7 +291,13 @@ impl<'a> Exporter<'a> { NodeKind::Page { .. } | NodeKind::Group => self.children(s, n, xf, depth), NodeKind::Text(_) => crate::text::draw_text(self, s, n, xf, inner), _ => { - let outline = figcraft_render::node_outline(self.doc, n); + // The page's frame reaches out to the bleed (fills and clip; paints keep their box). + let outline = match self.bleed_root { + Some((id, b)) if id == n.id && n.kind.is_frame_like() => { + Some((Rect::new(-b, -b, w + b, h + b).to_path(0.1), figcraft_render::FillRule::Winding)) + } + _ => figcraft_render::node_outline(self.doc, n), + }; let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. }); let closed = figcraft_doc::geometry::is_closed(n) || is_bool; if let Some((path, r)) = &outline @@ -410,7 +421,7 @@ impl<'a> Exporter<'a> { self.raster_paint(s, path, &kp, r, p, w, h, xf) } _ => { - if let Some((paint, opacity)) = vector_paint(p, w, h) { + if let Some((paint, opacity)) = vector_paint(p, w, h, self.ink()) { s.set_fill(Some(Fill { paint, opacity, rule: r })); s.draw_path(&kp); } @@ -424,7 +435,8 @@ impl<'a> Exporter<'a> { /// The PDF image for an image paint and its pixel size (cached per image + adjustments). fn image(&mut self, img: &ImagePaint) -> Option<(Image, u32, u32)> { - let key = format!("{}|{:?}", img.image, img.filters); + let ink = self.ink(); + let key = format!("{}|{:?}|{ink:?}", img.image, img.filters); if let Some(i) = self.images.get(&key) { return i.clone(); } @@ -432,8 +444,8 @@ impl<'a> Exporter<'a> { // Decoding through the renderer checks the image is readable and applies adjustments. let made = figcraft_render::images::get(&img.image, data, &img.filters, self.doc.color_profile).map(|pm| { let (pw, ph) = (pm.width(), pm.height()); - // Unadjusted images embed their original bytes (JPEG stays JPEG). - let original = data.filter(|_| img.filters.is_identity()).and_then(|d| { + // Unadjusted RGB images embed their original bytes (JPEG stays JPEG). + let original = data.filter(|_| img.filters.is_identity() && ink == Ink::Rgb).and_then(|d| { let bytes = krilla::Data::from(d.bytes.clone()); match sniff(&d.bytes) { Sniffed::Png => Image::from_png(bytes, true).ok(), @@ -443,7 +455,7 @@ impl<'a> Exporter<'a> { Sniffed::Other => None, } }); - let image = original.filter(|i| i.size() == (pw, ph)).unwrap_or_else(|| Image::from_rgba8(figcraft_render::to_rgba8(&pm), pw, ph)); + let image = original.filter(|i| i.size() == (pw, ph)).unwrap_or_else(|| ink.bitmap(figcraft_render::to_rgba8(&pm), pw, ph)); (image, pw, ph) }); self.images.insert(key, made.clone()); @@ -507,7 +519,7 @@ impl<'a> Exporter<'a> { let Some(rect) = tiny_skia::Rect::from_xywh(bb.x0 as f32, bb.y0 as f32, bb.width() as f32, bb.height() as f32) else { return }; pix.fill_rect(rect, &paint, tiny_skia::Transform::from_row(k as f32, 0.0, 0.0, k as f32, (-bb.x0 * k) as f32, (-bb.y0 * k) as f32), None); let Some(size) = Size::from_wh(pw as f32, ph as f32) else { return }; - let image = Image::from_rgba8(figcraft_render::to_rgba8(&pix), pw, ph); + let image = self.ink().bitmap(figcraft_render::to_rgba8(&pix), pw, ph); s.push_clip_path(kp, &r); s.push_transform(&to_xf(Affine::translate((bb.x0, bb.y0)) * Affine::scale(1.0 / k))); s.draw_image(image, size); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d014c..9c45d01 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -28,14 +28,17 @@ //! - layer blur, noise and texture: the whole node is rendered by `figcraft_render` and placed as //! one image. //! -//! Pages are sized in points, one point per pixel of the document. +//! Pages are sized in points, one point per pixel of the document. For print ([`PdfOptions::print`], +//! see [`print`]): a bleed around each page, crop and bleed marks, MediaBox / BleedBox / TrimBox, +//! and CMYK colour. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod draw; +pub mod print; mod raster; mod text; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{Document, NodeId, PrintSettings}; use figcraft_geom::{Affine, Rect}; /// Export settings. @@ -47,11 +50,13 @@ pub struct PdfOptions { pub raster_scale: f64, /// Document title (metadata). pub title: Option, + /// Bleed, printer's marks and CMYK colour. + pub print: PrintSettings, } impl Default for PdfOptions { fn default() -> Self { - Self { outline_text: false, raster_scale: 2.0, title: None } + Self { outline_text: false, raster_scale: 2.0, title: None, print: PrintSettings::default() } } } @@ -83,7 +88,8 @@ pub fn export_nodes_pdf(doc: &Document, ids: &[NodeId]) -> Result, PdfEr export_groups(doc, &[ids.to_vec()], &PdfOptions::default()) } -/// One page per group of ids. +/// One page per group of ids. For print, a page of one frame is trimmed to the frame's box (its +/// shadows and outside strokes go into the bleed). pub fn export_groups(doc: &Document, groups: &[Vec], opts: &PdfOptions) -> Result, PdfError> { let mut pages: Vec<(Vec, Rect)> = Vec::new(); for g in groups.iter().filter(|g| !g.is_empty()) { @@ -97,8 +103,15 @@ pub fn export_groups(doc: &Document, groups: &[Vec], opts: &PdfOptions) } } let first = g.first().copied().unwrap_or(NodeId(0)); - let b = bounds.ok_or(PdfError::NoBounds(first))?; - if b.width() > MAX_PAGE_SIDE || b.height() > MAX_PAGE_SIDE { + let frame = match g.as_slice() { + [one] if opts.print.extends_page() && doc.node(*one).is_some_and(|n| n.kind.is_frame_like()) => { + doc.world_bounds(*one).filter(|b| rect_finite(*b) && b.width() > 0.0 && b.height() > 0.0) + } + _ => None, + }; + let b = frame.or(bounds).ok_or(PdfError::NoBounds(first))?; + let grow = 2.0 * print::Sheet::new(b.width(), b.height(), &opts.print).margin; + if b.width() + grow > MAX_PAGE_SIDE || b.height() + grow > MAX_PAGE_SIDE { return Err(PdfError::TooLarge(b.width().round(), b.height().round())); } pages.push((g.clone(), b)); @@ -147,22 +160,40 @@ fn write(doc: &Document, pages: &[(Vec, Rect)], opts: &PdfOptions) -> Re } pdf.set_metadata(meta); let mut ex = draw::Exporter::new(doc, opts); + let print = &opts.print; for (ids, b) in pages { // Zero-area nodes (a line, an empty group) still get a (tiny) page. let (w, h) = (b.width().max(1.0), b.height().max(1.0)); - let size = krilla::geom::Size::from_wh(w as f32, h as f32).ok_or(PdfError::TooLarge(w, h))?; - let mut page = pdf.start_page_with(krilla::page::PageSettings::new(size)); + // The page's trim on a sheet grown by the bleed and the marks. + let sheet = print::Sheet::new(w, h, print); + let size = krilla::geom::Size::from_wh(sheet.width as f32, sheet.height as f32).ok_or(PdfError::TooLarge(sheet.width, sheet.height))?; + let mut settings = krilla::page::PageSettings::new(size); + if print.extends_page() { + let k = |r: Rect| krilla::geom::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + settings = settings.with_bleed_box(k(sheet.bleed_box())).with_trim_box(k(sheet.trim)); + } + let mut page = pdf.start_page_with(settings); let mut s = page.surface(); - ex.page_origin = Affine::translate((-b.x0, -b.y0)); - ex.page_rect = *b; + ex.page_origin = Affine::translate((sheet.margin - b.x0, sheet.margin - b.y0)); + ex.page_rect = b.inflate(sheet.bleed, sheet.bleed); ex.page_ids = ids.clone(); + ex.bleed_root = match ids.as_slice() { + [one] if sheet.bleed > 0.0 => Some((*one, sheet.bleed)), + _ => None, + }; + // Nothing is drawn past the bleed (the marks live there). + let clipped = print.extends_page() && sheet.push_bleed_clip(&mut s); for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); ex.node(&mut s, *id, ex.page_origin * parent_xf, 0, false); } + if clipped { + s.pop(); + } + sheet.draw_marks(&mut s, ex.ink()); s.finish(); for (r, url) in std::mem::take(&mut ex.links) { - if let Some(a) = link_annotation(r, url, w, h) { + if let Some(a) = link_annotation(r, url, sheet.width, sheet.height) { page.add_annotation(a); } } @@ -178,4 +209,6 @@ mod effect_order_tests; #[cfg(test)] mod export_tests; #[cfg(test)] +mod print_tests; +#[cfg(test)] mod tests; diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs new file mode 100644 index 0000000..64764d2 --- /dev/null +++ b/crates/pdf/src/print.rs @@ -0,0 +1,235 @@ +//! Print options: bleed, printer's marks, page boxes and CMYK colour. +//! +//! - **Bleed.** The page grows by the bleed on every side and shows what lies past the trim +//! there: a frame's fills reach out to the bleed edge (gradients and images keep their place; +//! they just show further), its children are clipped to the bleed instead of the frame, and +//! nothing is drawn past the bleed. +//! - **Marks.** Crop marks (at the trim's corners) and bleed marks (at the bleed's) are hairlines +//! [`MARK_LENGTH`] long, starting [`MARK_GAP`] outside the bleed, in registration colour (all +//! four inks in CMYK, black in RGB). The page grows to hold them. +//! - **Boxes.** MediaBox is the whole sheet, BleedBox the trim grown by the bleed, TrimBox the +//! frame (the finished page). +//! - **CMYK.** Colours are converted from sRGB with the naive, profile-free formula +//! `K = 1 − max(R, G, B)`, `C = (1 − R − K) / (1 − K)` (likewise M and Y), so the same file +//! always converts the same way; images and rasterised effects are converted pixel by pixel. +//! The colours are DeviceCMYK with no output intent: embedding one needs a CMYK ICC profile, +//! and none ships with FigCraft (third-party profiles aren't allowed in the repository). Print +//! shops should proof against their own profile. Soft masks stay greyscale/RGB (they're never +//! printed), and transparency groups (blend modes, soft masks) blend in RGB as the PDF writer +//! declares them. + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use figcraft_color::Color; +use figcraft_doc::PrintSettings; +use figcraft_geom::{BezPath, Rect, Shape}; +use krilla::color::{cmyk, rgb}; +use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use krilla::paint::{FillRule, LineCap, Stroke}; +use krilla::surface::Surface; + +use crate::draw::{norm, to_path}; + +/// Length of a printer's mark, in points. +pub const MARK_LENGTH: f64 = 18.0; +/// Space between the bleed and the marks, in points. +pub const MARK_GAP: f64 = 3.0; +/// Width of a printer's mark, in points. +pub const MARK_WIDTH: f32 = 0.25; + +/// sRGB → CMYK (all `0..=1`), the naive way: `K = 1 − max(R, G, B)`, `C = (1 − R − K) / (1 − K)`. +pub fn cmyk_of(r: f32, g: f32, b: f32) -> [f32; 4] { + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + let (r, g, b) = (unit(r), unit(g), unit(b)); + let k = 1.0 - r.max(g).max(b); + if k >= 1.0 - 1e-6 { + return [0.0, 0.0, 0.0, 1.0]; + } + let ink = |v: f32| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0); + [ink(r), ink(g), ink(b), k] +} + +fn byte(v: f32) -> u8 { + (v * 255.0).round().clamp(0.0, 255.0) as u8 +} + +/// The colour model colours are written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Ink { + Rgb, + Cmyk, +} + +impl Ink { + pub(crate) fn color(self, c: Color) -> krilla::color::Color { + let [r, g, b, _] = c.sanitized().to_rgba8(); + match self { + Ink::Rgb => rgb::Color::new(r, g, b).into(), + Ink::Cmyk => { + let f = |v: u8| f32::from(v) / 255.0; + let [c, m, y, k] = cmyk_of(f(r), f(g), f(b)); + cmyk::Color::new(byte(c), byte(m), byte(y), byte(k)).into() + } + } + } + + /// The colour that prints on every plate (marks): all four inks, or black. + fn registration(self) -> krilla::color::Color { + match self { + Ink::Rgb => rgb::Color::black().into(), + Ink::Cmyk => cmyk::Color::new(255, 255, 255, 255).into(), + } + } + + /// A `w × h` image from straight (not premultiplied) RGBA pixels, in this colour model. + pub(crate) fn bitmap(self, rgba: Vec, w: u32, h: u32) -> Image { + if self == Ink::Cmyk + && let Some(img) = CmykImage::new(&rgba, w, h).and_then(|c| Image::from_custom(c, true).ok()) + { + return img; + } + Image::from_rgba8(rgba, w, h) + } +} + +/// CMYK pixels (and alpha) converted from RGBA. +#[derive(Clone)] +struct CmykImage { + color: Arc>, + alpha: Option>>, + size: (u32, u32), + key: u64, +} + +impl CmykImage { + fn new(rgba: &[u8], w: u32, h: u32) -> Option { + let n = (w as usize).checked_mul(h as usize)?; + if rgba.len() != n.checked_mul(4)? || n == 0 { + return None; + } + let mut color = Vec::with_capacity(n * 4); + let mut alpha = Vec::with_capacity(n); + for px in rgba.chunks_exact(4) { + let f = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0; + color.extend(cmyk_of(f(0), f(1), f(2)).map(byte)); + alpha.push(px.get(3).copied().unwrap_or(255)); + } + let mut hs = std::collections::hash_map::DefaultHasher::new(); + rgba.hash(&mut hs); + let opaque = alpha.iter().all(|a| *a == 255); + Some(CmykImage { color: Arc::new(color), alpha: (!opaque).then(|| Arc::new(alpha)), size: (w, h), key: hs.finish() }) + } +} + +impl Hash for CmykImage { + fn hash(&self, state: &mut H) { + self.key.hash(state); + self.size.hash(state); + } +} + +impl CustomImage for CmykImage { + fn color_channel(&self) -> &[u8] { + self.color.as_slice() + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref().map(Vec::as_slice) + } + fn bits_per_component(&self) -> BitsPerComponent { + BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> ImageColorspace { + ImageColorspace::Cmyk + } +} + +/// A printed page: its trim (the frame, `w × h` points) on a sheet grown by the bleed and the +/// marks. Rectangles are in sheet coordinates (points, y down from the sheet's top-left). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Sheet { + pub width: f64, + pub height: f64, + pub trim: Rect, + pub bleed: f64, + /// Space around the trim (bleed and marks). + pub margin: f64, + pub crop_marks: bool, + pub bleed_marks: bool, +} + +impl Sheet { + pub(crate) fn new(w: f64, h: f64, p: &PrintSettings) -> Self { + let bleed = p.bleed(); + let marks = p.marks(); + let margin = if marks { bleed + 2.0 * MARK_GAP + MARK_LENGTH } else { bleed }; + Sheet { + width: w + 2.0 * margin, + height: h + 2.0 * margin, + trim: Rect::new(margin, margin, margin + w, margin + h), + bleed, + margin, + crop_marks: p.crop_marks, + bleed_marks: p.bleed_marks && bleed > 0.0, + } + } + + pub(crate) fn bleed_box(&self) -> Rect { + self.trim.inflate(self.bleed, self.bleed) + } + + /// The marks: at each corner of `r`, a horizontal and a vertical line outside the bleed. + fn mark_lines(&self, r: Rect) -> BezPath { + let (near, far) = (self.bleed + MARK_GAP, self.bleed + MARK_GAP + MARK_LENGTH); + let t = self.trim; + let mut p = BezPath::new(); + let mut line = |a: (f64, f64), b: (f64, f64)| { + p.move_to(a); + p.line_to(b); + }; + for (x, sx) in [(r.x0, -1.0), (r.x1, 1.0)] { + for (y, sy) in [(r.y0, -1.0), (r.y1, 1.0)] { + // Out from the trim's corner, along the line of `r`'s edge. + let (tx, ty) = (if sx < 0.0 { t.x0 } else { t.x1 }, if sy < 0.0 { t.y0 } else { t.y1 }); + line((tx + sx * near, y), (tx + sx * far, y)); + line((x, ty + sy * near), (x, ty + sy * far)); + } + } + p + } + + /// Draw the printer's marks (the surface in sheet coordinates). + pub(crate) fn draw_marks(&self, s: &mut Surface, ink: Ink) { + let mut lines = BezPath::new(); + if self.crop_marks { + lines.extend(self.mark_lines(self.trim)); + } + if self.bleed_marks { + lines.extend(self.mark_lines(self.bleed_box())); + } + let Some(path) = to_path(&lines) else { return }; + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: ink.registration().into(), + width: MARK_WIDTH, + line_cap: LineCap::Butt, + opacity: norm(1.0), + ..Stroke::default() + })); + s.draw_path(&path); + s.set_stroke(None); + } + + /// Clip what follows to the bleed (pop it after). + pub(crate) fn push_bleed_clip(&self, s: &mut Surface) -> bool { + let Some(p) = to_path(&self.bleed_box().to_path(0.1)) else { return false }; + s.push_clip_path(&p, &FillRule::NonZero); + true + } +} diff --git a/crates/pdf/src/print_tests.rs b/crates/pdf/src/print_tests.rs new file mode 100644 index 0000000..a359c6d --- /dev/null +++ b/crates/pdf/src/print_tests.rs @@ -0,0 +1,228 @@ +//! Print options: page boxes, the sheet grown by the bleed and marks, the marks themselves, what +//! shows in the bleed, CMYK colour everywhere colour is written, and hostile settings. + +use super::*; +use crate::print::{MARK_GAP, MARK_LENGTH, cmyk_of}; +use crate::tests::{inflated, sample_doc}; +use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint}; +use figcraft_doc::{Effect, EffectKind, NodeKind}; + +fn text(pdf: &[u8]) -> String { + String::from_utf8_lossy(pdf).into_owned() +} + +/// A page box's four numbers (`None` without one). +fn pdf_box(pdf: &[u8], name: &str) -> Option<[f64; 4]> { + let s = text(pdf); + let rest = s.get(s.find(&format!("/{name}["))? + name.len() + 2..)?; + let nums: Vec = rest.get(..rest.find(']')?)?.split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums.try_into().ok() +} + +/// A 200 × 100 red frame. +fn frame() -> (Document, NodeId) { + let mut d = Document::new("print"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Poster", 0.0, 0.0, 200.0, 100.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + (d, f) +} + +fn export(d: &Document, f: NodeId, print: PrintSettings) -> Vec { + export_pdf(d, &[f], &PdfOptions { print, ..Default::default() }).unwrap() +} + +#[test] +fn page_boxes_frame_the_trim_bleed_and_sheet() { + let (d, f) = frame(); + // Plain: the page is the frame, as before. + let plain = export(&d, f, PrintSettings::default()); + assert_eq!(pdf_box(&plain, "MediaBox"), Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!((pdf_box(&plain, "BleedBox"), pdf_box(&plain, "TrimBox")), (None, None)); + // A bleed grows the sheet by it. + let bled = export(&d, f, PrintSettings { bleed: 9.0, ..Default::default() }); + assert_eq!(pdf_box(&bled, "MediaBox"), Some([0.0, 0.0, 218.0, 118.0])); + assert_eq!(pdf_box(&bled, "BleedBox"), Some([0.0, 0.0, 218.0, 118.0])); + assert_eq!(pdf_box(&bled, "TrimBox"), Some([9.0, 9.0, 209.0, 109.0])); + // Marks need room beyond the bleed. + let m = 9.0 + 2.0 * MARK_GAP + MARK_LENGTH; + let marked = export(&d, f, PrintSettings { bleed: 9.0, crop_marks: true, ..Default::default() }); + assert_eq!(pdf_box(&marked, "MediaBox"), Some([0.0, 0.0, 200.0 + 2.0 * m, 100.0 + 2.0 * m])); + assert_eq!(pdf_box(&marked, "BleedBox"), Some([m - 9.0, m - 9.0, m + 209.0, m + 109.0])); + assert_eq!(pdf_box(&marked, "TrimBox"), Some([m, m, m + 200.0, m + 100.0])); + // Crop marks without a bleed: the trim is the bleed. + let crop = export(&d, f, PrintSettings { crop_marks: true, ..Default::default() }); + assert_eq!(pdf_box(&crop, "TrimBox"), pdf_box(&crop, "BleedBox")); +} + +#[test] +fn a_frame_trims_to_its_box_not_its_shadow() { + let (mut d, f) = frame(); + d.get_mut(f).unwrap().effects = vec![Effect { kind: EffectKind::DropShadow(Default::default()), visible: true }]; + // Plain export takes the shadow in; for print the shadow goes into the bleed. + let plain = export(&d, f, PrintSettings::default()); + assert!(pdf_box(&plain, "MediaBox").is_some_and(|b| b[3] > 100.0)); + let print = export(&d, f, PrintSettings { bleed: 9.0, ..Default::default() }); + assert_eq!(pdf_box(&print, "TrimBox"), Some([9.0, 9.0, 209.0, 109.0])); +} + +#[test] +fn crop_and_bleed_marks_are_hairlines_outside_the_bleed() { + let (d, f) = frame(); + let marks = |p: PrintSettings| { + let s = inflated(&export(&d, f, p)); + let block = s.get(s.find(" w ").unwrap_or(0)..).unwrap_or("").to_string(); + (s.contains("0.25 w"), block.matches(" m ").count(), s) + }; + let (none, ..) = marks(PrintSettings { bleed: 9.0, ..Default::default() }); + assert!(!none, "no marks asked for"); + // Four corners × two lines. + let (hair, lines, s) = marks(PrintSettings { bleed: 9.0, crop_marks: true, ..Default::default() }); + assert!(hair && lines == 8, "{s}"); + // The top-left crop marks: along the trim's top and left edges, from just outside the bleed. + let m = 9.0 + 2.0 * MARK_GAP + MARK_LENGTH; + let (near, far) = (m - 9.0 - MARK_GAP, m - 9.0 - MARK_GAP - MARK_LENGTH); + assert!(s.contains(&format!("{near} {m} m {far} {m} l")) && s.contains(&format!("{m} {near} m {m} {far} l")), "{s}"); + // In registration black (RGB) or all four inks (CMYK). + assert!(s.contains("0 0 0 RG"), "{s}"); + let (_, lines, s) = marks(PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true }); + assert!(lines == 16 && s.contains("1 1 1 1 K"), "{s}"); + // Bleed marks: along the bleed's edges. + let edge = m - 9.0; + assert!(s.contains(&format!("{near} {edge} m {far} {edge} l")), "{s}"); + // Bleed marks without a bleed would sit on the crop marks: none. + let (hair, ..) = marks(PrintSettings { bleed_marks: true, ..Default::default() }); + assert!(!hair); +} + +#[test] +fn the_bleed_shows_what_lies_past_the_frame() { + let mut d = Document::new("bleed"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("0000FF").unwrap())]; + // A band running past both sides of the frame. + let band = d.create(f, NodeKind::Rectangle, "Band", -20.0, 40.0, 140.0, 20.0).unwrap(); + d.get_mut(band).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + let s = inflated(&export(&d, f, PrintSettings::default())); + // Without a bleed: the frame's fill and clip are its box. + assert!(s.contains("0 0 m 100 0 l 100 100 l 0 100 l") && s.contains("0 100 m 100 100 l 100 0 l 0 0 l"), "{s}"); + let s = inflated(&export(&d, f, PrintSettings { bleed: 10.0, ..Default::default() })); + // With one: the fill reaches out 10 on every side (in the frame's space) ... + assert!(s.contains("0 0 1 rg -10 -10 m 110 -10 l 110 110 l -10 110 l"), "{s}"); + // ... and the band is clipped to the bleed (the 120-point sheet), not the frame. + assert_eq!(s.matches("0 120 m 120 120 l 120 0 l 0 0 l").count(), 2, "{s}"); + assert!(!s.contains("10 110 m 110 110 l"), "no clip at the trim: {s}"); + assert!(s.contains("1 0 0 rg 0 0 m 140 0 l"), "the band is drawn whole: {s}"); +} + +#[test] +fn layers_that_arent_frames_get_a_bleed_around_their_bounds() { + let mut d = Document::new("nodes"); + let p = d.first_page().unwrap(); + let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 50.0, 50.0).unwrap(); + let pdf = export_groups(&d, &[vec![r]], &PdfOptions { print: PrintSettings { bleed: 5.0, ..Default::default() }, ..Default::default() }).unwrap(); + assert_eq!(pdf_box(&pdf, "TrimBox"), Some([5.0, 5.0, 55.0, 55.0])); + assert_eq!(pdf_box(&pdf, "MediaBox"), Some([0.0, 0.0, 60.0, 60.0])); +} + +#[test] +fn cmyk_conversion_is_the_documented_formula() { + let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6); + assert!(near(cmyk_of(1.0, 0.0, 0.0), [0.0, 1.0, 1.0, 0.0])); + assert!(near(cmyk_of(1.0, 1.0, 1.0), [0.0, 0.0, 0.0, 0.0])); + assert!(near(cmyk_of(0.0, 0.0, 0.0), [0.0, 0.0, 0.0, 1.0])); + assert!(near(cmyk_of(0.5, 0.5, 0.5), [0.0, 0.0, 0.0, 0.5])); + assert!(near(cmyk_of(0.2, 0.4, 0.8), [0.75, 0.5, 0.0, 0.2])); + // Out of range and not-a-number channels count as their nearest valid value (NaN as 0). + assert!(near(cmyk_of(f32::NAN, 2.0, -1.0), cmyk_of(0.0, 1.0, 0.0))); +} + +#[test] +fn cmyk_writes_every_colour_in_cmyk() { + let (mut d, f) = frame(); + // A gradient, an image, a shadow (rasterised) and a stroke besides the solid fill. + let r = d.create(f, NodeKind::Rectangle, "G", 10.0, 10.0, 80.0, 40.0).unwrap(); + { + let n = d.get_mut(r).unwrap(); + let stops = vec![ColorStop { position: 0.0, color: Color::from_hex("00FF00").unwrap() }, ColorStop { position: 1.0, color: Color::WHITE }]; + n.fills = vec![Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from: (0.0, 0.5), to: (1.0, 0.5), width: (0.0, 1.0) })]; + n.strokes = vec![Paint::solid(Color::from_hex("336699").unwrap())]; + n.stroke.weight = 2.0; + n.effects = vec![Effect { kind: EffectKind::DropShadow(Default::default()), visible: true }]; + } + let rgb = export(&d, f, PrintSettings::default()); + let cmyk = export(&d, f, PrintSettings { cmyk: true, ..Default::default() }); + let (rs, cs) = (inflated(&rgb), inflated(&cmyk)); + assert!(rs.contains("1 0 0 rg") && !rs.contains(" k "), "{rs}"); + // Red is magenta and yellow; no RGB colour operators are left. + assert!(cs.contains("0 1 1 0 k"), "{cs}"); + assert!(!cs.contains(" rg") && !cs.contains(" RG"), "{cs}"); + // The gradient's shading and the shadow image are CMYK too. + let t = text(&cmyk); + assert!(t.contains("/DeviceCMYK"), "{t}"); + assert!(!t.contains("/ColorSpace/DeviceRGB"), "{t}"); + // The same file every time. + assert_eq!(cmyk, export(&d, f, PrintSettings { cmyk: true, ..Default::default() })); +} + +#[test] +fn cmyk_images_and_the_sample_document() { + let (d, frames) = sample_doc(); + let pdf = export_pdf( + &d, + &frames, + &PdfOptions { print: PrintSettings { cmyk: true, bleed: 8.0, crop_marks: true, ..Default::default() }, ..Default::default() }, + ) + .unwrap(); + let t = text(&pdf); + assert_eq!(t.matches("/TrimBox[").count(), frames.len()); + // Images (the image fill, rasterised effects) are CMYK, with grey soft masks for alpha. + assert!(t.contains("/Subtype/Image") && !t.contains("/ColorSpace/DeviceRGB"), "{}", t.chars().take(4000).collect::()); + assert!(inflated(&pdf).contains(" k "), "CMYK fills"); +} + +#[test] +fn soft_masks_stay_rgb_while_what_they_mask_prints_in_cmyk() { + let (mut d, f) = frame(); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + let m = d.create(g, NodeKind::Rectangle, "Mask", 0.0, 0.0, 100.0, 100.0).unwrap(); + { + let n = d.get_mut(m).unwrap(); + n.is_mask = true; + n.mask_type = figcraft_doc::MaskType::Luminance; + n.fills = vec![Paint::solid(Color::from_hex("808080").unwrap())]; + } + let c = d.create(g, NodeKind::Rectangle, "Content", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(c).unwrap().fills = vec![Paint::solid(Color::from_hex("0000FF").unwrap())]; + let s = inflated(&export(&d, f, PrintSettings { cmyk: true, ..Default::default() })); + // The mask's grey (never printed) and the content's blue as cyan and magenta. + assert!(s.contains("0.5019608 0.5019608 0.5019608 rg") && s.contains("1 1 0 0 k"), "{s}"); +} + +#[test] +fn hostile_print_settings_still_export() { + let (d, f) = frame(); + for print in [ + PrintSettings { bleed: f64::NAN, crop_marks: true, bleed_marks: true, cmyk: true }, + PrintSettings { bleed: f64::INFINITY, bleed_marks: true, ..Default::default() }, + PrintSettings { bleed: -50.0, crop_marks: true, ..Default::default() }, + PrintSettings { bleed: 1e12, crop_marks: true, ..Default::default() }, + ] { + let pdf = export(&d, f, print); + let media = pdf_box(&pdf, "MediaBox").unwrap(); + assert!( + media.iter().all(|v| v.is_finite()) && media[2] <= 200.0 + 2.0 * (PrintSettings::MAX_BLEED + 2.0 * MARK_GAP + MARK_LENGTH) + 1e-6, + "{print:?}: {media:?}" + ); + } + // A frame too big for a page is refused, with or without a bleed. + let mut d = Document::new("big"); + let p = d.first_page().unwrap(); + let big = d.create(p, NodeKind::Frame, "Big", 0.0, 0.0, MAX_PAGE_SIDE - 10.0, 10.0).unwrap(); + assert!(export_pdf(&d, &[big], &PdfOptions::default()).is_ok()); + assert!(matches!( + export_pdf(&d, &[big], &PdfOptions { print: PrintSettings { bleed: 20.0, ..Default::default() }, ..Default::default() }), + Err(PdfError::TooLarge(..)) + )); +} diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index d385cd8..3336d62 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -16,6 +16,8 @@ use figcraft_geom::{Affine, Rect}; use figcraft_render::{Alone, Pixmap, View, backdrop, shadow}; use krilla::geom::Size; use krilla::image::Image; + +use crate::print::Ink; use krilla::surface::Surface; use crate::draw::{Exporter, MAX_RASTER_SIDE, affine_ok, blend, norm, to_xf}; @@ -33,8 +35,10 @@ pub(crate) struct Shadows { pub inner: Vec, } -fn raster(p: &Pixmap, rect: Rect) -> Raster { - Raster { image: Image::from_rgba8(figcraft_render::to_rgba8(p), p.width(), p.height()), rect, px: (p.width(), p.height()) } +impl Raster { + fn new(p: &Pixmap, rect: Rect, ink: Ink) -> Raster { + Raster { image: ink.bitmap(figcraft_render::to_rgba8(p), p.width(), p.height()), rect, px: (p.width(), p.height()) } + } } impl Exporter<'_> { @@ -103,7 +107,7 @@ impl Exporter<'_> { // Its backdrop effects are placed separately (see `backdrop`). let Some(pix) = figcraft_render::render_alone(doc, id, &view, Alone::WithEffects) else { return }; // The renderer applied opacity; the blend mode applies against the PDF backdrop. - self.place_raster(s, &raster(&pix, Self::view_rect(&view)), 1.0, n.blend); + self.place_raster(s, &Raster::new(&pix, Self::view_rect(&view), self.ink()), 1.0, n.blend); } /// Node `id`'s background blurs and glass as an image over its shape, drawn by the renderer @@ -111,7 +115,7 @@ impl Exporter<'_> { /// ancestors' own fills included), the way the canvas and PNG export draw them. pub(crate) fn backdrop(&mut self, id: NodeId) -> Option { let (pix, view) = self.backdrop_pixels(id)?; - Some(raster(&pix, Self::view_rect(&view))) + Some(Raster::new(&pix, Self::view_rect(&view), self.ink())) } /// [`Exporter::backdrop`]'s pixels and the page area they cover. @@ -158,12 +162,12 @@ impl Exporter<'_> { match &e.kind { EffectKind::DropShadow(sh) => { if let Some(p) = shadow::drop_shadow(&caster, sh) { - out.drop.push((raster(&p, rect), sh.blend)); + out.drop.push((Raster::new(&p, rect, self.ink()), sh.blend)); } } EffectKind::InnerShadow(sh) => { if let Some(p) = shape.as_ref().and_then(|o| shadow::inner_shadow(o, w, h, xf, view.scale, sh)) { - out.inner.push(raster(&p, rect)); + out.inner.push(Raster::new(&p, rect, self.ink())); } } _ => {} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f101ce9..2a8f08c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -52,7 +52,7 @@ pub(crate) fn has_inter() -> bool { } /// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text. -fn sample_doc() -> (Document, Vec) { +pub(crate) fn sample_doc() -> (Document, Vec) { let mut d = Document::new("sample"); let p = d.first_page().unwrap(); let f1 = d.create(p, NodeKind::Frame, "Card", 0.0, 0.0, 320.0, 240.0).unwrap(); diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 57d8cfd..7590e3a 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -173,7 +173,7 @@ fn glyph_runs( let continues = run.as_ref().is_some_and(|r| r.face == g.face.id && (r.size - g.size).abs() < 1e-9); if !continues { if let Some(r) = run.take() { - write_run(s, r, p, w, h, gxf); + write_run(s, r, p, w, h, gxf, ex.ink()); } run = Some(Run { face: g.face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None }); } @@ -182,7 +182,7 @@ fn glyph_runs( } } if let Some(r) = run.take() { - write_run(s, r, p, w, h, gxf); + write_run(s, r, p, w, h, gxf, ex.ink()); } } rest @@ -203,11 +203,12 @@ fn push_glyph(r: &mut Run, g: &Glyph, chars: &[char]) { r.glyphs.push((g.gid, g.x, g.y, g.advance, range)); } -fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine) { +#[allow(clippy::too_many_arguments)] +fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine, ink: crate::print::Ink) { if r.glyphs.is_empty() { return; } - let Some((paint, opacity)) = vector_paint(p, w, h) else { return }; + let Some((paint, opacity)) = vector_paint(p, w, h, ink) else { return }; let size = r.size; let glyphs: Vec = r .glyphs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ba7c083..283fe14 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -278,7 +278,8 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { // With nothing selected, every layer on the page that has export settings. let with_settings = if sel.is_empty() { exportable_layers(&doc, page) } else { sel }; let jobs = export_jobs(&doc, &with_settings); - let preset = |size: f64, format| ExportSetting { size: ExportSize::Scale { value: size }, suffix: String::new(), format }; + let print = app.ui.pdf_print; + let preset = |size: f64, format| ExportSetting { size: ExportSize::Scale { value: size }, format, print, ..Default::default() }; let presets = [ preset(1.0, ExportFormat::Png), preset(2.0, ExportFormat::Png), @@ -327,6 +328,12 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { choice = Some(ExportChoice::Once(p.clone())); } } + // The PDF preset's print options. + ui.add_space(6.0); + caption(ui, "PDF print options"); + if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) { + app.ui.pdf_print = next; + } }); let close = choice.is_some() || !open; match choice { diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index e390880..1ef9d2b 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -1,13 +1,14 @@ //! Exporting: the Design panel's Export section (a row per export setting with size, suffix and -//! format, a preview, and "Export N layers"), the page's Export button, and the export actions -//! the Export dialog shares. Every change runs an `export.*` or `file.export*` command. +//! format, PDF print settings, a preview, and "Export N layers"), the page's Export button, and the +//! export actions and print options the Export dialog shares. Every change runs an `export.*` or +//! `file.export*` command. use std::collections::BTreeSet; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use figcraft_engine::cmd::file::export_jobs; -use serde_json::json; +use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; @@ -58,7 +59,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } } -/// One export setting: size (typed or picked), suffix, format and remove. +/// One export setting: size (typed or picked), suffix, format and remove; a PDF's print settings +/// under it. fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) { row(ui, 32.0, |ui| { let (size_w, format_w, gap) = (56.0, 60.0, ui.spacing().item_spacing.x); @@ -88,6 +90,99 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo app.run("export.remove", json!({"ids": ids, "index": i})); } }); + if e.format == ExportFormat::Pdf { + print_row(app, ui, ids, i, &e.print); + } +} + +/// A PDF setting's print settings: a summary row that opens them. +fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) { + let t = Tokens::get(ui.ctx()); + row(ui, 26.0, |ui| { + let open_id = Id::new(("export_print", i)); + let open = egui::Popup::is_id_open(ui.ctx(), open_id); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), FIELD_H), Sense::click()); + if resp.hovered() || open { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + let label = ui.painter().layout(print_summary(p), regular(11.0), t.text_secondary, r.width() - 32.0); + ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - label.size().y / 2.0), label, t.text_secondary); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(14.0, 14.0)), Icon::Dots, t.icon_secondary); + egui::Popup::from_toggle_button_response(&resp.on_hover_text("PDF print settings")) + .id(open_id) + .width(216.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + if let Some(next) = print_options(ui, Id::new(("export_print_fields", i)), p) { + let mut params = print_params(&next); + params["ids"] = json!(ids); + params["index"] = json!(i); + app.run("export.update", params); + } + }); + }); +} + +/// "3 mm bleed, crop marks, CMYK" (or "RGB, no bleed"). +fn print_summary(p: &PrintSettings) -> String { + let mut parts = Vec::new(); + if p.bleed() > 0.0 { + parts.push(format!("{} bleed", PrintSettings::length_label(p.bleed()))); + } + if p.crop_marks { + parts.push("crop marks".to_string()); + } + if p.bleed_marks && p.bleed() > 0.0 { + parts.push("bleed marks".to_string()); + } + parts.push(if p.cmyk { "CMYK" } else { "RGB" }.to_string()); + if p.bleed() <= 0.0 && !p.marks() { + parts.push("no bleed".to_string()); + } + let s = parts.join(", "); + let mut c = s.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or(s) +} + +/// PDF print options: bleed (typed with a unit: "3 mm", "0.125 in", "9 pt"; anything else keeps +/// the bleed), crop and bleed marks, CMYK colour. Returns the options when one changed. +pub(crate) fn print_options(ui: &mut Ui, id: Id, p: &PrintSettings) -> Option { + let t = Tokens::get(ui.ctx()); + let mut next = *p; + ui.horizontal(|ui| { + widgets::label(ui, "Bleed", t.text_secondary); + let w = (ui.available_width() - 4.0).max(48.0); + if let Some(b) = widgets::text_field(ui, id.with("bleed"), &PrintSettings::length_label(p.bleed()), w, "0 mm") + .and_then(|v| PrintSettings::parse_length(&v)) + .filter(|b| *b <= PrintSettings::MAX_BLEED) + { + next.bleed = b; + } + }); + for (on, label) in [(&mut next.crop_marks, "Crop marks"), (&mut next.bleed_marks, "Bleed marks"), (&mut next.cmyk, "CMYK colour")] { + if widgets::checkbox(ui, *on, label) { + *on = !*on; + } + } + if p.bleed_marks && p.bleed() <= 0.0 { + widgets::label(ui, "Bleed marks need a bleed", t.text_secondary); + } + (next != *p).then_some(next) +} + +/// Print options as command params (`bleed`, `cropMarks`, `bleedMarks`, `cmyk`). +fn print_params(p: &PrintSettings) -> Value { + json!({"bleed": p.bleed(), "cropMarks": p.crop_marks, "bleedMarks": p.bleed_marks, "cmyk": p.cmyk}) +} + +/// `params` with the setting's print options added (PDF exports). +fn with_print(mut params: Value, setting: &ExportSetting) -> Value { + if setting.format == ExportFormat::Pdf + && let (Value::Object(m), Value::Object(p)) = (&mut params, print_params(&setting.print)) + { + m.extend(p); + } + params } /// The rendered preview, cached per layer and document revision. @@ -196,7 +291,7 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let Some(p) = app.services.pick_save("Export", file, &[ext]) else { return }; let path = p.to_string_lossy().to_string(); - if app.run("file.export", json!({"ids": ids, "format": ext, "size": setting.size, "path": path})).is_some() { + if app.run("file.export", with_print(json!({"ids": ids, "format": ext, "size": setting.size, "path": path}), setting)).is_some() { app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15bec01..ac91599 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -205,6 +205,8 @@ pub struct UiState { pub quit: bool, /// The Export dialog is open. pub export_preview: bool, + /// The Export dialog's PDF print options (bleed, marks, CMYK). + pub pdf_print: figcraft_doc::PrintSettings, /// The Color profile dialog is open, with this profile chosen. pub color_profile: Option, /// The open style picker. @@ -281,6 +283,7 @@ impl Default for UiState { render_ms: 0.0, quit: false, export_preview: false, + pdf_print: figcraft_doc::PrintSettings::default(), color_profile: None, style_picker: None, style_grid: false, diff --git a/docs/cli.md b/docs/cli.md index 1478ddb..d043cbb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,7 +26,7 @@ page 1 "Screens": 380 nodes, 4 top-level … ``` -### `export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]` +### `export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]` - `` is a directory: one file per top-level frame of the page (default the first page), named after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. @@ -38,11 +38,19 @@ page 1 "Screens": 380 nodes, 4 top-level order, each the frame's size in points (a page without frames goes on one page). Shapes, text and gradients stay vector, text is real (selectable, with subset fonts) and its links clickable; shadows and blurs are images. +- PDF print options: `--bleed` (points, or a length like `3mm` or `0.125in`, up to 2 in) grows each + page by the bleed, which shows what lies past the frame's edge (its fills reach out to it, its + children are clipped there instead); `--crop-marks` and `--bleed-marks` draw hairline marks + outside the bleed; the page boxes are set (MediaBox the sheet, BleedBox, TrimBox the frame). + `--cmyk` writes CMYK colour, images included, converted from sRGB with the naive formula + `K = 1 − max(R, G, B)`, `C = (1 − R − K) / (1 − K)` (M and Y alike): DeviceCMYK without an + output intent (no CMYK profile ships with FigCraft), so proof against your printer's profile. ```sh figcraft-cli export app.figcraft -o exports/ --scale 2 figcraft-cli export app.figcraft -o hero.svg --node "Hero" figcraft-cli export app.figcraft -o screens.pdf --page "Screens" +figcraft-cli export app.figcraft -o poster.pdf --node "Poster" --bleed 3mm --crop-marks --cmyk ``` ### `convert ` diff --git a/docs/mcp.md b/docs/mcp.md index b233408..c4b1430 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -133,7 +133,7 @@ Or in a project's `.mcp.json`: | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | | `get_node {id, depth}` | One layer as JSON. | | `select {ids}` | Replace the selection (`[]` deselects). | -| `export {ids, format, scale \| size, path?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `.`). | +| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `.`). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | @@ -501,6 +501,18 @@ noise and texture over the result. the layer: on the page in PDF, among the exported layers in SVG), layer blur, noise and texture in PDF; noise grains (an image over the vector layer), texture and progressive layer blur (the layer whole) in SVG, where uniform layer blur and shadows stay SVG filters. +- **PDF for print:** a bleed grows each page by that much on every side and shows what lies past + the frame there (the frame's fills reach out to the bleed edge with gradients and images in + place, its children are clipped to the bleed instead of the frame, nothing is drawn past it); + a page of one frame is trimmed to the frame's box (its shadows go into the bleed). Crop marks + (at the trim's corners) and bleed marks (at the bleed's) are 0.25 pt hairlines 18 pt long, + starting 3 pt outside the bleed, in registration colour. MediaBox is the sheet, BleedBox the + trim grown by the bleed, TrimBox the frame. CMYK converts every colour (fills, strokes, text, + gradient stops, images and rasterised effects) from sRGB with `K = 1 − max(R, G, B)`, + `C = (1 − R − K) / (1 − K)` (M and Y alike), deterministic and profile-free: the file is + DeviceCMYK without an output intent, because embedding one needs a CMYK ICC profile and none + ships with FigCraft. Soft masks stay RGB (they aren't printed) and transparency groups blend in + RGB, as the PDF writer declares them. ### Paint, effect and layout grid lists