oxipdf-ir 0.1.0

Intermediate representation types for the oxipdf PDF engine
Documentation
//! The canonical `Pt` (PDF points) unit type.
//!
//! All internal computations use `Pt` as the canonical unit.
//! Input values in other units (mm, px) must be converted before layout.
//!
//! # Emission Rounding Policy
//!
//! During PDF emission, coordinate values are rounded to [`EMISSION_DECIMAL_PLACES`]
//! decimal places (4 digits → 0.0001 pt ≈ 0.000035 mm precision). This ensures
//! deterministic output across platforms while maintaining sub-pixel accuracy.

use std::fmt;
use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign};

/// Number of decimal places retained when emitting PDF coordinate values.
pub const EMISSION_DECIMAL_PLACES: u32 = 4;

/// The multiplier used for emission rounding: 10^EMISSION_DECIMAL_PLACES.
const EMISSION_FACTOR: f64 = 10_000.0;

/// Points per millimeter: 1 mm = 72/25.4 pt.
const PT_PER_MM: f64 = 72.0 / 25.4;

/// Default screen DPI used for px→pt conversion when no DPI is specified.
pub const DEFAULT_SCREEN_DPI: f64 = 96.0;

/// A measurement in PDF points (1/72 of an inch).
///
/// This is the canonical unit for all spatial computations in oxipdf.
/// The inner value is an `f64` to maintain precision through chained
/// arithmetic operations during layout.
#[derive(Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Pt(f64);

impl Pt {
    pub const ZERO: Self = Self(0.0);

    #[must_use]
    pub const fn new(value: f64) -> Self {
        Self(value)
    }

    /// Convert millimeters to points. 1 mm = 72/25.4 ≈ 2.834645669 pt.
    #[must_use]
    pub fn from_mm(mm: f64) -> Self {
        Self(mm * PT_PER_MM)
    }

    /// Convert pixels to points at the given DPI. Formula: pt = px × (72 / dpi).
    ///
    /// # Panics
    /// Panics if `dpi` is zero or negative.
    #[must_use]
    pub fn from_px(px: f64, dpi: f64) -> Self {
        assert!(dpi > 0.0, "DPI must be positive, got {dpi}");
        Self(px * (72.0 / dpi))
    }

    /// Convert pixels to points using the default screen DPI (96).
    #[must_use]
    pub fn from_px_default(px: f64) -> Self {
        Self::from_px(px, DEFAULT_SCREEN_DPI)
    }

    #[must_use]
    pub const fn get(self) -> f64 {
        self.0
    }

    /// Round to emission precision (4 decimal places).
    #[must_use]
    pub fn round_for_emission(self) -> Self {
        Self((self.0 * EMISSION_FACTOR).round() / EMISSION_FACTOR)
    }

    #[must_use]
    pub fn to_mm(self) -> f64 {
        self.0 / PT_PER_MM
    }

    #[must_use]
    pub fn to_px(self, dpi: f64) -> f64 {
        self.0 * (dpi / 72.0)
    }

    #[must_use]
    pub fn abs(self) -> Self {
        Self(self.0.abs())
    }

    #[must_use]
    pub fn min(self, other: Self) -> Self {
        Self(self.0.min(other.0))
    }

    #[must_use]
    pub fn max(self, other: Self) -> Self {
        Self(self.0.max(other.0))
    }

    #[must_use]
    pub fn clamp(self, min: Self, max: Self) -> Self {
        Self(self.0.clamp(min.0, max.0))
    }

    #[must_use]
    pub fn is_finite(self) -> bool {
        self.0.is_finite()
    }
}

impl fmt::Debug for Pt {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.4}pt", self.0)
    }
}

impl fmt::Display for Pt {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.4}pt", self.0)
    }
}

impl Add for Pt {
    type Output = Self;
    fn add(self, rhs: Self) -> Self {
        Self(self.0 + rhs.0)
    }
}

impl AddAssign for Pt {
    fn add_assign(&mut self, rhs: Self) {
        self.0 += rhs.0;
    }
}

