Merge pull request #92 from storytold/pr/m4-pdf-fidelity

M4: PDF keeps raster effects everywhere, pattern strokes and freeform gradients; image resampling and compression (M4.58–M4.60)
This commit is contained in:
BFlat
2026-10-05 11:59:45 +07:00
committed by GitHub
20 changed files with 1078 additions and 210 deletions
+19
View File
@@ -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<Rect> {
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<Item = ([f32; 3], f32)> + '_ {
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.
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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};
+7 -68
View File
@@ -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<CommandSpec> {
@@ -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<effects::RasterFx> = 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,
+2 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, 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<CommandSpec> {
"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
),
+13 -5
View File
@@ -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<String>| 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<String>| 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]
+226 -67
View File
@@ -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<RasterFx> {
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<Node>, 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<Node> {
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<Node> {
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<RasterFx> {
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<Node> {
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<Node> {
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<Node> {
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<Node> {
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<Arc<Node>> = 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<Node> {
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<Node>]) -> Option<Vec<Arc<Node>>> {
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<Document> {
if !doc.layers.iter().any(|l| needs(l)) {
let any = |nodes: &[Arc<Node>]| 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)
}
+4
View File
@@ -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;
+95
View File
@@ -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<Paint>, stroke: Option<Paint>) -> 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);
}
+149
View File
@@ -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<u8> {
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");
}
}
+162 -41
View File
@@ -95,7 +95,16 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
}
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None, knockout: doc.page_knockout, lab_spots: vec![] };
let mut ex = Exporter {
doc,
warnings: vec![],
images: HashMap::new(),
brushes: None,
knockout: doc.page_knockout,
lab_spots: vec![],
interpolate: set.standard != Standard::PdfA2b,
compression: &set.compression,
};
// CMYK documents blend in CMYK, as on screen: their groups' blending space is rewritten (see
// `cmyk_blending`), and transparency at the top of a page is put in a non-isolated group of
// its own (the PDF writer has no page group attributes).
@@ -219,8 +228,15 @@ struct Exporter<'a> {
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<u8> = 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(&region) {
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<Image> {
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<Image> {
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<Image> {
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))
})
}
+224
View File
@@ -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<Vec<u8>>,
alpha: Option<Arc<Vec<u8>>>,
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<Image> {
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<Image>,
/// 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<Image> {
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()
}
+8 -2
View File
@@ -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;
-5
View File
@@ -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<String> {
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"),
+95
View File
@@ -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<u8> {
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<u8>, 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<String>) {
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());
}
+8 -3
View File
@@ -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 =
+39
View File
@@ -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:?}");
}
+6 -13
View File
@@ -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));
+6
View File
@@ -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<Rect> {
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;
+12 -2
View File
@@ -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}}}