Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/pdf-canvas/src/path_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::sync::Arc;
use pdf_graphics::{
pdf_path::{PathVerb, PdfPath},
point::Point,
rect::Rect,
transform::{Transform, TransformError},
};

Expand Down Expand Up @@ -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<Option<Rect>, 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<PdfPath, TransformError> {
let mut path = PdfPath::default();
Expand Down
33 changes: 32 additions & 1 deletion crates/pdf-graphics-skia/src/skia_canvas_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, PdfCanvasError> {
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(
Expand All @@ -459,9 +487,12 @@ impl CanvasBackend for SkiaCanvasBackend<'_> {
shader: Option<&Shader>,
blend_mode: Option<BlendMode>,
) -> 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)?);
Expand Down
19 changes: 19 additions & 0 deletions crates/pdf-graphics/src/rect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ impl Rect<f32> {
}
}

/// 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
}
Expand Down
5 changes: 3 additions & 2 deletions tools/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading