mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.59: pattern strokes and freeform gradients keep their look in PDF
Pattern strokes are written as the pattern's tiles clipped to the stroke's outline (dashes, caps, profile, arrowheads and alignment included) instead of mid-grey; freeform gradients on fills, strokes and type as an image of their colour field at the document raster effects resolution, clipped to what they paint, instead of their average colour. Both read back within 2% of the canvas at 150 ppi with no warning. Text strokes are covered for cap, miter limit, dash array and alignment; knockout groups keep their soft-masked form with a warning. The freeform box and grid sampling are shared with the renderer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e967c31be6
commit
ffc60d5ff8
11 files changed
+354
-40
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1073,6 +1073,8 @@ mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_pdffidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfraster;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+117
-20
@@ -95,7 +95,15 @@ 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,
|
||||
};
|
||||
// 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 +227,13 @@ 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 the writer samples itself may ask viewers to smooth them (not in PDF/A).
|
||||
interpolate: bool,
|
||||
}
|
||||
|
||||
/// Most pixels along a side of a freeform gradient's image (its colour field is smooth).
|
||||
const MAX_FIELD_PX: f64 = 512.0;
|
||||
|
||||
fn xf(a: Affine) -> Transform {
|
||||
let c = a.as_coeffs();
|
||||
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
||||
@@ -484,6 +497,8 @@ impl Exporter<'_> {
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
// Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient
|
||||
// slices) the average colour stands in.
|
||||
GradientKind::Freeform => {
|
||||
self.warn("freeform gradients are exported as their average colour");
|
||||
let n = g.gradient.stops.len().max(1) as f32;
|
||||
@@ -498,8 +513,9 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Patterns paint through [`Self::area`]: only a missing one gets here.
|
||||
Paint::Pattern { .. } => {
|
||||
self.warn("pattern strokes (and missing patterns) are exported as mid-grey");
|
||||
self.warn("missing patterns are exported as mid-grey");
|
||||
Some(rgb::Color::new(128, 128, 128).into())
|
||||
}
|
||||
}
|
||||
@@ -685,22 +701,7 @@ impl Exporter<'_> {
|
||||
continue;
|
||||
}
|
||||
self.warn_raster(&fl.effects, || "fills".into());
|
||||
// Pattern fills: the tile instances covering the shape, clipped to it.
|
||||
let doc = self.doc;
|
||||
if let Paint::Pattern { pattern, xf } = &fl.paint
|
||||
&& let Some(def) = doc.pattern(pattern)
|
||||
{
|
||||
s.push_clip_path(&path, &rule(r));
|
||||
if fl.opacity < 1.0 {
|
||||
s.push_opacity(norm(fl.opacity));
|
||||
}
|
||||
for inst in def.instances_in(*xf, bounds) {
|
||||
self.node(s, &inst, bounds, true);
|
||||
}
|
||||
if fl.opacity < 1.0 {
|
||||
s.pop();
|
||||
}
|
||||
s.pop();
|
||||
if self.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
|
||||
continue;
|
||||
}
|
||||
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
|
||||
@@ -728,6 +729,87 @@ impl Exporter<'_> {
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
/// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or
|
||||
/// a freeform gradient?
|
||||
fn area_paint(&self, p: &Paint) -> bool {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
|
||||
Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity,
|
||||
/// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances
|
||||
/// covering it, or an image of a freeform gradient placed on `bounds` (the painted object's
|
||||
/// box, as on the canvas), clipped to it. False (nothing drawn) for other paints.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn area(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
clip: &Path,
|
||||
rule: krilla::paint::FillRule,
|
||||
p: &Paint,
|
||||
transparency: (f32, BlendMode),
|
||||
bounds: Rect,
|
||||
area: Rect,
|
||||
) -> bool {
|
||||
if !self.area_paint(p) {
|
||||
return false;
|
||||
}
|
||||
let (opacity, mode) = transparency;
|
||||
let mut pushes = 0;
|
||||
if mode != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(mode));
|
||||
pushes += 1;
|
||||
}
|
||||
s.push_clip_path(clip, &rule);
|
||||
pushes += 1;
|
||||
if opacity < 1.0 {
|
||||
s.push_opacity(norm(opacity));
|
||||
pushes += 1;
|
||||
}
|
||||
let doc = self.doc;
|
||||
match p {
|
||||
Paint::Pattern { pattern, xf } => {
|
||||
for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() {
|
||||
self.node(s, &inst, area, true);
|
||||
}
|
||||
}
|
||||
Paint::Gradient(g) => self.field_image(s, g, bounds, area),
|
||||
_ => {}
|
||||
}
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Draw freeform gradient `g`, placed on `bounds`, as an image of its colour field over `area`,
|
||||
/// sampled at the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side).
|
||||
fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) {
|
||||
use vectorcraft_color::freeform::{painted_box, spread_scale};
|
||||
let Some(b) = painted_box(bounds) else { return };
|
||||
let area = area.abs();
|
||||
let long = area.width().max(area.height());
|
||||
if !(long > 1e-9 && long.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
|
||||
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
|
||||
let (cols, rows) = (along(area.width()), along(area.height()));
|
||||
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
|
||||
let rgba: Vec<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 +817,16 @@ impl Exporter<'_> {
|
||||
if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
|
||||
return;
|
||||
}
|
||||
if self.area_paint(&st.paint) {
|
||||
// The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as
|
||||
// Outline Stroke makes it, painted with the pattern's tiles or the freeform's image.
|
||||
let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath();
|
||||
if let Some(clip) = to_path(®ion) {
|
||||
let area = region.bounding_box();
|
||||
self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
|
||||
let w = stroke::for_writer(bp, st);
|
||||
let mut pushes = 0;
|
||||
@@ -881,7 +973,10 @@ impl Exporter<'_> {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
let Some(path) = to_path(&bp) else { continue };
|
||||
if let Some(paint) = self.paint(&run.style.fill, layout.bounds) {
|
||||
let opaque = (1.0, BlendMode::Normal);
|
||||
if !self.area(s, &path, krilla::paint::FillRule::NonZero, &run.style.fill, opaque, layout.bounds, bp.bounding_box())
|
||||
&& let Some(paint) = self.paint(&run.style.fill, layout.bounds)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(&path);
|
||||
@@ -906,7 +1001,9 @@ impl Exporter<'_> {
|
||||
for item in items {
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if fl.visible => {
|
||||
if let Some(paint) = self.paint(&fl.paint, tb) {
|
||||
if !self.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
|
||||
&& let Some(paint) = self.paint(&fl.paint, tb)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(path);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Images the writer makes itself: pixels it samples (freeform gradients) as PDF image
|
||||
//! XObjects.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
|
||||
/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel.
|
||||
#[derive(Clone, Hash)]
|
||||
struct Raw {
|
||||
color: Arc<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()
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
//! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads,
|
||||
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
|
||||
//! as their brush art), opacity and blend modes (transparency groups),
|
||||
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
|
||||
//! paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF
|
||||
//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and
|
||||
//! freeform gradients as images (clipped to what they paint), embedded images and text as
|
||||
//! outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF
|
||||
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
|
||||
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
|
||||
//! Editing the native document rides along as an embedded file ([`editing()`]).
|
||||
@@ -22,6 +23,7 @@
|
||||
|
||||
mod editing;
|
||||
mod export;
|
||||
mod images;
|
||||
mod import;
|
||||
mod import_color;
|
||||
mod lab_spot;
|
||||
@@ -173,3 +175,5 @@ mod tests_settings;
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeout;
|
||||
#[cfg(test)]
|
||||
mod tests_textstroke;
|
||||
@@ -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));
|
||||
|
||||
@@ -193,6 +193,9 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili
|
||||
`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf`
|
||||
documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`:
|
||||
options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused.
|
||||
Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its
|
||||
dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the
|
||||
document's raster effects resolution, clipped the same way.
|
||||
`document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file.
|
||||
|
||||
Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),
|
||||
|
||||
Reference in new issue
Block a user