mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
Finding a placed PDF's Bounding Box crop rendered its page at 2048 pixels and took the bounds of the painted pixels. PDF export measures every such crop that import left unmeasured, so exporting a 17-page document that places one complex illustration took about 4 s longer than before crops were supported. The box is now the union of the page's painted paths (strokes included), glyphs and images, each cut to its clip, collected by interpreting the page without rasterizing it: the same box, exact instead of to the nearest pixel, in milliseconds. Test: content_crop_box_is_measured_without_rendering_the_page.
134 lines
5.5 KiB
Rust
134 lines
5.5 KiB
Rust
//! The bounds of what a PDF page draws, found by interpreting its drawing operations: the union
|
|
//! of every painted path, glyph and image, each cut to the clip in force when it's painted.
|
|
//! Nothing is rasterized, so measuring a page costs a fraction of drawing it.
|
|
|
|
use hayro::hayro_interpret::font::Glyph;
|
|
use hayro::hayro_interpret::{
|
|
BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt,
|
|
SoftMask, TransformExt, interpret_page,
|
|
};
|
|
use vello_cpu::kurbo::{self, Affine, BezPath, Rect, Shape};
|
|
|
|
/// The bounds of what PDF page `page` draws, as fractions of the page as rendered (its visible
|
|
/// crop box, rotated as it displays): left, top, right, bottom. `None` when it draws nothing.
|
|
pub(crate) fn page_content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> {
|
|
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
|
|
let p = pdf.pages().get(page)?;
|
|
let (w, h) = p.render_dimensions();
|
|
let (w, h) = (f64::from(w), f64::from(h));
|
|
if !(w > 0.0 && h > 0.0) {
|
|
return None;
|
|
}
|
|
let initial = p.initial_transform(true).to_kurbo();
|
|
let page_rect = Rect::new(0.0, 0.0, w, h);
|
|
let crop = (initial * p.intersected_crop_box().to_kurbo().to_path(0.1)).bounding_box().intersect(page_rect);
|
|
let cache = InterpreterCache::new();
|
|
let mut ctx = Context::new(initial, page_rect, &cache, p.xref(), InterpreterSettings::default());
|
|
let mut dev = Bounds { clips: vec![crop], hidden: Vec::new(), out: None };
|
|
interpret_page(p, &mut ctx, &mut dev);
|
|
let r = dev.out?;
|
|
let frac = [r.x0 / w, r.y0 / h, r.x1 / w, r.y1 / h];
|
|
frac.iter().all(|v| v.is_finite()).then_some(frac)
|
|
}
|
|
|
|
/// A [`Device`] that collects bounds instead of drawing.
|
|
struct Bounds {
|
|
/// The bounding box of each clip in force, innermost last (device space); the first is the
|
|
/// page's crop box.
|
|
clips: Vec<Rect>,
|
|
/// For each open transparency group: whether nothing in it can show (opacity 0).
|
|
hidden: Vec<bool>,
|
|
out: Option<Rect>,
|
|
}
|
|
|
|
impl Bounds {
|
|
fn add(&mut self, r: Rect) {
|
|
if self.hidden.last().copied().unwrap_or(false) {
|
|
return;
|
|
}
|
|
let Some(clip) = self.clips.last() else { return };
|
|
let r = r.intersect(*clip);
|
|
if !(r.width() > 0.0 && r.height() > 0.0) || ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
|
return;
|
|
}
|
|
self.out = Some(self.out.map_or(r, |o| o.union(r)));
|
|
}
|
|
|
|
fn add_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'_>, mode: &PathDrawMode) {
|
|
if let Paint::Color(c) = paint
|
|
&& c.to_rgba().to_rgba8()[3] == 0
|
|
{
|
|
return;
|
|
}
|
|
let r = match mode {
|
|
PathDrawMode::Fill(_) => (transform * path.clone()).bounding_box(),
|
|
PathDrawMode::Stroke(s) => {
|
|
// Dashes only remove parts of the line: the undashed stroke bounds them.
|
|
let stroke = kurbo::Stroke {
|
|
width: f64::from(s.line_width.max(0.0)),
|
|
join: s.line_join,
|
|
miter_limit: f64::from(s.miter_limit),
|
|
start_cap: s.line_cap,
|
|
end_cap: s.line_cap,
|
|
dash_pattern: Default::default(),
|
|
dash_offset: 0.0,
|
|
};
|
|
// A quarter point on the page, in the path's own units.
|
|
let scale = transform.determinant().abs().sqrt();
|
|
let tolerance = if scale.is_finite() && scale > 1e-9 { (0.25 / scale).min(1e6) } else { 0.25 };
|
|
let outline = kurbo::stroke(path.iter(), &stroke, &kurbo::StrokeOpts::default(), tolerance);
|
|
(transform * outline).bounding_box()
|
|
}
|
|
};
|
|
self.add(r);
|
|
}
|
|
}
|
|
|
|
impl<'a> Device<'a> for Bounds {
|
|
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
|
|
|
fn set_blend_mode(&mut self, _: BlendMode) {}
|
|
|
|
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, mode: &PathDrawMode) {
|
|
self.add_path(path, transform, paint, mode);
|
|
}
|
|
|
|
fn push_clip_path(&mut self, clip: &ClipPath) {
|
|
let top = self.clips.last().copied().unwrap_or(Rect::ZERO);
|
|
self.clips.push(clip.path.bounding_box().intersect(top));
|
|
}
|
|
|
|
fn push_transparency_group(&mut self, opacity: f32, _: Option<SoftMask<'a>>, _: BlendMode) {
|
|
let parent = self.hidden.last().copied().unwrap_or(false);
|
|
self.hidden.push(parent || opacity <= 0.0);
|
|
}
|
|
|
|
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) {
|
|
let mode = match mode {
|
|
GlyphDrawMode::Invisible => return,
|
|
GlyphDrawMode::Fill => PathDrawMode::Fill(FillRule::NonZero),
|
|
GlyphDrawMode::Stroke(s) => PathDrawMode::Stroke(s.clone()),
|
|
};
|
|
match glyph {
|
|
Glyph::Outline(o) => self.add_path(&o.outline(), transform * glyph_transform, paint, &mode),
|
|
Glyph::Type3(g) => g.interpret(self, transform, glyph_transform, paint),
|
|
}
|
|
}
|
|
|
|
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
|
let r = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height()));
|
|
self.add((transform * r.to_path(0.1)).bounding_box());
|
|
}
|
|
|
|
fn pop_clip_path(&mut self) {
|
|
// The crop box stays.
|
|
if self.clips.len() > 1 {
|
|
self.clips.pop();
|
|
}
|
|
}
|
|
|
|
fn pop_transparency_group(&mut self) {
|
|
self.hidden.pop();
|
|
}
|
|
}
|