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:
parent
ebe403c49a
commit
fe550fc14a
14
ROADMAP.md
14
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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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, &[], &[])?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(..))
|
||||
));
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
docs/cli.md
10
docs/cli.md
|
|
@ -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>`
|
||||
|
|
|
|||
14
docs/mcp.md
14
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 `<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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue