diff --git a/crates/pdf-canvas/src/path_geometry.rs b/crates/pdf-canvas/src/path_geometry.rs index 46fe26e5..33d86ed5 100644 --- a/crates/pdf-canvas/src/path_geometry.rs +++ b/crates/pdf-canvas/src/path_geometry.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use pdf_graphics::{ pdf_path::{PathVerb, PdfPath}, point::Point, + rect::Rect, transform::{Transform, TransformError}, }; @@ -124,6 +125,50 @@ impl<'a> CanvasPath<'a> { }) } + /// Returns the rectangle this path covers when it is one subpath of four corners whose + /// edges stay horizontal and vertical after mapping through `to_pixels`. + /// + /// The subpath may end with a segment back to its first corner and may be closed; + /// curves, further subpaths, or more corners yield `None`. + pub fn axis_aligned_rect(&self, to_pixels: &Transform) -> Result, TransformError> { + let mut corners = [Point::default(); 5]; + let mut len = 0_usize; + let mut closed = false; + for verb in self.verbs() { + let point = match verb? { + _ if closed => return Ok(None), + PathVerb::MoveTo { x, y } if len == 0 => Point::new(x, y), + PathVerb::LineTo { x, y } if len > 0 => Point::new(x, y), + PathVerb::Close if len > 0 => { + closed = true; + continue; + } + _ => return Ok(None), + }; + let Some(corner) = corners.get_mut(len) else { + return Ok(None); + }; + *corner = to_pixels.try_map_point(point)?; + len = len.saturating_add(1); + } + let [a, b, c, d] = match (len, corners) { + (4, [a, b, c, d, _]) => [a, b, c, d], + (5, [a, b, c, d, e]) if e == a => [a, b, c, d], + _ => return Ok(None), + }; + let horizontal_first = a.y == b.y && b.x == c.x && c.y == d.y && d.x == a.x; + let vertical_first = a.x == b.x && b.y == c.y && c.x == d.x && d.y == a.y; + Ok((horizontal_first || vertical_first).then(|| { + Rect { + left: a.x, + top: a.y, + right: c.x, + bottom: c.y, + } + .normalized() + })) + } + /// Materializes a mutable device-space path, validating all mapped coordinates. pub fn to_pdf_path(&self) -> Result { let mut path = PdfPath::default(); diff --git a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs index b05ecd61..a43447fb 100644 --- a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs +++ b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs @@ -449,6 +449,34 @@ fn make_paint( paint } +impl SkiaCanvasBackend<'_> { + /// Fills `path` without anti-aliasing, snapped to the nearest pixel edges, when it is + /// a rectangle aligned with the pixel grid, so thin bars stay solid and separate. + /// Returns whether the path was such a rectangle. + /// + /// See [`pdf_graphics::rect::Rect::snapped_to_pixels`]. + fn fill_pixel_rect( + &mut self, + path: &CanvasPath<'_>, + paint: &mut skia_safe::Paint, + ) -> Result { + let canvas = self.surface.canvas(); + let to_pixels = from_skia_matrix(&canvas.local_to_device_as_3x3()); + let Some(rect) = path.axis_aligned_rect(&to_pixels)? else { + return Ok(false); + }; + let rect = rect.snapped_to_pixels(); + // The snapped edges are whole numbers, so rounding only changes the type. + let pixels = skia_safe::Rect::new(rect.left, rect.top, rect.right, rect.bottom).round(); + paint.set_anti_alias(false); + canvas.save(); + canvas.reset_matrix(); + canvas.draw_irect(pixels, paint); + canvas.restore(); + Ok(true) + } +} + impl CanvasBackend for SkiaCanvasBackend<'_> { /// Fills the supplied device-space path using the requested paint and fill rule. fn fill_path( @@ -459,9 +487,12 @@ impl CanvasBackend for SkiaCanvasBackend<'_> { shader: Option<&Shader>, blend_mode: Option, ) -> Result<(), PdfCanvasError> { + let mut paint = make_paint(color, skia_safe::paint::Style::Fill, None, blend_mode); + if shader.is_none() && self.fill_pixel_rect(path, &mut paint)? { + return Ok(()); + } let mut sk_path = to_skia_path(path, true)?; sk_path.set_fill_type(to_skia_fill_type(fill_type)); - let mut paint = make_paint(color, skia_safe::paint::Style::Fill, None, blend_mode); if let Some(shader_spec) = shader { let mapping = from_skia_matrix(&self.surface.canvas().local_to_device_as_3x3()); paint.set_shader(to_skia_shader(shader_spec, &mapping)?); diff --git a/crates/pdf-graphics/src/rect.rs b/crates/pdf-graphics/src/rect.rs index 0c273e5a..eec600c5 100644 --- a/crates/pdf-graphics/src/rect.rs +++ b/crates/pdf-graphics/src/rect.rs @@ -47,6 +47,25 @@ impl Rect { } } + /// Returns this rectangle with each edge moved to the nearest pixel boundary, keeping + /// at least one pixel per axis, so thin bars stay solid and the gaps between them + /// stay open. + pub fn snapped_to_pixels(&self) -> Self { + let snap = |start: f32, end: f32| { + let (low, high) = (start.round(), end.round()); + (low, if high == low { low + 1.0 } else { high }) + }; + let rect = self.normalized(); + let (left, right) = snap(rect.left, rect.right); + let (top, bottom) = snap(rect.top, rect.bottom); + Self { + left, + top, + right, + bottom, + } + } + pub fn width(&self) -> f32 { self.right - self.left } diff --git a/tools/conformance/README.md b/tools/conformance/README.md index 6ce30904..8e22b220 100644 --- a/tools/conformance/README.md +++ b/tools/conformance/README.md @@ -66,8 +66,9 @@ image's darkness lies within the range of the other's 3x3 neighbourhood, widened `GLYPH_COVERAGE_SLACK` (40%, in `src/compare.rs`). Then it decides pass or fail again. Accepted pixels are drawn yellow. -Differences outside glyph bounds are compared as before, including vector shapes such as barcode -bars that PDFium snaps to pixels. Missing or extra glyphs, wrong glyphs or colors, and shapes off +Differences outside glyph bounds are compared as before, including vector shapes. Safe-PDF snaps +a solid axis-aligned rectangle to whole pixels without anti-aliasing, so barcode bars stay crisp, +though their edges may still differ from PDFium's by a pixel. Missing or extra glyphs, wrong glyphs or colors, and shapes off by more than a pixel still count. ## Commands