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    /// An annotation has an `AP` dictionary, but its normal appearance could not
236    /// be selected or loaded, so the annotation was not drawn.
237    UnresolvedAnnotationAppearance,
238}
239
240/// Resolve the normal (`/N`) appearance stream of an annotation.
241///
242/// Per ISO 32000 §12.5.5 and Table 168, the `/N` entry of the `/AP`
243/// dictionary is either an appearance stream or an appearance *subdictionary*
244/// mapping appearance-state names to streams (the latter is used by every
245/// checkbox and radio button, e.g. `/N << /Yes <stream> /Off <stream> >>`).
246///
247/// In the subdictionary case the stream is selected by the annotation's
248/// `/AS` entry (Table 168: "The annotation's appearance state, which
249/// selects the applicable appearance stream from an appearance
250/// subdictionary"). When `/AS` is absent, this follows pdfium's
251/// `GetAnnotAPInternal` fallback: the widget's own `/V` value as a name,
252/// then the `/Parent`'s `/V` (one level), accepting a candidate only if it
253/// exists as a key in the subdictionary. If no candidate resolves to an
254/// existing key, `None` is returned and nothing is rendered for the
255/// annotation (correct for e.g. `/AS /Off` when the subdictionary has no
256/// `/Off` entry).
257///
258/// All key matching is done on raw name bytes — appearance-state names may
259/// contain non-ASCII bytes and must never go through a lossy UTF-8
260/// conversion.
261fn normal_appearance_stream<'a>(annot: &Dict<'a>) -> Option<Stream<'a>> {
262    let ap = annot.get::<Dict<'_>>(AP)?;
263
264    // Single appearance stream: use it directly.
265    if let Some(stream) = ap.get::<Stream<'_>>(N) {
266        return Some(stream);
267    }
268
269    // Appearance subdictionary: select the stream by appearance state.
270    let states = ap.get::<Dict<'_>>(N)?;
271
272    if let Some(state) = annot.get::<Name>(AS) {
273        // An explicit /AS is authoritative; if its entry is missing, no
274        // appearance is rendered.
275        return states.get::<Stream<'_>>(state.as_ref());
276    }
277
278    // pdfium V-fallback: the widget's own /V, then the parent's /V, the
279    // first candidate that exists as a key in the subdictionary wins.
280    let candidates = [
281        annot.get::<Name>(V),
282        annot.get::<Dict<'_>>(PARENT).and_then(|p| p.get::<Name>(V)),
283    ];
284
285    candidates
286        .into_iter()
287        .flatten()
288        .find_map(|state| states.get::<Stream<'_>>(state.as_ref()))
289}
290
291/// `/F` bit 2: the annotation is not shown at all.
292const ANNOT_FLAG_HIDDEN: u32 = 1 << 1;
293/// `/F` bit 6: the annotation prints but is not shown on screen.
294const ANNOT_FLAG_NOVIEW: u32 = 1 << 5;
295
296/// How many `/Parent` links a field-attribute lookup follows.
297///
298/// Field attributes are inherited up the field tree (ISO 32000-2 §12.7.4.2)
299/// and nothing in a file stops that chain from pointing back at itself. The
300/// bound is what makes the walk terminate on a file that does.
301const FIELD_INHERITANCE_DEPTH: usize = 32;
302
303/// `/Ff` bit 17: the field is a push button and holds no value.
304const FIELD_FLAG_PUSHBUTTON: u32 = 1 << 16;
305/// `/Ff` bit 14: the text field's value is a password and is not to be shown.
306const FIELD_FLAG_PASSWORD: u32 = 1 << 13;
307
308/// The nearest value of an inherited field attribute: the widget's own entry,
309/// else the first ancestor that has one.
310fn inherited_field_entry<'a, T>(
311    annot: &Dict<'a>,
312    entry: impl Fn(&Dict<'a>) -> Option<T>,
313) -> Option<T> {
314    let mut node = annot.clone();
315
316    for _ in 0..FIELD_INHERITANCE_DEPTH {
317        if let Some(value) = entry(&node) {
318            return Some(value);
319        }
320
321        node = node.get::<Dict<'a>>(PARENT)?;
322    }
323
324    None
325}
326
327/// A name read as the text it stands for, or nothing when its bytes are not
328/// UTF-8.
329///
330/// Export values (`/Yes`, `/On`, a generated `/1`) are ASCII in every file
331/// that has ever been measured here, and a name that is not valid UTF-8 is
332/// not a string a reader may invent characters for.
333fn name_as_text(name: &Name) -> Option<String> {
334    std::str::from_utf8(name.as_ref()).ok().map(str::to_string)
335}
336
337/// What a choice field's control shows for one of its values.
338///
339/// `/Opt` is either a list of strings, in which case the value is the text, or
340/// a list of `[export, display]` pairs, in which case `/V` holds the export
341/// value and the control shows the display one -- `NL` in the file and
342/// `Netherlands` on the page. Extraction wants what is on the page.
343///
344/// An editable combo box may hold a value that is in no `/Opt` entry, because
345/// the user typed it; that value is its own label. (codex, #549)
346fn choice_option_label(annot: &Dict<'_>, value: &str) -> String {
347    let Some(options) = inherited_field_entry(annot, |d| d.get::<Array<'_>>(OPT)) else {
348        return value.to_string();
349    };
350
351    for option in options.iter::<Array<'_>>() {
352        let mut pair = option.iter::<PdfString>();
353        let Some(export) = pair.next().and_then(|s| decode_text_string(s.as_bytes())) else {
354            continue;
355        };
356        let Some(display) = pair.next().and_then(|s| decode_text_string(s.as_bytes())) else {
357            continue;
358        };
359
360        if export == value {
361            return display;
362        }
363    }
364
365    value.to_string()
366}
367
368/// The value of a choice field, which is one string or several.
369fn choice_value_text<'a>(annot: &Dict<'a>, key: &'static [u8]) -> Option<String> {
370    if let Some(value) = inherited_field_entry(annot, |d| d.get::<PdfString>(key)) {
371        let value = decode_text_string(value.as_bytes())?;
372        return Some(choice_option_label(annot, &value));
373    }
374
375    // A multi-select list box holds its selected options in an array. They are
376    // separate lines: they are separate rows of the control, and running them
377    // together would invent a word that is on no page.
378    let options = inherited_field_entry(annot, |d| d.get::<Array<'a>>(key))?;
379    let lines: Vec<String> = options
380        .iter::<PdfString>()
381        .filter_map(|option| decode_text_string(option.as_bytes()))
382        .map(|value| choice_option_label(annot, &value))
383        .collect();
384
385    (!lines.is_empty()).then(|| lines.join("\n"))
386}
387
388/// Whether this widget is the only one its field could be showing.
389///
390/// A radio group is one field with one `/V` and several widgets, and `/V`
391/// names the state of whichever of them is on. Reading it as every widget's
392/// own state prints the selected option once per option in the group. Only
393/// `/AS` is per widget, so the field's value answers for this widget when
394/// there is no other widget it could be answering for: a merged field and
395/// widget in one dictionary, or a field with a single kid. (codex, #549)
396fn is_the_only_widget_of_its_field(annot: &Dict<'_>) -> bool {
397    let Some(parent) = annot.get::<Dict<'_>>(PARENT) else {
398        return true;
399    };
400
401    match parent.get::<Array<'_>>(KIDS) {
402        Some(kids) => kids.raw_iter().take(2).count() < 2,
403        None => true,
404    }
405}
406
407/// The text a field widget stands for, for a widget that carries no appearance
408/// stream for a reader to take it from (#549).
409///
410/// `/V` first and `/DV` after it: the default value is what the field shows
411/// until somebody fills it, so it is the text on the page exactly when there
412/// is no `/V`.
413///
414/// Three field types answer, and the fourth deliberately does not. `/Sig` has
415/// no text value -- `/V` is a signature dictionary -- and a push button's
416/// caption lives in `/MK /CA`, which is a label printed on a control rather
417/// than content of the document.
418fn field_value_text(annot: &Dict<'_>) -> Option<String> {
419    let field_type = inherited_field_entry(annot, |d| d.get::<Name>(FT))?;
420    let flags = inherited_field_entry(annot, |d| d.get::<u32>(FF)).unwrap_or(0);
421
422    match field_type.as_ref() {
423        BTN => {
424            if flags & FIELD_FLAG_PUSHBUTTON != 0 {
425                return None;
426            }
427
428            // The state this widget is in, which for a radio group is the one
429            // thing that distinguishes the kids from each other: `/AS` is per
430            // widget while `/V` is on the field they share. `/AS` therefore
431            // wins, and `/V` answers only for a widget that has none.
432            let state = annot.get::<Name>(AS).or_else(|| {
433                is_the_only_widget_of_its_field(annot)
434                    .then(|| {
435                        inherited_field_entry(annot, |d| d.get::<Name>(V))
436                            .or_else(|| inherited_field_entry(annot, |d| d.get::<Name>(DV)))
437                    })
438                    .flatten()
439            })?;
440
441            // `/Off` is the one state name the specification fixes, and it is
442            // the empty box. A reader that wrote "Off" into the text would put
443            // a word on the page for every box nobody ticked.
444            if state.as_ref() == b"Off" {
445                return None;
446            }
447
448            name_as_text(&state)
449        }
450        TX => {
451            if flags & FIELD_FLAG_PASSWORD != 0 {
452                return None;
453            }
454
455            // A comb field (`/Ff` bit 25) draws its value one character per
456            // cell of `/MaxLen`. The cells are a layout and the value is the
457            // same string either way, so nothing here reads them.
458            let value = inherited_field_entry(annot, |d| d.get::<PdfString>(V))
459                .or_else(|| inherited_field_entry(annot, |d| d.get::<PdfString>(DV)))?;
460            let text = decode_text_string(value.as_bytes())?;
461
462            (!text.trim().is_empty()).then_some(text)
463        }
464        CH => choice_value_text(annot, V).or_else(|| choice_value_text(annot, DV)),
465        _ => None,
466    }
467}
468
469/// interpret the contents of the page and render them into the device.
470pub fn interpret_page<'a>(
471    page: &Page<'a>,
472    context: &mut Context<'a>,
473    device: &mut impl Device<'a>,
474) {
475    let resources = page.resources();
476    interpret(page.typed_operations(), resources, context, device);
477
478    if context.settings.render_annotations
479        && let Some(annot_arr) = page.raw().get::<Array<'_>>(ANNOTS)
480    {
481        for annot in annot_arr.iter::<Dict<'_>>() {
482            let flags = annot.get::<u32>(F).unwrap_or(0);
483
484            // Annotation should be hidden.
485            if flags & ANNOT_FLAG_HIDDEN != 0 {
486                continue;
487            }
488
489            // MuPDF renders signature widgets (/FT /Sig) with its own built-in
490            // "SIGN here" indicator and ignores the custom /AP/N stream, so we
491            // skip AP rendering for these annotations to match MuPDF output.
492            // Text extraction disables this skip so signature text is included.
493            if context.settings.skip_signature_widgets
494                && annot
495                    .get::<Name>(FT)
496                    .as_deref()
497                    .is_some_and(|n| n == b"Sig")
498            {
499                continue;
500            }
501
502            if let Some(apx) = normal_appearance_stream(&annot)
503                .and_then(|o| FormXObject::new(&o, &context.settings.warning_sink))
504            {
505                let Some(rect) = annot.get::<Rect>(RECT) else {
506                    continue;
507                };
508
509                let annot_rect = rect.to_kurbo();
510                // 12.5.5. Appearance streams
511                // "The algorithm outlined in this subclause shall be used
512                // to map from the coordinate system of the appearance XObject."
513
514                // 1) The appearance’s bounding box (specified by its BBox entry)
515                // shall be transformed, using Matrix, to produce a
516                // quadrilateral with arbitrary orientation. The transformed
517                // appearance box is the smallest upright rectangle that
518                // encompasses this quadrilateral.
519                let transformed_rect = (apx.matrix
520                    * kurbo::Rect::new(
521                        apx.bbox[0] as f64,
522                        apx.bbox[1] as f64,
523                        apx.bbox[2] as f64,
524                        apx.bbox[3] as f64,
525                    )
526                    .to_path(0.1))
527                .bounding_box();
528
529                // A degenerate (zero-width or zero-height) transformed
530                // appearance box would make the scale computation below
531                // divide by zero, producing a non-finite (inf/NaN) affine.
532                // Skip such annotations entirely.
533                let (tw, th) = (transformed_rect.width(), transformed_rect.height());
534                if !(tw.is_finite() && tw > 0.0 && th.is_finite() && th > 0.0) {
535                    continue;
536                }
537
538                // 2) A matrix A shall be computed that scales and translates
539                // the transformed appearance box to align with the edges
540                // of the annotation’s rectangle (specified by the Rect entry).
541                // A maps the lower-left corner (the corner with the smallest
542                // x and y coordinates) and the upper-right corner (the
543                // corner with the greatest x and y coordinates) of the
544                // transformed appearance box to the corresponding corners
545                // of the annotation’s rectangle.
546                let affine = Affine::new([
547                    annot_rect.width() / transformed_rect.width(),
548                    0.0,
549                    0.0,
550                    annot_rect.height() / transformed_rect.height(),
551                    annot_rect.x0 - transformed_rect.x0,
552                    annot_rect.y0 - transformed_rect.y0,
553                ]);
554
555                // 3) Matrix shall be concatenated with A to form a matrix
556                // AA that maps from the appearance’s coordinate system to
557                // the annotation’s rectangle in default user space.
558                context.save_state();
559                context.pre_concat_affine(affine);
560                context.push_root_transform();
561
562                // Bracket the appearance for the device: a replacement the
563                // stream carries without a glyph is the annotation's own
564                // statement about what it shows, not a reinterpretation of
565                // the page, so a text device reads it as the value even
566                // where replacements on the page stay off.
567                device
568                    .begin_annotation_appearance(context.get().ctm.transform_rect_bbox(annot_rect));
569                draw_form_xobject(resources, &apx, context, device);
570                device.end_annotation_appearance();
571                context.pop_root_transform();
572                context.restore_state(device);
573            } else if annot.get::<Name>(SUBTYPE).as_deref() == Some(WIDGET)
574                && flags & ANNOT_FLAG_NOVIEW == 0
575                && let Some(text) = field_value_text(&annot)
576                && let Some(rect) = annot.get::<Rect>(RECT)
577            {
578                // No appearance stream resolved, and the field has a value: the
579                // only place that value exists on this page is the field
580                // dictionary, and a reader of glyphs alone comes away with
581                // nothing (#549). `/NeedAppearances` is not consulted, and the
582                // condition is deliberately "no appearance" rather than "has a
583                // value": a stale appearance next to a newer `/V` is still an
584                // appearance, and taking both would print the value twice.
585                //
586                // `/NoView` is honoured here and not in the branch above,
587                // where an appearance stream is read whether or not the
588                // annotation is for the screen. That asymmetry is not an
589                // oversight -- it is the measured behaviour of the reader this
590                // extraction is graded against, and narrowing it is a change
591                // that removes text, which needs its own document and its own
592                // measurement.
593                device.draw_placed_text(
594                    &text,
595                    context.get().ctm.transform_rect_bbox(rect.to_kurbo()),
596                );
597            } else if annot.get::<Dict<'_>>(AP).is_some() {
598                // The annotation carries an appearance dictionary but no
599                // appearance resolved (and the widget-value fallback above did
600                // not apply), so nothing was drawn for it.
601                (context.settings.warning_sink)(InterpreterWarning::UnresolvedAnnotationAppearance);
602            }
603        }
604    }
605}
606
607/// Interpret the instructions from `ops` and render them into the device.
608pub fn interpret<'a, 'b>(
609    ops: impl Iterator<Item = TypedInstruction<'b>>,
610    resources: &Resources<'a>,
611    context: &mut Context<'a>,
612    device: &mut impl Device<'a>,
613) {
614    // One choke point for every nested interpretation, because every one of them
615    // arrives here: a Form XObject drawing another, a tiling pattern painting
616    // with a pattern, a soft mask, a Type 3 glyph procedure. Guarding the call
617    // sites instead would mean four guards and a fifth construct next year.
618    //
619    // Reproduced before the bound existed: a 639-byte file whose XObject `/X1`
620    // contains `q /X1 Do Q` aborts the process with a stack overflow, rc=134.
621    // No XFA and no script, so it reaches every binding exporting `render_page`.
622    if !context.begin_nested_interpretation() {
623        let limit = crate::context::MAX_NESTED_INTERPRETATION_DEPTH;
624        warn!("content stream nesting exceeds {limit}, stopping interpretation");
625        // Also to the sink, because the log line is invisible to a library
626        // caller and this is a silent loss of paint, not a diagnostic.
627        (context.settings.warning_sink)(InterpreterWarning::NestingTooDeep { limit });
628
629        return;
630    }
631
632    let num_states = context.num_states();
633    let max_operator_count = context.settings.max_operator_count.unwrap_or(u64::MAX);
634    let mut operator_count = 0_u64;
635
636    context.save_state();
637
638    for op in ops {
639        operator_count = operator_count.saturating_add(1);
640        if operator_count > max_operator_count {
641            warn!(
642                "content stream operator count exceeds {max_operator_count}, stopping interpretation"
643            );
644            break;
645        }
646
647        match op {
648            TypedInstruction::SaveState(_) => context.save_state(),
649            TypedInstruction::StrokeColorDeviceRgb(s) => {
650                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_rgb();
651                context.get_mut().graphics_state.stroke_color =
652                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
653            }
654            TypedInstruction::StrokeColorDeviceGray(s) => {
655                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_gray();
656                context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32()];
657            }
658            TypedInstruction::StrokeColorCmyk(s) => {
659                context.get_mut().graphics_state.stroke_cs = ColorSpace::device_cmyk();
660                context.get_mut().graphics_state.stroke_color =
661                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
662            }
663            TypedInstruction::LineWidth(w) => {
664                context.get_mut().graphics_state.stroke_props.line_width = w.0.as_f32();
665            }
666            TypedInstruction::LineCap(c) => {
667                context.get_mut().graphics_state.stroke_props.line_cap = convert_line_cap(c);
668            }
669            TypedInstruction::LineJoin(j) => {
670                context.get_mut().graphics_state.stroke_props.line_join = convert_line_join(j);
671            }
672            TypedInstruction::MiterLimit(l) => {
673                context.get_mut().graphics_state.stroke_props.miter_limit = l.0.as_f32();
674            }
675            TypedInstruction::Transform(t) => {
676                context.pre_concat_transform(t);
677            }
678            TypedInstruction::RectPath(r) => {
679                let rect = kurbo::Rect::new(
680                    r.0.as_f64(),
681                    r.1.as_f64(),
682                    r.0.as_f64() + r.2.as_f64(),
683                    r.1.as_f64() + r.3.as_f64(),
684                )
685                .to_path(0.1);
686                context.path_mut().extend(rect);
687            }
688            TypedInstruction::MoveTo(m) => {
689                let p = Point::new(m.0.as_f64(), m.1.as_f64());
690                *(context.last_point_mut()) = p;
691                *(context.sub_path_start_mut()) = p;
692                context.path_mut().move_to(p);
693            }
694            TypedInstruction::FillPathEvenOdd(_) => {
695                fill_path(context, device, FillRule::EvenOdd);
696            }
697            TypedInstruction::FillPathNonZero(_) => {
698                fill_path(context, device, FillRule::NonZero);
699            }
700            TypedInstruction::FillPathNonZeroCompatibility(_) => {
701                fill_path(context, device, FillRule::NonZero);
702            }
703            TypedInstruction::FillAndStrokeEvenOdd(_) => {
704                fill_stroke_path(context, device, FillRule::EvenOdd);
705            }
706            TypedInstruction::FillAndStrokeNonZero(_) => {
707                fill_stroke_path(context, device, FillRule::NonZero);
708            }
709            TypedInstruction::CloseAndStrokePath(_) => {
710                close_path(context);
711                stroke_path(context, device);
712            }
713            TypedInstruction::CloseFillAndStrokeEvenOdd(_) => {
714                close_path(context);
715                fill_stroke_path(context, device, FillRule::EvenOdd);
716            }
717            TypedInstruction::CloseFillAndStrokeNonZero(_) => {
718                close_path(context);
719                fill_stroke_path(context, device, FillRule::NonZero);
720            }
721            TypedInstruction::NonStrokeColorDeviceGray(s) => {
722                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_gray();
723                context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32()];
724            }
725            TypedInstruction::NonStrokeColorDeviceRgb(s) => {
726                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_rgb();
727                context.get_mut().graphics_state.non_stroke_color =
728                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
729            }
730            TypedInstruction::NonStrokeColorCmyk(s) => {
731                context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_cmyk();
732                context.get_mut().graphics_state.non_stroke_color =
733                    smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
734            }
735            TypedInstruction::LineTo(m) => {
736                if !context.path().elements().is_empty() {
737                    let last_point = *context.last_point();
738                    let mut p = Point::new(m.0.as_f64(), m.1.as_f64());
739                    *(context.last_point_mut()) = p;
740                    if last_point == p {
741                        // Add a small delta so that zero width lines can still have a round stroke.
742                        p.x += 0.0001;
743                    }
744
745                    context.path_mut().line_to(p);
746                }
747            }
748            TypedInstruction::CubicTo(c) => {
749                if !context.path().elements().is_empty() {
750                    let p1 = Point::new(c.0.as_f64(), c.1.as_f64());
751                    let p2 = Point::new(c.2.as_f64(), c.3.as_f64());
752                    let p3 = Point::new(c.4.as_f64(), c.5.as_f64());
753
754                    *(context.last_point_mut()) = p3;
755
756                    context.path_mut().curve_to(p1, p2, p3);
757                }
758            }
759            TypedInstruction::CubicStartTo(c) => {
760                if !context.path().elements().is_empty() {
761                    let p1 = *context.last_point();
762                    let p2 = Point::new(c.0.as_f64(), c.1.as_f64());
763                    let p3 = Point::new(c.2.as_f64(), c.3.as_f64());
764
765                    *(context.last_point_mut()) = p3;
766
767                    context.path_mut().curve_to(p1, p2, p3);
768                }
769            }
770            TypedInstruction::CubicEndTo(c) => {
771                if !context.path().elements().is_empty() {
772                    let p2 = Point::new(c.0.as_f64(), c.1.as_f64());
773                    let p3 = Point::new(c.2.as_f64(), c.3.as_f64());
774
775                    *(context.last_point_mut()) = p3;
776
777                    context.path_mut().curve_to(p2, p3, p3);
778                }
779            }
780            TypedInstruction::ClosePath(_) => {
781                close_path(context);
782            }
783            TypedInstruction::SetGraphicsState(gs) => {
784                if let Some(gs) = resources
785                    .get_ext_g_state(gs.0.clone())
786                    .warn_none(&format!("failed to get extgstate {}", gs.0.as_str()))
787                {
788                    handle_gs(&gs, context, resources);
789                }
790            }
791            TypedInstruction::StrokePath(_) => {
792                stroke_path(context, device);
793            }
794            TypedInstruction::EndPath(_) => {
795                if let Some(clip) = *context.clip()
796                    && !context.path().elements().is_empty()
797                {
798                    let clip_path = context.get().ctm * context.path().clone();
799                    context.push_clip_path(clip_path, clip, device);
800
801                    *(context.clip_mut()) = None;
802                }
803
804                context.path_mut().truncate(0);
805            }
806            TypedInstruction::NonStrokeColor(c) => {
807                let fill_c = &mut context.get_mut().graphics_state.non_stroke_color;
808                fill_c.truncate(0);
809
810                for e in c.0 {
811                    fill_c.push(e.as_f32());
812                }
813            }
814            TypedInstruction::StrokeColor(c) => {
815                let stroke_c = &mut context.get_mut().graphics_state.stroke_color;
816                stroke_c.truncate(0);
817
818                for e in c.0 {
819                    stroke_c.push(e.as_f32());
820                }
821            }
822            TypedInstruction::ClipNonZero(_) => {
823                *(context.clip_mut()) = Some(FillRule::NonZero);
824            }
825            TypedInstruction::ClipEvenOdd(_) => {
826                *(context.clip_mut()) = Some(FillRule::EvenOdd);
827            }
828            TypedInstruction::RestoreState(_) => context.restore_state(device),
829            TypedInstruction::FlatnessTolerance(_) => {
830                // Ignore for now.
831            }
832            TypedInstruction::ColorSpaceStroke(c) => {
833                let cs = if let Some(named) = ColorSpace::new_from_name(c.0.clone()) {
834                    named
835                } else {
836                    context
837                        .get_color_space(resources, c.0)
838                        .unwrap_or(ColorSpace::device_gray())
839                };
840
841                context.get_mut().graphics_state.stroke_color = cs.initial_color();
842                context.get_mut().graphics_state.stroke_cs = cs;
843            }
844            TypedInstruction::ColorSpaceNonStroke(c) => {
845                let cs = if let Some(named) = ColorSpace::new_from_name(c.0.clone()) {
846                    named
847                } else {
848                    context
849                        .get_color_space(resources, c.0)
850                        .unwrap_or(ColorSpace::device_gray())
851                };
852
853                context.get_mut().graphics_state.non_stroke_color = cs.initial_color();
854                context.get_mut().graphics_state.none_stroke_cs = cs;
855            }
856            TypedInstruction::DashPattern(p) => {
857                context.get_mut().graphics_state.stroke_props.dash_offset = p.1.as_f32();
858                // kurbo apparently cannot properly deal with offsets that are exactly 0.
859                context.get_mut().graphics_state.stroke_props.dash_array =
860                    p.0.iter::<f32>()
861                        .map(|n| if n == 0.0 { 0.01 } else { n })
862                        .collect();
863            }
864            TypedInstruction::RenderingIntent(_) => {
865                // Ignore for now.
866            }
867            TypedInstruction::NonStrokeColorNamed(n) => {
868                context.get_mut().graphics_state.non_stroke_color =
869                    n.0.into_iter().map(|n| n.as_f32()).collect();
870                context.get_mut().graphics_state.non_stroke_pattern = n.1.and_then(|name| {
871                    resources
872                        .get_pattern(name)
873                        .and_then(|d| Pattern::new(d, context))
874                });
875            }
876            TypedInstruction::StrokeColorNamed(n) => {
877                context.get_mut().graphics_state.stroke_color =
878                    n.0.into_iter().map(|n| n.as_f32()).collect();
879                context.get_mut().graphics_state.stroke_pattern = n.1.and_then(|name| {
880                    resources
881                        .get_pattern(name)
882                        .and_then(|d| Pattern::new(d, context))
883                });
884            }
885            TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
886                // Properties can be either:
887                // 1. A Name that references an entry in the Resources/Properties dictionary
888                // 2. An inline dictionary with an OC key
889
890                let oc = bdc
891                    .1
892                    .clone()
893                    .into_name()
894                    .and_then(|name| {
895                        let r = resources.properties.get_ref(name.clone())?;
896                        let d = resources
897                            .properties
898                            .get::<Dict<'_>>(name)
899                            .unwrap_or_default();
900                        Some((d, r))
901                    })
902                    .or_else(|| {
903                        let (props, _) = dict_or_stream(&bdc.1)?;
904                        let r = props.get_ref(OC)?;
905                        let d = props.get::<Dict<'_>>(OC).unwrap_or_default();
906                        Some((d, r))
907                    });
908
909                if let Some((dict, oc_ref)) = oc {
910                    context.ocg_state.begin_ocg(&dict, oc_ref.into());
911                } else {
912                    context.ocg_state.begin_marked_content();
913                }
914                let props = bdc
915                    .1
916                    .clone()
917                    .into_name()
918                    .and_then(|name| resources.properties.get::<Dict<'_>>(name))
919                    .or_else(|| dict_or_stream(&bdc.1).map(|(props, _)| props));
920                let mcid = props.as_ref().and_then(|p| p.get::<i32>(MCID));
921                let actual_text = props
922                    .as_ref()
923                    .and_then(|p| p.get::<PdfString>(ACTUAL_TEXT))
924                    .and_then(|s| decode_text_string(s.as_bytes()));
925                device.begin_marked_content(&bdc.0, mcid, actual_text.as_deref());
926            }
927            TypedInstruction::MarkedContentPointWithProperties(_) => {}
928            TypedInstruction::EndMarkedContent(_) => {
929                context.ocg_state.end_marked_content();
930                device.end_marked_content();
931            }
932            TypedInstruction::MarkedContentPoint(_) => {}
933            TypedInstruction::BeginMarkedContent(bmc) => {
934                context.ocg_state.begin_marked_content();
935                device.begin_marked_content(&bmc.0, None, None);
936            }
937            TypedInstruction::BeginText(_) => {
938                context.get_mut().text_state.text_matrix = Affine::IDENTITY;
939                context.get_mut().text_state.text_line_matrix = Affine::IDENTITY;
940            }
941            TypedInstruction::SetTextMatrix(m) => {
942                let m = Affine::new([
943                    m.0.as_f64(),
944                    m.1.as_f64(),
945                    m.2.as_f64(),
946                    m.3.as_f64(),
947                    m.4.as_f64(),
948                    m.5.as_f64(),
949                ]);
950                context.get_mut().text_state.text_line_matrix = m;
951                context.get_mut().text_state.text_matrix = m;
952            }
953            TypedInstruction::EndText(_) => {
954                let has_outline = context
955                    .get()
956                    .text_state
957                    .clip_paths
958                    .segments()
959                    .next()
960                    .is_some();
961
962                if has_outline {
963                    let clip_path = context.get().ctm * context.get().text_state.clip_paths.clone();
964
965                    context.push_clip_path(clip_path, FillRule::NonZero, device);
966                }
967
968                context.get_mut().text_state.clip_paths.truncate(0);
969            }
970            TypedInstruction::TextFont(t) => {
971                let name = t.0;
972
973                // In case we are unable to resolve the font, two scenarios:
974                // 1) If the font doesn't exist in the first place in the resource dictionary,
975                // assume Helvetica (this seems to be what other PDF viewers do).
976                // 2) In case it's `None` because we were unable to resolve the font
977                // (for whatever reason), leave it as `None`. Better showing no
978                // text at all than garbage text.
979                let mut font = if let Some(font_dict) = resources.get_font(name.clone()) {
980                    context.resolve_font(&font_dict)
981                } else {
982                    Font::new_standard(
983                        StandardFont::Helvetica,
984                        &context.settings.font_resolver,
985                        &context.object_cache,
986                    )
987                    .map(TextStateFont::Fallback)
988                };
989                // The resource name rides the resolved font (a clone out of
990                // the cache) down to the glyphs, so extraction can tell the
991                // engine's own OCR layer apart from author text. Fallbacks
992                // carry no resource and stay nameless.
993                if let Some(TextStateFont::Font(resolved)) = font.as_mut() {
994                    resolved.set_resource_name(name.as_str());
995                }
996
997                context.get_mut().text_state.font_size = t.1.as_f32();
998                context.get_mut().text_state.font = font;
999            }
1000            TypedInstruction::ShowText(s) => {
1001                if context.get().text_state.font.is_none() {
1002                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
1003                    // seems to do the same.
1004                    context.get_mut().text_state.font = Font::new_standard(
1005                        StandardFont::Helvetica,
1006                        &context.settings.font_resolver,
1007                        &context.object_cache,
1008                    )
1009                    .map(TextStateFont::Fallback);
1010                }
1011
1012                text::show_text_string(context, device, resources, s.0);
1013            }
1014            TypedInstruction::ShowTexts(s) => {
1015                if context.get().text_state.font.is_none() {
1016                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
1017                    // seems to do the same.
1018                    context.get_mut().text_state.font = Font::new_standard(
1019                        StandardFont::Helvetica,
1020                        &context.settings.font_resolver,
1021                        &context.object_cache,
1022                    )
1023                    .map(TextStateFont::Fallback);
1024                }
1025
1026                for obj in s.0.iter::<Object<'_>>() {
1027                    if let Some(adjustment) = obj.clone().into_f32() {
1028                        // ANN[r17/TEX1] Surface TJ adjustment to the Device
1029                        // before mutating the text matrix so extractors can
1030                        // record the word-boundary signal alongside the
1031                        // spatial gap they'd otherwise have to infer.
1032                        device.text_adjustment(adjustment);
1033                        context.get_mut().text_state.apply_adjustment(adjustment);
1034                    } else if let Some(text) = obj.into_string() {
1035                        text::show_text_string(context, device, resources, text);
1036                    }
1037                }
1038            }
1039            TypedInstruction::HorizontalScaling(h) => {
1040                context.get_mut().text_state.horizontal_scaling = h.0.as_f32();
1041            }
1042            TypedInstruction::TextLeading(tl) => {
1043                context.get_mut().text_state.leading = tl.0.as_f32();
1044            }
1045            TypedInstruction::CharacterSpacing(c) => {
1046                context.get_mut().text_state.char_space = c.0.as_f32();
1047            }
1048            TypedInstruction::WordSpacing(w) => {
1049                context.get_mut().text_state.word_space = w.0.as_f32();
1050            }
1051            TypedInstruction::NextLine(n) => {
1052                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1053                text::next_line(context, tx, ty);
1054            }
1055            TypedInstruction::NextLineUsingLeading(_) => {
1056                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1057            }
1058            TypedInstruction::NextLineAndShowText(n) => {
1059                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1060                text::show_text_string(context, device, resources, n.0);
1061            }
1062            TypedInstruction::TextRenderingMode(r) => {
1063                let mode = match r.0.as_i64() {
1064                    0 => TextRenderingMode::Fill,
1065                    1 => TextRenderingMode::Stroke,
1066                    2 => TextRenderingMode::FillStroke,
1067                    3 => TextRenderingMode::Invisible,
1068                    4 => TextRenderingMode::FillAndClip,
1069                    5 => TextRenderingMode::StrokeAndClip,
1070                    6 => TextRenderingMode::FillAndStrokeAndClip,
1071                    7 => TextRenderingMode::Clip,
1072                    _ => {
1073                        warn!("unknown text rendering mode {}", r.0.as_i64());
1074
1075                        TextRenderingMode::Fill
1076                    }
1077                };
1078
1079                context.get_mut().text_state.render_mode = mode;
1080            }
1081            TypedInstruction::NextLineAndSetLeading(n) => {
1082                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1083                context.get_mut().text_state.leading = -ty as f32;
1084                text::next_line(context, tx, ty);
1085            }
1086            // d1: uncolored (shape) glyph header.  The advance width (wx) and
1087            // bounding-box arguments are intentionally ignored here: the glyph
1088            // advance is taken from the Type3 font's /Widths array (via
1089            // Font::code_advance), and the is_shape_glyph flag is determined
1090            // by the pre-scan in Type3::render_glyph before the stream is
1091            // interpreted.
1092            TypedInstruction::ShapeGlyph(_) => {}
1093            TypedInstruction::XObject(x) => {
1094                let cache = context.object_cache.clone();
1095                let warning_sink = context.settings.warning_sink.clone();
1096                let transfer_function = context.get().graphics_state.transfer_function.clone();
1097                if let Some(x_object) = resources.get_x_object(x.0).and_then(|s| {
1098                    XObject::new(
1099                        &s,
1100                        |name| context.get_color_space(resources, name.clone()),
1101                        &warning_sink,
1102                        &cache,
1103                        transfer_function.clone(),
1104                    )
1105                }) {
1106                    draw_xobject(&x_object, resources, context, device);
1107                }
1108            }
1109            TypedInstruction::InlineImage(i) => {
1110                let warning_sink = context.settings.warning_sink.clone();
1111                let transfer_function = context.get().graphics_state.transfer_function.clone();
1112                let cache = context.object_cache.clone();
1113                if let Some(x_object) = ImageXObject::new(
1114                    &i.0,
1115                    |name| context.get_color_space(resources, name.clone()),
1116                    &warning_sink,
1117                    &cache,
1118                    false,
1119                    transfer_function,
1120                ) {
1121                    draw_image_xobject(&x_object, context, device);
1122                }
1123            }
1124            TypedInstruction::TextRise(t) => {
1125                context.get_mut().text_state.rise = t.0.as_f32();
1126            }
1127            TypedInstruction::Shading(s) => {
1128                if !context.ocg_state.is_visible() {
1129                    continue;
1130                }
1131
1132                let transfer_function = context.get().graphics_state.transfer_function.clone();
1133
1134                if let Some(sp) = resources
1135                    .get_shading(s.0)
1136                    .and_then(|o| dict_or_stream(&o))
1137                    .and_then(|s| {
1138                        Shading::new(
1139                            &s.0,
1140                            s.1.as_ref(),
1141                            &context.object_cache,
1142                            &context.settings.warning_sink,
1143                        )
1144                    })
1145                    .map(|s| {
1146                        Pattern::Shading(ShadingPattern {
1147                            shading: Arc::new(s),
1148                            matrix: Affine::IDENTITY,
1149                            opacity: context.get().graphics_state.non_stroke_alpha,
1150                            transfer_function: transfer_function.clone(),
1151                        })
1152                    })
1153                {
1154                    context.save_state();
1155                    context.push_root_transform();
1156                    let st = context.get_mut();
1157                    st.graphics_state.non_stroke_pattern = Some(sp);
1158                    st.graphics_state.none_stroke_cs = ColorSpace::pattern();
1159
1160                    device.set_soft_mask(st.graphics_state.soft_mask.clone());
1161                    device.set_blend_mode(st.graphics_state.blend_mode);
1162
1163                    let bbox = context.bbox().to_path(0.1);
1164                    let inverted_bbox = context.get().ctm.inverse() * bbox;
1165                    fill_path_impl(context, device, FillRule::NonZero, Some(&inverted_bbox));
1166
1167                    context.pop_root_transform();
1168                    context.restore_state(device);
1169                } else {
1170                    warn!("failed to process shading");
1171                }
1172            }
1173            TypedInstruction::BeginCompatibility(_) => {}
1174            TypedInstruction::EndCompatibility(_) => {}
1175            // d0: colored glyph header.  The advance width (wx) argument is
1176            // intentionally ignored here for the same reason as d1 above.
1177            TypedInstruction::ColorGlyph(_) => {}
1178            TypedInstruction::ShowTextWithParameters(t) => {
1179                context.get_mut().text_state.word_space = t.0.as_f32();
1180                context.get_mut().text_state.char_space = t.1.as_f32();
1181                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1182                text::show_text_string(context, device, resources, t.2);
1183            }
1184            _ => {
1185                warn!("failed to read an operator");
1186            }
1187        }
1188    }
1189
1190    while context.num_states() > num_states {
1191        context.restore_state(device);
1192    }
1193
1194    // Released here rather than on a guard object because every exit from this
1195    // function is this line: the early `return` above happens before the claim.
1196    // A counter that only rose would refuse the fifty-first XObject a page draws
1197    // in sequence, which is not nesting at all.
1198    context.end_nested_interpretation();
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use crate::device::Device;
1204    use crate::font::Glyph;
1205    use crate::soft_mask::SoftMask;
1206    use crate::util::PageExt;
1207    use crate::{
1208        BlendMode, ClipPath, Context, GlyphDrawMode, Image, InterpreterSettings,
1209        InterpreterWarning, Paint, PathDrawMode, interpret_page,
1210    };
1211    use kurbo::{Affine, BezPath, Shape};
1212    use pdf_syntax::Pdf;
1213
1214    /// A device that records the bounding-box width (in path coordinates) of
1215    /// every filled/stroked path, so tests can assert exactly which appearance
1216    /// stream's marks were interpreted.
1217    #[derive(Default)]
1218    struct CountingDevice {
1219        path_widths: Vec<f64>,
1220    }
1221
1222    impl Device<'_> for CountingDevice {
1223        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1224        fn set_blend_mode(&mut self, _: BlendMode) {}
1225        fn draw_path(&mut self, path: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {
1226            self.path_widths.push(path.bounding_box().width());
1227        }
1228        fn push_clip_path(&mut self, _: &ClipPath) {}
1229        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1230        fn draw_glyph(
1231            &mut self,
1232            _: &Glyph<'_>,
1233            _: Affine,
1234            _: Affine,
1235            _: &Paint<'_>,
1236            _: &GlyphDrawMode,
1237        ) {
1238        }
1239        fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1240        fn pop_clip_path(&mut self) {}
1241        fn pop_transparency_group(&mut self) {}
1242    }
1243
1244    /// Assemble a PDF from numbered object bodies (index `i` becomes object
1245    /// `i + 1`), computing byte-accurate xref offsets.
1246    fn build_pdf(objects: &[Vec<u8>]) -> Vec<u8> {
1247        let mut out = b"%PDF-1.7\n".to_vec();
1248        let mut offsets = Vec::with_capacity(objects.len());
1249
1250        for (i, body) in objects.iter().enumerate() {
1251            offsets.push(out.len());
1252            out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
1253            out.extend_from_slice(body);
1254            out.extend_from_slice(b"\nendobj\n");
1255        }
1256
1257        let xref_pos = out.len();
1258        out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1259        out.extend_from_slice(b"0000000000 65535 f \n");
1260        for offset in offsets {
1261            out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1262        }
1263        out.extend_from_slice(
1264            format!(
1265                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
1266                objects.len() + 1
1267            )
1268            .as_bytes(),
1269        );
1270
1271        out
1272    }
1273
1274    /// Build a Form XObject stream object body.
1275    fn form_stream(bbox: &str, content: &str) -> Vec<u8> {
1276        format!(
1277            "<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} >>\nstream\n{content}\nendstream",
1278            content.len()
1279        )
1280        .into_bytes()
1281    }
1282
1283    /// Build a single-page PDF with one widget annotation.
1284    ///
1285    /// Object layout: 1 catalog, 2 page tree, 3 page, 4 the annotation
1286    /// (`annot_body`), 5 the "on" appearance stream (two fills, path widths
1287    /// 10 and 4), 6 the "off" appearance stream (one fill, path width 7),
1288    /// 7 empty page contents, 8.. `extra_objects`. The "on" stream's BBox is
1289    /// `on_bbox` so degenerate-BBox behaviour can be exercised.
1290    fn checkbox_pdf(annot_body: &[u8], on_bbox: &str, extra_objects: &[Vec<u8>]) -> Vec<u8> {
1291        let mut objects = vec![
1292            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1293            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1294            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1295              /Annots [4 0 R] /Contents 7 0 R >>"
1296                .to_vec(),
1297            annot_body.to_vec(),
1298            form_stream(on_bbox, "0 0 10 10 re f\n12 12 4 4 re f"),
1299            form_stream("[0 0 20 20]", "0 0 7 7 re f"),
1300            b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
1301        ];
1302        objects.extend_from_slice(extra_objects);
1303        build_pdf(&objects)
1304    }
1305
1306    /// Interpret the first page of `pdf_bytes` and return the recorded path
1307    /// widths.
1308    fn interpret_widths(pdf_bytes: Vec<u8>) -> Vec<f64> {
1309        interpret_widths_and_warnings(pdf_bytes).1
1310    }
1311
1312    /// Interpret the first page of `pdf_bytes`, capturing interpreter
1313    /// warnings, and return both.
1314    fn interpret_widths_and_warnings(pdf_bytes: Vec<u8>) -> (Vec<InterpreterWarning>, Vec<f64>) {
1315        use std::sync::{Arc, Mutex};
1316
1317        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1318        let pages = pdf.pages();
1319        let page = pages.first().expect("test PDF must have one page");
1320
1321        let warnings = Arc::new(Mutex::new(Vec::new()));
1322        let warnings_sink = warnings.clone();
1323        let settings = InterpreterSettings {
1324            warning_sink: Arc::new(move |w| warnings_sink.lock().expect("test lock").push(w)),
1325            ..Default::default()
1326        };
1327        let initial_transform = page.initial_transform(true);
1328        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
1329        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1330        let mut device = CountingDevice::default();
1331
1332        interpret_page(page, &mut context, &mut device);
1333        (
1334            warnings.lock().expect("test lock").clone(),
1335            device.path_widths,
1336        )
1337    }
1338
1339    fn assert_widths(widths: &[f64], expected: &[f64]) {
1340        assert_eq!(
1341            widths.len(),
1342            expected.len(),
1343            "expected {expected:?}, got {widths:?}"
1344        );
1345        for (got, want) in widths.iter().zip(expected) {
1346            assert!(
1347                (got - want).abs() < 1e-6,
1348                "expected {expected:?}, got {widths:?}"
1349            );
1350        }
1351    }
1352
1353    /// /AP /N substate dictionary with /AS /Yes: the Yes stream (and only the
1354    /// Yes stream) must be drawn.
1355    #[test]
1356    fn widget_substate_as_on_state() {
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 >> >> /AS /Yes >>",
1360            "[0 0 20 20]",
1361            &[],
1362        );
1363        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1364    }
1365
1366    /// Same widget with /AS /Off: the Off stream is drawn, and none of the
1367    /// Yes stream's marks appear.
1368    #[test]
1369    fn widget_substate_as_off_state() {
1370        let pdf = checkbox_pdf(
1371            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1372              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Off >>",
1373            "[0 0 20 20]",
1374            &[],
1375        );
1376        assert_widths(&interpret_widths(pdf), &[7.0]);
1377    }
1378
1379    /// /N has only the on-state and /AS is /Off: nothing must be drawn and
1380    /// nothing must panic (ISO 32000 §12.5.5 — no applicable appearance).
1381    #[test]
1382    fn widget_substate_as_off_without_off_entry() {
1383        let pdf = checkbox_pdf(
1384            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1385              /AP << /N << /Yes 5 0 R >> >> /AS /Off >>",
1386            "[0 0 20 20]",
1387            &[],
1388        );
1389        assert_widths(&interpret_widths(pdf), &[]);
1390    }
1391
1392    /// /AS absent but /V /Yes on the widget: the pdfium V-fallback selects
1393    /// the Yes stream.
1394    #[test]
1395    fn widget_substate_v_fallback() {
1396        let pdf = checkbox_pdf(
1397            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1398              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /V /Yes >>",
1399            "[0 0 20 20]",
1400            &[],
1401        );
1402        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1403    }
1404
1405    /// /AS and /V absent but the /Parent field dict carries /V /Yes (radio
1406    /// button group pattern): the one-level parent V-fallback selects the Yes
1407    /// stream.
1408    #[test]
1409    fn widget_substate_parent_v_fallback() {
1410        let pdf = checkbox_pdf(
1411            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1412              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /Parent 8 0 R >>",
1413            "[0 0 20 20]",
1414            &[b"<< /FT /Btn /V /Yes >>".to_vec()],
1415        );
1416        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1417    }
1418
1419    /// Non-ASCII appearance-state name: the /N dict key contains raw byte
1420    /// 0xF6 and /AS spells the identical bytes via a #F6 hex escape. Matching
1421    /// must happen on raw decoded name bytes, never through lossy UTF-8.
1422    #[test]
1423    fn widget_substate_non_ascii_state_name() {
1424        let annot = b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1425              /AP << /N << /Stra\xf6m 5 0 R /Off 6 0 R >> >> /AS /Stra#F6m >>";
1426        // Sanity: the raw 0xF6 byte really is in the annotation dict bytes.
1427        assert!(annot.contains(&0xf6));
1428        let pdf = checkbox_pdf(annot, "[0 0 20 20]", &[]);
1429        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1430    }
1431
1432    /// A degenerate (zero-width) appearance BBox must not produce a
1433    /// non-finite scale matrix: the annotation is skipped without panicking.
1434    #[test]
1435    fn widget_degenerate_bbox_skipped() {
1436        let pdf = checkbox_pdf(
1437            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1438              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1439            "[0 0 0 20]",
1440            &[],
1441        );
1442        assert_widths(&interpret_widths(pdf), &[]);
1443    }
1444
1445    /// Regression guard: a plain (non-substate) /AP /N stream still renders.
1446    #[test]
1447    fn widget_direct_stream_still_renders() {
1448        let pdf = checkbox_pdf(
1449            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1450              /AP << /N 6 0 R >> >>",
1451            "[0 0 20 20]",
1452            &[],
1453        );
1454        assert_widths(&interpret_widths(pdf), &[7.0]);
1455    }
1456
1457    /// Ported from LaurenzV/hayro 36be86c1 (#1359): an annotation whose
1458    /// /AP exists but whose normal appearance cannot be selected warns
1459    /// instead of silently drawing nothing. /AS /Off with no /Off entry
1460    /// and no /V resolves to nothing and draws nothing.
1461    #[test]
1462    fn widget_unresolved_appearance_warns() {
1463        let pdf = checkbox_pdf(
1464            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1465              /AP << /N << /Yes 5 0 R >> >> /AS /Off >>",
1466            "[0 0 20 20]",
1467            &[],
1468        );
1469        let (warnings, widths) = interpret_widths_and_warnings(pdf);
1470        assert_widths(&widths, &[]);
1471        assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
1472        assert!(
1473            matches!(
1474                warnings[0],
1475                InterpreterWarning::UnresolvedAnnotationAppearance
1476            ),
1477            "expected UnresolvedAnnotationAppearance, got {:?}",
1478            warnings[0]
1479        );
1480    }
1481
1482    /// Control for the above: a resolved appearance draws and warns about
1483    /// nothing.
1484    #[test]
1485    fn widget_resolved_appearance_warns_nothing() {
1486        let pdf = checkbox_pdf(
1487            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1488              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1489            "[0 0 20 20]",
1490            &[],
1491        );
1492        let (warnings, widths) = interpret_widths_and_warnings(pdf);
1493        assert_widths(&widths, &[10.0, 4.0]);
1494        assert!(
1495            warnings.is_empty(),
1496            "expected no warnings, got {warnings:?}"
1497        );
1498    }
1499
1500    /// A device that records the first RGB pixel of every raster image it
1501    /// is asked to draw.
1502    #[derive(Default)]
1503    struct FirstPixelDevice {
1504        first_pixels: Vec<[u8; 3]>,
1505    }
1506
1507    impl Device<'_> for FirstPixelDevice {
1508        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1509        fn set_blend_mode(&mut self, _: BlendMode) {}
1510        fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
1511        fn push_clip_path(&mut self, _: &ClipPath) {}
1512        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1513        fn draw_glyph(
1514            &mut self,
1515            _: &Glyph<'_>,
1516            _: Affine,
1517            _: Affine,
1518            _: &Paint<'_>,
1519            _: &GlyphDrawMode,
1520        ) {
1521        }
1522        fn draw_image(&mut self, image: Image<'_, '_>, _: Affine) {
1523            if let Image::Raster(raster) = image {
1524                raster.with_rgba(
1525                    |rgb, _| {
1526                        if rgb.data.len() >= 3 {
1527                            self.first_pixels
1528                                .push([rgb.data[0], rgb.data[1], rgb.data[2]]);
1529                        }
1530                    },
1531                    None,
1532                );
1533            }
1534        }
1535        fn pop_clip_path(&mut self) {}
1536        fn pop_transparency_group(&mut self) {}
1537    }
1538
1539    /// Interpret the first page of `pdf_bytes` and return the first RGB
1540    /// pixel of every raster image drawn.
1541    fn interpret_first_pixels(pdf_bytes: Vec<u8>) -> Vec<[u8; 3]> {
1542        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1543        let pages = pdf.pages();
1544        let page = pages.first().expect("test PDF must have one page");
1545
1546        let settings = InterpreterSettings::default();
1547        let initial_transform = page.initial_transform(true);
1548        let bbox = kurbo::Rect::new(0.0, 0.0, 200.0, 200.0);
1549        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1550        let mut device = FirstPixelDevice::default();
1551
1552        interpret_page(page, &mut context, &mut device);
1553        device.first_pixels
1554    }
1555
1556    /// Ported from LaurenzV/hayro a05f51eb (#1311): an image XObject whose
1557    /// /ColorSpace is a bare resource name (spec-forbidden, seen in the
1558    /// wild) must resolve it against the page resources instead of being
1559    /// dropped. Mirrors upstream's `image_named_colorspace.pdf`: an 8x8
1560    /// /Indexed image whose first index maps to pure red.
1561    #[test]
1562    fn image_xobject_with_bare_name_colorspace_renders() {
1563        let mut pixels = Vec::with_capacity(64);
1564        for _ in 0..8 {
1565            pixels.extend_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]);
1566        }
1567        let mut image = format!(
1568            "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 8 \
1569             /ColorSpace /CS0 /Length {} >>\nstream\n",
1570            pixels.len()
1571        )
1572        .into_bytes();
1573        image.extend_from_slice(&pixels);
1574        image.extend_from_slice(b"\nendstream");
1575
1576        let content = b"q\n200 0 0 200 0 0 cm\n/Im0 Do\nQ";
1577        let mut content_stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
1578        content_stream.extend_from_slice(content);
1579        content_stream.extend_from_slice(b"\nendstream");
1580
1581        let pdf = build_pdf(&[
1582            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1583            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1584            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
1585              /Resources << /XObject << /Im0 4 0 R >> /ColorSpace << /CS0 6 0 R >> >> \
1586              /Contents 5 0 R >>"
1587                .to_vec(),
1588            image,
1589            content_stream,
1590            b"[/Indexed /DeviceRGB 7 <ff0000ff8000ffff0000ff0000ffff0000ffff00ff000000>]".to_vec(),
1591        ]);
1592
1593        // Index 0 of the palette is pure red; without the fix the image
1594        // decodes against a gray fallback (first pixel black) instead.
1595        assert_eq!(interpret_first_pixels(pdf), [[255, 0, 0]]);
1596    }
1597
1598    /// A device that collects the Unicode text of every glyph it is asked
1599    /// to draw.
1600    #[derive(Default)]
1601    struct TextDevice {
1602        text: String,
1603    }
1604
1605    impl Device<'_> for TextDevice {
1606        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1607        fn set_blend_mode(&mut self, _: BlendMode) {}
1608        fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
1609        fn push_clip_path(&mut self, _: &ClipPath) {}
1610        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1611        fn draw_glyph(
1612            &mut self,
1613            glyph: &Glyph<'_>,
1614            _: Affine,
1615            _: Affine,
1616            _: &Paint<'_>,
1617            _: &GlyphDrawMode,
1618        ) {
1619            match glyph.as_unicode() {
1620                Some(pdf_font::cmap::BfString::Char(c)) => self.text.push(c),
1621                Some(pdf_font::cmap::BfString::String(s)) => self.text.push_str(&s),
1622                None => {}
1623            }
1624        }
1625        fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1626        fn pop_clip_path(&mut self) {}
1627        fn pop_transparency_group(&mut self) {}
1628    }
1629
1630    /// Interpret the first page of `pdf_bytes` and return the drawn text.
1631    fn interpret_text(pdf_bytes: Vec<u8>) -> String {
1632        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1633        let pages = pdf.pages();
1634        let page = pages.first().expect("test PDF must have one page");
1635
1636        let settings = InterpreterSettings::default();
1637        let initial_transform = page.initial_transform(true);
1638        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
1639        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1640        let mut device = TextDevice::default();
1641
1642        interpret_page(page, &mut context, &mut device);
1643        device.text
1644    }
1645
1646    /// A Type1 font whose /Differences remap S to X, so resolving it (as
1647    /// opposed to falling back to Helvetica) is visible in the text.
1648    fn differences_font() -> Vec<u8> {
1649        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << \
1650          /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [83 /X] >> >>"
1651            .to_vec()
1652    }
1653
1654    /// Ported from LaurenzV/hayro 42ceaf44 (#1387), page-tree half: the
1655    /// page's own /Resources shadows the /Pages dict wholesale. /F1 lives
1656    /// only on the /Pages node and maps S to X; the page carries its own
1657    /// /Resources without /F1, so the text falls back to Helvetica
1658    /// ("Shadowed"). Without the fix the ancestor font leaks in
1659    /// ("Xhadowed").
1660    #[test]
1661    fn page_resources_shadow_pages_resources() {
1662        let content = b"BT /F1 12 Tf 72 700 Td (Shadowed) Tj ET";
1663        let content_stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
1664        let mut content_stream_body = content_stream;
1665        content_stream_body.extend_from_slice(content);
1666        content_stream_body.extend_from_slice(b"\nendstream");
1667
1668        let pdf = build_pdf(&[
1669            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1670            b"<< /Type /Pages /Kids [3 0 R] /Count 1 \
1671              /Resources << /Font << /F1 5 0 R >> >> >>"
1672                .to_vec(),
1673            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1674              /Resources << /Font << /F2 6 0 R >> >> /Contents 4 0 R >>"
1675                .to_vec(),
1676            content_stream_body,
1677            differences_font(),
1678            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
1679        ]);
1680
1681        assert_eq!(interpret_text(pdf), "Shadowed");
1682    }
1683
1684    /// Ported from LaurenzV/hayro 42ceaf44 (#1387), form-XObject half: a
1685    /// form with its own /Resources does not see the page's. The page
1686    /// holds /F1 (S to X); the form holds only /F2 and shows text with
1687    /// /F1, which falls back to Helvetica ("Shadowed"). Without the fix
1688    /// the page font leaks into the form ("Xhadowed").
1689    #[test]
1690    fn form_resources_shadow_page_resources() {
1691        let form_content = b"BT /F1 12 Tf 10 10 Td (Shadowed) Tj ET";
1692        let mut form = format!(
1693            "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \
1694             /Resources << /Font << /F2 7 0 R >> >> /Length {} >>\nstream\n",
1695            form_content.len()
1696        )
1697        .into_bytes();
1698        form.extend_from_slice(form_content);
1699        form.extend_from_slice(b"\nendstream");
1700
1701        let page_content = b"q 1 0 0 1 0 0 cm /Fm0 Do Q";
1702        let mut page_stream =
1703            format!("<< /Length {} >>\nstream\n", page_content.len()).into_bytes();
1704        page_stream.extend_from_slice(page_content);
1705        page_stream.extend_from_slice(b"\nendstream");
1706
1707        let pdf = build_pdf(&[
1708            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1709            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1710            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1711              /Resources << /Font << /F1 6 0 R >> /XObject << /Fm0 4 0 R >> >> \
1712              /Contents 5 0 R >>"
1713                .to_vec(),
1714            form,
1715            page_stream,
1716            differences_font(),
1717            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
1718        ]);
1719
1720        assert_eq!(interpret_text(pdf), "Shadowed");
1721    }
1722}