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);
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);
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> {
let mut state = State::new(ext_gstate, font_resources);
state.parse(input)?;
Ok(state.finish())
}
#[derive(Clone, Debug, Default)]
pub struct ParsedContent {
pub root: Group,
pub text_shows: Vec<ContentTextShow>,
}
#[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,
}
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>,
current_font: Option<(String, f32)>,
text_matrix: Transform2D,
text_line_matrix: Transform2D,
text_leading: f32,
in_text_object: bool,
text_shows: Vec<ContentTextShow>,
}
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>),
}
#[derive(Clone, Debug)]
enum ArrayElem {
Number(f32),
String(Vec<u8>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ColorSpaceKind {
DeviceGray,
DeviceRgb,
DeviceCmyk,
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::Unknown => None,
}
}
}
impl<'a> State<'a> {
fn new(ext_gstate: Option<&'a Dict>, font_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,
current_font: None,
text_matrix: Transform2D::identity(),
text_line_matrix: Transform2D::identity(),
text_leading: 0.0,
in_text_object: false,
text_shows: 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,
}
}
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);
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);
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();
}
_ => {
self.operands.clear();
}
}
Ok(())
}
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::Unknown => unreachable!("components() returned Some"),
})
}
fn take_color_space_name(&mut self) -> ColorSpaceKind {
match self.operands.last() {
Some(Operand::Name(n)) => ColorSpaceKind::from_name(n),
_ => 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 (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') {
let mut end = i;
if matches!(input[end], b'+' | b'-') {
end += 1;
}
let mut saw_digit = false;
let mut saw_dot = false;
while end < input.len() {
let c = input[end];
if c.is_ascii_digit() {
end += 1;
saw_digit = true;
} else if c == b'.' && !saw_dot {
end += 1;
saw_dot = true;
} else {
break;
}
}
if !saw_digit {
let kw_end = scan_keyword_end(input, i);
let kw = &input[i..kw_end];
self.dispatch(kw)?;
i = kw_end;
continue;
}
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;
}
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::Unknown => 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
}
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;
if matches!(b, b'+' | b'-') {
end += 1;
}
let mut saw_dot = false;
while end < input.len()
&& (input[end].is_ascii_digit() || (input[end] == b'.' && !saw_dot))
{
if input[end] == b'.' {
saw_dot = true;
}
end += 1;
}
if let Ok(s) = str::from_utf8(&input[nstart..end]) {
if let Ok(f) = s.parse::<f32>() {
items.push(ArrayElem::Number(f));
}
}
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 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 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]);
}
}