pdfrum_page/ops.rs
1//! The content-operator table (ISO 32000-1 §8-9), and the [`Op`] enum it
2//! generates.
3//!
4//! One `ops!` invocation is the single source of truth for every operator:
5//! its spelling, its variant, the operands it reads and in which order, and
6//! whether a wrong operand count makes it a no-op. Everything else in the
7//! crate matches on `Op` and never spells an operator name, so adding one is
8//! a single-line edit here and every consumer fails to compile until it
9//! handles the new variant.
10//!
11//! # Operand order is the contract
12//!
13//! PDFium keeps operands in a 16-slot ring indexed from the *newest*, and
14//! each handler reaches back by a fixed index. `1 2 m` reads x from index 1
15//! and y from index 0. The `from` positions in the table below are those
16//! indices, so a row reads exactly like the handler it replaces — see
17//! [`crate::content::OperandRing`] for the ring itself and what a missing
18//! operand yields (`0`, `""`, null; never an error).
19
20use crate::content::OperandRing;
21use kurbo::{Affine, Point};
22use pdfrum_object::{Name, Object, PdfString};
23use smallvec::SmallVec;
24
25/// Line-cap style (`J`, `/LC`; ISO 32000-1 table 52).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
27pub enum LineCap {
28 /// Square butt at the exact endpoint.
29 #[default]
30 Butt,
31 /// Semicircular cap centred on the endpoint.
32 Round,
33 /// Square cap projecting half the line width past the endpoint.
34 Square,
35}
36
37impl LineCap {
38 /// The cap an operand names, clamped into range.
39 ///
40 /// PDFium casts the operand into a three-valued enum unchecked and lets
41 /// the rasterizer's `default:` arm absorb anything out of range; clamping
42 /// here is observably identical and keeps the enum honest.
43 #[must_use]
44 pub fn from_int(v: i64) -> Self {
45 match v {
46 1 => Self::Round,
47 2 => Self::Square,
48 _ => Self::Butt,
49 }
50 }
51
52 /// Whether `v` named this cap exactly, i.e. no clamping happened.
53 #[must_use]
54 pub fn is_exact(v: i64) -> bool {
55 (0..=2).contains(&v)
56 }
57}
58
59/// Line-join style (`j`, `/LJ`; ISO 32000-1 table 53).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
61pub enum LineJoin {
62 /// Extend the outer edges until they meet, subject to the miter limit.
63 #[default]
64 Miter,
65 /// Round off the corner with an arc.
66 Round,
67 /// Cut the corner off with a straight edge.
68 Bevel,
69}
70
71impl LineJoin {
72 /// The join an operand names, clamped into range (see
73 /// [`LineCap::from_int`]).
74 #[must_use]
75 pub fn from_int(v: i64) -> Self {
76 match v {
77 1 => Self::Round,
78 2 => Self::Bevel,
79 _ => Self::Miter,
80 }
81 }
82
83 /// Whether `v` named this join exactly.
84 #[must_use]
85 pub fn is_exact(v: i64) -> bool {
86 (0..=2).contains(&v)
87 }
88}
89
90/// How a path-painting operator fills its interior (ISO 32000-1 §8.5.3.3).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
92pub enum FillRule {
93 /// Do not fill at all — the operator only strokes, or only clips.
94 #[default]
95 None,
96 /// Nonzero winding number rule (`f`, `B`, `W`).
97 Winding,
98 /// Even-odd rule (`f*`, `B*`, `W*`).
99 EvenOdd,
100}
101
102/// How glyphs are painted (`Tr`; ISO 32000-1 table 106).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
104pub enum TextRenderMode {
105 /// Fill the glyphs.
106 #[default]
107 Fill,
108 /// Stroke the glyph outlines.
109 Stroke,
110 /// Fill, then stroke.
111 FillStroke,
112 /// Paint nothing.
113 Invisible,
114 /// Fill and add to the clipping path.
115 FillClip,
116 /// Stroke and add to the clipping path.
117 StrokeClip,
118 /// Fill, stroke and add to the clipping path.
119 FillStrokeClip,
120 /// Add to the clipping path only.
121 Clip,
122}
123
124impl TextRenderMode {
125 /// The mode `v` names, or `None` when it is outside 0..=7.
126 ///
127 /// PDFium leaves the mode *unchanged* for an out-of-range operand rather
128 /// than defaulting it, so the caller must keep the old value on `None`.
129 #[must_use]
130 pub fn from_int(v: i64) -> Option<Self> {
131 Some(match v {
132 0 => Self::Fill,
133 1 => Self::Stroke,
134 2 => Self::FillStroke,
135 3 => Self::Invisible,
136 4 => Self::FillClip,
137 5 => Self::StrokeClip,
138 6 => Self::FillStrokeClip,
139 7 => Self::Clip,
140 _ => return None,
141 })
142 }
143
144 /// Whether the mode paints glyph interiors.
145 #[must_use]
146 pub fn fills(self) -> bool {
147 matches!(
148 self,
149 Self::Fill | Self::FillStroke | Self::FillClip | Self::FillStrokeClip
150 )
151 }
152
153 /// Whether the mode paints glyph outlines.
154 #[must_use]
155 pub fn strokes(self) -> bool {
156 matches!(
157 self,
158 Self::Stroke | Self::FillStroke | Self::StrokeClip | Self::FillStrokeClip
159 )
160 }
161
162 /// Whether the mode contributes the glyphs to the clipping path.
163 #[must_use]
164 pub fn clips(self) -> bool {
165 matches!(
166 self,
167 Self::FillClip | Self::StrokeClip | Self::FillStrokeClip | Self::Clip
168 )
169 }
170}
171
172/// One element of a `TJ` array: a string to show, or an adjustment in
173/// thousandths of a text-space unit.
174#[derive(Debug, Clone, PartialEq)]
175pub enum TextItem {
176 /// A string of character codes.
177 Show(Box<[u8]>),
178 /// A displacement subtracted from the current position.
179 Adjust(f32),
180}
181
182/// The property list a `BDC` operator carries: a name into `/Properties`, or
183/// an inline dictionary.
184#[derive(Debug, Clone, PartialEq)]
185pub enum MarkProperties {
186 /// `BDC /Tag /Name` — resolved through the `/Properties` resource.
187 Named(Name),
188 /// `BDC /Tag << … >>` — the dictionary is written out inline.
189 Inline(Box<pdfrum_object::Dict>),
190}
191
192/// An inline image (`BI … ID … EI`), already separated into its dictionary
193/// and its raw sample bytes by the tokenizer (ISO 32000-1 §8.9.7).
194#[derive(Debug, Clone, PartialEq)]
195pub struct InlineImage {
196 /// The image dictionary with abbreviations expanded and `/Subtype /Image`
197 /// established.
198 pub dict: pdfrum_object::Dict,
199 /// The bytes between `ID` and the terminating `EI`, still filtered.
200 pub data: Box<[u8]>,
201}
202
203/// How an operand is pulled out of the ring, and what a missing one yields.
204///
205/// This mirrors PDFium's accessors exactly: a number that is not there reads
206/// `0`, a string reads empty, an object reads null. Only the rows carrying an
207/// `exactly N` guard ever refuse to run.
208trait FromOperands: Sized {
209 fn extract(ring: &OperandRing, index: usize) -> Self;
210}
211
212impl FromOperands for f32 {
213 fn extract(ring: &OperandRing, index: usize) -> Self {
214 ring.number(index)
215 }
216}
217
218impl FromOperands for i64 {
219 fn extract(ring: &OperandRing, index: usize) -> Self {
220 ring.integer(index).unwrap_or(0)
221 }
222}
223
224impl FromOperands for LineCap {
225 fn extract(ring: &OperandRing, index: usize) -> Self {
226 Self::from_int(ring.integer(index).unwrap_or(0))
227 }
228}
229
230impl FromOperands for LineJoin {
231 fn extract(ring: &OperandRing, index: usize) -> Self {
232 Self::from_int(ring.integer(index).unwrap_or(0))
233 }
234}
235
236impl FromOperands for Name {
237 fn extract(ring: &OperandRing, index: usize) -> Self {
238 Name::new(ring.string(index))
239 }
240}
241
242impl FromOperands for PdfString {
243 fn extract(ring: &OperandRing, index: usize) -> Self {
244 PdfString::literal(ring.string(index))
245 }
246}
247
248impl FromOperands for Object {
249 fn extract(ring: &OperandRing, index: usize) -> Self {
250 ring.object(index)
251 }
252}
253
254/// A point read as `(number at index+1, number at index)` — x is the *older*
255/// operand, matching `GetPoint`.
256impl FromOperands for Point {
257 fn extract(ring: &OperandRing, index: usize) -> Self {
258 Self::new(
259 f64::from(ring.number(index + 1)),
260 f64::from(ring.number(index)),
261 )
262 }
263}
264
265/// Six numbers read oldest-to-newest as `(a, b, c, d, e, f)`, matching
266/// `GetMatrix`. Note kurbo's `Affine` takes the same `[a b c d e f]` layout.
267impl FromOperands for Affine {
268 fn extract(ring: &OperandRing, index: usize) -> Self {
269 let n = |k: usize| f64::from(ring.number(index + k));
270 Self::new([n(5), n(4), n(3), n(2), n(1), n(0)])
271 }
272}
273
274macro_rules! ops {
275 ($(
276 $(#[$meta:meta])*
277 $spelling:literal => $variant:ident ( $($ty:ty : $from:tt),* ) $($guard:literal)?;
278 )*) => {
279 /// One content-stream operator with its operands already extracted.
280 ///
281 /// Generated from the table in this module, which is the only place
282 /// operator spellings appear.
283 #[derive(Debug, Clone, PartialEq)]
284 #[non_exhaustive]
285 pub enum Op {
286 $(
287 $(#[$meta])*
288 $variant ( $($ty),* ),
289 )*
290 /// An inline image, consumed whole by the tokenizer.
291 InlineImage(Box<InlineImage>),
292 /// A keyword that names no operator. PDFium drops it silently
293 /// after clearing the operands; we keep the spelling so a
294 /// diagnostic and a dump can name it.
295 Unknown(Box<[u8]>),
296 }
297
298 impl Op {
299 /// The operator's spelling as it appears in a content stream.
300 #[must_use]
301 pub fn keyword(&self) -> &[u8] {
302 match self {
303 $( Self::$variant { .. } => $spelling, )*
304 Self::InlineImage(_) => b"BI",
305 Self::Unknown(w) => w,
306 }
307 }
308
309 /// Whether `word` names a registered operator.
310 ///
311 /// `BI`/`ID`/`EI` are registered but never reach dispatch — the
312 /// tokenizer consumes an inline image whole.
313 #[must_use]
314 pub fn is_operator(word: &[u8]) -> bool {
315 matches!(word, $( $spelling )|*)
316 }
317
318 /// Build the operator `word` names from the operands currently in
319 /// `ring`.
320 ///
321 /// A registered operator whose `param_count != N` guard fails
322 /// yields [`Dispatch::GuardFailed`] and produces no `Op` at all,
323 /// exactly as PDFium's early `return` does.
324 pub(crate) fn from_ring(word: &[u8], ring: &OperandRing) -> Dispatch {
325 let _ = ring;
326 match word {
327 $(
328 $spelling => {
329 $(
330 if ring.len() != $guard {
331 return Dispatch::GuardFailed;
332 }
333 )?
334 Dispatch::Op(Op::$variant(
335 $( <$ty as FromOperands>::extract(ring, $from) ),*
336 ))
337 }
338 )*
339 _ => Dispatch::NotAnOperator,
340 }
341 }
342 }
343 };
344}
345
346/// What looking a keyword up in the table produced.
347pub(crate) enum Dispatch {
348 /// A complete operator.
349 Op(Op),
350 /// A registered operator whose `param_count != N` guard refused it. The
351 /// operator is dropped and the operands are cleared, exactly as PDFium's
352 /// early `return` does.
353 GuardFailed,
354 /// The keyword names no operator at all.
355 NotAnOperator,
356}
357
358include!("ops_table.rs");
359
360#[cfg(test)]
361mod tests {
362 // Test fixtures quote the oracle's own vectors, compare floats exactly
363 // where the behaviour being pinned is exact, and index arrays whose
364 // length the fixture itself fixes.
365 #![allow(
366 clippy::unreadable_literal,
367 clippy::float_cmp,
368 clippy::indexing_slicing,
369 clippy::cast_precision_loss,
370 clippy::cast_possible_truncation,
371 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
372 )]
373
374 use super::{LineCap, LineJoin, Op, TextRenderMode};
375
376 #[test]
377 fn every_operator_round_trips_its_spelling() {
378 // The `keyword` arm and the dispatch arm come from the same row, so
379 // this catches a row whose spelling and variant drifted apart.
380 for word in [
381 &b"q"[..],
382 b"Q",
383 b"cm",
384 b"w",
385 b"J",
386 b"j",
387 b"M",
388 b"d",
389 b"ri",
390 b"i",
391 b"gs",
392 b"m",
393 b"l",
394 b"c",
395 b"v",
396 b"y",
397 b"h",
398 b"re",
399 b"S",
400 b"s",
401 b"f",
402 b"F",
403 b"f*",
404 b"B",
405 b"B*",
406 b"b",
407 b"b*",
408 b"n",
409 b"W",
410 b"W*",
411 b"BT",
412 b"ET",
413 b"Td",
414 b"TD",
415 b"Tm",
416 b"T*",
417 b"TL",
418 b"Ts",
419 b"Tz",
420 b"Tc",
421 b"Tw",
422 b"Tf",
423 b"Tr",
424 b"Tj",
425 b"'",
426 b"\"",
427 b"TJ",
428 b"d0",
429 b"d1",
430 b"CS",
431 b"cs",
432 b"SC",
433 b"sc",
434 b"SCN",
435 b"scn",
436 b"G",
437 b"g",
438 b"RG",
439 b"rg",
440 b"K",
441 b"k",
442 b"Do",
443 b"sh",
444 b"BI",
445 b"ID",
446 b"EI",
447 b"BMC",
448 b"BDC",
449 b"EMC",
450 b"MP",
451 b"DP",
452 b"BX",
453 b"EX",
454 ] {
455 assert!(Op::is_operator(word), "{word:?} is not registered");
456 }
457 assert!(!Op::is_operator(b"Tjxx"));
458 assert!(!Op::is_operator(b""));
459 assert!(!Op::is_operator(b"true"));
460 }
461
462 #[test]
463 fn caps_and_joins_clamp_out_of_range_operands() {
464 assert_eq!(LineCap::from_int(1), LineCap::Round);
465 assert_eq!(LineCap::from_int(5), LineCap::Butt);
466 assert_eq!(LineCap::from_int(-1), LineCap::Butt);
467 assert!(!LineCap::is_exact(3));
468 assert_eq!(LineJoin::from_int(2), LineJoin::Bevel);
469 assert_eq!(LineJoin::from_int(99), LineJoin::Miter);
470 assert!(LineJoin::is_exact(0));
471 }
472
473 #[test]
474 fn text_render_modes_outside_zero_to_seven_are_refused() {
475 assert_eq!(TextRenderMode::from_int(0), Some(TextRenderMode::Fill));
476 assert_eq!(TextRenderMode::from_int(7), Some(TextRenderMode::Clip));
477 assert_eq!(TextRenderMode::from_int(8), None);
478 assert_eq!(TextRenderMode::from_int(-1), None);
479 assert!(TextRenderMode::FillStrokeClip.fills());
480 assert!(TextRenderMode::FillStrokeClip.strokes());
481 assert!(TextRenderMode::FillStrokeClip.clips());
482 assert!(!TextRenderMode::Invisible.fills());
483 assert!(!TextRenderMode::Fill.clips());
484 }
485}