From e967c31be6fc992fc619daae449d2965adc158d2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:20:35 +0700 Subject: [PATCH 1/3] M4.58: raster effects in PDF on every kind of object PDF export now renders the shadows, glows, blurs and feathers of groups, layers, type, images, symbol instances, live objects and single fills and strokes (and those inside opacity masks, symbol definitions and pattern tiles) as images at the document raster effects resolution, instead of leaving them out with a warning. Shadows and outer glows go under the untouched vector art; a path whose fills or strokes carry effects is written as one piece per fill and stroke. Expand Appearance shares the image helpers (moved to rasterfx.rs); effect images now cover what the renderer paints (members' shadows included). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/group.rs | 2 +- crates/effects/src/lib.rs | 3 +- crates/engine/src/cmd/effectcmd.rs | 75 +----- crates/engine/src/cmd/fileio/tests_pdf.rs | 5 +- crates/engine/src/cmd/rasterfx.rs | 293 +++++++++++++++++----- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_pdfraster.rs | 149 +++++++++++ crates/pdf/src/export.rs | 6 +- crates/render/src/lib.rs | 6 + docs/mcp.md | 7 +- 10 files changed, 405 insertions(+), 143 deletions(-) create mode 100644 crates/engine/src/tests_pdfraster.rs diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 1a45d4a82..19926b01c 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -144,7 +144,7 @@ fn is_container(n: &Node) -> bool { } /// Does `item` paint anything? -pub(crate) fn paints(item: &AppearanceItem) -> bool { +pub fn paints(item: &AppearanceItem) -> bool { item.visible() && !item.paint().is_none() && match item { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8cba35500..bcd0645f5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -47,7 +47,8 @@ use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ - OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children, + OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints, + pathfinder_children, }; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use raster::{RasterFx, outset, raster_effects}; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 3b59c9d4d..8c15b9154 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; +use super::rasterfx; use super::*; pub fn specs() -> Vec { @@ -287,69 +288,17 @@ fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } -/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of -/// everything inside it. -fn strip_raster(n: &mut Node, members: bool) { - let keep = |e: &Effect| !effects::is_raster(&e.id); - n.appearance.effects.retain(keep); - for it in &mut n.appearance.items { - it.effects_mut().retain(keep); - } - if members { - for c in n.children_mut().into_iter().flatten() { - strip_raster(Arc::make_mut(c), true); - } - } -} - /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at /// the document's raster effects resolution, with whether they all paint below it (shadows and /// outer glows: the image then holds just them). A group's or layer's image leaves out its -/// members' raster effects when those are expanded with them. `None` when it has none. +/// members' raster effects, which are expanded with them. `None` when it has none. fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { - let fx: Vec = std::iter::once(&n.appearance.effects) - .chain(n.appearance.items.iter().map(|i| i.effects())) - .flat_map(|e| effects::raster_effects(e)) - .collect(); + let fx = rasterfx::raster_fx(n); if fx.is_empty() { return None; } let below = fx.iter().all(effects::RasterFx::is_below); - // Its transparency stays on the expanded object. - let mut whole = n.clone(); - (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); - if below && is_container(&whole) { - for c in whole.children_mut().into_iter().flatten() { - strip_raster(Arc::make_mut(c), true); - } - } - let bare = below.then(|| { - let mut b = whole.clone(); - strip_raster(&mut b, false); - b - }); - let scale = super::rasterfx::effects_scale(d); - let image = super::rasterfx::effect_image(d, &whole, bare.as_ref(), scale)?; - Some((image, below)) -} - -/// `m` (an expanded object) with `image` of its shadows and outer glows painted below its art: the -/// image goes first in a group or layer (after a layer's clipping path), anything else is grouped -/// with it under its id, name and transparency. -fn put_below(d: &mut Document, m: &mut Node, image: Node) { - match &mut m.kind { - NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)), - NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)), - _ => { - let mut inner = m.clone(); - inner.id = d.alloc_id(); - (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); - inner.knockout = Default::default(); - inner.knockout_shape = false; - m.appearance = Default::default(); - m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; - } - } + Some((rasterfx::raster_image(d, n, below, true)?, below)) } /// Give the members of `m`, art evaluated from the object of the same id (its pieces share that @@ -395,23 +344,13 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush } let image = raster_image(d, &n); let mut v = n.clone(); - strip_raster(&mut v, false); + rasterfx::strip_raster(&mut v, false); let mut stroke_art = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st); let expanded = match image { // Blur, feather and inner glow change the object itself: it becomes the image (a layer // keeps it as its only member). - Some((image, false)) => Some(if n.is_layer() { - let mut m = v; - m.appearance = Default::default(); - m.set_clips(false); - if let Some(ch) = m.children_mut() { - *ch = vec![Arc::new(image)]; - } - m - } else { - Node { kind: image.kind, appearance: Default::default(), ..v } - }), + Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { let m = if container { // A group's or layer's own fills and strokes become art among its members. @@ -435,7 +374,7 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush match image { Some((image, _)) => { let mut m = m.unwrap_or(v); - put_below(d, &mut m, image); + rasterfx::put_below(d, &mut m, image); Some(m) } None => m, diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 2156a2946..18fe6f13c 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -158,10 +158,13 @@ fn pdf_settings_summarise_changes_and_warnings() { s.execute("select.all", &json!({})).unwrap(); s.execute("object.group", &json!({})).unwrap(); let g = s.doc().unwrap().selection.objects[0].0; + // Raster effects are written as images: nothing to report. s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap(); + assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([])); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([])); let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap(); - assert!(warnings(&full).iter().any(|w| w.contains("group objects")), "{}", full["warnings"]); + assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]); } #[test] diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index e7ed37540..e8c7896b5 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -1,18 +1,21 @@ //! Effect → Document Raster Effects Settings, and raster effects for PDF export. //! //! PDF has no live shadows, glows or blurs, so PDF export receives a copy of the document in which -//! every object with a raster effect is accompanied by an image rendered with the document's raster -//! effects settings (resolution, colour model, background, anti-aliasing, room around the art). -//! Shadows and outer glows only add the effect: the image (with the object's own area knocked out) -//! goes under the untouched vector object. Effects that change the object itself (inner glow, -//! feather, Gaussian blur) replace it with the image. +//! every object with a raster effect (any kind of object, or one of its fills or strokes) is +//! accompanied by an image rendered with the document's raster effects settings (resolution, colour +//! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the +//! effect: the image (with the object's own area knocked out) goes under the untouched vector +//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it +//! with the image. Expand Appearance makes its images with the same helpers. use std::sync::Arc; use serde_json::{Map, Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; -use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer, +}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_render::effects::{self, RasterFx}; use vectorcraft_render::{AntiAlias, RenderOptions}; @@ -135,17 +138,6 @@ pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) } -fn raster_fx(n: &Node) -> Vec { - if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { - return vec![]; - } - effects::raster_effects(&n.appearance.effects) -} - -fn needs(n: &Node) -> bool { - !raster_fx(n).is_empty() || n.children().is_some_and(|ch| ch.iter().any(|c| needs(c))) -} - /// Render `nodes` alone over transparency: (premultiplied pixels, width, height). fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias: AntiAlias) -> vectorcraft_render::Rendered { let mut tmp = doc.clone(); @@ -166,16 +158,16 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { } /// An embedded image (a new node of `out`, its pixels in `out.images`) of `whole` rendered alone -/// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects -/// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out +/// with `out`'s resources at `scale` over all it paints (the reach of its own, its members' and its +/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the /// vector object). The document's raster effects settings add room around it and finish its pixels. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { - let b = whole.visual_bounds()?; + // What it paints (its members' effects included), and how far its fills' and strokes' raster + // effects reach beyond that. + let b = vectorcraft_render::painted_bounds(whole)?; let look = out.raster_effects.clone(); - let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) - + 2.0 - + look.add_around; + let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); // Whole pixels, and no larger than 64 Mpx. let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); @@ -208,67 +200,234 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N Some(image) } -/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`. -fn flatten_node(out: &mut Document, n: &Node, scale: f64) -> Option { - let below_only = raster_fx(n).iter().all(RasterFx::is_below); - let mut whole = n.clone(); - whole.blend = vectorcraft_doc::color::BlendMode::Normal; - // Shadows only: knock the object's own coverage out of the image; the vector object goes on top. - let bare = below_only.then(|| { - let mut bare = whole.clone(); - bare.appearance.effects.retain(|e| !effects::is_raster(&e.id)); - bare.opacity = 1.0; - bare - }); - let mut image = effect_image(out, &whole, bare.as_ref(), scale)?; - let vector = bare.map(|_| { - let mut v = n.clone(); - v.appearance.effects.retain(|e| !effects::is_raster(&e.id)); - v.blend = vectorcraft_doc::color::BlendMode::Normal; - v - }); - Some(match vector { - Some(v) => { - let mut g = Node::group(out.alloc_id(), vec![Arc::new(image), Arc::new(v)]); - g.blend = n.blend; - g.name = n.name.clone(); - g - } - None => { - image.blend = n.blend; - image - } - }) +/// The visible raster effects of `n`: its own and its fills' and strokes' (none on guides and +/// clipping paths, which paint nothing). +pub(crate) fn raster_fx(n: &Node) -> Vec { + if matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) { + return vec![]; + } + std::iter::once(&n.appearance.effects).chain(n.appearance.items.iter().map(|i| i.effects())).flat_map(|e| effects::raster_effects(e)).collect() } -fn walk(out: &mut Document, n: &Node, scale: f64) -> Option { - if !needs(n) || !n.visible { +/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of +/// everything inside it. +pub(crate) fn strip_raster(n: &mut Node, members: bool) { + let keep = |e: &Effect| !effects::is_raster(&e.id); + n.appearance.effects.retain(keep); + for it in &mut n.appearance.items { + it.effects_mut().retain(keep); + } + if members { + for c in n.children_mut().into_iter().flatten() { + strip_raster(Arc::make_mut(c), true); + } + } +} + +/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d` +/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays +/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds +/// just them; with `members` too, a group's or layer's image leaves out its members' raster +/// effects (they get images of their own). +pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { + let mut whole = n.clone(); + (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); + if below && members && is_container(&whole) { + for c in whole.children_mut().into_iter().flatten() { + strip_raster(Arc::make_mut(c), true); + } + } + let bare = below.then(|| { + let mut b = whole.clone(); + strip_raster(&mut b, false); + b + }); + let scale = effects_scale(d); + effect_image(d, &whole, bare.as_ref(), scale) +} + +/// `v` (an object without its raster effects) replaced by `image` of the object with them: the +/// image takes its place with its id, name and transparency (a layer keeps the image as its only +/// member). +pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { + if !v.is_layer() { + return Node { kind: image.kind, appearance: Default::default(), ..v }; + } + v.appearance = Default::default(); + v.set_clips(false); + if let Some(ch) = v.children_mut() { + *ch = vec![Arc::new(image)]; + } + v +} + +/// `m` with `image` of its shadows and outer glows painted below its art: the image goes first in +/// a group or layer (after a layer's clipping path), anything else is grouped with it under its +/// id, name and transparency. +pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { + match &mut m.kind { + NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)), + NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)), + _ => { + let mut inner = m.clone(); + inner.id = d.alloc_id(); + (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); + inner.knockout = Default::default(); + inner.knockout_shape = false; + m.appearance = Default::default(); + m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; + } + } +} + +fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Can the raster effects of `n` go in an image under its untouched vector art? They must all +/// paint below it (shadows, outer glows) and belong to the object or to fills and strokes with +/// nothing painted under them (a stroke's shadow falls on the fills below it, and type's +/// characters or a group's members paint below its upper fills and strokes). +fn below_only(n: &Node) -> bool { + let contents = (is_container(n) || matches!(n.kind, NodeKind::Text(_))).then(|| n.appearance.contents_at()); + let mut covered = false; + for (i, item) in n.appearance.items.iter().enumerate() { + covered |= contents == Some(i); + let fx = effects::raster_effects(item.effects()); + if !fx.iter().all(RasterFx::is_below) || (covered && !fx.is_empty()) { + return false; + } + covered |= effects::paints(item); + } + effects::raster_effects(&n.appearance.effects).iter().all(RasterFx::is_below) +} + +/// A path or compound path with several fills and strokes, some of them with raster effects, as a +/// group of one path per fill and stroke (each keeping its own effects, so each gets an image of +/// its own) under the object's transparency and effects. `None` for other objects. +fn split_items(out: &mut Document, n: &Node) -> Option { + let items = &n.appearance.items; + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + || items.iter().filter(|i| effects::paints(i)).count() < 2 + || items.iter().all(|i| effects::raster_effects(i.effects()).is_empty()) + { return None; } - if !raster_fx(n).is_empty() { - return flatten_node(out, n, scale); + let mut stroke_piece = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| { + let mut p = super::pathops::shape_node(d, path.clone(), None); + p.appearance = Appearance { items: vec![AppearanceItem::Stroke(st.clone())], ..Default::default() }; + if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(p) + }; + effects::expand_leaf(out, n, &mut stroke_piece) +} + +/// Symbol instance `n` as a group of its symbol's art (placed, and tinted as the instance tints +/// it) with the instance's appearance and transparency, which PDF export draws the same: the +/// instance alone has no size to render its effects' image over. `None` for other objects. +fn placed_symbol(d: &Document, n: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let mut art = vectorcraft_render::instance_art(&d.symbols.iter().find(|s| s.name == *symbol)?.art, n); + art.transform(*xf, false); + Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) +} + +/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity +/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn. +fn needs(n: &Node) -> bool { + n.visible + && !matches!(n.kind, NodeKind::Layer { template: true, .. }) + && (!raster_fx(n).is_empty() + || n.mask.as_ref().is_some_and(|m| needs(&m.art)) + || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c))))) +} + +/// The art of `n`'s opacity mask with its raster effects as images. +fn walk_mask(out: &mut Document, n: &mut Node) { + if let Some(m) = n.mask.as_mut() + && let Some(art) = walk(out, &m.art) + { + m.art = Arc::new(art); } - let mut m = n.clone(); - let ch: Vec> = n.children()?.iter().map(|c| walk(out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); - if let Some(slot) = m.children_mut() { - *slot = ch; +} + +/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it +/// has none. An object whose effects all paint below it keeps its vector art over an image of +/// them; one whose effects change it (blur, feather, inner glow) becomes the image. +fn walk(out: &mut Document, n: &Node) -> Option { + if !needs(n) { + return None; } + if raster_fx(n).is_empty() { + let mut m = n.clone(); + walk_mask(out, &mut m); + if is_container(&m) + && let Some(ch) = walk_all(out, n.children()?) + && let Some(slot) = m.children_mut() + { + *slot = ch; + } + return Some(m); + } + if let Some(g) = placed_symbol(out, n) { + return Some(walk(out, &g).unwrap_or(g)); + } + let below = below_only(n); + if !below && let Some(g) = split_items(out, n) { + return Some(walk(out, &g).unwrap_or(g)); + } + // An image that can't be made leaves the object as it is (the writer reports its effects). + let image = raster_image(out, n, below, false)?; + let mut v = n.clone(); + strip_raster(&mut v, false); + let mut m = if below { + let mut m = walk(out, &v).unwrap_or(v); + put_below(out, &mut m, image); + m + } else { + replace_with_image(v, image) + }; + walk_mask(out, &mut m); Some(m) } +/// `nodes` with their raster effects as images, `None` when none has any. +fn walk_all(out: &mut Document, nodes: &[Arc]) -> Option>> { + nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) +} + /// A copy of `doc` with raster effects turned into images at the document's raster effects -/// resolution, or `None` when there are none. +/// resolution, or `None` when there are none: on every kind of object (paths, groups and layers, +/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers, +/// opacity masks, symbol definitions and pattern tiles. pub fn flatten_raster_effects(doc: &Document) -> Option { - if !doc.layers.iter().any(|l| needs(l)) { + let any = |nodes: &[Arc]| nodes.iter().any(|n| needs(n)); + if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) { return None; } // Geometry effects first, so the vector objects kept above shadows are final. let baked = effects::bake_document(doc); let src = baked.as_ref().unwrap_or(doc); - let scale = effects_scale(src); let mut out = src.clone(); - let layers = src.layers.clone(); - out.layers = layers.iter().map(|l| walk(&mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); + if let Some(layers) = walk_all(&mut out, &src.layers) { + out.layers = layers; + } + for i in 0..out.symbols.len() { + if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a)) + && let Some(s) = out.symbols.get_mut(i) + { + s.art = Arc::new(art); + } + } + for i in 0..out.patterns.len() { + if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a)) + && let Some(p) = out.patterns.get_mut(i) + { + p.art = art; + } + } Some(out) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39914f03b..038e86318 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1075,6 +1075,8 @@ mod tests_pattern; #[cfg(test)] mod tests_pdfpresets; #[cfg(test)] +mod tests_pdfraster; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_prefs; diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs new file mode 100644 index 000000000..bf10064fa --- /dev/null +++ b/crates/engine/src/tests_pdfraster.rs @@ -0,0 +1,149 @@ +//! Raster effects in PDF on every kind of object: groups, layers, type, images, symbol instances, +//! live objects and single fills and strokes are written with images of their shadows, glows and +//! blurs (no "left out" warning), and the PDF reads back looking like the canvas. + +use std::io::Cursor; +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol, TextObject}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +fn shadow() -> Effect { + Effect { id: "stylize.dropShadow".into(), params: json!({"x": 6.0, "y": 6.0, "blur": 3.0, "opacity": 75.0}), visible: true } +} + +fn blur() -> Effect { + Effect { id: "blur.gaussian".into(), params: json!({"radius": 3.0}), visible: true } +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)) +} + +fn add(d: &mut Document, n: Node) { + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +fn with(mut n: Node, fx: Effect) -> Node { + n.appearance.effects.push(fx); + n +} + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, _| image::Rgba([(x * 255 / w) as u8, 60, 160, 255])); + let mut out = Vec::new(); + image::DynamicImage::from(img).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +/// Mean difference of the colour channels of two renders, as a share of full scale. +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +/// The PDF of `d` embeds images, warns about nothing and reads back within 2% of the canvas, much +/// closer than the art without its raster effects. +fn assert_written(what: &str, d: &Document) { + let r = crate::cmd::rasterfx::export_pdf_with_report(d, &Default::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"), "{what}: an image XObject"); + let canvas = render(d); + let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&r.bytes).unwrap())); + let mut plain = d.clone(); + for l in &mut plain.layers { + crate::cmd::rasterfx::strip_raster(Arc::make_mut(l), true); + } + for s in &mut plain.symbols { + crate::cmd::rasterfx::strip_raster(Arc::make_mut(&mut s.art), true); + } + let without = mean_diff(&canvas, &render(&plain)); + assert!(diff < 0.02 && diff < without / 4.0, "{what}: read back {:.2}% off ({:.2}% without the effects)", diff * 100.0, without * 100.0); +} + +#[test] +fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { + // A group's shadow (one shadow of both members). + let mut d = Document::new(200.0, 200.0); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 30.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)), + rect(&mut d, Rect::new(70.0, 70.0, 150.0, 150.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let g = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + add(&mut d, with(g, shadow())); + assert_written("group", &d); + + // A layer's blur. + let mut d = Document::new(200.0, 200.0); + let r = rect(&mut d, Rect::new(40.0, 40.0, 160.0, 120.0), Color::rgb(0.1, 0.6, 0.2)); + add(&mut d, r); + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(blur()); + assert_written("layer", &d); + + // Type with a shadow. + let mut d = Document::new(200.0, 200.0); + let style = CharStyle { size: 60.0, fill: Paint::solid(Color::rgb(0.8, 0.1, 0.1)), ..Default::default() }; + let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), "Ab", style)))); + add(&mut d, with(t, shadow())); + assert_written("type", &d); + + // An image with a shadow. + let mut d = Document::new(200.0, 200.0); + d.images.insert("pic".into(), ImageBlob::new("image/png", png(40, 30))); + let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); + let im = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None })); + add(&mut d, with(im, shadow())); + assert_written("image", &d); + + // A symbol instance with a shadow, and a symbol whose art blurs. + let mut d = Document::new(200.0, 200.0); + let art = rect(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.9, 0.6, 0.0)); + d.symbols.push(Symbol { name: "Tile".into(), art: Arc::new(art) }); + let blurred = with(rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), Color::rgb(0.2, 0.2, 0.8)), blur()); + d.symbols.push(Symbol { name: "Soft".into(), art: Arc::new(blurred) }); + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Tile".into(), xf: Affine::translate((30.0, 30.0)) }); + add(&mut d, with(inst, shadow())); + let soft = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Soft".into(), xf: Affine::translate((120.0, 120.0)) }); + add(&mut d, soft); + assert_written("symbols", &d); +} + +#[test] +fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() { + // A blend with a shadow. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 30, "height": 30})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 140, "y": 120, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let g = s.execute("object.blend.make", &json!({"steps": 3})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap(); + assert_written("blend", &s.doc().unwrap().doc); + + // A stroke's shadow falls on the fill below it; a fill's blur stays under a sharp stroke. + let wide_blur = Effect { params: json!({"radius": 12.0}), ..blur() }; + for (item, fx) in [(1, shadow()), (0, wide_blur)] { + let mut d = Document::new(200.0, 200.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 40.0, 160.0, 160.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.9, 0.2)), Paint::solid(Color::rgb(0.1, 0.1, 0.6)), 6.0), + ); + n.appearance.effects_mut(Some(item)).unwrap().push(fx); + add(&mut d, n); + assert_written(&format!("item {item}"), &d); + let flat = crate::flatten_raster_effects(&d).unwrap(); + let pieces = flat.layers[0].children().unwrap()[0].children().map_or(0, |c| c.len()); + assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a784ed708..247d8d652 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -391,9 +391,9 @@ impl Exporter<'_> { Some(out) } - /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. - /// Warn when `effects` has a raster effect: the app renders them to images before export - /// (`vectorcraft_engine::export_pdf`), so the ones still here (on `what`) can't be written. + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the + /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones + /// still here (on `what`) can't be written. fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7d281d9fa..3b4007a90 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -36,6 +36,12 @@ pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; pub use vello_cpu; +/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and +/// geometry effects, and the shadows and glows of it and its members. +pub fn painted_bounds(n: &Node) -> Option { + brush_fx::cull_bounds(n) +} + /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; diff --git a/docs/mcp.md b/docs/mcp.md index 5fc724b6f..07faf7e34 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -456,7 +456,7 @@ fills and strokes above it paint over the members (characters), those below unde objects themselves (a group's own stack; layers through `ids`) or, with `target: "contents"`, the objects inside the groups and layers. `appearance.targetContents` selects a group's members (double-clicking the Contents row). SVG and PDF export bake a group's own fills, strokes and geometry effects into paths; its raster effects stay a -filter on the whole group. +filter on the whole group in SVG and become an image of the whole group in PDF. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} @@ -937,7 +937,10 @@ export or Expand Appearance renders them: `resolution` (ppi, also New Document's (off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its `clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them -in one undo step. +in one undo step. PDF export renders the raster effects of every kind of object this way (paths, groups, layers, type, +images, symbol instances, live objects, and those in opacity masks, symbols and pattern tiles): shadows and outer glows +go in an image under the untouched vector art, other effects replace the object with its image, and a path whose +fills or strokes carry effects is written as one piece per fill and stroke, so only the affected ones become images. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} From ffc60d5ff89c15998b2cead278427c8393ac0393 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:40:18 +0700 Subject: [PATCH 2/3] M4.59: pattern strokes and freeform gradients keep their look in PDF Pattern strokes are written as the pattern's tiles clipped to the stroke's outline (dashes, caps, profile, arrowheads and alignment included) instead of mid-grey; freeform gradients on fills, strokes and type as an image of their colour field at the document raster effects resolution, clipped to what they paint, instead of their average colour. Both read back within 2% of the canvas at 150 ppi with no warning. Text strokes are covered for cap, miter limit, dash array and alignment; knockout groups keep their soft-masked form with a warning. The freeform box and grid sampling are shared with the renderer. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/freeform.rs | 19 +++ crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdf.rs | 13 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_pdffidelity.rs | 95 +++++++++++++++ crates/pdf/src/export.rs | 137 ++++++++++++++++++---- crates/pdf/src/images.rs | 57 +++++++++ crates/pdf/src/lib.rs | 8 +- crates/pdf/src/tests_textstroke.rs | 39 ++++++ crates/render/src/freeform.rs | 19 +-- docs/mcp.md | 3 + 11 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/tests_pdffidelity.rs create mode 100644 crates/pdf/src/images.rs create mode 100644 crates/pdf/src/tests_textstroke.rs diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 686416294..9dc8f063b 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -87,6 +87,15 @@ pub fn spread_scale(b: Rect) -> f64 { b.width().abs().max(b.height().abs()) / 2.0 } +/// The box a freeform gradient painted over `bounds` is placed on: `bounds` with positive sides, a +/// side without length getting a sliver (so a grid over it maps back invertibly). `None` for an +/// empty or non-finite box. +pub fn painted_box(bounds: Rect) -> Option { + let b = bounds.abs(); + let side = b.width().max(b.height()); + (side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3)))) +} + /// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]). #[derive(Clone, Copy, Debug, PartialEq)] pub struct LineHit { @@ -371,6 +380,16 @@ impl Piece { } impl Field { + /// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and + /// opacity (as [`Self::sample`]). + pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); + (0..rows as usize * cols as usize).map(move |k| { + let (i, j) = (k % cols as usize, k / cols as usize); + self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)) + }) + } + /// Display RGB and opacity at `p`. pub fn sample(&self, p: Point) -> ([f32; 3], f32) { // (weight sum, weighted rgba) inside discs, and blending by distance outside them. diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ea8f6a5ae..9cd51faef 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)", + "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)", always, pdf_settings ), diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 18fe6f13c..9fe8e9dce 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -107,16 +107,21 @@ fn options_not_applied_yet_and_document_features_warn() { let w = warnings(&export(&mut s, json!({"thumbnails": true, "marks": {"trim": true}}))); assert_eq!(w.len(), 2, "{w:?}"); assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("marks"))); - // A pattern stroke is approximated: its warning comes back from every PDF path. + // A pattern stroke is written as its tiles clipped to the stroke: nothing to report. let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [tile]})).unwrap(); s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); s.execute("object.pattern.done", &json!({})).unwrap(); let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap(); - let pattern = |w: Vec| w.iter().any(|w| w.contains("pattern strokes")); - assert!(pattern(warnings(&export(&mut s, json!({}))))); - assert!(pattern(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap()))); + assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0); + // A knockout group is approximated: its warning comes back from every PDF path. + s.execute("select.set", &json!({"ids": [frame]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); + let knockout = |w: Vec| w.iter().any(|w| w.contains("knockout groups")); + assert!(knockout(warnings(&export(&mut s, json!({}))))); + assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap()))); // Other formats have none. assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 038e86318..5678c79f7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1073,6 +1073,8 @@ mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] +mod tests_pdffidelity; +#[cfg(test)] mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests_pdffidelity.rs new file mode 100644 index 000000000..21acfe4a7 --- /dev/null +++ b/crates/engine/src/tests_pdffidelity.rs @@ -0,0 +1,95 @@ +//! PDF vector fidelity: pattern strokes are written as their tiles clipped to the stroke's +//! outline, freeform gradients as images of their colour field clipped to what they paint, and +//! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Node}; +use vectorcraft_geom::Rect; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: &Value) -> Node { + s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone() +} + +/// `s`'s document with object `id`'s fills and strokes repainted. +fn repainted(s: &Session, id: &Value, fill: Option, stroke: Option) -> Document { + let mut d = (*s.doc().unwrap().doc).clone(); + for item in &mut d.node_mut(NodeId(id.as_u64().unwrap())).unwrap().appearance.items { + match (item, &fill, &stroke) { + (AppearanceItem::Fill(f), Some(p), _) => f.paint = p.clone(), + (AppearanceItem::Stroke(st), _, Some(p)) => st.paint = p.clone(), + _ => {} + } + } + d +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 150.0 / 72.0, true) +} + +/// Mean difference of the colour channels of two renders, as a share of full scale. +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +/// The PDF of `s`'s document: no warnings, an embedded image when `image`, and read back within 2% +/// of the canvas and much closer than `rough` (the art painted the old way). +fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) { + let d = &s.doc().unwrap().doc; + let r = crate::cmd::rasterfx::export_pdf_with_report(d, &Default::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + assert_eq!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"), image, "{what}: image XObject"); + let canvas = render(d); + let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&r.bytes).unwrap())); + let before = mean_diff(&canvas, &render(rough)); + assert!(diff < 0.02 && diff < before / 4.0, "{what}: read back {:.2}% off (the old way {:.2}%)", diff * 100.0, before * 100.0); +} + +#[test] +fn pattern_strokes_are_tiles_clipped_to_the_stroke() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [dot]})); + run(&mut s, "paint.setFill", json!({"color": "#d02020"})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 8, "height": 8})); + run(&mut s, "object.pattern.done", json!({})); + let frame = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 140, "height": 120}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [frame]})); + run(&mut s, "paint.setFill", json!({"color": "#2040c0"})); + run(&mut s, "stroke.set", json!({"weight": 16, "align": "outside", "dash": [30, 10]})); + run(&mut s, "paint.setStroke", json!({"swatch": "Dots"})); + // The old way: a mid-grey stroke. + let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey))); +} + +#[test] +fn freeform_gradients_are_images_of_their_colour_field() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut s, "shape.ellipse", json!({"x": 20, "y": 30, "width": 160, "height": 130}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [id]})); + run(&mut s, "stroke.set", json!({"weight": 14})); + run(&mut s, "paint.editGradient", json!({"kind": "freeform"})); + run(&mut s, "paint.editGradient", json!({"kind": "freeform", "stroke": true})); + // The old way: the average colour. + let Paint::Gradient(g) = node(&s, &id).appearance.fill_paint() else { panic!("a freeform fill") }; + let n = g.gradient.stops.len() as f32; + let c = g.gradient.stops.iter().fold([0.0f32; 3], |acc, st| { + let c = st.color.to_rgb(); + [acc[0] + c[0] / n, acc[1] + c[1] / n, acc[2] + c[2] / n] + }); + let avg = Paint::solid(Color::rgb(c[0], c[1], c[2])); + let rough = repainted(&s, &id, Some(avg.clone()), Some(avg)); + assert_faithful("freeform", &s, true, &rough); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 247d8d652..5f519edff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -95,7 +95,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { knockout: bool, /// Spot colours written with a Lab alternate ([`crate::lab_spot`]): colorant name, Lab values. lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, + /// Images the writer samples itself may ask viewers to smooth them (not in PDF/A). + interpolate: bool, } +/// Most pixels along a side of a freeform gradient's image (its colour field is smooth). +const MAX_FIELD_PX: f64 = 512.0; + fn xf(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) @@ -484,6 +497,8 @@ impl Exporter<'_> { .into(), ) } + // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient + // slices) the average colour stands in. GradientKind::Freeform => { self.warn("freeform gradients are exported as their average colour"); let n = g.gradient.stops.len().max(1) as f32; @@ -498,8 +513,9 @@ impl Exporter<'_> { } } } + // Patterns paint through [`Self::area`]: only a missing one gets here. Paint::Pattern { .. } => { - self.warn("pattern strokes (and missing patterns) are exported as mid-grey"); + self.warn("missing patterns are exported as mid-grey"); Some(rgb::Color::new(128, 128, 128).into()) } } @@ -685,22 +701,7 @@ impl Exporter<'_> { continue; } self.warn_raster(&fl.effects, || "fills".into()); - // Pattern fills: the tile instances covering the shape, clipped to it. - let doc = self.doc; - if let Paint::Pattern { pattern, xf } = &fl.paint - && let Some(def) = doc.pattern(pattern) - { - s.push_clip_path(&path, &rule(r)); - if fl.opacity < 1.0 { - s.push_opacity(norm(fl.opacity)); - } - for inst in def.instances_in(*xf, bounds) { - self.node(s, &inst, bounds, true); - } - if fl.opacity < 1.0 { - s.pop(); - } - s.pop(); + if self.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) { continue; } let Some(paint) = self.paint(&fl.paint, bounds) else { continue }; @@ -728,6 +729,87 @@ impl Exporter<'_> { s.set_stroke(None); } + /// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or + /// a freeform gradient? + fn area_paint(&self, p: &Paint) -> bool { + match p { + Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), + Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform, + _ => false, + } + } + + /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, + /// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances + /// covering it, or an image of a freeform gradient placed on `bounds` (the painted object's + /// box, as on the canvas), clipped to it. False (nothing drawn) for other paints. + #[allow(clippy::too_many_arguments)] + fn area( + &mut self, + s: &mut Surface, + clip: &Path, + rule: krilla::paint::FillRule, + p: &Paint, + transparency: (f32, BlendMode), + bounds: Rect, + area: Rect, + ) -> bool { + if !self.area_paint(p) { + return false; + } + let (opacity, mode) = transparency; + let mut pushes = 0; + if mode != BlendMode::Normal { + s.push_blend_mode(blend(mode)); + pushes += 1; + } + s.push_clip_path(clip, &rule); + pushes += 1; + if opacity < 1.0 { + s.push_opacity(norm(opacity)); + pushes += 1; + } + let doc = self.doc; + match p { + Paint::Pattern { pattern, xf } => { + for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() { + self.node(s, &inst, area, true); + } + } + Paint::Gradient(g) => self.field_image(s, g, bounds, area), + _ => {} + } + for _ in 0..pushes { + s.pop(); + } + true + } + + /// Draw freeform gradient `g`, placed on `bounds`, as an image of its colour field over `area`, + /// sampled at the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side). + fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { + use vectorcraft_color::freeform::{painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let area = area.abs(); + let long = area.width().max(area.height()); + if !(long > 1e-9 && long.is_finite()) { + return; + } + let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; + let (cols, rows) = (along(area.width()), along(area.height())); + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + let rgba: Vec = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); + let (Some(img), Some(size)) = + (crate::images::from_rgba(&rgba, cols.into(), rows.into(), self.interpolate), Size::from_wh(area.width() as f32, area.height() as f32)) + else { + return; + }; + s.push_transform(&xf(Affine::translate(area.origin().to_vec2()))); + s.draw_image(img, size); + s.pop(); + } + /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its /// unplaced gradients. #[allow(clippy::too_many_arguments)] @@ -735,6 +817,16 @@ impl Exporter<'_> { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } + if self.area_paint(&st.paint) { + // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as + // Outline Stroke makes it, painted with the pattern's tiles or the freeform's image. + let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); + if let Some(clip) = to_path(®ion) { + let area = region.bounding_box(); + self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area); + } + return; + } let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); let mut pushes = 0; @@ -881,7 +973,10 @@ impl Exporter<'_> { bp.extend(g.outline.iter()); } let Some(path) = to_path(&bp) else { continue }; - if let Some(paint) = self.paint(&run.style.fill, layout.bounds) { + let opaque = (1.0, BlendMode::Normal); + if !self.area(s, &path, krilla::paint::FillRule::NonZero, &run.style.fill, opaque, layout.bounds, bp.bounding_box()) + && let Some(paint) = self.paint(&run.style.fill, layout.bounds) + { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); @@ -906,7 +1001,9 @@ impl Exporter<'_> { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => { - if let Some(paint) = self.paint(&fl.paint, tb) { + if !self.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box()) + && let Some(paint) = self.paint(&fl.paint, tb) + { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); s.draw_path(path); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs new file mode 100644 index 000000000..385bdbfb1 --- /dev/null +++ b/crates/pdf/src/images.rs @@ -0,0 +1,57 @@ +//! Images the writer makes itself: pixels it samples (freeform gradients) as PDF image +//! XObjects. + +use std::sync::Arc; + +use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; + +/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel. +#[derive(Clone, Hash)] +struct Raw { + color: Arc>, + alpha: Option>>, + width: u32, + height: u32, + luma: bool, +} + +impl CustomImage for Raw { + fn color_channel(&self) -> &[u8] { + &self.color + } + + 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.width, self.height) + } + + fn icc_profile(&self) -> Option<&[u8]> { + None + } + + fn color_space(&self) -> ImageColorspace { + if self.luma { ImageColorspace::Luma } else { ImageColorspace::Rgb } + } +} + +/// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB, or as +/// grey when every pixel is grey, with an alpha channel only when some pixel isn't opaque. +/// `interpolate` asks viewers to smooth it when enlarged (PDF/A forbids it). `None` when `rgba` +/// doesn't hold that many pixels. +pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) -> Option { + let px = rgba.as_chunks::<4>().0; + if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { + return None; + } + let luma = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]); + let color = if luma { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() }; + let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b24e19a4a..6bb3692fb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -4,8 +4,9 @@ //! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads, //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), -//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph -//! paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and +//! freeform gradients as images (clipped to what they paint), embedded images and text as +//! outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). @@ -22,6 +23,7 @@ mod editing; mod export; +mod images; mod import; mod import_color; mod lab_spot; @@ -173,3 +175,5 @@ mod tests_settings; mod tests_stroke; #[cfg(test)] mod tests_strokeout; +#[cfg(test)] +mod tests_textstroke; diff --git a/crates/pdf/src/tests_textstroke.rs b/crates/pdf/src/tests_textstroke.rs new file mode 100644 index 000000000..b6fd50347 --- /dev/null +++ b/crates/pdf/src/tests_textstroke.rs @@ -0,0 +1,39 @@ +//! Strokes on type keep every stroke option in PDF: the characters' cap, join, miter limit and +//! dash array, and the alignment of the object's own strokes. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_geom::Point; + +use crate::*; + +#[test] +fn text_strokes_keep_cap_join_miter_dashes_and_alignment() { + let style = CharStyle { + size: 48.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 1.5, + stroke_cap: LineCap::Round, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 7.0, + stroke_dash: Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() }), + ..Default::default() + }; + let mut d = Document::new(240.0, 100.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 70.0), "Wave", style)))); + let mut own = StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 255)), 3.0); + own.align = StrokeAlign::Inside; + n.appearance.items.push(AppearanceItem::Stroke(own)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let pdf = export(&d, &PdfOptions::uncompressed()).unwrap(); + assert!(String::from_utf8_lossy(&pdf).contains("7 M 1 J[4 2]0 d"), "the characters' miter limit, cap and dash array"); + let back = import(&pdf).unwrap(); + let mut strokes = vec![]; + back.walk(|n| strokes.extend(n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }))); + let chars = strokes.iter().find(|s| s.dash.is_some()).expect("the characters' stroke"); + assert_eq!((chars.cap, chars.join), (LineCap::Round, LineJoin::Miter)); + assert!((chars.miter_limit - 7.0).abs() < 1e-3, "miter limit {}", chars.miter_limit); + // An inside stroke is drawn twice as wide, clipped to the glyphs. + assert!(strokes.iter().any(|s| s.dash.is_none() && (s.width - 6.0).abs() < 1e-3), "{strokes:?}"); +} diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index 520a363a9..f61530388 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -11,9 +11,9 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; -use vectorcraft_color::freeform::spread_scale; +use vectorcraft_color::freeform::{painted_box, spread_scale}; use vectorcraft_color::{Color, Freeform, GradientPaint}; -use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_geom::{Affine, Rect}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; @@ -58,11 +58,9 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 { /// paints them). pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap { let field = f.field_with(spread_scale(b), &|c| ink.rgb(c)); - let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); - let data = (0..rows as usize * cols as usize) - .map(|k| { - let (i, j) = (k % cols as usize, k / cols as usize); - let ([r, g, bl], a) = field.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)); + let data = field + .grid(b, cols, rows) + .map(|([r, g, bl], a)| { let a = a.clamp(0.0, 1.0); let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8; vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 } @@ -79,13 +77,8 @@ fn cells_along(len: f64, side: f64, cells: f64) -> u16 { /// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current /// transform maps to pixels) as the context paint. Returns false for an empty box. pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool { - let b = bounds.abs(); + let Some(b) = painted_box(bounds) else { return false }; let side = b.width().max(b.height()); - if !(side > 1e-9 && side.is_finite()) { - return false; - } - // A box without height (or width) gets a sliver of it, so the grid maps back invertibly. - let b = Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))); let device = side * ctx.transform().determinant().abs().sqrt(); let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS); let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells)); diff --git a/docs/mcp.md b/docs/mcp.md index 07faf7e34..f2aedc17a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -193,6 +193,9 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. +Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its +dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the +document's raster effects resolution, clipped the same way. `document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), From cfed319af476309202a4676ee04322601cb5d961 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:51:44 +0700 Subject: [PATCH 3/3] M4.60: PDF export resamples and compresses images as the Compression settings say Colour, greyscale and monochrome (black-and-white) images placed above the threshold resolution are resampled to the target (average, subsample or bicubic) and compressed with ZIP, JPEG at the chosen quality, or automatically (JPEGs stay JPEG, the others lossless); images already as asked are embedded unchanged. JPEG 2000, CCITT, Run Length and no compression are written as ZIP with a warning when an image needs them; images with transparency stay lossless. Images no longer ask viewers to smooth them in PDF/A files. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/pdf/src/export.rs | 62 ++++++---- crates/pdf/src/images.rs | 171 +++++++++++++++++++++++++++- crates/pdf/src/lib.rs | 6 +- crates/pdf/src/settings.rs | 5 - crates/pdf/src/tests_compression.rs | 95 ++++++++++++++++ crates/pdf/src/tests_settings.rs | 11 +- docs/mcp.md | 4 + 8 files changed, 324 insertions(+), 32 deletions(-) create mode 100644 crates/pdf/src/tests_compression.rs diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 9cd51faef..abd5fcc63 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "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?, 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}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, 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, passwords and PDF/A-2b at 2.0 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|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?, 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, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, 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, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5f519edff..8f1b4e040 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -103,6 +103,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { knockout: bool, /// Spot colours written with a Lab alternate ([`crate::lab_spot`]): colorant name, Lab values. lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, - /// Images the writer samples itself may ask viewers to smooth them (not in PDF/A). + /// Images may ask viewers to smooth them (not in PDF/A). interpolate: bool, + /// How images are resampled and compressed. + compression: &'a crate::CompressionSettings, } /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). @@ -1017,37 +1020,58 @@ impl Exporter<'_> { s.set_stroke(None); } - fn load_image(&mut self, key: &str) -> Option { - if let Some(i) = self.images.get(key) { + /// Image `key` placed `size` points wide and high, resampled and compressed as the Compression + /// settings say ([`crate::images::recode`]); cached by key (and size when images are + /// resampled). + fn load_image(&mut self, key: &str, size: (f64, f64)) -> Option { + let c = self.compression; + let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); + let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + if let Some(i) = self.images.get(&cache) { return i.clone(); } - let img = self.doc.images.get(key).and_then(|blob| { - let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { - "image/png" => Image::from_png(bytes.clone().into(), true).ok(), - "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), true).ok(), - "image/gif" => Image::from_gif(bytes.clone().into(), true).ok(), - "image/webp" => Image::from_webp(bytes.clone().into(), true).ok(), - _ => None, - }; - direct.or_else(|| { - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some(Image::from_rgba8(rgba.into_raw(), w, h)) - }) + let interpolate = self.interpolate; + let doc = self.doc; + let img = doc.images.get(key).and_then(|blob| { + let r = crate::images::recode(&blob.bytes, size, c, interpolate); + if let Some(w) = r.warning { + self.warn(w); + } + r.image.or_else(|| embed(blob, interpolate)) }); if img.is_none() { self.warn(format!("image '{key}' could not be decoded and was skipped")); } - self.images.insert(key.to_string(), img.clone()); + self.images.insert(cache, img.clone()); img } fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) { - let Some(img) = self.load_image(&im.key) else { return }; + // The size it is placed at: its pixel grid's sides through its transform. + let [a, b, c, d, ..] = im.xf.as_coeffs(); + let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)); + let Some(img) = self.load_image(&im.key, size) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); s.pop(); } } + +/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), +/// other formats decoded. +fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option { + let bytes = blob.bytes.as_ref().clone(); + let direct = match blob.mime.as_str() { + "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), + "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), + "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), + "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), + _ => None, + }; + direct.or_else(|| { + let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); + let (w, h) = rgba.dimensions(); + Some(Image::from_rgba8(rgba.into_raw(), w, h)) + }) +} diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 385bdbfb1..43a309d9b 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -1,10 +1,21 @@ -//! Images the writer makes itself: pixels it samples (freeform gradients) as PDF image -//! XObjects. +//! Images in the PDF: the document's images as the Compression settings say (resampled above a +//! resolution, compressed with ZIP or JPEG), and pixels the writer makes itself (freeform +//! gradients). +//! +//! Every image is classified as the settings are split: colour, greyscale (a grey colour type) or +//! monochrome (greyscale that is only black and white). An image already as asked (not resampled, +//! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded, +//! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless. +use std::io::Cursor; use std::sync::Arc; +use image::imageops::FilterType; +use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader}; use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; + /// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel. #[derive(Clone, Hash)] struct Raw { @@ -55,3 +66,159 @@ pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok() } + +/// The kinds of images the Compression section has settings for. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Color, + Gray, + Mono, +} + +/// How an image's pixels are compressed in the file. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Codec { + /// JPEG images stay JPEG (at this quality when they are resampled); the others are lossless. + Auto(u8), + Zip, + Jpeg(u8), +} + +/// The settings for one kind of image. +struct Rule { + downsample: Downsample, + /// Target resolution, and the resolution above which images are resampled (ppi). + ppi: f64, + above: f64, + codec: Codec, + /// Why the codec differs from the one asked for (that one isn't available). + instead: Option<&'static str>, +} + +const UNCOMPRESSED: &str = "uncompressed images are not written: images are compressed with ZIP"; + +/// JPEG quality (1–100) of a quality setting. +fn quality(q: JpegQuality) -> u8 { + match q { + JpegQuality::Minimum => 10, + JpegQuality::Low => 30, + JpegQuality::Medium => 55, + JpegQuality::High => 80, + JpegQuality::Maximum => 95, + } +} + +fn rule(c: &CompressionSettings, kind: Kind) -> Rule { + if kind == Kind::Mono { + let m = &c.mono; + let instead = match m.compression { + MonoCodec::Zip => None, + MonoCodec::None => Some(UNCOMPRESSED), + MonoCodec::CcittG3 | MonoCodec::CcittG4 => Some("CCITT compression is not supported: monochrome images are compressed with ZIP"), + MonoCodec::RunLength => Some("Run Length compression is not supported: monochrome images are compressed with ZIP"), + }; + return Rule { downsample: m.downsample, ppi: m.ppi, above: m.above_ppi, codec: Codec::Zip, instead }; + } + let i = if kind == Kind::Color { &c.color } else { &c.gray }; + let (codec, instead) = match i.compression { + ImageCodec::Auto => (Codec::Auto(quality(i.quality)), None), + ImageCodec::Zip => (Codec::Zip, None), + ImageCodec::Jpeg => (Codec::Jpeg(quality(i.quality)), None), + ImageCodec::None => (Codec::Zip, Some(UNCOMPRESSED)), + ImageCodec::Jpeg2000 => (Codec::Zip, Some("JPEG 2000 compression is not supported: those images are compressed with ZIP")), + }; + Rule { downsample: i.downsample, ppi: i.ppi, above: i.above_ppi, codec, instead } +} + +/// What [`recode`] makes of an image. +pub(crate) struct Recoded { + /// The image to write; `None` keeps the source image as it is. + pub image: Option, + /// Set when the image isn't compressed as asked. + pub warning: Option<&'static str>, +} + +/// Is `img` (a grey image) only black and white? +fn bilevel(img: &DynamicImage) -> bool { + img.to_luma_alpha8().pixels().all(|p| p[0] == 0 || p[0] == 255) +} + +/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: +/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG. +/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool) -> Recoded { + let keep = Recoded { image: None, warning: None }; + let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; + let jpeg = reader.format() == Some(ImageFormat::Jpeg); + let Ok(decoder) = reader.into_decoder() else { return keep }; + let (w, h) = decoder.dimensions(); + // Resolution along each side as placed; above the threshold, a side goes down to the target. + let ppi = |px: u32, pt: f64| px as f64 * 72.0 / pt.abs(); + let (px, py) = (ppi(w, size.0), ppi(h, size.1)); + let sized = |r: &Rule| { + let resample = r.downsample != Downsample::None && [px, py].iter().any(|p| p.is_finite() && *p > r.above); + let side = |n: u32, p: f64| if resample && p.is_finite() && p > r.ppi { ((n as f64 * r.ppi / p).round() as u32).clamp(1, n) } else { n }; + (side(w, px), side(h, py)) + }; + // Already as asked: not resampled, and lossless or a JPEG kept as JPEG. + let kept = |r: &Rule| { + sized(r) == (w, h) + && match r.codec { + Codec::Auto(_) => true, + Codec::Zip => !jpeg, + Codec::Jpeg(_) => jpeg, + } + }; + use ExtendedColorType as T; + let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); + let (kind, pixels) = if grey { + // Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it. + let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono)); + if kept(&g) && kept(&m) && g.instead == m.instead { + return Recoded { image: None, warning: g.instead }; + } + let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; + (if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img)) + } else { + (Kind::Color, None) + }; + let rule = rule(c, kind); + if kept(&rule) { + return Recoded { image: None, warning: rule.instead }; + } + let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep }; + let (nw, nh) = sized(&rule); + let img = if (nw, nh) == (w, h) { + img + } else { + match rule.downsample { + Downsample::Average => img.thumbnail_exact(nw, nh), + Downsample::Subsample => img.resize_exact(nw, nh, FilterType::Nearest), + Downsample::Bicubic | Downsample::None => img.resize_exact(nw, nh, FilterType::CatmullRom), + } + }; + let mut rgba = img.to_rgba8(); + if kind == Kind::Mono { + // Resampling greys the edges: back to black and white. + for p in rgba.pixels_mut() { + let v = if p[0] >= 128 { 255 } else { 0 }; + (p[0], p[1], p[2]) = (v, v, v); + } + } + let quality = match rule.codec { + Codec::Jpeg(q) => Some(q), + Codec::Auto(q) if jpeg => Some(q), + _ => None, + }; + let image = quality.filter(|_| rgba.pixels().all(|p| p[3] == 255)).and_then(|q| to_jpeg(&img, kind != Kind::Color, q, interpolate)); + let (width, height) = rgba.dimensions(); + Recoded { image: image.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate)), warning: rule.instead } +} + +/// Opaque `img` as a JPEG image at `quality` (a greyscale JPEG with `grey`). +fn to_jpeg(img: &DynamicImage, grey: bool, quality: u8, interpolate: bool) -> Option { + let img = if grey { DynamicImage::ImageLuma8(img.to_luma8()) } else { DynamicImage::ImageRgb8(img.to_rgb8()) }; + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&img).ok()?; + Image::from_jpeg(out.into(), interpolate).ok() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 6bb3692fb..614e58625 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,8 +5,8 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and -//! freeform gradients as images (clipped to what they paint), embedded images and text as -//! outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! freeform gradients as images (clipped to what they paint), embedded images (resampled and +//! compressed as the Compression settings say) and text as outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). @@ -156,6 +156,8 @@ mod tests_charstroke; #[cfg(test)] mod tests_cmykblend; #[cfg(test)] +mod tests_compression; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_editing; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 6745c9e9b..b472646b9 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -433,17 +433,12 @@ impl PdfSettings { /// Options that are accepted but not applied by the writer yet, one warning each. pub fn warnings(&self) -> Vec { let d = Self::default(); - let c = &self.compression; let o = &self.output; let s = &self.security; [ (self.thumbnails, "page thumbnails are not embedded yet"), (self.fast_web_view, "fast web view (a linearised file) is not written yet"), (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), - ( - c.color != d.compression.color || c.gray != d.compression.gray || c.mono != d.compression.mono, - "image downsampling and compression settings are not applied yet: images are embedded unchanged", - ), (self.marks.any(), "printer's marks are not drawn yet"), (!self.bleed.use_document && self.bleed.values().iter().any(|v| *v > 0.0), "bleed is not added yet: each page is its artboard"), (o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"), diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs new file mode 100644 index 000000000..c65742acc --- /dev/null +++ b/crates/pdf/src/tests_compression.rs @@ -0,0 +1,95 @@ +//! Image compression and downsampling: images above the threshold resolution are resampled to the +//! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the +//! codecs the writer lacks are written as ZIP with a warning. + +use std::io::Cursor; + +use image::{DynamicImage, ImageFormat}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::Affine; + +use crate::*; + +fn encoded(img: DynamicImage, format: ImageFormat) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), format).unwrap(); + out +} + +/// A 600 × 600 colour photo-like image. +fn colour() -> DynamicImage { + image::RgbImage::from_fn(600, 600, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x * y) % 251) as u8])).into() +} + +/// A document with image `bytes` (600 × 600 pixels) placed one inch wide: 600 ppi. +fn doc(bytes: Vec, mime: &str) -> Document { + let mut d = Document::new(100.0, 100.0); + d.images.insert("img".into(), ImageBlob::new(mime, bytes)); + let xf = Affine::scale(72.0 / 600.0); + let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None })); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// The PDF of `d` with Compression options `compression` (content streams left readable) and its +/// warnings. +fn pdf(d: &Document, compression: Value) -> (String, Vec) { + let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + (String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings) +} + +#[test] +fn images_above_the_threshold_are_resampled_to_the_target_resolution() { + let d = doc(encoded(colour(), ImageFormat::Png), "image/png"); + for method in ["average", "subsample", "bicubic"] { + let (text, warnings) = pdf(&d, json!({"color": {"downsample": method, "ppi": 150, "abovePpi": 225}})); + assert!(text.contains("/Width 150") && text.contains("/Height 150"), "{method}"); + assert!(warnings.is_empty(), "{warnings:?}"); + } + // Below the threshold, or without downsampling, the image keeps its pixels. + for c in [json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 700}}), json!({})] { + assert!(pdf(&d, c.clone()).0.contains("/Width 600"), "{c}"); + } + // Greyscale images take the greyscale settings. + let grey = doc(encoded(colour().grayscale(), ImageFormat::Png), "image/png"); + let (text, _) = pdf(&grey, json!({"color": {"downsample": "bicubic", "ppi": 150}, "gray": {"downsample": "bicubic", "ppi": 100}})); + assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); +} + +#[test] +fn jpeg_zip_and_automatic_compression() { + let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); + let jpeg = doc(encoded(colour(), ImageFormat::Jpeg), "image/jpeg"); + let dct = |d: &Document, c: Value| pdf(d, c).0.contains("/DCTDecode"); + assert!(dct(&png, json!({"color": {"compression": "jpeg", "quality": "low"}})), "JPEG compression"); + assert!(!dct(&png, json!({"color": {"compression": "zip"}})) && pdf(&png, json!({})).0.contains("/FlateDecode"), "ZIP"); + assert!(!dct(&png, json!({})), "automatic: lossless for a PNG"); + assert!(dct(&jpeg, json!({})) && dct(&jpeg, json!({"color": {"downsample": "average", "ppi": 150}})), "automatic: a JPEG stays JPEG"); + assert!(!dct(&jpeg, json!({"color": {"compression": "zip"}})), "ZIP re-encodes a JPEG losslessly"); + // JPEG has no transparency: an image with some stays lossless. + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + let clear = doc(encoded(clear.into(), ImageFormat::Png), "image/png"); + let (text, _) = pdf(&clear, json!({"color": {"compression": "jpeg"}})); + assert!(!text.contains("/DCTDecode") && text.contains("/SMask")); +} + +#[test] +fn codecs_the_writer_lacks_are_zip_with_a_warning() { + let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); + let (text, warnings) = pdf(&png, json!({"color": {"compression": "jpeg2000"}})); + assert!(!text.contains("/JPXDecode") && text.contains("/FlateDecode")); + assert!(warnings.len() == 1 && warnings[0].contains("JPEG 2000"), "{warnings:?}"); + // Black-and-white images are monochrome: their settings apply, CCITT becomes ZIP. + let bw = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 7 + y / 5) % 2 == 0 { 0 } else { 255 }])); + let bw = doc(encoded(bw.into(), ImageFormat::Png), "image/png"); + let (text, warnings) = pdf(&bw, json!({"mono": {"downsample": "bicubic", "ppi": 300, "abovePpi": 450, "compression": "ccittG4"}})); + assert!(text.contains("/Width 300") && !text.contains("/CCITTFaxDecode")); + assert!(warnings.len() == 1 && warnings[0].contains("CCITT"), "{warnings:?}"); + // The codecs of other kinds of images say nothing. + assert!(pdf(&png, json!({"mono": {"compression": "ccittG4"}})).1.is_empty()); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index 237b5f70c..cf4deca6c 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -74,7 +74,6 @@ fn options_not_applied_yet_come_back_as_warnings() { (json!({"thumbnails": true}), "thumbnails"), (json!({"fastWebView": true}), "fast web view"), (json!({"createLayers": true}), "layers"), - (json!({"compression": {"color": {"compression": "jpeg"}}}), "image"), (json!({"marks": {"registration": true}}), "marks"), (json!({"bleed": {"top": 9}}), "bleed"), (json!({"output": {"conversion": "destination"}}), "conversion"), @@ -86,8 +85,14 @@ fn options_not_applied_yet_come_back_as_warnings() { let w = settings(v.clone()).warnings(); assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}"); } - // A document bleed, the default view/overprint choices and applied options warn about nothing. - for v in [json!({"bleed": {"useDocument": true, "top": 9}}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}})] { + // A document bleed, the default view/overprint choices and applied options (image compression + // too: a codec the writer lacks is reported when an image needs it) warn about nothing. + for v in [ + json!({"bleed": {"useDocument": true, "top": 9}}), + json!({"viewAfterSaving": true}), + json!({"compression": {"compressText": false}}), + json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}), + ] { assert!(settings(v.clone()).warnings().is_empty(), "{v}"); } let r = diff --git a/docs/mcp.md b/docs/mcp.md index f2aedc17a..0a8015042 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,6 +196,10 @@ options accepted but not applied yet, and features approximated or left out. PDF Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the document's raster effects resolution, clipped the same way. +Images follow the `compression` settings of their kind (`color`, `gray`, or `mono` for black-and-white images): above +`abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed +with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others +lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them. `document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),