M6: PDF print options (bleed, marks, CMYK) (#92)

PDF export gains print options, saved on PDF export settings and taken
by file.export, file.exportLayers, file.exportFramesPdf, export.add /
export.update, figcraft-cli export and the MCP export tool:

- a bleed (points or a length like 3mm / 0.125in, up to 2 in) that grows
  each page and shows what lies past the frame: its fills reach out to
  the bleed edge and its children are clipped there instead; a print
  page of one frame is trimmed to the frame's box;
- crop and bleed marks: 0.25 pt hairlines outside the bleed in
  registration colour;
- MediaBox / BleedBox / TrimBox;
- CMYK colour: fills, strokes, text, gradient stops, images and
  rasterised effects converted from sRGB with the naive, deterministic
  K = 1 - max(R, G, B) formula; DeviceCMYK without an output intent (no
  CMYK ICC profile can ship in the repository), soft masks stay RGB.

The Design panel shows a PDF row's print settings as a summary line that
opens them; the Export dialog has print options for its PDF preset.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 20:50:29 +07:00 committed by GitHub
parent ebe403c49a
commit fe550fc14a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1067 additions and 83 deletions

View File

@ -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 |

View File

@ -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<figcraft_doc::PrintSettings> {
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<f64> {
match self.values.get("scale") {
None => Ok(1.0),

View File

@ -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<Vec<u8>> {
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
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<ExitCode> {
/// What `export` exports and how.
pub struct Target {
pub format: Option<String>,
pub scale: f64,
pub page: Option<String>,
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
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)?;

View File

@ -17,6 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
@ -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 <out> directory, or one file when <out>
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<ExitCode> {
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 <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".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, &[], &[])?;

View File

@ -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);
}

View File

@ -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;

View File

@ -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::<ExportSetting>(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());
}

View File

@ -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<f64> {
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(),
}

View File

@ -259,7 +259,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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";

View File

@ -31,14 +31,14 @@ pub fn specs() -> Vec<CommandSpec> {
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 `<layer><suffix>.<format>` (\"@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 `<layer><suffix>.<format>` (\"@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<CommandSpec> {
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<Value> {
/// Encode `ids` (or the page) in `format` at `scale`.
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<Vec<NodeId>> = 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<Value> {
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<PrintSettings> {
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<Option<ExportSize>> {
let size = match get(p, "size") {
@ -761,10 +797,12 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
/// A PDF with one page per group of layers (an sRGB document), titled after the file.
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>]) -> Result<Vec<u8>> {
let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), ..Default::default() };
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>], print: &PrintSettings) -> Result<Vec<u8>> {
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<u8>, usize)> {
pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec<u8>, 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<Vec<NodeId>> = 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<Value> {
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)?;

View File

@ -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");

View File

@ -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;

View File

@ -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}");
}

View File

@ -514,6 +514,7 @@ pub(super) fn export_settings(v: &Value) -> Vec<ExportSetting> {
},
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()

View File

@ -196,7 +196,7 @@ pub fn tool_definitions() -> Vec<Value> {
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<Value> {
"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<String, Value>) -> Result<ToolResult, Str
if let Some(s) = s_arg(a, "size")? {
params.insert("size".into(), json!(s));
}
// PDF print options, checked by the command.
for k in ["bleed", "cropMarks", "bleedMarks", "cmyk"] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
}
}
if let Some(p) = s_arg(a, "path")? {
params.insert("path".into(), json!(p));
return exec(b, "file.export", Value::Object(params)).map(|v| ToolResult::json(&v));

View File

@ -3,7 +3,7 @@ name = "figcraft-pdf"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft PDF export (krilla): vector shapes, gradients, embedded images, aligned strokes as outlines, clips, masks, blend modes, real selectable text, effects as rasters"
description = "FigCraft PDF export (krilla): vector shapes, gradients, embedded images, aligned strokes as outlines, clips, masks, blend modes, real selectable text, effects as rasters, print options (bleed, marks, page boxes, CMYK)"
[lints]
workspace = true

View File

@ -2,10 +2,9 @@
use std::collections::HashMap;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_color::{BlendMode, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, MaskType as DocMaskType, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use krilla::color::rgb;
use crate::raster::Raster;
use krilla::geom::{Path, PathBuilder, Size, Transform};
@ -16,6 +15,7 @@ use krilla::paint::{Fill, FillRule, LinearGradient, Pattern, RadialGradient, Spr
use krilla::surface::Surface;
use crate::PdfOptions;
use crate::print::Ink;
/// Deepest node nesting drawn (the renderer's limit).
pub(crate) const MAX_DEPTH: usize = 256;
@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> {
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<u32, Option<krilla::text::Font>>,
images: HashMap<String, Option<(Image, u32, u32)>>,
}
@ -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<krilla::blend::BlendMode> {
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<Stop> = 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);

View File

@ -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<String>,
/// 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<Vec<u8>, 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<NodeId>], opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let mut pages: Vec<(Vec<NodeId>, 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<NodeId>], 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<NodeId>, 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;

235
crates/pdf/src/print.rs Normal file
View File

@ -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<u8>, 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<Vec<u8>>,
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
key: u64,
}
impl CmykImage {
fn new(rgba: &[u8], w: u32, h: u32) -> Option<Self> {
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<H: Hasher>(&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
}
}

View File

@ -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<f64> = 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<u8> {
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::<String>());
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(..))
));
}

View File

@ -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<Raster>,
}
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<Raster> {
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()));
}
}
_ => {}

View File

@ -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<NodeId>) {
pub(crate) fn sample_doc() -> (Document, Vec<NodeId>) {
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();

View File

@ -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<KrillaGlyph> = r
.glyphs

View File

@ -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 {

View File

@ -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<PrintSettings> {
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()));
}
}

View File

@ -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<figcraft_color::ColorProfile>,
/// 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,

View File

@ -26,7 +26,7 @@ page 1 "Screens": 380 nodes, 4 top-level
…
```
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]`
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
- `<out>` 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 <in> <out>`

View File

@ -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 `<layer><suffix>.<format>`). |
| `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 `<layer><suffix>.<format>`). |
| `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