mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4 files written as their standards say
document.exportPdf {standard: pdfX1a|pdfX3|pdfX4} and the PDF/X presets now
write conforming files instead of being refused:
- every PDF/X file gets a /GTS_PDFX output intent (by default the CMYK
profile in effect, embedded), TrimBox, Trapped, GTS_PDFXVersion (and
GTS_PDFXConformance for X-1a), a title and dates; editing data,
passwords and, in X-1a/X-3, PDF layers are refused
- PDF/X-1a converts every colour and image to a CMYK destination,
untagged, with CMYK spot alternates; X-3 and X-4 tag colours with ICC
profiles
- X-1a and X-3 are written as PDF 1.3 and their transparency is flattened
first (High Resolution preset; images with see-through pixels count,
rasters stay opaque); X-4 keeps transparency, is PDF 1.6 at most and
names the standard in its XMP metadata
- the written file is checked against its standard (soft masks,
transparency groups, opacity, blend modes, RGB in X-1a, fonts not
embedded) and refused rather than mislabelled
- choosing a standard sets the latest compatibility it allows and turns
off what it forbids (engine and Save PDF dialog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4e72f8c0ec
commit
a825aed763
@@ -901,6 +901,8 @@ mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_tiffbmp;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, FormatOption, write_or_return};
|
||||
use crate::EngineError;
|
||||
@@ -30,7 +31,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "\"VectorCraft Default\"",
|
||||
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
|
||||
},
|
||||
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
|
||||
FormatOption {
|
||||
name: "standard",
|
||||
ty: "string",
|
||||
default: "\"none\"",
|
||||
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
|
||||
},
|
||||
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
|
||||
FormatOption {
|
||||
name: "preserveEditing",
|
||||
@@ -79,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with the High Resolution flattener preset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers? (choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded). document.export {format: pdf} takes the same options",
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
@@ -145,9 +151,10 @@ pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
|
||||
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
|
||||
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
|
||||
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
|
||||
/// aren't PDF options (path, format…) are ignored.
|
||||
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
|
||||
/// allows, unless `p` asks for those. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
|
||||
/// (path, format…) are ignored.
|
||||
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||||
let base = preset_settings(cmd, p, saved)?;
|
||||
if !p.is_object() {
|
||||
@@ -156,8 +163,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
merge(&mut v, p);
|
||||
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
|
||||
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
|
||||
s.preserve_editing = false;
|
||||
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
|
||||
if s.standard != base.standard {
|
||||
if s.standard != Standard::None && unasked("preserveEditing") {
|
||||
s.preserve_editing = false;
|
||||
}
|
||||
if !s.standard.allows_layers() && unasked("createLayers") {
|
||||
s.create_layers = false;
|
||||
}
|
||||
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
|
||||
s.compatibility = s.standard.version();
|
||||
}
|
||||
}
|
||||
s.check_values().map_err(|e| pdf_error(cmd, e))?;
|
||||
Ok(s)
|
||||
@@ -223,7 +239,14 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
}
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
let flat = flatten_for(doc, &opts.settings)?;
|
||||
if flat.is_some() {
|
||||
warnings.push(format!(
|
||||
"transparency was flattened with the High Resolution flattener preset: {} files have none",
|
||||
opts.settings.standard.label()
|
||||
));
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
|
||||
@@ -231,6 +254,18 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
Ok((r.bytes, warnings))
|
||||
}
|
||||
|
||||
/// `doc` with its transparency flattened for a standard whose files have none (PDF/X-1a, PDF/X-3),
|
||||
/// with the High Resolution flattener preset; images with see-through pixels count as
|
||||
/// transparency. `None` when nothing changes (or the standard keeps transparency).
|
||||
fn flatten_for(doc: &Document, set: &PdfSettings) -> Result<Option<Document>> {
|
||||
if !set.standard.flattens() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut o = FlattenOptions::preset("high").unwrap_or_default();
|
||||
o.no_transparency = Some(see_through_images(doc));
|
||||
flatten_document(doc, &o)
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
|
||||
@@ -66,7 +66,7 @@ fn bad_options_are_refused() {
|
||||
json!({"range": "0"}),
|
||||
json!({"artboards": []}),
|
||||
json!({"preset": "Nope"}),
|
||||
json!({"standard": "pdfX4"}),
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
|
||||
json!({"compression": {"color": {"ppi": 0}}}),
|
||||
json!({"compression": "zip"}),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
|
||||
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
|
||||
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
|
||||
/// pixels over both.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
|
||||
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
|
||||
p["compression"] = json!({"compressText": false});
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
|
||||
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
|
||||
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
|
||||
assert!(text.starts_with(header), "{standard}");
|
||||
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
|
||||
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
|
||||
assert!(!text.contains(no), "{standard}: no {no}");
|
||||
}
|
||||
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
|
||||
}
|
||||
// PDF/X-1a: CMYK only, the flattened colours too.
|
||||
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
|
||||
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
|
||||
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
|
||||
// The document itself is untouched.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// PDF/X-4 keeps transparency (and the image's soft mask).
|
||||
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
|
||||
assert!(text.starts_with("%PDF-1.6"));
|
||||
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
|
||||
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
let mut s = session();
|
||||
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
|
||||
// The default preset is PDF 1.7 and keeps editing data.
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4"}));
|
||||
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.4"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
|
||||
// What is asked for explicitly is checked, not changed.
|
||||
for p in [
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfX3", "compatibility": "1.6"}),
|
||||
json!({"standard": "pdfX1a", "createLayers": true}),
|
||||
json!({"standard": "pdfX3", "preserveEditing": true}),
|
||||
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
|
||||
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
|
||||
] {
|
||||
let e = s.execute("document.exportPdf", &p).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
|
||||
}
|
||||
// The built-in PDF/X presets export as they are.
|
||||
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
|
||||
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
|
||||
//! the document would touch ([`FlattenReport`], `flattener.preview`).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -129,6 +130,12 @@ pub struct FlattenOptions {
|
||||
/// Areas showing a single paint keep it (spot and process colours, swatches) with its
|
||||
/// overprint; off, the flattened objects lose their overprints.
|
||||
pub preserve_overprints: bool,
|
||||
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
||||
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
||||
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
|
||||
/// preset option.
|
||||
#[serde(skip)]
|
||||
pub no_transparency: Option<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for FlattenOptions {
|
||||
@@ -175,6 +182,7 @@ impl FlattenOptions {
|
||||
anti_alias,
|
||||
preserve_alpha: false,
|
||||
preserve_overprints: true,
|
||||
no_transparency: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +230,11 @@ impl FlattenOptions {
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `n` shows an image whose see-through pixels count as transparency.
|
||||
fn draws_see_through_image(&self, n: &Node) -> bool {
|
||||
self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys))
|
||||
}
|
||||
|
||||
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
|
||||
fn rasterize_all(&self, regions: usize) -> bool {
|
||||
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
|
||||
@@ -792,7 +805,7 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(Node::shows_transparency).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
for g in overlapping(&reaches) {
|
||||
@@ -927,7 +940,13 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
if !o.preserve_alpha || !o.anti_alias {
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
for p in img.pixels.as_chunks_mut::<4>().0 {
|
||||
p[3] = 255;
|
||||
}
|
||||
} else if !o.preserve_alpha || !o.anti_alias {
|
||||
// The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial.
|
||||
let cover: Vec<Node> = art
|
||||
.iter()
|
||||
@@ -988,7 +1007,7 @@ fn opaque(n: &mut Node) {
|
||||
/// clipping path stays). `None` when that changes nothing.
|
||||
pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Option<Document>> {
|
||||
let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints;
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut roots = vec![];
|
||||
@@ -1197,6 +1216,25 @@ fn carry(from: &Node, mut to: Node) -> Node {
|
||||
to
|
||||
}
|
||||
|
||||
/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque
|
||||
/// everywhere).
|
||||
pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
|
||||
let see_through = |b: &ImageBlob| {
|
||||
b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255))
|
||||
};
|
||||
doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
/// `n` shows an image of `keys` (itself or inside).
|
||||
fn draws_image(n: &Node, keys: &BTreeSet<String>) -> bool {
|
||||
!keys.is_empty()
|
||||
&& n.visible
|
||||
&& match &n.kind {
|
||||
NodeKind::Image(im) => keys.contains(&im.key),
|
||||
_ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_fx(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user