use std::sync::{Arc, Mutex, MutexGuard};
use kurbo::{Affine, BezPath, Rect, Stroke};
use pdfrum_page::BlendMode;
use pdfrum_render::{
AlphaMask, AntiAlias, Brush, FillRule, ImageQuality, Pixmap, RasterBackend, RasterImage,
RenderDevice,
};
use crate::doc::Svg;
use crate::evidence::{Fingerprint, Seed, Witness, classify};
use crate::report::{RasterRegion, RasterReport};
#[derive(Debug)]
struct Shared {
svg: Option<Svg>,
report: RasterReport,
pending: Vec<Witness>,
}
fn lock(shared: &Mutex<Shared>) -> MutexGuard<'_, Shared> {
match shared.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[derive(Debug)]
pub struct SvgBackend<'a, B> {
inner: &'a B,
shared: Arc<Mutex<Shared>>,
}
impl<'a, B> SvgBackend<'a, B> {
pub fn new(inner: &'a B) -> Self {
Self {
inner,
shared: Arc::new(Mutex::new(Shared {
svg: None,
report: RasterReport::default(),
pending: Vec::new(),
})),
}
}
#[must_use]
pub fn into_svg(self) -> (String, RasterReport) {
let mut shared = lock(&self.shared);
let report = core::mem::take(&mut shared.report);
let svg = shared.svg.take().map(Svg::finish).unwrap_or_default();
(svg, report)
}
}
#[derive(Debug)]
enum Role {
Root,
Offscreen(Fingerprint),
}
#[derive(Debug)]
pub struct SvgDevice<D> {
inner: D,
role: Role,
shared: Arc<Mutex<Shared>>,
}
impl<D> SvgDevice<D> {
fn record(&mut self, f: impl FnOnce(&mut Svg)) {
if !matches!(self.role, Role::Root) {
return;
}
let mut shared = lock(&self.shared);
if let Some(svg) = shared.svg.as_mut() {
f(svg);
}
}
fn note(&mut self, f: impl FnOnce(&mut Fingerprint)) {
if let Role::Offscreen(seen) = &mut self.role {
seen.draws += 1;
f(seen);
}
}
}
fn brush_color(brush: &Brush<'_>) -> Option<peniko::Color> {
match brush {
Brush::Solid(c) => Some(*c),
Brush::Image(_) => None,
}
}
impl<D: RenderDevice> RenderDevice for SvgDevice<D> {
fn fill_path(
&mut self,
path: &BezPath,
t: Affine,
brush: &Brush<'_>,
rule: FillRule,
aa: AntiAlias,
) {
self.inner.fill_path(path, t, brush, rule, aa);
self.note(|f| {
f.drew.fills = true;
f.full_cover_fill |= matches!(aa, AntiAlias::FullCover);
});
if let Some(color) = brush_color(brush) {
self.record(|svg| svg.fill_path(path, t, color, rule, aa));
}
}
fn stroke_path(
&mut self,
path: &BezPath,
t: Affine,
brush: &Brush<'_>,
stroke: &Stroke,
aa: AntiAlias,
) {
self.inner.stroke_path(path, t, brush, stroke, aa);
self.note(|f| f.drew.strokes = true);
if let Some(color) = brush_color(brush) {
self.record(|svg| svg.stroke_path(path, t, color, stroke, aa));
}
}
fn draw_image(&mut self, img: &RasterImage, t: Affine, quality: ImageQuality, alpha: f32) {
self.inner.draw_image(img, t, quality, alpha);
self.note(|f| f.drew.images = true);
if !matches!(self.role, Role::Root) {
return;
}
let png = img.encode_png().ok();
let mut shared = lock(&self.shared);
let cause = classify(&core::mem::take(&mut shared.pending));
shared.report.push(RasterRegion {
bounds: image_bounds(img, t),
cause,
});
if let (Some(png), Some(svg)) = (png, shared.svg.as_mut()) {
svg.draw_raster_region(&png, img.width(), img.height(), t, alpha, cause);
}
}
fn push_clip(&mut self, path: &BezPath, rule: FillRule) {
self.inner.push_clip(path, rule);
self.record(|svg| svg.push_clip(path, rule));
}
fn push_clip_rect(&mut self, rect: Rect) {
self.inner.push_clip_rect(rect);
self.record(|svg| svg.push_clip_rect(rect));
}
fn push_layer(&mut self, blend: BlendMode, alpha: f32, mask: Option<&AlphaMask>) {
self.inner.push_layer(blend, alpha, mask);
self.record(|svg| svg.push_layer(blend, alpha));
}
fn pop(&mut self) {
self.inner.pop();
self.record(Svg::pop);
}
}
fn image_bounds(img: &RasterImage, t: Affine) -> Rect {
t.transform_rect_bbox(Rect::new(
0.0,
0.0,
f64::from(img.width()),
f64::from(img.height()),
))
}
impl<B: RasterBackend> RasterBackend for SvgBackend<'_, B> {
type Device = SvgDevice<B::Device>;
fn new_target(&self, w: u32, h: u32, clear: peniko::Color) -> Self::Device {
let inner = self.inner.new_target(w, h, clear);
let role = {
let mut shared = lock(&self.shared);
if shared.svg.is_none() {
shared.svg = Some(Svg::new(w, h));
Role::Root
} else {
Role::Offscreen(Fingerprint::default())
}
};
SvgDevice {
inner,
role,
shared: Arc::clone(&self.shared),
}
}
fn new_target_with_backdrop(&self, base: &Pixmap) -> Self::Device {
SvgDevice {
inner: self.inner.new_target_with_backdrop(base),
role: Role::Offscreen(Fingerprint {
seed: Seed::Backdrop,
..Fingerprint::default()
}),
shared: Arc::clone(&self.shared),
}
}
fn snapshot(&self, d: &Self::Device) -> Pixmap {
self.inner.snapshot(&d.inner)
}
fn finish(&self, d: Self::Device) -> Pixmap {
if let Role::Offscreen(seen) = d.role {
lock(&d.shared).pending.push(seen);
}
self.inner.finish(d.inner)
}
}
#[cfg(test)]
mod tests {
use pdfrum_raster_tinyskia::TinySkiaBackend;
use super::*;
fn square(size: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((size, 0.0));
p.line_to((size, size));
p.line_to((0.0, size));
p.close_path();
p
}
#[test]
fn the_recorder_and_its_devices_are_send_and_sync() {
fn send_sync<T: Send + Sync>() {}
send_sync::<SvgBackend<'static, TinySkiaBackend>>();
send_sync::<SvgDevice<<TinySkiaBackend as RasterBackend>::Device>>();
send_sync::<Shared>();
}
#[test]
fn the_first_target_is_the_root_and_the_rest_are_offscreen() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let root = backend.new_target(8, 8, peniko::Color::WHITE);
assert!(matches!(root.role, Role::Root));
let sub = backend.new_target(4, 4, peniko::Color::TRANSPARENT);
assert!(matches!(sub.role, Role::Offscreen(_)));
drop(backend.finish(sub));
drop(backend.finish(root));
}
#[test]
fn a_root_fill_reaches_both_the_pixels_and_the_document() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
root.fill_path(
&square(8.0),
Affine::IDENTITY,
&Brush::Solid(peniko::Color::from_rgba8(255, 0, 0, 255)),
FillRule::Winding,
AntiAlias::Off,
);
let pixels = backend.finish(root);
assert_eq!(
pixels.pixel(4, 4),
Some([255, 0, 0, 255]),
"still rasterized"
);
let (svg, report) = backend.into_svg();
assert!(svg.contains("fill=\"#ff0000\""));
assert!(report.is_empty(), "a fill is not a raster region");
}
#[test]
fn an_offscreen_fill_stays_out_of_the_document() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let root = backend.new_target(8, 8, peniko::Color::WHITE);
let mut sub = backend.new_target(4, 4, peniko::Color::TRANSPARENT);
sub.fill_path(
&square(4.0),
Affine::IDENTITY,
&Brush::Solid(peniko::Color::from_rgba8(0, 255, 0, 255)),
FillRule::Winding,
AntiAlias::On,
);
drop(backend.finish(sub));
drop(backend.finish(root));
let (svg, _) = backend.into_svg();
assert!(!svg.contains("#00ff00"), "the offscreen is not the page");
}
#[test]
fn an_offscreen_subtree_blitted_back_is_reported_as_a_composite() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
let mut sub = backend.new_target(4, 4, peniko::Color::TRANSPARENT);
sub.fill_path(
&square(4.0),
Affine::IDENTITY,
&Brush::Solid(peniko::Color::from_rgba8(0, 0, 255, 255)),
FillRule::Winding,
AntiAlias::On,
);
let pixels = backend.finish(sub);
root.draw_image(
&pixels,
Affine::translate((2.0, 2.0)),
ImageQuality::Nearest,
1.0,
);
drop(backend.finish(root));
let (svg, report) = backend.into_svg();
assert_eq!(
report.counts(),
vec![(crate::RasterCause::CompositedGroup, 1)]
);
assert_eq!(
report.regions().first().map(|r| r.bounds),
Some(Rect::new(2.0, 2.0, 6.0, 6.0))
);
assert!(svg.contains("data-cause=\"composited-group\""));
}
#[test]
fn an_image_with_no_offscreen_behind_it_is_a_sampled_source() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
let img = Pixmap::filled(2, 2, peniko::Color::from_rgba8(1, 2, 3, 255));
root.draw_image(&img, Affine::IDENTITY, ImageQuality::Nearest, 1.0);
drop(backend.finish(root));
let (_, report) = backend.into_svg();
assert_eq!(
report.counts(),
vec![(crate::RasterCause::SampledSource, 1)]
);
}
#[test]
fn a_backdrop_target_reports_a_non_isolated_group() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
let base = backend.snapshot(&root);
let sub = backend.new_target_with_backdrop(&base);
let pixels = backend.finish(sub);
root.draw_image(&pixels, Affine::IDENTITY, ImageQuality::Nearest, 1.0);
drop(backend.finish(root));
let (_, report) = backend.into_svg();
assert_eq!(
report.counts(),
vec![(crate::RasterCause::NonIsolatedGroup, 1)]
);
}
#[test]
fn clips_and_layers_nest_and_close_on_the_root() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
root.push_clip_rect(Rect::new(0.0, 0.0, 4.0, 8.0));
root.push_layer(BlendMode::Multiply, 0.5, None);
root.pop();
root.pop();
drop(backend.finish(root));
let (svg, _) = backend.into_svg();
assert!(svg.contains("mix-blend-mode:multiply"));
assert_eq!(svg.matches("</g>").count(), 2);
}
#[test]
fn the_evidence_is_consumed_by_the_draw_it_explains() {
let tiny = TinySkiaBackend::new();
let backend = SvgBackend::new(&tiny);
let mut root = backend.new_target(8, 8, peniko::Color::WHITE);
let sub = backend.new_target(2, 2, peniko::Color::TRANSPARENT);
let pixels = backend.finish(sub);
root.draw_image(&pixels, Affine::IDENTITY, ImageQuality::Nearest, 1.0);
root.draw_image(
&pixels,
Affine::translate((4.0, 0.0)),
ImageQuality::Nearest,
1.0,
);
drop(backend.finish(root));
let (_, report) = backend.into_svg();
assert_eq!(
report.counts(),
vec![
(crate::RasterCause::CompositedGroup, 1),
(crate::RasterCause::SampledSource, 1)
]
);
}
}