use kurbo::{Affine, BezPath, Rect, Shape};
use pdfrum_font::GlyphCache;
use pdfrum_page::ClipStack;
use pdfrum_page::{ClipEntry, ClipRule, TextClipRun};
use crate::options::RenderOptions;
use crate::device::{FillRule, RenderDevice};
use crate::path::{outer_rect, path_rect};
const EMPTY_CLIP_RECT: Rect = Rect::new(-1.0, -1.0, 0.0, 0.0);
#[derive(Debug, Clone, PartialEq)]
pub enum Clip {
Rect(Rect),
Path(BezPath, FillRule),
Empty,
}
fn is_degenerate(path: &BezPath) -> bool {
let b = path.bounding_box();
!b.width().is_finite() || !b.height().is_finite() || b.width() <= 0.0 || b.height() <= 0.0
}
fn text_clip_glyphs(
run: &TextClipRun,
glyphs: &mut GlyphCache,
opts: &RenderOptions,
to_device: Affine,
) -> Vec<BezPath> {
let state = pdfrum_page::GraphicsState {
text: pdfrum_page::TextState {
char_space: run.char_space,
word_space: run.word_space,
..pdfrum_page::TextState::default()
},
..pdfrum_page::GraphicsState::default()
};
let opts = RenderOptions {
subpixel_text_positioning: true,
..opts.clone()
};
let kinds = crate::text::TextPaintKinds {
fill: false,
stroke: false,
clip: true,
};
crate::text::place_glyphs(&run.object, &state, glyphs, to_device, &opts, kinds)
.iter()
.map(crate::text::PlacedGlyph::device_path)
.collect()
}
#[must_use]
pub fn resolve(
clip: &ClipStack,
to_device: Affine,
glyphs: &mut GlyphCache,
opts: &RenderOptions,
) -> Vec<Clip> {
let mut out = Vec::with_capacity(clip.len());
if !clip.is_empty() {
crate::walkprofile::alloc_items(
crate::walkprofile::Site::ClipVec,
clip.len(),
core::mem::size_of::<Clip>(),
);
}
for entry in clip.entries() {
match entry {
ClipEntry::Path {
path,
rule: clip_rule,
} => {
if path.elements().is_empty() || is_degenerate(path) {
out.push(Clip::Empty);
continue;
}
let rule = match clip_rule {
ClipRule::EvenOdd => FillRule::EvenOdd,
ClipRule::Winding => FillRule::Winding,
};
if let Some(r) = path_rect(path, to_device) {
out.push(Clip::Rect(outer_rect(r).to_rect()));
} else {
crate::walkprofile::alloc_items(
crate::walkprofile::Site::ClipPath,
path.elements().len(),
core::mem::size_of::<kurbo::PathEl>(),
);
out.push(Clip::Path(to_device * path.clone(), rule));
}
}
ClipEntry::Text { runs } => {
let mut union = BezPath::new();
for run in runs {
for glyph in text_clip_glyphs(run, glyphs, opts, to_device) {
union.extend(glyph);
}
}
if union.elements().is_empty() {
out.push(Clip::Empty);
} else {
out.push(Clip::Path(union, FillRule::Winding));
}
}
}
}
out
}
pub fn push(device: &mut dyn RenderDevice, clips: &[Clip]) -> usize {
for clip in clips {
match clip {
Clip::Rect(r) => device.push_clip_rect(*r),
Clip::Path(p, rule) => device.push_clip(p, *rule),
Clip::Empty => device.push_clip_rect(EMPTY_CLIP_RECT),
}
}
clips.len()
}
pub fn pop(device: &mut dyn RenderDevice, n: usize) {
for _ in 0..n {
device.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device_bounds(clips: &[Clip]) -> Option<Rect> {
let mut acc: Option<Rect> = None;
for clip in clips {
let r = match clip {
Clip::Rect(r) => *r,
Clip::Path(p, _) => p.bounding_box(),
Clip::Empty => EMPTY_CLIP_RECT,
};
acc = Some(match acc {
Some(a) => a.intersect(r),
None => r,
});
}
acc
}
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((x0, y0));
p.line_to((x1, y0));
p.line_to((x1, y1));
p.line_to((x0, y1));
p.close_path();
p
}
#[test]
fn empty_path_is_the_empty_clip() {
let mut stack = ClipStack::new();
stack.push_empty();
let clips = resolve_bare(&stack);
assert_eq!(clips.as_slice(), &[Clip::Empty]);
#[expect(
clippy::float_cmp,
reason = "the rect is a literal const; an exact width is what is being pinned"
)]
{
assert_eq!(EMPTY_CLIP_RECT.width(), 1.0);
}
const { assert!(EMPTY_CLIP_RECT.x1 <= 0.0 && EMPTY_CLIP_RECT.y1 <= 0.0) };
}
#[test]
fn an_axis_aligned_rect_clips_hard_edged_and_snapped() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(1.2, 2.7, 5.4, 8.1), ClipRule::Winding);
let clips = resolve_bare(&stack);
assert_eq!(
clips.as_slice(),
&[Clip::Rect(Rect::new(1.0, 2.0, 6.0, 9.0))]
);
}
#[test]
fn a_non_rect_clip_stays_a_path() {
let mut curved = BezPath::new();
curved.move_to((0.0, 0.0));
curved.curve_to((5.0, 0.0), (5.0, 5.0), (0.0, 5.0));
curved.close_path();
let mut stack = ClipStack::new();
stack.push_path(curved, ClipRule::EvenOdd);
let clips = resolve_bare(&stack);
assert!(matches!(
clips.first(),
Some(Clip::Path(_, FillRule::EvenOdd))
));
}
fn text_run(text: &[u8], x: f64) -> TextClipRun {
let font = std::sync::Arc::new(pdfrum_font::Font::load_standard(
pdfrum_font::StandardFont::Helvetica,
&pdfrum_font::FontCache::default(),
));
TextClipRun {
object: pdfrum_page::TextObject {
segments: Box::new([pdfrum_page::TextSegment {
codes: text.to_vec().into_boxed_slice(),
kerning: 0.0,
}]),
position: kurbo::Point::new(x, 0.0),
matrix: Affine::IDENTITY,
font: Some((font, 20.0)),
font_source: None,
render_mode: pdfrum_page::TextRenderMode::Clip,
type3_metrics: std::collections::BTreeMap::new(),
},
char_space: 0.0,
word_space: 0.0,
}
}
fn resolve_bare(stack: &ClipStack) -> Vec<Clip> {
let mut glyphs = pdfrum_font::GlyphCache::default();
resolve(
stack,
Affine::IDENTITY,
&mut glyphs,
&RenderOptions::default(),
)
}
#[test]
fn text_clips_union_every_run_in_the_batch_into_one_path() {
let mut stack = ClipStack::new();
assert!(
stack
.push_text(vec![text_run(b"H", 0.0), text_run(b"H", 100.0)])
.is_ok()
);
let clips = resolve_bare(&stack);
let Some(Clip::Path(p, rule)) = clips.first() else {
panic!("expected a path clip, got {clips:?}")
};
assert_eq!(*rule, FillRule::Winding);
let b = p.bounding_box();
assert!(b.x0 < 5.0 && b.x1 > 100.0, "one path over both runs: {b:?}");
assert_eq!(clips.len(), 1, "the batch is one clip, not one per run");
}
#[test]
fn an_empty_text_clip_still_clips_everything_out() {
let mut stack = ClipStack::new();
assert!(stack.push_text(vec![text_run(b"", 0.0)]).is_ok());
let clips = resolve_bare(&stack);
assert_eq!(clips.as_slice(), &[Clip::Empty]);
}
#[test]
fn clips_intersect_for_the_cull_bounds() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(0.0, 0.0, 10.0, 10.0), ClipRule::Winding);
stack.push_path(rect_path(5.0, 5.0, 20.0, 20.0), ClipRule::Winding);
let clips = resolve_bare(&stack);
let b = device_bounds(&clips).expect("bounded");
assert_eq!((b.x0, b.y0, b.x1, b.y1), (5.0, 5.0, 10.0, 10.0));
}
#[test]
fn an_unclipped_stack_bounds_nothing() {
assert_eq!(device_bounds(&[]), None);
}
#[test]
fn the_transform_is_applied_before_the_rect_test() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(0.0, 0.0, 4.0, 2.0), ClipRule::Winding);
let quarter = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
assert!(matches!(
resolve(
&stack,
quarter,
&mut pdfrum_font::GlyphCache::default(),
&RenderOptions::default()
)
.first(),
Some(Clip::Rect(_))
));
let eighth = Affine::rotate(std::f64::consts::FRAC_PI_4);
assert!(matches!(
resolve(
&stack,
eighth,
&mut pdfrum_font::GlyphCache::default(),
&RenderOptions::default()
)
.first(),
Some(Clip::Path(..))
));
}
}