mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
This commit is contained in:
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,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
@@ -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
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user