use pdfboss_core::FastMap;
use std::sync::Arc;
use pdfboss_core::content::{parse_content, ImageParams, Op, TextItem};
use pdfboss_core::geom::{Matrix, Point};
use pdfboss_core::{
block_on, page_content_with, AsyncObjectSource, Dict, Document, Error, Immediate, Name, Object,
Page, Result, Stream,
};
use crate::color::{self, ColorSpace};
use crate::glyph::GlyphFont;
use crate::image::{self, DrawParams};
use crate::path::{PathBuilder, Subpath};
use crate::raster::{fill_path, FillRule, Mask};
use crate::stroke::stroke_path;
#[cfg(feature = "substitute-fonts")]
use crate::substitute::BuiltinProvider;
use crate::substitute::{DirProvider, SubstituteProvider};
use crate::type3::Type3Font;
use crate::{
GlyphPainting, Pixmap, RenderOptions, RenderReport, SkipReason, SkippedKind, SubstituteSource,
};
const MAX_GSTATE_DEPTH: usize = 64;
const MAX_FORM_DEPTH: u32 = 16;
const MAX_SIDE: f32 = 16384.0;
const MAX_CLIP_CACHE: usize = 256;
#[derive(PartialEq, Eq, Hash, Clone)]
struct ClipKey {
even_odd: bool,
subpaths: Vec<(bool, Vec<(u32, u32)>)>,
}
impl ClipKey {
fn new(polys: &[Subpath], rule: FillRule) -> ClipKey {
ClipKey {
even_odd: rule == FillRule::EvenOdd,
subpaths: polys
.iter()
.map(|s| {
(
s.closed,
s.points
.iter()
.map(|p| (p.x.to_bits(), p.y.to_bits()))
.collect(),
)
})
.collect(),
}
}
}
#[derive(Debug, Clone)]
struct GState {
ctm: Matrix,
fill_space: ColorSpace,
stroke_space: ColorSpace,
fill_rgb: [f32; 3],
stroke_rgb: [f32; 3],
fill_pattern: bool,
stroke_pattern: bool,
line_width: f32,
#[allow(dead_code)]
line_cap: i32,
#[allow(dead_code)]
line_join: i32,
#[allow(dead_code)]
miter_limit: f32,
dash: Vec<f32>,
dash_phase: f32,
fill_alpha: f32,
stroke_alpha: f32,
clip: Option<Arc<Mask>>,
}
impl GState {
fn new(ctm: Matrix) -> GState {
GState {
ctm,
fill_space: ColorSpace::DeviceGray,
stroke_space: ColorSpace::DeviceGray,
fill_rgb: [0.0; 3],
stroke_rgb: [0.0; 3],
fill_pattern: false,
stroke_pattern: false,
line_width: 1.0,
line_cap: 0,
line_join: 0,
miter_limit: 10.0,
dash: Vec::new(),
dash_phase: 0.0,
fill_alpha: 1.0,
stroke_alpha: 1.0,
clip: None,
}
}
fn fill_rgba8(&self) -> [u8; 4] {
rgba8(if self.fill_pattern {
[0.5; 3]
} else {
self.fill_rgb
})
}
fn stroke_rgba8(&self) -> [u8; 4] {
rgba8(if self.stroke_pattern {
[0.5; 3]
} else {
self.stroke_rgb
})
}
}
struct TextState {
tm: Matrix,
tlm: Matrix,
font: Option<Arc<GlyphFont>>,
type3: Option<Arc<Type3Font>>,
size: f32,
char_spacing: f32,
word_spacing: f32,
horiz: f32,
leading: f32,
rise: f32,
}
impl Default for TextState {
fn default() -> TextState {
TextState {
tm: Matrix::identity(),
tlm: Matrix::identity(),
font: None,
type3: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
horiz: 1.0,
leading: 0.0,
rise: 0.0,
}
}
}
fn rgba8(rgb: [f32; 3]) -> [u8; 4] {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
[q(rgb[0]), q(rgb[1]), q(rgb[2]), 255]
}
fn ctm_scale(m: Matrix) -> f32 {
let det = (m.a * m.d - m.b * m.c).abs();
if det.is_finite() && det > 0.0 {
det.sqrt()
} else {
1.0
}
}
fn all_finite(vals: &[f32]) -> bool {
vals.iter().all(|v| v.is_finite())
}
fn finite_matrix(m: &Matrix) -> bool {
all_finite(&[m.a, m.b, m.c, m.d, m.e, m.f])
}
fn base_ctm(crop: pdfboss_core::Rect, rotate: i32, scale: f32) -> Matrix {
let (cw, ch) = (crop.width(), crop.height());
let spin = match rotate {
90 => Matrix {
a: 0.0,
b: -1.0,
c: 1.0,
d: 0.0,
e: 0.0,
f: cw,
},
180 => Matrix {
a: -1.0,
b: 0.0,
c: 0.0,
d: -1.0,
e: cw,
f: ch,
},
270 => Matrix {
a: 0.0,
b: 1.0,
c: -1.0,
d: 0.0,
e: ch,
f: 0.0,
},
_ => Matrix::identity(),
};
let disp_h = if rotate == 90 || rotate == 270 {
cw
} else {
ch
};
let flip = Matrix {
a: scale,
b: 0.0,
c: 0.0,
d: -scale,
e: 0.0,
f: disp_h * scale,
};
Matrix::translate(-crop.x0, -crop.y0)
.concat(spin)
.concat(flip)
}
pub(crate) fn render_page_reporting(
doc: &Document,
page: &Page,
scale: f32,
opts: &RenderOptions,
) -> Result<(Pixmap, RenderReport)> {
block_on(render_page_reporting_with(
Immediate(doc),
page,
scale,
opts,
))
}
pub(crate) async fn render_page_reporting_with<S: AsyncObjectSource>(
src: S,
page: &Page,
scale: f32,
opts: &RenderOptions,
) -> Result<(Pixmap, RenderReport)> {
let scale = if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
};
let (w_pt, h_pt) = page.size();
let pw = (w_pt * scale).ceil().clamp(1.0, MAX_SIDE) as u32;
let ph = (h_pt * scale).ceil().clamp(1.0, MAX_SIDE) as u32;
let mut pix = Pixmap::new(pw, ph);
pix.fill([255, 255, 255, 255]);
let mut report = RenderReport::default();
let content = match page_content_with(&src, page).await {
Ok(content) => content,
Err(e) => {
report.record(SkippedKind::PageContents, skip_reason_for(&e));
Vec::new()
}
};
let ops = match parse_content(&content) {
Ok(ops) => ops,
Err(e) => {
report.record(SkippedKind::PageContents, skip_reason_for(&e));
Vec::new()
}
};
record_annotations(&src, page, &mut report).await;
let ctm = base_ctm(page.crop_box.normalize(), page.rotate, scale);
let provider: Option<Box<dyn SubstituteProvider>> = match &opts.substitutes {
SubstituteSource::Dir(dir) => Some(Box::new(DirProvider { dir: dir.clone() })),
#[cfg(feature = "substitute-fonts")]
SubstituteSource::Builtin => Some(Box::new(BuiltinProvider)),
#[cfg(not(feature = "substitute-fonts"))]
SubstituteSource::Builtin => None,
SubstituteSource::None => None,
};
let mut exec = Executor {
src: &src,
pix,
painting: opts.glyph_painting,
color_locked: false,
provider,
glyph_blit: Vec::new(),
clip_cache: FastMap::default(),
charproc_cache: FastMap::default(),
report,
};
let root = Frame::new(
ops.into(),
vec![Arc::new(page.resources.clone())],
GState::new(ctm),
0,
FrameKind::PageOrForm,
);
exec.run(root).await;
Ok((exec.pix, exec.report))
}
async fn record_annotations<S: AsyncObjectSource>(src: &S, page: &Page, report: &mut RenderReport) {
const INVISIBLE: i64 = (1 << 1) | (1 << 5);
let Some(annots) = page.dict().get("Annots") else {
return;
};
let Ok(Object::Array(items)) = src.resolve(annots).await else {
return;
};
for item in &items {
let Ok(resolved) = src.resolve(item).await else {
continue;
};
let Some(dict) = resolved.as_dict() else {
continue;
};
if dict.get("AP").is_none() || dict.get_int("F").unwrap_or(0) & INVISIBLE != 0 {
continue;
}
report.record(SkippedKind::Annotation, SkipReason::Unsupported);
}
}
const MAX_CHARPROC_CACHE: usize = 1024;
struct Executor<'a, S> {
src: &'a S,
pix: Pixmap,
painting: GlyphPainting,
color_locked: bool,
provider: Option<Box<dyn SubstituteProvider>>,
glyph_blit: Vec<Subpath>,
clip_cache: FastMap<ClipKey, Arc<Mask>>,
charproc_cache: FastMap<pdfboss_core::ObjRef, Arc<[Op]>>,
report: RenderReport,
}
struct Frame {
ops: Arc<[Op]>,
chain: Vec<Arc<Dict>>,
pc: usize,
depth: u32,
gs: GState,
saved: Vec<GState>,
path: Option<PathBuilder>,
pending_clip: Option<FillRule>,
ts: TextState,
fonts: FastMap<String, Option<Arc<GlyphFont>>>,
pending_glyphs: std::collections::VecDeque<Type3Glyph>,
pending_t3: Option<Arc<Type3Font>>,
kind: FrameKind,
}
enum FrameKind {
PageOrForm,
CharProc { saved_lock: bool },
}
impl Frame {
fn new(
ops: Arc<[Op]>,
chain: Vec<Arc<Dict>>,
gs: GState,
depth: u32,
kind: FrameKind,
) -> Frame {
Frame {
ops,
chain,
pc: 0,
depth,
gs,
saved: Vec::new(),
path: None,
pending_clip: None,
ts: TextState::default(),
fonts: FastMap::default(),
pending_glyphs: std::collections::VecDeque::new(),
pending_t3: None,
kind,
}
}
}
impl<S: AsyncObjectSource> Executor<'_, S> {
async fn run(&mut self, root: Frame) {
let mut frames = vec![root];
'frames: while let Some(mut frame) = frames.pop() {
while let Some(glyph) = frame.pending_glyphs.pop_front() {
let Some(t3) = frame.pending_t3.clone() else {
break;
};
let child = self.char_proc_frame(&glyph, &t3, &frame).await;
if let Some(child) = child {
frames.push(frame);
frames.push(child);
continue 'frames;
}
}
frame.pending_t3 = None;
let ops = Arc::clone(&frame.ops);
let mut spawned: Option<Frame> = None;
'ops: while frame.pc < ops.len() {
let op = &ops[frame.pc];
frame.pc += 1;
let frame = &mut frame;
match op {
Op::Save => {
if frame.saved.len() < MAX_GSTATE_DEPTH {
frame.saved.push(frame.gs.clone());
}
}
Op::Restore => {
if let Some(prev) = frame.saved.pop() {
frame.gs = prev;
}
}
Op::Concat(m) => {
if finite_matrix(m) {
frame.gs.ctm = m.concat(frame.gs.ctm);
}
}
Op::SetLineWidth(w) => {
if w.is_finite() && *w >= 0.0 {
frame.gs.line_width = *w;
}
}
Op::SetLineCap(c) => frame.gs.line_cap = *c,
Op::SetLineJoin(j) => frame.gs.line_join = *j,
Op::SetMiterLimit(m) => {
if m.is_finite() {
frame.gs.miter_limit = *m;
}
}
Op::SetDash(d, phase) => {
if all_finite(d) && phase.is_finite() {
frame.gs.dash = d.clone();
frame.gs.dash_phase = *phase;
}
}
Op::SetExtGState(name) => self.apply_ext_gstate_op(name, frame).await,
Op::SetRenderingIntent(_) | Op::SetFlatness(_) => {}
Op::MoveTo(x, y) => {
if all_finite(&[*x, *y]) {
builder(&mut frame.path, &frame.gs).move_to(*x, *y);
}
}
Op::LineTo(x, y) => {
if all_finite(&[*x, *y]) {
builder(&mut frame.path, &frame.gs).line_to(*x, *y);
}
}
Op::CurveTo(x1, y1, x2, y2, x3, y3) => {
if all_finite(&[*x1, *y1, *x2, *y2, *x3, *y3]) {
builder(&mut frame.path, &frame.gs)
.curve_to(*x1, *y1, *x2, *y2, *x3, *y3);
}
}
Op::CurveToV(x2, y2, x3, y3) => {
if all_finite(&[*x2, *y2, *x3, *y3]) {
builder(&mut frame.path, &frame.gs).curve_to_v(*x2, *y2, *x3, *y3);
}
}
Op::CurveToY(x1, y1, x3, y3) => {
if all_finite(&[*x1, *y1, *x3, *y3]) {
builder(&mut frame.path, &frame.gs).curve_to_y(*x1, *y1, *x3, *y3);
}
}
Op::ClosePath => {
if let Some(pb) = frame.path.as_mut() {
pb.close();
}
}
Op::Rect(x, y, w, h) => {
if all_finite(&[*x, *y, *w, *h]) {
builder(&mut frame.path, &frame.gs).rect(*x, *y, *w, *h);
}
}
Op::Stroke => self.paint_frame(frame, PAINT_STROKE),
Op::CloseStroke => self.paint_frame(
frame,
Paint {
close: true,
..PAINT_STROKE
},
),
Op::Fill => self.paint_frame(frame, PAINT_FILL),
Op::FillEvenOdd => self.paint_frame(frame, PAINT_FILL_EO),
Op::FillStroke => self.paint_frame(frame, PAINT_BOTH),
Op::FillStrokeEvenOdd => self.paint_frame(frame, PAINT_BOTH_EO),
Op::CloseFillStroke => self.paint_frame(
frame,
Paint {
close: true,
..PAINT_BOTH
},
),
Op::CloseFillStrokeEvenOdd => self.paint_frame(
frame,
Paint {
close: true,
..PAINT_BOTH_EO
},
),
Op::EndPath => self.paint_frame(frame, PAINT_NONE),
Op::ClipNonZero => frame.pending_clip = Some(FillRule::NonZero),
Op::ClipEvenOdd => frame.pending_clip = Some(FillRule::EvenOdd),
Op::BeginText => {
frame.ts.tm = Matrix::identity();
frame.ts.tlm = Matrix::identity();
}
Op::SetCharSpacing(v) if v.is_finite() => frame.ts.char_spacing = *v,
Op::SetWordSpacing(v) if v.is_finite() => frame.ts.word_spacing = *v,
Op::SetHorizScaling(v) if v.is_finite() => frame.ts.horiz = v / 100.0,
Op::SetLeading(v) if v.is_finite() => frame.ts.leading = *v,
Op::SetTextRise(v) if v.is_finite() => frame.ts.rise = *v,
Op::SetFont(name, size) => {
frame.ts.size = if size.is_finite() { *size } else { 0.0 };
frame.ts.font = self
.glyph_font(&name.0, &frame.chain, &mut frame.fonts)
.await;
frame.ts.type3 = if frame.ts.font.is_some() {
None
} else {
self.type3_font(&name.0, &frame.chain).await
};
}
Op::SetTextMatrix(m) if finite_matrix(m) => {
frame.ts.tm = *m;
frame.ts.tlm = *m;
}
Op::TextMove(tx, ty) if all_finite(&[*tx, *ty]) => {
frame.ts.tlm = Matrix::translate(*tx, *ty).concat(frame.ts.tlm);
frame.ts.tm = frame.ts.tlm;
}
Op::TextMoveSetLeading(tx, ty) if all_finite(&[*tx, *ty]) => {
frame.ts.leading = -*ty;
frame.ts.tlm = Matrix::translate(*tx, *ty).concat(frame.ts.tlm);
frame.ts.tm = frame.ts.tlm;
}
Op::TextNextLine => {
frame.ts.tlm =
Matrix::translate(0.0, -frame.ts.leading).concat(frame.ts.tlm);
frame.ts.tm = frame.ts.tlm;
}
Op::ShowText(s) => {
self.show_text(frame, s);
if !frame.pending_glyphs.is_empty() {
break 'ops;
}
}
Op::ShowTextAdjusted(items) => {
for item in items {
match item {
TextItem::Str(s) => self.show_text(frame, s),
TextItem::Offset(n) => {
let tx = -n / 1000.0 * frame.ts.size * frame.ts.horiz;
if tx.is_finite() {
frame.ts.tm =
Matrix::translate(tx, 0.0).concat(frame.ts.tm);
}
}
}
}
}
Op::NextLineShowText(s) => {
frame.ts.tlm =
Matrix::translate(0.0, -frame.ts.leading).concat(frame.ts.tlm);
frame.ts.tm = frame.ts.tlm;
self.show_text(frame, s);
}
Op::NextLineShowTextSpaced(aw, ac, s) => {
if aw.is_finite() {
frame.ts.word_spacing = *aw;
}
if ac.is_finite() {
frame.ts.char_spacing = *ac;
}
frame.ts.tlm =
Matrix::translate(0.0, -frame.ts.leading).concat(frame.ts.tlm);
frame.ts.tm = frame.ts.tlm;
self.show_text(frame, s);
}
other => {
spawned = self.run_color_or_misc(other, frame).await;
}
}
if spawned.is_some() || !frame.pending_glyphs.is_empty() {
break;
}
}
if let Some(child) = spawned {
frames.push(frame);
frames.push(child);
continue 'frames;
}
if !frame.pending_glyphs.is_empty() {
frames.push(frame);
continue 'frames;
}
if let FrameKind::CharProc { saved_lock } = frame.kind {
self.color_locked = saved_lock;
}
}
}
}
fn builder<'p>(path: &'p mut Option<PathBuilder>, gs: &GState) -> &'p mut PathBuilder {
path.get_or_insert_with(|| PathBuilder::new(gs.ctm))
}
#[derive(Clone, Copy)]
struct Paint {
close: bool,
fill: Option<FillRule>,
stroke: bool,
}
const PAINT_NONE: Paint = Paint {
close: false,
fill: None,
stroke: false,
};
const PAINT_STROKE: Paint = Paint {
stroke: true,
..PAINT_NONE
};
const PAINT_FILL: Paint = Paint {
fill: Some(FillRule::NonZero),
..PAINT_NONE
};
const PAINT_FILL_EO: Paint = Paint {
fill: Some(FillRule::EvenOdd),
..PAINT_NONE
};
const PAINT_BOTH: Paint = Paint {
stroke: true,
..PAINT_FILL
};
const PAINT_BOTH_EO: Paint = Paint {
stroke: true,
..PAINT_FILL_EO
};
impl<S: AsyncObjectSource> Executor<'_, S> {
fn paint_frame(&mut self, frame: &mut Frame, how: Paint) {
let Frame {
gs,
path,
pending_clip,
..
} = frame;
self.paint(gs, path, pending_clip, how);
}
fn paint(
&mut self,
gs: &mut GState,
path: &mut Option<PathBuilder>,
pending: &mut Option<FillRule>,
how: Paint,
) {
let polys = match path.take() {
Some(mut pb) => {
if how.close {
pb.close();
}
pb.finish()
}
None => Vec::new(),
};
if !polys.is_empty()
&& ((how.fill.is_some() && gs.fill_pattern) || (how.stroke && gs.stroke_pattern))
{
self.skip(SkippedKind::Pattern, SkipReason::Unsupported);
}
if let Some(rule) = how.fill {
fill_path(
&mut self.pix,
&polys,
rule,
gs.fill_rgba8(),
gs.fill_alpha,
gs.clip.as_deref(),
);
}
if how.stroke {
let s = ctm_scale(gs.ctm);
let dash: Vec<f32> = gs.dash.iter().map(|d| d * s).collect();
let quads = stroke_path(&polys, gs.line_width * s, &dash, gs.dash_phase * s);
fill_path(
&mut self.pix,
&quads,
FillRule::NonZero,
gs.stroke_rgba8(),
gs.stroke_alpha,
gs.clip.as_deref(),
);
}
if let Some(rule) = pending.take() {
let rasterized = self.rasterize_clip(&polys, rule);
gs.clip = Some(match &gs.clip {
Some(old) => Arc::new(Mask::intersected(&rasterized, old)),
None => rasterized,
});
}
}
fn rasterize_clip(&mut self, polys: &[Subpath], rule: FillRule) -> Arc<Mask> {
let key = ClipKey::new(polys, rule);
if let Some(cached) = self.clip_cache.get(&key) {
return Arc::clone(cached);
}
let mask = Arc::new(Mask::from_path(
self.pix.width,
self.pix.height,
polys,
rule,
));
if self.clip_cache.len() < MAX_CLIP_CACHE {
self.clip_cache.insert(key, Arc::clone(&mask));
}
mask
}
async fn glyph_font(
&self,
name: &str,
chain: &[Arc<Dict>],
cache: &mut FastMap<String, Option<Arc<GlyphFont>>>,
) -> Option<Arc<GlyphFont>> {
if let Some(f) = cache.get(name) {
return f.clone();
}
let dict = self
.find_res(chain, "Font", name)
.await
.and_then(|o| o.as_dict().cloned());
let loaded = match dict {
Some(d) => GlyphFont::load_with(self.src, &d, self.painting, self.provider.as_deref())
.await
.map(Arc::new),
None => None,
};
cache.insert(name.to_string(), loaded.clone());
loaded
}
async fn type3_font(&self, name: &str, chain: &[Arc<Dict>]) -> Option<Arc<Type3Font>> {
if !self.painting.paints_all_embedded() {
return None;
}
let dict = self
.find_res(chain, "Font", name)
.await
.and_then(|o| o.as_dict().cloned())?;
if dict.get_name("Subtype").map(|n| n.0.as_str()) != Some("Type3") {
return None;
}
Type3Font::load_with(self.src, &dict).await.map(Arc::new)
}
fn blit_glyph(&mut self, cached: &[Subpath], dx: f32, dy: f32, fill: [u8; 4], gs: &GState) {
for (i, src) in cached.iter().enumerate() {
if i == self.glyph_blit.len() {
self.glyph_blit.push(Subpath {
points: Vec::new(),
closed: src.closed,
});
}
let dst = &mut self.glyph_blit[i];
dst.points.clear();
dst.points
.extend(src.points.iter().map(|p| Point::new(p.x + dx, p.y + dy)));
dst.closed = src.closed;
}
fill_path(
&mut self.pix,
&self.glyph_blit[..cached.len()],
FillRule::NonZero,
fill,
gs.fill_alpha,
gs.clip.as_deref(),
);
}
fn show_text(&mut self, frame: &mut Frame, bytes: &[u8]) {
if let Some(t3) = frame.ts.type3.clone() {
let paint = frame.depth < MAX_FORM_DEPTH;
let planned = type3_glyph_plan(&mut frame.ts, &t3, bytes, frame.gs.ctm, paint);
frame.pending_glyphs.extend(planned);
frame.pending_t3 = Some(t3);
return;
}
let gs = &frame.gs;
let ts = &mut frame.ts;
let Some(font) = ts.font.clone() else {
return;
};
let upm = font.units_per_em();
let two_byte = font.two_byte();
let fill = gs.fill_rgba8();
let mut i = 0;
while i < bytes.len() {
let (code, n) = if two_byte && i + 1 < bytes.len() {
(u32::from(u16::from_be_bytes([bytes[i], bytes[i + 1]])), 2)
} else {
(u32::from(bytes[i]), 1)
};
i += n;
let gid = font.gid(code);
let params = Matrix {
a: ts.size * ts.horiz,
b: 0.0,
c: 0.0,
d: ts.size,
e: 0.0,
f: ts.rise,
};
let to_device = Matrix::scale(1.0 / upm, 1.0 / upm)
.concat(params)
.concat(ts.tm)
.concat(gs.ctm);
if gid != 0 && finite_matrix(&to_device) {
let linear = Matrix {
a: to_device.a,
b: to_device.b,
c: to_device.c,
d: to_device.d,
e: 0.0,
f: 0.0,
};
let polys = font.flattened(gid, linear);
if !polys.is_empty() {
self.blit_glyph(&polys, to_device.e, to_device.f, fill, gs);
}
}
let w0 = font.advance(code) / upm;
let word = if n == 1 && code == 32 {
ts.word_spacing
} else {
0.0
};
let tx = (w0 * ts.size + ts.char_spacing + word) * ts.horiz;
if tx.is_finite() {
ts.tm = Matrix::translate(tx, 0.0).concat(ts.tm);
}
}
}
async fn char_proc_frame(
&mut self,
glyph: &Type3Glyph,
t3: &Type3Font,
parent: &Frame,
) -> Option<Frame> {
let cached = match &glyph.proc_obj {
Object::Ref(r) => self.charproc_cache.get(r).cloned(),
_ => None,
};
let ops: Arc<[Op]> = match cached {
Some(ops) => ops,
None => {
let Ok(Object::Stream(stream)) = self.src.resolve(&glyph.proc_obj).await else {
return None;
};
let Ok(data) = self.src.stream_data(&stream).await else {
return None;
};
let Ok(ops) = parse_content(&data) else {
return None;
};
let ops: Arc<[Op]> = ops.into();
if let Object::Ref(r) = &glyph.proc_obj {
if self.charproc_cache.len() < MAX_CHARPROC_CACHE {
self.charproc_cache.insert(*r, Arc::clone(&ops));
}
}
ops
}
};
let mut inner = parent.gs.clone();
inner.ctm = glyph.ctm;
let mut inner_chain: Vec<Arc<Dict>> = Vec::with_capacity(parent.chain.len() + 1);
if let Some(d) = t3.resources() {
inner_chain.push(Arc::clone(d));
}
inner_chain.extend_from_slice(&parent.chain);
let is_d1 = matches!(ops.first(), Some(Op::SetGlyphWidthBBox(..)));
let saved_lock = self.color_locked;
self.color_locked = is_d1;
Some(Frame::new(
ops,
inner_chain,
inner,
parent.depth + 1,
FrameKind::CharProc { saved_lock },
))
}
async fn run_color_or_misc(&mut self, op: &Op, frame: &mut Frame) -> Option<Frame> {
if self.color_locked {
match op {
Op::SetFillColorSpace(_)
| Op::SetStrokeColorSpace(_)
| Op::SetFillColor(_)
| Op::SetStrokeColor(_)
| Op::SetFillColorN(_, _)
| Op::SetStrokeColorN(_, _)
| Op::SetFillGray(_)
| Op::SetStrokeGray(_)
| Op::SetFillRGB(_, _, _)
| Op::SetStrokeRGB(_, _, _)
| Op::SetFillCMYK(_, _, _, _)
| Op::SetStrokeCMYK(_, _, _, _) => return None,
_ => {}
}
}
match op {
Op::SetFillColorSpace(name) => {
let (cs, pattern) = self.resolve_colorspace(name, &frame.chain).await;
let gs = &mut frame.gs;
gs.fill_rgb = initial_color(&cs);
gs.fill_space = cs;
gs.fill_pattern = pattern;
}
Op::SetStrokeColorSpace(name) => {
let (cs, pattern) = self.resolve_colorspace(name, &frame.chain).await;
let gs = &mut frame.gs;
gs.stroke_rgb = initial_color(&cs);
gs.stroke_space = cs;
gs.stroke_pattern = pattern;
}
Op::SetFillColor(c) => frame.gs.fill_rgb = frame.gs.fill_space.to_rgb(c),
Op::SetStrokeColor(c) => frame.gs.stroke_rgb = frame.gs.stroke_space.to_rgb(c),
Op::SetFillColorN(c, pattern_name) => {
let gs = &mut frame.gs;
if pattern_name.is_some() {
gs.fill_pattern = true;
} else if !gs.fill_pattern {
gs.fill_rgb = gs.fill_space.to_rgb(c);
}
}
Op::SetStrokeColorN(c, pattern_name) => {
let gs = &mut frame.gs;
if pattern_name.is_some() {
gs.stroke_pattern = true;
} else if !gs.stroke_pattern {
gs.stroke_rgb = gs.stroke_space.to_rgb(c);
}
}
Op::SetFillGray(g) => {
let gs = &mut frame.gs;
gs.fill_space = ColorSpace::DeviceGray;
gs.fill_pattern = false;
gs.fill_rgb = ColorSpace::DeviceGray.to_rgb(&[*g]);
}
Op::SetStrokeGray(g) => {
let gs = &mut frame.gs;
gs.stroke_space = ColorSpace::DeviceGray;
gs.stroke_pattern = false;
gs.stroke_rgb = ColorSpace::DeviceGray.to_rgb(&[*g]);
}
Op::SetFillRGB(r, g, b) => {
let gs = &mut frame.gs;
gs.fill_space = ColorSpace::DeviceRGB;
gs.fill_pattern = false;
gs.fill_rgb = ColorSpace::DeviceRGB.to_rgb(&[*r, *g, *b]);
}
Op::SetStrokeRGB(r, g, b) => {
let gs = &mut frame.gs;
gs.stroke_space = ColorSpace::DeviceRGB;
gs.stroke_pattern = false;
gs.stroke_rgb = ColorSpace::DeviceRGB.to_rgb(&[*r, *g, *b]);
}
Op::SetFillCMYK(c, m, y, k) => {
let gs = &mut frame.gs;
gs.fill_space = ColorSpace::DeviceCMYK;
gs.fill_pattern = false;
gs.fill_rgb = ColorSpace::DeviceCMYK.to_rgb(&[*c, *m, *y, *k]);
}
Op::SetStrokeCMYK(c, m, y, k) => {
let gs = &mut frame.gs;
gs.stroke_space = ColorSpace::DeviceCMYK;
gs.stroke_pattern = false;
gs.stroke_rgb = ColorSpace::DeviceCMYK.to_rgb(&[*c, *m, *y, *k]);
}
Op::XObject(name) => {
return self
.do_xobject(name, &frame.chain, &frame.gs, frame.depth)
.await;
}
Op::InlineImage(img) => {
self.draw_inline_image(img, &frame.chain, &frame.gs).await;
}
Op::Shading(_) => self.skip(SkippedKind::Shading, SkipReason::Unsupported),
_ => {}
}
None
}
}
struct Type3Glyph {
proc_obj: Object,
ctm: Matrix,
}
fn type3_glyph_plan(
ts: &mut TextState,
t3: &Type3Font,
bytes: &[u8],
ctm: Matrix,
paint: bool,
) -> Vec<Type3Glyph> {
let font_matrix = t3.font_matrix();
let mut planned = Vec::new();
for &byte in bytes {
let code = u32::from(byte);
let params = Matrix {
a: ts.size * ts.horiz,
b: 0.0,
c: 0.0,
d: ts.size,
e: 0.0,
f: ts.rise,
};
let glyph_ctm = font_matrix.concat(params).concat(ts.tm).concat(ctm);
if paint && finite_matrix(&glyph_ctm) {
if let Some(proc_obj) = t3.char_proc(code).cloned() {
planned.push(Type3Glyph {
proc_obj,
ctm: glyph_ctm,
});
}
}
let w0 = t3.width(code).unwrap_or(0.0) * font_matrix.a;
let word = if code == 32 { ts.word_spacing } else { 0.0 };
let tx = (w0 * ts.size + ts.char_spacing + word) * ts.horiz;
if tx.is_finite() {
ts.tm = Matrix::translate(tx, 0.0).concat(ts.tm);
}
}
planned
}
fn initial_color(cs: &ColorSpace) -> [f32; 3] {
match cs {
ColorSpace::DeviceCMYK => cs.to_rgb(&[0.0, 0.0, 0.0, 1.0]),
ColorSpace::Other(_) => cs.to_rgb(&[1.0; 8]),
_ => cs.to_rgb(&[0.0, 0.0, 0.0, 0.0]),
}
}
impl<S: AsyncObjectSource> Executor<'_, S> {
async fn find_res(&self, chain: &[Arc<Dict>], category: &str, name: &str) -> Option<Object> {
for res in chain {
let Some(cat) = res.get(category) else {
continue;
};
let Ok(Object::Dict(dict)) = self.src.resolve(cat).await else {
continue;
};
let Some(value) = dict.get(name) else {
continue;
};
if let Ok(obj) = self.src.resolve(value).await {
if !obj.is_null() {
return Some(obj);
}
}
}
None
}
async fn resolve_colorspace(&self, name: &Name, chain: &[Arc<Dict>]) -> (ColorSpace, bool) {
match name.0.as_str() {
"Pattern" => return (ColorSpace::DeviceGray, true),
"DeviceGray" | "G" | "CalGray" => return (ColorSpace::DeviceGray, false),
"DeviceRGB" | "RGB" | "CalRGB" => return (ColorSpace::DeviceRGB, false),
"DeviceCMYK" | "CMYK" => return (ColorSpace::DeviceCMYK, false),
_ => {}
}
match self.find_res(chain, "ColorSpace", &name.0).await {
Some(obj) => {
if let Object::Array(items) = &obj {
if let Some(Object::Name(n)) = items.first() {
if n.0 == "Pattern" {
return (ColorSpace::DeviceGray, true);
}
}
}
(ColorSpace::parse_with(self.src, &obj).await, false)
}
None => (ColorSpace::DeviceGray, false),
}
}
async fn apply_ext_gstate_op(&mut self, name: &Name, frame: &mut Frame) {
let Some(Object::Dict(dict)) = self.find_res(&frame.chain, "ExtGState", &name.0).await
else {
return;
};
if ignores_mask(self.src, &dict).await {
self.skip(SkippedKind::SoftMask, SkipReason::Unsupported);
}
if ignores_blend_mode(self.src, &dict).await {
self.skip(SkippedKind::BlendMode, SkipReason::Unsupported);
}
let gs = &mut frame.gs;
if let Some(ca) = dict_f32(self.src, &dict, "ca").await {
gs.fill_alpha = ca.clamp(0.0, 1.0);
}
if let Some(ca) = dict_f32(self.src, &dict, "CA").await {
gs.stroke_alpha = ca.clamp(0.0, 1.0);
}
if let Some(lw) = dict_f32(self.src, &dict, "LW").await {
if lw >= 0.0 {
gs.line_width = lw;
}
}
if let Some(lc) = dict_f32(self.src, &dict, "LC").await {
gs.line_cap = lc as i32;
}
if let Some(lj) = dict_f32(self.src, &dict, "LJ").await {
gs.line_join = lj as i32;
}
let d = match dict.get("D") {
Some(o) => self.src.resolve(o).await.ok(),
None => None,
};
if let Some(Object::Array(items)) = d {
let lens = match items.first() {
Some(o) => self.src.resolve(o).await.ok(),
None => None,
};
let phase = match items.get(1) {
Some(o) => self.src.resolve(o).await.ok().and_then(|o| o.as_f64()),
None => None,
};
if let (Some(Object::Array(lens)), Some(phase)) = (lens, phase) {
let mut dash: Vec<f32> = Vec::with_capacity(lens.len());
for o in &lens {
if let Some(v) = num_f32(self.src, o).await {
dash.push(v);
}
}
if dash.len() == lens.len() && (phase as f32).is_finite() {
gs.dash = dash;
gs.dash_phase = phase as f32;
}
}
}
}
}
async fn num_f32<S: AsyncObjectSource>(src: &S, obj: &Object) -> Option<f32> {
let v = src.resolve(obj).await.ok()?.as_f64()? as f32;
v.is_finite().then_some(v)
}
async fn dict_f32<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<f32> {
num_f32(src, dict.get(key)?).await
}
async fn floats_from<S: AsyncObjectSource>(
src: &S,
obj: Option<&Object>,
n: usize,
) -> Option<Vec<f32>> {
let arr = match src.resolve(obj?).await {
Ok(Object::Array(a)) if a.len() >= n => a,
_ => return None,
};
let mut out: Vec<f32> = Vec::with_capacity(n);
for o in arr.iter().take(n) {
if let Some(v) = num_f32(src, o).await {
out.push(v);
}
}
(out.len() == n).then_some(out)
}
fn skip_reason_for(e: &Error) -> SkipReason {
match e {
Error::UnsupportedFilter(name) => SkipReason::UnsupportedFilter(name.clone()),
other => SkipReason::DecodeFailed(other.to_string()),
}
}
async fn ignores_mask<S: AsyncObjectSource>(src: &S, dict: &Dict) -> bool {
for key in ["SMask", "Mask"] {
let Some(obj) = dict.get(key) else {
continue;
};
let ignored = match src.resolve(obj).await {
Ok(Object::Null) => false,
Ok(Object::Name(n)) => n.0 != "None",
_ => true,
};
if ignored {
return true;
}
}
false
}
async fn ignores_blend_mode<S: AsyncObjectSource>(src: &S, dict: &Dict) -> bool {
let Some(bm) = dict.get("BM") else {
return false;
};
let selected = match src.resolve(bm).await {
Ok(Object::Name(n)) => n.0,
Ok(Object::Array(items)) => {
let Some(first) = items.first() else {
return false;
};
match src.resolve(first).await {
Ok(Object::Name(n)) => n.0,
_ => return false,
}
}
_ => return false,
};
!matches!(selected.as_str(), "Normal" | "Compatible")
}
impl<S: AsyncObjectSource> Executor<'_, S> {
async fn do_xobject(
&mut self,
name: &Name,
chain: &[Arc<Dict>],
gs: &GState,
depth: u32,
) -> Option<Frame> {
let Some(Object::Stream(stream)) = self.find_res(chain, "XObject", &name.0).await else {
self.skip(SkippedKind::XObject, SkipReason::Missing);
return None;
};
match stream.dict.get_name("Subtype").map(|n| n.0.as_str()) {
Some("Image") => {
self.draw_image_xobject(&stream, chain, gs).await;
None
}
Some("Form") => self.form_frame(&stream, chain, gs, depth).await,
_ => {
self.skip(SkippedKind::XObject, SkipReason::Unsupported);
None
}
}
}
async fn form_frame(
&mut self,
stream: &Stream,
chain: &[Arc<Dict>],
gs: &GState,
depth: u32,
) -> Option<Frame> {
if depth >= MAX_FORM_DEPTH {
self.skip(SkippedKind::Form, SkipReason::LimitExceeded);
return None;
}
let data = match self.src.stream_data(stream).await {
Ok(data) => data,
Err(e) => {
self.skip(SkippedKind::Form, skip_reason_for(&e));
return None;
}
};
let ops = match parse_content(&data) {
Ok(ops) => ops,
Err(e) => {
self.skip(SkippedKind::Form, skip_reason_for(&e));
return None;
}
};
let mut inner = gs.clone();
if let Some(m) = floats_from(self.src, stream.dict.get("Matrix"), 6).await {
let matrix = Matrix {
a: m[0],
b: m[1],
c: m[2],
d: m[3],
e: m[4],
f: m[5],
};
inner.ctm = matrix.concat(inner.ctm);
}
if let Some(b) = floats_from(self.src, stream.dict.get("BBox"), 4).await {
let (x0, x1) = (b[0].min(b[2]), b[0].max(b[2]));
let (y0, y1) = (b[1].min(b[3]), b[1].max(b[3]));
let mut pb = PathBuilder::new(inner.ctm);
pb.rect(x0, y0, x1 - x0, y1 - y0);
let rasterized = self.rasterize_clip(&pb.finish(), FillRule::NonZero);
inner.clip = Some(match &inner.clip {
Some(old) => Arc::new(Mask::intersected(&rasterized, old)),
None => rasterized,
});
}
let own_res = match stream.dict.get("Resources") {
Some(o) => match self.src.resolve(o).await {
Ok(Object::Dict(d)) => Some(d),
_ => None,
},
None => None,
};
let mut inner_chain: Vec<Arc<Dict>> = Vec::with_capacity(chain.len() + 1);
if let Some(d) = own_res {
inner_chain.push(Arc::new(d));
}
inner_chain.extend_from_slice(chain);
Some(Frame::new(
ops.into(),
inner_chain,
inner,
depth + 1,
FrameKind::PageOrForm,
))
}
fn skip(&mut self, kind: SkippedKind, reason: SkipReason) {
self.report.record(kind, reason);
}
async fn draw_image_xobject(&mut self, stream: &Stream, chain: &[Arc<Dict>], gs: &GState) {
let data = match self.src.stream_data(stream).await {
Ok(data) => data,
Err(e) => {
self.skip(SkippedKind::Image, skip_reason_for(&e));
return;
}
};
let cs_obj = self.image_colorspace(&stream.dict, chain).await;
self.blit_image(&stream.dict, &data, cs_obj, gs).await;
}
async fn draw_inline_image(&mut self, img: &ImageParams, chain: &[Arc<Dict>], gs: &GState) {
let stream = Stream {
dict: img.dict.clone(),
data: img.data.clone(),
};
let data = match self.src.stream_data(&stream).await {
Ok(data) => data,
Err(e) => {
self.skip(SkippedKind::Image, skip_reason_for(&e));
return;
}
};
let cs_obj = self.image_colorspace(&img.dict, chain).await;
self.blit_image(&img.dict, &data, cs_obj, gs).await;
}
async fn blit_image(&mut self, dict: &Dict, data: &[u8], cs_obj: Option<Object>, gs: &GState) {
let palette_err = match cs_obj.as_ref() {
Some(o) => color::palette_error_with(self.src, o).await,
None => None,
};
if let Some(e) = palette_err {
self.skip(SkippedKind::Image, skip_reason_for(&e));
}
if ignores_mask(self.src, dict).await {
self.skip(SkippedKind::SoftMask, SkipReason::Unsupported);
}
let meta = image::ImageMeta::read_with(self.src, dict, cs_obj.as_ref()).await;
if gs.fill_pattern && meta.stencil {
self.skip(SkippedKind::Pattern, SkipReason::Unsupported);
}
let fill = gs.fill_rgba8();
let outcome = image::draw(
&mut self.pix,
&meta,
data,
&DrawParams {
ctm: gs.ctm,
alpha: gs.fill_alpha,
fill_rgb: [fill[0], fill[1], fill[2]],
clip: gs.clip.as_deref(),
},
);
match outcome {
image::Drawn::Whole => {}
image::Drawn::Truncated => self.skip(SkippedKind::Image, SkipReason::Truncated),
image::Drawn::Nothing => self.skip(SkippedKind::Image, SkipReason::Undecodable),
}
}
async fn image_colorspace(&self, dict: &Dict, chain: &[Arc<Dict>]) -> Option<Object> {
let resolved = self.src.resolve(dict.get("ColorSpace")?).await.ok()?;
if let Object::Name(n) = &resolved {
let device = matches!(
n.0.as_str(),
"DeviceGray" | "DeviceRGB" | "DeviceCMYK" | "G" | "RGB" | "CMYK"
);
if !device {
if let Some(from_res) = self.find_res(chain, "ColorSpace", &n.0).await {
return Some(from_res);
}
}
}
Some(resolved)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_page_with_options;
use crate::{GlyphPainting, RenderOptions};
use pdfboss_testkit::{doc_with_graphics, PdfBuilder};
#[test]
fn render_options_default_is_all_embedded() {
assert_eq!(
RenderOptions::default().glyph_painting,
GlyphPainting::AllEmbedded
);
}
#[test]
fn all_glyph_tiers_match_default_render_today() {
let bytes = small_doc("", b"1 0 0 rg 10 10 80 80 re f", |_| {});
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let base =
render_page_with_options(&doc, &page, 1.0, &RenderOptions::default()).expect("render");
for tier in [
GlyphPainting::EmbeddedTrueTypeOnly,
GlyphPainting::AllEmbedded,
GlyphPainting::Full,
] {
let opts = RenderOptions {
glyph_painting: tier,
..Default::default()
};
let got = render_page_with_options(&doc, &page, 1.0, &opts).expect("render");
assert_eq!(got, base, "tier {tier:?} differs from default render");
}
}
fn render(bytes: Vec<u8>, scale: f32) -> Pixmap {
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
render_page_with_options(&doc, &page, scale, &RenderOptions::default()).expect("render")
}
fn px(pix: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let off = ((y * pix.width + x) * 4) as usize;
pix.data[off..off + 4].try_into().unwrap()
}
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const BLACK: [u8; 4] = [0, 0, 0, 255];
fn small_doc(resources: &str, content: &[u8], extra: impl FnOnce(&mut PdfBuilder)) -> Vec<u8> {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Resources << {resources} >> /Contents 4 0 R >>"
),
);
b.stream(4, "", content);
extra(&mut b);
b.build(1)
}
#[test]
fn red_rect_fills_at_yflipped_device_location() {
let pix = render(doc_with_graphics("1 0 0 rg 100 100 200 150 re f"), 1.0);
assert_eq!((pix.width, pix.height), (612, 792));
assert_eq!(px(&pix, 200, 600), RED, "interior");
assert_eq!(px(&pix, 101, 543), RED, "top-left corner inside");
assert_eq!(px(&pix, 298, 690), RED, "bottom-right corner inside");
assert_eq!(px(&pix, 200, 530), WHITE, "above rect (device)");
assert_eq!(px(&pix, 200, 700), WHITE, "below rect (device)");
assert_eq!(px(&pix, 95, 600), WHITE, "left of rect");
assert_eq!(px(&pix, 305, 600), WHITE, "right of rect");
assert_eq!(
px(&pix, 200, 100),
WHITE,
"user-space y kept would paint here"
);
}
#[test]
fn clip_limits_full_page_fill() {
let content = "20 20 40 40 re W n 0 0 612 792 re f";
let pix = render(doc_with_graphics(content), 1.0);
assert_eq!(px(&pix, 40, 750), BLACK, "inside clip");
assert_eq!(px(&pix, 40, 700), WHITE, "above clip");
assert_eq!(px(&pix, 70, 750), WHITE, "right of clip");
assert_eq!(px(&pix, 300, 400), WHITE, "page center untouched");
}
#[test]
fn cm_translate_scale_moves_rect() {
let content = "1 0 0 rg q 2 0 0 2 50 30 cm 10 10 20 20 re f Q";
let pix = render(doc_with_graphics(content), 1.0);
assert_eq!(px(&pix, 90, 720), RED, "transformed interior");
assert_eq!(px(&pix, 60, 720), WHITE, "left of transformed rect");
assert_eq!(px(&pix, 90, 750), WHITE, "below transformed rect");
assert_eq!(px(&pix, 20, 770), WHITE, "untransformed location clear");
}
#[test]
fn q_restore_resets_color_and_nonfinite_cm_is_skipped() {
let content = "1 0 0 rg q 0 1 0 rg Q 10 10 20 20 re f";
let pix = render(doc_with_graphics(content), 1.0);
assert_eq!(px(&pix, 20, 770), RED, "Q restored the red fill");
let content = "1e39 0 0 1e39 0 0 cm 1 0 0 rg 10 10 20 20 re f";
let pix = render(doc_with_graphics(content), 1.0);
assert_eq!(px(&pix, 20, 770), RED, "rect painted with identity ctm");
}
#[test]
fn extgstate_ca_blends_toward_white() {
let bytes = small_doc(
"/ExtGState << /G1 5 0 R >>",
b"/G1 gs 1 0 0 rg 0 0 100 100 re f",
|b| {
b.object(5, "<< /Type /ExtGState /ca 0.5 >>");
},
);
let pix = render(bytes, 1.0);
let [r, g, b, a] = px(&pix, 50, 50);
assert_eq!(r, 255);
assert!((127..=129).contains(&g), "green {g}");
assert!((127..=129).contains(&b), "blue {b}");
assert_eq!(a, 255);
}
#[test]
fn stroke_width_scales_with_ctm() {
let content = "4 0 0 4 0 0 cm 1 w 10 20 m 140 20 l S";
let pix = render(doc_with_graphics(content), 1.0);
let dark = (700..725).filter(|&y| px(&pix, 300, y)[0] < 128).count();
assert!((3..=5).contains(&dark), "band thickness {dark}");
let pix = render(doc_with_graphics("1 w 10 80 m 560 80 l S"), 1.0);
let inked = (700..725).filter(|&y| px(&pix, 300, y)[0] < 200).count();
assert!((1..=2).contains(&inked), "hairline thickness {inked}");
}
#[test]
fn dashed_stroke_leaves_gaps() {
let content = "2 w [6 6] 0 d 10 50 m 90 50 l S";
let pix = render(small_doc("", content.as_bytes(), |_| {}), 1.0);
assert_eq!((pix.width, pix.height), (100, 100));
let mut runs = 0;
let mut prev_on = false;
for x in 0..100 {
let on = px(&pix, x, 50)[0] < 128;
if on && !prev_on {
runs += 1;
}
prev_on = on;
}
assert!(runs >= 4, "expected several dash runs, got {runs}");
}
#[test]
fn separation_and_devicen_initial_color_is_full_tint() {
for (entry, content) in [
(
"[/Separation /Spot /DeviceGray 5 0 R]",
"/T cs 10 10 80 80 re f",
),
(
"[/DeviceN [/A /B] /DeviceGray 5 0 R]",
"/T cs 10 10 80 80 re f",
),
(
"[/Separation /Spot /DeviceGray 5 0 R]",
"/T CS 20 w 10 50 m 90 50 l S",
),
] {
let bytes = small_doc("/ColorSpace << /T 6 0 R >>", content.as_bytes(), |b| {
b.object(5, "<< /FunctionType 2 /Domain [0 1] /N 1 >>");
b.object(6, entry);
});
let pix = render(bytes, 1.0);
assert_eq!(px(&pix, 50, 50), BLACK, "{entry} via `{content}`");
}
let bytes = small_doc(
"/ColorSpace << /T 6 0 R >>",
b"/T cs 0 scn 10 10 80 80 re f",
|b| {
b.object(5, "<< /FunctionType 2 /Domain [0 1] /N 1 >>");
b.object(6, "[/Separation /Spot /DeviceGray 5 0 R]");
},
);
assert_eq!(px(&render(bytes, 1.0), 50, 50), WHITE, "0 scn wins");
}
#[test]
fn form_xobject_matrix_paints_displaced() {
let bytes = small_doc("/XObject << /Fm1 5 0 R >>", b"/Fm1 Do", |b| {
b.stream(
5,
"/Type /XObject /Subtype /Form /BBox [0 0 50 50] \
/Matrix [1 0 0 1 20 30]",
b"1 0 0 rg 0 0 50 50 re f",
);
});
let pix = render(bytes, 1.0);
assert_eq!(px(&pix, 40, 50), RED, "displaced interior");
assert_eq!(px(&pix, 10, 50), WHITE, "left of form");
assert_eq!(px(&pix, 40, 80), WHITE, "below form");
assert_eq!(px(&pix, 40, 10), WHITE, "above form");
}
#[test]
fn form_bbox_clips_its_content() {
let bytes = small_doc("/XObject << /Fm1 5 0 R >>", b"/Fm1 Do", |b| {
b.stream(
5,
"/Type /XObject /Subtype /Form /BBox [0 0 40 40]",
b"1 0 0 rg 0 0 80 80 re f",
);
});
let pix = render(bytes, 1.0);
assert_eq!(px(&pix, 20, 80), RED, "inside bbox (device)");
assert_eq!(px(&pix, 60, 40), WHITE, "outside bbox");
}
#[test]
fn inline_image_blits_quadrant_colors() {
let content = "q 50 0 0 50 25 25 cm \
BI /W 2 /H 2 /CS /RGB /BPC 8 /F /AHx ID \
ff0000 00ff00 0000ff ffffff> EI Q";
let pix = render(small_doc("", content.as_bytes(), |_| {}), 1.0);
assert_eq!(px(&pix, 35, 35), RED, "top-left quadrant");
assert_eq!(px(&pix, 65, 35), [0, 255, 0, 255], "top-right quadrant");
assert_eq!(px(&pix, 35, 65), [0, 0, 255, 255], "bottom-left quadrant");
assert_eq!(px(&pix, 65, 65), WHITE, "bottom-right quadrant");
assert_eq!(px(&pix, 10, 50), WHITE, "outside image");
}
#[test]
fn image_mask_stencils_fill_color() {
let bytes = small_doc(
"/XObject << /Im1 5 0 R >>",
b"0 0 1 rg q 100 0 0 100 0 0 cm /Im1 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 2 /Height 2 \
/ImageMask true /BitsPerComponent 1",
&[0x40, 0x80],
);
},
);
let pix = render(bytes, 1.0);
let blue = [0, 0, 255, 255];
assert_eq!(px(&pix, 25, 25), blue, "row 0 sample 0 painted");
assert_eq!(px(&pix, 75, 25), WHITE, "row 0 sample 1 clear");
assert_eq!(px(&pix, 25, 75), WHITE, "row 1 sample 0 clear");
assert_eq!(px(&pix, 75, 75), blue, "row 1 sample 1 painted");
}
#[test]
fn image_mask_decode_inverts_stencil() {
let bytes = small_doc(
"/XObject << /Im1 5 0 R >>",
b"0 0 1 rg q 100 0 0 100 0 0 cm /Im1 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 2 /Height 2 \
/ImageMask true /BitsPerComponent 1 /Decode [1 0]",
&[0x40, 0x80],
);
},
);
let pix = render(bytes, 1.0);
let blue = [0, 0, 255, 255];
assert_eq!(px(&pix, 25, 25), WHITE, "inverted: row 0 sample 0 clear");
assert_eq!(px(&pix, 75, 25), blue, "inverted: row 0 sample 1 painted");
assert_eq!(px(&pix, 25, 75), blue, "inverted: row 1 sample 0 painted");
assert_eq!(px(&pix, 75, 75), WHITE, "inverted: row 1 sample 1 clear");
}
fn zlib_stored(raw: &[u8]) -> Vec<u8> {
let mut out = vec![0x78, 0x01];
let len = raw.len() as u16;
out.push(0x01); out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&(!len).to_le_bytes());
out.extend_from_slice(raw);
let (mut low, mut high) = (1u32, 0u32);
for &byte in raw {
low = (low + u32::from(byte)) % 65521;
high = (high + low) % 65521;
}
out.extend_from_slice(&((high << 16) | low).to_be_bytes());
out
}
fn doc_with_image_filter(filter: &str) -> Vec<u8> {
let samples = [0b1010_1010u8; 8];
let data = if filter == "FlateDecode" {
zlib_stored(&samples)
} else {
samples.to_vec()
};
small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
&format!(
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /{filter}"
),
&data,
);
},
)
}
fn render_reporting(bytes: Vec<u8>) -> (Pixmap, RenderReport) {
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page 0");
render_page_reporting(&doc, &page, 1.0, &RenderOptions::default())
.expect("render succeeds despite any dropped content")
}
fn drops(report: &RenderReport) -> Vec<(SkippedKind, SkipReason, u64)> {
report
.skipped
.iter()
.map(|item| (item.kind, item.reason.clone(), item.count))
.collect()
}
#[test]
fn unsupported_image_filter_is_reported() {
let (pix, report) = render_reporting(doc_with_image_filter("JPXDecode"));
assert!(pix.width > 0 && pix.height > 0, "page still rasterizes");
assert_eq!(
drops(&report),
vec![(
SkippedKind::Image,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
assert!(!report.is_empty());
assert_eq!(report.summary().as_deref(), Some("1 image skipped"));
assert_eq!(
report.warnings(),
vec!["1 image skipped: unsupported filter /JPXDecode".to_string()],
);
}
#[test]
fn clean_page_reports_nothing() {
let (pix, report) = render_reporting(doc_with_image_filter("FlateDecode"));
assert_eq!(px(&pix, 6, 50), WHITE, "column 0 sample is white");
assert_eq!(px(&pix, 18, 50), BLACK, "column 1 sample is black");
assert!(report.is_empty(), "a decodable image reports no skips");
assert_eq!(report.summary(), None);
assert!(report.warnings().is_empty());
}
#[test]
fn unsupported_inline_image_filter_is_reported() {
let content = "q 100 0 0 100 0 0 cm BI /W 8 /H 8 /BPC 1 /CS /G \
/F /JPXDecode ID 01234567 EI Q";
let (_, report) = render_reporting(small_doc("", content.as_bytes(), |_| {}));
assert_eq!(
drops(&report),
vec![(
SkippedKind::Image,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
}
#[test]
fn image_that_decodes_but_cannot_be_interpreted_is_reported() {
let bytes = small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 0 /Height 8 \
/BitsPerComponent 1 /ColorSpace /DeviceGray",
&[0; 8],
);
},
);
let (_, report) = render_reporting(bytes);
assert_eq!(
drops(&report),
vec![(SkippedKind::Image, SkipReason::Undecodable, 1)],
);
assert_eq!(report.summary().as_deref(), Some("1 image skipped"));
}
#[test]
fn a_repeated_drop_costs_one_entry_and_counts_up() {
let content = "q 100 0 0 100 0 0 cm /Im0 Do /Im0 Do Q";
let bytes = small_doc("/XObject << /Im0 5 0 R >>", content.as_bytes(), |b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JPXDecode",
&[0; 8],
);
});
let (_, report) = render_reporting(bytes);
assert_eq!(report.skipped.len(), 1, "one entry, not one per draw");
assert_eq!(report.skipped[0].count, 2);
assert_eq!(report.summary().as_deref(), Some("2 images skipped"));
}
#[test]
fn nested_forms_repeating_a_broken_image_keep_the_report_small() {
const LEVELS: u32 = 4;
const FANOUT: u32 = 10;
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Resources << /XObject << /F0 10 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"/F0 Do");
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JPXDecode",
&[0; 8],
);
for level in 0..LEVELS {
let (child, child_obj) = if level + 1 < LEVELS {
(format!("F{}", level + 1), 11 + level)
} else {
("Im0".to_string(), 5)
};
let content = format!("/{child} Do ").repeat(FANOUT as usize);
b.stream(
10 + level,
&format!(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] \
/Resources << /XObject << /{child} {child_obj} 0 R >> >>"
),
content.as_bytes(),
);
}
let (_, report) = render_reporting(b.build(1));
assert_eq!(report.skipped.len(), 1, "one entry for 10,000 draws");
assert_eq!(report.skipped[0].count, u64::from(FANOUT.pow(LEVELS)));
assert_eq!(report.unlisted, 0);
}
#[test]
fn distinct_drops_stop_at_the_report_cap() {
let mut content = String::new();
for i in 0..70 {
content.push_str(&format!(
"BI /W 8 /H 8 /BPC 1 /CS /G /F /Bogus{i}Decode ID 01234567 EI\n"
));
}
let (_, report) = render_reporting(small_doc("", content.as_bytes(), |_| {}));
assert_eq!(report.skipped.len(), 64, "entry list is capped");
assert_eq!(report.unlisted, 6, "the rest are counted, not described");
assert!(report
.warnings()
.last()
.expect("a warning per entry plus the overflow line")
.starts_with("6 further drops"));
}
#[test]
fn undecodable_page_contents_are_reported() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>",
);
b.stream(4, "/Filter /JPXDecode", b"0 0 100 100 re f");
let (pix, report) = render_reporting(b.build(1));
assert_eq!(px(&pix, 50, 50), WHITE, "nothing painted");
assert_eq!(
drops(&report),
vec![(
SkippedKind::PageContents,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
assert_eq!(
report.summary().as_deref(),
Some("1 content stream skipped")
);
}
#[test]
fn undecodable_form_xobject_is_reported() {
let bytes = small_doc("/XObject << /Fm0 5 0 R >>", b"/Fm0 Do", |b| {
b.stream(
5,
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /JPXDecode",
b"0 0 100 100 re f",
);
});
let (pix, report) = render_reporting(bytes);
assert_eq!(px(&pix, 50, 50), WHITE, "the form painted nothing");
assert_eq!(
drops(&report),
vec![(
SkippedKind::Form,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
}
#[test]
fn unresolvable_and_untyped_xobjects_are_reported() {
let bytes = small_doc("/XObject << /X1 5 0 R >>", b"/Im0 Do /X1 Do", |b| {
b.stream(5, "/Width 8 /Height 8", &[0; 8]);
});
let (_, report) = render_reporting(bytes);
assert_eq!(
drops(&report),
vec![
(SkippedKind::XObject, SkipReason::Missing, 1),
(SkippedKind::XObject, SkipReason::Unsupported, 1),
],
);
}
#[test]
fn jpx_image_is_reported_instead_of_painted_as_noise() {
let bytes = small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /JPXDecode",
&[0x42; 192],
);
},
);
let (pix, report) = render_reporting(bytes);
assert_eq!(px(&pix, 50, 50), WHITE, "no noise painted");
assert_eq!(
drops(&report),
vec![(
SkippedKind::Image,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
assert_eq!(
report.warnings(),
vec!["1 image skipped: unsupported filter /JPXDecode".to_string()],
"the caller can name what went missing",
);
}
#[test]
fn image_with_too_few_samples_is_reported() {
let bytes = small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 8 /ColorSpace /DeviceGray",
&[0xFF; 4],
);
},
);
let (pix, report) = render_reporting(bytes);
assert_eq!(px(&pix, 6, 6), [255, 255, 255, 255], "real sample painted");
assert_eq!(px(&pix, 50, 50), BLACK, "padding painted black");
assert_eq!(
drops(&report),
vec![(SkippedKind::Image, SkipReason::Truncated, 1)],
);
}
#[test]
fn indexed_image_with_undecodable_palette_is_reported() {
let bytes = small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 6 0 R]",
&[0; 64],
);
b.stream(6, "/Filter /JPXDecode", &[0; 12]);
},
);
let (_, report) = render_reporting(bytes);
assert_eq!(
drops(&report),
vec![(
SkippedKind::Image,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
1,
)],
);
}
#[test]
fn shading_operator_is_reported() {
let (pix, report) = render_reporting(small_doc("", b"q /Sh0 sh Q", |_| {}));
assert_eq!(px(&pix, 50, 50), WHITE, "shadings paint nothing");
assert_eq!(
drops(&report),
vec![(SkippedKind::Shading, SkipReason::Unsupported, 1)],
);
}
#[test]
fn pattern_fill_is_reported_as_an_approximation() {
let content = b"/Pattern cs /P0 scn 0 0 100 100 re f";
let (pix, report) = render_reporting(small_doc("", content, |_| {}));
assert_eq!(px(&pix, 50, 50), [128, 128, 128, 255], "stand-in gray");
assert_eq!(
drops(&report),
vec![(SkippedKind::Pattern, SkipReason::Unsupported, 1)],
);
}
#[test]
fn ignored_soft_mask_and_blend_mode_are_reported() {
let resources = "/ExtGState << /GS0 << /SMask << /S /Luminosity /G 5 0 R >> \
/BM /Multiply >> >>";
let bytes = small_doc(resources, b"/GS0 gs 0 0 100 100 re f", |b| {
b.stream(5, "/Type /XObject /Subtype /Form /BBox [0 0 8 8]", b"");
});
let (_, report) = render_reporting(bytes);
assert_eq!(
drops(&report),
vec![
(SkippedKind::SoftMask, SkipReason::Unsupported, 1),
(SkippedKind::BlendMode, SkipReason::Unsupported, 1),
],
);
}
#[test]
fn image_soft_mask_is_reported() {
let bytes = small_doc(
"/XObject << /Im0 5 0 R >>",
b"q 100 0 0 100 0 0 cm /Im0 Do Q",
|b| {
b.stream(
5,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R",
&[0xFF; 64],
);
b.stream(
6,
"/Type /XObject /Subtype /Image /Width 8 /Height 8 \
/BitsPerComponent 8 /ColorSpace /DeviceGray",
&[0; 64],
);
},
);
let (pix, report) = render_reporting(bytes);
assert_eq!(
px(&pix, 50, 50),
WHITE,
"fully masked content painted anyway"
);
assert_eq!(
drops(&report),
vec![(SkippedKind::SoftMask, SkipReason::Unsupported, 1)],
);
}
#[test]
fn unpainted_annotation_appearance_is_reported() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R \
/Annots [5 0 R 6 0 R 7 0 R] >>",
);
b.stream(4, "", b"");
b.object(
5,
"<< /Type /Annot /Subtype /Stamp /Rect [0 0 10 10] /AP << /N 8 0 R >> >>",
);
b.object(
6,
"<< /Type /Annot /Subtype /Stamp /Rect [0 0 10 10] /F 2 /AP << /N 8 0 R >> >>",
);
b.object(7, "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>");
b.stream(8, "/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"");
let (_, report) = render_reporting(b.build(1));
assert_eq!(
drops(&report),
vec![(SkippedKind::Annotation, SkipReason::Unsupported, 1)],
);
}
#[test]
fn rotate_90_swaps_dimensions_and_spins_content() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 200] \
/Rotate 90 /Contents 4 0 R >>",
);
b.stream(4, "", b"1 0 0 rg 0 0 10 10 re f");
let pix = render(b.build(1), 1.0);
assert_eq!((pix.width, pix.height), (200, 100));
assert_eq!(px(&pix, 5, 5), RED, "rotated corner");
assert_eq!(px(&pix, 5, 94), WHITE, "old corner clear");
assert_eq!(px(&pix, 194, 94), WHITE);
}
#[test]
fn scale_doubles_pixel_size_and_coordinates() {
let content = "1 0 0 rg 10 10 20 20 re f";
let pix = render(small_doc("", content.as_bytes(), |_| {}), 2.0);
assert_eq!((pix.width, pix.height), (200, 200));
assert_eq!(px(&pix, 40, 160), RED, "scaled interior");
assert_eq!(px(&pix, 40, 120), WHITE, "above scaled rect");
assert_eq!(px(&pix, 80, 160), WHITE, "right of scaled rect");
}
fn fixture(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name)
}
#[test]
fn shapes_fixture_renders_expected_colors() {
let doc = Document::open(fixture("shapes.pdf")).expect("open");
let page = doc.page(0).expect("page");
let pix =
render_page_with_options(&doc, &page, 1.0, &RenderOptions::default()).expect("render");
assert_eq!((pix.width, pix.height), (612, 792));
assert!(
pix.data.chunks_exact(4).any(|p| p[0] != 255 || p[1] != 255),
"page must contain non-white pixels"
);
assert_eq!(px(&pix, 100, 150), RED, "red rect");
let [r, g, b, _] = px(&pix, 250, 150);
assert_eq!((r, b), (0, 255), "blue-ish rect r/b");
assert!((127..=129).contains(&g), "blue-ish rect g {g}");
assert_eq!(px(&pix, 380, 150), [51, 204, 51, 255], "green rect");
assert_eq!(px(&pix, 350, 650), [204, 0, 204, 255], "magenta rect");
let dark = (410..425).any(|y| px(&pix, 200, y)[0] < 128);
assert!(dark, "stroked curve missing");
assert_eq!(px(&pix, 550, 750), WHITE);
}
#[test]
fn hello_fixture_renders_all_white_without_error() {
let doc = Document::open(fixture("hello.pdf")).expect("open");
let page = doc.page(0).expect("page");
let pix =
render_page_with_options(&doc, &page, 1.0, &RenderOptions::default()).expect("render");
assert_eq!((pix.width, pix.height), (612, 792));
assert!(pix.data.iter().all(|&b| b == 255), "expected a white page");
}
#[test]
fn even_odd_fill_and_close_fill_stroke() {
let content = "1 0 0 rg 10 10 80 80 re 30 30 40 40 re f*";
let pix = render(small_doc("", content.as_bytes(), |_| {}), 1.0);
assert_eq!(px(&pix, 50, 50), WHITE, "even-odd hole");
assert_eq!(px(&pix, 15, 50), RED, "ring");
let content = "1 0 0 rg 0 0 0 RG 2 w 20 10 m 80 10 l 50 60 l b";
let pix = render(small_doc("", content.as_bytes(), |_| {}), 1.0);
assert_eq!(px(&pix, 50, 70), RED, "triangle interior filled");
assert!(px(&pix, 50, 90)[0] < 128, "closing edge stroked");
}
fn render_at_tier(bytes: &[u8], tier: GlyphPainting) -> Pixmap {
let doc = Document::load(bytes.to_vec()).expect("load");
let page = doc.page(0).expect("page");
let opts = RenderOptions {
glyph_painting: tier,
..Default::default()
};
render_page_with_options(&doc, &page, 1.0, &opts).expect("render")
}
#[test]
fn every_shared_render_handle_is_shareable_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Mask>();
assert_send_sync::<Type3Font>();
assert_send_sync::<GlyphFont>();
assert_send_sync::<Arc<Mask>>();
assert_send_sync::<Arc<Type3Font>>();
assert_send_sync::<Arc<GlyphFont>>();
assert_send_sync::<GState>();
assert_send_sync::<TextState>();
}
fn dark_at(pix: &Pixmap, x: u32, y: u32) -> bool {
let o = ((y * pix.width + x) * 4) as usize;
pix.data[o] < 128 && pix.data[o + 1] < 128 && pix.data[o + 2] < 128
}
fn type3_doc(charproc: &str, font_extra: &str, content: &[u8]) -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", content);
b.object(
5,
&format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] \
/FontMatrix [0.001 0 0 0.001 0 0] \
/Encoding << /Differences [65 /boxglyph] >> \
/CharProcs << /boxglyph 6 0 R >> {font_extra} >>"
),
);
b.stream(6, "", charproc.as_bytes());
b.build(1)
}
fn type3_page_doc(charproc: &str, content: &[u8]) -> Vec<u8> {
type3_doc(charproc, "/FirstChar 65 /Widths [1000]", content)
}
fn type3_page_doc_widths(width: i32, content: &[u8]) -> Vec<u8> {
type3_doc(
"1000 0 d0 100 0 500 700 re f",
&format!("/FirstChar 65 /Widths [{width}]"),
content,
)
}
fn type3_recursive_doc() -> Vec<u8> {
type3_doc(
"1000 0 d0 100 0 500 700 re f BT /F0 100 Tf <41> Tj ET",
"/FirstChar 65 /Widths [1000] /Resources << /Font << /F0 5 0 R >> >>",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
)
}
#[test]
fn type3_glyph_paints_at_all_embedded_not_embedded_truetype_only() {
let doc = type3_page_doc(
"1000 0 d0 100 0 500 700 re f",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET", );
for tier in [GlyphPainting::AllEmbedded, GlyphPainting::Full] {
let pix = render_at_tier(&doc, tier);
assert!(
dark_at(&pix, 55, 115),
"Type3 glyph should paint at {tier:?}"
);
}
let pix = render_at_tier(&doc, GlyphPainting::EmbeddedTrueTypeOnly);
assert!(
!dark_at(&pix, 55, 115),
"Type3 must not paint at EmbeddedTrueTypeOnly"
);
}
#[test]
fn type3_self_referential_glyph_terminates() {
let doc = type3_recursive_doc();
let started = std::time::Instant::now();
let pix = render_at_tier(&doc, GlyphPainting::AllEmbedded);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"self-referential Type3 must be depth-bounded, not hang/overflow"
);
assert!(dark_at(&pix, 55, 115), "the box still paints");
}
#[test]
fn a_char_proc_resolves_names_from_the_fonts_own_resources() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <41> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] \
/FontMatrix [0.001 0 0 0.001 0 0] \
/Encoding << /Differences [65 /boxglyph] >> \
/CharProcs << /boxglyph 6 0 R >> /FirstChar 65 /Widths [1000] \
/Resources << /XObject << /Fx 7 0 R >> >> >>",
);
b.stream(6, "", b"1000 0 d0 /Fx Do");
b.stream(
7,
"/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]",
b"100 0 500 700 re f",
);
let pix = render_at_tier(&b.build(1), GlyphPainting::AllEmbedded);
assert!(
dark_at(&pix, 55, 115),
"the CharProc's form must resolve through the font's own /Resources"
);
}
#[test]
fn type3_width_governs_second_glyph_origin() {
let doc = type3_page_doc_widths(800, b"BT /F0 100 Tf 20 50 Td <4141> Tj ET");
let pix = render_at_tier(&doc, GlyphPainting::AllEmbedded);
assert!(dark_at(&pix, 55, 115), "first glyph at (55,115)");
assert!(
dark_at(&pix, 135, 115),
"second glyph at the /Widths-implied (135,115)"
);
}
#[test]
fn type3_d1_glyph_ignores_its_own_color_and_uses_text_fill() {
let doc = type3_page_doc(
"1000 0 0 0 1000 1000 d1 0 0 1 rg 100 0 500 700 re f",
b"1 0 0 rg BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
let pix = render_at_tier(&doc, GlyphPainting::AllEmbedded);
let [r, g, b, _] = px(&pix, 55, 115);
assert!(
r > 200 && g < 60 && b < 60,
"d1 glyph paints in the text fill (red), got {r},{g},{b}"
);
}
#[test]
fn type3_d0_glyph_honors_its_own_color() {
let doc = type3_page_doc(
"1000 0 d0 0 0 1 rg 100 0 500 700 re f",
b"1 0 0 rg BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
let pix = render_at_tier(&doc, GlyphPainting::AllEmbedded);
let [r, g, b, _] = px(&pix, 55, 115);
assert!(
b > 200 && r < 60 && g < 60,
"d0 glyph paints its own color (blue), got {r},{g},{b}"
);
}
#[test]
fn type3_d0_nested_in_d1_regains_color() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"1 0 0 rg BT /F0 100 Tf 20 50 Td <41> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] \
/FontMatrix [0.001 0 0 0.001 0 0] \
/Encoding << /Differences [65 /d1glyph 66 /d0glyph] >> \
/CharProcs << /d1glyph 6 0 R /d0glyph 7 0 R >> \
/FirstChar 65 /Widths [1000 1000] >>",
);
b.stream(
6,
"",
b"1000 0 0 0 1000 1000 d1 0 0 1 rg 100 0 500 700 re f \
BT /F0 100 Tf 800 0 Td <42> Tj ET",
);
b.stream(7, "", b"1000 0 d0 0 0 1 rg 1000 0 2000 3000 re f");
let pix = render_at_tier(&b.build(1), GlyphPainting::AllEmbedded);
let [r, g, bch, _] = px(&pix, 55, 115);
assert!(
r > 200 && g < 60 && bch < 60,
"outer d1 box must paint the inherited text fill (red), got {r},{g},{bch}"
);
let [r, g, bch, _] = px(&pix, 120, 135);
assert!(
bch > 200 && r < 60 && g < 60,
"nested d0 box must regain its own color (blue), got {r},{g},{bch}"
);
}
fn write_temp_face(tag: &str, basename: &str, bytes: &[u8]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pdfboss-executor-{tag}-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join(basename), bytes).expect("write fixture face");
dir
}
#[test]
fn type3_at_full_with_provider_still_paints_via_charprocs() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <80> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] \
/FontMatrix [0.001 0 0 0.001 0 0] \
/Encoding << /Differences [128 /boxglyph] >> \
/CharProcs << /boxglyph 6 0 R >> /FirstChar 128 /Widths [1000] >>",
);
b.stream(6, "", b"1000 0 d0 100 0 500 700 re f");
let bytes = b.build(1);
let dir = write_temp_face(
"type3-substitute",
"Arimo[wght].ttf",
&crate::truetype::tests::build_font(),
);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let opts = RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
};
let pix = render_page_with_options(&doc, &page, 1.0, &opts).expect("render");
assert!(
dark_at(&pix, 55, 115),
"Type3 CharProc box must still paint at Full+provider, not be \
clobbered by wrongly-fired substitution"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn non_embedded_type0_at_full_with_provider_stays_blank() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <0041> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /Helvetica \
/Encoding /Identity-H /DescendantFonts [6 0 R] >>",
);
b.object(
6,
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Helvetica >>",
);
let bytes = b.build(1);
let dir = write_temp_face(
"type0-substitute",
"Arimo[wght].ttf",
&crate::truetype::tests::build_font(),
);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let opts = RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
};
let pix = render_page_with_options(&doc, &page, 1.0, &opts).expect("render");
assert!(
!dark_at(&pix, 55, 115),
"non-embedded Type0 must not be substituted into mis-split garbage"
);
std::fs::remove_dir_all(&dir).ok();
}
}