mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
crates/render/src/raster.rs: both new tests (#2975 stencil masks, #2971 knockout groups) vendor/hayro/src/renderer.rs: set_paint returns main's PaintSetup; the PR's knockout erase early return wraps its clip in PaintSetup::Clip vendor/README.md: main's rows plus the PR's hayro-interpret patch 39 and hayro patch numbered 22 (19 is #3296's), with tests
This commit is contained in:
@@ -6338,6 +6338,152 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// #2971: in a knockout transparency group (`/K true`) each element composites with the
|
||||
/// group's initial backdrop, not with the elements drawn before it in the group, over the
|
||||
/// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue
|
||||
/// where they overlap; without `/K` the two blend. The same holds for an image (its shape
|
||||
/// is its rectangle; a soft-masked image's shape is its mask, so its transparent part leaves
|
||||
/// what is under it) and for a nested group (its shape is the union of its elements'
|
||||
/// shapes, while its own elements still blend with each other).
|
||||
#[test]
|
||||
fn knockout_groups_replace_what_their_elements_cover() {
|
||||
let render_as = |group: &str, form: &str, preview: bool| {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj
|
||||
4 0 obj << /Length 39 >> stream
|
||||
/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream
|
||||
{form}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream
|
||||
/A gs 0 0 1 rg 100 50 150 120 re f
|
||||
endstream endobj
|
||||
7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream
|
||||
0000>
|
||||
endstream endobj
|
||||
8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream
|
||||
/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f
|
||||
endstream endobj
|
||||
9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream
|
||||
0000FF>
|
||||
endstream endobj
|
||||
10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream
|
||||
0000FF0000FF>
|
||||
endstream endobj
|
||||
11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream
|
||||
00FF>
|
||||
endstream endobj
|
||||
12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream
|
||||
/H gs q 160 0 0 100 60 50 cm /Img Do Q
|
||||
endstream endobj
|
||||
13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream
|
||||
/H gs /In3 Do
|
||||
endstream endobj
|
||||
14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream
|
||||
0 0 1 rg 100 50 150 120 re f
|
||||
endstream endobj
|
||||
15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj
|
||||
16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream
|
||||
/Pattern cs /P scn 100 50 150 120 re f
|
||||
endstream endobj
|
||||
17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream
|
||||
0000FF>
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
form.len()
|
||||
);
|
||||
let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
preview,
|
||||
});
|
||||
assert!(p.error.is_none(), "{group} {form}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let render = |group: &str, form: &str| render_as(group, form, false);
|
||||
// Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y).
|
||||
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((200 - y) * p.width + x) as usize * 4..][..3].to_vec();
|
||||
let check = |p: &RenderedPage, x: u32, y: u32, want: [u8; 3], what: &str| {
|
||||
let got = px(p, x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}");
|
||||
};
|
||||
let red = "/A gs 1 0 0 rg 20 20 150 150 re f ";
|
||||
let ko = "/I true /K true";
|
||||
|
||||
let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f"));
|
||||
check(&squares, 130, 100, [128, 128, 255], "knocked out");
|
||||
check(&squares, 50, 40, [255, 128, 128], "red alone");
|
||||
check(&squares, 210, 100, [128, 128, 255], "blue alone");
|
||||
check(&squares, 280, 190, [255, 255, 255], "paper");
|
||||
// Page content after the group composites normally again: green at 50 % over the red.
|
||||
check(&squares, 30, 30, [128, 191, 64], "after the group");
|
||||
let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f"));
|
||||
check(&plain, 130, 100, [128, 64, 191], "blended without /K");
|
||||
|
||||
// An image at 50 % replaces the red under its rectangle.
|
||||
let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q"));
|
||||
check(&image, 100, 100, [128, 128, 255], "image knocks out");
|
||||
check(&image, 40, 40, [255, 128, 128], "red beside the image");
|
||||
// A soft-masked image: its transparent left half leaves the red, its opaque half replaces it.
|
||||
let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q");
|
||||
check(&masked, 100, 100, [255, 0, 0], "under the transparent half");
|
||||
check(&masked, 155, 100, [0, 0, 255], "under the opaque half");
|
||||
|
||||
// A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape.
|
||||
let nested = render(ko, &format!("{red}/In Do"));
|
||||
check(&nested, 130, 100, [191, 191, 255], "nested group knocks out");
|
||||
check(&nested, 50, 40, [255, 128, 128], "red beside the nested group");
|
||||
// A nested group at full opacity: it knocks out the red as a whole, and its own two
|
||||
// squares at 50 % still blend with each other where they overlap.
|
||||
let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do");
|
||||
check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red");
|
||||
check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend");
|
||||
check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group");
|
||||
|
||||
// An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red.
|
||||
let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do");
|
||||
check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group");
|
||||
// A group inside a nested group knocks out the red as well.
|
||||
let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do");
|
||||
check(&deep, 130, 100, [128, 128, 255], "group in a nested group");
|
||||
check(&deep, 40, 40, [255, 0, 0], "red beside it");
|
||||
|
||||
// A stencil mask must not erase the red beside it.
|
||||
let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q"));
|
||||
check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil");
|
||||
// Where the stencil paints (every sample 0), its blue is drawn (review on #2974: the check
|
||||
// above alone passed even if the stencil drew nothing). A stencil composites as it did
|
||||
// before rather than knocking the red out, and its exact colour also depends on #2975 (a
|
||||
// stencil's fill opacity applied twice), so only "blue was drawn here" is pinned.
|
||||
let under = px(&stencil, 130, 100);
|
||||
assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}");
|
||||
|
||||
// A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path
|
||||
// inside the box: the erase pass must clear only that, or the red outside the box but
|
||||
// inside the path stays erased (review on #2974).
|
||||
let shaded = render(ko, &format!("{red}/Shg Do"));
|
||||
check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box");
|
||||
check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box");
|
||||
|
||||
// An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it
|
||||
// must not clear what is under its rectangle either (review on #2974).
|
||||
let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q"));
|
||||
check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn");
|
||||
// Nor one a preview skips for covering only a few device pixels (review on #2974): the
|
||||
// red under a 3 x 3 image stays in a preview, and a final render knocks it out.
|
||||
let tiny = format!("{red}q 3 0 0 3 100 100 cm /Img Do Q");
|
||||
check(&render_as(ko, &tiny, true), 101, 101, [255, 128, 128], "red under a preview's skipped image");
|
||||
check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render");
|
||||
}
|
||||
|
||||
/// Reading colours replace *document* colours, never annotation colours (issue #1548): a
|
||||
/// highlight keeps its `/C`, so on a dark background the content under it shows through the
|
||||
/// Multiply blend in the highlight's colour instead of being darkened into invisibility.
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+34
@@ -73,6 +73,40 @@ pub trait Device<'a> {
|
||||
fn begin_annotations(&mut self) {}
|
||||
/// PdfCraft patch (29): the page's annotations are done; what follows is page content.
|
||||
fn end_annotations(&mut self) {}
|
||||
/// PdfCraft patch (39): the transparency group just pushed is a knockout group (`/K true`):
|
||||
/// each of its elements composites with the group's initial backdrop, not with the
|
||||
/// elements drawn before it in the group (ISO 32000-2, knockout groups), until the
|
||||
/// matching `pop_transparency_group`.
|
||||
fn set_knockout(&mut self) {}
|
||||
/// PdfCraft patch (39): an element whose shape is `shape` (device space) is about to be
|
||||
/// drawn into a transparency group of its own. In a knockout group (`set_knockout`) the
|
||||
/// element replaces what the group drew under its shape, so a device clears that first;
|
||||
/// the element's layer then composites over the cleared area as usual.
|
||||
fn knockout_shape(&mut self, _shape: &BezPath) {}
|
||||
/// PdfCraft patch (39): whether `knockout_shape` would clear anything now, that is whether
|
||||
/// the innermost transparency group is a knockout group. Lets the interpreter skip work it
|
||||
/// only needs for the clear.
|
||||
fn in_knockout_group(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// PdfCraft patch (39): `image`, an unmasked raster image, is about to be drawn with
|
||||
/// `transform` in a knockout group. A device that will draw it (it isn't skipped and it
|
||||
/// decodes) clears what the group drew under its placement rectangle first, so the image
|
||||
/// replaces it. Decided by the device, at the size it draws the image, so the draw reuses
|
||||
/// that decode.
|
||||
fn knockout_image(&mut self, _image: &crate::RasterImage<'_>, _transform: Affine) {}
|
||||
/// PdfCraft patch (39): whether a transparency group drawn now is an element of a knockout
|
||||
/// group that the device wants cleared under the group's shape first. If so the
|
||||
/// interpreter draws the group's content once between `begin_knockout_erase` and
|
||||
/// `end_knockout_erase`, in which every paint clears what is under its shape instead of
|
||||
/// painting, and then draws the group as usual, in a layer of its own.
|
||||
fn knockout_erase_wanted(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// PdfCraft patch (39): see `knockout_erase_wanted`.
|
||||
fn begin_knockout_erase(&mut self) {}
|
||||
/// PdfCraft patch (39): see `knockout_erase_wanted`.
|
||||
fn end_knockout_erase(&mut self) {}
|
||||
}
|
||||
|
||||
/// A device that discards all drawing operations.
|
||||
|
||||
+47
-1
@@ -162,7 +162,38 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
|
||||
&& state.soft_mask.is_none()
|
||||
&& state.blend_mode == BlendMode::Normal
|
||||
};
|
||||
let own_layer = x_object.is_transparency_group && !neutral;
|
||||
// PdfCraft patch (39): a group drawn as an element of a knockout group replaces what that
|
||||
// group drew under its shape, the union of its own elements' shapes (#2971). The shape is
|
||||
// cleared by drawing the group's content once in the device's erase mode; the group then
|
||||
// composites over the cleared area in a layer of its own (also when it would otherwise be
|
||||
// drawn straight onto its parent, where its elements would knock each other out).
|
||||
let knockout_element = x_object.is_transparency_group && device.knockout_erase_wanted();
|
||||
if knockout_element {
|
||||
context.save_state();
|
||||
device.begin_knockout_erase();
|
||||
device.push_clip_path(&ClipPath {
|
||||
path: context.get().ctm
|
||||
* Rect::new(
|
||||
x_object.bbox[0] as f64,
|
||||
x_object.bbox[1] as f64,
|
||||
x_object.bbox[2] as f64,
|
||||
x_object.bbox[3] as f64,
|
||||
)
|
||||
.to_path(0.1),
|
||||
fill: FillRule::NonZero,
|
||||
});
|
||||
interpret(
|
||||
TypedIter::new(x_object.decoded.as_ref()),
|
||||
&Resources::from_parent(x_object.resources.clone(), resources.clone()),
|
||||
context,
|
||||
device,
|
||||
);
|
||||
device.pop_clip_path();
|
||||
device.end_knockout_erase();
|
||||
context.restore_state(device);
|
||||
context.path_mut().truncate(0);
|
||||
}
|
||||
let own_layer = x_object.is_transparency_group && (!neutral || knockout_element);
|
||||
|
||||
if own_layer {
|
||||
device.push_transparency_group(
|
||||
@@ -173,6 +204,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
|
||||
|
||||
context.get_mut().graphics_state.non_stroke_alpha = 1.0;
|
||||
context.get_mut().graphics_state.stroke_alpha = 1.0;
|
||||
// PdfCraft patch (39): its elements knock each other out (#2971).
|
||||
if x_object.is_knockout {
|
||||
device.set_knockout();
|
||||
}
|
||||
}
|
||||
|
||||
device.set_soft_mask(context.get().graphics_state.soft_mask.clone());
|
||||
@@ -254,6 +289,17 @@ pub(crate) fn draw_image_xobject<'a, 'b>(
|
||||
soft_mask = None;
|
||||
}
|
||||
|
||||
// PdfCraft patch (39): an image without a mask covers its whole placement rectangle, so in
|
||||
// a knockout group it replaces what the group drew there (#2971). A stencil's or a masked
|
||||
// image's shape is its mask; those composite as before (exact where the mask is opaque or
|
||||
// transparent, which is what masks mostly are). Only an image the device draws clears: one
|
||||
// that doesn't decode (over `MAX_IMAGE_PIXELS`, damaged data) or that it skips (a preview's
|
||||
// tiny image) replaces nothing. The device decides, at its own draw size (review on #2974:
|
||||
// a full-size decode here cost a second decode of every downscaled image).
|
||||
if !x_object.is_mask && !has_alpha && device.in_knockout_group() {
|
||||
device.knockout_image(&RasterImage(x_object.clone()), transform);
|
||||
}
|
||||
|
||||
device.push_transparency_group(
|
||||
context.get().graphics_state.non_stroke_alpha,
|
||||
std::mem::take(&mut soft_mask),
|
||||
|
||||
Vendored
+180
-30
@@ -444,6 +444,13 @@ pub(crate) struct Renderer {
|
||||
/// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their
|
||||
/// colours — reading colours replace document colours, never annotation colours.
|
||||
in_annotations: bool,
|
||||
/// PdfCraft patch (19): one entry per transparency group pushed, whether it is a knockout
|
||||
/// group (`Device::set_knockout`).
|
||||
knockout: Vec<bool>,
|
||||
/// PdfCraft patch (19): inside a knockout erase pass (`Device::begin_knockout_erase`), and
|
||||
/// how many transparency groups were skipped in it (nothing is layered while erasing).
|
||||
erasing: bool,
|
||||
erase_groups: usize,
|
||||
pub(crate) inside_pattern: bool,
|
||||
/// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one
|
||||
/// render, every nested renderer starting empty) and so to one `preview` setting: a mask
|
||||
@@ -467,6 +474,11 @@ enum ImagePixelFormat {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// PdfCraft patch (19): whether the innermost transparency group is a knockout group.
|
||||
fn in_knockout(&self) -> bool {
|
||||
self.knockout.last().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
width: u16,
|
||||
height: u16,
|
||||
@@ -479,6 +491,9 @@ impl Renderer {
|
||||
ctx: RenderContext::new_with(width, height, settings),
|
||||
color_replacement,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
inside_pattern: false,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
outline_cache: cache.outline_cache.clone(),
|
||||
@@ -580,6 +595,9 @@ impl Renderer {
|
||||
// original values, so it is never remapped by the reading-colours setting.
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
inside_pattern: false,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
outline_cache: self.outline_cache.clone(),
|
||||
@@ -744,6 +762,40 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The size an image of `width` x `height` samples is decoded at to be drawn with
|
||||
/// `transform`, or `None` if `draw_image` skips it: off the canvas (PdfCraft patch: see
|
||||
/// `may_paint_rect`; checked before decoding, so a tile no longer decodes every image on its
|
||||
/// page), or, in a preview, covering only a few device pixels (PdfCraft patch; final renders
|
||||
/// always draw them). The tiny-image test measures axis lengths: the size returned maps a
|
||||
/// point, so it includes the translation and only suits the decode hint.
|
||||
fn image_draw_size(&self, width: u32, height: u32, transform: Affine) -> Option<(u32, u32)> {
|
||||
if !may_paint_rect(
|
||||
Rect::new(0.0, 0.0, f64::from(width), f64::from(height)),
|
||||
transform,
|
||||
self.ctx.width(),
|
||||
self.ctx.height(),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let target_width = (transform * Point::new(width as f64, 0.0))
|
||||
.to_vec2()
|
||||
.length()
|
||||
.ceil() as u32;
|
||||
let target_height = (transform * Point::new(0.0, height as f64))
|
||||
.to_vec2()
|
||||
.length()
|
||||
.ceil() as u32;
|
||||
if self.preview {
|
||||
let c = transform.as_coeffs();
|
||||
let tiny_w = (f64::from(width) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32;
|
||||
let tiny_h = (f64::from(height) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32;
|
||||
if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((target_width, target_height))
|
||||
}
|
||||
|
||||
fn draw_image(&mut self, image_data: ImageData, alpha_data: Option<LumaData>) {
|
||||
let cur_transform = *self.ctx.transform();
|
||||
let (x_scale, y_scale) = {
|
||||
@@ -1049,6 +1101,20 @@ impl Renderer {
|
||||
|
||||
#[must_use]
|
||||
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup {
|
||||
// PdfCraft patch (19): in a knockout erase pass every paint clears what is under its
|
||||
// shape (a transparent fill with `Compose::Copy`, weighted by coverage).
|
||||
if self.erasing {
|
||||
self.ctx.set_blend_mode(knockout_blend_mode());
|
||||
self.ctx.set_paint(AlphaColor::<Srgb>::new([0.0, 0.0, 0.0, 0.0]));
|
||||
// A shading's `/BBox` clips what it paints, so it clips what it clears too.
|
||||
return PaintSetup::Clip(match paint {
|
||||
Paint::Pattern(p) => match p.as_ref() {
|
||||
Pattern::Shading(s) => s.shading.clip_path.clone(),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let mut paint_transform = Affine::IDENTITY;
|
||||
let mut clip_path = None;
|
||||
// PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture
|
||||
@@ -1197,6 +1263,9 @@ impl Renderer {
|
||||
),
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
cur_mask: None,
|
||||
inside_pattern: true,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
@@ -1251,6 +1320,9 @@ impl Renderer {
|
||||
// content is never remapped by the reading-colours setting.
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
cur_mask: None,
|
||||
inside_pattern: true,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
@@ -1444,40 +1516,19 @@ impl Renderer {
|
||||
|
||||
impl<'a> Device<'a> for Renderer {
|
||||
fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) {
|
||||
// PdfCraft patch: see `may_paint_rect`. Checked before decoding: off-canvas images
|
||||
// cost nothing, so a tile no longer decodes every image on its page.
|
||||
if !may_paint_rect(
|
||||
Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())),
|
||||
transform,
|
||||
self.ctx.width(),
|
||||
self.ctx.height(),
|
||||
) {
|
||||
// PdfCraft patch (19): an erase pass paints nothing; an unmasked image's rectangle was
|
||||
// cleared by `knockout_shape`, and a masked image's shape is left alone.
|
||||
if self.erasing {
|
||||
return;
|
||||
}
|
||||
let Some((target_width, target_height)) =
|
||||
self.image_draw_size(image.width(), image.height(), transform)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.ctx.set_paint_transform(Affine::IDENTITY);
|
||||
self.ctx.set_aliasing_threshold(Some(1));
|
||||
|
||||
let target_width = (transform * Point::new(image.width() as f64, 0.0))
|
||||
.to_vec2()
|
||||
.length()
|
||||
.ceil() as u32;
|
||||
let target_height = (transform * Point::new(0.0, image.height() as f64))
|
||||
.to_vec2()
|
||||
.length()
|
||||
.ceil() as u32;
|
||||
|
||||
// PdfCraft patch: previews skip images that would cover only a few device pixels.
|
||||
// Final renders always draw them. (Measured as axis lengths: the `target_*` above
|
||||
// map a point, so they include the translation and only suit the decode hint.)
|
||||
if self.preview {
|
||||
let c = transform.as_coeffs();
|
||||
let tiny_w = (f64::from(image.width()) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32;
|
||||
let tiny_h = (f64::from(image.height()) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32;
|
||||
if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match image {
|
||||
hayro_interpret::Image::Stencil(s) => {
|
||||
s.with_stencil(
|
||||
@@ -1567,6 +1618,9 @@ impl<'a> Device<'a> for Renderer {
|
||||
// the reading-colours setting.
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
inside_pattern: false,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
outline_cache: self.outline_cache.clone(),
|
||||
@@ -1644,6 +1698,11 @@ impl<'a> Device<'a> for Renderer {
|
||||
mask: Option<SoftMask<'_>>,
|
||||
blend_mode: BlendMode,
|
||||
) {
|
||||
// PdfCraft patch (19): nothing is layered in a knockout erase pass.
|
||||
if self.erasing {
|
||||
self.erase_groups += 1;
|
||||
return;
|
||||
}
|
||||
let settings = *self.ctx.render_settings();
|
||||
let force_images_may_have_transparency = mask.is_some()
|
||||
|| blend_mode != BlendMode::Normal
|
||||
@@ -1670,6 +1729,7 @@ impl<'a> Device<'a> for Renderer {
|
||||
);
|
||||
self.image_transparency_stack
|
||||
.push(force_images_may_have_transparency);
|
||||
self.knockout.push(false);
|
||||
}
|
||||
|
||||
fn pop_clip_path(&mut self) {
|
||||
@@ -1677,10 +1737,86 @@ impl<'a> Device<'a> for Renderer {
|
||||
}
|
||||
|
||||
fn pop_transparency_group(&mut self) {
|
||||
if self.erasing && self.erase_groups > 0 {
|
||||
self.erase_groups -= 1;
|
||||
return;
|
||||
}
|
||||
self.image_transparency_stack.pop();
|
||||
self.knockout.pop();
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// PdfCraft patch (19): the group just pushed is a knockout group. Its elements are drawn
|
||||
/// with `Compose::Copy`, which (weighted by coverage) replaces what earlier elements drew
|
||||
/// where the element's shape is, so each element composites with the group's initial
|
||||
/// backdrop (transparent: an isolated group, and the non-isolated approximation every
|
||||
/// group layer here already makes) instead of with the elements before it. Against a
|
||||
/// transparent backdrop the element's own blend mode has nothing to blend with, so the
|
||||
/// mix is Normal. This covers paths, strokes, glyph outlines and shadings. Images and
|
||||
/// nested groups are drawn into a layer of their own, which can't be copied (it covers the
|
||||
/// whole canvas, transparent where nothing was drawn): what is under their shape is cleared
|
||||
/// first (`knockout_shape`, the knockout erase pass) and the layer composites over it.
|
||||
fn set_knockout(&mut self) {
|
||||
if let Some(top) = self.knockout.last_mut() {
|
||||
*top = true;
|
||||
self.ctx.set_blend_mode(knockout_blend_mode());
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (19): in a knockout group, clear what the group drew under `shape` (a
|
||||
/// transparent fill with `Compose::Copy`, weighted by coverage) before an element drawn into
|
||||
/// a layer of its own composites there, so the element replaces it.
|
||||
fn knockout_erase_wanted(&mut self) -> bool {
|
||||
self.in_knockout() && !self.erasing
|
||||
}
|
||||
|
||||
fn begin_knockout_erase(&mut self) {
|
||||
self.erasing = true;
|
||||
self.erase_groups = 0;
|
||||
}
|
||||
|
||||
fn end_knockout_erase(&mut self) {
|
||||
self.erasing = false;
|
||||
self.erase_groups = 0;
|
||||
self.ctx.set_blend_mode(knockout_blend_mode());
|
||||
}
|
||||
|
||||
fn in_knockout_group(&mut self) -> bool {
|
||||
self.in_knockout()
|
||||
}
|
||||
|
||||
fn knockout_image(&mut self, image: &hayro_interpret::RasterImage<'_>, transform: Affine) {
|
||||
if !self.in_knockout() {
|
||||
return;
|
||||
}
|
||||
// Only an image `draw_image` will draw, decoded at the size it draws it (the same
|
||||
// decode, so a cached decode serves both).
|
||||
let Some(size) = self.image_draw_size(image.width(), image.height(), transform) else {
|
||||
return;
|
||||
};
|
||||
let mut decodes = false;
|
||||
image.with_rgba(|_, _| decodes = true, Some(size));
|
||||
if decodes {
|
||||
let rect = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height()));
|
||||
self.knockout_shape(&(transform * rect.to_path(0.1)));
|
||||
}
|
||||
}
|
||||
|
||||
fn knockout_shape(&mut self, shape: &BezPath) {
|
||||
if !self.in_knockout() {
|
||||
return;
|
||||
}
|
||||
let old_transform = *self.ctx.transform();
|
||||
let old_rule = *self.ctx.fill_rule();
|
||||
self.ctx.set_transform(Affine::IDENTITY);
|
||||
self.ctx.set_fill_rule(Fill::NonZero);
|
||||
self.ctx.set_blend_mode(knockout_blend_mode());
|
||||
self.ctx.set_paint(AlphaColor::<Srgb>::new([0.0, 0.0, 0.0, 0.0]));
|
||||
self.ctx.fill_path(shape);
|
||||
self.ctx.set_fill_rule(old_rule);
|
||||
self.ctx.set_transform(old_transform);
|
||||
}
|
||||
|
||||
fn set_soft_mask(&mut self, mask: Option<SoftMask<'_>>) {
|
||||
let settings = *self.ctx.render_settings();
|
||||
self.cur_mask = mask.map(|m| {
|
||||
@@ -1784,7 +1920,13 @@ impl<'a> Device<'a> for Renderer {
|
||||
}
|
||||
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
self.ctx.set_blend_mode(convert_blend_mode(blend_mode));
|
||||
// PdfCraft patch (19): inside a knockout group every element replaces what it covers.
|
||||
let mode = if self.in_knockout() {
|
||||
knockout_blend_mode()
|
||||
} else {
|
||||
convert_blend_mode(blend_mode)
|
||||
};
|
||||
self.ctx.set_blend_mode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1868,6 +2010,9 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
|
||||
// content is never remapped by the reading-colours setting.
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
knockout: Vec::new(),
|
||||
erasing: false,
|
||||
erase_groups: 0,
|
||||
inside_pattern: false,
|
||||
cur_mask: None,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
@@ -1962,6 +2107,11 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill {
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (19): how an element of a knockout group is drawn (see `set_knockout`).
|
||||
fn knockout_blend_mode() -> peniko::BlendMode {
|
||||
peniko::BlendMode::new(Mix::Normal, Compose::Copy)
|
||||
}
|
||||
|
||||
fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
|
||||
let mix = match blend_mode {
|
||||
BlendMode::Normal => Mix::Normal,
|
||||
|
||||
Reference in New Issue
Block a user