use crate::color::{ColorSpace, ColorSpaceCache};
use crate::function::FunctionCache;
use crate::image::{ImageCache, RequestedSize, decode_image};
use crate::names;
use crate::ops::{FillRule, LineCap, Op, TextItem, TextRenderMode};
use crate::page::{
Content, FormObject, ImageObject, Page, PageObject, PathObject, ShadingObject, TextObject,
TextSegment,
};
use crate::pattern::{Pattern, TilingPattern};
use crate::resources::Resources;
use crate::shading::{Shading, ShadingSource};
use crate::state::{
ClipRule, ContentMarks, GraphicsState, StateStack, TextClipRun, TextCursor, apply_ext_gstate,
glyph_matrix, kerning_shift,
};
use crate::transparency::Transparency;
use kurbo::{Affine, BezPath, Point, Rect};
use pdfrum_common::{DiagKind, Diagnostics, Limits, Operation, Severity};
use pdfrum_font::{Font, FontCache};
use pdfrum_object::{Dict, Name, Object, Resolve};
use std::any::Any;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
pub const MAX_FORM_LEVEL: usize = 40;
#[derive(Debug, Default)]
pub struct BuildContext {
pub colorspaces: ColorSpaceCache,
pub functions: FunctionCache,
pub images: ImageCache,
pub decode_target: RequestedSize,
pub fonts: Arc<FontCache>,
pub substitution: pdfrum_font::SubstitutionOptions,
form_fonts: HashMap<FormFontsKey, Arc<dyn Any + Send + Sync>>,
in_flight: HashSet<BufferId>,
type3_depth: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FormFontsKey {
Form(pdfrum_object::ObjRef),
None,
DirectResources(pdfrum_object::ObjRef),
Direct,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct BufferId {
reference: Option<pdfrum_object::ObjRef>,
len: usize,
fingerprint: u64,
}
impl BufferId {
fn new(reference: Option<pdfrum_object::ObjRef>, data: &[u8]) -> Self {
let mut fingerprint = 0xcbf2_9ce4_8422_2325u64;
for b in data.iter().take(64).chain(data.iter().rev().take(64)) {
fingerprint ^= u64::from(*b);
fingerprint = fingerprint.wrapping_mul(0x100_0000_01b3);
}
Self {
reference,
len: data.len(),
fingerprint,
}
}
}
impl BuildContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_substitution(options: pdfrum_font::SubstitutionOptions) -> Self {
Self {
substitution: options,
..Self::default()
}
}
pub fn form_fonts<T: Any + Send + Sync>(
&mut self,
key: FormFontsKey,
load: impl FnOnce(&mut Self) -> T,
) -> Arc<T> {
if key == FormFontsKey::Direct {
crate::renderprofile::form_font_miss();
return Arc::new(load(self));
}
if let Some(cached) = self.form_fonts.get(&key)
&& let Ok(hit) = Arc::clone(cached).downcast::<T>()
{
return hit;
}
crate::renderprofile::form_font_miss();
let built = Arc::new(load(self));
self.form_fonts
.insert(key, Arc::clone(&built) as Arc<dyn Any + Send + Sync>);
built
}
#[must_use]
pub fn forms_in_flight(&self) -> usize {
self.in_flight.len()
}
pub(crate) fn enter_type3(&mut self) -> bool {
if self.type3_depth >= pdfrum_font::MAX_TYPE3_DEPTH {
return false;
}
self.type3_depth += 1;
true
}
pub(crate) fn leave_type3(&mut self) {
self.type3_depth = self.type3_depth.saturating_sub(1);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StreamBounds {
starts: Vec<usize>,
}
impl StreamBounds {
#[must_use]
pub fn from_counts(counts: impl IntoIterator<Item = usize>) -> Self {
let mut starts = Vec::new();
let mut at = 0usize;
for count in counts {
starts.push(at);
at = at.saturating_add(count);
}
Self { starts }
}
#[must_use]
pub fn from_joined(bytes: &[u8], total_ops: usize, ends: &[usize], limits: &Limits) -> Self {
if ends.len() <= 1 {
return Self::default();
}
let mut counts = Vec::with_capacity(ends.len());
let mut start = 0usize;
let mut consumed = 0usize;
for (index, end) in ends.iter().enumerate() {
if index.saturating_add(1) == ends.len() {
counts.push(total_ops.saturating_sub(consumed));
break;
}
let element = bytes.get(start..*end).unwrap_or_default();
let mut ignored = Diagnostics::default();
let count = crate::parse_content(element, limits, &mut ignored).len();
consumed = consumed.saturating_add(count);
counts.push(count);
start = *end;
}
Self::from_counts(counts)
}
#[must_use]
pub fn stream_of(&self, op_index: usize) -> usize {
self.starts
.partition_point(|start| *start <= op_index)
.saturating_sub(1)
}
#[must_use]
pub fn len(&self) -> usize {
self.starts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.starts.is_empty()
}
}
#[must_use]
pub fn build_page<R: Resolve>(
ops: &[Op],
resources: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Page {
let objects = interpret(
ops,
resources,
&GraphicsState::default(),
Affine::IDENTITY,
r,
ctx,
limits,
diags,
);
Page {
objects,
resources: resources.chosen.clone(),
..Page::empty()
}
}
#[expect(
clippy::too_many_arguments,
reason = "a page needs its operators, dictionary, inherited attributes, \
resources and the usual resolver/context/limits/diagnostics"
)]
#[must_use]
pub fn build_page_from_dict<R: Resolve>(
ops: &[Op],
dict: &Dict,
inherited: impl Fn(&Name) -> Option<Object>,
resources: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Page {
build_page_streams(
ops,
&StreamBounds::default(),
dict,
inherited,
resources,
r,
ctx,
limits,
diags,
)
}
#[expect(
clippy::too_many_arguments,
reason = "as `build_page_from_dict`, plus the stream boundaries"
)]
#[must_use]
pub fn build_page_streams<R: Resolve>(
ops: &[Op],
bounds: &StreamBounds,
dict: &Dict,
inherited: impl Fn(&Name) -> Option<Object>,
resources: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Page {
let (media_box, crop_box) = crate::page::derive_boxes(dict, &inherited, r, diags);
let rotate = crate::page::Rotation::from_degrees(
dict.int(names::ROTATE, r)
.or_else(|| inherited(names::ROTATE).and_then(|o| o.as_int()))
.unwrap_or(0),
);
let transparency = Transparency::for_page(dict.dict(names::GROUP, r).as_ref(), r);
let (objects, stream_ctms) = interpret_streams(
ops,
bounds,
resources,
&GraphicsState::default(),
Affine::IDENTITY,
r,
ctx,
limits,
diags,
);
Page {
objects,
media_box,
crop_box,
rotate,
transparency,
resources: resources.chosen.clone(),
dirty_streams: BTreeSet::new(),
stream_ctms,
}
}
#[must_use]
pub fn build_form_object<R: Resolve>(
stream: &pdfrum_object::Stream,
matrix: Affine,
resources: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<PageObject> {
build_form_object_with(stream, matrix, resources, r, ctx, limits, diags, false)
}
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "one more than `build_form_object`, which is already at the \
limit; grouping the resolver, context, limits and sink into a \
struct is a change to every builder entry point in this crate \
and not this function's to make"
)]
pub fn build_form_object_with<R: Resolve>(
stream: &pdfrum_object::Stream,
matrix: Affine,
resources: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
live_edit: bool,
) -> Option<PageObject> {
let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
let form_matrix = stream.dict.matrix(names::MATRIX, r);
let placed = matrix * form_matrix;
let mut state = GraphicsState {
ctm: placed,
..GraphicsState::default()
};
let transparency = Transparency::from_group(stream.dict.dict(names::GROUP, r).as_ref(), r);
if transparency.group {
state.general.enter_transparency_group();
}
let bbox = stream
.dict
.array(names::BBOX, r)
.filter(|a| a.len() == 4)
.map(|a| a.as_rect());
if let Some(rect) = bbox {
let mut path = kurbo::BezPath::new();
path.move_to((rect.x0, rect.y0));
path.line_to((rect.x1, rect.y0));
path.line_to((rect.x1, rect.y1));
path.line_to((rect.x0, rect.y1));
path.close_path();
state.clip.push_path(placed * path, ClipRule::Winding);
}
let inner = Resources::choose(
stream.dict.dict(names::RESOURCES, r),
resources.chosen.clone(),
resources.page.clone(),
);
let ops = crate::parse_content(&content, limits, diags);
let objects = interpret(&ops, &inner, &state, placed, r, ctx, limits, diags);
Some(PageObject::Form(Box::new(Content {
object: FormObject {
objects,
matrix: placed,
bbox,
transparency,
oc: stream.dict.dict(names::OC, r).map(Arc::new),
source: None,
live_edit,
},
state,
marks: ContentMarks::default(),
content_stream: None,
dirty: false,
active: true,
})))
}
struct Interp<'a, R: Resolve> {
state: GraphicsState,
stack: StateStack,
marks: ContentMarks,
cursor: TextCursor,
points: Vec<PathPoint>,
pending_clip: FillRule,
subpath_start: Point,
current: Point,
text_clip: Vec<TextClipRun>,
resources: &'a Resources,
parent_matrix: Affine,
resolver: &'a R,
objects: Vec<PageObject>,
stream: usize,
stream_ctms: BTreeMap<usize, Affine>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PointKind {
Move,
Line,
Curve,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct PathPoint {
at: Point,
kind: PointKind,
closes: bool,
}
impl PathPoint {
const fn new(at: Point, kind: PointKind) -> Self {
Self {
at,
kind,
closes: false,
}
}
const fn closing_line(at: Point) -> Self {
Self {
at,
kind: PointKind::Line,
closes: true,
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "the interpreter needs its operators, resources, initial state, \
parent matrix, resolver, context, limits and diagnostics"
)]
fn interpret<R: Resolve>(
ops: &[Op],
resources: &Resources,
initial: &GraphicsState,
parent_matrix: Affine,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Vec<PageObject> {
interpret_streams(
ops,
&StreamBounds::default(),
resources,
initial,
parent_matrix,
r,
ctx,
limits,
diags,
)
.0
}
const DEADLINE_STRIDE: usize = 256;
#[expect(
clippy::too_many_arguments,
reason = "as `interpret`, plus the stream boundaries the editor needs"
)]
fn interpret_streams<R: Resolve>(
ops: &[Op],
bounds: &StreamBounds,
resources: &Resources,
initial: &GraphicsState,
parent_matrix: Affine,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> (Vec<PageObject>, BTreeMap<usize, Affine>) {
let mut interp = Interp {
state: initial.clone(),
stack: StateStack::new(),
marks: ContentMarks::new(),
cursor: TextCursor::default(),
points: Vec::new(),
pending_clip: FillRule::None,
subpath_start: Point::ZERO,
current: Point::ZERO,
text_clip: Vec::new(),
resources,
parent_matrix,
resolver: r,
objects: Vec::new(),
stream: 0,
stream_ctms: BTreeMap::new(),
};
for (index, op) in ops.iter().enumerate() {
if index.is_multiple_of(DEADLINE_STRIDE)
&& limits.check_deadline(Operation::Interpret).is_err()
{
diags.record(Severity::Suspicious, DiagKind::TimeLimitReached, None);
break;
}
interp.stream = bounds.stream_of(index);
interp.apply(op, ctx, limits, diags);
}
(interp.objects, interp.stream_ctms)
}
impl<R: Resolve> Interp<'_, R> {
#[expect(
clippy::too_many_lines,
reason = "the operator dispatch is a flat table by design: one arm \
per operator, each a few lines, and splitting it would \
hide which operator does what"
)]
fn apply(&mut self, op: &Op, ctx: &mut BuildContext, limits: &Limits, diags: &mut Diagnostics) {
match op {
Op::SaveState() => self.stack.push(&self.state),
Op::RestoreState() => {
if !self.stack.pop(&mut self.state) {
diags.record(Severity::Suspicious, DiagKind::UnbalancedRestore, None);
}
self.record_ctm();
}
Op::Concat(m) => {
self.state.ctm *= *m;
self.record_ctm();
}
Op::SetLineWidth(w) => self.state.stroke_params.width = *w,
Op::SetLineCap(c) => self.state.stroke_params.cap = *c,
Op::SetLineJoin(j) => self.state.stroke_params.join = *j,
Op::SetMiterLimit(m) => self.state.stroke_params.miter_limit = *m,
Op::SetDash(d) => {
if d.valid {
self.state.stroke_params.dash.clone_from(&d.array);
self.state.stroke_params.dash_phase = d.phase;
}
}
Op::SetFlatness(f) => self.state.general.flatness = *f,
Op::SetExtGState(name) => self.apply_ext_gstate(name, ctx, limits, diags),
Op::MoveTo(p) => {
self.add_point(PathPoint::new(*p, PointKind::Move));
self.subpath_start = *p;
}
Op::LineTo(p) => self.add_point(PathPoint::new(*p, PointKind::Line)),
Op::CurveTo(a, b, c) => {
self.add_point(PathPoint::new(*a, PointKind::Curve));
self.add_point(PathPoint::new(*b, PointKind::Curve));
self.add_point(PathPoint::new(*c, PointKind::Curve));
}
Op::CurveToV(b, c) => {
let start = self.current;
self.add_point(PathPoint::new(start, PointKind::Curve));
self.add_point(PathPoint::new(*b, PointKind::Curve));
self.add_point(PathPoint::new(*c, PointKind::Curve));
}
Op::CurveToY(a, c) => {
self.add_point(PathPoint::new(*a, PointKind::Curve));
self.add_point(PathPoint::new(*c, PointKind::Curve));
self.add_point(PathPoint::new(*c, PointKind::Curve));
}
Op::ClosePath() => self.close_path(),
Op::Rectangle(x, y, w, h) => {
let (x, y, w, h) = (f64::from(*x), f64::from(*y), f64::from(*w), f64::from(*h));
self.add_point(PathPoint::new(Point::new(x, y), PointKind::Move));
self.add_point(PathPoint::new(Point::new(x + w, y), PointKind::Line));
self.add_point(PathPoint::new(Point::new(x + w, y + h), PointKind::Line));
self.add_point(PathPoint::new(Point::new(x, y + h), PointKind::Line));
self.add_point(PathPoint::closing_line(Point::new(x, y)));
self.subpath_start = Point::new(x, y);
}
Op::Stroke() => self.paint(FillRule::None, true),
Op::CloseStroke() => {
self.close_path();
self.paint(FillRule::None, true);
}
Op::Fill() | Op::FillObsolete() => self.paint(FillRule::Winding, false),
Op::FillEvenOdd() => self.paint(FillRule::EvenOdd, false),
Op::FillStroke() => self.paint(FillRule::Winding, true),
Op::FillStrokeEvenOdd() => self.paint(FillRule::EvenOdd, true),
Op::CloseFillStroke() => {
self.close_path();
self.paint(FillRule::Winding, true);
}
Op::CloseFillStrokeEvenOdd() => {
let start = self.subpath_start;
self.current = start;
if !self.points.is_empty() {
self.points.push(PathPoint::closing_line(start));
}
self.paint(FillRule::EvenOdd, true);
}
Op::EndPath() => self.paint(FillRule::None, false),
Op::Clip() => self.pending_clip = FillRule::Winding,
Op::ClipEvenOdd() => self.pending_clip = FillRule::EvenOdd,
Op::BeginText() => {
self.cursor.set_matrix(Affine::IDENTITY);
}
Op::EndText() => {
let runs = std::mem::take(&mut self.text_clip);
if !runs.is_empty() && self.state.text.render_mode.clips() {
let _ = self.state.clip.push_text(runs);
}
}
Op::TextMove(tx, ty) => {
self.cursor.move_line(f64::from(*tx), f64::from(*ty));
}
Op::TextMoveSetLeading(tx, ty) => {
self.cursor.move_line(f64::from(*tx), f64::from(*ty));
self.state.text.leading = -*ty;
}
Op::SetTextMatrix(m) => self.cursor.set_matrix(*m),
Op::TextNextLine() => {
self.cursor.next_line(f64::from(self.state.text.leading));
}
Op::SetLeading(l) => self.state.text.leading = *l,
Op::SetTextRise(rise) => self.state.text.rise = *rise,
Op::SetHorzScale(z) => self.state.text.horz_scale = *z / 100.0,
Op::SetCharSpace(c) => self.state.text.char_space = *c,
Op::SetWordSpace(w) => self.state.text.word_space = *w,
Op::SetFont(name, size) => {
let font = self.find_font(name, ctx, limits, diags);
let source = self.resources.find_ref(names::FONT, name, self.resolver);
match (font, self.state.text.font.take()) {
(Some(f), _) => {
self.state.text.font = Some((f, *size));
self.state.text.font_source = source;
}
(None, Some((old, _))) => self.state.text.font = Some((old, *size)),
(None, None) => {}
}
}
Op::SetTextRenderMode(mode) => match TextRenderMode::from_int(*mode) {
Some(m) => self.state.text.render_mode = m,
None => {
diags.record(Severity::Suspicious, DiagKind::BadTextRenderMode, None);
}
},
Op::ShowText(s) => self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags),
Op::NextLineShowText(s) => {
self.cursor.next_line(f64::from(self.state.text.leading));
self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
}
Op::SetSpacingShowText(word, char_space, s) => {
self.state.text.word_space = *word;
self.state.text.char_space = *char_space;
self.cursor.next_line(f64::from(self.state.text.leading));
self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
}
Op::ShowTextAdjusted(array) => {
if array.valid {
self.show_adjusted(&array.items, ctx, limits, diags);
}
}
Op::SetStrokeColorSpace(name) => {
self.set_color_space(name, true, ctx, limits, diags);
}
Op::SetFillColorSpace(name) => {
self.set_color_space(name, false, ctx, limits, diags);
}
Op::SetStrokeColor(c) => {
let _ = self.state.stroke.set_components(&c.0);
}
Op::SetFillColor(c) => {
let _ = self.state.fill.set_components(&c.0);
}
Op::SetStrokeColorN(c) => self.set_color_n(c, true, ctx, limits, diags),
Op::SetFillColorN(c) => self.set_color_n(c, false, ctx, limits, diags),
Op::SetStrokeGray(g) => {
self.state.stroke.set_stock(ColorSpace::DeviceGray, &[*g]);
}
Op::SetFillGray(g) => self.state.fill.set_stock(ColorSpace::DeviceGray, &[*g]),
Op::SetStrokeRgb(r, g, b) => {
self.state
.stroke
.set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
}
Op::SetFillRgb(r, g, b) => {
self.state
.fill
.set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
}
Op::SetStrokeCmyk(c, m, y, k) => {
self.state
.stroke
.set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
}
Op::SetFillCmyk(c, m, y, k) => {
self.state
.fill
.set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
}
Op::DoXObject(name) => self.do_xobject(name, ctx, limits, diags),
Op::ShadeFill(name) => self.shade_fill(name, ctx, limits, diags),
Op::InlineImage(image) => self.inline_image(image, ctx, limits, diags),
Op::BeginMarkedContent(tag) => self.marks.push(tag.clone()),
Op::BeginMarkedContentDict(tag, props) => {
if let Some(properties) = &props.0 {
let resources = self.resources;
let resolver = self.resolver;
self.marks
.push_with_properties(tag.clone(), properties, |name| {
resources
.find(names::PROPERTIES, name, resolver)
.and_then(|o| o.as_dict().cloned())
});
}
}
Op::EndMarkedContent() => {
if !self.marks.pop() {
diags.record(
Severity::Suspicious,
DiagKind::UnbalancedMarkedContent,
None,
);
}
}
Op::Type3Width(..)
| Op::Type3WidthBBox(..)
| Op::SetRenderIntent(_)
| Op::MarkPoint(_)
| Op::MarkPointDict(..)
| Op::BeginInlineImage()
| Op::InlineImageData()
| Op::EndInlineImage()
| Op::BeginCompat()
| Op::EndCompat()
| Op::Unknown(_) => {}
}
}
fn add_point(&mut self, point: PathPoint) {
self.current = point.at;
match self.points.last() {
Some(previous) if previous.kind == PointKind::Move && point.kind == PointKind::Move => {
if previous.at == point.at {
return;
}
if let Some(last) = self.points.last_mut() {
*last = point;
}
return;
}
None if point.kind != PointKind::Move => return,
_ => {}
}
self.points.push(point);
}
fn close_path(&mut self) {
if self.points.is_empty() {
return;
}
if self.current == self.subpath_start {
if let Some(last) = self.points.last_mut() {
last.closes = true;
}
} else {
let start = self.subpath_start;
self.points.push(PathPoint::closing_line(start));
self.current = start;
}
}
fn paint(&mut self, fill_rule: FillRule, stroke: bool) {
let points = std::mem::take(&mut self.points);
let clip_rule = std::mem::replace(&mut self.pending_clip, FillRule::None);
if points.is_empty() {
return;
}
let matrix = self.state.ctm;
if points.len() == 1 {
if clip_rule != FillRule::None {
self.state.clip.push_empty();
return;
}
let point = points
.first()
.copied()
.unwrap_or(PathPoint::new(Point::ZERO, PointKind::Move));
if !point.closes || self.state.stroke_params.cap != LineCap::Round {
return;
}
let mut path = BezPath::new();
path.move_to(point.at);
path.line_to(point.at);
path.close_path();
self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
return;
}
let mut points = points;
if matches!(points.last(), Some(last) if last.kind == PointKind::Move) {
points.pop();
}
if points.is_empty() {
return;
}
let path = build_path(&points);
self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
}
fn emit_path(
&mut self,
path: BezPath,
matrix: Affine,
fill_rule: FillRule,
stroke: bool,
clip_rule: FillRule,
) {
if stroke || fill_rule != FillRule::None {
let object = PathObject {
path: path.clone(),
matrix,
fill_rule,
stroke,
};
self.push(PageObject::Path(Box::new(self.content(object))));
}
if clip_rule != FillRule::None {
let clipped = if matrix == Affine::IDENTITY {
path
} else {
matrix * path
};
self.state.clip.push_path(
clipped,
match clip_rule {
FillRule::EvenOdd => ClipRule::EvenOdd,
_ => ClipRule::Winding,
},
);
}
}
fn content<T>(&self, object: T) -> Content<T> {
Content {
object,
state: self.state.clone(),
marks: self.marks.clone(),
content_stream: Some(self.stream),
dirty: false,
active: true,
}
}
fn record_ctm(&mut self) {
self.stream_ctms.insert(self.stream, self.state.ctm);
}
fn push(&mut self, object: PageObject) {
self.objects.push(object);
}
fn show_text(
&mut self,
segments: &[(Box<[u8]>, f32)],
initial_kerning: f32,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some((font, size)) = self.state.text.font.clone() else {
return;
};
let vertical = font.is_vertical();
if initial_kerning != 0.0 {
let shift = -kerning_shift(initial_kerning, size, self.state.text.horz_scale, vertical);
self.cursor.advance(shift, vertical);
}
let segments: Vec<TextSegment> = segments
.iter()
.filter(|(codes, _)| !codes.is_empty())
.map(|(codes, kerning)| TextSegment {
codes: codes.clone(),
kerning: *kerning,
})
.collect();
if segments.is_empty() {
return;
}
let render_mode = if font.type3().is_some() {
TextRenderMode::Fill
} else {
self.state.text.render_mode
};
let position = self
.cursor
.device_position(self.state.text.rise, self.state.ctm);
let matrix = glyph_matrix(
self.state.text.horz_scale,
self.cursor.matrix,
self.state.ctm,
);
let advance = self.advance_for(&segments, &font, size);
let type3_metrics = self.type3_metrics_for(&segments, &font, ctx, limits, diags);
let object = TextObject {
segments: segments.into(),
position,
matrix,
font: Some((Arc::clone(&font), size)),
font_source: self.state.text.font_source,
render_mode,
type3_metrics,
};
if render_mode.clips() {
self.text_clip.push(TextClipRun {
object: object.clone(),
char_space: self.state.text.char_space,
word_space: self.state.text.word_space,
});
}
let mut content = self.content(object);
if render_mode.strokes() {
content.state.text.stroke_ctm = stroke_ctm_of(self.state.ctm);
}
self.push(PageObject::Text(Box::new(content)));
self.cursor.advance(advance, vertical);
}
fn type3_metrics_for(
&self,
segments: &[TextSegment],
font: &Font,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> std::collections::BTreeMap<u32, crate::type3::Type3Metrics> {
let mut out = std::collections::BTreeMap::new();
let Some(type3) = font.type3() else {
return out;
};
for segment in segments {
for item in font.decode(&segment.codes) {
if out.contains_key(&item.code.0) {
continue;
}
if let Some(m) = crate::type3::metrics(
type3,
item.code,
self.resources.page.as_ref(),
self.resolver,
ctx,
limits,
diags,
) {
out.insert(item.code.0, m);
}
}
}
out
}
fn advance_for(&self, segments: &[TextSegment], font: &Font, size: f32) -> f64 {
let vertical = font.is_vertical();
let mut total = 0.0f64;
for segment in segments {
for item in font.decode(&segment.codes) {
let mut width = f64::from(item.width) * f64::from(size) / 1000.0;
if item.code.0 == 0x20 && item.cid.is_none() {
width += f64::from(self.state.text.word_space);
}
width += f64::from(self.state.text.char_space);
total += width;
}
total -= kerning_shift(segment.kerning, size, 1.0, vertical);
}
if vertical {
total
} else {
total * f64::from(self.state.text.horz_scale)
}
}
fn show_adjusted(
&mut self,
items: &[TextItem],
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let strings = items
.iter()
.filter(|i| matches!(i, TextItem::Show(_)))
.count();
let vertical = self
.state
.text
.font
.as_ref()
.is_some_and(|(f, _)| f.is_vertical());
if strings == 0 {
let Some((_, size)) = self.state.text.font.clone() else {
return;
};
for item in items {
let TextItem::Adjust(k) = item else { continue };
if *k != 0.0 {
let shift = -kerning_shift(*k, size, self.state.text.horz_scale, false);
self.cursor.pos.x += shift;
}
}
let _ = vertical;
return;
}
let mut segments: Vec<(Box<[u8]>, f32)> = Vec::new();
let mut initial = 0.0f32;
for item in items {
match item {
TextItem::Show(codes) => {
if !codes.is_empty() {
segments.push((codes.clone(), 0.0));
}
}
TextItem::Adjust(k) => match segments.last_mut() {
Some((_, kerning)) => *kerning += *k,
None => initial += *k,
},
}
}
self.show_text(&segments, initial, ctx, limits, diags);
}
fn find_font(
&self,
name: &Name,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Arc<Font>> {
let fonts = Arc::clone(&ctx.fonts);
let substitution = &ctx.substitution;
let mut load = || {
let dict = self
.resources
.find(names::FONT, name, self.resolver)
.and_then(|o| o.as_dict().cloned());
match dict {
Some(d) => pdfrum_font::load_with_options(
&d,
self.resolver,
&fonts,
substitution,
limits,
diags,
),
None => Some(Font::load_standard(
pdfrum_font::StandardFont::Helvetica,
&fonts,
)),
}
};
match self.resources.find_ref(names::FONT, name, self.resolver) {
Some(reference) => fonts.get_or_load(reference, load),
None => load().map(Arc::new),
}
}
fn set_color_space(
&mut self,
name: &Name,
stroking: bool,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some(space) = self.load_named_colorspace(name, ctx, limits, diags) else {
return;
};
let target = if stroking {
&mut self.state.stroke
} else {
&mut self.state.fill
};
target.set_space(Arc::new(space));
}
fn load_named_colorspace(
&self,
name: &Name,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<ColorSpace> {
if name.as_bytes() == b"Pattern" {
return Some(ColorSpace::Pattern(Box::default()));
}
let colorspaces = self.resources.color_spaces(self.resolver);
crate::color::load_colorspace(
&Object::Name(name.clone()),
colorspaces.as_ref(),
self.resolver,
&mut ctx.functions,
limits,
diags,
)
}
fn set_color_n(
&mut self,
c: &crate::ops::PatternComponents,
stroking: bool,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
if let Some(name) = &c.pattern {
let found = load_pattern(
name,
self.resources,
self.parent_matrix,
&self.state.general,
self.resolver,
ctx,
limits,
diags,
);
let loaded = match found {
FoundPattern::Loaded(p) => Some(p),
FoundPattern::Unusable => None,
FoundPattern::Missing => return,
};
let target = if stroking {
&mut self.state.stroke
} else {
&mut self.state.fill
};
target.set_pattern(name.clone(), &c.values, loaded);
return;
}
let target = if stroking {
&mut self.state.stroke
} else {
&mut self.state.fill
};
let _ = target.set_components(&c.values);
}
fn apply_ext_gstate(
&mut self,
name: &Name,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some(ext) = self
.resources
.find(names::EXT_G_STATE, name, self.resolver)
.and_then(|o| o.as_dict().cloned())
else {
return;
};
let resources = self.resources;
let resolver = self.resolver;
let fonts = &ctx.fonts;
let substitution = &ctx.substitution;
let find_font = |first: Option<&Object>| -> Option<Arc<Font>> {
let (reference, dict) = match first? {
Object::Ref(reference) => (
Some(*reference),
Object::Ref(*reference)
.resolve(resolver)
.ok()?
.as_dict()
.cloned()?,
),
Object::Name(name) => (
resources.find_ref(names::FONT, name, resolver),
resources
.find(names::FONT, name, resolver)
.and_then(|o| o.as_dict().cloned())?,
),
Object::Dict(d) => (None, d.clone()),
_ => return None,
};
let load = || {
pdfrum_font::load_with_options(
&dict,
resolver,
fonts,
substitution,
limits,
&mut Diagnostics::with_limit(0),
)
};
match reference {
Some(reference) => fonts.get_or_load(reference, load),
None => load().map(Arc::new),
}
};
apply_ext_gstate(
&mut self.state,
&ext,
find_font,
self.resolver,
&mut ctx.functions,
limits,
diags,
);
self.expand_soft_mask_group(ctx, limits, diags);
}
fn expand_soft_mask_group(
&mut self,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some(mask) = self.state.general.soft_mask.as_ref() else {
return;
};
if !mask.objects.is_empty() {
return;
}
let group = mask.group.clone();
let matrix = mask.matrix;
let content = pdfrum_filters::decode_chain(&group, 0, self.resolver, limits, diags).data;
let id = BufferId::new(None, &content);
if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
return;
}
let form_matrix = group.dict.matrix(names::MATRIX, self.resolver);
let inner = GraphicsState {
ctm: matrix * form_matrix,
..GraphicsState::default()
};
let resources = Resources::choose(
group.dict.dict(names::RESOURCES, self.resolver),
self.resources.page.clone(),
self.resources.page.clone(),
);
ctx.in_flight.insert(id);
let ops = crate::parse_content(&content, limits, diags);
let objects = interpret(
&ops,
&resources,
&inner,
inner.ctm,
self.resolver,
ctx,
limits,
diags,
);
ctx.in_flight.remove(&id);
if let Some(mask) = self.state.general.soft_mask.as_mut() {
Arc::make_mut(mask).objects = objects;
}
}
fn do_xobject(
&mut self,
name: &Name,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some(object) = self.resources.find(names::XOBJECT, name, self.resolver) else {
return;
};
let Some(stream) = object.as_stream() else {
return;
};
let reference = self
.resources
.holder(names::XOBJECT, self.resolver)
.and_then(|h| h.reference(name));
match stream
.dict
.byte_string(names::SUBTYPE, self.resolver)
.as_deref()
{
Some(b"Form") => self.add_form(stream, reference, ctx, limits, diags),
Some(b"Image") => self.add_image(stream, reference, ctx, limits, diags),
_ => {}
}
}
fn add_form(
&mut self,
stream: &pdfrum_object::Stream,
reference: Option<pdfrum_object::ObjRef>,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let content = pdfrum_filters::decode_chain(stream, 0, self.resolver, limits, diags).data;
let id = BufferId::new(reference, &content);
if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
return;
}
let form_matrix = stream.dict.matrix(names::MATRIX, self.resolver);
let matrix = self.state.ctm * form_matrix;
let mut inner = self.state.clone();
inner.ctm = matrix;
inner.clip = crate::state::ClipStack::new();
let transparency = Transparency::from_group(
stream.dict.dict(names::GROUP, self.resolver).as_ref(),
self.resolver,
);
if transparency.group {
inner.general.enter_transparency_group();
}
let bbox = stream
.dict
.array(names::BBOX, self.resolver)
.filter(|a| a.len() == 4)
.map(|a| a.as_rect());
let resources = Resources::choose(
stream.dict.dict(names::RESOURCES, self.resolver),
self.resources.chosen.clone(),
self.resources.page.clone(),
);
ctx.in_flight.insert(id);
let ops = crate::parse_content(&content, limits, diags);
let objects = interpret(
&ops,
&resources,
&inner,
matrix,
self.resolver,
ctx,
limits,
diags,
);
ctx.in_flight.remove(&id);
let object = FormObject {
objects,
matrix,
bbox,
transparency,
oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
source: reference,
live_edit: false,
};
self.push(PageObject::Form(Box::new(self.content(object))));
}
fn add_image(
&mut self,
stream: &pdfrum_object::Stream,
reference: Option<pdfrum_object::ObjRef>,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let size = ctx.decode_target;
if size == RequestedSize::NoSamples {
return;
}
let cached = reference.and_then(|id| ctx.images.get(id, size));
let image = if let Some(hit) = cached {
hit
} else {
let decoded = decode_image(
stream,
None,
self.resources.page.as_ref(),
size,
self.resolver,
&mut ctx.functions,
limits,
diags,
);
let Ok(image) = decoded else {
return;
};
let image = Arc::new(image);
if let Some(id) = reference {
ctx.images.insert(id, size, Arc::clone(&image));
}
image
};
let is_mask = image.samples.is_stencil();
let object = ImageObject {
image,
matrix: self.state.ctm,
is_mask,
oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
source: reference,
};
self.push(PageObject::Image(Box::new(self.content(object))));
}
fn inline_image(
&mut self,
image: &crate::ops::InlineImage,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let stream = pdfrum_object::Stream::new(
crate::inline_image::as_xobject_dict(image),
pdfrum_object::ByteSpan::from(image.data.to_vec()),
);
let decoded = decode_image(
&stream,
self.resources.chosen.as_ref(),
self.resources.page.as_ref(),
ctx.decode_target,
self.resolver,
&mut ctx.functions,
limits,
diags,
);
let Ok(data) = decoded else {
return;
};
let is_mask = data.samples.is_stencil();
let object = ImageObject {
image: Arc::new(data),
matrix: self.state.ctm,
is_mask,
oc: None,
source: None,
};
self.push(PageObject::Image(Box::new(self.content(object))));
}
fn shade_fill(
&mut self,
name: &Name,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) {
let Some(object) = self.resources.find(names::SHADING, name, self.resolver) else {
return;
};
let colorspaces = self.resources.color_spaces(self.resolver);
let Some(shading) = Shading::load(
&object,
colorspaces.as_ref(),
ShadingSource::ShadingOperator,
self.resolver,
&mut ctx.functions,
limits,
diags,
) else {
return;
};
let mut bounds = self
.state
.clip
.bounds()
.unwrap_or(crate::page::DEFAULT_MEDIA_BOX);
if let crate::shading::Geometry::Mesh { mesh, .. } = &shading.geometry
&& let Some(extent) = mesh.bounds()
{
bounds = bounds.intersect(self.state.ctm.transform_rect_bbox(extent));
}
let object = ShadingObject {
shading: Arc::new(shading),
matrix: self.state.ctm,
bounds,
};
self.push(PageObject::Shading(Box::new(self.content(object))));
}
}
fn stroke_ctm_of(ctm: Affine) -> [f32; 4] {
let [a, b, c, d, _, _] = ctm.as_coeffs();
#[expect(
clippy::cast_possible_truncation,
reason = "the stored slot is f32, matching the graphics state's other text scalars"
)]
{
[a as f32, c as f32, b as f32, d as f32]
}
}
fn build_path(points: &[PathPoint]) -> BezPath {
let mut path = BezPath::new();
let mut pending: Vec<Point> = Vec::new();
let mut open = false;
for point in points {
match point.kind {
PointKind::Move => {
pending.clear();
path.move_to(point.at);
open = true;
}
PointKind::Line => {
if open {
path.line_to(point.at);
}
}
PointKind::Curve => {
pending.push(point.at);
if pending.len() == 3 {
if open
&& let (Some(a), Some(b), Some(c)) =
(pending.first(), pending.get(1), pending.get(2))
{
path.curve_to(*a, *b, *c);
}
pending.clear();
}
}
}
if point.closes && open {
path.close_path();
pending.clear();
open = false;
}
}
path
}
#[derive(Debug, Clone)]
pub enum FoundPattern {
Loaded(Arc<Pattern>),
Unusable,
Missing,
}
#[expect(
clippy::too_many_arguments,
reason = "looking a pattern up needs its name, resources, anchor matrix, \
the painting object's general state, and the usual four"
)]
#[must_use]
pub fn load_pattern<R: Resolve>(
name: &Name,
resources: &Resources,
parent_matrix: Affine,
general: &crate::state::GeneralState,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> FoundPattern {
let Some(object) = resources.find(names::PATTERN, name, r) else {
return FoundPattern::Missing;
};
if !matches!(object, Object::Dict(_) | Object::Stream(_)) {
return FoundPattern::Missing;
}
let colorspaces = resources.color_spaces(r);
let loaded = Pattern::load(
&object,
parent_matrix,
colorspaces.as_ref(),
r,
&mut ctx.functions,
limits,
diags,
);
let Some(mut pattern) = loaded else {
return FoundPattern::Unusable;
};
if let Pattern::Tiling(tiling) = &mut pattern
&& let Some(stream) = object.as_stream()
{
tiling.objects =
expand_tiling_cell(tiling, stream, general, resources, r, ctx, limits, diags);
}
FoundPattern::Loaded(Arc::new(pattern))
}
#[expect(
clippy::too_many_arguments,
reason = "expanding a cell needs the pattern, its stream, the inherited \
state, resources, resolver and the usual three"
)]
fn expand_tiling_cell<R: Resolve>(
tiling: &TilingPattern,
stream: &pdfrum_object::Stream,
general: &crate::state::GeneralState,
outer: &Resources,
r: &R,
ctx: &mut BuildContext,
limits: &Limits,
diags: &mut Diagnostics,
) -> Vec<PageObject> {
let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
let id = BufferId::new(None, &content);
if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
return Vec::new();
}
let mut initial = GraphicsState {
general: general.clone(),
ctm: tiling.matrix,
..GraphicsState::default()
};
if tiling.bbox.width() > 0.0 && tiling.bbox.height() > 0.0 {
initial.clip.push_path(
tiling.matrix * kurbo::Shape::to_path(&tiling.bbox, 0.1),
ClipRule::Winding,
);
}
let resources = Resources::choose(
tiling.resources.clone(),
outer.chosen.clone(),
outer.page.clone(),
);
ctx.in_flight.insert(id);
let ops = crate::parse_content(&content, limits, diags);
let objects = interpret(
&ops,
&resources,
&initial,
tiling.matrix,
r,
ctx,
limits,
diags,
);
ctx.in_flight.remove(&id);
objects
}
pub fn eliminate_redundant_clips(
objects: &mut [PageObject],
bounds_of: impl Fn(&PageObject) -> Rect,
) {
for object in objects.iter_mut() {
if matches!(object, PageObject::Shading(_)) {
continue;
}
let rect = bounds_of(object);
let state = match object {
PageObject::Path(c) => &mut c.state,
PageObject::Text(c) => &mut c.state,
PageObject::Image(c) => &mut c.state,
PageObject::Form(c) => &mut c.state,
PageObject::Shading(_) => continue,
};
if state.clip.len() != 1 {
continue;
}
let Some(crate::state::ClipEntry::Path { path, .. }) = state.clip.entries().first() else {
continue;
};
let clip_rect = kurbo::Shape::bounding_box(path);
if clip_rect.x0 <= rect.x0
&& clip_rect.y0 <= rect.y0
&& clip_rect.x1 >= rect.x1
&& clip_rect.y1 >= rect.y1
{
state.clip = crate::state::ClipStack::new();
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{BuildContext, MAX_FORM_LEVEL, build_page};
use crate::color::ColorSpace;
use crate::ops::{FillRule, LineCap};
use crate::page::PageObject;
use crate::resources::Resources;
use crate::state::GraphicsState;
use kurbo::{Affine, Point};
use pdfrum_common::{DiagKind, Diagnostics, Limits};
use pdfrum_object::NoResolve;
fn build(src: &[u8]) -> (crate::page::Page, Diagnostics) {
build_with(src, &Resources::default())
}
fn clip_kinds(page: &crate::page::Page) -> Vec<&'static str> {
page.objects
.last()
.expect("at least one object")
.state()
.clip
.entries()
.iter()
.map(|e| match e {
crate::state::ClipEntry::Path { .. } => "path",
crate::state::ClipEntry::Text { .. } => "text",
})
.collect()
}
#[test]
fn a_clipping_text_mode_reaches_the_clip_stack_at_et() {
let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
assert_eq!(clip_kinds(&page), ["text"]);
}
#[test]
fn a_non_clipping_mode_contributes_nothing() {
let (page, _) = build(b"BT /F1 24 Tf 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
assert!(clip_kinds(&page).is_empty());
}
#[test]
fn the_mode_at_et_decides_whether_the_batch_is_kept() {
let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET 0 0 50 50 re f");
assert!(clip_kinds(&page).is_empty(), "the batch is dropped at ET");
let (page, _) = build(
b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET \
BT /F1 24 Tf 7 Tr 10 10 Td (o) Tj ET 0 0 50 50 re f",
);
assert_eq!(
clip_kinds(&page),
["text"],
"only the second object's own run clips"
);
}
#[test]
fn a_standalone_form_clips_to_its_own_bbox() {
use pdfrum_object::{ByteSpan, Dict, Name, Object, Stream};
let dict = Dict::from_pairs([(
Name::from("BBox"),
Object::Array(pdfrum_object::Array::of([
Object::Int(0),
Object::Int(0),
Object::Int(10),
Object::Int(20),
])),
)]);
let stream = Stream::new(dict, ByteSpan::from(b"0 0 100 100 re f".to_vec()));
let mut ctx = BuildContext::default();
let mut diags = Diagnostics::default();
let object = super::build_form_object(
&stream,
Affine::IDENTITY,
&Resources::default(),
&NoResolve,
&mut ctx,
&Limits::default(),
&mut diags,
)
.expect("a form");
let PageObject::Form(form) = &object else {
panic!("expected a form");
};
assert_eq!(
form.object.bbox,
Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
);
let child = form.object.objects.first().expect("one child");
let PageObject::Path(path) = child else {
panic!("expected a path");
};
assert_eq!(path.state.clip.len(), 1, "the bbox is on the child's clip");
assert_eq!(
path.state.clip.bounds(),
Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
);
}
#[test]
fn a_form_with_no_bbox_is_unclipped() {
use pdfrum_object::{ByteSpan, Dict, Stream};
let stream = Stream::new(Dict::new(), ByteSpan::from(b"0 0 100 100 re f".to_vec()));
let mut ctx = BuildContext::default();
let mut diags = Diagnostics::default();
let object = super::build_form_object(
&stream,
Affine::IDENTITY,
&Resources::default(),
&NoResolve,
&mut ctx,
&Limits::default(),
&mut diags,
)
.expect("a form");
let PageObject::Form(form) = &object else {
panic!("expected a form");
};
assert_eq!(form.object.bbox, None, "a missing /BBox is no clip at all");
let PageObject::Path(path) = form.object.objects.first().expect("one child") else {
panic!("expected a path");
};
assert!(path.state.clip.is_empty());
}
fn build_with(src: &[u8], resources: &Resources) -> (crate::page::Page, Diagnostics) {
build_under(src, resources, &Limits::default())
}
fn build_under(
src: &[u8],
resources: &Resources,
limits: &Limits,
) -> (crate::page::Page, Diagnostics) {
let mut diags = Diagnostics::default();
let ops = crate::parse_content(src, limits, &mut diags);
let mut ctx = BuildContext::new();
let page = build_page(&ops, resources, &NoResolve, &mut ctx, limits, &mut diags);
(page, diags)
}
#[test]
fn a_spent_deadline_stops_the_interpreter_with_a_diagnostic() {
let spent = Limits {
deadline: Some(pdfrum_common::Deadline::after(std::time::Duration::ZERO)),
..Limits::default()
};
let (page, diags) = build_under(
b"0 0 10 10 re f 0 0 20 20 re f",
&Resources::default(),
&spent,
);
assert!(page.objects.is_empty());
assert!(diags.contains(&DiagKind::TimeLimitReached));
let generous = Limits {
deadline: Some(pdfrum_common::Deadline::after(
std::time::Duration::from_hours(1),
)),
..Limits::default()
};
let (page, diags) = build_under(
b"0 0 10 10 re f 0 0 20 20 re f",
&Resources::default(),
&generous,
);
assert_eq!(page.objects.len(), 2);
assert!(!diags.contains(&DiagKind::TimeLimitReached));
}
#[derive(Debug, Default)]
struct Store(std::collections::HashMap<u32, std::sync::Arc<pdfrum_object::Object>>);
impl pdfrum_object::Resolve for Store {
fn fetch(
&self,
r: pdfrum_object::ObjRef,
) -> Result<std::sync::Arc<pdfrum_object::Object>, pdfrum_object::Error> {
self.0
.get(&r.num)
.map(std::sync::Arc::clone)
.ok_or(pdfrum_object::Error::UnresolvedRef(r))
}
}
fn helvetica() -> pdfrum_object::Dict {
use pdfrum_object::{Dict, Name, Object};
Dict::from_pairs([
(Name::from("Type"), Object::Name(Name::from("Font"))),
(Name::from("Subtype"), Object::Name(Name::from("Type1"))),
(
Name::from("BaseFont"),
Object::Name(Name::from("Helvetica")),
),
])
}
fn font_from_ext_gstate(first: pdfrum_object::Object) -> Option<f32> {
use pdfrum_object::{Array, Dict, Name, Object};
let store = Store(
[(7u32, std::sync::Arc::new(Object::Dict(helvetica())))]
.into_iter()
.collect(),
);
let gs = Dict::from_pairs([(
Name::from("Font"),
Object::Array(Array::of([first, Object::Int(12)])),
)]);
let resources = Resources {
chosen: Some(Dict::from_pairs([
(
Name::from("ExtGState"),
Object::Dict(Dict::from_pairs([(Name::from("GS"), Object::Dict(gs))])),
),
(
Name::from("Font"),
Object::Dict(Dict::from_pairs([(
Name::from("F1"),
Object::Dict(helvetica()),
)])),
),
])),
page: None,
};
let limits = Limits::default();
let mut diags = Diagnostics::default();
let ops = crate::parse_content(b"/GS gs BT (x) Tj ET", &limits, &mut diags);
let mut ctx = BuildContext::new();
let mut state = GraphicsState::default();
let page = build_page(&ops, &resources, &store, &mut ctx, &limits, &mut diags);
let _ = &mut state;
page.objects
.first()
.and_then(|o| o.state().text.font.as_ref())
.map(|(_, size)| *size)
}
#[test]
fn an_ext_gstate_font_resolves_the_specs_indirect_reference() {
let size =
font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(7, 0)));
assert_eq!(size, Some(12.0));
}
#[test]
fn an_ext_gstate_font_still_takes_the_oracles_resource_name() {
let size =
font_from_ext_gstate(pdfrum_object::Object::Name(pdfrum_object::Name::from("F1")));
assert_eq!(size, Some(12.0));
}
#[test]
fn an_ext_gstate_font_reference_to_nothing_installs_nothing() {
let size = font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(
99, 0,
)));
assert_eq!(size, None);
}
#[test]
fn two_form_objects_with_identical_bytes_are_two_forms() {
use super::BufferId;
use pdfrum_object::ObjRef;
let body = b"/X1 Do";
let five = BufferId::new(
Some(ObjRef {
num: 5,
generation: 0,
}),
body,
);
let six = BufferId::new(
Some(ObjRef {
num: 6,
generation: 0,
}),
body,
);
assert_ne!(five, six, "same bytes, different objects, different ids");
assert_eq!(
five,
BufferId::new(
Some(ObjRef {
num: 5,
generation: 0
}),
body
),
"the same object really is the same id, which is what catches a \
form that draws itself"
);
assert_ne!(
BufferId::new(None, b"a"),
BufferId::new(None, b"b"),
"content still distinguishes two unreferenced buffers"
);
}
#[test]
fn a_rectangle_fill_produces_one_path_object() {
let (page, _) = build(b"0 0 100 50 re f");
assert_eq!(page.objects.len(), 1);
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path, got {:?}", page.objects[0]);
};
assert_eq!(path.object.fill_rule, FillRule::Winding);
assert!(!path.object.stroke);
}
#[test]
fn n_with_no_clip_produces_nothing() {
let (page, _) = build(b"0 0 100 50 re n");
assert!(page.objects.is_empty());
}
#[test]
fn n_with_a_pending_clip_clips_but_paints_nothing() {
let (page, _) = build(b"0 0 100 50 re W n 0 0 10 10 re f");
assert_eq!(page.objects.len(), 1);
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert_eq!(path.state.clip.len(), 1);
}
#[test]
fn a_curve_that_closes_its_subpath_stays_a_curve_and_leaks_nothing() {
let (page, _) = build(
b"10 10 m 12 14 16 14 18 10 c 14 6 12 6 10 10 c h \
50 10 m 52 14 56 14 58 10 c 54 6 52 6 50 10 c h S",
);
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
let elements: Vec<_> = path.object.path.elements().to_vec();
let kinds: Vec<&str> = elements
.iter()
.map(|e| match e {
kurbo::PathEl::MoveTo(_) => "M",
kurbo::PathEl::LineTo(_) => "L",
kurbo::PathEl::CurveTo(..) => "C",
kurbo::PathEl::QuadTo(..) => "Q",
kurbo::PathEl::ClosePath => "Z",
})
.collect();
assert_eq!(kinds, ["M", "C", "C", "Z", "M", "C", "C", "Z"], "{kinds:?}");
let kurbo::PathEl::CurveTo(a, b, c) = elements[5] else {
panic!("expected the second subpath's first curve");
};
for p in [a, b, c] {
assert!(
p.x >= 49.0,
"control point {p:?} leaked from the first glyph"
);
}
}
#[test]
fn a_line_before_any_move_is_discarded() {
let (page, _) = build(b"5 5 l 10 10 l S");
assert!(page.objects.is_empty());
}
#[test]
fn consecutive_moves_collapse_to_the_last() {
let (page, _) = build(b"1 1 m 2 2 m 3 3 m 9 9 l S");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
let start = path.object.path.elements().first().copied();
assert!(
matches!(start, Some(kurbo::PathEl::MoveTo(p)) if (p.x - 3.0).abs() < 1e-6),
"got {start:?}"
);
}
#[test]
fn a_single_point_paints_nothing_unless_the_cap_is_round() {
let (page, _) = build(b"5 5 m h S");
assert!(page.objects.is_empty());
let (page, _) = build(b"1 J 5 5 m h S");
assert_eq!(page.objects.len(), 1);
}
#[test]
fn a_single_point_with_a_pending_clip_blanks_everything() {
let (page, _) = build(b"5 5 m W n 0 0 10 10 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
let bounds = path.state.clip.bounds().expect("an empty clip");
assert!(bounds.area() < 1e-6, "got {bounds:?}");
}
#[test]
fn q_and_restore_round_trip_the_state() {
let (page, _) = build(b"q 5 w 1 0 0 rg Q 0 0 10 10 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert!((path.state.stroke_params.width - 1.0).abs() < 1e-6);
assert_eq!(&path.state.fill.components[..], &[0.0]);
}
#[test]
fn an_unbalanced_restore_is_harmless() {
let (page, diags) = build(b"Q Q 0 0 10 10 re f");
assert_eq!(page.objects.len(), 1);
assert!(diags.contains(&DiagKind::UnbalancedRestore));
}
#[test]
fn cm_pre_concatenates() {
let (page, _) = build(b"2 0 0 2 0 0 cm 1 0 0 1 10 0 cm 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
let origin = path.object.matrix * Point::ZERO;
assert!((origin.x - 20.0).abs() < 1e-6, "got {origin:?}");
}
#[test]
fn tz_is_stored_as_a_fraction() {
let (page, _) = build(b"150 Tz 0 0 10 10 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert!((path.state.text.horz_scale - 1.5).abs() < 1e-6);
}
#[test]
fn td_sets_the_leading_to_the_negated_offset() {
let (page, _) = build(b"BT 0 -14 TD ET 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert!((path.state.text.leading - 14.0).abs() < 1e-6);
}
fn first_text_state(page: &crate::page::Page) -> &crate::state::TextState {
let PageObject::Text(text) = &page.objects[0] else {
panic!("expected text, got {:?}", page.objects[0]);
};
&text.state.text
}
#[test]
fn a_stroked_tj_under_a_scaling_ctm_records_the_transposed_linear_part() {
let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
assert_eq!(first_text_state(&page).stroke_ctm, [2.0, 0.0, 0.0, 3.0]);
assert_eq!(
first_text_state(&page).render_mode,
crate::ops::TextRenderMode::Stroke
);
let (page, _) = build(b"1 2 3 4 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 3.0, 2.0, 4.0]);
}
#[test]
fn a_filled_tj_under_a_scaling_ctm_keeps_the_identity_stroke_ctm() {
let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 0 Tr (x) Tj ET");
assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(
first_text_state(&page).render_mode,
crate::ops::TextRenderMode::Fill
);
}
#[test]
fn an_out_of_range_text_render_mode_leaves_the_mode_alone() {
let (page, diags) = build(b"2 Tr 9 Tr 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert_eq!(
path.state.text.render_mode,
crate::ops::TextRenderMode::FillStroke,
"the 9 should have been refused"
);
assert!(diags.contains(&DiagKind::BadTextRenderMode));
}
#[test]
fn a_colorspace_operator_resets_the_colour_to_the_default() {
let (page, _) = build(b"1 0 0 rg /DeviceGray cs 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert_eq!(&path.state.fill.components[..], &[0.0]);
assert_eq!(
path.state.fill.space.as_deref(),
Some(&ColorSpace::DeviceGray)
);
}
#[test]
fn too_few_colour_operands_leave_the_colour_standing() {
let (page, _) = build(b"0 0 1 rg /DeviceCMYK cs 0.5 0.5 sc 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert_eq!(&path.state.fill.components[..], &[0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn marked_content_is_snapshotted_onto_each_object() {
let (page, _) = build(b"/Span BMC 0 0 1 1 re f EMC 0 0 1 1 re f");
assert_eq!(page.objects.len(), 2);
assert_eq!(page.objects[0].marks().len(), 1);
assert_eq!(page.objects[1].marks().len(), 0);
}
#[test]
fn an_unbalanced_emc_is_harmless() {
let (page, diags) = build(b"EMC EMC 0 0 1 1 re f");
assert_eq!(page.objects.len(), 1);
assert!(diags.contains(&DiagKind::UnbalancedMarkedContent));
}
#[test]
fn b_star_appends_its_closing_segment_unconditionally() {
let (with_b, _) = build(b"0 0 m 10 0 l 0 0 l b");
let (with_b_star, _) = build(b"0 0 m 10 0 l 0 0 l b*");
let PageObject::Path(a) = &with_b.objects[0] else {
panic!("expected a path");
};
let PageObject::Path(b) = &with_b_star.objects[0] else {
panic!("expected a path");
};
assert!(
b.object.path.elements().len() >= a.object.path.elements().len(),
"b* should not produce fewer elements than b"
);
}
#[test]
fn the_form_guard_allows_forty_one_and_refuses_the_forty_second() {
assert_eq!(MAX_FORM_LEVEL, 40);
let ctx = BuildContext::new();
assert_eq!(ctx.forms_in_flight(), 0);
}
#[test]
fn a_dash_operand_that_is_not_an_array_is_a_no_op() {
let (page, _) = build(b"[3 3] 0 d 5 0 d 0 0 1 1 re f");
let PageObject::Path(path) = &page.objects[0] else {
panic!("expected a path");
};
assert_eq!(&path.state.stroke_params.dash[..], &[3.0, 3.0]);
}
#[test]
fn the_default_state_is_what_a_page_starts_with() {
let state = GraphicsState::default();
assert_eq!(state.ctm, Affine::IDENTITY);
assert_eq!(state.stroke_params.cap, LineCap::Butt);
}
#[test]
fn an_appearance_is_not_a_live_edit_unless_it_is_built_as_one() {
let stream = pdfrum_object::Stream::new(
pdfrum_object::Dict::new(),
pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
);
let build = |live_edit| {
let mut ctx = BuildContext::default();
let mut diags = Diagnostics::default();
let object = super::build_form_object_with(
&stream,
Affine::IDENTITY,
&Resources::default(),
&NoResolve,
&mut ctx,
&Limits::default(),
&mut diags,
live_edit,
)
.expect("a form");
let PageObject::Form(form) = object else {
panic!("expected a form");
};
form.object.live_edit
};
assert!(!build(false));
assert!(build(true));
}
#[test]
fn the_plain_entry_point_never_marks_a_live_edit() {
let stream = pdfrum_object::Stream::new(
pdfrum_object::Dict::new(),
pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
);
let mut ctx = BuildContext::default();
let mut diags = Diagnostics::default();
let object = super::build_form_object(
&stream,
Affine::IDENTITY,
&Resources::default(),
&NoResolve,
&mut ctx,
&Limits::default(),
&mut diags,
)
.expect("a form");
let PageObject::Form(form) = object else {
panic!("expected a form");
};
assert!(!form.object.live_edit);
}
}