Skip to main content

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}