use kurbo::{BezPath, Rect, Shape};
use std::sync::Arc;
pub const MAX_TEXT_OBJECTS: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("text clip batch of {adding} would exceed the {limit}-object cap ({have} already held)")]
pub struct TextClipLimit {
pub have: usize,
pub adding: usize,
pub limit: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub enum ClipRule {
#[default]
Winding,
EvenOdd,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ClipEntry {
Path {
path: BezPath,
rule: ClipRule,
},
Text {
runs: Vec<TextClipRun>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextClipRun {
pub object: crate::TextObject,
pub char_space: f32,
pub word_space: f32,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ClipStack {
entries: Arc<Vec<ClipEntry>>,
text_objects: usize,
}
impl ClipStack {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn entries(&self) -> &[ClipEntry] {
&self.entries
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn push_path(&mut self, path: BezPath, rule: ClipRule) {
let incoming = path.bounding_box();
if let Some(ClipEntry::Path {
path: previous,
rule: _,
}) = self.entries.last()
&& let Some(rect) = as_rectangle(previous)
&& contains_rect(rect, incoming)
{
Arc::make_mut(&mut self.entries).pop();
}
Arc::make_mut(&mut self.entries).push(ClipEntry::Path { path, rule });
}
pub fn push_text(&mut self, runs: Vec<TextClipRun>) -> Result<(), TextClipLimit> {
let adding = runs.len();
if self.text_objects + adding > MAX_TEXT_OBJECTS {
return Err(TextClipLimit {
have: self.text_objects,
adding,
limit: MAX_TEXT_OBJECTS,
});
}
self.text_objects += adding;
Arc::make_mut(&mut self.entries).push(ClipEntry::Text { runs });
Ok(())
}
pub fn push_empty(&mut self) {
Arc::make_mut(&mut self.entries).push(ClipEntry::Path {
path: Rect::ZERO.to_path(0.1),
rule: ClipRule::Winding,
});
}
#[must_use]
pub fn bounds(&self) -> Option<Rect> {
let mut result: Option<Rect> = None;
for entry in self.entries.iter() {
let rect = match entry {
ClipEntry::Path { path, .. } => path.bounding_box(),
ClipEntry::Text { runs } => {
let mut union: Option<Rect> = None;
for run in runs {
let p = run.object.position;
let b = Rect::new(p.x, p.y, p.x, p.y);
union = Some(union.map_or(b, |u| u.union(b)));
}
union?
}
};
result = Some(result.map_or(rect, |r| r.intersect(rect)));
}
result
}
}
fn as_rectangle(path: &BezPath) -> Option<Rect> {
let points: Vec<_> = path
.elements()
.iter()
.filter_map(|el| match el {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p),
_ => None,
})
.collect();
if !(4..=5).contains(&points.len()) {
return None;
}
let (p0, p2) = (points.first()?, points.get(2)?);
let rect = Rect::from_points(*p0, *p2);
let on_edge = |p: &kurbo::Point| {
let x_edge = (p.x - rect.x0).abs() < 1e-9 || (p.x - rect.x1).abs() < 1e-9;
let y_edge = (p.y - rect.y0).abs() < 1e-9 || (p.y - rect.y1).abs() < 1e-9;
x_edge && y_edge
};
points.iter().all(on_edge).then_some(rect)
}
fn contains_rect(outer: Rect, inner: Rect) -> bool {
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ClipRule, ClipStack, MAX_TEXT_OBJECTS, TextClipLimit, TextClipRun};
use kurbo::{BezPath, Rect, Shape};
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
Rect::new(x0, y0, x1, y1).to_path(0.1)
}
fn run_at(x: f64, y: f64) -> TextClipRun {
TextClipRun {
object: crate::TextObject {
segments: Box::new([]),
position: kurbo::Point::new(x, y),
matrix: kurbo::Affine::IDENTITY,
font: None,
font_source: None,
render_mode: crate::ops::TextRenderMode::Clip,
type3_metrics: std::collections::BTreeMap::new(),
},
char_space: 0.0,
word_space: 0.0,
}
}
#[test]
fn a_contained_rectangle_replaces_its_container() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
assert_eq!(stack.len(), 1);
stack.push_path(rect_path(10.0, 10.0, 50.0, 50.0), ClipRule::Winding);
assert_eq!(stack.len(), 1);
let bounds = stack.bounds().expect("bounds");
assert!((bounds.width() - 40.0).abs() < 1.0);
}
#[test]
fn an_overlapping_rectangle_does_not_merge() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
stack.push_path(rect_path(50.0, 50.0, 150.0, 150.0), ClipRule::Winding);
assert_eq!(stack.len(), 2);
}
#[test]
fn a_text_batch_past_the_cap_is_dropped_whole() {
let mut stack = ClipStack::new();
let run = || run_at(0.0, 0.0);
let batch: Vec<_> = std::iter::repeat_with(run).take(MAX_TEXT_OBJECTS).collect();
assert!(stack.push_text(batch).is_ok());
assert_eq!(stack.len(), 1);
assert_eq!(
stack.push_text(vec![run()]),
Err(TextClipLimit {
have: MAX_TEXT_OBJECTS,
adding: 1,
limit: MAX_TEXT_OBJECTS,
})
);
assert_eq!(stack.len(), 1);
}
#[test]
fn a_batch_that_would_overflow_is_refused_before_any_of_it_lands() {
let mut stack = ClipStack::new();
let run = || run_at(0.0, 0.0);
let batch: Vec<_> = std::iter::repeat_with(run)
.take(MAX_TEXT_OBJECTS - 1)
.collect();
assert!(stack.push_text(batch).is_ok());
assert_eq!(
stack.push_text(vec![run(), run()]),
Err(TextClipLimit {
have: MAX_TEXT_OBJECTS - 1,
adding: 2,
limit: MAX_TEXT_OBJECTS,
})
);
assert_eq!(stack.len(), 1);
}
#[test]
fn an_empty_clip_blanks_everything() {
let mut stack = ClipStack::new();
stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
stack.push_empty();
let bounds = stack.bounds().expect("bounds");
assert!(bounds.area() < 1e-6, "got {bounds:?}");
}
#[test]
fn an_unclipped_stack_has_no_bounds() {
assert!(ClipStack::new().bounds().is_none());
assert!(ClipStack::new().is_empty());
}
#[test]
fn text_layers_union_within_and_intersect_between() {
let mut stack = ClipStack::new();
assert!(
stack
.push_text(vec![run_at(0.0, 0.0), run_at(100.0, 0.0)])
.is_ok()
);
let bounds = stack.bounds().expect("bounds");
assert!((bounds.width() - 100.0).abs() < 1.0);
assert!(
stack
.push_text(vec![run_at(0.0, 0.0), run_at(20.0, 0.0)])
.is_ok()
);
let bounds = stack.bounds().expect("bounds");
assert!(bounds.width() <= 21.0);
}
}