feat(pdftract-44f6): implement GraphicsState with 13 fields, Color enum, and matrix ops
Implements the complete graphics state per PDF spec section 8.4:
- Color enum with 5 variants (DeviceGray/RGB/CMYK, Spot, Other)
- Color::to_css_hex() for JSON serialization (returns None for Spot/Other)
- GraphicsState struct with all 13 fields (ctm, text_matrix, text_line_matrix,
font, font_size, char_spacing, word_spacing, horiz_scaling, leading,
text_rise, text_rendering_mode, fill_color, stroke_color)
- GraphicsState::initial() returning default state (identity CTM, black colors)
- Matrix operations: scale(), translate(), rotate(), invert()
- Manual Debug impl for GraphicsState (Font doesn't implement Debug)
All acceptance criteria PASS:
- initial() has identity CTM, font_size 0.0, fill_color DeviceGray(0.0)
- Clone produces deep-equal value
- Color::DeviceRGB([1.0, 0.0, 0.0]).to_css_hex() == Some("#ff0000")
- Color::Spot returns None
- Matrix multiply identity*identity within 1e-10
Closes: pdftract-44f6
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cbbe7e5f44
commit
6ea0b0aa54
2 changed files with 470 additions and 5 deletions
|
|
@ -14,11 +14,75 @@
|
|||
//! x' = a*x + c*y + e
|
||||
//! y' = b*x + d*y + f
|
||||
|
||||
use crate::diagnostics::{DiagCode, Diagnostic};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::font::Font;
|
||||
|
||||
/// Maximum depth of graphics state stack (prevents stack overflow).
|
||||
const MAX_GSTATE_DEPTH: usize = 32;
|
||||
|
||||
/// 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.0–1.0 (black to white)
|
||||
DeviceGray(f32),
|
||||
/// DeviceRGB: three components [R, G, B] each 0.0–1.0
|
||||
DeviceRGB([f32; 3]),
|
||||
/// DeviceCMYK: four components [C, M, Y, K] each 0.0–1.0
|
||||
DeviceCMYK([f32; 4]),
|
||||
/// Spot color: (colorant name, tint 0.0–1.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:
|
||||
|
|
@ -113,6 +177,67 @@ impl Matrix3x3 {
|
|||
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 {
|
||||
|
|
@ -123,20 +248,98 @@ impl Default for Matrix3x3 {
|
|||
|
||||
/// Graphics state as defined in PDF spec section 8.4.
|
||||
///
|
||||
/// This contains the CTM and other graphics state parameters.
|
||||
/// For Phase 5.2.1 image compositing, we only need the CTM.
|
||||
#[derive(Debug, Clone)]
|
||||
/// 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
|
||||
/// 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, 0–7)
|
||||
pub text_rendering_mode: u8,
|
||||
/// Fill color
|
||||
pub fill_color: Color,
|
||||
/// Stroke color
|
||||
pub stroke_color: Color,
|
||||
}
|
||||
|
||||
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)
|
||||
.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)
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,4 +537,166 @@ mod tests {
|
|||
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]);
|
||||
assert_eq!(color.to_css_hex(), Some("#ff8080".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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
notes/pdftract-44f6.md
Normal file
100
notes/pdftract-44f6.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# pdftract-44f6: GraphicsState struct + Color enum + matrix initialization
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the complete `GraphicsState` struct with all 13 fields per PDF spec section 8.4, plus the `Color` enum with CSS hex serialization and matrix operations.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `crates/pdftract-core/src/graphics_state.rs`
|
||||
|
||||
1. **Added `Color` enum** with 5 variants:
|
||||
- `DeviceGray(f32)` - single grayscale component
|
||||
- `DeviceRGB([f32; 3])` - RGB color
|
||||
- `DeviceCMYK([f32; 4])` - CMYK color
|
||||
- `Spot(Arc<str>, f32)` - spot color with tint
|
||||
- `Other` - other color spaces
|
||||
|
||||
2. **Added `Color::to_css_hex()`** method:
|
||||
- Converts DeviceGray/RGB/CMYK to "#rrggbb" format
|
||||
- Returns None for Spot/Other
|
||||
- Includes clamp for safety
|
||||
- CMYK→RGB uses naive formula: R=(1-C)*(1-K)
|
||||
|
||||
3. **Extended `GraphicsState` struct** with all 13 fields:
|
||||
- `ctm: Matrix3x3` - current transformation matrix
|
||||
- `text_matrix: Matrix3x3` - Tm
|
||||
- `text_line_matrix: Matrix3x3` - Tlm
|
||||
- `font: Option<Arc<Font>>` - current font
|
||||
- `font_size: f64` - Tf
|
||||
- `char_spacing: f64` - Tc
|
||||
- `word_spacing: f64` - Tw
|
||||
- `horiz_scaling: f64` - Tz (default 100)
|
||||
- `leading: f64` - TL
|
||||
- `text_rise: f64` - Ts
|
||||
- `text_rendering_mode: u8` - Tr (0-7)
|
||||
- `fill_color: Color` - fill color
|
||||
- `stroke_color: Color` - stroke color
|
||||
|
||||
4. **Added `GraphicsState::initial()`** returning default state:
|
||||
- Identity CTM, text_matrix, text_line_matrix
|
||||
- font: None
|
||||
- font_size: 0.0
|
||||
- All spacings: 0.0
|
||||
- horiz_scaling: 100.0
|
||||
- text_rendering_mode: 0
|
||||
- fill/stroke_color: DeviceGray(0.0) (black)
|
||||
|
||||
5. **Added matrix operations** to `Matrix3x3`:
|
||||
- `scale(sx, sy)` - create scale matrix
|
||||
- `translate(tx, ty)` - create translation matrix
|
||||
- `rotate(angle)` - create rotation matrix (angle in radians)
|
||||
- `invert()` - invert matrix, returns None if singular
|
||||
|
||||
6. **Implemented `Debug` manually** for `GraphicsState`:
|
||||
- Font doesn't implement Debug, so we show `<Arc<Font>>` placeholder
|
||||
|
||||
7. **Added comprehensive tests**:
|
||||
- `test_gstate_initial_ctm_is_identity`
|
||||
- `test_gstate_initial_font_size_is_zero`
|
||||
- `test_gstate_initial_fill_color_is_black`
|
||||
- `test_gstate_initial_horiz_scaling_is_100`
|
||||
- `test_gstate_initial_text_matrices_are_identity`
|
||||
- `test_gstate_initial_font_is_none`
|
||||
- `test_gstate_initial_text_rendering_mode_is_0`
|
||||
- `test_gstate_clone_deep_equal`
|
||||
- `test_color_device_rgb_to_css_hex` - verifies `#ff0000` for red
|
||||
- `test_color_device_gray_to_css_hex`
|
||||
- `test_color_device_cmyk_to_css_hex`
|
||||
- `test_color_spot_to_css_hex_none`
|
||||
- `test_color_other_to_css_hex_none`
|
||||
- `test_color_device_rgb_clamped`
|
||||
- `test_matrix_scale`
|
||||
- `test_matrix_translate`
|
||||
- `test_matrix_rotate`
|
||||
- `test_matrix_invert_identity`
|
||||
- `test_matrix_invert_translation`
|
||||
- `test_matrix_invert_singular`
|
||||
- `test_identity_multiply_identity`
|
||||
- `test_multiply_within_tolerance`
|
||||
|
||||
## Acceptance Criteria Status
|
||||
|
||||
- ✅ `GraphicsState::initial()` returns state with ctm == identity
|
||||
- ✅ `GraphicsState::initial()` returns state with font_size == 0.0
|
||||
- ✅ `GraphicsState::initial()` returns state with fill_color == DeviceGray(0.0)
|
||||
- ✅ Clone produces a deep-equal value (Arc cloned cheaply)
|
||||
- ✅ `Color::DeviceRGB([1.0, 0.0, 0.0]).to_css_hex() == Some("#ff0000".into())`
|
||||
- ✅ `Color::Spot("PANTONE".into(), 0.5).to_css_hex() == None`
|
||||
- ✅ Matrix multiply test: identity * identity == identity within 1e-10
|
||||
|
||||
## Notes
|
||||
|
||||
- Font doesn't implement Debug, so GraphicsState has a manual Debug impl that shows `<Arc<Font>>` placeholder for the font field
|
||||
- All matrix operations use f64 for precision (INV-30)
|
||||
- Color uses f32 for components per PDF spec convention
|
||||
- GraphicsState is Clone thanks to Arc<Font> (cheap clone for q/Q operators)
|
||||
|
||||
## References
|
||||
|
||||
- Plan section: Phase 3.1 State struct fields (lines 1444-1460), Color type (lines 1463-1471), CSS hex rules (line 1473)
|
||||
Loading…
Add table
Reference in a new issue