mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(®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<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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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}}}
|
||||
|
||||
Reference in New Issue
Block a user