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/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/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ea8f6a5ae..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 ), @@ -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 2156a2946..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()); } @@ -158,10 +163,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..5678c79f7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1073,8 +1073,12 @@ mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] +mod tests_pdffidelity; +#[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_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/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..8f1b4e040 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -95,7 +95,16 @@ 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 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). +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) @@ -391,9 +407,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())); @@ -484,6 +500,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 +516,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 +704,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 +732,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 +820,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 +976,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 +1004,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); @@ -920,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 new file mode 100644 index 000000000..43a309d9b --- /dev/null +++ b/crates/pdf/src/images.rs @@ -0,0 +1,224 @@ +//! 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 { + 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() +} + +/// 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 b24e19a4a..614e58625 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 (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()`]). @@ -22,6 +23,7 @@ mod editing; mod export; +mod images; mod import; mod import_color; mod lab_spot; @@ -154,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; @@ -173,3 +177,5 @@ mod tests_settings; mod tests_stroke; #[cfg(test)] mod tests_strokeout; +#[cfg(test)] +mod tests_textstroke; 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/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/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..0a8015042 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -193,6 +193,13 @@ 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. +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), @@ -456,7 +463,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 +944,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}}}