use crate::geometry::{Matrix, Rect};
use crate::pages::boxes::object_to_f64;
use lopdf::{Dictionary, Document, Object, ObjectId};
const MAX_FORM_DEPTH: u8 = 8;
#[derive(Clone, Debug, PartialEq)]
pub enum PdfColor {
DeviceGray(f64),
DeviceRgb(f64, f64, f64),
DeviceCmyk(f64, f64, f64, f64),
Separation { name: String, tint: f64 },
}
#[derive(Clone, Debug, Default)]
enum ColorSpace {
#[default]
DeviceGray,
DeviceRgb,
DeviceCmyk,
Separation(String),
Other,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OverprintState {
pub stroke_overprint: bool,
pub fill_overprint: bool,
pub overprint_mode: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CmykChannel {
Cyan,
Magenta,
Yellow,
Black,
}
#[derive(Clone, Copy, Debug)]
pub enum PathPoint {
MoveTo(f64, f64),
LineTo(f64, f64),
CurveTo(f64, f64, f64, f64, f64, f64),
Close,
}
#[derive(Clone, Debug, Default)]
pub struct SubPath {
pub points: Vec<PathPoint>,
}
#[derive(Clone, Debug)]
pub enum ObjectKind {
Fill,
Stroke,
FillStroke,
Text(String),
Image,
FormXObject,
}
#[derive(Clone, Debug)]
pub struct PageObject {
pub bbox: Rect,
pub ctm: Matrix,
pub kind: ObjectKind,
pub fill_color: Option<PdfColor>,
pub stroke_color: Option<PdfColor>,
pub stroke_width: f64,
pub overprint: OverprintState,
pub subpaths: Vec<SubPath>,
}
pub struct ObjectTree {
pub objects: Vec<PageObject>,
}
#[derive(Clone)]
struct GraphicsState {
ctm: Matrix,
fill_color: PdfColor,
stroke_color: PdfColor,
stroke_width: f64,
fill_cs: ColorSpace,
stroke_cs: ColorSpace,
overprint: OverprintState,
}
impl Default for GraphicsState {
fn default() -> Self {
Self {
ctm: Matrix::identity(),
fill_color: PdfColor::DeviceGray(0.0),
stroke_color: PdfColor::DeviceGray(0.0),
stroke_width: 1.0,
fill_cs: ColorSpace::DeviceGray,
stroke_cs: ColorSpace::DeviceGray,
overprint: OverprintState::default(),
}
}
}
pub fn build_object_tree(doc: &Document, page_id: ObjectId) -> crate::Result<ObjectTree> {
let content = doc.get_and_decode_page_content(page_id)?;
let mut objects = Vec::new();
parse_content(
doc,
&content.operations,
&GraphicsState::default(),
page_id,
&mut objects,
MAX_FORM_DEPTH,
);
Ok(ObjectTree { objects })
}
fn parse_content(
doc: &Document,
operations: &[lopdf::content::Operation],
initial_gs: &GraphicsState,
resource_parent_id: ObjectId,
mut objects: &mut Vec<PageObject>,
depth: u8,
) {
let mut gs_stack: Vec<GraphicsState> = Vec::new();
let mut gs = initial_gs.clone();
let mut subpaths: Vec<SubPath> = Vec::new();
let mut current_sub = SubPath::default();
let mut in_text = false;
let mut text_buf = String::new();
let mut text_origin: Option<Matrix> = None;
let mut tm = Matrix::identity(); let mut lm = Matrix::identity(); let mut font_size: f64 = 12.0;
let mut leading: f64 = 0.0;
for op in operations {
match op.operator.as_str() {
"q" => gs_stack.push(gs.clone()),
"Q" => {
if let Some(prev) = gs_stack.pop() {
gs = prev;
}
}
"cm" if op.operands.len() >= 6 => {
let m = ops_to_matrix(&op.operands);
gs.ctm = gs.ctm.concat(&m);
}
"w" if !op.operands.is_empty() => {
gs.stroke_width = object_to_f64(&op.operands[0]);
}
"G" if !op.operands.is_empty() => {
gs.stroke_color = PdfColor::DeviceGray(object_to_f64(&op.operands[0]));
}
"RG" if op.operands.len() >= 3 => {
gs.stroke_color = PdfColor::DeviceRgb(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
object_to_f64(&op.operands[2]),
);
}
"K" if op.operands.len() >= 4 => {
gs.stroke_color = PdfColor::DeviceCmyk(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
object_to_f64(&op.operands[2]),
object_to_f64(&op.operands[3]),
);
}
"g" if !op.operands.is_empty() => {
gs.fill_color = PdfColor::DeviceGray(object_to_f64(&op.operands[0]));
}
"rg" if op.operands.len() >= 3 => {
gs.fill_color = PdfColor::DeviceRgb(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
object_to_f64(&op.operands[2]),
);
}
"k" if op.operands.len() >= 4 => {
gs.fill_color = PdfColor::DeviceCmyk(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
object_to_f64(&op.operands[2]),
object_to_f64(&op.operands[3]),
);
}
"gs" if !op.operands.is_empty() => {
if let Object::Name(name) = &op.operands[0] {
if let Some(op_state) = read_ext_gstate(doc, resource_parent_id, name) {
gs.overprint = op_state;
}
}
}
"cs" if !op.operands.is_empty() => {
if let Object::Name(name) = &op.operands[0] {
gs.fill_cs = resolve_colorspace(doc, resource_parent_id, name);
}
}
"CS" if !op.operands.is_empty() => {
if let Object::Name(name) = &op.operands[0] {
gs.stroke_cs = resolve_colorspace(doc, resource_parent_id, name);
}
}
"scn" | "sc" => {
gs.fill_color = color_from_cs(&gs.fill_cs, &op.operands);
}
"SCN" | "SC" => {
gs.stroke_color = color_from_cs(&gs.stroke_cs, &op.operands);
}
"m" if op.operands.len() >= 2 => {
if !current_sub.points.is_empty() {
subpaths.push(std::mem::take(&mut current_sub));
}
current_sub.points.push(PathPoint::MoveTo(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
));
}
"l" if op.operands.len() >= 2 => {
current_sub.points.push(PathPoint::LineTo(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
));
}
"c" if op.operands.len() >= 6 => {
current_sub.points.push(PathPoint::CurveTo(
object_to_f64(&op.operands[0]),
object_to_f64(&op.operands[1]),
object_to_f64(&op.operands[2]),
object_to_f64(&op.operands[3]),
object_to_f64(&op.operands[4]),
object_to_f64(&op.operands[5]),
));
}
"v" if op.operands.len() >= 4 => {
let x2 = object_to_f64(&op.operands[0]);
let y2 = object_to_f64(&op.operands[1]);
let x3 = object_to_f64(&op.operands[2]);
let y3 = object_to_f64(&op.operands[3]);
current_sub
.points
.push(PathPoint::CurveTo(x2, y2, x2, y2, x3, y3));
}
"y" if op.operands.len() >= 4 => {
let x1 = object_to_f64(&op.operands[0]);
let y1 = object_to_f64(&op.operands[1]);
let x3 = object_to_f64(&op.operands[2]);
let y3 = object_to_f64(&op.operands[3]);
current_sub
.points
.push(PathPoint::CurveTo(x1, y1, x3, y3, x3, y3));
}
"h" => {
current_sub.points.push(PathPoint::Close);
}
"re" if op.operands.len() >= 4 => {
if !current_sub.points.is_empty() {
subpaths.push(std::mem::take(&mut current_sub));
}
let x = object_to_f64(&op.operands[0]);
let y = object_to_f64(&op.operands[1]);
let w = object_to_f64(&op.operands[2]);
let h = object_to_f64(&op.operands[3]);
current_sub.points.push(PathPoint::MoveTo(x, y));
current_sub.points.push(PathPoint::LineTo(x + w, y));
current_sub.points.push(PathPoint::LineTo(x + w, y + h));
current_sub.points.push(PathPoint::LineTo(x, y + h));
current_sub.points.push(PathPoint::Close);
}
"S" => {
commit_paint(
&mut objects,
&mut subpaths,
&mut current_sub,
&gs,
ObjectKind::Stroke,
);
}
"s" => {
current_sub.points.push(PathPoint::Close);
commit_paint(
&mut objects,
&mut subpaths,
&mut current_sub,
&gs,
ObjectKind::Stroke,
);
}
"f" | "f*" | "F" => {
commit_paint(
&mut objects,
&mut subpaths,
&mut current_sub,
&gs,
ObjectKind::Fill,
);
}
"B" | "B*" => {
commit_paint(
&mut objects,
&mut subpaths,
&mut current_sub,
&gs,
ObjectKind::FillStroke,
);
}
"b" | "b*" => {
current_sub.points.push(PathPoint::Close);
commit_paint(
&mut objects,
&mut subpaths,
&mut current_sub,
&gs,
ObjectKind::FillStroke,
);
}
"n" => {
subpaths.clear();
current_sub = SubPath::default();
}
"Do" if !op.operands.is_empty() => {
if let Object::Name(name) = &op.operands[0] {
match resolve_xobject(doc, resource_parent_id, name) {
Some((form_id, ref subtype)) if subtype == b"Form" => {
if depth == 0 {
let local_bbox = form_bbox_from_id(doc, form_id).unwrap_or(Rect {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
});
objects.push(PageObject {
bbox: gs.ctm.transform_rect(&local_bbox),
ctm: gs.ctm,
kind: ObjectKind::FormXObject,
fill_color: None,
stroke_color: None,
stroke_width: 0.0,
overprint: gs.overprint,
subpaths: vec![],
});
} else {
parse_form_xobject(doc, form_id, &gs, objects, depth - 1);
}
}
Some((_, ref subtype)) if subtype == b"Image" => {
let bbox = gs.ctm.transform_rect(&Rect {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
});
objects.push(PageObject {
bbox,
ctm: gs.ctm,
kind: ObjectKind::Image,
fill_color: None,
stroke_color: None,
stroke_width: 0.0,
overprint: gs.overprint,
subpaths: vec![],
});
}
_ => {}
}
}
}
"BT" => {
in_text = true;
text_buf.clear();
text_origin = None;
tm = Matrix::identity();
lm = Matrix::identity();
leading = 0.0;
}
"ET" => {
if in_text && !text_buf.is_empty() {
let origin = text_origin.unwrap_or(tm);
let char_count = text_buf.chars().count() as f64;
let text_w = char_count * 0.5 * font_size;
let ascent = 0.8 * font_size;
let descent = -0.2 * font_size;
let corners: [(f64, f64); 4] = [
(0.0, descent),
(text_w, descent),
(0.0, ascent),
(text_w, ascent),
];
let mut px_arr = [0.0_f64; 4];
let mut py_arr = [0.0_f64; 4];
for (i, &(lx, ly)) in corners.iter().enumerate() {
let tx = origin.a * lx + origin.c * ly + origin.e;
let ty = origin.b * lx + origin.d * ly + origin.f;
let (cx, cy) = gs.ctm.transform_point(tx, ty);
px_arr[i] = cx;
py_arr[i] = cy;
}
let min_x = px_arr.iter().copied().fold(f64::INFINITY, f64::min);
let max_x = px_arr.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let min_y = py_arr.iter().copied().fold(f64::INFINITY, f64::min);
let max_y = py_arr.iter().copied().fold(f64::NEG_INFINITY, f64::max);
objects.push(PageObject {
bbox: Rect {
x: min_x,
y: min_y,
width: (max_x - min_x).max(0.1),
height: (max_y - min_y).max(0.1),
},
ctm: gs.ctm,
kind: ObjectKind::Text(std::mem::take(&mut text_buf)),
fill_color: Some(gs.fill_color.clone()),
stroke_color: None,
stroke_width: 0.0,
overprint: gs.overprint,
subpaths: vec![],
});
}
in_text = false;
text_buf.clear();
text_origin = None;
}
"Tf" if in_text && op.operands.len() >= 2 => {
font_size = object_to_f64(&op.operands[1]).abs().max(0.1);
}
"TL" if in_text && !op.operands.is_empty() => {
leading = object_to_f64(&op.operands[0]);
}
"Tm" if in_text && op.operands.len() >= 6 => {
tm = ops_to_matrix(&op.operands);
lm = tm;
}
"Td" | "TD" if in_text && op.operands.len() >= 2 => {
let tx = object_to_f64(&op.operands[0]);
let ty = object_to_f64(&op.operands[1]);
if op.operator == "TD" {
leading = -ty;
}
let (new_e, new_f) = lm.transform_point(tx, ty);
lm = Matrix::from_values(lm.a, lm.b, lm.c, lm.d, new_e, new_f);
tm = lm;
}
"T*" if in_text => {
let (new_e, new_f) = lm.transform_point(0.0, -leading);
lm = Matrix::from_values(lm.a, lm.b, lm.c, lm.d, new_e, new_f);
tm = lm;
}
"Tj" | "'" if in_text && !op.operands.is_empty() => {
if let Object::String(bytes, _) = &op.operands[0] {
if text_origin.is_none() {
text_origin = Some(tm);
}
text_buf.push_str(&loss_bytes(bytes));
}
}
"\"" if in_text && op.operands.len() >= 3 => {
if let Object::String(bytes, _) = &op.operands[2] {
if text_origin.is_none() {
text_origin = Some(tm);
}
text_buf.push_str(&loss_bytes(bytes));
}
}
"TJ" if in_text && !op.operands.is_empty() => {
if let Object::Array(arr) = &op.operands[0] {
for item in arr {
if let Object::String(bytes, _) = item {
if text_origin.is_none() {
text_origin = Some(tm);
}
text_buf.push_str(&loss_bytes(bytes));
}
}
}
}
_ => {}
}
}
}
fn parse_form_xobject(
doc: &Document,
form_id: ObjectId,
parent_gs: &GraphicsState,
objects: &mut Vec<PageObject>,
depth: u8,
) {
let Ok(form_obj) = doc.get_object(form_id) else {
return;
};
let Object::Stream(stream) = form_obj else {
return;
};
let form_matrix = stream
.dict
.get(b"Matrix")
.ok()
.and_then(|m| m.as_array().ok())
.filter(|arr| arr.len() >= 6)
.map(|arr| ops_to_matrix(arr))
.unwrap_or_else(Matrix::identity);
let mut entry_gs = parent_gs.clone();
entry_gs.ctm = parent_gs.ctm.concat(&form_matrix);
let bytes = match stream.decompressed_content() {
Ok(b) => b,
Err(_) => stream.content.clone(),
};
let Ok(content) = lopdf::content::Content::decode(&bytes) else {
return;
};
parse_content(doc, &content.operations, &entry_gs, form_id, objects, depth);
}
fn resolve_xobject(
doc: &Document,
resource_parent_id: ObjectId,
name: &[u8],
) -> Option<(ObjectId, Vec<u8>)> {
let xo_dict = xobject_dict_for(doc, resource_parent_id)?;
let xobj_id = match xo_dict.get(name).ok()? {
Object::Reference(id) => *id,
_ => return None,
};
let xobj = doc.get_object(xobj_id).ok()?;
let stream = match xobj {
Object::Stream(s) => s,
_ => return None,
};
let subtype = match stream.dict.get(b"Subtype").ok()? {
Object::Name(n) => n.clone(),
_ => return None,
};
Some((xobj_id, subtype))
}
fn xobject_dict_for(doc: &Document, resource_parent_id: ObjectId) -> Option<Dictionary> {
let parent_obj = doc.get_object(resource_parent_id).ok()?;
let resources_obj: &Object = match parent_obj {
Object::Dictionary(d) => deref(doc, d.get(b"Resources").ok()?),
Object::Stream(s) => deref(doc, s.dict.get(b"Resources").ok()?),
_ => return None,
};
let res_dict = resources_obj.as_dict().ok()?;
let xo_obj = deref(doc, res_dict.get(b"XObject").ok()?);
Some(xo_obj.as_dict().ok()?.clone())
}
fn form_bbox_from_id(doc: &Document, form_id: ObjectId) -> Option<Rect> {
let form_obj = doc.get_object(form_id).ok()?;
let stream = match form_obj {
Object::Stream(s) => s,
_ => return None,
};
let bbox_arr = stream.dict.get(b"BBox").ok()?.as_array().ok()?;
if bbox_arr.len() < 4 {
return None;
}
Some(Rect::from_corners(
object_to_f64(&bbox_arr[0]),
object_to_f64(&bbox_arr[1]),
object_to_f64(&bbox_arr[2]),
object_to_f64(&bbox_arr[3]),
))
}
fn color_from_cs(cs: &ColorSpace, operands: &[Object]) -> PdfColor {
let get = |i: usize| operands.get(i).map(object_to_f64).unwrap_or(0.0);
match cs {
ColorSpace::DeviceGray => PdfColor::DeviceGray(get(0)),
ColorSpace::DeviceRgb => PdfColor::DeviceRgb(get(0), get(1), get(2)),
ColorSpace::DeviceCmyk => PdfColor::DeviceCmyk(get(0), get(1), get(2), get(3)),
ColorSpace::Separation(name) => PdfColor::Separation {
name: name.clone(),
tint: get(0),
},
ColorSpace::Other => PdfColor::DeviceGray(0.0),
}
}
fn resolve_colorspace(doc: &Document, resource_parent_id: ObjectId, name: &[u8]) -> ColorSpace {
match name {
b"DeviceGray" => return ColorSpace::DeviceGray,
b"DeviceRGB" => return ColorSpace::DeviceRgb,
b"DeviceCMYK" => return ColorSpace::DeviceCmyk,
_ => {}
}
let cs_dict = match colorspace_dict_for(doc, resource_parent_id) {
Some(d) => d,
None => return ColorSpace::Other,
};
let cs_object = match cs_dict.get(name).ok() {
Some(o) => deref(doc, o),
None => return ColorSpace::Other,
};
if let Ok(arr) = cs_object.as_array() {
if arr.len() >= 2 {
if let Object::Name(kind) = &arr[0] {
if kind == b"Separation" {
let ink_name = match &arr[1] {
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => "Unknown".to_string(),
};
return ColorSpace::Separation(ink_name);
}
}
}
}
ColorSpace::Other
}
fn read_ext_gstate(
doc: &Document,
resource_parent_id: ObjectId,
name: &[u8],
) -> Option<OverprintState> {
let gs_dict = ext_gstate_dict_for(doc, resource_parent_id)?;
let entry = match gs_dict.get(name).ok()? {
Object::Reference(id) => doc.get_object(*id).ok()?,
other => other,
};
let entry_dict = entry.as_dict().ok()?;
let bool_flag =
|key: &[u8]| -> bool { matches!(entry_dict.get(key), Ok(Object::Boolean(true))) };
let int_flag = |key: &[u8]| -> u8 {
match entry_dict.get(key) {
Ok(Object::Integer(n)) => (*n).clamp(0, 1) as u8,
_ => 0,
}
};
Some(OverprintState {
stroke_overprint: bool_flag(b"OP"),
fill_overprint: bool_flag(b"op"),
overprint_mode: int_flag(b"OPM"),
})
}
fn ext_gstate_dict_for(doc: &Document, resource_parent_id: ObjectId) -> Option<Dictionary> {
let parent_obj = doc.get_object(resource_parent_id).ok()?;
let resources_obj: &Object = match parent_obj {
Object::Dictionary(d) => deref(doc, d.get(b"Resources").ok()?),
Object::Stream(s) => deref(doc, s.dict.get(b"Resources").ok()?),
_ => return None,
};
let res_dict = resources_obj.as_dict().ok()?;
let gs_obj = deref(doc, res_dict.get(b"ExtGState").ok()?);
Some(gs_obj.as_dict().ok()?.clone())
}
fn colorspace_dict_for(doc: &Document, resource_parent_id: ObjectId) -> Option<Dictionary> {
let parent_obj = doc.get_object(resource_parent_id).ok()?;
let resources_obj: &Object = match parent_obj {
Object::Dictionary(d) => deref(doc, d.get(b"Resources").ok()?),
Object::Stream(s) => deref(doc, s.dict.get(b"Resources").ok()?),
_ => return None,
};
let res_dict = resources_obj.as_dict().ok()?;
let cs_object = deref(doc, res_dict.get(b"ColorSpace").ok()?);
Some(cs_object.as_dict().ok()?.clone())
}
fn ops_to_matrix(operands: &[Object]) -> Matrix {
Matrix::from_values(
object_to_f64(&operands[0]),
object_to_f64(&operands[1]),
object_to_f64(&operands[2]),
object_to_f64(&operands[3]),
object_to_f64(&operands[4]),
object_to_f64(&operands[5]),
)
}
fn commit_paint(
objects: &mut Vec<PageObject>,
subpaths: &mut Vec<SubPath>,
current_sub: &mut SubPath,
gs: &GraphicsState,
kind: ObjectKind,
) {
if !current_sub.points.is_empty() {
subpaths.push(std::mem::take(current_sub));
}
if subpaths.is_empty() {
return;
}
let bbox = path_bbox(subpaths, &gs.ctm);
let (fill_color, stroke_color) = match &kind {
ObjectKind::Fill => (Some(gs.fill_color.clone()), None),
ObjectKind::Stroke => (None, Some(gs.stroke_color.clone())),
ObjectKind::FillStroke => (Some(gs.fill_color.clone()), Some(gs.stroke_color.clone())),
_ => (None, None),
};
objects.push(PageObject {
bbox,
ctm: gs.ctm,
kind,
fill_color,
stroke_color,
stroke_width: gs.stroke_width,
overprint: gs.overprint,
subpaths: std::mem::take(subpaths),
});
}
fn path_bbox(subpaths: &[SubPath], ctm: &Matrix) -> Rect {
let mut xmin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
let mut ymin = f64::INFINITY;
let mut ymax = f64::NEG_INFINITY;
for sub in subpaths {
for pt in &sub.points {
match *pt {
PathPoint::MoveTo(x, y) | PathPoint::LineTo(x, y) => {
let (px, py) = ctm.transform_point(x, y);
xmin = xmin.min(px);
xmax = xmax.max(px);
ymin = ymin.min(py);
ymax = ymax.max(py);
}
PathPoint::CurveTo(x1, y1, x2, y2, x3, y3) => {
for (x, y) in [(x1, y1), (x2, y2), (x3, y3)] {
let (px, py) = ctm.transform_point(x, y);
xmin = xmin.min(px);
xmax = xmax.max(px);
ymin = ymin.min(py);
ymax = ymax.max(py);
}
}
PathPoint::Close => {}
}
}
}
if xmin.is_finite() {
Rect::from_corners(xmin, ymin, xmax, ymax)
} else {
Rect {
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
}
}
}
fn loss_bytes(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
pub fn deref<'a>(doc: &'a Document, obj: &'a Object) -> &'a Object {
match obj {
Object::Reference(id) => doc.get_object(*id).unwrap_or(obj),
_ => obj,
}
}
pub fn ref_id(obj: &Object) -> Option<ObjectId> {
if let Object::Reference(id) = obj {
Some(*id)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pdf_color_eq_gray() {
assert_eq!(PdfColor::DeviceGray(0.5), PdfColor::DeviceGray(0.5));
assert_ne!(PdfColor::DeviceGray(0.0), PdfColor::DeviceGray(1.0));
}
#[test]
fn pdf_color_eq_rgb() {
assert_eq!(
PdfColor::DeviceRgb(1.0, 0.0, 0.5),
PdfColor::DeviceRgb(1.0, 0.0, 0.5)
);
}
#[test]
fn pdf_color_eq_cmyk() {
assert_eq!(
PdfColor::DeviceCmyk(0.0, 0.0, 0.0, 1.0),
PdfColor::DeviceCmyk(0.0, 0.0, 0.0, 1.0),
);
}
#[test]
fn pdf_color_eq_separation() {
assert_eq!(
PdfColor::Separation {
name: "PANTONE 485 C".to_string(),
tint: 1.0
},
PdfColor::Separation {
name: "PANTONE 485 C".to_string(),
tint: 1.0
},
);
assert_ne!(
PdfColor::Separation {
name: "PANTONE 485 C".to_string(),
tint: 1.0
},
PdfColor::Separation {
name: "PANTONE 485 C".to_string(),
tint: 0.5
},
);
}
#[test]
fn overprint_state_default_is_all_off() {
let op = OverprintState::default();
assert!(!op.stroke_overprint);
assert!(!op.fill_overprint);
assert_eq!(op.overprint_mode, 0);
}
#[test]
fn overprint_state_equality() {
let a = OverprintState {
stroke_overprint: true,
fill_overprint: false,
overprint_mode: 1,
};
let b = OverprintState {
stroke_overprint: true,
fill_overprint: false,
overprint_mode: 1,
};
assert_eq!(a, b);
let c = OverprintState {
stroke_overprint: false,
fill_overprint: false,
overprint_mode: 0,
};
assert_ne!(a, c);
}
#[test]
fn path_bbox_empty_returns_zero() {
let r = path_bbox(&[], &Matrix::identity());
assert_eq!(r.x, 0.0);
assert_eq!(r.y, 0.0);
assert_eq!(r.width, 0.0);
assert_eq!(r.height, 0.0);
}
#[test]
fn path_bbox_axis_aligned_rect_identity_ctm() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(10.0, 20.0));
sub.points.push(PathPoint::LineTo(50.0, 20.0));
sub.points.push(PathPoint::LineTo(50.0, 60.0));
sub.points.push(PathPoint::LineTo(10.0, 60.0));
sub.points.push(PathPoint::Close);
let r = path_bbox(&[sub], &Matrix::identity());
assert!((r.x - 10.0).abs() < 0.01, "x={}", r.x);
assert!((r.y - 20.0).abs() < 0.01, "y={}", r.y);
assert!((r.width - 40.0).abs() < 0.01, "w={}", r.width);
assert!((r.height - 40.0).abs() < 0.01, "h={}", r.height);
}
#[test]
fn path_bbox_translate_ctm() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(0.0, 0.0));
sub.points.push(PathPoint::LineTo(10.0, 10.0));
let ctm = Matrix::from_values(1.0, 0.0, 0.0, 1.0, 100.0, 200.0);
let r = path_bbox(&[sub], &ctm);
assert!((r.x - 100.0).abs() < 0.01);
assert!((r.y - 200.0).abs() < 0.01);
assert!((r.width - 10.0).abs() < 0.01);
assert!((r.height - 10.0).abs() < 0.01);
}
#[test]
fn path_bbox_curve_includes_control_points() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(0.0, 0.0));
sub.points
.push(PathPoint::CurveTo(100.0, 0.0, 100.0, 100.0, 50.0, 50.0));
let r = path_bbox(&[sub], &Matrix::identity());
assert!(
r.width >= 100.0,
"bbox must span control points: w={}",
r.width
);
assert!(
r.height >= 100.0,
"bbox must span control points: h={}",
r.height
);
}
#[test]
fn ops_to_matrix_identity_passthrough() {
let ops = vec![
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
];
let m = ops_to_matrix(&ops);
let (x, y) = m.transform_point(3.0, 7.0);
assert!((x - 3.0).abs() < 1e-10);
assert!((y - 7.0).abs() < 1e-10);
}
#[test]
fn ops_to_matrix_translation() {
let ops = vec![
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
Object::Real(1.0),
Object::Real(50.0),
Object::Real(75.0),
];
let m = ops_to_matrix(&ops);
let (x, y) = m.transform_point(0.0, 0.0);
assert!((x - 50.0).abs() < 1e-10);
assert!((y - 75.0).abs() < 1e-10);
}
#[test]
fn parse_content_depth_zero_emits_no_objects_for_empty_ops() {
let mut objects: Vec<PageObject> = Vec::new();
parse_content(
&lopdf::Document::new(),
&[],
&GraphicsState::default(),
(0, 0),
&mut objects,
0,
);
assert!(objects.is_empty());
}
#[test]
fn parse_content_depth_zero_emits_fill_objects() {
use lopdf::content::Operation;
let ops = vec![
Operation::new(
"re",
vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(100.0),
Object::Real(100.0),
],
),
Operation::new("f", vec![]),
];
let mut objects: Vec<PageObject> = Vec::new();
parse_content(
&lopdf::Document::new(),
&ops,
&GraphicsState::default(),
(0, 0),
&mut objects,
0, );
assert_eq!(objects.len(), 1, "re+f must emit exactly one Fill object");
assert!(matches!(objects[0].kind, ObjectKind::Fill));
assert!((objects[0].bbox.width - 100.0).abs() < 0.1);
assert!((objects[0].bbox.height - 100.0).abs() < 0.1);
}
fn fixture() -> Option<(lopdf::Document, lopdf::ObjectId)> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/pdf_test_data_print_v2.pdf");
if !path.exists() {
return None;
}
let doc = lopdf::Document::load(&path).ok()?;
let page_id = doc.get_pages()[&1];
Some((doc, page_id))
}
#[test]
fn build_object_tree_produces_objects() {
let Some((doc, page_id)) = fixture() else {
return;
};
let tree = build_object_tree(&doc, page_id).unwrap();
assert!(
!tree.objects.is_empty(),
"expected at least one painted object"
);
}
#[test]
fn build_object_tree_all_bboxes_finite() {
let Some((doc, page_id)) = fixture() else {
return;
};
let tree = build_object_tree(&doc, page_id).unwrap();
for (i, obj) in tree.objects.iter().enumerate() {
assert!(obj.bbox.x.is_finite(), "object {i} bbox.x not finite");
assert!(obj.bbox.y.is_finite(), "object {i} bbox.y not finite");
assert!(
obj.bbox.width.is_finite(),
"object {i} bbox.width not finite"
);
assert!(
obj.bbox.height.is_finite(),
"object {i} bbox.height not finite"
);
}
}
#[test]
fn build_object_tree_has_image_xobject() {
let Some((doc, page_id)) = fixture() else {
return;
};
let tree = build_object_tree(&doc, page_id).unwrap();
let has_img = tree
.objects
.iter()
.any(|o| matches!(o.kind, ObjectKind::Image));
assert!(has_img, "expected at least one Image XObject");
}
#[test]
fn build_object_tree_fill_objects_have_fill_color() {
let Some((doc, page_id)) = fixture() else {
return;
};
let tree = build_object_tree(&doc, page_id).unwrap();
for obj in &tree.objects {
if matches!(obj.kind, ObjectKind::Fill | ObjectKind::FillStroke) {
assert!(
obj.fill_color.is_some(),
"fill/fillstroke object missing fill_color"
);
}
}
}
#[test]
fn build_object_tree_forms_are_expanded() {
let Some((doc, page_id)) = fixture() else {
return;
};
let tree = build_object_tree(&doc, page_id).unwrap();
let unexpanded = tree
.objects
.iter()
.filter(|o| matches!(o.kind, ObjectKind::FormXObject))
.count();
assert_eq!(
unexpanded, 0,
"all Form XObjects should be recursed into; found {unexpanded} placeholder(s)"
);
}
}