Render: a soft mask shrunk below its size is filtered, so thin strokes drawn through it survive (fixes #1649) (#1654)

This commit is contained in:
Marco Arnulfo 2026-10-13 06:18:27 +02:00 committed by GitHub
parent f9d7aa72b6
commit e8fcf76c33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 43 additions and 3 deletions

View File

@ -4827,6 +4827,37 @@ trailer << /Root 1 0 R >>
assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected);
}
/// Issue #1649: Word can save text as a tiny black image whose `/SMask` is a high-resolution
/// bitmap of the glyphs, so the mask alone carries the text. Shrinking that mask by
/// nearest-neighbour keeps one sample in N, and a stroke thinner than N mask pixels vanished:
/// the text came out broken and aliased. Here a one-column stroke of an 8-column mask is drawn
/// 4 pixels wide; nearest reads columns 1, 3, 5 and 7 and misses the stroke in column 2.
#[test]
fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
let mask: [u8; 8] = [0, 0, 255, 0, 0, 0, 0, 0];
let content = "q 4 0 0 1 0 0 cm /Im0 Do Q";
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length 3 >> stream\n00>\nendstream endobj\n\
6 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
hex(&mask).len() + 1,
hex(&mask)
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (4, 1));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
// The stroke covers half of device pixel 1, so it is a mid-tone; the far side stays white.
assert!((64..=191).contains(&grays[1]), "the stroke is lost or not a mid-tone: {grays:?}");
assert_eq!(grays[3], 255, "{grays:?}");
}
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic

4
vendor/README.md vendored

File diff suppressed because one or more lines are too long

View File

@ -534,7 +534,7 @@ impl Renderer {
reach
}
fn draw_image_with_alpha_mask(&mut self, image_data: ImageData, alpha_data: LumaData) {
fn draw_image_with_alpha_mask(&mut self, image_data: ImageData, mut alpha_data: LumaData) {
// PdfCraft patch: mismatched masks need a dummy RGB staging image. Validate and
// reserve it before creating/pushing any mask, so failure skips the whole image.
let Some(len) = source_byte_len(alpha_data.width, alpha_data.height, 3, MAX_IMAGE_PIXELS)
@ -555,6 +555,15 @@ impl Renderer {
image_data.width() as f64 / alpha_data.width as f64,
image_data.height() as f64 / alpha_data.height as f64,
);
// PdfCraft patch (#1649): a mask that differs in size from its image carries the
// shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask).
// Shrunk by nearest-neighbour it keeps one sample in N and drops every stroke thinner
// than N mask pixels, so a shrunk mask is filtered whatever its `/Interpolate`, which
// only asks for smoothing when the mask is enlarged.
let (x, y) = x_y_advances(&transform);
if x.length() < 1.0 || y.length() < 1.0 {
alpha_data.interpolate = true;
}
let mut renderer = Self {
ctx: RenderContext::new_with(
self.ctx.width(),