use std::str;
use oxideav_core::vector::{
DashPattern, FillRule, Group, LineCap, LineJoin, Node, Paint, Path, PathCommand, PathNode,
Point, Rgba, Stroke, Transform2D,
};
use crate::error::PdfError;
use crate::objects::{Dict, Object};
pub fn parse_content_stream(input: &[u8]) -> Result<Group, PdfError> {
let mut state = State::new(None, None, None, None, None);
state.parse(input)?;
Ok(state.finish().root)
}
pub fn parse_content_stream_with_resources(
input: &[u8],
ext_gstate: Option<&Dict>,
) -> Result<Group, PdfError> {
let mut state = State::new(ext_gstate, None, None, None, None);
state.parse(input)?;
Ok(state.finish().root)
}
pub fn parse_content_stream_full(
input: &[u8],
ext_gstate: Option<&Dict>,
font_resources: Option<&Dict>,
) -> Result<ParsedContent, PdfError> {
parse_content_stream_full_with_shading(input, ext_gstate, font_resources, None)
}
pub fn parse_content_stream_full_with_shading(
input: &[u8],
ext_gstate: Option<&Dict>,
font_resources: Option<&Dict>,
shading_resources: Option<&Dict>,
) -> Result<ParsedContent, PdfError> {
parse_content_stream_full_with_color_space(
input,
ext_gstate,
font_resources,
shading_resources,
None,
)
}
pub fn parse_content_stream_full_with_color_space(
input: &[u8],
ext_gstate: Option<&Dict>,
font_resources: Option<&Dict>,
shading_resources: Option<&Dict>,
color_space_resources: Option<&Dict>,
) -> Result<ParsedContent, PdfError> {
parse_content_stream_full_with_properties(
input,
ext_gstate,
font_resources,
shading_resources,
color_space_resources,
None,
)
}
pub fn parse_content_stream_full_with_properties(
input: &[u8],
ext_gstate: Option<&Dict>,
font_resources: Option<&Dict>,
shading_resources: Option<&Dict>,
color_space_resources: Option<&Dict>,
properties_resources: Option<&Dict>,
) -> Result<ParsedContent, PdfError> {
let mut state = State::new(
ext_gstate,
font_resources,
shading_resources,
color_space_resources,
properties_resources,
);
state.parse(input)?;
Ok(state.finish())
}
#[derive(Clone, Debug, Default)]
pub struct ParsedContent {
pub root: Group,
pub text_shows: Vec<ContentTextShow>,
pub shadings: Vec<ContentShading>,
pub marked_content: Vec<ContentMarkedContent>,
}
#[derive(Clone, Debug)]
pub struct ContentTextShow {
pub font_name: String,
pub font_size: f32,
pub font_dict: Option<Dict>,
pub bytes: Vec<u8>,
pub position: (f32, f32),
pub operator: TextShowOp,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextShowOp {
Tj,
TJ,
SingleQuote,
DoubleQuote,
}
#[derive(Clone, Debug)]
pub struct ContentShading {
pub name: String,
pub shading_dict: Option<Dict>,
pub ctm: Transform2D,
pub clip: Option<Path>,
}
#[derive(Clone, Debug)]
pub struct ContentMarkedContent {
pub operator: MarkedContentOp,
pub tag: String,
pub properties: Option<Dict>,
pub depth: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MarkedContentOp {
Mp,
Dp,
Bmc,
Bdc,
Emc,
}
struct State<'a> {
operands: Vec<Operand>,
stack: Vec<Frame>,
current_path: Option<Path>,
current_point: Point,
fill_paint: Option<Paint>,
stroke_paint: Option<Paint>,
fill_cs: ColorSpaceKind,
stroke_cs: ColorSpaceKind,
stroke_width: f32,
line_cap: LineCap,
line_join: LineJoin,
miter_limit: f32,
dash: Option<DashPattern>,
fill_alpha: f32,
stroke_alpha: f32,
ext_gstate: Option<&'a Dict>,
font_resources: Option<&'a Dict>,
shading_resources: Option<&'a Dict>,
color_space_resources: Option<&'a Dict>,
properties_resources: Option<&'a Dict>,
mc_depth: u32,
marked_content: Vec<ContentMarkedContent>,
current_font: Option<(String, f32)>,
text_matrix: Transform2D,
text_line_matrix: Transform2D,
text_leading: f32,
in_text_object: bool,
text_shows: Vec<ContentTextShow>,
shadings: Vec<ContentShading>,
}
struct Frame {
transform: Transform2D,
children: Vec<Node>,
clip: Option<Path>,
}
#[derive(Clone, Debug)]
enum Operand {
Number(f32),
Array(Vec<ArrayElem>),
Name(String),
String(Vec<u8>),
Dict(Dict),
}
#[derive(Clone, Debug)]
enum ArrayElem {
Number(f32),
String(Vec<u8>),
}
#[derive(Clone, Debug, PartialEq)]
enum PdfFunction {
Exponential {
domain: [f32; 2],
range: Option<Vec<f32>>,
c0: Vec<f32>,
c1: Vec<f32>,
n: f32,
},
Stitching {
domain: [f32; 2],
range: Option<Vec<f32>>,
functions: Vec<PdfFunction>,
bounds: Vec<f32>,
encode: Vec<f32>,
},
}
impl PdfFunction {
fn parse(obj: &Object) -> Option<PdfFunction> {
let Object::Dict(dict) = obj else {
return None;
};
let get = |key: &str| {
dict.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
};
let domain = get("Domain").and_then(read_num_pair)?;
let range = get("Range").and_then(read_num_array);
match get("FunctionType").and_then(number_as_i64) {
Some(2) => {
let c0 = get("C0")
.and_then(read_num_array)
.unwrap_or_else(|| vec![0.0]);
let c1 = get("C1")
.and_then(read_num_array)
.unwrap_or_else(|| vec![1.0]);
let n = get("N").and_then(number_as_f32)?;
if c0.len() != c1.len() || c0.is_empty() {
return None;
}
Some(PdfFunction::Exponential {
domain,
range,
c0,
c1,
n,
})
}
Some(3) => {
let Some(Object::Array(fs)) = get("Functions") else {
return None;
};
let functions: Vec<PdfFunction> =
fs.iter().map(PdfFunction::parse).collect::<Option<_>>()?;
if functions.is_empty() {
return None;
}
let bounds = get("Bounds").and_then(read_num_array).unwrap_or_default();
let encode = get("Encode").and_then(read_num_array)?;
if bounds.len() + 1 != functions.len() || encode.len() != 2 * functions.len() {
return None;
}
Some(PdfFunction::Stitching {
domain,
range,
functions,
bounds,
encode,
})
}
_ => None,
}
}
fn eval(&self, x: f32) -> Vec<f32> {
match self {
PdfFunction::Exponential {
domain,
range,
c0,
c1,
n,
} => {
let xc = x.clamp(domain[0], domain[1]);
let xn = xc.powf(*n);
let mut out: Vec<f32> = c0
.iter()
.zip(c1.iter())
.map(|(&a, &b)| a + xn * (b - a))
.collect();
clip_to_range(&mut out, range.as_deref());
out
}
PdfFunction::Stitching {
domain,
range,
functions,
bounds,
encode,
} => {
let xc = x.clamp(domain[0], domain[1]);
let k = functions.len();
let mut i = 0;
while i < bounds.len() && xc >= bounds[i] {
i += 1;
}
let lo = if i == 0 { domain[0] } else { bounds[i - 1] };
let hi = if i == k - 1 { domain[1] } else { bounds[i] };
let xprime = if (hi - lo).abs() < f32::EPSILON {
encode[2 * i]
} else {
interpolate(xc, lo, hi, encode[2 * i], encode[2 * i + 1])
};
let mut out = functions[i].eval(xprime);
clip_to_range(&mut out, range.as_deref());
out
}
}
}
}
fn interpolate(x: f32, xmin: f32, xmax: f32, ymin: f32, ymax: f32) -> f32 {
if (xmax - xmin).abs() < f32::EPSILON {
return ymin;
}
ymin + (x - xmin) * (ymax - ymin) / (xmax - xmin)
}
fn clip_to_range(out: &mut [f32], range: Option<&[f32]>) {
if let Some(r) = range {
for (j, v) in out.iter_mut().enumerate() {
if let (Some(&lo), Some(&hi)) = (r.get(2 * j), r.get(2 * j + 1)) {
*v = v.clamp(lo, hi);
}
}
}
}
fn read_num_pair(obj: &Object) -> Option<[f32; 2]> {
let Object::Array(items) = obj else {
return None;
};
if items.len() != 2 {
return None;
}
Some([number_as_f32(&items[0])?, number_as_f32(&items[1])?])
}
fn read_num_array(obj: &Object) -> Option<Vec<f32>> {
let Object::Array(items) = obj else {
return None;
};
items.iter().map(number_as_f32).collect()
}
#[derive(Clone, Debug, PartialEq)]
enum ColorSpaceKind {
DeviceGray,
DeviceRgb,
DeviceCmyk,
Indexed {
base: Box<ColorSpaceKind>,
hival: u32,
table: Vec<u8>,
},
Separation {
alt: Box<ColorSpaceKind>,
tint: PdfFunction,
none_colorant: bool,
},
Unknown,
}
impl ColorSpaceKind {
fn from_name(name: &str) -> Self {
match name {
"DeviceGray" | "G" => ColorSpaceKind::DeviceGray,
"DeviceRGB" | "RGB" => ColorSpaceKind::DeviceRgb,
"DeviceCMYK" | "CMYK" => ColorSpaceKind::DeviceCmyk,
_ => ColorSpaceKind::Unknown,
}
}
fn components(&self) -> Option<usize> {
match self {
ColorSpaceKind::DeviceGray => Some(1),
ColorSpaceKind::DeviceRgb => Some(3),
ColorSpaceKind::DeviceCmyk => Some(4),
ColorSpaceKind::Indexed { .. } => Some(1),
ColorSpaceKind::Separation { .. } => Some(1),
ColorSpaceKind::Unknown => None,
}
}
fn resolve_with_resources(name: &str, resources: Option<&Dict>) -> Self {
let direct = ColorSpaceKind::from_name(name);
if direct != ColorSpaceKind::Unknown {
return direct;
}
let Some(res) = resources else {
return ColorSpaceKind::Unknown;
};
match res.entries().iter().find(|(k, _)| k == name) {
Some((_, obj)) => color_space_from_object(obj),
None => ColorSpaceKind::Unknown,
}
}
}
fn color_space_from_object(obj: &Object) -> ColorSpaceKind {
match obj {
Object::Name(n) => ColorSpaceKind::from_name(n),
Object::Array(items) => match items.first() {
Some(Object::Name(family)) if family == "ICCBased" => icc_based_from_array(items),
Some(Object::Name(family)) if family == "Indexed" => indexed_from_array(items),
Some(Object::Name(family)) if family == "Separation" => separation_from_array(items),
_ => ColorSpaceKind::Unknown,
},
_ => ColorSpaceKind::Unknown,
}
}
fn icc_based_from_array(items: &[Object]) -> ColorSpaceKind {
let Some(Object::Dict(dict)) = items.get(1) else {
return ColorSpaceKind::Unknown;
};
if let Some((_, alt)) = dict.entries().iter().find(|(k, _)| k == "Alternate") {
let resolved = color_space_from_object(alt);
if resolved != ColorSpaceKind::Unknown {
return resolved;
}
}
match dict.entries().iter().find(|(k, _)| k == "N") {
Some((_, Object::Integer(1))) => ColorSpaceKind::DeviceGray,
Some((_, Object::Integer(3))) => ColorSpaceKind::DeviceRgb,
Some((_, Object::Integer(4))) => ColorSpaceKind::DeviceCmyk,
_ => ColorSpaceKind::Unknown,
}
}
fn indexed_from_array(items: &[Object]) -> ColorSpaceKind {
if items.len() < 4 {
return ColorSpaceKind::Unknown;
}
let base = color_space_from_object(&items[1]);
if base.components().is_none() || matches!(base, ColorSpaceKind::Indexed { .. }) {
return ColorSpaceKind::Unknown;
}
let hival = match &items[2] {
Object::Integer(n) if *n >= 0 && *n <= 255 => *n as u32,
_ => return ColorSpaceKind::Unknown,
};
let table = match &items[3] {
Object::LiteralString(b) | Object::HexString(b) => b.clone(),
_ => return ColorSpaceKind::Unknown,
};
ColorSpaceKind::Indexed {
base: Box::new(base),
hival,
table,
}
}
fn separation_from_array(items: &[Object]) -> ColorSpaceKind {
if items.len() < 4 {
return ColorSpaceKind::Unknown;
}
let none_colorant = matches!(&items[1], Object::Name(n) if n == "None");
let alt = color_space_from_object(&items[2]);
if alt.components().is_none()
|| matches!(
alt,
ColorSpaceKind::Indexed { .. } | ColorSpaceKind::Separation { .. }
)
{
if none_colorant {
return ColorSpaceKind::Separation {
alt: Box::new(ColorSpaceKind::DeviceGray),
tint: PdfFunction::Exponential {
domain: [0.0, 1.0],
range: None,
c0: vec![0.0],
c1: vec![0.0],
n: 1.0,
},
none_colorant: true,
};
}
return ColorSpaceKind::Unknown;
}
let Some(tint) = PdfFunction::parse(&items[3]) else {
return ColorSpaceKind::Unknown;
};
ColorSpaceKind::Separation {
alt: Box::new(alt),
tint,
none_colorant,
}
}
impl<'a> State<'a> {
fn new(
ext_gstate: Option<&'a Dict>,
font_resources: Option<&'a Dict>,
shading_resources: Option<&'a Dict>,
color_space_resources: Option<&'a Dict>,
properties_resources: Option<&'a Dict>,
) -> Self {
Self {
operands: Vec::new(),
stack: vec![Frame::new()],
current_path: None,
current_point: Point::default(),
fill_paint: None,
stroke_paint: None,
fill_cs: ColorSpaceKind::DeviceGray,
stroke_cs: ColorSpaceKind::DeviceGray,
stroke_width: 1.0,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
miter_limit: 10.0,
dash: None,
fill_alpha: 1.0,
stroke_alpha: 1.0,
ext_gstate,
font_resources,
shading_resources,
color_space_resources,
properties_resources,
mc_depth: 0,
marked_content: Vec::new(),
current_font: None,
text_matrix: Transform2D::identity(),
text_line_matrix: Transform2D::identity(),
text_leading: 0.0,
in_text_object: false,
text_shows: Vec::new(),
shadings: Vec::new(),
}
}
fn finish(mut self) -> ParsedContent {
while self.stack.len() > 1 {
self.pop_q();
}
let root = self.stack.pop().expect("root frame present");
ParsedContent {
root: Group {
transform: root.transform,
opacity: 1.0,
clip: root.clip,
children: root.children,
..Group::default()
},
text_shows: self.text_shows,
shadings: self.shadings,
marked_content: self.marked_content,
}
}
fn current(&mut self) -> &mut Frame {
self.stack.last_mut().expect("at least the root frame")
}
fn push_q(&mut self) {
self.stack.push(Frame::new());
}
fn pop_q(&mut self) {
if self.stack.len() <= 1 {
return;
}
let frame = self.stack.pop().unwrap();
if frame.is_effectively_empty() {
return;
}
let g = Group {
transform: frame.transform,
opacity: 1.0,
clip: frame.clip,
children: frame.children,
..Group::default()
};
self.current().children.push(Node::Group(g));
}
fn dispatch(&mut self, op: &[u8]) -> Result<(), PdfError> {
match op {
b"q" => {
self.push_q();
}
b"Q" => {
self.pop_q();
}
b"cm" => {
let nums = self.take_numbers(6)?;
let t = Transform2D {
a: nums[0],
b: nums[1],
c: nums[2],
d: nums[3],
e: nums[4],
f: nums[5],
};
let frame = self.current();
frame.transform = compose(frame.transform, t);
}
b"gs" => {
let name = match self.operands.last() {
Some(Operand::Name(n)) => Some(n.clone()),
_ => None,
};
self.operands.clear();
if let (Some(name), Some(ext_gstate)) = (name, self.ext_gstate) {
if let Some(dict) = lookup_dict(ext_gstate, &name) {
self.apply_ext_gstate(dict);
}
}
}
b"m" => {
let p = self.take_point()?;
let path = self.path_mut();
path.commands.push(PathCommand::MoveTo(p));
self.current_point = p;
}
b"l" => {
let p = self.take_point()?;
let path = self.path_mut();
path.commands.push(PathCommand::LineTo(p));
self.current_point = p;
}
b"c" => {
let nums = self.take_numbers(6)?;
let c1 = Point::new(nums[0], nums[1]);
let c2 = Point::new(nums[2], nums[3]);
let end = Point::new(nums[4], nums[5]);
let path = self.path_mut();
path.commands
.push(PathCommand::CubicCurveTo { c1, c2, end });
self.current_point = end;
}
b"v" => {
let nums = self.take_numbers(4)?;
let c1 = self.current_point;
let c2 = Point::new(nums[0], nums[1]);
let end = Point::new(nums[2], nums[3]);
let path = self.path_mut();
path.commands
.push(PathCommand::CubicCurveTo { c1, c2, end });
self.current_point = end;
}
b"y" => {
let nums = self.take_numbers(4)?;
let c1 = Point::new(nums[0], nums[1]);
let end = Point::new(nums[2], nums[3]);
let c2 = end;
let path = self.path_mut();
path.commands
.push(PathCommand::CubicCurveTo { c1, c2, end });
self.current_point = end;
}
b"re" => {
let nums = self.take_numbers(4)?;
let (x, y, w, h) = (nums[0], nums[1], nums[2], nums[3]);
let path = self.path_mut();
path.commands.push(PathCommand::MoveTo(Point::new(x, y)));
path.commands
.push(PathCommand::LineTo(Point::new(x + w, y)));
path.commands
.push(PathCommand::LineTo(Point::new(x + w, y + h)));
path.commands
.push(PathCommand::LineTo(Point::new(x, y + h)));
path.commands.push(PathCommand::Close);
self.current_point = Point::new(x, y);
}
b"h" => {
let path = self.path_mut();
path.commands.push(PathCommand::Close);
}
b"f" | b"F" => self.commit_path(true, false, FillRule::NonZero),
b"f*" => self.commit_path(true, false, FillRule::EvenOdd),
b"S" => self.commit_path(false, true, FillRule::NonZero),
b"s" => {
if let Some(p) = &mut self.current_path {
p.commands.push(PathCommand::Close);
}
self.commit_path(false, true, FillRule::NonZero);
}
b"B" => self.commit_path(true, true, FillRule::NonZero),
b"B*" => self.commit_path(true, true, FillRule::EvenOdd),
b"b" => {
if let Some(p) = &mut self.current_path {
p.commands.push(PathCommand::Close);
}
self.commit_path(true, true, FillRule::NonZero);
}
b"b*" => {
if let Some(p) = &mut self.current_path {
p.commands.push(PathCommand::Close);
}
self.commit_path(true, true, FillRule::EvenOdd);
}
b"n" => {
self.current_path = None;
self.operands.clear();
}
b"W" | b"W*" => {
if let Some(p) = self.current_path.take() {
self.current().clip = Some(p);
}
self.operands.clear();
}
b"rg" => {
let nums = self.take_numbers(3)?;
self.fill_cs = ColorSpaceKind::DeviceRgb;
self.fill_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[1], nums[2])));
}
b"RG" => {
let nums = self.take_numbers(3)?;
self.stroke_cs = ColorSpaceKind::DeviceRgb;
self.stroke_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[1], nums[2])));
}
b"g" => {
let nums = self.take_numbers(1)?;
self.fill_cs = ColorSpaceKind::DeviceGray;
self.fill_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[0], nums[0])));
}
b"G" => {
let nums = self.take_numbers(1)?;
self.stroke_cs = ColorSpaceKind::DeviceGray;
self.stroke_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[0], nums[0])));
}
b"k" | b"K" => {
let nums = self.take_numbers(4)?;
let p = Some(Paint::Solid(rgb_from_cmyk(
nums[0], nums[1], nums[2], nums[3],
)));
if op == b"K" {
self.stroke_cs = ColorSpaceKind::DeviceCmyk;
self.stroke_paint = p;
} else {
self.fill_cs = ColorSpaceKind::DeviceCmyk;
self.fill_paint = p;
}
}
b"sc" | b"scn" => {
let paint = self.color_from_components(&self.fill_cs.clone());
self.fill_paint = paint.or_else(|| {
self.fill_paint
.clone()
.or(Some(Paint::Solid(Rgba::opaque(0, 0, 0))))
});
self.operands.clear();
}
b"SC" | b"SCN" => {
let paint = self.color_from_components(&self.stroke_cs.clone());
self.stroke_paint = paint.or_else(|| {
self.stroke_paint
.clone()
.or(Some(Paint::Solid(Rgba::opaque(0, 0, 0))))
});
self.operands.clear();
}
b"cs" => {
self.fill_cs = self.take_color_space_name();
self.fill_paint = initial_color_for(&self.fill_cs);
self.operands.clear();
}
b"CS" => {
self.stroke_cs = self.take_color_space_name();
self.stroke_paint = initial_color_for(&self.stroke_cs);
self.operands.clear();
}
b"w" => {
let nums = self.take_numbers(1)?;
self.stroke_width = nums[0];
}
b"J" => {
let nums = self.take_numbers(1)?;
self.line_cap = match nums[0] as i32 {
0 => LineCap::Butt,
1 => LineCap::Round,
2 => LineCap::Square,
_ => LineCap::Butt,
};
}
b"j" => {
let nums = self.take_numbers(1)?;
self.line_join = match nums[0] as i32 {
0 => LineJoin::Miter,
1 => LineJoin::Round,
2 => LineJoin::Bevel,
_ => LineJoin::Miter,
};
}
b"M" => {
let nums = self.take_numbers(1)?;
self.miter_limit = nums[0];
}
b"d" => {
if self.operands.len() < 2 {
self.operands.clear();
return Ok(());
}
let offset = match self.operands.pop().unwrap() {
Operand::Number(n) => n,
_ => 0.0,
};
let array = match self.operands.pop().unwrap() {
Operand::Array(v) => v
.into_iter()
.filter_map(|el| match el {
ArrayElem::Number(n) => Some(n),
ArrayElem::String(_) => None,
})
.collect::<Vec<f32>>(),
_ => Vec::new(),
};
self.dash = if array.is_empty() {
None
} else {
Some(DashPattern { array, offset })
};
self.operands.clear();
}
b"BT" => {
self.text_matrix = Transform2D::identity();
self.text_line_matrix = Transform2D::identity();
self.in_text_object = true;
self.operands.clear();
}
b"ET" => {
self.in_text_object = false;
self.operands.clear();
}
b"Tf" => {
let size = match self.operands.last() {
Some(Operand::Number(n)) => *n,
_ => 0.0,
};
let name = match self.operands.iter().rev().nth(1) {
Some(Operand::Name(s)) => s.clone(),
_ => String::new(),
};
self.current_font = Some((name, size));
self.operands.clear();
}
b"TL" => {
if let Some(Operand::Number(n)) = self.operands.last() {
self.text_leading = *n;
}
self.operands.clear();
}
b"Tc" | b"Tw" | b"Tz" | b"Tr" | b"Ts" => {
self.operands.clear();
}
b"Td" => {
if let Ok(nums) = self.take_numbers(2) {
let (tx, ty) = (nums[0], nums[1]);
let m = Transform2D {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: tx,
f: ty,
};
self.text_line_matrix = compose(self.text_line_matrix, m);
self.text_matrix = self.text_line_matrix;
}
self.operands.clear();
}
b"TD" => {
if let Ok(nums) = self.take_numbers(2) {
let (tx, ty) = (nums[0], nums[1]);
self.text_leading = -ty;
let m = Transform2D {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: tx,
f: ty,
};
self.text_line_matrix = compose(self.text_line_matrix, m);
self.text_matrix = self.text_line_matrix;
}
self.operands.clear();
}
b"Tm" => {
if let Ok(nums) = self.take_numbers(6) {
let m = Transform2D {
a: nums[0],
b: nums[1],
c: nums[2],
d: nums[3],
e: nums[4],
f: nums[5],
};
self.text_matrix = m;
self.text_line_matrix = m;
}
self.operands.clear();
}
b"T*" => {
let leading = self.text_leading;
let m = Transform2D {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: -leading,
};
self.text_line_matrix = compose(self.text_line_matrix, m);
self.text_matrix = self.text_line_matrix;
self.operands.clear();
}
b"Tj" => {
let bytes = match self.operands.last() {
Some(Operand::String(s)) => s.clone(),
_ => Vec::new(),
};
self.emit_text_show(bytes, TextShowOp::Tj);
self.operands.clear();
}
b"TJ" => {
let mut bytes = Vec::new();
if let Some(Operand::Array(items)) = self.operands.last() {
for el in items {
if let ArrayElem::String(s) = el {
bytes.extend_from_slice(s);
}
}
}
self.emit_text_show(bytes, TextShowOp::TJ);
self.operands.clear();
}
b"'" => {
let leading = self.text_leading;
let m = Transform2D {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: -leading,
};
self.text_line_matrix = compose(self.text_line_matrix, m);
self.text_matrix = self.text_line_matrix;
let bytes = match self.operands.last() {
Some(Operand::String(s)) => s.clone(),
_ => Vec::new(),
};
self.emit_text_show(bytes, TextShowOp::SingleQuote);
self.operands.clear();
}
b"\"" => {
let leading = self.text_leading;
let m = Transform2D {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: -leading,
};
self.text_line_matrix = compose(self.text_line_matrix, m);
self.text_matrix = self.text_line_matrix;
let bytes = match self.operands.last() {
Some(Operand::String(s)) => s.clone(),
_ => Vec::new(),
};
self.emit_text_show(bytes, TextShowOp::DoubleQuote);
self.operands.clear();
}
b"Do" => {
self.operands.clear();
}
b"sh" => {
let name = match self.operands.last() {
Some(Operand::Name(n)) => n.clone(),
_ => String::new(),
};
let shading_dict = match (self.shading_resources, name.as_str()) {
(Some(res), n) if !n.is_empty() => lookup_dict(res, n).cloned(),
_ => None,
};
let ctm = self.effective_ctm();
let clip = self.current_clip();
self.shadings.push(ContentShading {
name,
shading_dict,
ctm,
clip,
});
self.operands.clear();
}
b"MP" => {
let tag = self.last_name_operand();
let depth = self.mc_depth;
self.marked_content.push(ContentMarkedContent {
operator: MarkedContentOp::Mp,
tag,
properties: None,
depth,
});
self.operands.clear();
}
b"DP" => {
let (tag, properties) = self.marked_content_tag_props();
let depth = self.mc_depth;
self.marked_content.push(ContentMarkedContent {
operator: MarkedContentOp::Dp,
tag,
properties,
depth,
});
self.operands.clear();
}
b"BMC" => {
let tag = self.last_name_operand();
let depth = self.mc_depth;
self.marked_content.push(ContentMarkedContent {
operator: MarkedContentOp::Bmc,
tag,
properties: None,
depth,
});
self.mc_depth = self.mc_depth.saturating_add(1);
self.operands.clear();
}
b"BDC" => {
let (tag, properties) = self.marked_content_tag_props();
let depth = self.mc_depth;
self.marked_content.push(ContentMarkedContent {
operator: MarkedContentOp::Bdc,
tag,
properties,
depth,
});
self.mc_depth = self.mc_depth.saturating_add(1);
self.operands.clear();
}
b"EMC" => {
self.mc_depth = self.mc_depth.saturating_sub(1);
let depth = self.mc_depth;
self.marked_content.push(ContentMarkedContent {
operator: MarkedContentOp::Emc,
tag: String::new(),
properties: None,
depth,
});
self.operands.clear();
}
_ => {
self.operands.clear();
}
}
Ok(())
}
fn last_name_operand(&self) -> String {
self.operands
.iter()
.rev()
.find_map(|o| match o {
Operand::Name(n) => Some(n.clone()),
_ => None,
})
.unwrap_or_default()
}
fn marked_content_tag_props(&self) -> (String, Option<Dict>) {
let tag = self
.operands
.iter()
.find_map(|o| match o {
Operand::Name(n) => Some(n.clone()),
_ => None,
})
.unwrap_or_default();
let properties = match self.operands.last() {
Some(Operand::Dict(d)) => Some(d.clone()),
Some(Operand::Name(n)) if self.operands.len() >= 2 => self
.properties_resources
.and_then(|res| lookup_dict(res, n).cloned()),
_ => None,
};
(tag, properties)
}
fn effective_ctm(&self) -> Transform2D {
let mut acc = Transform2D::identity();
for frame in &self.stack {
acc = compose(acc, frame.transform);
}
acc
}
fn current_clip(&self) -> Option<Path> {
self.stack.last().and_then(|f| f.clip.clone())
}
fn commit_path(&mut self, fill: bool, stroke: bool, rule: FillRule) {
let Some(path) = self.current_path.take() else {
self.operands.clear();
return;
};
let fill_paint = if fill {
let base = self
.fill_paint
.clone()
.unwrap_or(Paint::Solid(Rgba::opaque(0, 0, 0)));
Some(apply_alpha(base, self.fill_alpha))
} else {
None
};
let stroke_obj = if stroke {
let stroke_paint = self
.stroke_paint
.clone()
.unwrap_or(Paint::Solid(Rgba::opaque(0, 0, 0)));
Some(Stroke {
width: self.stroke_width,
paint: apply_alpha(stroke_paint, self.stroke_alpha),
cap: self.line_cap,
join: self.line_join,
miter_limit: self.miter_limit,
dash: self.dash.clone(),
})
} else {
None
};
let node = Node::Path(PathNode {
path,
fill: fill_paint,
stroke: stroke_obj,
fill_rule: rule,
});
self.current().children.push(node);
self.operands.clear();
}
fn emit_text_show(&mut self, bytes: Vec<u8>, operator: TextShowOp) {
if !self.in_text_object || self.font_resources.is_none() {
return;
}
let (font_name, font_size) = match &self.current_font {
Some((n, s)) => (n.clone(), *s),
None => (String::new(), 0.0),
};
let font_dict = match self.font_resources {
Some(fr) if !font_name.is_empty() => lookup_dict(fr, &font_name).cloned(),
_ => None,
};
self.text_shows.push(ContentTextShow {
font_name,
font_size,
font_dict,
bytes,
position: (self.text_matrix.e, self.text_matrix.f),
operator,
});
}
fn apply_ext_gstate(&mut self, dict: &Dict) {
for (k, v) in dict.entries() {
match k.as_str() {
"LW" => {
if let Some(n) = number_as_f32(v) {
self.stroke_width = n;
}
}
"LC" => {
if let Some(i) = number_as_i64(v) {
self.line_cap = match i {
0 => LineCap::Butt,
1 => LineCap::Round,
2 => LineCap::Square,
_ => self.line_cap,
};
}
}
"LJ" => {
if let Some(i) = number_as_i64(v) {
self.line_join = match i {
0 => LineJoin::Miter,
1 => LineJoin::Round,
2 => LineJoin::Bevel,
_ => self.line_join,
};
}
}
"ML" => {
if let Some(n) = number_as_f32(v) {
self.miter_limit = n;
}
}
"D" => {
if let Some((array, offset)) = parse_dash_pair(v) {
self.dash = if array.is_empty() {
None
} else {
Some(DashPattern { array, offset })
};
}
}
"CA" => {
if let Some(n) = number_as_f32(v) {
self.stroke_alpha = n.clamp(0.0, 1.0);
}
}
"ca" => {
if let Some(n) = number_as_f32(v) {
self.fill_alpha = n.clamp(0.0, 1.0);
}
}
_ => {}
}
}
}
fn path_mut(&mut self) -> &mut Path {
if self.current_path.is_none() {
self.current_path = Some(Path::new());
}
self.current_path.as_mut().unwrap()
}
fn take_numbers(&mut self, n: usize) -> Result<Vec<f32>, PdfError> {
if self.operands.len() < n {
return Err(PdfError::other(format!(
"PDF content parser: operator needed {n} numeric operands, got {}",
self.operands.len()
)));
}
let split = self.operands.len() - n;
let tail: Vec<Operand> = self.operands.drain(split..).collect();
let mut out = Vec::with_capacity(n);
for op in tail {
match op {
Operand::Number(f) => out.push(f),
other => {
return Err(PdfError::other(format!(
"PDF content parser: expected numeric operand, got {other:?}"
)));
}
}
}
Ok(out)
}
fn take_point(&mut self) -> Result<Point, PdfError> {
let nums = self.take_numbers(2)?;
Ok(Point::new(nums[0], nums[1]))
}
fn color_from_components(&self, cs: &ColorSpaceKind) -> Option<Paint> {
let want = cs.components()?;
if matches!(self.operands.last(), Some(Operand::Name(_))) {
return None;
}
let nums: Vec<f32> = self
.operands
.iter()
.rev()
.take_while(|o| matches!(o, Operand::Number(_)))
.filter_map(|o| match o {
Operand::Number(n) => Some(*n),
_ => None,
})
.collect();
if nums.len() < want {
return None;
}
let comps: Vec<f32> = nums.iter().take(want).rev().copied().collect();
Some(match cs {
ColorSpaceKind::DeviceGray => Paint::Solid(rgb_from_unit(comps[0], comps[0], comps[0])),
ColorSpaceKind::DeviceRgb => Paint::Solid(rgb_from_unit(comps[0], comps[1], comps[2])),
ColorSpaceKind::DeviceCmyk => {
Paint::Solid(rgb_from_cmyk(comps[0], comps[1], comps[2], comps[3]))
}
ColorSpaceKind::Indexed { base, hival, table } => {
return indexed_color(base, *hival, table, comps[0])
}
ColorSpaceKind::Separation {
alt,
tint,
none_colorant,
} => return separation_color(alt, tint, *none_colorant, comps[0]),
ColorSpaceKind::Unknown => unreachable!("components() returned Some"),
})
}
fn take_color_space_name(&mut self) -> ColorSpaceKind {
match self.operands.last() {
Some(Operand::Name(n)) => {
ColorSpaceKind::resolve_with_resources(n, self.color_space_resources)
}
_ => ColorSpaceKind::Unknown,
}
}
fn parse(&mut self, input: &[u8]) -> Result<(), PdfError> {
let mut i = 0;
while i < input.len() {
let b = input[i];
if is_whitespace(b) {
i += 1;
continue;
}
if b == b'%' {
while i < input.len() && input[i] != b'\n' && input[i] != b'\r' {
i += 1;
}
continue;
}
if b == b'(' {
let (end, bytes) = read_literal_string(input, i)?;
self.operands.push(Operand::String(bytes));
i = end;
continue;
}
if b == b'<' && input.get(i + 1) == Some(&b'<') {
let mut p = crate::reader::parse::Parser::new(&input[i..]);
match p.parse_object() {
Ok(Some(Object::Dict(d))) => {
self.operands.push(Operand::Dict(d));
i += p.position();
continue;
}
_ => {
i += 2;
continue;
}
}
}
if b == b'<' && input.get(i + 1) != Some(&b'<') {
let (end, bytes) = read_hex_string(input, i)?;
self.operands.push(Operand::String(bytes));
i = end;
continue;
}
if b == b'[' {
let (end, items) = read_array(input, i)?;
self.operands.push(Operand::Array(items));
i = end;
continue;
}
if b == b'/' {
let mut end = i + 1;
while end < input.len() && !is_whitespace(input[end]) && !is_delimiter(input[end]) {
end += 1;
}
let name = String::from_utf8_lossy(&input[i + 1..end]).into_owned();
self.operands.push(Operand::Name(name));
i = end;
continue;
}
if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
match scan_number(input, i) {
NumScan::Fast(end, f) => {
self.operands.push(Operand::Number(f));
i = end;
continue;
}
NumScan::Slow(end) => {
let s = str::from_utf8(&input[i..end]).map_err(|_| {
PdfError::other(format!(
"PDF content parser: non-UTF-8 number at byte {i}"
))
})?;
let f: f32 = s.parse().map_err(|_| {
PdfError::other(format!(
"PDF content parser: invalid number `{s}` at byte {i}"
))
})?;
self.operands.push(Operand::Number(f));
i = end;
continue;
}
NumScan::NotANumber => {
let kw_end = scan_keyword_end(input, i);
let kw = &input[i..kw_end];
self.dispatch(kw)?;
i = kw_end;
continue;
}
}
}
let kw_end = scan_keyword_end(input, i);
if kw_end == i {
i += 1;
continue;
}
let kw = &input[i..kw_end];
self.dispatch(kw)?;
i = kw_end;
}
Ok(())
}
}
impl Frame {
fn new() -> Self {
Self {
transform: Transform2D::identity(),
children: Vec::new(),
clip: None,
}
}
fn is_effectively_empty(&self) -> bool {
self.children.is_empty() && self.clip.is_none() && self.transform.is_identity()
}
}
fn is_whitespace(b: u8) -> bool {
matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn is_delimiter(b: u8) -> bool {
matches!(
b,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
fn rgb_from_unit(r: f32, g: f32, b: f32) -> Rgba {
Rgba::opaque(unit_to_byte(r), unit_to_byte(g), unit_to_byte(b))
}
fn initial_color_for(cs: &ColorSpaceKind) -> Option<Paint> {
match cs {
ColorSpaceKind::DeviceGray | ColorSpaceKind::DeviceRgb | ColorSpaceKind::DeviceCmyk => {
Some(Paint::Solid(Rgba::opaque(0, 0, 0)))
}
ColorSpaceKind::Indexed { base, hival, table } => indexed_color(base, *hival, table, 0.0),
ColorSpaceKind::Separation {
alt,
tint,
none_colorant,
} => separation_color(alt, tint, *none_colorant, 1.0),
ColorSpaceKind::Unknown => None,
}
}
fn indexed_color(base: &ColorSpaceKind, hival: u32, table: &[u8], index: f32) -> Option<Paint> {
let m = base.components()?;
let idx = if index.is_finite() {
let r = index.round();
r.clamp(0.0, hival as f32) as u32
} else {
0
};
let start = (idx as usize).checked_mul(m)?;
let entry = table.get(start..start + m)?;
let unit = |i: usize| entry[i] as f32 / 255.0;
Some(match base {
ColorSpaceKind::DeviceGray => Paint::Solid(rgb_from_unit(unit(0), unit(0), unit(0))),
ColorSpaceKind::DeviceRgb => Paint::Solid(rgb_from_unit(unit(0), unit(1), unit(2))),
ColorSpaceKind::DeviceCmyk => {
Paint::Solid(rgb_from_cmyk(unit(0), unit(1), unit(2), unit(3)))
}
ColorSpaceKind::Indexed { .. }
| ColorSpaceKind::Separation { .. }
| ColorSpaceKind::Unknown => Paint::Solid(Rgba::opaque(0, 0, 0)),
})
}
fn separation_color(
alt: &ColorSpaceKind,
tint: &PdfFunction,
none_colorant: bool,
tint_value: f32,
) -> Option<Paint> {
if none_colorant {
return None;
}
let t = tint_value.clamp(0.0, 1.0);
let comps = tint.eval(t);
paint_from_device_components(alt, &comps)
}
fn paint_from_device_components(cs: &ColorSpaceKind, comps: &[f32]) -> Option<Paint> {
match cs {
ColorSpaceKind::DeviceGray if comps.len() == 1 => {
Some(Paint::Solid(rgb_from_unit(comps[0], comps[0], comps[0])))
}
ColorSpaceKind::DeviceRgb if comps.len() == 3 => {
Some(Paint::Solid(rgb_from_unit(comps[0], comps[1], comps[2])))
}
ColorSpaceKind::DeviceCmyk if comps.len() == 4 => Some(Paint::Solid(rgb_from_cmyk(
comps[0], comps[1], comps[2], comps[3],
))),
_ => None,
}
}
fn rgb_from_cmyk(cyan: f32, magenta: f32, yellow: f32, black: f32) -> Rgba {
let c = cyan.clamp(0.0, 1.0);
let m = magenta.clamp(0.0, 1.0);
let y = yellow.clamp(0.0, 1.0);
let k = black.clamp(0.0, 1.0);
let red = 1.0 - (c + k).min(1.0);
let green = 1.0 - (m + k).min(1.0);
let blue = 1.0 - (y + k).min(1.0);
rgb_from_unit(red, green, blue)
}
fn unit_to_byte(f: f32) -> u8 {
(f.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn compose(a: Transform2D, b: Transform2D) -> Transform2D {
Transform2D {
a: a.a * b.a + a.c * b.b,
b: a.b * b.a + a.d * b.b,
c: a.a * b.c + a.c * b.d,
d: a.b * b.c + a.d * b.d,
e: a.a * b.e + a.c * b.f + a.e,
f: a.b * b.e + a.d * b.f + a.f,
}
}
fn scan_keyword_end(input: &[u8], start: usize) -> usize {
let mut end = start;
while end < input.len() && !is_whitespace(input[end]) && !is_delimiter(input[end]) {
end += 1;
}
end
}
const POW10_F32: [f32; 11] = [1.0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10];
enum NumScan {
NotANumber,
Fast(usize, f32),
Slow(usize),
}
fn scan_number(input: &[u8], start: usize) -> NumScan {
let mut end = start;
let neg = input[end] == b'-';
if matches!(input[end], b'+' | b'-') {
end += 1;
}
let mut mant: u64 = 0;
let mut frac: usize = 0;
let mut saw_digit = false;
let mut saw_dot = false;
let mut wide = false;
while end < input.len() {
let c = input[end];
if c.is_ascii_digit() {
saw_digit = true;
if mant < (1 << 56) {
mant = mant * 10 + (c - b'0') as u64;
} else {
wide = true;
}
if saw_dot {
frac += 1;
}
end += 1;
} else if c == b'.' && !saw_dot {
saw_dot = true;
end += 1;
} else {
break;
}
}
if !saw_digit {
return NumScan::NotANumber;
}
if !wide && mant < (1 << 24) && frac <= 10 {
let q = mant as f32 / POW10_F32[frac];
NumScan::Fast(end, if neg { -q } else { q })
} else {
NumScan::Slow(end)
}
}
fn read_literal_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
let mut end = start + 1;
let mut depth = 1u32;
let mut decoded = Vec::new();
while end < input.len() {
let b = input[end];
if b == b'\\' {
end += 1;
if end >= input.len() {
break;
}
let esc = input[end];
match esc {
b'n' => {
decoded.push(b'\n');
end += 1;
}
b'r' => {
decoded.push(b'\r');
end += 1;
}
b't' => {
decoded.push(b'\t');
end += 1;
}
b'b' => {
decoded.push(0x08);
end += 1;
}
b'f' => {
decoded.push(0x0C);
end += 1;
}
b'(' | b')' | b'\\' => {
decoded.push(esc);
end += 1;
}
b'\n' => {
end += 1;
}
b'\r' => {
end += 1;
if end < input.len() && input[end] == b'\n' {
end += 1;
}
}
d if d.is_ascii_digit() => {
let mut val: u16 = 0;
let mut n = 0;
while n < 3 && end < input.len() {
let c = input[end];
if !(b'0'..=b'7').contains(&c) {
break;
}
val = val * 8 + (c - b'0') as u16;
end += 1;
n += 1;
}
decoded.push((val & 0xFF) as u8);
}
other => {
decoded.push(other);
end += 1;
}
}
continue;
}
if b == b'(' {
depth += 1;
}
if b == b')' {
depth -= 1;
if depth == 0 {
end += 1;
return Ok((end, decoded));
}
}
decoded.push(b);
end += 1;
}
Err(PdfError::other(
"PDF content parser: unterminated literal string",
))
}
fn read_hex_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
let mut end = start + 1;
let mut nibbles: Vec<u8> = Vec::new();
while end < input.len() {
let c = input[end];
if c == b'>' {
if nibbles.len() % 2 == 1 {
nibbles.push(0);
}
let mut out = Vec::with_capacity(nibbles.len() / 2);
for pair in nibbles.chunks(2) {
out.push((pair[0] << 4) | pair[1]);
}
return Ok((end + 1, out));
}
if let Some(v) = hex_nibble(c) {
nibbles.push(v);
}
end += 1;
}
Err(PdfError::other(
"PDF content parser: unterminated hex string",
))
}
fn hex_nibble(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(10 + c - b'a'),
b'A'..=b'F' => Some(10 + c - b'A'),
_ => None,
}
}
fn lookup_dict<'a>(dict: &'a Dict, key: &str) -> Option<&'a Dict> {
dict.entries()
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
Object::Dict(d) => Some(d),
_ => None,
})
}
fn apply_alpha(paint: Paint, alpha: f32) -> Paint {
if (alpha - 1.0).abs() < f32::EPSILON {
return paint;
}
match paint {
Paint::Solid(rgba) => {
let base = rgba.a as f32 / 255.0;
let combined = (base * alpha).clamp(0.0, 1.0);
Paint::Solid(Rgba::new(
rgba.r,
rgba.g,
rgba.b,
(combined * 255.0).round() as u8,
))
}
other => other,
}
}
fn number_as_f32(obj: &Object) -> Option<f32> {
match obj {
Object::Integer(i) => Some(*i as f32),
Object::Real(r) => Some(*r as f32),
_ => None,
}
}
fn number_as_i64(obj: &Object) -> Option<i64> {
match obj {
Object::Integer(i) => Some(*i),
Object::Real(r) => Some(*r as i64),
_ => None,
}
}
fn parse_dash_pair(obj: &Object) -> Option<(Vec<f32>, f32)> {
let Object::Array(items) = obj else {
return None;
};
if items.len() != 2 {
return None;
}
let Object::Array(arr_items) = &items[0] else {
return None;
};
let mut array = Vec::with_capacity(arr_items.len());
for it in arr_items {
array.push(number_as_f32(it)?);
}
let offset = number_as_f32(&items[1])?;
Some((array, offset))
}
fn read_array(input: &[u8], start: usize) -> Result<(usize, Vec<ArrayElem>), PdfError> {
let mut end = start + 1;
let mut items: Vec<ArrayElem> = Vec::new();
while end < input.len() && input[end] != b']' {
let b = input[end];
if is_whitespace(b) {
end += 1;
continue;
}
if b == b'(' {
let (next, bytes) = read_literal_string(input, end)?;
items.push(ArrayElem::String(bytes));
end = next;
continue;
}
if b == b'<' && input.get(end + 1) != Some(&b'<') {
let (next, bytes) = read_hex_string(input, end)?;
items.push(ArrayElem::String(bytes));
end = next;
continue;
}
if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
let nstart = end;
match scan_number(input, nstart) {
NumScan::Fast(next, f) => {
items.push(ArrayElem::Number(f));
end = next;
}
NumScan::Slow(next) => {
if let Ok(s) = str::from_utf8(&input[nstart..next]) {
if let Ok(f) = s.parse::<f32>() {
items.push(ArrayElem::Number(f));
}
}
end = next;
}
NumScan::NotANumber => {
end = nstart + 1;
}
}
continue;
}
end += 1;
}
if end < input.len() {
end += 1;
} Ok((end, items))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &[u8]) -> Group {
parse_content_stream(input).unwrap()
}
#[test]
fn scan_number_matches_str_parse_bitwise() {
let mut cases: Vec<String> = vec![
"0",
"-0",
"+0",
"5",
"-5",
"+5",
"5.",
"-5.",
".5",
"-.5",
"+.5",
"0.5",
"595.0",
"842.75",
"0.0001",
"-0.0001",
"123456",
"-123456",
"16777215",
"16777216",
"16777217",
"-16777216",
"999999999",
"0.1234567890",
"0.12345678901",
"3.14159265358979",
"1000000.25",
"-1000000.25",
"0.000000001",
"99999999999999999999",
"-99999999999999999999.5",
"00042",
"-00042.50",
]
.into_iter()
.map(str::to_owned)
.collect();
let mut state = 0x1234_5678u32;
let mut xs = || {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
state
};
for _ in 0..2000 {
let int_len = (xs() % 13) as usize;
let frac_len = (xs() % 13) as usize;
if int_len == 0 && frac_len == 0 {
continue;
}
let mut s = String::new();
match xs() % 3 {
0 => s.push('-'),
1 => s.push('+'),
_ => {}
}
for _ in 0..int_len {
s.push(char::from(b'0' + (xs() % 10) as u8));
}
if frac_len > 0 {
s.push('.');
for _ in 0..frac_len {
s.push(char::from(b'0' + (xs() % 10) as u8));
}
}
cases.push(s);
}
for case in &cases {
let bytes = case.as_bytes();
let expected: f32 = case.parse().unwrap_or_else(|_| panic!("parse {case}"));
match scan_number(bytes, 0) {
NumScan::Fast(end, got) => {
assert_eq!(end, bytes.len(), "consumed all of `{case}`");
assert_eq!(
got.to_bits(),
expected.to_bits(),
"`{case}`: fast {got} vs parse {expected}"
);
}
NumScan::Slow(end) => {
assert_eq!(end, bytes.len(), "consumed all of `{case}`");
}
NumScan::NotANumber => panic!("`{case}` should scan as a number"),
}
}
}
#[test]
fn scan_number_rejects_bare_sign_and_dot() {
for case in [&b"-"[..], b"+", b".", b"-.", b"+.", b"-x", b".)"] {
assert!(
matches!(scan_number(case, 0), NumScan::NotANumber),
"{case:?} must not scan as a number"
);
}
}
#[test]
fn scan_number_stops_at_delimiters_and_whitespace() {
let input = b"-12.5]";
match scan_number(input, 0) {
NumScan::Fast(end, v) => {
assert_eq!(end, 5);
assert_eq!(v.to_bits(), (-12.5f32).to_bits());
}
_ => panic!("expected fast scan"),
}
let input = b"7 0 R";
match scan_number(input, 0) {
NumScan::Fast(end, v) => {
assert_eq!(end, 1);
assert_eq!(v, 7.0);
}
_ => panic!("expected fast scan"),
}
let input = b"1.2.3";
match scan_number(input, 0) {
NumScan::Fast(end, v) => {
assert_eq!(end, 3);
assert_eq!(v.to_bits(), (1.2f32).to_bits());
}
_ => panic!("expected fast scan"),
}
}
#[test]
fn empty_content_yields_empty_group() {
let g = parse(b"");
assert!(g.children.is_empty());
assert!(g.clip.is_none());
}
#[test]
fn rect_fill_round_trips() {
let bytes = b"q 1 0 0 rg 10 10 m 110 10 l 110 60 l 10 60 l h f Q\n";
let root = parse(bytes);
assert_eq!(root.children.len(), 1);
let Node::Group(g) = &root.children[0] else {
panic!("expected group")
};
assert_eq!(g.children.len(), 1);
let Node::Path(pn) = &g.children[0] else {
panic!("expected path")
};
assert_eq!(pn.path.commands.len(), 5);
assert!(matches!(pn.path.commands[0], PathCommand::MoveTo(p) if (p.x - 10.0).abs() < 1e-3));
assert!(matches!(pn.path.commands[4], PathCommand::Close));
assert_eq!(pn.fill_rule, FillRule::NonZero);
match &pn.fill {
Some(Paint::Solid(r)) => assert_eq!((r.r, r.g, r.b), (255, 0, 0)),
other => panic!("unexpected fill: {other:?}"),
}
assert!(pn.stroke.is_none());
}
#[test]
fn nested_q_groups_are_promoted_to_node_groups() {
let bytes = b"q q 1 0 0 1 5 5 cm 0 0 m 10 10 l S Q Q\n";
let root = parse(bytes);
assert_eq!(root.children.len(), 1);
let Node::Group(outer) = &root.children[0] else {
panic!()
};
assert_eq!(outer.children.len(), 1);
let Node::Group(inner) = &outer.children[0] else {
panic!()
};
assert!(!inner.transform.is_identity());
assert_eq!(inner.children.len(), 1);
}
#[test]
fn rectangle_operator_re_expands_to_subpath() {
let bytes = b"q 0.5 0.5 0.5 rg 10 20 30 40 re f Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
assert_eq!(p.path.commands.len(), 5);
assert!(
matches!(p.path.commands[0], PathCommand::MoveTo(pp) if pp.x == 10.0 && pp.y == 20.0)
);
assert!(
matches!(p.path.commands[1], PathCommand::LineTo(pp) if pp.x == 40.0 && pp.y == 20.0)
);
assert!(
matches!(p.path.commands[2], PathCommand::LineTo(pp) if pp.x == 40.0 && pp.y == 60.0)
);
assert!(
matches!(p.path.commands[3], PathCommand::LineTo(pp) if pp.x == 10.0 && pp.y == 60.0)
);
assert!(matches!(p.path.commands[4], PathCommand::Close));
}
#[test]
fn cubic_curve_roundtrips() {
let bytes = b"q 0 0 m 1 1 2 1 3 0 c S Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
assert!(matches!(
p.path.commands[1],
PathCommand::CubicCurveTo { c1, c2, end }
if c1.x == 1.0 && c1.y == 1.0 && c2.x == 2.0 && c2.y == 1.0 && end.x == 3.0 && end.y == 0.0
));
}
#[test]
fn fill_rule_evenodd_recognised() {
let bytes = b"q 0 0 m 10 0 l 10 10 l h f* Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
assert_eq!(p.fill_rule, FillRule::EvenOdd);
}
#[test]
fn cm_translate_lands_on_group_transform() {
let bytes = b"q 1 0 0 1 100 200 cm 0 0 m 5 5 l S Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
assert!((g.transform.e - 100.0).abs() < 1e-3);
assert!((g.transform.f - 200.0).abs() < 1e-3);
}
#[test]
fn stroke_style_w_j_m_d_recorded() {
let bytes = b"q 2.5 w 1 J 2 j 8 M [5 3] 1 d 0 0 0 RG 0 0 m 10 10 l S Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
assert!((s.width - 2.5).abs() < 1e-3);
assert!(matches!(s.cap, LineCap::Round));
assert!(matches!(s.join, LineJoin::Bevel));
assert!((s.miter_limit - 8.0).abs() < 1e-3);
let dash = s.dash.as_ref().expect("dash set");
assert_eq!(dash.array, vec![5.0, 3.0]);
assert!((dash.offset - 1.0).abs() < 1e-3);
}
#[test]
fn clip_w_assigns_to_group_clip() {
let bytes =
b"q 10 10 m 50 10 l 50 50 l 10 50 l h W n 0 0 0 rg 20 20 m 30 20 l 30 30 l h f Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!()
};
assert!(g.clip.is_some());
assert_eq!(g.children.len(), 1);
}
#[test]
fn cmyk_pure_inks_convert_per_10_3_5() {
assert_eq!(rgb_from_cmyk(1.0, 0.0, 0.0, 0.0), Rgba::opaque(0, 255, 255));
assert_eq!(rgb_from_cmyk(0.0, 1.0, 0.0, 0.0), Rgba::opaque(255, 0, 255));
assert_eq!(rgb_from_cmyk(0.0, 0.0, 1.0, 0.0), Rgba::opaque(255, 255, 0));
assert_eq!(rgb_from_cmyk(0.0, 0.0, 0.0, 1.0), Rgba::opaque(0, 0, 0));
assert_eq!(
rgb_from_cmyk(0.0, 0.0, 0.0, 0.0),
Rgba::opaque(255, 255, 255)
);
}
#[test]
fn cmyk_component_plus_black_clamps_at_one() {
let r = rgb_from_cmyk(0.7, 0.0, 0.0, 0.7);
assert_eq!(r.r, 0);
assert_eq!(r.g, (0.3f32 * 255.0).round() as u8);
assert_eq!(r.b, (0.3f32 * 255.0).round() as u8);
}
#[test]
fn cmyk_out_of_range_operands_clamp() {
assert_eq!(
rgb_from_cmyk(-0.5, 2.0, 0.0, 0.0),
rgb_from_cmyk(0.0, 1.0, 0.0, 0.0)
);
}
#[test]
fn k_and_upper_k_operators_apply_cmyk_conversion() {
let bytes = b"q 1 0 0 0 k 0 1 0 0 K 0 0 m 10 10 l 10 0 l h B Q\n";
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!("expected group")
};
let Node::Path(p) = &g.children[0] else {
panic!("expected path")
};
match &p.fill {
Some(Paint::Solid(c)) => assert_eq!((c.r, c.g, c.b), (0, 255, 255)),
other => panic!("unexpected fill: {other:?}"),
}
let s = p.stroke.as_ref().expect("stroke set");
match &s.paint {
Paint::Solid(c) => assert_eq!((c.r, c.g, c.b), (255, 0, 255)),
other => panic!("unexpected stroke paint: {other:?}"),
}
}
fn first_path(bytes: &[u8]) -> PathNode {
let root = parse(bytes);
let Node::Group(g) = &root.children[0] else {
panic!("expected group");
};
let Node::Path(p) = &g.children[0] else {
panic!("expected path");
};
p.clone()
}
fn fill_rgb(p: &PathNode) -> (u8, u8, u8) {
match &p.fill {
Some(Paint::Solid(c)) => (c.r, c.g, c.b),
other => panic!("unexpected fill: {other:?}"),
}
}
#[test]
fn cs_devicergb_then_sc_sets_rgb_fill() {
let bytes = b"q /DeviceRGB cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (255, 0, 0));
}
#[test]
fn cs_devicegray_then_sc_sets_gray_fill() {
let bytes = b"q /DeviceGray cs 0.5 sc 0 0 m 10 10 l 10 0 l h f Q\n";
let (r, g, b) = fill_rgb(&first_path(bytes));
let expect = (0.5f32 * 255.0).round() as u8;
assert_eq!((r, g, b), (expect, expect, expect));
}
#[test]
fn cs_devicecmyk_then_scn_sets_cmyk_fill() {
let bytes = b"q /DeviceCMYK cs 1 0 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (0, 255, 255));
}
#[test]
fn upper_cs_and_upper_sc_set_stroke_color() {
let bytes = b"q /DeviceRGB CS 0 1 0 SC 0 0 m 10 10 l S Q\n";
let p = first_path(bytes);
let s = p.stroke.as_ref().expect("stroke set");
match &s.paint {
Paint::Solid(c) => assert_eq!((c.r, c.g, c.b), (0, 255, 0)),
other => panic!("unexpected stroke paint: {other:?}"),
}
}
#[test]
fn pattern_scn_keeps_black_fallback() {
let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
}
#[test]
fn unknown_resource_colorspace_sc_keeps_black_fallback() {
let bytes = b"q /CS0 cs 0.2 0.4 0.6 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
}
#[test]
fn bare_cs_initialises_color_to_black() {
let bytes = b"q /DeviceRGB cs 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
}
#[test]
fn switching_colorspace_reroutes_following_sc() {
let bytes = b"q /DeviceGray cs 1 sc /DeviceRGB cs 0 0 1 sc \
0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 255));
}
#[test]
fn color_space_from_name_table() {
assert_eq!(
ColorSpaceKind::from_name("DeviceGray"),
ColorSpaceKind::DeviceGray
);
assert_eq!(ColorSpaceKind::from_name("G"), ColorSpaceKind::DeviceGray);
assert_eq!(
ColorSpaceKind::from_name("DeviceRGB"),
ColorSpaceKind::DeviceRgb
);
assert_eq!(ColorSpaceKind::from_name("RGB"), ColorSpaceKind::DeviceRgb);
assert_eq!(
ColorSpaceKind::from_name("DeviceCMYK"),
ColorSpaceKind::DeviceCmyk
);
assert_eq!(
ColorSpaceKind::from_name("CMYK"),
ColorSpaceKind::DeviceCmyk
);
assert_eq!(
ColorSpaceKind::from_name("Pattern"),
ColorSpaceKind::Unknown
);
assert_eq!(ColorSpaceKind::from_name("CS0"), ColorSpaceKind::Unknown);
}
fn first_fill_with_cs(bytes: &[u8], cs: &Dict) -> (u8, u8, u8) {
let parsed =
parse_content_stream_full_with_color_space(bytes, None, None, None, Some(cs)).unwrap();
let Node::Group(g) = &parsed.root.children[0] else {
panic!("expected group");
};
let Node::Path(p) = &g.children[0] else {
panic!("expected path");
};
match &p.fill {
Some(Paint::Solid(c)) => (c.r, c.g, c.b),
other => panic!("unexpected fill: {other:?}"),
}
}
#[test]
fn icc_based_n3_resolves_devicergb() {
let arr = Object::Array(vec![
Object::Name("ICCBased".into()),
Object::Dict(Dict::new().with("N", Object::Integer(3))),
]);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::DeviceRgb);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
}
#[test]
fn icc_based_n1_and_n4_resolve_gray_and_cmyk() {
let gray = Object::Array(vec![
Object::Name("ICCBased".into()),
Object::Dict(Dict::new().with("N", Object::Integer(1))),
]);
assert_eq!(color_space_from_object(&gray), ColorSpaceKind::DeviceGray);
let cmyk = Object::Array(vec![
Object::Name("ICCBased".into()),
Object::Dict(Dict::new().with("N", Object::Integer(4))),
]);
assert_eq!(color_space_from_object(&cmyk), ColorSpaceKind::DeviceCmyk);
let cs = Dict::new().with("CS0", cmyk);
let bytes = b"q /CS0 cs 1 0 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0, 255, 255));
}
#[test]
fn icc_based_alternate_wins_over_n() {
let arr = Object::Array(vec![
Object::Name("ICCBased".into()),
Object::Dict(
Dict::new()
.with("N", Object::Integer(3))
.with("Alternate", Object::Name("DeviceCMYK".into())),
),
]);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::DeviceCmyk);
}
#[test]
fn icc_based_without_n_is_unknown() {
let arr = Object::Array(vec![
Object::Name("ICCBased".into()),
Object::Dict(Dict::new()),
]);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
}
#[test]
fn indexed_devicergb_index_selects_table_entry() {
let table = vec![
0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, ];
let arr = Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("DeviceRGB".into()),
Object::Integer(2),
Object::HexString(table),
]);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 1 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
}
#[test]
fn indexed_bare_cs_uses_entry_zero() {
let table = vec![0x10, 0x20, 0x30, 0xFF, 0xFF, 0xFF];
let arr = Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("DeviceRGB".into()),
Object::Integer(1),
Object::HexString(table),
]);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0x10, 0x20, 0x30));
}
#[test]
fn indexed_index_rounds_and_clamps() {
let table = vec![
0x00, 0x00, 0x00, 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, ];
let mk = |arr| Dict::new().with("CS0", arr);
let arr = || {
Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("DeviceRGB".into()),
Object::Integer(2),
Object::HexString(table.clone()),
])
};
let bytes = b"q /CS0 cs 1.6 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x80, 0x80, 0x80));
let bytes = b"q /CS0 cs 9 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x80, 0x80, 0x80));
let bytes = b"q /CS0 cs -3 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x00, 0x00, 0x00));
}
#[test]
fn indexed_nondevice_base_is_unknown() {
let arr = Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("Lab".into()),
Object::Integer(1),
Object::HexString(vec![0, 0, 0, 1, 1, 1]),
]);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
}
#[test]
fn indexed_truncated_table_returns_none_for_missing_slot() {
let table = vec![0x00, 0x00, 0x00];
let base = ColorSpaceKind::DeviceRgb;
assert!(indexed_color(&base, 2, &table, 0.0).is_some());
assert!(indexed_color(&base, 2, &table, 2.0).is_none());
let arr = Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("DeviceRGB".into()),
Object::Integer(2),
Object::HexString(table),
]);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 2 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
}
#[test]
fn device_name_resolves_without_consulting_resources() {
let cs = Dict::new().with("DeviceRGB", Object::Name("DeviceGray".into()));
let bytes = b"q /DeviceRGB cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
}
#[test]
fn resource_key_without_resources_stays_unknown() {
assert_eq!(
ColorSpaceKind::resolve_with_resources("CS0", None),
ColorSpaceKind::Unknown
);
}
fn num_arr(vals: &[f32]) -> Object {
Object::Array(vals.iter().map(|v| Object::Real(*v as f64)).collect())
}
fn type2(c0: &[f32], c1: &[f32], n: f32) -> Object {
Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(2))
.with("Domain", num_arr(&[0.0, 1.0]))
.with("C0", num_arr(c0))
.with("C1", num_arr(c1))
.with("N", Object::Real(n as f64)),
)
}
#[test]
fn type2_exponential_interpolates() {
let f = PdfFunction::parse(&type2(&[0.0, 0.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 0.0], 1.0))
.expect("type 2 parses");
assert_eq!(f.eval(0.0), vec![0.0, 0.0, 0.0, 0.0]);
assert_eq!(f.eval(1.0), vec![1.0, 0.0, 0.0, 0.0]);
assert_eq!(f.eval(0.5), vec![0.5, 0.0, 0.0, 0.0]);
}
#[test]
fn type2_exponent_two_is_quadratic() {
let f = PdfFunction::parse(&type2(&[0.0], &[1.0], 2.0)).expect("parses");
assert!((f.eval(0.5)[0] - 0.25).abs() < 1e-6);
}
#[test]
fn type2_range_clips_output() {
let dict = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(2))
.with("Domain", num_arr(&[0.0, 1.0]))
.with("Range", num_arr(&[0.0, 1.0]))
.with("C0", num_arr(&[0.0]))
.with("C1", num_arr(&[2.0]))
.with("N", Object::Integer(1)),
);
let f = PdfFunction::parse(&dict).expect("parses");
assert_eq!(f.eval(1.0), vec![1.0]);
}
#[test]
fn type3_stitching_routes_to_subdomain() {
let dict = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(3))
.with("Domain", num_arr(&[0.0, 1.0]))
.with(
"Functions",
Object::Array(vec![type2(&[0.0], &[1.0], 1.0), type2(&[1.0], &[0.0], 1.0)]),
)
.with("Bounds", num_arr(&[0.5]))
.with("Encode", num_arr(&[0.0, 1.0, 0.0, 1.0])),
);
let f = PdfFunction::parse(&dict).expect("type 3 parses");
assert!((f.eval(0.25)[0] - 0.5).abs() < 1e-6);
assert!((f.eval(0.75)[0] - 0.5).abs() < 1e-6);
assert!(f.eval(1.0)[0].abs() < 1e-6);
}
#[test]
fn type0_and_type4_are_not_evaluable() {
let t0 = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(0))
.with("Domain", num_arr(&[0.0, 1.0])),
);
assert!(PdfFunction::parse(&t0).is_none());
let t4 = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(4))
.with("Domain", num_arr(&[0.0, 1.0])),
);
assert!(PdfFunction::parse(&t4).is_none());
}
fn separation(name: &str, alt: Object, tint: Object) -> Object {
Object::Array(vec![
Object::Name("Separation".into()),
Object::Name(name.into()),
alt,
tint,
])
}
#[test]
fn separation_cmyk_tint_maps_through_alternate() {
let tint = type2(&[0.0, 0.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 0.0], 1.0);
let arr = separation("LogoGreen", Object::Name("DeviceCMYK".into()), tint);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0, 255, 255));
let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
}
#[test]
fn separation_bare_cs_uses_full_tint() {
let tint = type2(&[1.0], &[0.0], 1.0); let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
}
#[test]
fn separation_with_type3_tint() {
let tint = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(3))
.with("Domain", num_arr(&[0.0, 1.0]))
.with(
"Functions",
Object::Array(vec![type2(&[0.0], &[0.5], 1.0), type2(&[0.5], &[1.0], 1.0)]),
)
.with("Bounds", num_arr(&[0.5]))
.with("Encode", num_arr(&[0.0, 1.0, 0.0, 1.0])),
);
let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 0.75 scn 0 0 m 10 10 l 10 0 l h f Q\n";
let (r, g, b) = first_fill_with_cs(bytes, &cs);
let expect = (0.75f32 * 255.0).round() as u8;
assert_eq!((r, g, b), (expect, expect, expect));
}
#[test]
fn separation_none_colorant_produces_no_paint() {
let arr = separation(
"None",
Object::Name("DeviceGray".into()),
type2(&[0.0], &[1.0], 1.0),
);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 0.5 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
}
#[test]
fn separation_nondevice_alternate_is_unknown() {
let arr = separation(
"Spot",
Object::Name("Lab".into()),
type2(&[0.0], &[1.0], 1.0),
);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
}
#[test]
fn separation_unevaluable_tint_is_unknown() {
let t4 = Object::Dict(
Dict::new()
.with("FunctionType", Object::Integer(4))
.with("Domain", num_arr(&[0.0, 1.0])),
);
let arr = separation("Spot", Object::Name("DeviceGray".into()), t4);
assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
}
#[test]
fn separation_tint_clamped_to_unit_range() {
let tint = type2(&[0.0], &[1.0], 1.0); let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
let cs = Dict::new().with("CS0", arr);
let bytes = b"q /CS0 cs 5 scn 0 0 m 10 10 l 10 0 l h f Q\n";
assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
}
fn ext_gstate_with(name: &str, dict: Dict) -> Dict {
Dict::new().with(name, Object::Dict(dict))
}
fn parse_with(input: &[u8], ext: &Dict) -> Group {
parse_content_stream_with_resources(input, Some(ext)).unwrap()
}
#[test]
fn gs_applies_line_width_lw() {
let ext = ext_gstate_with(
"GS1",
Dict::new()
.with("Type", Object::Name("ExtGState".into()))
.with("LW", Object::Real(3.5)),
);
let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
assert!((s.width - 3.5).abs() < 1e-3);
}
#[test]
fn gs_applies_lc_lj_ml() {
let ext = ext_gstate_with(
"GS1",
Dict::new()
.with("LC", Object::Integer(1)) .with("LJ", Object::Integer(2)) .with("ML", Object::Real(7.5)),
);
let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
assert!(matches!(s.cap, LineCap::Round));
assert!(matches!(s.join, LineJoin::Bevel));
assert!((s.miter_limit - 7.5).abs() < 1e-3);
}
#[test]
fn gs_applies_d_dash_pattern() {
let ext = ext_gstate_with(
"GS1",
Dict::new().with(
"D",
Object::Array(vec![
Object::Array(vec![Object::Real(4.0), Object::Real(2.0)]),
Object::Real(1.0),
]),
),
);
let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
let dash = s.dash.as_ref().expect("dash set");
assert_eq!(dash.array, vec![4.0, 2.0]);
assert!((dash.offset - 1.0).abs() < 1e-3);
}
#[test]
fn gs_applies_ca_to_fill_alpha() {
let ext = ext_gstate_with("GS1", Dict::new().with("ca", Object::Real(0.5)));
let bytes = b"q 1 0 0 rg /GS1 gs 0 0 m 10 10 l 10 0 l h f Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let Some(Paint::Solid(c)) = &p.fill else {
panic!("fill")
};
assert_eq!((c.r, c.g, c.b), (255, 0, 0));
assert_eq!(c.a, 128);
}
#[test]
fn gs_applies_cap_ca_to_stroke_alpha() {
let ext = ext_gstate_with("GS1", Dict::new().with("CA", Object::Real(0.25)));
let bytes = b"q 0 1 0 RG /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
let Paint::Solid(c) = &s.paint else { panic!() };
assert_eq!((c.r, c.g, c.b), (0, 255, 0));
assert_eq!(c.a, 64);
}
#[test]
fn gs_unknown_name_is_no_op() {
let ext = ext_gstate_with("GS1", Dict::new().with("LW", Object::Real(9.0)));
let bytes = b"q 2.5 w /GS_OTHER gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke");
assert!((s.width - 2.5).abs() < 1e-3);
}
#[test]
fn multiple_gs_invocations_cumulate() {
let mut ext = Dict::new();
ext.set(
"GW",
Object::Dict(Dict::new().with("LW", Object::Real(4.0))),
);
ext.set(
"GA",
Object::Dict(Dict::new().with("CA", Object::Real(0.5))),
);
let bytes = b"q /GW gs /GA gs 1 0 0 RG 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke");
assert!((s.width - 4.0).abs() < 1e-3);
let Paint::Solid(c) = &s.paint else { panic!() };
assert_eq!(c.a, 128);
}
#[test]
fn legacy_parse_content_stream_drops_gs_operands() {
let bytes = b"q 2.5 w /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_content_stream(bytes).unwrap();
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke");
assert!((s.width - 2.5).abs() < 1e-3);
}
#[test]
fn gs_unknown_table_58_keys_are_tolerated() {
let ext = ext_gstate_with(
"GS1",
Dict::new()
.with("BM", Object::Name("Multiply".into()))
.with("OP", Object::Bool(true))
.with("RI", Object::Name("Perceptual".into()))
.with("LW", Object::Real(2.0)),
);
let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
let root = parse_with(bytes, &ext);
let Node::Group(g) = &root.children[0] else {
panic!()
};
let Node::Path(p) = &g.children[0] else {
panic!()
};
let s = p.stroke.as_ref().expect("stroke set");
assert!((s.width - 2.0).abs() < 1e-3);
}
#[test]
fn apply_alpha_composes_with_existing_alpha() {
let base = Paint::Solid(Rgba::new(100, 200, 50, 200));
let out = apply_alpha(base, 0.5);
let Paint::Solid(c) = out else { panic!() };
assert_eq!((c.r, c.g, c.b), (100, 200, 50));
assert_eq!(c.a, 100);
}
#[test]
fn apply_alpha_unit_is_identity() {
let base = Paint::Solid(Rgba::new(10, 20, 30, 200));
let out = apply_alpha(base, 1.0);
let Paint::Solid(c) = out else { panic!() };
assert_eq!(c.a, 200);
}
#[test]
fn parse_dash_pair_two_element_array() {
let obj = Object::Array(vec![
Object::Array(vec![Object::Real(2.0), Object::Real(1.0)]),
Object::Integer(3),
]);
let (arr, off) = parse_dash_pair(&obj).expect("parses");
assert_eq!(arr, vec![2.0, 1.0]);
assert!((off - 3.0).abs() < 1e-3);
}
#[test]
fn parse_dash_pair_rejects_malformed() {
assert!(parse_dash_pair(&Object::Integer(0)).is_none());
assert!(parse_dash_pair(&Object::Array(vec![Object::Integer(0)])).is_none());
assert!(
parse_dash_pair(&Object::Array(vec![Object::Integer(0), Object::Integer(0)])).is_none()
);
}
fn font_res_with(name: &str, dict: Dict) -> Dict {
Dict::new().with(name, Object::Dict(dict))
}
fn parse_full(input: &[u8], ext: Option<&Dict>, fonts: Option<&Dict>) -> ParsedContent {
parse_content_stream_full(input, ext, fonts).unwrap()
}
#[test]
fn tj_emits_one_text_show_with_font_and_size() {
let f1 = Dict::new()
.with("Type", Object::Name("Font".into()))
.with("Subtype", Object::Name("Type1".into()))
.with("BaseFont", Object::Name("Helvetica".into()));
let fonts = font_res_with("F1", f1);
let bytes = b"BT /F1 12 Tf 72 712 Td (Hello) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 1);
let show = &p.text_shows[0];
assert_eq!(show.font_name, "F1");
assert!((show.font_size - 12.0).abs() < 1e-3);
assert_eq!(show.bytes, b"Hello");
assert!((show.position.0 - 72.0).abs() < 1e-3);
assert!((show.position.1 - 712.0).abs() < 1e-3);
assert!(matches!(show.operator, TextShowOp::Tj));
assert!(show.font_dict.is_some());
}
#[test]
fn tj_array_concatenates_strings_and_drops_kerns() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 10 Tf 0 0 Td [(Hel) -250 (lo) -120 (!)] TJ ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 1);
let show = &p.text_shows[0];
assert_eq!(show.bytes, b"Hello!");
assert!(matches!(show.operator, TextShowOp::TJ));
}
#[test]
fn single_quote_does_implicit_t_star_then_show() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 14 TL 0 100 Td (first) Tj (second) ' ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 2);
assert_eq!(p.text_shows[0].bytes, b"first");
assert!((p.text_shows[0].position.1 - 100.0).abs() < 1e-3);
assert_eq!(p.text_shows[1].bytes, b"second");
assert!((p.text_shows[1].position.1 - 86.0).abs() < 1e-3);
assert!(matches!(p.text_shows[1].operator, TextShowOp::SingleQuote));
}
#[test]
fn double_quote_does_implicit_t_star_then_show() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 10 TL 0 100 Td (first) Tj 1 2 (second) \" ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 2);
assert_eq!(p.text_shows[1].bytes, b"second");
assert!((p.text_shows[1].position.1 - 90.0).abs() < 1e-3);
assert!(matches!(p.text_shows[1].operator, TextShowOp::DoubleQuote));
}
#[test]
fn tm_sets_text_matrix_directly() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 10 Tf 1 0 0 1 50 600 Tm (P) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 1);
assert!((p.text_shows[0].position.0 - 50.0).abs() < 1e-3);
assert!((p.text_shows[0].position.1 - 600.0).abs() < 1e-3);
}
#[test]
fn bt_resets_text_matrix() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 100 200 Td (A) Tj ET BT /F1 12 Tf 0 0 Td (B) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 2);
assert!((p.text_shows[0].position.0 - 100.0).abs() < 1e-3);
assert!(p.text_shows[1].position.0.abs() < 1e-3);
assert!(p.text_shows[1].position.1.abs() < 1e-3);
}
#[test]
fn tj_with_unknown_font_name_still_emits_show_with_none_dict() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F_OTHER 12 Tf 0 0 Td (Hi) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 1);
assert_eq!(p.text_shows[0].font_name, "F_OTHER");
assert_eq!(p.text_shows[0].bytes, b"Hi");
assert!(p.text_shows[0].font_dict.is_none());
}
#[test]
fn legacy_entry_points_drop_tj_silently() {
let bytes = b"BT /F1 12 Tf 0 0 Td (Hello) Tj ET\n";
let r1 = parse_content_stream(bytes).unwrap();
assert!(r1.children.is_empty());
let r2 = parse_content_stream_with_resources(bytes, None).unwrap();
assert!(r2.children.is_empty());
}
#[test]
fn tj_outside_text_object_is_dropped() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"(stray) Tj\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 0);
}
#[test]
fn hex_string_operand_decodes_for_tj() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 0 0 Td <48656C6C6F> Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows.len(), 1);
assert_eq!(p.text_shows[0].bytes, b"Hello");
}
#[test]
fn literal_string_octal_escape() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 0 0 Td (\\101\\102\\103) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows[0].bytes, b"ABC");
}
#[test]
fn literal_string_named_escapes() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"BT /F1 12 Tf 0 0 Td (a\\nb\\tc\\(d\\)) Tj ET\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.text_shows[0].bytes, b"a\nb\tc(d)");
}
#[test]
fn text_and_path_coexist_in_one_stream() {
let fonts = font_res_with("F1", Dict::new());
let bytes = b"q 0 0 m 10 10 l 10 0 l h f BT /F1 12 Tf 0 0 Td (X) Tj ET Q\n";
let p = parse_full(bytes, None, Some(&fonts));
assert_eq!(p.root.children.len(), 1);
let Node::Group(g) = &p.root.children[0] else {
panic!()
};
assert!(matches!(g.children[0], Node::Path(_)));
assert_eq!(p.text_shows.len(), 1);
assert_eq!(p.text_shows[0].bytes, b"X");
}
#[test]
fn hex_string_pads_trailing_odd_nibble() {
let (end, bytes) = read_hex_string(b"<4>x", 0).unwrap();
assert_eq!(end, 3);
assert_eq!(bytes, vec![0x40]);
}
#[test]
fn hex_string_skips_whitespace_and_is_case_insensitive() {
let (_end, bytes) = read_hex_string(b"<4a 5C>", 0).unwrap();
assert_eq!(bytes, vec![0x4A, 0x5C]);
}
fn shading_res_with(name: &str, dict: Dict) -> Dict {
Dict::new().with(name, Object::Dict(dict))
}
fn parse_with_shading(
input: &[u8],
ext: Option<&Dict>,
fonts: Option<&Dict>,
shadings: Option<&Dict>,
) -> ParsedContent {
parse_content_stream_full_with_shading(input, ext, fonts, shadings).unwrap()
}
#[test]
fn sh_emits_one_shading_event_with_resolved_dict() {
let sh1 = Dict::new()
.with("ShadingType", Object::Integer(2))
.with("ColorSpace", Object::Name("DeviceRGB".into()))
.with(
"Coords",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(100.0),
Object::Real(0.0),
]),
);
let shadings = shading_res_with("Sh1", sh1);
let bytes = b"q /Sh1 sh Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
assert_eq!(s.name, "Sh1");
let dict = s.shading_dict.as_ref().expect("resolved");
let st = dict
.entries()
.iter()
.find(|(k, _)| k == "ShadingType")
.map(|(_, v)| v.clone());
assert!(matches!(st, Some(Object::Integer(2))));
assert!((s.ctm.a - 1.0).abs() < 1e-6);
assert!((s.ctm.d - 1.0).abs() < 1e-6);
assert!(s.ctm.b.abs() < 1e-6);
assert!(s.ctm.c.abs() < 1e-6);
assert!(s.ctm.e.abs() < 1e-6);
assert!(s.ctm.f.abs() < 1e-6);
assert!(s.clip.is_none());
}
#[test]
fn sh_captures_effective_ctm_from_cm() {
let sh1 = Dict::new().with("ShadingType", Object::Integer(2));
let shadings = shading_res_with("Sh1", sh1);
let bytes = b"q 27.7843 0.0 0.0 -27.7843 310.2461 121.1521 cm /Sh1 sh Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
assert!((s.ctm.a - 27.7843).abs() < 1e-3);
assert!(s.ctm.b.abs() < 1e-3);
assert!(s.ctm.c.abs() < 1e-3);
assert!((s.ctm.d - -27.7843).abs() < 1e-3);
assert!((s.ctm.e - 310.2461).abs() < 1e-3);
assert!((s.ctm.f - 121.1521).abs() < 1e-3);
}
#[test]
fn sh_composes_nested_cm_across_q_frames() {
let sh1 = Dict::new();
let shadings = shading_res_with("Sh1", sh1);
let bytes = b"q 1 0 0 1 10 20 cm q 1 0 0 1 5 0 cm /Sh1 sh Q Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
assert!((s.ctm.e - 15.0).abs() < 1e-3);
assert!((s.ctm.f - 20.0).abs() < 1e-3);
assert!((s.ctm.a - 1.0).abs() < 1e-3);
assert!((s.ctm.d - 1.0).abs() < 1e-3);
}
#[test]
fn sh_captures_active_clip_path() {
let sh1 = Dict::new();
let shadings = shading_res_with("Sh1", sh1);
let bytes = b"q 0 0 100 50 re W n /Sh1 sh Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
let clip = s.clip.as_ref().expect("clip in force");
assert!(!clip.commands.is_empty());
}
#[test]
fn sh_with_unknown_name_still_emits_event_with_none_dict() {
let shadings = shading_res_with("Sh1", Dict::new());
let bytes = b"q /Other sh Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
assert_eq!(s.name, "Other");
assert!(s.shading_dict.is_none());
}
#[test]
fn sh_without_shading_resources_emits_event_with_none_dict() {
let bytes = b"q 1 0 0 1 50 60 cm /Sh1 sh Q\n";
let p = parse_with_shading(bytes, None, None, None);
assert_eq!(p.shadings.len(), 1);
let s = &p.shadings[0];
assert_eq!(s.name, "Sh1");
assert!(s.shading_dict.is_none());
assert!((s.ctm.e - 50.0).abs() < 1e-3);
assert!((s.ctm.f - 60.0).abs() < 1e-3);
}
#[test]
fn sh_multiple_events_surface_in_stream_order() {
let shadings = Dict::new()
.with("Sh1", Object::Dict(Dict::new()))
.with("Sh2", Object::Dict(Dict::new()));
let bytes = b"q 1 0 0 1 10 20 cm /Sh1 sh Q q 1 0 0 1 30 40 cm /Sh2 sh Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert_eq!(p.shadings.len(), 2);
assert_eq!(p.shadings[0].name, "Sh1");
assert!((p.shadings[0].ctm.e - 10.0).abs() < 1e-3);
assert!((p.shadings[0].ctm.f - 20.0).abs() < 1e-3);
assert_eq!(p.shadings[1].name, "Sh2");
assert!((p.shadings[1].ctm.e - 30.0).abs() < 1e-3);
assert!((p.shadings[1].ctm.f - 40.0).abs() < 1e-3);
}
#[test]
fn parse_content_stream_full_still_drops_sh_with_none_dict() {
let bytes = b"q /Sh1 sh Q\n";
let p = parse_content_stream_full(bytes, None, None).unwrap();
assert_eq!(p.shadings.len(), 1);
assert_eq!(p.shadings[0].name, "Sh1");
assert!(p.shadings[0].shading_dict.is_none());
}
#[test]
fn shadings_empty_when_no_sh_operator() {
let shadings = shading_res_with("Sh1", Dict::new());
let bytes = b"q 100 100 m 200 200 l S Q\n";
let p = parse_with_shading(bytes, None, None, Some(&shadings));
assert!(p.shadings.is_empty());
}
}