From ffc60d5ff89c15998b2cead278427c8393ac0393 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:40:18 +0700 Subject: [PATCH] M4.59: pattern strokes and freeform gradients keep their look in PDF Pattern strokes are written as the pattern's tiles clipped to the stroke's outline (dashes, caps, profile, arrowheads and alignment included) instead of mid-grey; freeform gradients on fills, strokes and type as an image of their colour field at the document raster effects resolution, clipped to what they paint, instead of their average colour. Both read back within 2% of the canvas at 150 ppi with no warning. Text strokes are covered for cap, miter limit, dash array and alignment; knockout groups keep their soft-masked form with a warning. The freeform box and grid sampling are shared with the renderer. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/freeform.rs | 19 +++ crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdf.rs | 13 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_pdffidelity.rs | 95 +++++++++++++++ crates/pdf/src/export.rs | 137 ++++++++++++++++++---- crates/pdf/src/images.rs | 57 +++++++++ crates/pdf/src/lib.rs | 8 +- crates/pdf/src/tests_textstroke.rs | 39 ++++++ crates/render/src/freeform.rs | 19 +-- docs/mcp.md | 3 + 11 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/tests_pdffidelity.rs create mode 100644 crates/pdf/src/images.rs create mode 100644 crates/pdf/src/tests_textstroke.rs diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 68641629..9dc8f063 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -87,6 +87,15 @@ pub fn spread_scale(b: Rect) -> f64 { b.width().abs().max(b.height().abs()) / 2.0 } +/// The box a freeform gradient painted over `bounds` is placed on: `bounds` with positive sides, a +/// side without length getting a sliver (so a grid over it maps back invertibly). `None` for an +/// empty or non-finite box. +pub fn painted_box(bounds: Rect) -> Option { + let b = bounds.abs(); + let side = b.width().max(b.height()); + (side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3)))) +} + /// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]). #[derive(Clone, Copy, Debug, PartialEq)] pub struct LineHit { @@ -371,6 +380,16 @@ impl Piece { } impl Field { + /// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and + /// opacity (as [`Self::sample`]). + pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); + (0..rows as usize * cols as usize).map(move |k| { + let (i, j) = (k % cols as usize, k / cols as usize); + self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)) + }) + } + /// Display RGB and opacity at `p`. pub fn sample(&self, p: Point) -> ([f32; 3], f32) { // (weight sum, weighted rgba) inside discs, and blending by distance outside them. diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ea8f6a5a..9cd51fae 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)", + "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)", always, pdf_settings ), diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 18fe6f13..9fe8e9dc 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -107,16 +107,21 @@ fn options_not_applied_yet_and_document_features_warn() { let w = warnings(&export(&mut s, json!({"thumbnails": true, "marks": {"trim": true}}))); assert_eq!(w.len(), 2, "{w:?}"); assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("marks"))); - // A pattern stroke is approximated: its warning comes back from every PDF path. + // A pattern stroke is written as its tiles clipped to the stroke: nothing to report. let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [tile]})).unwrap(); s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); s.execute("object.pattern.done", &json!({})).unwrap(); let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap(); - let pattern = |w: Vec| w.iter().any(|w| w.contains("pattern strokes")); - assert!(pattern(warnings(&export(&mut s, json!({}))))); - assert!(pattern(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap()))); + assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0); + // A knockout group is approximated: its warning comes back from every PDF path. + s.execute("select.set", &json!({"ids": [frame]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); + let knockout = |w: Vec| w.iter().any(|w| w.contains("knockout groups")); + assert!(knockout(warnings(&export(&mut s, json!({}))))); + assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap()))); // Other formats have none. assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 038e8631..5678c79f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1073,6 +1073,8 @@ mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] +mod tests_pdffidelity; +#[cfg(test)] mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests_pdffidelity.rs new file mode 100644 index 00000000..21acfe4a --- /dev/null +++ b/crates/engine/src/tests_pdffidelity.rs @@ -0,0 +1,95 @@ +//! PDF vector fidelity: pattern strokes are written as their tiles clipped to the stroke's +//! outline, freeform gradients as images of their colour field clipped to what they paint, and +//! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Node}; +use vectorcraft_geom::Rect; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: &Value) -> Node { + s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone() +} + +/// `s`'s document with object `id`'s fills and strokes repainted. +fn repainted(s: &Session, id: &Value, fill: Option, stroke: Option) -> Document { + let mut d = (*s.doc().unwrap().doc).clone(); + for item in &mut d.node_mut(NodeId(id.as_u64().unwrap())).unwrap().appearance.items { + match (item, &fill, &stroke) { + (AppearanceItem::Fill(f), Some(p), _) => f.paint = p.clone(), + (AppearanceItem::Stroke(st), _, Some(p)) => st.paint = p.clone(), + _ => {} + } + } + d +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 150.0 / 72.0, true) +} + +/// Mean difference of the colour channels of two renders, as a share of full scale. +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +/// The PDF of `s`'s document: no warnings, an embedded image when `image`, and read back within 2% +/// of the canvas and much closer than `rough` (the art painted the old way). +fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) { + let d = &s.doc().unwrap().doc; + let r = crate::cmd::rasterfx::export_pdf_with_report(d, &Default::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + assert_eq!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"), image, "{what}: image XObject"); + let canvas = render(d); + let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&r.bytes).unwrap())); + let before = mean_diff(&canvas, &render(rough)); + assert!(diff < 0.02 && diff < before / 4.0, "{what}: read back {:.2}% off (the old way {:.2}%)", diff * 100.0, before * 100.0); +} + +#[test] +fn pattern_strokes_are_tiles_clipped_to_the_stroke() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [dot]})); + run(&mut s, "paint.setFill", json!({"color": "#d02020"})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 8, "height": 8})); + run(&mut s, "object.pattern.done", json!({})); + let frame = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 140, "height": 120}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [frame]})); + run(&mut s, "paint.setFill", json!({"color": "#2040c0"})); + run(&mut s, "stroke.set", json!({"weight": 16, "align": "outside", "dash": [30, 10]})); + run(&mut s, "paint.setStroke", json!({"swatch": "Dots"})); + // The old way: a mid-grey stroke. + let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey))); +} + +#[test] +fn freeform_gradients_are_images_of_their_colour_field() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut s, "shape.ellipse", json!({"x": 20, "y": 30, "width": 160, "height": 130}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [id]})); + run(&mut s, "stroke.set", json!({"weight": 14})); + run(&mut s, "paint.editGradient", json!({"kind": "freeform"})); + run(&mut s, "paint.editGradient", json!({"kind": "freeform", "stroke": true})); + // The old way: the average colour. + let Paint::Gradient(g) = node(&s, &id).appearance.fill_paint() else { panic!("a freeform fill") }; + let n = g.gradient.stops.len() as f32; + let c = g.gradient.stops.iter().fold([0.0f32; 3], |acc, st| { + let c = st.color.to_rgb(); + [acc[0] + c[0] / n, acc[1] + c[1] / n, acc[2] + c[2] / n] + }); + let avg = Paint::solid(Color::rgb(c[0], c[1], c[2])); + let rough = repainted(&s, &id, Some(avg.clone()), Some(avg)); + assert_faithful("freeform", &s, true, &rough); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 247d8d65..5f519edf 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -95,7 +95,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { knockout: bool, /// Spot colours written with a Lab alternate ([`crate::lab_spot`]): colorant name, Lab values. lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, + /// Images the writer samples itself may ask viewers to smooth them (not in PDF/A). + interpolate: bool, } +/// Most pixels along a side of a freeform gradient's image (its colour field is smooth). +const MAX_FIELD_PX: f64 = 512.0; + fn xf(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) @@ -484,6 +497,8 @@ impl Exporter<'_> { .into(), ) } + // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient + // slices) the average colour stands in. GradientKind::Freeform => { self.warn("freeform gradients are exported as their average colour"); let n = g.gradient.stops.len().max(1) as f32; @@ -498,8 +513,9 @@ impl Exporter<'_> { } } } + // Patterns paint through [`Self::area`]: only a missing one gets here. Paint::Pattern { .. } => { - self.warn("pattern strokes (and missing patterns) are exported as mid-grey"); + self.warn("missing patterns are exported as mid-grey"); Some(rgb::Color::new(128, 128, 128).into()) } } @@ -685,22 +701,7 @@ impl Exporter<'_> { continue; } self.warn_raster(&fl.effects, || "fills".into()); - // Pattern fills: the tile instances covering the shape, clipped to it. - let doc = self.doc; - if let Paint::Pattern { pattern, xf } = &fl.paint - && let Some(def) = doc.pattern(pattern) - { - s.push_clip_path(&path, &rule(r)); - if fl.opacity < 1.0 { - s.push_opacity(norm(fl.opacity)); - } - for inst in def.instances_in(*xf, bounds) { - self.node(s, &inst, bounds, true); - } - if fl.opacity < 1.0 { - s.pop(); - } - s.pop(); + if self.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) { continue; } let Some(paint) = self.paint(&fl.paint, bounds) else { continue }; @@ -728,6 +729,87 @@ impl Exporter<'_> { s.set_stroke(None); } + /// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or + /// a freeform gradient? + fn area_paint(&self, p: &Paint) -> bool { + match p { + Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), + Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform, + _ => false, + } + } + + /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, + /// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances + /// covering it, or an image of a freeform gradient placed on `bounds` (the painted object's + /// box, as on the canvas), clipped to it. False (nothing drawn) for other paints. + #[allow(clippy::too_many_arguments)] + fn area( + &mut self, + s: &mut Surface, + clip: &Path, + rule: krilla::paint::FillRule, + p: &Paint, + transparency: (f32, BlendMode), + bounds: Rect, + area: Rect, + ) -> bool { + if !self.area_paint(p) { + return false; + } + let (opacity, mode) = transparency; + let mut pushes = 0; + if mode != BlendMode::Normal { + s.push_blend_mode(blend(mode)); + pushes += 1; + } + s.push_clip_path(clip, &rule); + pushes += 1; + if opacity < 1.0 { + s.push_opacity(norm(opacity)); + pushes += 1; + } + let doc = self.doc; + match p { + Paint::Pattern { pattern, xf } => { + for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() { + self.node(s, &inst, area, true); + } + } + Paint::Gradient(g) => self.field_image(s, g, bounds, area), + _ => {} + } + for _ in 0..pushes { + s.pop(); + } + true + } + + /// Draw freeform gradient `g`, placed on `bounds`, as an image of its colour field over `area`, + /// sampled at the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side). + fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { + use vectorcraft_color::freeform::{painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let area = area.abs(); + let long = area.width().max(area.height()); + if !(long > 1e-9 && long.is_finite()) { + return; + } + let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; + let (cols, rows) = (along(area.width()), along(area.height())); + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + let rgba: Vec = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); + let (Some(img), Some(size)) = + (crate::images::from_rgba(&rgba, cols.into(), rows.into(), self.interpolate), Size::from_wh(area.width() as f32, area.height() as f32)) + else { + return; + }; + s.push_transform(&xf(Affine::translate(area.origin().to_vec2()))); + s.draw_image(img, size); + s.pop(); + } + /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its /// unplaced gradients. #[allow(clippy::too_many_arguments)] @@ -735,6 +817,16 @@ impl Exporter<'_> { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } + if self.area_paint(&st.paint) { + // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as + // Outline Stroke makes it, painted with the pattern's tiles or the freeform's image. + let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); + if let Some(clip) = to_path(®ion) { + let area = region.bounding_box(); + self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area); + } + return; + } let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); let mut pushes = 0; @@ -881,7 +973,10 @@ impl Exporter<'_> { bp.extend(g.outline.iter()); } let Some(path) = to_path(&bp) else { continue }; - if let Some(paint) = self.paint(&run.style.fill, layout.bounds) { + let opaque = (1.0, BlendMode::Normal); + if !self.area(s, &path, krilla::paint::FillRule::NonZero, &run.style.fill, opaque, layout.bounds, bp.bounding_box()) + && let Some(paint) = self.paint(&run.style.fill, layout.bounds) + { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); @@ -906,7 +1001,9 @@ impl Exporter<'_> { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => { - if let Some(paint) = self.paint(&fl.paint, tb) { + if !self.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box()) + && let Some(paint) = self.paint(&fl.paint, tb) + { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); s.draw_path(path); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs new file mode 100644 index 00000000..385bdbfb --- /dev/null +++ b/crates/pdf/src/images.rs @@ -0,0 +1,57 @@ +//! Images the writer makes itself: pixels it samples (freeform gradients) as PDF image +//! XObjects. + +use std::sync::Arc; + +use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; + +/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel. +#[derive(Clone, Hash)] +struct Raw { + color: Arc>, + alpha: Option>>, + width: u32, + height: u32, + luma: bool, +} + +impl CustomImage for Raw { + fn color_channel(&self) -> &[u8] { + &self.color + } + + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref().map(Vec::as_slice) + } + + fn bits_per_component(&self) -> BitsPerComponent { + BitsPerComponent::Eight + } + + fn size(&self) -> (u32, u32) { + (self.width, self.height) + } + + fn icc_profile(&self) -> Option<&[u8]> { + None + } + + fn color_space(&self) -> ImageColorspace { + if self.luma { ImageColorspace::Luma } else { ImageColorspace::Rgb } + } +} + +/// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB, or as +/// grey when every pixel is grey, with an alpha channel only when some pixel isn't opaque. +/// `interpolate` asks viewers to smooth it when enlarged (PDF/A forbids it). `None` when `rgba` +/// doesn't hold that many pixels. +pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) -> Option { + let px = rgba.as_chunks::<4>().0; + if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { + return None; + } + let luma = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]); + let color = if luma { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() }; + let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b24e19a4..6bb3692f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -4,8 +4,9 @@ //! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads, //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), -//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph -//! paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and +//! freeform gradients as images (clipped to what they paint), embedded images and text as +//! outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). @@ -22,6 +23,7 @@ mod editing; mod export; +mod images; mod import; mod import_color; mod lab_spot; @@ -173,3 +175,5 @@ mod tests_settings; mod tests_stroke; #[cfg(test)] mod tests_strokeout; +#[cfg(test)] +mod tests_textstroke; diff --git a/crates/pdf/src/tests_textstroke.rs b/crates/pdf/src/tests_textstroke.rs new file mode 100644 index 00000000..b6fd5034 --- /dev/null +++ b/crates/pdf/src/tests_textstroke.rs @@ -0,0 +1,39 @@ +//! Strokes on type keep every stroke option in PDF: the characters' cap, join, miter limit and +//! dash array, and the alignment of the object's own strokes. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_geom::Point; + +use crate::*; + +#[test] +fn text_strokes_keep_cap_join_miter_dashes_and_alignment() { + let style = CharStyle { + size: 48.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 1.5, + stroke_cap: LineCap::Round, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 7.0, + stroke_dash: Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() }), + ..Default::default() + }; + let mut d = Document::new(240.0, 100.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 70.0), "Wave", style)))); + let mut own = StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 255)), 3.0); + own.align = StrokeAlign::Inside; + n.appearance.items.push(AppearanceItem::Stroke(own)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let pdf = export(&d, &PdfOptions::uncompressed()).unwrap(); + assert!(String::from_utf8_lossy(&pdf).contains("7 M 1 J[4 2]0 d"), "the characters' miter limit, cap and dash array"); + let back = import(&pdf).unwrap(); + let mut strokes = vec![]; + back.walk(|n| strokes.extend(n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }))); + let chars = strokes.iter().find(|s| s.dash.is_some()).expect("the characters' stroke"); + assert_eq!((chars.cap, chars.join), (LineCap::Round, LineJoin::Miter)); + assert!((chars.miter_limit - 7.0).abs() < 1e-3, "miter limit {}", chars.miter_limit); + // An inside stroke is drawn twice as wide, clipped to the glyphs. + assert!(strokes.iter().any(|s| s.dash.is_none() && (s.width - 6.0).abs() < 1e-3), "{strokes:?}"); +} diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index 520a363a..f6153038 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -11,9 +11,9 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; -use vectorcraft_color::freeform::spread_scale; +use vectorcraft_color::freeform::{painted_box, spread_scale}; use vectorcraft_color::{Color, Freeform, GradientPaint}; -use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_geom::{Affine, Rect}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; @@ -58,11 +58,9 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 { /// paints them). pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap { let field = f.field_with(spread_scale(b), &|c| ink.rgb(c)); - let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); - let data = (0..rows as usize * cols as usize) - .map(|k| { - let (i, j) = (k % cols as usize, k / cols as usize); - let ([r, g, bl], a) = field.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)); + let data = field + .grid(b, cols, rows) + .map(|([r, g, bl], a)| { let a = a.clamp(0.0, 1.0); let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8; vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 } @@ -79,13 +77,8 @@ fn cells_along(len: f64, side: f64, cells: f64) -> u16 { /// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current /// transform maps to pixels) as the context paint. Returns false for an empty box. pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool { - let b = bounds.abs(); + let Some(b) = painted_box(bounds) else { return false }; let side = b.width().max(b.height()); - if !(side > 1e-9 && side.is_finite()) { - return false; - } - // A box without height (or width) gets a sliver of it, so the grid maps back invertibly. - let b = Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))); let device = side * ctx.transform().determinant().abs().sqrt(); let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS); let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells)); diff --git a/docs/mcp.md b/docs/mcp.md index 07faf7e3..f2aedc17 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -193,6 +193,9 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. +Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its +dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the +document's raster effects resolution, clipped the same way. `document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),