impl Sub for Pt {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self {
        Self(self.0 - rhs.0)
    }
}

impl SubAssign for Pt {
    fn sub_assign(&mut self, rhs: Self) {
        self.0 -= rhs.0;
    }
}

impl Mul<f64> for Pt {
    type Output = Self;
    fn mul(self, rhs: f64) -> Self {
        Self(self.0 * rhs)
    }
}

impl Mul<Pt> for f64 {
    type Output = Pt;
    fn mul(self, rhs: Pt) -> Pt {
        Pt(self * rhs.0)
    }
}

impl MulAssign<f64> for Pt {
    fn mul_assign(&mut self, rhs: f64) {
        self.0 *= rhs;
    }
}

impl Div<f64> for Pt {
    type Output = Self;
    fn div(self, rhs: f64) -> Self {
        Self(self.0 / rhs)
    }
}

impl Div<Pt> for Pt {
    type Output = f64;
    fn div(self, rhs: Pt) -> f64 {
        self.0 / rhs.0
    }
}

impl DivAssign<f64> for Pt {
    fn div_assign(&mut self, rhs: f64) {
        self.0 /= rhs;
    }
}

impl Neg for Pt {
    type Output = Self;
    fn neg(self) -> Self {
        Self(-self.0)
    }
}

impl From<f64> for Pt {
    fn from(value: f64) -> Self {
        Self(value)
    }
}

impl From<Pt> for f64 {
    fn from(pt: Pt) -> Self {
        pt.0
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mm_to_pt_conversion() {
        let pt = Pt::from_mm(25.4);
        assert!((pt.get() - 72.0).abs() < 1e-10);
    }

    #[test]
    fn px_to_pt_at_96dpi() {
        let pt = Pt::from_px(96.0, 96.0);
        assert!((pt.get() - 72.0).abs() < 1e-10);
    }

    #[test]
    fn px_to_pt_at_72dpi() {
        let pt = Pt::from_px(72.0, 72.0);
        assert!((pt.get() - 72.0).abs() < 1e-10);
    }

    #[test]
    fn px_default_uses_96dpi() {
        assert_eq!(Pt::from_px_default(100.0), Pt::from_px(100.0, 96.0));
    }

    #[test]
    fn round_for_emission_precision() {
        assert_eq!(Pt::new(12.345_678_9).round_for_emission().get(), 12.3457);
    }

    #[test]
    fn round_for_emission_exact_values_unchanged() {
        assert_eq!(Pt::new(12.0).round_for_emission().get(), 12.0);
    }

    #[test]
    fn pt_arithmetic() {
        let a = Pt::new(10.0);
        let b = Pt::new(3.0);
        assert_eq!((a + b).get(), 13.0);
        assert_eq!((a - b).get(), 7.0);
        assert_eq!((a * 2.0).get(), 20.0);
        assert_eq!((2.0 * a).get(), 20.0);
        assert_eq!((a / 2.0).get(), 5.0);
        assert_eq!(a / b, 10.0 / 3.0);
        assert_eq!((-a).get(), -10.0);
    }

    #[test]
    fn pt_to_mm_roundtrip() {
        let mm = 210.0;
        assert!((Pt::from_mm(mm).to_mm() - mm).abs() < 1e-10);
    }

    #[test]
    fn pt_min_max_clamp() {
        let a = Pt::new(5.0);
        let b = Pt::new(10.0);
        assert_eq!(a.min(b), a);
        assert_eq!(a.max(b), b);
        assert_eq!(Pt::new(15.0).clamp(a, b), b);
        assert_eq!(Pt::new(1.0).clamp(a, b), a);
    }

    #[test]
    #[should_panic(expected = "DPI must be positive")]
    fn px_zero_dpi_panics() {
        let _ = Pt::from_px(100.0, 0.0);
    }

    #[test]
    fn display_format() {
        assert_eq!(format!("{}", Pt::new(12.5)), "12.5000pt");
    }
}