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
27 changes: 26 additions & 1 deletion tools/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ when its revision differs from the pinned one.
command line tools.
- **No prebuilt downloads.** The harness never downloads prebuilt PDFium binaries.

## Text rasterization

PDFium and Safe-PDF rasterize text differently, so glyph edges differ on almost every page
with text. Neither render is wrong:

- PDFium (Skia) draws text from cached glyph masks. It snaps each glyph's origin to a whole
pixel vertically and to a quarter pixel horizontally. On small text the masks are also darker
than the exact outline, by about 10%.
- Safe-PDF fills each glyph's exact outline at its exact position.

The result is a sub-pixel shift, constant along a line, plus heavier coverage. The plain
anti-aliasing and 1 px displacement tests don't catch it, so it used to show as orange and could
push text-heavy pages over the tolerance.

When a page exceeds the tolerance, the worker therefore takes Safe-PDF's glyph bounds (grown by
1 px). Inside them it accepts a differing pixel when both images show the same hue and each
image's darkness lies within the range of the other's 3x3 neighbourhood, widened by
`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
by more than a pixel still count.

## Commands

```sh
Expand Down Expand Up @@ -98,7 +122,8 @@ agents.

- `pN-compare.png`: reference | Safe-PDF | diff. Diff colors: red = ink missing in Safe-PDF,
blue = extra ink in Safe-PDF, magenta = hue differs, orange = same hue but different
intensity (shape), yellow = anti-aliasing or 1 px displacement (tolerated), pale blue
intensity (shape), yellow = anti-aliasing, 1 px displacement or glyph rasterization (see
[Text rasterization](#text-rasterization); tolerated), pale blue
hatching = annotation area (not compared), gray = matching.
- Region classes: `missing_ink` / `extra_ink` = one renderer drew nothing there;
`color_shift` = same shapes, different color; `reshaped` = both drew but glyph shapes or
Expand Down
76 changes: 75 additions & 1 deletion tools/conformance/src/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ pub struct Comparison {
/// Squared YIQ distance for pixelmatch's default threshold of 0.1.
const MAX_DELTA: f64 = 35215.0 * 0.1 * 0.1;

/// Relative darkness by which a glyph pixel may exceed the other image's local range.
///
/// PDFium draws text from cached glyph masks: each glyph's origin is snapped to a whole pixel
/// vertically and a quarter pixel horizontally, and the masks cover more than the exact outline
/// on small text. Safe-PDF fills the exact outline at its exact position, so glyph edges differ by
/// a sub-pixel shift plus heavier coverage.
const GLYPH_COVERAGE_SLACK: f64 = 0.4;

/// Diff color of differences tolerated as anti-aliasing or rasterization.
const TOLERATED: Rgba<u8> = Rgba([255, 220, 0, 255]);

/// Luma below which a pixel counts as ink.
const INK_LUMA: f64 = 200.0;

Expand Down Expand Up @@ -90,7 +101,7 @@ pub fn compare(reference: &RgbaImage, safe: &RgbaImage, ignore: &[[u32; 4]]) ->
|| antialiased(safe, x, y, reference)
|| displaced(reference, safe, x, y)
{
(PixelDiff::Same, Rgba([255, 220, 0, 255]))
(PixelDiff::Same, TOLERATED)
} else if luma_a < INK_LUMA && luma_b >= INK_LUMA {
(PixelDiff::Missing, Rgba([230, 0, 0, 255]))
} else if luma_b < INK_LUMA && luma_a >= INK_LUMA {
Expand Down Expand Up @@ -122,6 +133,69 @@ pub fn compare(reference: &RgbaImage, safe: &RgbaImage, ignore: &[[u32; 4]]) ->
})
}

impl Comparison {
/// Accepts differing pixels inside the `glyphs` boxes (`[x0, y0, x1, y1]`, exclusive) that
/// differ only by glyph-mask rasterization: the same hue, placed within a pixel, with up to
/// [`GLYPH_COVERAGE_SLACK`] more or less coverage. Updates the mismatch ratio.
pub fn tolerate_glyph_rasterization(
&mut self,
reference: &RgbaImage,
safe: &RgbaImage,
glyphs: &[[u32; 4]],
) {
let width = reference.width();
for &[x0, y0, x1, y1] in glyphs {
for y in y0..y1 {
for x in x0..x1 {
let index = u64::from(y)
.saturating_mul(u64::from(width))
.saturating_add(u64::from(x));
let Some(outcome) = usize::try_from(index)
.ok()
.and_then(|index| self.mask.get_mut(index))
else {
continue;
};
if *outcome == PixelDiff::Same || !rasterized_glyph(reference, safe, x, y) {
continue;
}
*outcome = PixelDiff::Same;
self.diff.put_pixel(x, y, TOLERATED);
}
}
}
let mismatched = self
.mask
.iter()
.filter(|&&outcome| outcome != PixelDiff::Same)
.count();
let count = f64::from(reference.width()) * f64::from(reference.height());
self.metrics.mismatch =
to_f64(u64::try_from(mismatched).unwrap_or(u64::MAX)) / count.max(1.0);
}
}

/// Returns whether the pixel differs only as a glyph mask rasterized on a coarser grid: both
/// images show the same hue, and each image's darkness lies within the darkness range of the
/// other's 3x3 neighbourhood, widened by [`GLYPH_COVERAGE_SLACK`].
fn rasterized_glyph(reference: &RgbaImage, safe: &RgbaImage, x: u32, y: u32) -> bool {
let a = pixel(reference, x, y);
let b = pixel(safe, x, y);
let within = |darkness: f64, other: &RgbaImage| {
let (low, high) = neighbours(other, x, y)
.chain(std::iter::once((x, y)))
.map(|(nx, ny)| 255.0 - luma(pixel(other, nx, ny)))
.fold((f64::MAX, f64::MIN), |(low, high), value| {
(low.min(value), high.max(value))
});
darkness >= low * (1.0 - GLYPH_COVERAGE_SLACK)
&& darkness <= (high * (1.0 + GLYPH_COVERAGE_SLACK)).min(255.0).max(high)
};
chroma_delta(a, b) <= MIN_CHROMA_DELTA
&& within(255.0 - luma(a), safe)
&& within(255.0 - luma(b), reference)
}

/// Converts a count to `f64`; counts here stay far below 2^53.
pub fn to_f64(value: u64) -> f64 {
value.to_f64().unwrap_or(f64::MAX)
Expand Down
40 changes: 34 additions & 6 deletions tools/conformance/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use pdf_canvas::{PageViewport, ViewportError};
use pdf_document::{document::PdfDocument, reader::PdfReader};
use pdf_graphics::point::Point;
use pdf_graphics::size::Size;
use pdf_renderer::PdfRenderer;
use pdf_renderer::{PdfRenderer, text_selection::PageTextLayout};
use std::{fmt::Write as _, fs, path::Path, time::Instant};

/// Regions reported per page.
Expand Down Expand Up @@ -210,6 +210,24 @@ fn annotation_boxes(page: &pdf_document::page::PdfPage, width: u32, height: u32)
.collect()
}

/// Returns the pixel boxes of Safe-PDF's glyphs, grown by one pixel and clamped to the image.
fn glyph_boxes(layout: &PageTextLayout, width: u32, height: u32) -> Vec<[u32; 4]> {
let clamp = |value: f32, limit: u32| value.max(0.0).to_u32().map(|v| v.min(limit));
layout
.glyphs()
.iter()
.filter_map(|glyph| {
let bounds = glyph.bounds.normalized();
Some([
clamp((bounds.left - 1.0).floor(), width)?,
clamp((bounds.top - 1.0).floor(), height)?,
clamp((bounds.right + 1.0).ceil(), width)?,
clamp((bounds.bottom + 1.0).ceil(), height)?,
])
})
.collect()
}

/// Renders one page with Safe-PDF, compares it with the reference, and writes evidence files.
pub fn page(job: &PageJob<'_>) -> Result<()> {
let bytes = fs::read(job.pdf)?;
Expand Down Expand Up @@ -348,14 +366,24 @@ pub fn page(job: &PageJob<'_>) -> Result<()> {
} else {
Vec::new()
};
let comparison = compare::compare(reference_image, safe_image, &ignore)?;
let failed = comparison.metrics.mismatch > job.tolerance;
output.metrics = Some(comparison.metrics);
if failed {
let mut comparison = compare::compare(reference_image, safe_image, &ignore)?;
let mut traced = None;
if comparison.metrics.mismatch > job.tolerance {
stage("safe-trace");
let traced = renderer.as_ref().ok().and_then(|renderer| {
traced = renderer.as_ref().ok().and_then(|renderer| {
safe_render::trace(renderer, job.page, width, height).ok()
});
if let Some((_, layout)) = &traced {
comparison.tolerate_glyph_rasterization(
reference_image,
safe_image,
&glyph_boxes(layout, width, height),
);
}
}
let failed = comparison.metrics.mismatch > job.tolerance;
output.metrics = Some(comparison.metrics);
if failed {
output.draw_count = traced
.as_ref()
.map_or(0, |(backend, _)| backend.draws.len());
Expand Down
Loading