From 845214d93c1c17d3ae98f94115c5e20df0c2a3d7 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sat, 31 Oct 2026 15:15:59 +0200 Subject: [PATCH 1/3] Fill pixel-aligned rectangles over whole pixels without anti-aliasing A solid fill of a single rectangle that stays axis-aligned in device space now covers every pixel it touches, at least one pixel wide and tall, without anti-aliasing. Thin barcode bars drawn with `re f` stay solid instead of turning grey and narrow, matching PDFium. Co-Authored-By: Claude Opus 5.5 --- crates/pdf-canvas/src/path_geometry.rs | 45 +++++++++++++++++++ .../src/skia_canvas_backend.rs | 35 ++++++++++++++- tools/conformance/README.md | 5 ++- 3 files changed, 82 insertions(+), 3 deletions(-) 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..ffe213ca 100644 --- a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs +++ b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs @@ -13,6 +13,7 @@ use pdf_graphics::{ size::Size, transform::Transform, }; use pdf_shading::paint::ShadingPaint; +use skia_safe::RoundOut; /// Native allocation, conversion, and mask replay failures. #[derive(Debug, thiserror::Error)] @@ -449,6 +450,35 @@ fn make_paint( paint } +impl SkiaCanvasBackend<'_> { + /// Fills `path` without anti-aliasing over every pixel it touches when it is a + /// rectangle aligned with the pixel grid, at least one pixel wide and tall, so thin + /// bars stay solid. Returns whether the path was such a rectangle. + 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 mut pixels: skia_safe::IRect = + skia_safe::Rect::new(rect.left, rect.top, rect.right, rect.bottom).round_out(); + if pixels.is_empty() { + pixels.right = pixels.left.saturating_add(1).max(pixels.right); + pixels.bottom = pixels.top.saturating_add(1).max(pixels.bottom); + } + 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 +489,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/tools/conformance/README.md b/tools/conformance/README.md index 6ce30904..b1b0b303 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. Like PDFium, +Safe-PDF fills a solid axis-aligned rectangle over every pixel it touches without anti-aliasing, +so barcode bars match. Missing or extra glyphs, wrong glyphs or colors, and shapes off by more than a pixel still count. ## Commands From 6662576452bb9c790ae4cc38082eaaf79499ccd7 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sat, 31 Oct 2026 15:21:28 +0200 Subject: [PATCH 2/3] Trim snapped rectangles on the side with more slack Rounding a rectangle out to every pixel it touches can make a 1.13 px bar 3 px wide and close the gap next to it, so narrow barcodes filled in as solid blocks. When the outer pixel span is a full pixel wider than the rectangle's rounded-up size, drop the pixel on the side with more slack, as PDFium does. Co-Authored-By: Claude Opus 5.5 --- .../src/skia_canvas_backend.rs | 16 ++++------ crates/pdf-graphics/src/rect.rs | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs index ffe213ca..75606486 100644 --- a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs +++ b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs @@ -13,7 +13,6 @@ use pdf_graphics::{ size::Size, transform::Transform, }; use pdf_shading::paint::ShadingPaint; -use skia_safe::RoundOut; /// Native allocation, conversion, and mask replay failures. #[derive(Debug, thiserror::Error)] @@ -451,9 +450,9 @@ fn make_paint( } impl SkiaCanvasBackend<'_> { - /// Fills `path` without anti-aliasing over every pixel it touches when it is a - /// rectangle aligned with the pixel grid, at least one pixel wide and tall, so thin - /// bars stay solid. Returns whether the path was such a rectangle. + /// Fills `path` without anti-aliasing over the whole pixels it covers when it is a + /// rectangle aligned with the pixel grid (see [`pdf_graphics::rect::Rect::snapped_to_pixels`]), so thin + /// bars stay solid and separate. Returns whether the path was such a rectangle. fn fill_pixel_rect( &mut self, path: &CanvasPath<'_>, @@ -464,12 +463,9 @@ impl SkiaCanvasBackend<'_> { let Some(rect) = path.axis_aligned_rect(&to_pixels)? else { return Ok(false); }; - let mut pixels: skia_safe::IRect = - skia_safe::Rect::new(rect.left, rect.top, rect.right, rect.bottom).round_out(); - if pixels.is_empty() { - pixels.right = pixels.left.saturating_add(1).max(pixels.right); - pixels.bottom = pixels.top.saturating_add(1).max(pixels.bottom); - } + 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(); diff --git a/crates/pdf-graphics/src/rect.rs b/crates/pdf-graphics/src/rect.rs index 0c273e5a..9e9b2303 100644 --- a/crates/pdf-graphics/src/rect.rs +++ b/crates/pdf-graphics/src/rect.rs @@ -47,6 +47,38 @@ impl Rect { } } + /// Returns the whole-pixel rectangle a solid fill of this rectangle covers: its outer + /// pixel bounds, at least one pixel per axis, trimmed on the side with more slack when + /// they are a full pixel wider than the rectangle's rounded-up size. + /// + /// The trim keeps thin bars separated by thin gaps from merging into one block. + pub fn snapped_to_pixels(&self) -> Self { + let snap = |start: f32, end: f32| { + let (mut low, mut high) = (start.floor(), end.ceil()); + if high == low { + high += 1.0; + } + let size = (end - start).ceil().max(1.0); + if high - low >= size + 1.0 { + if start - low > high - end { + low += 1.0; + } else { + high -= 1.0; + } + } + (low, 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 } From 1b2282086e6924310138737ac85e1b505459dee2 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sat, 31 Oct 2026 15:31:25 +0200 Subject: [PATCH 3/3] Snap filled rectangles to the nearest pixel edges Rounding a rectangle out can only grow it, so bars separated by gaps of about one pixel still merged into solid blocks at low zoom. Move each edge to the nearest pixel boundary instead, keeping at least one pixel per axis: bars stay solid and the gaps between them stay open. Co-Authored-By: Claude Opus 5.5 --- .../src/skia_canvas_backend.rs | 8 ++++--- crates/pdf-graphics/src/rect.rs | 23 ++++--------------- tools/conformance/README.md | 6 ++--- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs index 75606486..a43447fb 100644 --- a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs +++ b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs @@ -450,9 +450,11 @@ fn make_paint( } impl SkiaCanvasBackend<'_> { - /// Fills `path` without anti-aliasing over the whole pixels it covers when it is a - /// rectangle aligned with the pixel grid (see [`pdf_graphics::rect::Rect::snapped_to_pixels`]), so thin - /// bars stay solid and separate. Returns whether the path was such a rectangle. + /// 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<'_>, diff --git a/crates/pdf-graphics/src/rect.rs b/crates/pdf-graphics/src/rect.rs index 9e9b2303..eec600c5 100644 --- a/crates/pdf-graphics/src/rect.rs +++ b/crates/pdf-graphics/src/rect.rs @@ -47,26 +47,13 @@ impl Rect { } } - /// Returns the whole-pixel rectangle a solid fill of this rectangle covers: its outer - /// pixel bounds, at least one pixel per axis, trimmed on the side with more slack when - /// they are a full pixel wider than the rectangle's rounded-up size. - /// - /// The trim keeps thin bars separated by thin gaps from merging into one block. + /// 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 (mut low, mut high) = (start.floor(), end.ceil()); - if high == low { - high += 1.0; - } - let size = (end - start).ceil().max(1.0); - if high - low >= size + 1.0 { - if start - low > high - end { - low += 1.0; - } else { - high -= 1.0; - } - } - (low, high) + 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); diff --git a/tools/conformance/README.md b/tools/conformance/README.md index b1b0b303..8e22b220 100644 --- a/tools/conformance/README.md +++ b/tools/conformance/README.md @@ -66,9 +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. Like PDFium, -Safe-PDF fills a solid axis-aligned rectangle over every pixel it touches without anti-aliasing, -so barcode bars match. 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