pdftract/crates/pdftract-core/src/graphics_state.rs
jedarden 225f96c241 fix(pyo3): correct extract_text_fn call in extract_markdown stub
The extract_markdown stub was calling extract_text instead of
extract_text_fn, causing a compilation error. This fixes the
function name to match the exported function from extract_text.rs.

This completes the extract_text PyO3 entry point implementation,
which was already present in extract_text.rs and lib.rs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:28:25 -04:00

1434 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Graphics state management for PDF content stream processing.
//!
//! This module implements the graphics state stack and CTM (Current Transformation Matrix)
//! tracking needed for Phase 3 content stream processing and Phase 5.2.1 image compositing.
//!
//! Per PDF spec section 8.4 "Graphics State":
//! - q operator pushes a copy of the current graphics state onto the stack
//! - Q operator pops the graphics state stack and restores the state
//! - cm operator concatenates a matrix with the CTM
//!
//! The CTM is a 3x3 transformation matrix that transforms coordinates from user space
//! to device space. For 2D operations, only 6 values are relevant: [a b c d e f]
//! representing the affine transformation:
//! x' = a*x + c*y + e
//! y' = b*x + d*y + f
use std::sync::Arc;
use crate::font::Font;
/// Maximum depth of graphics state stack (per PDF spec section 8.4).
const MAX_GSTATE_DEPTH: usize = 64;
/// Color space identifier for tracking current fill/stroke color space.
///
/// Per PDF spec section 8.6.5, color spaces determine how color values are interpreted.
/// The cs/CS operators set the current color space for non-stroking/stroking operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpace {
/// DeviceGray color space (1 component)
DeviceGray,
/// DeviceRGB color space (3 components)
DeviceRGB,
/// DeviceCMYK color space (4 components)
DeviceCMYK,
/// Pattern color space (special handling)
Pattern,
/// ICCBased color space (profile-based, treated as Other)
ICCBased,
/// Indexed color space (color lookup table, treated as Other)
Indexed,
/// CalRGB color space (CIE-based CalRGB, treated as Other)
CalRGB,
/// CalGray color space (CIE-based CalGray, treated as Other)
CalGray,
/// DeviceN color space (multi-component, treated as Other)
DeviceN,
/// Separation color space (spot color with tint transform)
Separation,
/// Unknown or custom color space
Other,
}
impl ColorSpace {
/// Get the number of color components for this color space.
///
/// Returns the number of numeric arguments expected by sc/scn operators.
/// For Pattern, returns 0 (pattern names are not numeric components).
/// For unknown spaces, returns 0 (treated as Other).
pub fn component_count(&self) -> usize {
match self {
ColorSpace::DeviceGray => 1,
ColorSpace::DeviceRGB => 3,
ColorSpace::DeviceCMYK => 4,
ColorSpace::Pattern => 0,
ColorSpace::ICCBased
| ColorSpace::Indexed
| ColorSpace::CalRGB
| ColorSpace::CalGray
| ColorSpace::DeviceN
| ColorSpace::Separation
| ColorSpace::Other => 0,
}
}
}
/// Color space and value for text extraction output.
///
/// Per PDF spec, color spaces include DeviceGray, DeviceRGB, DeviceCMYK,
/// and special color spaces (Spot, ICCBased, Pattern, etc.). For text extraction,
/// we only need to serialize DeviceGray/RGB/CMYK to CSS hex; Spot and Other
/// become null in JSON output.
#[derive(Debug, Clone, PartialEq)]
pub enum Color {
/// DeviceGray: single component 0.01.0 (black to white)
DeviceGray(f32),
/// DeviceRGB: three components [R, G, B] each 0.01.0
DeviceRGB([f32; 3]),
/// DeviceCMYK: four components [C, M, Y, K] each 0.01.0
DeviceCMYK([f32; 4]),
/// Spot color: (colorant name, tint 0.01.0)
Spot(Arc<str>, f32),
/// Other color spaces (CalRGB, ICCBased, Pattern, etc.) — not serializable to CSS
Other,
}
impl Color {
/// Convert to CSS hex color string for JSON output.
///
/// Returns `Some("#rrggbb")` for DeviceGray, DeviceRGB, and DeviceCMYK.
/// Returns `None` for Spot and Other (serialized as null in JSON).
///
/// # Conversion rules
///
/// - `DeviceGray(v)`: treated as RGB `[v, v, v]` → `#rrggbb`
/// - `DeviceRGB([r, g, b])`: direct mapping → `#rrggbb`
/// - `DeviceCMYK([c, m, y, k])`: naive formula `R = (1-C)*(1-K)`, etc. → `#rrggbb`
/// - `Spot`, `Other`: `None`
pub fn to_css_hex(&self) -> Option<String> {
match self {
Color::DeviceGray(v) => {
let r = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let g = r;
let b = r;
Some(format!("#{:02x}{:02x}{:02x}", r, g, b))
}
Color::DeviceRGB(rgb) => {
let r = (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8;
let g = (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8;
let b = (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8;
Some(format!("#{:02x}{:02x}{:02x}", r, g, b))
}
Color::DeviceCMYK(cmyk) => {
// Naive CMYK → RGB conversion: R = (1-C)*(1-K)
let c = cmyk[0].clamp(0.0, 1.0);
let m = cmyk[1].clamp(0.0, 1.0);
let y = cmyk[2].clamp(0.0, 1.0);
let k = cmyk[3].clamp(0.0, 1.0);
let r = ((1.0 - c) * (1.0 - k) * 255.0).round() as u8;
let g = ((1.0 - m) * (1.0 - k) * 255.0).round() as u8;
let b = ((1.0 - y) * (1.0 - k) * 255.0).round() as u8;
Some(format!("#{:02x}{:02x}{:02x}", r, g, b))
}
Color::Spot(_, _) | Color::Other => None,
}
}
}
/// 3x3 transformation matrix for PDF coordinate transformations.
///
/// Only the first 6 values are used for 2D affine transformations:
/// [a b 0]
/// [c d 0]
/// [e f 1]
///
/// Per PDF spec, the CTM transforms from user space to device space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Matrix3x3 {
/// The a coefficient (x scale)
pub a: f64,
/// The b coefficient (y skew)
pub b: f64,
/// The c coefficient (x skew)
pub c: f64,
/// The d coefficient (y scale)
pub d: f64,
/// The e coefficient (x translation)
pub e: f64,
/// The f coefficient (y translation)
pub f: f64,
}
impl Matrix3x3 {
/// Create a new identity matrix.
#[inline]
pub fn identity() -> Self {
Self {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: 0.0,
}
}
/// Create a matrix from a PDF-style 6-element array [a b c d e f].
#[inline]
pub fn from_pdf_array(arr: [f64; 6]) -> Self {
Self {
a: arr[0],
b: arr[1],
c: arr[2],
d: arr[3],
e: arr[4],
f: arr[5],
}
}
/// Check if this is the identity matrix.
#[inline]
pub fn is_identity(&self) -> bool {
self.a == 1.0
&& self.b == 0.0
&& self.c == 0.0
&& self.d == 1.0
&& self.e == 0.0
&& self.f == 0.0
}
/// Multiply this matrix by another (this * other).
#[inline]
pub fn multiply(&self, other: &Matrix3x3) -> Matrix3x3 {
Matrix3x3 {
a: self.a * other.a + self.b * other.c,
b: self.a * other.b + self.b * other.d,
c: self.c * other.a + self.d * other.c,
d: self.c * other.b + self.d * other.d,
e: self.e * other.a + self.f * other.c + other.e,
f: self.e * other.b + self.f * other.d + other.f,
}
}
/// Transform a point (x, y) by this matrix.
#[inline]
pub fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
let new_x = self.a * x + self.c * y + self.e;
let new_y = self.b * x + self.d * y + self.f;
(new_x, new_y)
}
/// Get the determinant of this matrix.
#[inline]
pub fn determinant(&self) -> f64 {
self.a * self.d - self.b * self.c
}
/// Check if the matrix has a negative determinant (flip).
#[inline]
pub fn has_flip(&self) -> bool {
self.determinant() < 0.0
}
/// Create a scale matrix.
#[inline]
pub fn scale(sx: f64, sy: f64) -> Self {
Self {
a: sx,
b: 0.0,
c: 0.0,
d: sy,
e: 0.0,
f: 0.0,
}
}
/// Create a translation matrix.
#[inline]
pub fn translate(tx: f64, ty: f64) -> Self {
Self {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: tx,
f: ty,
}
}
/// Create a rotation matrix (angle in radians).
#[inline]
pub fn rotate(angle: f64) -> Self {
let cos_a = angle.cos();
let sin_a = angle.sin();
Self {
a: cos_a,
b: sin_a,
c: -sin_a,
d: cos_a,
e: 0.0,
f: 0.0,
}
}
/// Invert this matrix.
///
/// Returns None if the matrix is not invertible (determinant is zero).
#[inline]
pub fn invert(&self) -> Option<Self> {
let det = self.determinant();
if det.abs() < f64::EPSILON {
return None;
}
let inv_det = 1.0 / det;
Some(Matrix3x3 {
a: self.d * inv_det,
b: -self.b * inv_det,
c: -self.c * inv_det,
d: self.a * inv_det,
e: (self.c * self.f - self.d * self.e) * inv_det,
f: (self.b * self.e - self.a * self.f) * inv_det,
})
}
}
impl Default for Matrix3x3 {
fn default() -> Self {
Self::identity()
}
}
/// Graphics state as defined in PDF spec section 8.4.
///
/// This contains all 13 graphics state parameters needed for content stream processing.
/// Per INV-30, GraphicsState is Clone (cheap thanks to `Arc<Font>`) so q/Q can snapshot it.
#[derive(Clone)]
pub struct GraphicsState {
/// Current Transformation Matrix (ctm)
pub ctm: Matrix3x3,
/// Text matrix (Tm)
pub text_matrix: Matrix3x3,
/// Text line matrix (Tlm)
pub text_line_matrix: Matrix3x3,
/// Current font (None until Tf operator)
pub font: Option<Arc<Font>>,
/// Font size (set by Tf operator)
pub font_size: f64,
/// Character spacing (Tc)
pub char_spacing: f64,
/// Word spacing (Tw)
pub word_spacing: f64,
/// Horizontal scaling (Tz, percentage, default 100)
pub horiz_scaling: f64,
/// Leading (TL)
pub leading: f64,
/// Text rise (Ts)
pub text_rise: f64,
/// Text rendering mode (Tr, 07)
pub text_rendering_mode: u8,
/// Fill color
pub fill_color: Color,
/// Stroke color
pub stroke_color: Color,
/// Current fill color space (for sc/scn operators)
fill_color_space: ColorSpace,
/// Current stroke color space (for SC/SCN operators)
stroke_color_space: ColorSpace,
}
impl std::fmt::Debug for GraphicsState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Font doesn't implement Debug, so we show a placeholder
f.debug_struct("GraphicsState")
.field("ctm", &self.ctm)
.field("text_matrix", &self.text_matrix)
.field("text_line_matrix", &self.text_line_matrix)
.field("font", &self.font.as_ref().map(|_| "<Arc<Font>>"))
.field("font_size", &self.font_size)
.field("char_spacing", &self.char_spacing)
.field("word_spacing", &self.word_spacing)
.field("horiz_scaling", &self.horiz_scaling)
.field("leading", &self.leading)
.field("text_rise", &self.text_rise)
.field("text_rendering_mode", &self.text_rendering_mode)
.field("fill_color", &self.fill_color)
.field("stroke_color", &self.stroke_color)
.field("fill_color_space", &self.fill_color_space)
.field("stroke_color_space", &self.stroke_color_space)
.finish()
}
}
impl GraphicsState {
/// Create a new graphics state with identity CTM.
#[inline]
pub fn new() -> Self {
Self::initial()
}
/// Create the initial graphics state per PDF spec.
///
/// Returns a state with:
/// - CTM: identity matrix
/// - text_matrix: identity matrix (will be reset on BT)
/// - text_line_matrix: identity matrix (will be reset on BT)
/// - font: None (must be set by Tf operator before use)
/// - font_size: 0.0 (must be set by Tf operator)
/// - char_spacing: 0.0
/// - word_spacing: 0.0
/// - horiz_scaling: 100.0
/// - leading: 0.0
/// - text_rise: 0.0
/// - text_rendering_mode: 0
/// - fill_color: DeviceGray(0.0) (black per PDF spec)
/// - stroke_color: DeviceGray(0.0) (black per PDF spec)
/// - fill_color_space: DeviceGray (default per PDF spec)
/// - stroke_color_space: DeviceGray (default per PDF spec)
#[inline]
pub fn initial() -> Self {
Self {
ctm: Matrix3x3::identity(),
text_matrix: Matrix3x3::identity(),
text_line_matrix: Matrix3x3::identity(),
font: None,
font_size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
horiz_scaling: 100.0,
leading: 0.0,
text_rise: 0.0,
text_rendering_mode: 0,
fill_color: Color::DeviceGray(0.0),
stroke_color: Color::DeviceGray(0.0),
fill_color_space: ColorSpace::DeviceGray,
stroke_color_space: ColorSpace::DeviceGray,
}
}
/// Concatenate a matrix with the current CTM.
///
/// This implements the `cm` operator behavior: CTM' = CTM × M
#[inline]
pub fn concat_ctm(&mut self, matrix: &Matrix3x3) {
self.ctm = self.ctm.multiply(matrix);
}
/// Set character spacing (Tc operator).
///
/// Tc sets the character spacing parameter, Tw. Negative values are allowed.
#[inline]
pub fn set_char_spacing(&mut self, value: f64) {
self.char_spacing = value;
}
/// Set word spacing (Tw operator).
///
/// Tw sets the word spacing parameter, Tw. Negative values are allowed.
#[inline]
pub fn set_word_spacing(&mut self, value: f64) {
self.word_spacing = value;
}
/// Set horizontal scaling (Tz operator).
///
/// Tz sets the horizontal scaling parameter, Tz, as a percentage.
/// Values <= 0 are clamped to 1.0 to avoid zero-width glyphs.
#[inline]
pub fn set_horiz_scaling(&mut self, value: f64) {
self.horiz_scaling = if value <= 0.0 { 1.0 } else { value };
}
/// Set leading (TL operator).
///
/// TL sets the leading parameter, Tl. Negative values are allowed.
#[inline]
pub fn set_leading(&mut self, value: f64) {
self.leading = value;
}
/// Set text rise (Ts operator).
///
/// Ts sets the text rise parameter, Ts. Negative values are allowed.
#[inline]
pub fn set_text_rise(&mut self, value: f64) {
self.text_rise = value;
}
/// Set text rendering mode (Tr operator).
///
/// Tr sets the text rendering mode. Values outside 0-7 are clamped to the valid range.
#[inline]
pub fn set_text_rendering_mode(&mut self, value: u8) {
self.text_rendering_mode = value.min(7);
}
/// Move text position (Td operator).
///
/// Sets text_line_matrix = translate(tx, ty) * text_line_matrix,
/// then copies text_line_matrix to text_matrix.
#[inline]
pub fn move_text(&mut self, tx: f64, ty: f64) {
let translation = Matrix3x3::translate(tx, ty);
self.text_line_matrix = translation.multiply(&self.text_line_matrix);
self.text_matrix = self.text_line_matrix;
}
/// Move text position and set leading (TD operator).
///
/// Same as Td, but also sets leading = -ty.
#[inline]
pub fn move_text_set_leading(&mut self, tx: f64, ty: f64) {
self.leading = -ty;
self.move_text(tx, ty);
}
/// Set text matrix (Tm operator).
///
/// Sets both text_matrix and text_line_matrix to the given matrix.
#[inline]
pub fn set_text_matrix(&mut self, matrix: &Matrix3x3) {
self.text_matrix = *matrix;
self.text_line_matrix = *matrix;
}
/// Move to next line (T* operator).
///
/// Equivalent to Td 0 -leading. If leading == 0, this is a no-op.
#[inline]
pub fn next_line(&mut self) {
self.move_text(0.0, -self.leading);
}
/// Bind font (Tf operator).
///
/// Sets the font and font_size. If size <= 0, clamps to 1.0.
#[inline]
pub fn set_font(&mut self, font: std::sync::Arc<Font>, size: f64) {
self.font = Some(font);
self.font_size = if size <= 0.0 { 1.0 } else { size };
}
/// Reset text matrices to identity (BT operator).
///
/// Called when beginning a text block.
#[inline]
pub fn begin_text(&mut self) {
self.text_matrix = Matrix3x3::identity();
self.text_line_matrix = Matrix3x3::identity();
}
/// Discard text matrices (ET operator).
///
/// Called when ending a text block.
#[inline]
pub fn end_text(&mut self) {
self.text_matrix = Matrix3x3::identity();
self.text_line_matrix = Matrix3x3::identity();
}
/// Translate the text matrix horizontally (for TJ operator kerning).
///
/// This is used by the TJ operator to adjust the text position by
/// the kerning amount: `text_matrix = translate(tx, 0) * text_matrix`.
#[inline]
pub fn translate_text(&mut self, tx: f64) {
let translation = Matrix3x3::translate(tx, 0.0);
self.text_matrix = translation.multiply(&self.text_matrix);
}
// Color-setting operators (rg RG g G k K cs CS sc SC scn SCN)
/// Set fill color to DeviceGray (g operator).
#[inline]
pub fn set_fill_gray(&mut self, gray: f32) {
self.fill_color = Color::DeviceGray(gray);
self.fill_color_space = ColorSpace::DeviceGray;
}
/// Set stroke color to DeviceGray (G operator).
#[inline]
pub fn set_stroke_gray(&mut self, gray: f32) {
self.stroke_color = Color::DeviceGray(gray);
self.stroke_color_space = ColorSpace::DeviceGray;
}
/// Set fill color to DeviceRGB (rg operator).
#[inline]
pub fn set_fill_rgb(&mut self, r: f32, g: f32, b: f32) {
self.fill_color = Color::DeviceRGB([r, g, b]);
self.fill_color_space = ColorSpace::DeviceRGB;
}
/// Set stroke color to DeviceRGB (RG operator).
#[inline]
pub fn set_stroke_rgb(&mut self, r: f32, g: f32, b: f32) {
self.stroke_color = Color::DeviceRGB([r, g, b]);
self.stroke_color_space = ColorSpace::DeviceRGB;
}
/// Set fill color to DeviceCMYK (k operator).
#[inline]
pub fn set_fill_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) {
self.fill_color = Color::DeviceCMYK([c, m, y, k]);
self.fill_color_space = ColorSpace::DeviceCMYK;
}
/// Set stroke color to DeviceCMYK (K operator).
#[inline]
pub fn set_stroke_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) {
self.stroke_color = Color::DeviceCMYK([c, m, y, k]);
self.stroke_color_space = ColorSpace::DeviceCMYK;
}
/// Set fill color space (cs operator).
#[inline]
pub fn set_fill_color_space(&mut self, color_space: ColorSpace) {
self.fill_color_space = color_space;
}
/// Set stroke color space (CS operator).
#[inline]
pub fn set_stroke_color_space(&mut self, color_space: ColorSpace) {
self.stroke_color_space = color_space;
}
/// Set fill color in current color space (sc operator).
///
/// The numeric components are interpreted based on the current fill_color_space.
/// For DeviceGray: [gray]
/// For DeviceRGB: [r, g, b]
/// For DeviceCMYK: [c, m, y, k]
/// For other spaces: sets Color::Other
#[inline]
pub fn set_fill_color(&mut self, components: &[f32]) {
self.fill_color = match self.fill_color_space {
ColorSpace::DeviceGray => {
if components.len() >= 1 {
Color::DeviceGray(components[0])
} else {
Color::Other
}
}
ColorSpace::DeviceRGB => {
if components.len() >= 3 {
Color::DeviceRGB([components[0], components[1], components[2]])
} else {
Color::Other
}
}
ColorSpace::DeviceCMYK => {
if components.len() >= 4 {
Color::DeviceCMYK([components[0], components[1], components[2], components[3]])
} else {
Color::Other
}
}
_ => Color::Other,
};
}
/// Set stroke color in current color space (SC operator).
///
/// Same as set_fill_color but for stroke.
#[inline]
pub fn set_stroke_color(&mut self, components: &[f32]) {
self.stroke_color = match self.stroke_color_space {
ColorSpace::DeviceGray => {
if components.len() >= 1 {
Color::DeviceGray(components[0])
} else {
Color::Other
}
}
ColorSpace::DeviceRGB => {
if components.len() >= 3 {
Color::DeviceRGB([components[0], components[1], components[2]])
} else {
Color::Other
}
}
ColorSpace::DeviceCMYK => {
if components.len() >= 4 {
Color::DeviceCMYK([components[0], components[1], components[2], components[3]])
} else {
Color::Other
}
}
_ => Color::Other,
};
}
/// Set fill color with optional pattern/spot name (scn operator).
///
/// If a name is provided and the current color space is Separation,
/// sets Color::Spot with the name and first component as tint.
/// Otherwise behaves like set_fill_color.
#[inline]
pub fn set_fill_color_named(&mut self, components: &[f32], name: Option<&str>) {
if let Some(colorant_name) = name {
// For Separation color spaces, treat as Spot color
if self.fill_color_space == ColorSpace::Separation {
let tint = if components.len() >= 1 {
components[0]
} else {
1.0
};
self.fill_color = Color::Spot(Arc::from(colorant_name), tint);
return;
}
// Pattern color space
if self.fill_color_space == ColorSpace::Pattern {
self.fill_color = Color::Other;
return;
}
}
// Fall back to numeric-only behavior
self.set_fill_color(components);
}
/// Set stroke color with optional pattern/spot name (SCN operator).
///
/// Same as set_fill_color_named but for stroke.
#[inline]
pub fn set_stroke_color_named(&mut self, components: &[f32], name: Option<&str>) {
if let Some(colorant_name) = name {
// For Separation color spaces, treat as Spot color
if self.stroke_color_space == ColorSpace::Separation {
let tint = if components.len() >= 1 {
components[0]
} else {
1.0
};
self.stroke_color = Color::Spot(Arc::from(colorant_name), tint);
return;
}
// Pattern color space
if self.stroke_color_space == ColorSpace::Pattern {
self.stroke_color = Color::Other;
return;
}
}
// Fall back to numeric-only behavior
self.set_stroke_color(components);
}
}
impl Default for GraphicsState {
fn default() -> Self {
Self::new()
}
}
/// Graphics state stack for q/Q operators.
///
/// Per PDF spec, the graphics state stack has a maximum depth to prevent
/// stack overflow in malformed PDFs.
#[derive(Debug, Clone)]
pub struct GraphicsStateStack {
/// The stack of saved graphics states
stack: Vec<GraphicsState>,
}
impl GraphicsStateStack {
/// Create a new empty graphics state stack.
#[inline]
pub fn new() -> Self {
Self {
stack: Vec::with_capacity(16),
}
}
/// Push a graphics state onto the stack (implements `q` operator).
///
/// Returns false if the stack would exceed the maximum depth.
#[inline]
pub fn push(&mut self, state: &GraphicsState) -> bool {
if self.stack.len() >= MAX_GSTATE_DEPTH {
return false;
}
self.stack.push(state.clone());
true
}
/// Pop a graphics state from the stack (implements `Q` operator).
///
/// Returns None if the stack is empty.
#[inline]
pub fn pop(&mut self) -> Option<GraphicsState> {
self.stack.pop()
}
/// Get the current depth of the stack.
#[inline]
pub fn depth(&self) -> usize {
self.stack.len()
}
/// Check if the stack is empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.stack.is_empty()
}
}
impl Default for GraphicsStateStack {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_matrix() {
let m = Matrix3x3::identity();
assert!(m.is_identity());
assert_eq!(m.transform_point(1.0, 0.0), (1.0, 0.0));
assert_eq!(m.transform_point(0.0, 1.0), (0.0, 1.0));
}
#[test]
fn test_translation_matrix() {
let m = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
let (x, y) = m.transform_point(0.0, 0.0);
assert_eq!(x, 10.0);
assert_eq!(y, 20.0);
}
#[test]
fn test_scale_matrix() {
let m = Matrix3x3::from_pdf_array([2.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
let (x, y) = m.transform_point(1.0, 1.0);
assert_eq!(x, 2.0);
assert_eq!(y, 3.0);
}
#[test]
fn test_matrix_multiply() {
let m1 = Matrix3x3::from_pdf_array([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let m2 = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
let result = m1.multiply(&m2);
// Should scale x by 2, y by 3
let (x, y) = result.transform_point(1.0, 1.0);
assert_eq!(x, 2.0);
assert_eq!(y, 3.0);
}
#[test]
fn test_determinant_positive() {
let m = Matrix3x3::identity();
assert_eq!(m.determinant(), 1.0);
assert!(!m.has_flip());
}
#[test]
fn test_determinant_negative() {
// Y flip matrix
let m = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
assert_eq!(m.determinant(), -1.0);
assert!(m.has_flip());
}
#[test]
fn test_gstate_stack_push_pop() {
let mut stack = GraphicsStateStack::new();
let state1 = GraphicsState::new();
assert!(stack.is_empty());
assert_eq!(stack.depth(), 0);
assert!(stack.push(&state1));
assert_eq!(stack.depth(), 1);
assert!(!stack.is_empty());
let popped = stack.pop();
assert!(popped.is_some());
assert!(stack.is_empty());
}
#[test]
fn test_gstate_stack_depth_limit() {
let mut stack = GraphicsStateStack::new();
let state = GraphicsState::new();
// Fill to max depth
for _ in 0..MAX_GSTATE_DEPTH {
assert!(stack.push(&state));
}
// Should fail to push beyond max
assert!(!stack.push(&state));
assert_eq!(stack.depth(), MAX_GSTATE_DEPTH);
}
#[test]
fn test_gstate_ctm_concat() {
let mut state = GraphicsState::new();
let translate = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
state.concat_ctm(&translate);
let (x, y) = state.ctm.transform_point(0.0, 0.0);
assert_eq!(x, 10.0);
assert_eq!(y, 20.0);
}
#[test]
fn test_gstate_stack_restore() {
let mut stack = GraphicsStateStack::new();
let mut state1 = GraphicsState::new();
let mut state2 = GraphicsState::new();
// Modify state1
let translate = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
state1.concat_ctm(&translate);
// Push state1
stack.push(&state1);
// Modify state2
let scale = Matrix3x3::from_pdf_array([2.0, 0.0, 0.0, 2.0, 0.0, 0.0]);
state2.concat_ctm(&scale);
// Pop should restore state1
let restored = stack.pop().unwrap();
let (x, y) = restored.ctm.transform_point(0.0, 0.0);
assert_eq!(x, 10.0);
assert_eq!(y, 20.0);
}
// Tests for GraphicsState::initial()
#[test]
fn test_gstate_initial_ctm_is_identity() {
let state = GraphicsState::initial();
assert!(state.ctm.is_identity());
}
#[test]
fn test_gstate_initial_font_size_is_zero() {
let state = GraphicsState::initial();
assert_eq!(state.font_size, 0.0);
}
#[test]
fn test_gstate_initial_fill_color_is_black() {
let state = GraphicsState::initial();
assert_eq!(state.fill_color, Color::DeviceGray(0.0));
}
#[test]
fn test_gstate_initial_horiz_scaling_is_100() {
let state = GraphicsState::initial();
assert_eq!(state.horiz_scaling, 100.0);
}
#[test]
fn test_gstate_initial_text_matrices_are_identity() {
let state = GraphicsState::initial();
assert!(state.text_matrix.is_identity());
assert!(state.text_line_matrix.is_identity());
}
#[test]
fn test_gstate_initial_font_is_none() {
let state = GraphicsState::initial();
assert!(state.font.is_none());
}
#[test]
fn test_gstate_initial_text_rendering_mode_is_0() {
let state = GraphicsState::initial();
assert_eq!(state.text_rendering_mode, 0);
}
#[test]
fn test_gstate_clone_deep_equal() {
let state = GraphicsState::initial();
let cloned = state.clone();
assert_eq!(state.ctm, cloned.ctm);
assert_eq!(state.text_matrix, cloned.text_matrix);
assert_eq!(state.font_size, cloned.font_size);
assert_eq!(state.fill_color, cloned.fill_color);
}
// Tests for Color::to_css_hex()
#[test]
fn test_color_device_rgb_to_css_hex() {
let color = Color::DeviceRGB([1.0, 0.0, 0.0]);
assert_eq!(color.to_css_hex(), Some("#ff0000".into()));
}
#[test]
fn test_color_device_gray_to_css_hex() {
let color = Color::DeviceGray(0.5);
assert_eq!(color.to_css_hex(), Some("#808080".into()));
}
#[test]
fn test_color_device_cmyk_to_css_hex() {
let color = Color::DeviceCMYK([0.0, 0.0, 0.0, 0.0]); // No ink, should be white
assert_eq!(color.to_css_hex(), Some("#ffffff".into()));
}
#[test]
fn test_color_spot_to_css_hex_none() {
let color = Color::Spot("PANTONE".into(), 0.5);
assert_eq!(color.to_css_hex(), None);
}
#[test]
fn test_color_other_to_css_hex_none() {
let color = Color::Other;
assert_eq!(color.to_css_hex(), None);
}
#[test]
fn test_color_device_rgb_clamped() {
let color = Color::DeviceRGB([1.5, -0.5, 0.5]);
// R: 1.5 -> 1.0 -> ff, G: -0.5 -> 0.0 -> 00, B: 0.5 -> 0.5 -> 80
assert_eq!(color.to_css_hex(), Some("#ff0080".into()));
}
// Tests for matrix operations
#[test]
fn test_matrix_scale() {
let m = Matrix3x3::scale(2.0, 3.0);
let (x, y) = m.transform_point(1.0, 1.0);
assert_eq!(x, 2.0);
assert_eq!(y, 3.0);
}
#[test]
fn test_matrix_translate() {
let m = Matrix3x3::translate(10.0, 20.0);
let (x, y) = m.transform_point(0.0, 0.0);
assert_eq!(x, 10.0);
assert_eq!(y, 20.0);
}
#[test]
fn test_matrix_rotate() {
let m = Matrix3x3::rotate(std::f64::consts::FRAC_PI_2); // 90 degrees
let (x, y) = m.transform_point(1.0, 0.0);
assert!((x - 0.0).abs() < 1e-10);
assert!((y - 1.0).abs() < 1e-10);
}
#[test]
fn test_matrix_invert_identity() {
let m = Matrix3x3::identity();
let inv = m.invert().unwrap();
assert!(inv.is_identity());
}
#[test]
fn test_matrix_invert_translation() {
let m = Matrix3x3::translate(10.0, 20.0);
let inv = m.invert().unwrap();
let (x, y) = inv.transform_point(10.0, 20.0);
assert!((x - 0.0).abs() < 1e-10);
assert!((y - 0.0).abs() < 1e-10);
}
#[test]
fn test_matrix_invert_singular() {
let m = Matrix3x3::from_pdf_array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
assert!(m.invert().is_none());
}
#[test]
fn test_identity_multiply_identity() {
let m1 = Matrix3x3::identity();
let m2 = Matrix3x3::identity();
let result = m1.multiply(&m2);
assert!(result.is_identity());
}
#[test]
fn test_multiply_within_tolerance() {
let m1 = Matrix3x3::identity();
let m2 = Matrix3x3::identity();
let result = m1.multiply(&m2);
assert!((result.a - 1.0).abs() < 1e-10);
assert!((result.b - 0.0).abs() < 1e-10);
assert!((result.c - 0.0).abs() < 1e-10);
assert!((result.d - 1.0).abs() < 1e-10);
assert!((result.e - 0.0).abs() < 1e-10);
assert!((result.f - 0.0).abs() < 1e-10);
}
// Acceptance criteria tests for pdftract-1os1
#[test]
fn test_64_nested_q_calls_succeed() {
// AC: 64 nested q calls succeed; the 65th emits diagnostic and discards
let mut stack = GraphicsStateStack::new();
let state = GraphicsState::new();
// 64 nested q calls should all succeed
for i in 0..64 {
assert!(stack.push(&state), "q call {} should succeed", i + 1);
}
assert_eq!(stack.depth(), 64);
// 65th q should fail
assert!(!stack.push(&state), "65th q should fail");
assert_eq!(stack.depth(), 64);
}
#[test]
fn test_64_q_plus_64_q_restores_initial_state() {
// AC: 64 q + 64 Q restores to initial state
let mut stack = GraphicsStateStack::new();
let mut state = GraphicsState::new();
// Modify state
let translate = Matrix3x3::from_pdf_array([1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
state.concat_ctm(&translate);
let initial_ctm = state.ctm;
// Push 64 times
for _ in 0..64 {
stack.push(&state);
}
// Pop 64 times
for _ in 0..64 {
stack.pop();
}
// Stack should be empty
assert!(stack.is_empty());
assert_eq!(stack.depth(), 0);
}
#[test]
fn test_q_at_depth_0_is_noop() {
// AC: Q at depth 0 is a no-op (no panic) and emits GSTATE_STACK_UNDERFLOW
let mut stack = GraphicsStateStack::new();
// Stack is empty, Q should return None
assert!(stack.pop().is_none());
assert!(stack.is_empty());
// Multiple Q at depth 0 should all be no-ops
assert!(stack.pop().is_none());
assert!(stack.pop().is_none());
assert!(stack.pop().is_none());
assert!(stack.is_empty());
}
#[test]
fn test_1000_paired_q_q_operations_succeed() {
// AC: 1000 paired q...Q operations succeed (depth never exceeds 1)
let mut stack = GraphicsStateStack::new();
let state = GraphicsState::new();
for i in 0..1000 {
assert!(stack.push(&state), "Paired q {} should succeed", i);
assert_eq!(stack.depth(), 1);
let restored = stack.pop();
assert!(restored.is_some(), "Paired Q {} should succeed", i);
assert!(stack.is_empty());
}
}
#[test]
fn test_max_depth_is_64() {
// Verify MAX_GSTATE_DEPTH is 64 per PDF spec
assert_eq!(MAX_GSTATE_DEPTH, 64);
}
// Acceptance criteria tests for pdftract-4dmp
#[test]
fn test_set_char_spacing() {
let mut state = GraphicsState::new();
state.set_char_spacing(5.0);
assert_eq!(state.char_spacing, 5.0);
}
#[test]
fn test_set_word_spacing() {
let mut state = GraphicsState::new();
state.set_word_spacing(10.0);
assert_eq!(state.word_spacing, 10.0);
}
#[test]
fn test_set_horiz_scaling_positive() {
let mut state = GraphicsState::new();
state.set_horiz_scaling(150.0);
assert_eq!(state.horiz_scaling, 150.0);
}
#[test]
fn test_set_horiz_scaling_zero_clamps_to_one() {
let mut state = GraphicsState::new();
state.set_horiz_scaling(0.0);
assert_eq!(state.horiz_scaling, 1.0);
}
#[test]
fn test_set_horiz_scaling_negative_clamps_to_one() {
let mut state = GraphicsState::new();
state.set_horiz_scaling(-10.0);
assert_eq!(state.horiz_scaling, 1.0);
}
#[test]
fn test_set_leading() {
let mut state = GraphicsState::new();
state.set_leading(15.0);
assert_eq!(state.leading, 15.0);
}
#[test]
fn test_set_text_rise() {
let mut state = GraphicsState::new();
state.set_text_rise(3.0);
assert_eq!(state.text_rise, 3.0);
}
#[test]
fn test_set_text_rendering_mode_valid() {
let mut state = GraphicsState::new();
for mode in 0..=7 {
state.set_text_rendering_mode(mode);
assert_eq!(state.text_rendering_mode, mode);
}
}
#[test]
fn test_set_text_rendering_mode_clamps_to_seven() {
let mut state = GraphicsState::new();
state.set_text_rendering_mode(9);
assert_eq!(state.text_rendering_mode, 7);
}
#[test]
fn test_set_text_rendering_mode_clamps_to_zero() {
let mut state = GraphicsState::new();
state.set_text_rendering_mode(255); // u8 overflow wraps to 255
assert_eq!(state.text_rendering_mode, 7);
}
#[test]
fn test_negative_char_spacing_allowed() {
let mut state = GraphicsState::new();
state.set_char_spacing(-5.0);
assert_eq!(state.char_spacing, -5.0);
}
#[test]
fn test_negative_word_spacing_allowed() {
let mut state = GraphicsState::new();
state.set_word_spacing(-10.0);
assert_eq!(state.word_spacing, -10.0);
}
#[test]
fn test_negative_text_rise_allowed() {
let mut state = GraphicsState::new();
state.set_text_rise(-3.0);
assert_eq!(state.text_rise, -3.0);
}
#[test]
fn test_negative_leading_allowed() {
let mut state = GraphicsState::new();
state.set_leading(-15.0);
assert_eq!(state.leading, -15.0);
}
// Acceptance criteria tests for pdftract-4ubed (color operators)
#[test]
fn test_color_space_component_count() {
// AC: DeviceGray has 1 component
assert_eq!(ColorSpace::DeviceGray.component_count(), 1);
// AC: DeviceRGB has 3 components
assert_eq!(ColorSpace::DeviceRGB.component_count(), 3);
// AC: DeviceCMYK has 4 components
assert_eq!(ColorSpace::DeviceCMYK.component_count(), 4);
// AC: Pattern has 0 components (uses names, not numbers)
assert_eq!(ColorSpace::Pattern.component_count(), 0);
// AC: Other spaces have 0 components
assert_eq!(ColorSpace::ICCBased.component_count(), 0);
assert_eq!(ColorSpace::Indexed.component_count(), 0);
assert_eq!(ColorSpace::Other.component_count(), 0);
}
#[test]
fn test_set_fill_gray() {
let mut state = GraphicsState::new();
state.set_fill_gray(0.5);
assert_eq!(state.fill_color, Color::DeviceGray(0.5));
assert_eq!(state.fill_color_space, ColorSpace::DeviceGray);
}
#[test]
fn test_set_stroke_gray() {
let mut state = GraphicsState::new();
state.set_stroke_gray(0.5);
assert_eq!(state.stroke_color, Color::DeviceGray(0.5));
assert_eq!(state.stroke_color_space, ColorSpace::DeviceGray);
}
#[test]
fn test_set_fill_rgb() {
let mut state = GraphicsState::new();
state.set_fill_rgb(0.5, 0.5, 0.5);
assert_eq!(state.fill_color, Color::DeviceRGB([0.5, 0.5, 0.5]));
assert_eq!(state.fill_color_space, ColorSpace::DeviceRGB);
}
#[test]
fn test_set_stroke_rgb() {
let mut state = GraphicsState::new();
state.set_stroke_rgb(1.0, 0.0, 0.0);
assert_eq!(state.stroke_color, Color::DeviceRGB([1.0, 0.0, 0.0]));
assert_eq!(state.stroke_color_space, ColorSpace::DeviceRGB);
}
#[test]
fn test_set_fill_cmyk() {
let mut state = GraphicsState::new();
state.set_fill_cmyk(0.1, 0.2, 0.3, 0.4);
assert_eq!(state.fill_color, Color::DeviceCMYK([0.1, 0.2, 0.3, 0.4]));
assert_eq!(state.fill_color_space, ColorSpace::DeviceCMYK);
}
#[test]
fn test_set_stroke_cmyk() {
let mut state = GraphicsState::new();
state.set_stroke_cmyk(0.1, 0.2, 0.3, 0.4);
assert_eq!(state.stroke_color, Color::DeviceCMYK([0.1, 0.2, 0.3, 0.4]));
assert_eq!(state.stroke_color_space, ColorSpace::DeviceCMYK);
}
#[test]
fn test_set_fill_color_space() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceRGB);
assert_eq!(state.fill_color_space, ColorSpace::DeviceRGB);
}
#[test]
fn test_set_stroke_color_space() {
let mut state = GraphicsState::new();
state.set_stroke_color_space(ColorSpace::Pattern);
assert_eq!(state.stroke_color_space, ColorSpace::Pattern);
}
#[test]
fn test_set_fill_color_device_gray() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceGray);
state.set_fill_color(&[0.5]);
assert_eq!(state.fill_color, Color::DeviceGray(0.5));
}
#[test]
fn test_set_fill_color_device_rgb() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceRGB);
state.set_fill_color(&[1.0, 0.0, 0.0]);
assert_eq!(state.fill_color, Color::DeviceRGB([1.0, 0.0, 0.0]));
}
#[test]
fn test_set_fill_color_device_cmyk() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceCMYK);
state.set_fill_color(&[0.1, 0.2, 0.3, 0.4]);
assert_eq!(state.fill_color, Color::DeviceCMYK([0.1, 0.2, 0.3, 0.4]));
}
#[test]
fn test_set_fill_color_insufficient_components() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceRGB);
// Only 2 components for DeviceRGB (needs 3)
state.set_fill_color(&[1.0, 0.0]);
assert_eq!(state.fill_color, Color::Other);
}
#[test]
fn test_set_fill_color_unknown_colorspace() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::ICCBased);
state.set_fill_color(&[0.5]);
assert_eq!(state.fill_color, Color::Other);
}
#[test]
fn test_set_fill_color_named_spot() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::Separation);
state.set_fill_color_named(&[0.5], Some("PANTONE"));
assert_eq!(state.fill_color, Color::Spot(Arc::from("PANTONE"), 0.5));
}
#[test]
fn test_set_fill_color_named_spot_default_tint() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::Separation);
// No tint value, defaults to 1.0
state.set_fill_color_named(&[], Some("PANTONE"));
assert_eq!(state.fill_color, Color::Spot(Arc::from("PANTONE"), 1.0));
}
#[test]
fn test_set_fill_color_named_pattern() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::Pattern);
state.set_fill_color_named(&[], Some("Pattern1"));
// Pattern color space always sets Color::Other
assert_eq!(state.fill_color, Color::Other);
}
#[test]
fn test_set_fill_color_named_fallback_to_numeric() {
let mut state = GraphicsState::new();
state.set_fill_color_space(ColorSpace::DeviceRGB);
// No name provided, falls back to numeric-only behavior
state.set_fill_color_named(&[1.0, 0.0, 0.0], None);
assert_eq!(state.fill_color, Color::DeviceRGB([1.0, 0.0, 0.0]));
}
#[test]
fn test_initial_fill_color_is_black() {
let state = GraphicsState::initial();
assert_eq!(state.fill_color, Color::DeviceGray(0.0));
assert_eq!(state.fill_color_space, ColorSpace::DeviceGray);
}
#[test]
fn test_initial_stroke_color_is_black() {
let state = GraphicsState::initial();
assert_eq!(state.stroke_color, Color::DeviceGray(0.0));
assert_eq!(state.stroke_color_space, ColorSpace::DeviceGray);
}
#[test]
fn test_graphics_state_clone_preserves_colors() {
let mut state1 = GraphicsState::new();
state1.set_fill_rgb(1.0, 0.0, 0.0);
state1.set_stroke_cmyk(0.1, 0.2, 0.3, 0.4);
state1.set_fill_color_space(ColorSpace::DeviceRGB);
state1.set_stroke_color_space(ColorSpace::DeviceCMYK);
let state2 = state1.clone();
assert_eq!(state2.fill_color, Color::DeviceRGB([1.0, 0.0, 0.0]));
assert_eq!(state2.stroke_color, Color::DeviceCMYK([0.1, 0.2, 0.3, 0.4]));
assert_eq!(state2.fill_color_space, ColorSpace::DeviceRGB);
assert_eq!(state2.stroke_color_space, ColorSpace::DeviceCMYK);
}
}