Skip to main content

pdf_interpret/interpret/
mod.rs

1use crate::FillRule;
2use crate::color::ColorSpace;
3use crate::context::Context;
4use crate::convert::{convert_line_cap, convert_line_join};
5use crate::device::Device;
6use crate::font::{Font, FontData, FontQuery, StandardFont};
7use crate::interpret::path::{
8    close_path, fill_path, fill_path_impl, fill_stroke_path, stroke_path,
9};
10use crate::interpret::state::{TextStateFont, handle_gs};
11use crate::interpret::text::TextRenderingMode;
12use crate::pattern::{Pattern, ShadingPattern};
13use crate::shading::Shading;
14use crate::util::{OptionLog, RectExt, decode_text_string};
15use crate::x_object::{
16    FormXObject, ImageXObject, XObject, draw_form_xobject, draw_image_xobject, draw_xobject,
17};
18use kurbo::{Affine, Point, Shape};
19use log::warn;
20use pdf_syntax::content::ops::TypedInstruction;
21use pdf_syntax::object::dict::keys::{
22    ACTUAL_TEXT, ANNOTS, AP, AS, BTN, CH, DV, F, FF, FT, KIDS, MCID, N, OC, OPT, PARENT, RECT,
23    SUBTYPE, TX, V, WIDGET,
24};
25use pdf_syntax::object::{
26    Array, Dict, Name, Object, Rect, Stream, String as PdfString, dict_or_stream,
27};
28use pdf_syntax::page::{Page, Resources};
29use smallvec::smallvec;
30use std::sync::{Arc, OnceLock};
31
32pub(crate) mod path;
33pub(crate) mod state;
34pub(crate) mod text;
35
36pub use state::ActiveTransferFunction;
37
38/// A callback function for resolving font queries.
39///
40/// The first argument is the raw data, the second argument is the index in case the font
41/// is a TTC, otherwise it should be 0.
42pub type FontResolverFn = Arc<dyn Fn(&FontQuery) -> Option<(FontData, u32)> + Send + Sync>;
43/// A callback function for resolving cmap names to their files.
44pub type CMapResolverFn =
45    Arc<dyn Fn(pdf_font::cmap::CMapName<'_>) -> Option<&'static [u8]> + Send + Sync>;
46/// A callback function for resolving warnings during interpretation.
47pub type WarningSinkFn = Arc<dyn Fn(InterpreterWarning) + Send + Sync>;
48
49#[derive(Clone)]
50/// Settings that should be applied during the interpretation process.
51pub struct InterpreterSettings {
52    /// Nearly every PDF contains text. In most cases, PDF files embed the fonts they use, and
53    /// pdf-interpret can therefore read the font files and do all the processing needed. However, there
54    /// are two problems:
55    /// - Fonts don't _have_ to be embedded, it's possible that the PDF file only defines the basic
56    ///   metadata of the font, like its name, but relies on the PDF processor to find that font
57    ///   in its environment.
58    /// - The PDF specification requires a list of 14 fonts that should always be available to a
59    ///   PDF processor. These include:
60    ///   - Times New Roman (Normal, Bold, Italic, `BoldItalic`)
61    ///   - Courier (Normal, Bold, Italic, `BoldItalic`)
62    ///   - Helvetica (Normal, Bold, Italic, `BoldItalic`)
63    ///   - `ZapfDingBats`
64    ///   - Symbol
65    ///
66    /// Because of this, if any of the above situations occurs, this callback will be called, which
67    /// expects the data of an appropriate font to be returned, if available. If no such font is
68    /// provided, the text will most likely fail to render.
69    ///
70    /// For the font data, there are two different formats that are accepted:
71    /// - Any valid TTF/OTF font.
72    /// - A valid CFF font program.
73    ///
74    /// The following recommendations are given for the implementation of this callback function.
75    ///
76    /// For the standard fonts, in case the original fonts are available on the system, you should
77    /// just return those. Otherwise, for Helvetica, Courier and Times New Roman, the best alternative
78    /// are the corresponding fonts of the [Liberation font family](https://github.com/liberationfonts/liberation-fonts).
79    /// If you prefer smaller fonts, you can use the [Foxit CFF fonts](https://github.com/LaurenzV/pdf-interpret/tree/master/assets/standard_fonts),
80    /// which are much smaller but are missing glyphs for certain scripts.
81    ///
82    /// For the `Symbol` and `ZapfDingBats` fonts, you should also prefer the system fonts, and if
83    /// not available to you, you can, similarly to above, use the corresponding fonts from Foxit.
84    ///
85    /// If you don't want having to deal with this, you can just enable the `embed-fonts` feature
86    /// and use the default implementation of the callback.
87    pub font_resolver: FontResolverFn,
88    /// A callback for resolving cmaps that aren't embedded.
89    ///
90    /// When the PDF requires using a cmap that is not directly embedded in the PDF,
91    /// this callback will be called to attempt fetching the data of the file.
92    ///
93    /// When the `embed-cmaps` feature is enabled, this uses `load_embedded`
94    /// method from `pdf-interpret-cmap` by default, which embeds the cmap files for
95    /// all 61 predefined cmaps
96    /// that the PDF specification requires to be readily available on a system.
97    /// Otherwise, you can implement your custom logic for lazily fetching the
98    /// data. If you are fine not supporting such PDFs, you can simply pass a closure
99    /// that always returns `None`.
100    pub cmap_resolver: CMapResolverFn,
101    /// In certain cases, `pdf-interpret` will emit a warning in case an issue was encountered while interpreting
102    /// the PDF file. Providing a callback allows you to catch those warnings and handle them, if desired.
103    pub warning_sink: WarningSinkFn,
104    /// Whether annotations should be rendered as well.
105    ///
106    /// Note that this feature is currently not fully implemented yet, so some
107    /// annotations might be missing.
108    pub render_annotations: bool,
109    /// Whether to skip `/FT /Sig` (signature widget) appearance streams.
110    ///
111    /// Rendering sets this to `true` to match MuPDF behaviour, but text
112    /// extraction should set it to `false` so that signature text is included.
113    pub skip_signature_widgets: bool,
114    /// Maximum number of content-stream operators to interpret.
115    ///
116    /// `None` preserves the historical unlimited behavior for callers that do
117    /// not configure processing limits.
118    pub max_operator_count: Option<u64>,
119    /// A shared cache to reuse between page interpretations (specifically for images).
120    pub shared_cache: Option<crate::Cache>,
121}
122
123/// Known paths for CJK fonts, ordered by preference.
124/// Covers macOS, Ubuntu/Debian, Fedora/RHEL, and Alpine Linux.
125#[cfg(feature = "embed-fonts")]
126const CJK_FONT_CANDIDATE_PATHS: &[&str] = &[
127    // macOS — ships with every installation
128    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
129    // Noto CJK — most common on Linux
130    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
131    "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
132    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
133    "/usr/share/fonts/noto-cjk/NotoSansCJKsc-Regular.otf",
134    // WenQuanYi — fallback on older Ubuntu/Debian systems
135    "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
136    "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
137    // Arphic (traditional)
138    "/usr/share/fonts/truetype/arphic/uming.ttc",
139    // Alpine Linux
140    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
141];
142
143/// Lazily loaded CJK system font bytes.  `None` means no CJK font was found.
144#[cfg(feature = "embed-fonts")]
145static SYSTEM_CJK_FONT: OnceLock<Option<Arc<Vec<u8>>>> = OnceLock::new();
146
147/// Try to load a CJK font from the host system, returning its raw bytes.
148#[cfg(feature = "embed-fonts")]
149fn system_cjk_font() -> Option<FontData> {
150    SYSTEM_CJK_FONT
151        .get_or_init(|| {
152            for path in CJK_FONT_CANDIDATE_PATHS {
153                if let Ok(bytes) = std::fs::read(path) {
154                    log::debug!("CJK fallback font loaded from {path}");
155                    return Some(Arc::new(bytes));
156                }
157            }
158            log::warn!(
159                "no system CJK font found; non-embedded CJK fonts will render with a Latin fallback"
160            );
161            None
162        })
163        .as_ref()
164        .map(|data| -> FontData { data.clone() })
165}
166
167impl Default for InterpreterSettings {
168    fn default() -> Self {
169        Self {
170            #[cfg(not(feature = "embed-fonts"))]
171            font_resolver: Arc::new(|_| None),
172            #[cfg(feature = "embed-fonts")]
173            font_resolver: Arc::new(|query| match query {
174                FontQuery::Standard(s) => Some(s.get_font_data()),
175                FontQuery::Fallback(f) => {
176                    // For non-embedded CJK fonts (Adobe-GB1, CNS1, Japan1, Korea1)
177                    // try a system CJK font first so characters render correctly.
178                    // This avoids the situation where a Latin fallback font is used
179                    // and Chinese/Japanese/Korean glyphs appear as "d", "a", etc.
180                    if f.character_collection
181                        .as_ref()
182                        .is_some_and(|cc| cc.family.is_cjk())
183                        && let Some(data) = system_cjk_font()
184                    {
185                        return Some((data, 0));
186                    }
187                    Some(f.pick_standard_font().get_font_data())
188                }
189            }),
190            #[cfg(feature = "embed-cmaps")]
191            cmap_resolver: Arc::new(pdf_font::cmap::load_embedded),
192            #[cfg(not(feature = "embed-cmaps"))]
193            cmap_resolver: Arc::new(|_| None),
194            warning_sink: Arc::new(|_| {}),
195            render_annotations: true,
196            skip_signature_widgets: true,
197            max_operator_count: None,
198            shared_cache: None,
199        }
200    }
201}
202
203#[derive(Copy, Clone, Debug)]
204/// Warnings that can occur while interpreting a PDF file.
205pub enum InterpreterWarning {
206    /// An unsupported font kind was encountered.
207    ///
208    /// Currently, only CID fonts with non-identity encoding are unsupported.
209    UnsupportedFont,
210    /// An image failed to decode.
211    ImageDecodeFailure,
212    /// A stream exceeded the configured `max_stream_bytes` cap during
213    /// image decode.  Must not be silently discarded — propagate as
214    /// `LimitError::StreamTooLarge` / `Error::ResourceLimitExceeded`.
215    ///
216    /// Both fields are `u64` so the variant remains `Copy`.
217    StreamTooLarge {
218        /// Observed decompressed size in bytes.
219        observed: u64,
220        /// Configured limit in bytes.
221        limit: u64,
222    },
223    /// Content stream nesting hit `MAX_NESTED_INTERPRETATION_DEPTH` and the
224    /// rest of that branch was not interpreted (#318).
225    ///
226    /// Reaching this means paint is MISSING from the page: a caller that
227    /// renders a thumbnail may not care, one that rasterises for archival
228    /// should. It was a `warn!` line only, which a library embedder never sees
229    /// -- the depth bound turns a crash into a silent omission, and a silent
230    /// omission is exactly what a warning channel is for.
231    NestingTooDeep {
232        /// The limit that was hit.
233        limit: u32,
234    },
235}
236
237/// Resolve the normal (`/N`) appearance stream of an annotation.
238///
239/// Per ISO 32000 §12.5.5 and Table 168, the `/N` entry of the `/AP`
240/// dictionary is either an appearance stream or an appearance *subdictionary*
241/// mapping appearance-state names to streams (the latter is used by every
242/// checkbox and radio button, e.g. `/N << /Yes <stream> /Off <stream> >>`).
243///
244/// In the subdictionary case the stream is selected by the annotation's
245/// `/AS` entry (Table 168: "The annotation's appearance state, which
246/// selects the applicable appearance stream from an appearance
247/// subdictionary"). When `/AS` is absent, this follows pdfium's
248/// `GetAnnotAPInternal` fallback: the widget's own `/V` value as a name,
249/// then the `/Parent`'s `/V` (one level), accepting a candidate only if it
250/// exists as a key in the subdictionary. If no candidate resolves to an
251/// existing key, `None` is returned and nothing is rendered for the
252/// annotation (correct for e.g. `/AS /Off` when the subdictionary has no
253/// `/Off` entry).
254///
255/// All key matching is done on raw name bytes — appearance-state names may
256/// contain non-ASCII bytes and must never go through a lossy UTF-8
257/// conversion.
258fn normal_appearance_stream<'a>(annot: &Dict<'a>) -> Option<Stream<'a>> {
259    let ap = annot.get::<Dict<'_>>(AP)?;
260
261    // Single appearance stream: use it directly.
262    if let Some(stream) = ap.get::<Stream<'_>>(N) {
263        return Some(stream);
264    }
265
266    // Appearance subdictionary: select the stream by appearance state.
267    let states = ap.get::<Dict<'_>>(N)?;
268
269    if let Some(state) = annot.get::<Name>(AS) {
270        // An explicit /AS is authoritative; if its entry is missing, no
271        // appearance is rendered.
272        return states.get::<Stream<'_>>(state.as_ref());
273    }
274
275    // pdfium V-fallback: the widget's own /V, then the parent's /V, the
276    // first candidate that exists as a key in the subdictionary wins.
277    let candidates = [
278        annot.get::<Name>(V),
279        annot.get::<Dict<'_>>(PARENT).and_then(|p| p.get::<Name>(V)),
280    ];
281
282    candidates
283        .into_iter()
284        .flatten()
285        .find_map(|state| states.get::<Stream<'_>>(state.as_ref()))
286}
287
288/// `/F` bit 2: the annotation is not shown at all.
289const ANNOT_FLAG_HIDDEN: u32 = 1 << 1;
290/// `/F` bit 6: the annotation prints but is not shown on screen.
291const ANNOT_FLAG_NOVIEW: u32 = 1 << 5;
292
293/// How many `/Parent` links a field-attribute lookup follows.
294///
295/// Field attributes are inherited up the field tree (ISO 32000-2 §12.7.4.2)
296/// and nothing in a file stops that chain from pointing back at itself. The
297/// bound is what makes the walk terminate on a file that does.
298const FIELD_INHERITANCE_DEPTH: usize = 32;
299
300/// `/Ff` bit 17: the field is a push button and holds no value.
301const FIELD_FLAG_PUSHBUTTON: u32 = 1 << 16;
302/// `/Ff` bit 14: the text field's value is a password and is not to be shown.
303const FIELD_FLAG_PASSWORD: u32 = 1 << 13;
304
305/// The nearest value of an inherited field attribute: the widget's own entry,
306/// else the first ancestor that has one.
307fn inherited_field_entry<'a, T>(
308    annot: &Dict<'a>,
309    entry: impl Fn(&Dict<'a>) -> Option<T>,
310) -> Option<T> {
311    let mut node = annot.clone();
312
313    for _ in 0..FIELD_INHERITANCE_DEPTH {
314        if let Some(value) = entry(&node) {
315            return Some(value);
316        }
317
318        node = node.get::<Dict<'a>>(PARENT)?;
319    }
320
321    None
322}
323
324/// A name read as the text it stands for, or nothing when its bytes are not
325/// UTF-8.
326///
327/// Export values (`/Yes`, `/On`, a generated `/1`) are ASCII in every file
328/// that has ever been measured here, and a name that is not valid UTF-8 is
329/// not a string a reader may invent characters for.
330fn name_as_text(name: &Name) -> Option<String> {
331    std::str::from_utf8(name.as_ref()).ok().map(str::to_string)
332}
333
334/// What a choice field's control shows for one of its values.
335///
336/// `/Opt` is either a list of strings, in which case the value is the text, or
337/// a list of `[export, display]` pairs, in which case `/V` holds the export
338/// value and the control shows the display one -- `NL` in the file and
339/// `Netherlands` on the page. Extraction wants what is on the page.
340///
341/// An editable combo box may hold a value that is in no `/Opt` entry, because
342/// the user typed it; that value is its own label. (codex, #549)
343fn choice_option_label(annot: &Dict<'_>, value: &str) -> String {
344    let Some(options) = inherited_field_entry(annot, |d| d.get::<Array<'_>>(OPT)) else {
345        return value.to_string();
346    };
347
348    for option in options.iter::<Array<'_>>() {
349        let mut pair = option.iter::<PdfString>();
350        let Some(export) = pair.next().and_then(|s| decode_text_string(s.as_bytes())) else {
351            continue;
352        };
353        let Some(display) = pair.next().and_then(|s| decode_text_string(s.as_bytes())) else {
354            continue;
355        };
356
357        if export == value {
358            return display;
359        }
360    }
361
362    value.to_string()
363}
364
365/// The value of a choice field, which is one string or several.
366fn choice_value_text<'a>(annot: &Dict<'a>, key: &'static [u8]) -> Option<String> {
367    if let Some(value) = inherited_field_entry(annot, |d| d.get::<PdfString>(key)) {
368        let value = decode_text_string(value.as_bytes())?;
369        return Some(choice_option_label(annot, &value));
370    }
371
372    // A multi-select list box holds its selected options in an array. They are
373    // separate lines: they are separate rows of the control, and running them
374    // together would invent a word that is on no page.
375    let options = inherited_field_entry(annot, |d| d.get::<Array<'a>>(key))?;
376    let lines: Vec<String> = options
377        .iter::<PdfString>()
378        .filter_map(|option| decode_text_string(option.as_bytes()))
379        .map(|value| choice_option_label(annot, &value))
380        .collect();
381
382    (!lines.is_empty()).then(|| lines.join("\n"))
383}
384
385/// Whether this widget is the only one its field could be showing.
386///
387/// A radio group is one field with one `/V` and several widgets, and `/V`
388/// names the state of whichever of them is on. Reading it as every widget's
389/// own state prints the selected option once per option in the group. Only
390/// `/AS` is per widget, so the field's value answers for this widget when
391/// there is no other widget it could be answering for: a merged field and
392/// widget in one dictionary, or a field with a single kid. (codex, #549)
393fn is_the_only_widget_of_its_field(annot: &Dict<'_>) -> bool {
394    let Some(parent) = annot.get::<Dict<'_>>(PARENT) else {
395        return true;
396    };
397
398    match parent.get::<Array<'_>>(KIDS) {
399        Some(kids) => kids.raw_iter().take(2).count() < 2,
400        None => true,
401    }
402}
403
404/// The text a field widget stands for, for a widget that carries no appearance
405/// stream for a reader to take it from (#549).
406///
407/// `/V` first and `/DV` after it: the default value is what the field shows
408/// until somebody fills it, so it is the text on the page exactly when there
409/// is no `/V`.
410///
411/// Three field types answer, and the fourth deliberately does not. `/Sig` has
412/// no text value -- `/V` is a signature dictionary -- and a push button's
413/// caption lives in `/MK /CA`, which is a label printed on a control rather
414/// than content of the document.
415fn field_value_text(annot: &Dict<'_>) -> Option<String> {
416    let field_type = inherited_field_entry(annot, |d| d.get::<Name>(FT))?;
417    let flags = inherited_field_entry(annot, |d| d.get::<u32>(FF)).unwrap_or(0);
418
419    match field_type.as_ref() {
420        BTN => {
421            if flags & FIELD_FLAG_PUSHBUTTON != 0 {
422                return None;
423            }
424
425            // The state this widget is in, which for a radio group is the one
426            // thing that distinguishes the kids from each other: `/AS` is per
427            // widget while `/V` is on the field they share. `/AS` therefore
428            // wins, and `/V` answers only for a widget that has none.
429            let state = annot.get::<Name>(AS).or_else(|| {
430                is_the_only_widget_of_its_field(annot)
431                    .then(|| {
432                        inherited_field_entry(annot, |d| d.get::<Name>(V))
433                            .or_else(|| inherited_field_entry(annot, |d| d.get::<Name>(DV)))
434                    })
435                    .flatten()
436            })?;
437
438            // `/Off` is the one state name the specification fixes, and it is
439            // the empty box. A reader that wrote "Off" into the text would put
440            // a word on the page for every box nobody ticked.
441            if state.as_ref() == b"Off" {
442                return None;
443            }
444
445            name_as_text(&state)
446        }
447        TX => {
448            if flags & FIELD_FLAG_PASSWORD != 0 {
449                return None;
450            }
451
452            // A comb field (`/Ff` bit 25) draws its value one character per
453            // cell of `/MaxLen`. The cells are a layout and the value is the
454            // same string either way, so nothing here reads them.
455            let value = inherited_field_entry(annot, |d| d.get::<PdfString>(V))
456                .or_else(|| inherited_field_entry(annot, |d| d.get::<PdfString>(DV)))?;
457            let text = decode_text_string(value.as_bytes())?;
458
459            (!text.trim().is_empty()).then_some(text)
460        }
461        CH => choice_value_text(annot, V).or_else(|| choice_value_text(annot, DV)),
462        _ => None,
463    }
464}
465
466/// interpret the contents of the page and render them into the device.
467pub fn interpret_page<'a>(
468    page: &Page<'a>,
469    context: &mut Context<'a>,
470    device: &mut impl Device<'a>,
471) {
472    let resources = page.resources();
473    interpret(page.typed_operations(), resources, context, device);
474
475    if context.settings.render_annotations
476        && let Some(annot_arr) = page.raw().get::<Array<'_>>(ANNOTS)
477    {
478        for annot in annot_arr.iter::<Dict<'_>>() {
479            let flags = annot.get::<u32>(F).unwrap_or(0);
480
481            // Annotation should be hidden.
482            if flags & ANNOT_FLAG_HIDDEN != 0 {
483                continue;
484            }
485
486            // MuPDF renders signature widgets (/FT /Sig) with its own built-in
487            // "SIGN here" indicator and ignores the custom /AP/N stream, so we
488            // skip AP rendering for these annotations to match MuPDF output.
489            // Text extraction disables this skip so signature text is included.
490            if context.settings.skip_signature_widgets
491                && annot
492                    .get::<Name>(FT)
493                    .as_deref()
494                    .is_some_and(|n| n == b"Sig")
495            {
496                continue;
497            }
498
499            if let Some(apx) = normal_appearance_stream(&annot)
500                .and_then(|o| FormXObject::new(&o, &context.settings.warning_sink))
501            {
502                let Some(rect) = annot.get::<Rect>(RECT) else {
503                    continue;
504                };
505
506                let annot_rect = rect.to_kurbo();
507                // 12.5.5. Appearance streams
508                // "The algorithm outlined in this subclause shall be used
509                // to map from the coordinate system of the appearance XObject."
510
511                // 1) The appearance’s bounding box (specified by its BBox entry)
512                // shall be transformed, using Matrix, to produce a
513                // quadrilateral with arbitrary orientation. The transformed
514                // appearance box is the smallest upright rectangle that
515                // encompasses this quadrilateral.
516                let transformed_rect = (apx.matrix
517                    * kurbo::Rect::new(
518                        apx.bbox[0] as f64,
519                        apx.bbox[1] as f64,
520                        apx.bbox[2] as f64,
521                        apx.bbox[3] as f64,
522                    )
523                    .to_path(0.1))
524                .bounding_box();
525
526                // A degenerate (zero-width or zero-height) transformed
527                // appearance box would make the scale computation below
528                // divide by zero, producing a non-finite (inf/NaN) affine.
529                // Skip such annotations entirely.
530                let (tw, th) = (transformed_rect.width(), transformed_rect.height());
531                if !(tw.is_finite() && tw > 0.0 && th.is_finite() && th > 0.0) {
532                    continue;
533                }
534
535                // 2) A matrix A shall be computed that scales and translates
536                // the transformed appearance box to align with the edges
537                // of the annotation’s rectangle (specified by the Rect entry).
538                // A maps the lower-left corner (the corner with the smallest
539                // x and y coordinates) and the upper-right corner (the
540                // corner with the greatest x and y coordinates) of the
541                // transformed appearance box to the corresponding corners
542                // of the annotation’s rectangle.
543                let affine = Affine::new([
544                    annot_rect.width() / transformed_rect.width(),
545                    0.0,
546                    0.0,
547                    annot_rect.height() / transformed_rect.height(),
548                    annot_rect.x0 - transformed_rect.x0,
549                    annot_rect.y0 - transformed_rect.y0,
550                ]);
551
552                // 3) Matrix shall be concatenated with A to form a matrix
553                // AA that maps from the appearance’s coordinate system to
554                // the annotation’s rectangle in default user space.
555                context.save_state();
556                context.pre_concat_affine(affine);
557                context.push_root_transform();
558
559                draw_form_xobject(resources, &apx, context, device);
560                context.pop_root_transform();
561                context.restore_state(device);
562            } else if annot.get::<Name>(SUBTYPE).as_deref() == Some(WIDGET)
563                && flags & ANNOT_FLAG_NOVIEW == 0
564                && let Some(text) = field_value_text(&annot)
565                && let Some(rect) = annot.get::<Rect>(RECT)
566            {
567                // No appearance stream resolved, and the field has a value: the
568                // only place that value exists on this page is the field
569                // dictionary, and a reader of glyphs alone comes away with
570                // nothing (#549). `/NeedAppearances` is not consulted, and the
571                // condition is deliberately "no appearance" rather than "has a
572                // value": a stale appearance next to a newer `/V` is still an
573                // appearance, and taking both would print the value twice.
574                //
575                // `/NoView` is honoured here and not in the branch above,
576                // where an appearance stream is read whether or not the
577                // annotation is for the screen. That asymmetry is not an
578                // oversight -- it is the measured behaviour of the reader this
579                // extraction is graded against, and narrowing it is a change
580                // that removes text, which needs its own document and its own
581                // measurement.
582                device.draw_placed_text(
583                    &text,
584                    context.get().ctm.transform_rect_bbox(rect.to_kurbo()),
585                );
586            }
587        }
588    }
589}
590
591/// Interpret the instructions from `ops` and render them into the device.
592pub fn interpret<'a, 'b>(
593    ops: impl Iterator<Item = TypedInstruction<'b>>,
594    resources: &Resources<'a>,
595    context: &mut Context<'a>,
596    device: &mut impl Device<'a>,
597) {
598    // One choke point for every nested interpretation, because every one of them
599    // arrives here: a Form XObject drawing another, a tiling pattern painting
600    // with a pattern, a soft mask, a Type 3 glyph procedure. Guarding the call
601    // sites instead would mean four guards and a fifth construct next year.
602    //
603    // Reproduced before the bound existed: a 639-byte file whose XObject `/X1`
604    // contains `q /X1 Do Q` aborts the process with a stack overflow, rc=134.
605    // No XFA and no script, so it reaches every binding exporting `render_page`.
606    if !context.begin_nested_interpretation() {
607        let limit = crate::context::MAX_NESTED_INTERPRETATION_DEPTH;
608        warn!("content stream nesting exceeds {limit}, stopping interpretation");
609        // Also to the sink, because the log line is invisible to a library
610        // caller and this is a silent loss of paint, not a diagnostic.
611        (context.settings.warning_sink)(InterpreterWarning::NestingTooDeep { limit });
612
613        return;
614    }
615
616    let num_states = context.num_states();
617    let max_operator_count = context.settings.max_operator_count.unwrap_or(u64::MAX);
618    let mut operator_count = 0_u64;
619
620    context.save_state();
621
622    for op in ops {
623        operator_count = operator_count.saturating_add(1);
624        if operator_count > max_operator_count {
625            warn!(
626                "content stream operator count exceeds {max_operator_count}, stopping interpretation"
627            );
628            break;
629        }
630
631        match op {
632            TypedInstruction::SaveState(_) => context.save_state(),
633            TypedInstruction::StrokeColorDeviceRgb(s) => {
634                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_rgb();
635                context.get_mut().graphics_state.stroke_color =
636                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
637            }
638            TypedInstruction::StrokeColorDeviceGray(s) => {
639                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_gray();
640                context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32()];
641            }
642            TypedInstruction::StrokeColorCmyk(s) => {
643                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_cmyk();
644                context.get_mut().graphics_state.stroke_color =
645                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
646            }
647            TypedInstruction::LineWidth(w) => {
648                context.get_mut().graphics_state.stroke_props.line_width = w.0.as_f32();
649            }
650            TypedInstruction::LineCap(c) => {
651                context.get_mut().graphics_state.stroke_props.line_cap = convert_line_cap(c);
652            }
653            TypedInstruction::LineJoin(j) => {
654                context.get_mut().graphics_state.stroke_props.line_join = convert_line_join(j);
655            }
656            TypedInstruction::MiterLimit(l) => {
657                context.get_mut().graphics_state.stroke_props.miter_limit = l.0.as_f32();
658            }
659            TypedInstruction::Transform(t) => {
660                context.pre_concat_transform(t);
661            }
662            TypedInstruction::RectPath(r) => {
663                let rect = kurbo::Rect::new(
664                    r.0.as_f64(),
665                    r.1.as_f64(),
666                    r.0.as_f64() + r.2.as_f64(),
667                    r.1.as_f64() + r.3.as_f64(),
668                )
669                .to_path(0.1);
670                context.path_mut().extend(rect);
671            }
672            TypedInstruction::MoveTo(m) => {
673                let p = Point::new(m.0.as_f64(), m.1.as_f64());
674                *(context.last_point_mut()) = p;
675                *(context.sub_path_start_mut()) = p;
676                context.path_mut().move_to(p);
677            }
678            TypedInstruction::FillPathEvenOdd(_) => {
679                fill_path(context, device, FillRule::EvenOdd);
680            }
681            TypedInstruction::FillPathNonZero(_) => {
682                fill_path(context, device, FillRule::NonZero);
683            }
684            TypedInstruction::FillPathNonZeroCompatibility(_) => {
685                fill_path(context, device, FillRule::NonZero);
686            }
687            TypedInstruction::FillAndStrokeEvenOdd(_) => {
688                fill_stroke_path(context, device, FillRule::EvenOdd);
689            }
690            TypedInstruction::FillAndStrokeNonZero(_) => {
691                fill_stroke_path(context, device, FillRule::NonZero);
692            }
693            TypedInstruction::CloseAndStrokePath(_) => {
694                close_path(context);
695                stroke_path(context, device);
696            }
697            TypedInstruction::CloseFillAndStrokeEvenOdd(_) => {
698                close_path(context);
699                fill_stroke_path(context, device, FillRule::EvenOdd);
700            }
701            TypedInstruction::CloseFillAndStrokeNonZero(_) => {
702                close_path(context);
703                fill_stroke_path(context, device, FillRule::NonZero);
704            }
705            TypedInstruction::NonStrokeColorDeviceGray(s) => {
706                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_gray();
707                context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32()];
708            }
709            TypedInstruction::NonStrokeColorDeviceRgb(s) => {
710                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_rgb();
711                context.get_mut().graphics_state.non_stroke_color =
712                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
713            }
714            TypedInstruction::NonStrokeColorCmyk(s) => {
715                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_cmyk();
716                context.get_mut().graphics_state.non_stroke_color =
717                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
718            }
719            TypedInstruction::LineTo(m) => {
720                if !context.path().elements().is_empty() {
721                    let last_point = *context.last_point();
722                    let mut p = Point::new(m.0.as_f64(), m.1.as_f64());
723                    *(context.last_point_mut()) = p;
724                    if last_point == p {
725                        // Add a small delta so that zero width lines can still have a round stroke.
726                        p.x += 0.0001;
727                    }
728
729                    context.path_mut().line_to(p);
730                }
731            }
732            TypedInstruction::CubicTo(c) => {
733                if !context.path().elements().is_empty() {
734                    let p1 = Point::new(c.0.as_f64(), c.1.as_f64());
735                    let p2 = Point::new(c.2.as_f64(), c.3.as_f64());
736                    let p3 = Point::new(c.4.as_f64(), c.5.as_f64());
737
738                    *(context.last_point_mut()) = p3;
739
740                    context.path_mut().curve_to(p1, p2, p3);
741                }
742            }
743            TypedInstruction::CubicStartTo(c) => {
744                if !context.path().elements().is_empty() {
745                    let p1 = *context.last_point();
746                    let p2 = Point::new(c.0.as_f64(), c.1.as_f64());
747                    let p3 = Point::new(c.2.as_f64(), c.3.as_f64());
748
749                    *(context.last_point_mut()) = p3;
750
751                    context.path_mut().curve_to(p1, p2, p3);
752                }
753            }
754            TypedInstruction::CubicEndTo(c) => {
755                if !context.path().elements().is_empty() {
756                    let p2 = Point::new(c.0.as_f64(), c.1.as_f64());
757                    let p3 = Point::new(c.2.as_f64(), c.3.as_f64());
758
759                    *(context.last_point_mut()) = p3;
760
761                    context.path_mut().curve_to(p2, p3, p3);
762                }
763            }
764            TypedInstruction::ClosePath(_) => {
765                close_path(context);
766            }
767            TypedInstruction::SetGraphicsState(gs) => {
768                if let Some(gs) = resources
769                    .get_ext_g_state(gs.0.clone())
770                    .warn_none(&format!("failed to get extgstate {}", gs.0.as_str()))
771                {
772                    handle_gs(&gs, context, resources);
773                }
774            }
775            TypedInstruction::StrokePath(_) => {
776                stroke_path(context, device);
777            }
778            TypedInstruction::EndPath(_) => {
779                if let Some(clip) = *context.clip()
780                    && !context.path().elements().is_empty()
781                {
782                    let clip_path = context.get().ctm * context.path().clone();
783                    context.push_clip_path(clip_path, clip, device);
784
785                    *(context.clip_mut()) = None;
786                }
787
788                context.path_mut().truncate(0);
789            }
790            TypedInstruction::NonStrokeColor(c) => {
791                let fill_c = &mut context.get_mut().graphics_state.non_stroke_color;
792                fill_c.truncate(0);
793
794                for e in c.0 {
795                    fill_c.push(e.as_f32());
796                }
797            }
798            TypedInstruction::StrokeColor(c) => {
799                let stroke_c = &mut context.get_mut().graphics_state.stroke_color;
800                stroke_c.truncate(0);
801
802                for e in c.0 {
803                    stroke_c.push(e.as_f32());
804                }
805            }
806            TypedInstruction::ClipNonZero(_) => {
807                *(context.clip_mut()) = Some(FillRule::NonZero);
808            }
809            TypedInstruction::ClipEvenOdd(_) => {
810                *(context.clip_mut()) = Some(FillRule::EvenOdd);
811            }
812            TypedInstruction::RestoreState(_) => context.restore_state(device),
813            TypedInstruction::FlatnessTolerance(_) => {
814                // Ignore for now.
815            }
816            TypedInstruction::ColorSpaceStroke(c) => {
817                let cs = if let Some(named) = ColorSpace::new_from_name(c.0.clone()) {
818                    named
819                } else {
820                    context
821                        .get_color_space(resources, c.0)
822                        .unwrap_or(ColorSpace::device_gray())
823                };
824
825                context.get_mut().graphics_state.stroke_color = cs.initial_color();
826                context.get_mut().graphics_state.stroke_cs = cs;
827            }
828            TypedInstruction::ColorSpaceNonStroke(c) => {
829                let cs = if let Some(named) = ColorSpace::new_from_name(c.0.clone()) {
830                    named
831                } else {
832                    context
833                        .get_color_space(resources, c.0)
834                        .unwrap_or(ColorSpace::device_gray())
835                };
836
837                context.get_mut().graphics_state.non_stroke_color = cs.initial_color();
838                context.get_mut().graphics_state.none_stroke_cs = cs;
839            }
840            TypedInstruction::DashPattern(p) => {
841                context.get_mut().graphics_state.stroke_props.dash_offset = p.1.as_f32();
842                // kurbo apparently cannot properly deal with offsets that are exactly 0.
843                context.get_mut().graphics_state.stroke_props.dash_array =
844                    p.0.iter::<f32>()
845                        .map(|n| if n == 0.0 { 0.01 } else { n })
846                        .collect();
847            }
848            TypedInstruction::RenderingIntent(_) => {
849                // Ignore for now.
850            }
851            TypedInstruction::NonStrokeColorNamed(n) => {
852                context.get_mut().graphics_state.non_stroke_color =
853                    n.0.into_iter().map(|n| n.as_f32()).collect();
854                context.get_mut().graphics_state.non_stroke_pattern = n.1.and_then(|name| {
855                    resources
856                        .get_pattern(name)
857                        .and_then(|d| Pattern::new(d, context, resources))
858                });
859            }
860            TypedInstruction::StrokeColorNamed(n) => {
861                context.get_mut().graphics_state.stroke_color =
862                    n.0.into_iter().map(|n| n.as_f32()).collect();
863                context.get_mut().graphics_state.stroke_pattern = n.1.and_then(|name| {
864                    resources
865                        .get_pattern(name)
866                        .and_then(|d| Pattern::new(d, context, resources))
867                });
868            }
869            TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
870                // Properties can be either:
871                // 1. A Name that references an entry in the Resources/Properties dictionary
872                // 2. An inline dictionary with an OC key
873
874                let props = bdc
875                    .1
876                    .clone()
877                    .into_name()
878                    .and_then(|name| resources.properties.get::<Dict<'_>>(name))
879                    .or_else(|| dict_or_stream(&bdc.1).map(|(props, _)| props));
880
881                let mcid = props.as_ref().and_then(|p| p.get::<i32>(MCID));
882
883                // /ActualText (§14.9.4): the characters this sequence stands
884                // for, whatever its glyphs decode to. It is the only thing
885                // that reads `6-2-11-7-3-t01-pass-e.pdf`, whose one glyph
886                // maps into the private-use area and whose span says `ABC`.
887                let actual_text = props
888                    .as_ref()
889                    .and_then(|p| p.get::<PdfString>(ACTUAL_TEXT))
890                    .and_then(|s| decode_text_string(s.as_bytes()));
891
892                let oc = bdc
893                    .1
894                    .clone()
895                    .into_name()
896                    .and_then(|name| {
897                        let r = resources.properties.get_ref(name.clone())?;
898                        let d = resources
899                            .properties
900                            .get::<Dict<'_>>(name)
901                            .unwrap_or_default();
902                        Some((d, r))
903                    })
904                    .or_else(|| {
905                        let (props, _) = dict_or_stream(&bdc.1)?;
906                        let r = props.get_ref(OC)?;
907                        let d = props.get::<Dict<'_>>(OC).unwrap_or_default();
908                        Some((d, r))
909                    });
910
911                if let Some((dict, oc_ref)) = oc {
912                    context.ocg_state.begin_ocg(&dict, oc_ref.into());
913                } else {
914                    context.ocg_state.begin_marked_content();
915                }
916
917                device.begin_marked_content(&bdc.0, mcid, actual_text.as_deref());
918            }
919            TypedInstruction::MarkedContentPointWithProperties(_) => {}
920            TypedInstruction::EndMarkedContent(_) => {
921                context.ocg_state.end_marked_content();
922                device.end_marked_content();
923            }
924            TypedInstruction::MarkedContentPoint(_) => {}
925            TypedInstruction::BeginMarkedContent(bmc) => {
926                context.ocg_state.begin_marked_content();
927                device.begin_marked_content(&bmc.0, None, None);
928            }
929            TypedInstruction::BeginText(_) => {
930                context.get_mut().text_state.text_matrix = Affine::IDENTITY;
931                context.get_mut().text_state.text_line_matrix = Affine::IDENTITY;
932            }
933            TypedInstruction::SetTextMatrix(m) => {
934                let m = Affine::new([
935                    m.0.as_f64(),
936                    m.1.as_f64(),
937                    m.2.as_f64(),
938                    m.3.as_f64(),
939                    m.4.as_f64(),
940                    m.5.as_f64(),
941                ]);
942                context.get_mut().text_state.text_line_matrix = m;
943                context.get_mut().text_state.text_matrix = m;
944            }
945            TypedInstruction::EndText(_) => {
946                let has_outline = context
947                    .get()
948                    .text_state
949                    .clip_paths
950                    .segments()
951                    .next()
952                    .is_some();
953
954                if has_outline {
955                    let clip_path = context.get().ctm * context.get().text_state.clip_paths.clone();
956
957                    context.push_clip_path(clip_path, FillRule::NonZero, device);
958                }
959
960                context.get_mut().text_state.clip_paths.truncate(0);
961            }
962            TypedInstruction::TextFont(t) => {
963                let name = t.0;
964
965                // In case we are unable to resolve the font, two scenarios:
966                // 1) If the font doesn't exist in the first place in the resource dictionary,
967                // assume Helvetica (this seems to be what other PDF viewers do).
968                // 2) In case it's `None` because we were unable to resolve the font
969                // (for whatever reason), leave it as `None`. Better showing no
970                // text at all than garbage text.
971                let font = if let Some(font_dict) = resources.get_font(name.clone()) {
972                    context.resolve_font(&font_dict)
973                } else {
974                    Font::new_standard(StandardFont::Helvetica, &context.settings.font_resolver)
975                        .map(TextStateFont::Fallback)
976                };
977
978                context.get_mut().text_state.font_size = t.1.as_f32();
979                context.get_mut().text_state.font = font;
980            }
981            TypedInstruction::ShowText(s) => {
982                if context.get().text_state.font.is_none() {
983                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
984                    // seems to do the same.
985                    context.get_mut().text_state.font = Font::new_standard(
986                        StandardFont::Helvetica,
987                        &context.settings.font_resolver,
988                    )
989                    .map(TextStateFont::Fallback);
990                }
991
992                text::show_text_string(context, device, resources, s.0);
993            }
994            TypedInstruction::ShowTexts(s) => {
995                if context.get().text_state.font.is_none() {
996                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
997                    // seems to do the same.
998                    context.get_mut().text_state.font = Font::new_standard(
999                        StandardFont::Helvetica,
1000                        &context.settings.font_resolver,
1001                    )
1002                    .map(TextStateFont::Fallback);
1003                }
1004
1005                for obj in s.0.iter::<Object<'_>>() {
1006                    if let Some(adjustment) = obj.clone().into_f32() {
1007                        // ANN[r17/TEX1] Surface TJ adjustment to the Device
1008                        // before mutating the text matrix so extractors can
1009                        // record the word-boundary signal alongside the
1010                        // spatial gap they'd otherwise have to infer.
1011                        device.text_adjustment(adjustment);
1012                        context.get_mut().text_state.apply_adjustment(adjustment);
1013                    } else if let Some(text) = obj.into_string() {
1014                        text::show_text_string(context, device, resources, text);
1015                    }
1016                }
1017            }
1018            TypedInstruction::HorizontalScaling(h) => {
1019                context.get_mut().text_state.horizontal_scaling = h.0.as_f32();
1020            }
1021            TypedInstruction::TextLeading(tl) => {
1022                context.get_mut().text_state.leading = tl.0.as_f32();
1023            }
1024            TypedInstruction::CharacterSpacing(c) => {
1025                context.get_mut().text_state.char_space = c.0.as_f32();
1026            }
1027            TypedInstruction::WordSpacing(w) => {
1028                context.get_mut().text_state.word_space = w.0.as_f32();
1029            }
1030            TypedInstruction::NextLine(n) => {
1031                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1032                text::next_line(context, tx, ty);
1033            }
1034            TypedInstruction::NextLineUsingLeading(_) => {
1035                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1036            }
1037            TypedInstruction::NextLineAndShowText(n) => {
1038                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1039                text::show_text_string(context, device, resources, n.0);
1040            }
1041            TypedInstruction::TextRenderingMode(r) => {
1042                let mode = match r.0.as_i64() {
1043                    0 => TextRenderingMode::Fill,
1044                    1 => TextRenderingMode::Stroke,
1045                    2 => TextRenderingMode::FillStroke,
1046                    3 => TextRenderingMode::Invisible,
1047                    4 => TextRenderingMode::FillAndClip,
1048                    5 => TextRenderingMode::StrokeAndClip,
1049                    6 => TextRenderingMode::FillAndStrokeAndClip,
1050                    7 => TextRenderingMode::Clip,
1051                    _ => {
1052                        warn!("unknown text rendering mode {}", r.0.as_i64());
1053
1054                        TextRenderingMode::Fill
1055                    }
1056                };
1057
1058                context.get_mut().text_state.render_mode = mode;
1059            }
1060            TypedInstruction::NextLineAndSetLeading(n) => {
1061                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1062                context.get_mut().text_state.leading = -ty as f32;
1063                text::next_line(context, tx, ty);
1064            }
1065            // d1: uncolored (shape) glyph header.  The advance width (wx) and
1066            // bounding-box arguments are intentionally ignored here: the glyph
1067            // advance is taken from the Type3 font's /Widths array (via
1068            // Font::code_advance), and the is_shape_glyph flag is determined
1069            // by the pre-scan in Type3::render_glyph before the stream is
1070            // interpreted.
1071            TypedInstruction::ShapeGlyph(_) => {}
1072            TypedInstruction::XObject(x) => {
1073                let cache = context.object_cache.clone();
1074                let transfer_function = context.get().graphics_state.transfer_function.clone();
1075                if let Some(x_object) = resources.get_x_object(x.0).and_then(|s| {
1076                    XObject::new(
1077                        &s,
1078                        &context.settings.warning_sink,
1079                        &cache,
1080                        transfer_function.clone(),
1081                    )
1082                }) {
1083                    draw_xobject(&x_object, resources, context, device);
1084                }
1085            }
1086            TypedInstruction::InlineImage(i) => {
1087                let warning_sink = context.settings.warning_sink.clone();
1088                let transfer_function = context.get().graphics_state.transfer_function.clone();
1089                let cache = context.object_cache.clone();
1090                if let Some(x_object) = ImageXObject::new(
1091                    &i.0,
1092                    |name| context.get_color_space(resources, name.clone()),
1093                    &warning_sink,
1094                    &cache,
1095                    false,
1096                    transfer_function,
1097                ) {
1098                    draw_image_xobject(&x_object, context, device);
1099                }
1100            }
1101            TypedInstruction::TextRise(t) => {
1102                context.get_mut().text_state.rise = t.0.as_f32();
1103            }
1104            TypedInstruction::Shading(s) => {
1105                if !context.ocg_state.is_visible() {
1106                    continue;
1107                }
1108
1109                let transfer_function = context.get().graphics_state.transfer_function.clone();
1110
1111                if let Some(sp) = resources
1112                    .get_shading(s.0)
1113                    .and_then(|o| dict_or_stream(&o))
1114                    .and_then(|s| {
1115                        Shading::new(
1116                            &s.0,
1117                            s.1.as_ref(),
1118                            &context.object_cache,
1119                            &context.settings.warning_sink,
1120                        )
1121                    })
1122                    .map(|s| {
1123                        Pattern::Shading(ShadingPattern {
1124                            shading: Arc::new(s),
1125                            matrix: Affine::IDENTITY,
1126                            opacity: context.get().graphics_state.non_stroke_alpha,
1127                            transfer_function: transfer_function.clone(),
1128                        })
1129                    })
1130                {
1131                    context.save_state();
1132                    context.push_root_transform();
1133                    let st = context.get_mut();
1134                    st.graphics_state.non_stroke_pattern = Some(sp);
1135                    st.graphics_state.none_stroke_cs = ColorSpace::pattern();
1136
1137                    device.set_soft_mask(st.graphics_state.soft_mask.clone());
1138                    device.set_blend_mode(st.graphics_state.blend_mode);
1139
1140                    let bbox = context.bbox().to_path(0.1);
1141                    let inverted_bbox = context.get().ctm.inverse() * bbox;
1142                    fill_path_impl(context, device, FillRule::NonZero, Some(&inverted_bbox));
1143
1144                    context.pop_root_transform();
1145                    context.restore_state(device);
1146                } else {
1147                    warn!("failed to process shading");
1148                }
1149            }
1150            TypedInstruction::BeginCompatibility(_) => {}
1151            TypedInstruction::EndCompatibility(_) => {}
1152            // d0: colored glyph header.  The advance width (wx) argument is
1153            // intentionally ignored here for the same reason as d1 above.
1154            TypedInstruction::ColorGlyph(_) => {}
1155            TypedInstruction::ShowTextWithParameters(t) => {
1156                context.get_mut().text_state.word_space = t.0.as_f32();
1157                context.get_mut().text_state.char_space = t.1.as_f32();
1158                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1159                text::show_text_string(context, device, resources, t.2);
1160            }
1161            _ => {
1162                warn!("failed to read an operator");
1163            }
1164        }
1165    }
1166
1167    while context.num_states() > num_states {
1168        context.restore_state(device);
1169    }
1170
1171    // Released here rather than on a guard object because every exit from this
1172    // function is this line: the early `return` above happens before the claim.
1173    // A counter that only rose would refuse the fifty-first XObject a page draws
1174    // in sequence, which is not nesting at all.
1175    context.end_nested_interpretation();
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use crate::device::Device;
1181    use crate::font::Glyph;
1182    use crate::soft_mask::SoftMask;
1183    use crate::util::PageExt;
1184    use crate::{
1185        BlendMode, ClipPath, Context, GlyphDrawMode, Image, InterpreterSettings, Paint,
1186        PathDrawMode, interpret_page,
1187    };
1188    use kurbo::{Affine, BezPath, Shape};
1189    use pdf_syntax::Pdf;
1190
1191    /// A device that records the bounding-box width (in path coordinates) of
1192    /// every filled/stroked path, so tests can assert exactly which appearance
1193    /// stream's marks were interpreted.
1194    #[derive(Default)]
1195    struct CountingDevice {
1196        path_widths: Vec<f64>,
1197    }
1198
1199    impl Device<'_> for CountingDevice {
1200        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1201        fn set_blend_mode(&mut self, _: BlendMode) {}
1202        fn draw_path(&mut self, path: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {
1203            self.path_widths.push(path.bounding_box().width());
1204        }
1205        fn push_clip_path(&mut self, _: &ClipPath) {}
1206        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1207        fn draw_glyph(
1208            &mut self,
1209            _: &Glyph<'_>,
1210            _: Affine,
1211            _: Affine,
1212            _: &Paint<'_>,
1213            _: &GlyphDrawMode,
1214        ) {
1215        }
1216        fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1217        fn pop_clip_path(&mut self) {}
1218        fn pop_transparency_group(&mut self) {}
1219    }
1220
1221    /// Assemble a PDF from numbered object bodies (index `i` becomes object
1222    /// `i + 1`), computing byte-accurate xref offsets.
1223    fn build_pdf(objects: &[Vec<u8>]) -> Vec<u8> {
1224        let mut out = b"%PDF-1.7\n".to_vec();
1225        let mut offsets = Vec::with_capacity(objects.len());
1226
1227        for (i, body) in objects.iter().enumerate() {
1228            offsets.push(out.len());
1229            out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
1230            out.extend_from_slice(body);
1231            out.extend_from_slice(b"\nendobj\n");
1232        }
1233
1234        let xref_pos = out.len();
1235        out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1236        out.extend_from_slice(b"0000000000 65535 f \n");
1237        for offset in offsets {
1238            out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1239        }
1240        out.extend_from_slice(
1241            format!(
1242                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
1243                objects.len() + 1
1244            )
1245            .as_bytes(),
1246        );
1247
1248        out
1249    }
1250
1251    /// Build a Form XObject stream object body.
1252    fn form_stream(bbox: &str, content: &str) -> Vec<u8> {
1253        format!(
1254            "<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} >>\nstream\n{content}\nendstream",
1255            content.len()
1256        )
1257        .into_bytes()
1258    }
1259
1260    /// Build a single-page PDF with one widget annotation.
1261    ///
1262    /// Object layout: 1 catalog, 2 page tree, 3 page, 4 the annotation
1263    /// (`annot_body`), 5 the "on" appearance stream (two fills, path widths
1264    /// 10 and 4), 6 the "off" appearance stream (one fill, path width 7),
1265    /// 7 empty page contents, 8.. `extra_objects`. The "on" stream's BBox is
1266    /// `on_bbox` so degenerate-BBox behaviour can be exercised.
1267    fn checkbox_pdf(annot_body: &[u8], on_bbox: &str, extra_objects: &[Vec<u8>]) -> Vec<u8> {
1268        let mut objects = vec![
1269            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1270            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1271            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1272              /Annots [4 0 R] /Contents 7 0 R >>"
1273                .to_vec(),
1274            annot_body.to_vec(),
1275            form_stream(on_bbox, "0 0 10 10 re f\n12 12 4 4 re f"),
1276            form_stream("[0 0 20 20]", "0 0 7 7 re f"),
1277            b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
1278        ];
1279        objects.extend_from_slice(extra_objects);
1280        build_pdf(&objects)
1281    }
1282
1283    /// Interpret the first page of `pdf_bytes` and return the recorded path
1284    /// widths.
1285    fn interpret_widths(pdf_bytes: Vec<u8>) -> Vec<f64> {
1286        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1287        let pages = pdf.pages();
1288        let page = pages.first().expect("test PDF must have one page");
1289
1290        let settings = InterpreterSettings::default();
1291        let initial_transform = page.initial_transform(true);
1292        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
1293        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1294        let mut device = CountingDevice::default();
1295
1296        interpret_page(page, &mut context, &mut device);
1297        device.path_widths
1298    }
1299
1300    fn assert_widths(widths: &[f64], expected: &[f64]) {
1301        assert_eq!(
1302            widths.len(),
1303            expected.len(),
1304            "expected {expected:?}, got {widths:?}"
1305        );
1306        for (got, want) in widths.iter().zip(expected) {
1307            assert!(
1308                (got - want).abs() < 1e-6,
1309                "expected {expected:?}, got {widths:?}"
1310            );
1311        }
1312    }
1313
1314    /// /AP /N substate dictionary with /AS /Yes: the Yes stream (and only the
1315    /// Yes stream) must be drawn.
1316    #[test]
1317    fn widget_substate_as_on_state() {
1318        let pdf = checkbox_pdf(
1319            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1320              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1321            "[0 0 20 20]",
1322            &[],
1323        );
1324        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1325    }
1326
1327    /// Same widget with /AS /Off: the Off stream is drawn, and none of the
1328    /// Yes stream's marks appear.
1329    #[test]
1330    fn widget_substate_as_off_state() {
1331        let pdf = checkbox_pdf(
1332            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1333              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Off >>",
1334            "[0 0 20 20]",
1335            &[],
1336        );
1337        assert_widths(&interpret_widths(pdf), &[7.0]);
1338    }
1339
1340    /// /N has only the on-state and /AS is /Off: nothing must be drawn and
1341    /// nothing must panic (ISO 32000 §12.5.5 — no applicable appearance).
1342    #[test]
1343    fn widget_substate_as_off_without_off_entry() {
1344        let pdf = checkbox_pdf(
1345            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1346              /AP << /N << /Yes 5 0 R >> >> /AS /Off >>",
1347            "[0 0 20 20]",
1348            &[],
1349        );
1350        assert_widths(&interpret_widths(pdf), &[]);
1351    }
1352
1353    /// /AS absent but /V /Yes on the widget: the pdfium V-fallback selects
1354    /// the Yes stream.
1355    #[test]
1356    fn widget_substate_v_fallback() {
1357        let pdf = checkbox_pdf(
1358            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1359              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /V /Yes >>",
1360            "[0 0 20 20]",
1361            &[],
1362        );
1363        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1364    }
1365
1366    /// /AS and /V absent but the /Parent field dict carries /V /Yes (radio
1367    /// button group pattern): the one-level parent V-fallback selects the Yes
1368    /// stream.
1369    #[test]
1370    fn widget_substate_parent_v_fallback() {
1371        let pdf = checkbox_pdf(
1372            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1373              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /Parent 8 0 R >>",
1374            "[0 0 20 20]",
1375            &[b"<< /FT /Btn /V /Yes >>".to_vec()],
1376        );
1377        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1378    }
1379
1380    /// Non-ASCII appearance-state name: the /N dict key contains raw byte
1381    /// 0xF6 and /AS spells the identical bytes via a #F6 hex escape. Matching
1382    /// must happen on raw decoded name bytes, never through lossy UTF-8.
1383    #[test]
1384    fn widget_substate_non_ascii_state_name() {
1385        let annot = b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1386              /AP << /N << /Stra\xf6m 5 0 R /Off 6 0 R >> >> /AS /Stra#F6m >>";
1387        // Sanity: the raw 0xF6 byte really is in the annotation dict bytes.
1388        assert!(annot.contains(&0xf6));
1389        let pdf = checkbox_pdf(annot, "[0 0 20 20]", &[]);
1390        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1391    }
1392
1393    /// A degenerate (zero-width) appearance BBox must not produce a
1394    /// non-finite scale matrix: the annotation is skipped without panicking.
1395    #[test]
1396    fn widget_degenerate_bbox_skipped() {
1397        let pdf = checkbox_pdf(
1398            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1399              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1400            "[0 0 0 20]",
1401            &[],
1402        );
1403        assert_widths(&interpret_widths(pdf), &[]);
1404    }
1405
1406    /// Regression guard: a plain (non-substate) /AP /N stream still renders.
1407    #[test]
1408    fn widget_direct_stream_still_renders() {
1409        let pdf = checkbox_pdf(
1410            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1411              /AP << /N 6 0 R >> >>",
1412            "[0 0 20 20]",
1413            &[],
1414        );
1415        assert_widths(&interpret_widths(pdf), &[7.0]);
1416    }
1417}