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:
bflatastic
2026-10-05 17:19:11 +07:00
co-authored by Claude Opus 5.5
parent 4e72f8c0ec
commit a825aed763
21 changed files with 896 additions and 105 deletions
+2
View File
@@ -901,6 +901,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_psd;
+43 -8
View File
@@ -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;
+1 -1
View File
@@ -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}");
}
}
+41 -3
View File
@@ -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)
}