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),