use crate::build::BuildContext;
use crate::ops::Op;
use crate::page::PageObject;
use crate::resources::Resources;
use kurbo::{Affine, Rect};
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_font::{CharCode, Type3Font};
use pdfrum_object::Resolve;
const TEXT_UNIT_IN_GLYPH_UNITS: f64 = 1000.0;
#[derive(Debug, Clone, PartialEq)]
pub struct Type3Metrics {
pub width: f32,
pub bbox: Rect,
pub painted: Rect,
pub colored: bool,
pub objects: Vec<PageObject>,
}
#[must_use]
pub fn metrics<R: Resolve>(
font: &Type3Font,
code: CharCode,
page_resources: Option<&pdfrum_object::Dict>,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Type3Metrics> {
let stream = font.char_proc(code, r)?;
let content = pdfrum_filters::decode_chain(&stream, 0, r, limits, diags).data;
let ops = crate::parse_content(&content, limits, diags);
let mut declared: Option<(f32, Option<Rect>, bool)> = None;
for op in &ops {
match op {
Op::Type3Width(wx, _) => {
declared = Some((*wx, None, true));
break;
}
Op::Type3WidthBBox(wx, _, llx, lly, urx, ury) => {
declared = Some((
*wx,
Some(Rect::new(
f64::from(*llx),
f64::from(*lly),
f64::from(*urx),
f64::from(*ury),
)),
false,
));
break;
}
_ => {}
}
}
let (width, stated, colored) = declared.unwrap_or((0.0, None, false));
let objects = if ctx.enter_type3() {
let resources = Resources::choose(
font.resources.clone(),
page_resources.cloned(),
page_resources.cloned(),
);
let page = crate::build_page(&ops, &resources, r, ctx, limits, diags);
ctx.leave_type3();
page.objects
} else {
diags.record(
pdfrum_common::Severity::Recovered,
pdfrum_common::DiagKind::FormRecursionRefused,
None,
);
Vec::new()
};
let usable = stated.filter(|b| b.x1 > b.x0 && b.y1 > b.y0);
let painted = painted_extent(&objects);
let text_box = match usable {
Some(rect) => scale(rect, TEXT_UNIT_IN_GLYPH_UNITS),
None => scale(painted, TEXT_UNIT_IN_GLYPH_UNITS),
};
Some(Type3Metrics {
width: round(f64::from(width) * TEXT_UNIT_IN_GLYPH_UNITS),
bbox: transform_rect(font.font_matrix, text_box),
painted,
colored,
objects,
})
}
fn painted_extent(objects: &[PageObject]) -> Rect {
let mut out: Option<Rect> = None;
let mut add = |rect: Rect| {
out = Some(match out {
Some(current) => current.union(rect),
None => rect,
});
};
for object in objects {
match object {
PageObject::Path(content) => {
let path = &content.object;
add(transform_rect(
path.matrix,
kurbo::Shape::bounding_box(&path.path),
));
}
PageObject::Image(content) => {
add(transform_rect(
content.object.matrix,
Rect::new(0.0, 0.0, 1.0, 1.0),
));
}
PageObject::Shading(content) => add(content.object.bounds),
PageObject::Form(content) => {
let inner = painted_extent(&content.object.objects);
if inner.width() > 0.0 || inner.height() > 0.0 {
add(inner);
}
}
PageObject::Text(_) => {}
}
}
out.unwrap_or(Rect::ZERO)
}
fn scale(rect: Rect, factor: f64) -> Rect {
Rect::new(
rect.x0 * factor,
rect.y0 * factor,
rect.x1 * factor,
rect.y1 * factor,
)
}
fn transform_rect(matrix: Affine, rect: Rect) -> Rect {
let corners = [
matrix * kurbo::Point::new(rect.x0, rect.y0),
matrix * kurbo::Point::new(rect.x1, rect.y0),
matrix * kurbo::Point::new(rect.x0, rect.y1),
matrix * kurbo::Point::new(rect.x1, rect.y1),
];
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
Rect::new(
xs.iter().copied().fold(f64::INFINITY, f64::min),
ys.iter().copied().fold(f64::INFINITY, f64::min),
xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
)
}
fn round(value: f64) -> f32 {
#[expect(
clippy::cast_possible_truncation,
reason = "a glyph advance outside f32 is nonsense whatever we do"
)]
let rounded = value.round() as f32;
rounded
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
#[test]
fn a_text_space_box_scales_by_a_thousand() {
assert_eq!(
scale(Rect::new(0.0, 0.0, 0.5, 0.75), 1000.0),
Rect::new(0.0, 0.0, 500.0, 750.0)
);
}
#[test]
fn rounding_is_half_away_from_zero() {
assert_eq!(round(0.5), 1.0);
assert_eq!(round(-0.5), -1.0);
assert_eq!(round(1.4), 1.0);
assert_eq!(round(1.6), 2.0);
}
#[test]
fn a_procedure_that_paints_nothing_has_an_empty_extent() {
assert_eq!(painted_extent(&[]), Rect::ZERO);
}
struct OneStream(std::sync::Arc<pdfrum_object::Object>);
impl pdfrum_object::Resolve for OneStream {
fn fetch(
&self,
r: pdfrum_object::ObjRef,
) -> Result<std::sync::Arc<pdfrum_object::Object>, pdfrum_object::Error> {
if r.num == 1 {
Ok(std::sync::Arc::clone(&self.0))
} else {
Err(pdfrum_object::Error::UnresolvedRef(r))
}
}
}
fn one_glyph_font(proc_body: &[u8]) -> (pdfrum_font::Font, OneStream) {
use pdfrum_object::{Array, ByteSpan, Dict, Name, Object, Stream};
let proc = Stream::new(Dict::new(), ByteSpan::from(proc_body.to_vec()));
let store = OneStream(std::sync::Arc::new(Object::Stream(Box::new(proc))));
let font = Dict::from_pairs([
(Name::from("Type"), Object::Name(Name::from("Font"))),
(Name::from("Subtype"), Object::Name(Name::from("Type3"))),
(
Name::from("CharProcs"),
Object::Dict(Dict::from_pairs([(
Name::from("g"),
Object::Ref(pdfrum_object::ObjRef {
num: 1,
generation: 0,
}),
)])),
),
(
Name::from("Encoding"),
Object::Dict(Dict::from_pairs([(
Name::from("Differences"),
Object::Array(
[Object::Int(65), Object::Name(Name::from("g"))]
.into_iter()
.collect::<Array>(),
),
)])),
),
(Name::from("FirstChar"), Object::Int(65)),
(Name::from("LastChar"), Object::Int(65)),
(
Name::from("Widths"),
Object::Array([Object::Int(100)].into_iter().collect::<Array>()),
),
]);
let cache = pdfrum_font::FontCache::new();
let mut diags = Diagnostics::default();
let loaded = pdfrum_font::load(&font, &store, &cache, &Limits::default(), &mut diags)
.expect("a type 3 font");
(loaded, store)
}
fn glyph_metrics(proc_body: &[u8]) -> Type3Metrics {
let (font, store) = one_glyph_font(proc_body);
let type3 = font.type3().expect("a type 3 font");
let mut ctx = BuildContext::new();
let mut diags = Diagnostics::default();
metrics(
type3,
pdfrum_font::CharCode(65),
None,
&store,
&mut ctx,
&Limits::default(),
&mut diags,
)
.expect("the glyph names a procedure")
}
#[test]
fn a_d1_procedure_is_uncoloured_and_a_d0_one_is_coloured() {
assert!(!glyph_metrics(b"100 0 0 0 50 50 d1 0 0 50 50 re f").colored);
assert!(glyph_metrics(b"100 0 d0 1 0 0 rg 0 0 50 50 re f").colored);
assert!(!glyph_metrics(b"0 0 50 50 re f").colored);
}
#[test]
fn a_procedure_yields_the_objects_it_paints() {
let m = glyph_metrics(b"100 0 0 0 50 50 d1 0 0 50 50 re f");
assert_eq!(m.objects.len(), 1, "one rectangle: {:?}", m.objects);
assert!(matches!(m.objects.first(), Some(PageObject::Path(_))));
assert!(glyph_metrics(b"100 0 0 0 50 50 d1").objects.is_empty());
}
#[test]
fn a_declared_box_is_believed_and_a_degenerate_one_is_not() {
let stated = glyph_metrics(b"100 0 0 0 10 10 d1 0 0 40 40 re f");
assert_eq!(stated.bbox, Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
let computed = glyph_metrics(b"100 0 10 10 0 0 d1 0 0 40 40 re f");
assert_eq!(computed.bbox, Rect::new(0.0, 0.0, 40_000.0, 40_000.0));
}
#[test]
fn the_advance_is_the_declared_width_in_glyph_units() {
assert_eq!(glyph_metrics(b"100 0 d0 0 0 1 1 re f").width, 100_000.0);
}
#[test]
fn the_painted_extent_is_the_objects_own_and_ignores_the_declared_box() {
let m = glyph_metrics(b"100 0 0 0 10 10 d1 0 0 40 40 re f");
assert_eq!(m.painted, Rect::new(0.0, 0.0, 40.0, 40.0));
assert_eq!(m.bbox, Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
assert_eq!(glyph_metrics(b"100 0 0 0 10 10 d1").painted, Rect::ZERO);
}
#[test]
fn a_font_matrix_transforms_the_glyph_box() {
let matrix = Affine::new([0.001, 0.0, 0.0, 0.001, 0.0, 0.0]);
let out = transform_rect(matrix, Rect::new(0.0, 0.0, 1000.0, 1000.0));
assert_eq!(out, Rect::new(0.0, 0.0, 1.0, 1.0));
}
}