use kurbo::{Affine, Point, Rect};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CharType {
Normal,
Generated,
NotUnicode,
Hyphen,
Piece,
ActualText,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CharBox {
pub char_type: CharType,
pub unicode: u32,
pub code: Option<pdfrum_font::CharCode>,
pub origin: Point,
pub char_box: Rect,
pub loose_char_box: Rect,
pub matrix: Affine,
pub object: Option<ObjectIndex>,
pub font_size: f32,
pub angle: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectIndex(pub u32);
impl CharBox {
#[must_use]
pub fn is_normal(&self) -> bool {
if self.unicode == 0 {
return self.code.is_some_and(|code| code.0 != 0);
}
!self.is_control()
}
#[must_use]
pub fn is_control(&self) -> bool {
matches!(
self.unicode,
0x02 | 0x03 | 0x93 | 0x94 | 0x96 | 0x97 | 0x98 | 0xFFFE
) && self.char_type != CharType::Hyphen
}
#[must_use]
pub fn is_generated(&self) -> bool {
self.char_type == CharType::Generated
}
}
#[must_use]
pub fn matrix_angle(matrix: Affine) -> f32 {
let [a, _, c, ..] = matrix.as_coeffs();
let angle = c.atan2(a);
let angle = if angle < 0.0 {
angle + std::f64::consts::TAU
} else {
angle
};
#[expect(
clippy::cast_possible_truncation,
reason = "an angle in [0, 2pi) is exactly representable enough in f32"
)]
let narrowed = angle as f32;
narrowed
}
#[derive(Debug)]
pub struct LooseBoundsInput<'a> {
pub char_box: Rect,
pub origin: Point,
pub matrix: Affine,
pub code: Option<pdfrum_font::CharCode>,
pub font: Option<&'a pdfrum_font::Font>,
pub font_size: f32,
pub scaled_width: f32,
}
#[must_use]
pub(crate) fn is_float_zero(value: f32) -> bool {
value > -0.0001 && value < 0.0001
}
fn is_empty(rect: Rect) -> bool {
rect.x1 <= rect.x0 || rect.y1 <= rect.y0
}
fn union(a: Rect, b: Rect) -> Rect {
Rect::new(
a.x0.min(b.x0),
a.y0.min(b.y0),
a.x1.max(b.x1),
a.y1.max(b.y1),
)
}
#[must_use]
pub fn inverse_or_zero(matrix: Affine) -> Affine {
let [a, b, c, d, ..] = matrix.as_coeffs();
if a * d - b * c == 0.0 {
Affine::new([0.0; 6])
} else {
matrix.inverse()
}
}
#[must_use]
pub fn loose_bounds(input: &LooseBoundsInput<'_>) -> Rect {
let tight = input.char_box;
if is_empty(tight) {
return tight;
}
let (Some(font), Some(code)) = (input.font, input.code) else {
return tight;
};
let font_size = f64::from(input.font_size);
if is_float_zero(input.font_size) {
return tight;
}
let vertical = font.is_vertical();
if vertical && let (Some((vx, vy)), Some(vw)) = (font.vert_origin(code), font.vert_width(code))
{
let offset_x = (f64::from(vx) - 500.0) * font_size / 1000.0;
let offset_y = f64::from(vy) * font_size / 1000.0;
let height = f64::from(vw) * font_size / 1000.0;
let left = input.origin.x + offset_x;
let top = input.origin.y + offset_y;
let box_rect = Rect::new(left, top + height, left + font_size, top);
return union(box_rect, tight);
}
let mut ascent = font.type_ascent();
let mut descent = font.type_descent();
let bbox = font.font_bbox();
#[expect(
clippy::cast_possible_truncation,
reason = "font bbox metrics are integral 1000/em values"
)]
if bbox.y1 > bbox.y0 {
ascent = ascent.min(bbox.y1 as i32);
descent = descent.max(bbox.y0 as i32);
}
if ascent == descent {
return tight;
}
let width = f64::from(input.scaled_width);
let inverse = inverse_or_zero(input.matrix);
let origin = inverse * input.origin;
let right = origin.x + if vertical { -width } else { width };
let bottom = origin.y + f64::from(descent) * font_size / 1000.0;
let top = origin.y + f64::from(ascent) * font_size / 1000.0;
let box_rect = transform_rect(input.matrix, Rect::new(origin.x, bottom, right, top));
union(box_rect, tight)
}
#[must_use]
pub fn transform_rect(matrix: Affine, rect: Rect) -> Rect {
let corners = [
matrix * Point::new(rect.x0, rect.y0),
matrix * Point::new(rect.x1, rect.y0),
matrix * Point::new(rect.x0, rect.y1),
matrix * 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),
)
}
#[must_use]
pub fn transform_distance(matrix: Affine, distance: f64) -> f64 {
let [a, b, c, d, ..] = matrix.as_coeffs();
let x_unit = (a * a + b * b).sqrt();
let y_unit = (c * c + d * d).sqrt();
distance * (x_unit + y_unit) / 2.0
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
use pdfrum_font::CharCode;
fn char_box(char_type: CharType, unicode: u32, code: Option<u32>) -> CharBox {
CharBox {
char_type,
unicode,
code: code.map(CharCode),
origin: Point::ZERO,
char_box: Rect::ZERO,
loose_char_box: Rect::ZERO,
matrix: Affine::IDENTITY,
object: None,
font_size: 1.0,
angle: 0.0,
}
}
#[test]
fn control_characters_are_the_eight_the_cpp_lists() {
for unicode in [0x02, 0x03, 0x93, 0x94, 0x96, 0x97, 0x98, 0xFFFE] {
assert!(char_box(CharType::Normal, unicode, Some(1)).is_control());
}
for unicode in [0x01, 0x04, 0x92, 0x95, 0x99, 0x20, 0x41, 0xFFFD] {
assert!(!char_box(CharType::Normal, unicode, Some(1)).is_control());
}
}
#[test]
fn a_hyphen_is_exempt_from_the_control_test() {
let hyphen = char_box(CharType::Hyphen, 0x02, None);
assert!(!hyphen.is_control());
assert!(hyphen.is_normal());
}
#[test]
fn normality_falls_back_to_the_char_code_when_there_is_no_unicode() {
assert!(!char_box(CharType::Normal, 0, Some(0)).is_normal());
assert!(char_box(CharType::Normal, 0, Some(7)).is_normal());
assert!(!char_box(CharType::Generated, 0, None).is_normal());
assert!(!char_box(CharType::Normal, 0x03, Some(3)).is_normal());
}
#[test]
fn a_singular_matrix_inverts_to_the_zero_matrix() {
let singular = Affine::new([1.0, 2.0, 2.0, 4.0, 5.0, 6.0]);
let inverse = inverse_or_zero(singular);
assert_eq!(inverse.as_coeffs(), [0.0; 6]);
assert_eq!(inverse * Point::new(100.0, 200.0), Point::ZERO);
let scale = Affine::scale(2.0);
assert_eq!(
inverse_or_zero(scale) * Point::new(4.0, 6.0),
Point::new(2.0, 3.0)
);
}
#[test]
fn the_angle_is_read_from_the_y_into_x_coefficient() {
use std::f64::consts::{FRAC_PI_2, PI, TAU};
assert!((matrix_angle(Affine::IDENTITY) - 0.0).abs() < 1e-6);
let quarter = matrix_angle(Affine::new([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]));
assert!((f64::from(quarter) - FRAC_PI_2).abs() < 1e-5, "{quarter}");
let half = matrix_angle(Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
assert!((f64::from(half) - PI).abs() < 1e-5, "{half}");
let three_quarter = matrix_angle(Affine::new([0.0, 0.0, -1.0, 0.0, 0.0, 0.0]));
assert!(
(f64::from(three_quarter) - 3.0 * FRAC_PI_2).abs() < 1e-5,
"{three_quarter}"
);
assert!(f64::from(three_quarter) < TAU);
}
#[test]
fn a_generated_characters_loose_box_is_its_empty_tight_box() {
let input = LooseBoundsInput {
char_box: Rect::new(50.0, 100.0, 50.0, 100.0),
origin: Point::new(50.0, 100.0),
matrix: Affine::IDENTITY,
code: None,
font: None,
font_size: 1.0,
scaled_width: 0.0,
};
let loose = loose_bounds(&input);
assert_eq!(loose, Rect::new(50.0, 100.0, 50.0, 100.0));
assert_eq!(loose.width(), 0.0);
assert_eq!(loose.height(), 0.0);
}
#[test]
fn transform_distance_averages_the_two_axis_scales() {
assert_eq!(transform_distance(Affine::scale(2.0), 10.0), 20.0);
let skewed = Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
assert_eq!(transform_distance(skewed, 10.0), 20.0);
}
#[test]
fn transforming_a_rect_bounds_the_rotated_corners() {
let rect = Rect::new(0.0, 0.0, 2.0, 1.0);
let rotated = transform_rect(Affine::rotate(std::f64::consts::FRAC_PI_2), rect);
assert!((rotated.width() - 1.0).abs() < 1e-9);
assert!((rotated.height() - 2.0).abs() < 1e-9);
}
}