From 2c7b035de73244532ff309220aaf260e53ed661b Mon Sep 17 00:00:00 2001 From: Velli20 Date: Tue, 13 Oct 2026 20:51:27 +0300 Subject: [PATCH] Tolerate PDFium glyph-mask rasterization in conformance comparison PDFium draws text from cached glyph masks: it snaps each glyph's origin to a whole pixel vertically and a quarter pixel horizontally, and its masks cover more than the exact outline on small text. Safe-PDF fills exact outlines at exact positions, so glyph edges differed on nearly every text page and text-heavy pages crossed the mismatch tolerance. When a page exceeds the tolerance, accept same-hue differences inside Safe-PDF's glyph bounds whose darkness fits the other image's 3x3 neighbourhood range widened by 40%, then decide pass or fail again. Document the behavior in the conformance README. Co-Authored-By: Claude Opus 5.5 --- tools/conformance/README.md | 27 +++++++++++- tools/conformance/src/compare.rs | 76 +++++++++++++++++++++++++++++++- tools/conformance/src/worker.rs | 40 ++++++++++++++--- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/tools/conformance/README.md b/tools/conformance/README.md index bc884fc5..6ce30904 100644 --- a/tools/conformance/README.md +++ b/tools/conformance/README.md @@ -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 @@ -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 diff --git a/tools/conformance/src/compare.rs b/tools/conformance/src/compare.rs index d47b2ace..6ee1abbe 100644 --- a/tools/conformance/src/compare.rs +++ b/tools/conformance/src/compare.rs @@ -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 = Rgba([255, 220, 0, 255]); + /// Luma below which a pixel counts as ink. const INK_LUMA: f64 = 200.0; @@ -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 { @@ -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) diff --git a/tools/conformance/src/worker.rs b/tools/conformance/src/worker.rs index d96b7f2c..e6d655da 100644 --- a/tools/conformance/src/worker.rs +++ b/tools/conformance/src/worker.rs @@ -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. @@ -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)?; @@ -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());