fix(render): treat combined fill-and-stroke operators as a knockout group (#3229) (#4788)

This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-09 10:09:06 +08:00
1 parent 138acb38b1
commit 5e671ec301
4 files changed
+252 -32

No files matched your search

@@ -0,0 +1,166 @@
//! Contributor-original synthetic pages for combined fill-and-stroke operators (`B`, `B*`, `b`,
//! `b*`) and text rendering modes 2 and 6 (`FillStroke`, `FillAndStrokeAndClip`) under
//! transparency, blend modes, and soft masks (ISO 32000-2 §11.7.4.4).
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use std::sync::Arc;
fn pdf_with_content(content: &str) -> Vec<u8> {
let mask_stream = b"0.5 g 0 0 200 200 re f";
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Group << /S /Transparency /CS /DeviceRGB >> \
/Contents 4 0 R \
/Resources << \
/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> \
/ExtGState << \
/GS50 << /Type /ExtGState /ca 0.5 /CA 0.5 >> \
/GSStroke50 << /Type /ExtGState /ca 1.0 /CA 0.5 >> \
/GSMul << /Type /ExtGState /BM /Multiply /ca 1.0 /CA 1.0 >> \
/GSMask << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 5 0 R >> >> \
>> \
>> >>"
.to_vec(),
stream(b"", content.as_bytes()),
stream(
b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] \
/Group << /S /Transparency /CS /DeviceGray /I true >> ",
mask_stream,
),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(object);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn stream(dict_entries: &[u8], body: &[u8]) -> Vec<u8> {
let mut s = b"<< ".to_vec();
s.extend_from_slice(dict_entries);
s.extend_from_slice(format!("/Length {} >>\nstream\n", body.len()).as_bytes());
s.extend_from_slice(body);
s.extend_from_slice(b"\nendstream");
s
}
struct Page(Vec<u8>);
impl Page {
fn render(content: &str) -> Self {
let mut renderer = PageRenderer::new(Arc::new(pdf_with_content(content)), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{content}: {:?}", page.error);
assert_eq!((page.width, page.height), (200, 200), "{content}");
Self(page.rgba.to_vec())
}
fn at(&self, x: usize, y: usize) -> [u8; 3] {
let i = ((199 - y) * 200 + x) * 4;
match self.0.get(i..i + 3) {
Some([r, g, b]) => [*r, *g, *b],
_ => panic!("pixel ({x}, {y}) is out of bounds"),
}
}
fn assert_close(&self, x: usize, y: usize, want: [u8; 3], label: &str) {
let got = self.at(x, y);
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{label} at ({x}, {y}): got {got:?}, expected {want:?}");
}
}
#[test]
fn fill_stroke_path_knocks_out_fill_under_stroke() {
// 50% red fill (`ca = 0.5`) and 50% blue 20 pt stroke (`CA = 0.5`) on a white page.
// The stroke spans x = 30..50 along the left edge of the 40..160 square:
// - outer half of stroke at (35, 100) is over white -> [128, 128, 255]
// - inner half of stroke at (45, 100) knocks out the fill -> [128, 128, 255]
// - interior fill at (100, 100) is 50% red over white -> [255, 128, 128]
for op in ["40 40 120 120 re B", "40 40 120 120 re B*", "40 40 m 160 40 l 160 160 l 40 160 l b", "40 40 m 160 40 l 160 160 l 40 160 l b*"] {
let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w {op} Q"));
page.assert_close(100, 100, [255, 128, 128], &format!("{op} fill center"));
page.assert_close(35, 100, [128, 128, 255], &format!("{op} outer stroke"));
page.assert_close(45, 100, [128, 128, 255], &format!("{op} inner stroke"));
}
// Even when fill alpha is 1.0 (`ca = 1.0`) and only stroke alpha is 0.5 (`CA = 0.5`),
// the semi-transparent stroke knocks out the opaque fill beneath it and composites
// directly with the white backdrop.
let page = Page::render("q /GSStroke50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q");
page.assert_close(100, 100, [255, 0, 0], "opaque fill center");
page.assert_close(35, 100, [128, 128, 255], "CA=0.5 outer stroke");
page.assert_close(45, 100, [128, 128, 255], "CA=0.5 inner stroke knocks out opaque fill");
// Control: separate `f` followed by `S` are two distinct graphics objects, so the stroke
// composites over the already-painted fill: 0.5 * (0, 0, 255) + 0.5 * (255, 128, 128) = (128, 64, 191).
let separate = Page::render("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re f 40 40 120 120 re S Q");
separate.assert_close(35, 100, [128, 128, 255], "separate outer stroke");
separate.assert_close(45, 100, [128, 64, 191], "separate inner stroke over fill");
}
#[test]
fn fill_stroke_path_applies_blend_mode_and_soft_mask_once() {
// Under `/BM /Multiply` on a (0.8, 0.8, 0.5) backdrop, `B` knocks out the yellow fill
// (1.0, 0.8, 0.0) under the cyan stroke (0.0, 0.8, 1.0) before multiplying with the backdrop:
// - fill center: (1.0 * 0.8, 0.8 * 0.8, 0.0 * 0.5) = [204, 163, 0]
// - both halves of the stroke: (0.0 * 0.8, 0.8 * 0.8, 1.0 * 0.5) = [0, 163, 128]
let mul = Page::render(
"0.8 0.8 0.5 rg 0 0 200 200 re f \
q /GSMul gs 1 0.8 0 rg 0 0.8 1 RG 20 w 40 40 120 120 re B Q",
);
mul.assert_close(100, 100, [204, 163, 0], "Multiply fill center");
mul.assert_close(35, 100, [0, 163, 128], "Multiply outer stroke");
mul.assert_close(45, 100, [0, 163, 128], "Multiply inner stroke");
// Under a 50% luminosity `/SMask`, `B` masks the combined knockout group once:
// - fill center: 50% red over white = [255, 128, 128]
// - both halves of the stroke: 50% blue over white = [128, 128, 255]
let masked = Page::render("q /GSMask gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q");
masked.assert_close(100, 100, [255, 128, 128], "SMask fill center");
masked.assert_close(35, 100, [128, 128, 255], "SMask outer stroke");
masked.assert_close(45, 100, [128, 128, 255], "SMask inner stroke");
}
#[test]
fn text_rendering_modes_2_and_6_knock_out_fill_under_stroke() {
for mode in [2, 6] {
let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td {mode} Tr (I) Tj ET Q"));
// Both the 50% red fill ([255, 128, 128]) and the 50% blue stroke ([128, 128, 255]) over
// white have G >= 128. With knockout, every pixel (fill, inner stroke, outer stroke, and
// anti-aliased edges) stays at G >= 126; without knockout, the inner half of the stroke
// composites over the fill to [128, 64, 191] (G = 64).
let mut stroke_pixels = 0usize;
let mut fill_pixels = 0usize;
for &[r, g, b, _] in page.0.as_chunks::<4>().0 {
assert!(g >= 126, "mode {mode} Tr pixel [{r}, {g}, {b}] composited stroke over fill");
if b >= 253 && r <= 130 {
stroke_pixels += 1;
} else if r >= 253 && b <= 130 {
fill_pixels += 1;
}
}
assert!(stroke_pixels > 200, "mode {mode} Tr should paint stroke pixels");
assert!(fill_pixels > 100, "mode {mode} Tr should paint interior fill pixels");
}
// Mode 6 (`FillAndStrokeAndClip`) also clips subsequent drawing after `ET` to the glyph outline.
let clipped = Page::render(
"q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td 6 Tr (I) Tj ET \
1.0 ca 0 1 0 rg 0 0 200 200 re f Q",
);
// Outside the glyph (10, 10), the green rectangle is clipped away.
clipped.assert_close(10, 10, [255, 255, 255], "outside 6 Tr glyph clip");
}
+1
View File
@@ -1,6 +1,7 @@
//! The crate's integration tests, linked into one test binary.
mod cid_fallback_query;
mod fill_stroke_knockout;
mod inline_image_ei;
mod jbig2_invariants;
mod knockout_backdrop;
+52 -3
View File
@@ -2,7 +2,7 @@ use crate::color::{Color, ColorSpace, device_cmyk_to_gray};
use crate::context::{Context, path_as_rect};
use crate::device::Device;
use crate::util::{BezPathExt, Float32Ext};
use crate::{FillRule, Paint, PathDrawMode, StrokeProps};
use crate::{BlendMode, FillRule, Paint, PathDrawMode, StrokeProps};
use kurbo::{BezPath, Cap, Join, PathEl};
use smallvec::smallvec;
@@ -29,13 +29,62 @@ pub(crate) fn fill_stroke_path<'a>(
device: &mut impl Device<'a>,
fill_rule: FillRule,
) {
fill_path_impl(context, device, fill_rule, None);
stroke_path_impl(context, device, None);
with_fill_stroke_group(context, device, |context, device| {
fill_path_impl(context, device, fill_rule, None);
stroke_path_impl(context, device, None);
});
apply_pending_clip(context, device);
context.path_mut().truncate(0);
}
/// PdfCraft patch: when one operator both fills and strokes a path (`B`, `B*`, `b`, `b*`) or
/// paints a glyph in text rendering mode 2 or 6, the fill and stroke form an implicit
/// non-isolated knockout transparency group that is composited as a unit with the current
/// soft mask and blend mode (ISO 32000-2 §11.7.4.4).
pub(crate) fn with_fill_stroke_group<'a, D: Device<'a>>(
context: &mut Context<'a>,
device: &mut D,
f: impl FnOnce(&mut Context<'a>, &mut D),
) {
let gs = &context.get().graphics_state;
let has_pattern = gs.stroke_cs.is_pattern()
|| gs.stroke_pattern.is_some()
|| gs.none_stroke_cs.is_pattern()
|| gs.non_stroke_pattern.is_some();
let needs_group = context.ocg_state.is_visible()
&& !device.in_knockout_group()
&& (gs.stroke_alpha < 1.0
|| gs.non_stroke_alpha < 1.0
|| gs.soft_mask.is_some()
|| gs.blend_mode != BlendMode::Normal
|| has_pattern);
if !needs_group {
f(context, device);
return;
}
let soft_mask = context.get_mut().graphics_state.soft_mask.take();
let blend_mode = std::mem::replace(
&mut context.get_mut().graphics_state.blend_mode,
BlendMode::Normal,
);
device.set_soft_mask(None);
device.push_transparency_group(1.0, soft_mask.clone(), blend_mode);
device.set_knockout();
device.set_blend_mode(BlendMode::Normal);
f(context, device);
device.pop_transparency_group();
context.get_mut().graphics_state.soft_mask = soft_mask.clone();
context.get_mut().graphics_state.blend_mode = blend_mode;
device.set_soft_mask(soft_mask);
device.set_blend_mode(blend_mode);
}
/// PdfCraft patch: a clip set by `W` / `W*` takes effect once the path-painting operator that
/// ends the path has painted it, whichever operator that is (ISO 32000-2 §8.5.4). Upstream
/// applied it only on `n`, and otherwise left it pending for some later, unrelated `n`.
+33 -29
View File
@@ -2,7 +2,7 @@ use crate::GlyphDrawMode;
use crate::context::Context;
use crate::device::Device;
use crate::font::Glyph;
use crate::interpret::path::get_paint;
use crate::interpret::path::{get_paint, with_fill_stroke_group};
use crate::interpret::state::TextStateFont;
use hayro_syntax::object;
use hayro_syntax::page::Resources;
@@ -102,20 +102,22 @@ pub(crate) fn show_glyph<'a>(
);
}
TextRenderingMode::FillStroke => {
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, false),
&GlyphDrawMode::Fill,
);
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, true),
&GlyphDrawMode::Stroke(stroke_props),
);
with_fill_stroke_group(ctx, device, |ctx, device| {
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, false),
&GlyphDrawMode::Fill,
);
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, true),
&GlyphDrawMode::Stroke(stroke_props),
);
});
}
TextRenderingMode::Invisible => {
// Still call draw_glyph for invisible text, so that it can
@@ -163,20 +165,22 @@ pub(crate) fn show_glyph<'a>(
}
TextRenderingMode::FillAndStrokeAndClip => {
clip_glyph(ctx, glyph, glyph_transform);
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, false),
&GlyphDrawMode::Fill,
);
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, true),
&GlyphDrawMode::Stroke(stroke_props),
);
with_fill_stroke_group(ctx, device, |ctx, device| {
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, false),
&GlyphDrawMode::Fill,
);
device.draw_glyph(
glyph,
ctx.get().ctm,
glyph_transform,
&get_paint(ctx, true),
&GlyphDrawMode::Stroke(stroke_props),
);
});
}
}
}