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(StandardFont::Helvetica, &context.settings.font_resolver)
983                        .map(TextStateFont::Fallback)
984                };
985                // The resource name rides the resolved font (a clone out of
986                // the cache) down to the glyphs, so extraction can tell the
987                // engine's own OCR layer apart from author text. Fallbacks
988                // carry no resource and stay nameless.
989                if let Some(TextStateFont::Font(resolved)) = font.as_mut() {
990                    resolved.set_resource_name(name.as_str());
991                }
992
993                context.get_mut().text_state.font_size = t.1.as_f32();
994                context.get_mut().text_state.font = font;
995            }
996            TypedInstruction::ShowText(s) => {
997                if context.get().text_state.font.is_none() {
998                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
999                    // seems to do the same.
1000                    context.get_mut().text_state.font = Font::new_standard(
1001                        StandardFont::Helvetica,
1002                        &context.settings.font_resolver,
1003                    )
1004                    .map(TextStateFont::Fallback);
1005                }
1006
1007                text::show_text_string(context, device, resources, s.0);
1008            }
1009            TypedInstruction::ShowTexts(s) => {
1010                if context.get().text_state.font.is_none() {
1011                    // Even if no explicit font was set, we try to assume Helvetica. Acrobat
1012                    // seems to do the same.
1013                    context.get_mut().text_state.font = Font::new_standard(
1014                        StandardFont::Helvetica,
1015                        &context.settings.font_resolver,
1016                    )
1017                    .map(TextStateFont::Fallback);
1018                }
1019
1020                for obj in s.0.iter::<Object<'_>>() {
1021                    if let Some(adjustment) = obj.clone().into_f32() {
1022                        // ANN[r17/TEX1] Surface TJ adjustment to the Device
1023                        // before mutating the text matrix so extractors can
1024                        // record the word-boundary signal alongside the
1025                        // spatial gap they'd otherwise have to infer.
1026                        device.text_adjustment(adjustment);
1027                        context.get_mut().text_state.apply_adjustment(adjustment);
1028                    } else if let Some(text) = obj.into_string() {
1029                        text::show_text_string(context, device, resources, text);
1030                    }
1031                }
1032            }
1033            TypedInstruction::HorizontalScaling(h) => {
1034                context.get_mut().text_state.horizontal_scaling = h.0.as_f32();
1035            }
1036            TypedInstruction::TextLeading(tl) => {
1037                context.get_mut().text_state.leading = tl.0.as_f32();
1038            }
1039            TypedInstruction::CharacterSpacing(c) => {
1040                context.get_mut().text_state.char_space = c.0.as_f32();
1041            }
1042            TypedInstruction::WordSpacing(w) => {
1043                context.get_mut().text_state.word_space = w.0.as_f32();
1044            }
1045            TypedInstruction::NextLine(n) => {
1046                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1047                text::next_line(context, tx, ty);
1048            }
1049            TypedInstruction::NextLineUsingLeading(_) => {
1050                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1051            }
1052            TypedInstruction::NextLineAndShowText(n) => {
1053                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1054                text::show_text_string(context, device, resources, n.0);
1055            }
1056            TypedInstruction::TextRenderingMode(r) => {
1057                let mode = match r.0.as_i64() {
1058                    0 => TextRenderingMode::Fill,
1059                    1 => TextRenderingMode::Stroke,
1060                    2 => TextRenderingMode::FillStroke,
1061                    3 => TextRenderingMode::Invisible,
1062                    4 => TextRenderingMode::FillAndClip,
1063                    5 => TextRenderingMode::StrokeAndClip,
1064                    6 => TextRenderingMode::FillAndStrokeAndClip,
1065                    7 => TextRenderingMode::Clip,
1066                    _ => {
1067                        warn!("unknown text rendering mode {}", r.0.as_i64());
1068
1069                        TextRenderingMode::Fill
1070                    }
1071                };
1072
1073                context.get_mut().text_state.render_mode = mode;
1074            }
1075            TypedInstruction::NextLineAndSetLeading(n) => {
1076                let (tx, ty) = (n.0.as_f64(), n.1.as_f64());
1077                context.get_mut().text_state.leading = -ty as f32;
1078                text::next_line(context, tx, ty);
1079            }
1080            // d1: uncolored (shape) glyph header.  The advance width (wx) and
1081            // bounding-box arguments are intentionally ignored here: the glyph
1082            // advance is taken from the Type3 font's /Widths array (via
1083            // Font::code_advance), and the is_shape_glyph flag is determined
1084            // by the pre-scan in Type3::render_glyph before the stream is
1085            // interpreted.
1086            TypedInstruction::ShapeGlyph(_) => {}
1087            TypedInstruction::XObject(x) => {
1088                let cache = context.object_cache.clone();
1089                let warning_sink = context.settings.warning_sink.clone();
1090                let transfer_function = context.get().graphics_state.transfer_function.clone();
1091                if let Some(x_object) = resources.get_x_object(x.0).and_then(|s| {
1092                    XObject::new(
1093                        &s,
1094                        |name| context.get_color_space(resources, name.clone()),
1095                        &warning_sink,
1096                        &cache,
1097                        transfer_function.clone(),
1098                    )
1099                }) {
1100                    draw_xobject(&x_object, resources, context, device);
1101                }
1102            }
1103            TypedInstruction::InlineImage(i) => {
1104                let warning_sink = context.settings.warning_sink.clone();
1105                let transfer_function = context.get().graphics_state.transfer_function.clone();
1106                let cache = context.object_cache.clone();
1107                if let Some(x_object) = ImageXObject::new(
1108                    &i.0,
1109                    |name| context.get_color_space(resources, name.clone()),
1110                    &warning_sink,
1111                    &cache,
1112                    false,
1113                    transfer_function,
1114                ) {
1115                    draw_image_xobject(&x_object, context, device);
1116                }
1117            }
1118            TypedInstruction::TextRise(t) => {
1119                context.get_mut().text_state.rise = t.0.as_f32();
1120            }
1121            TypedInstruction::Shading(s) => {
1122                if !context.ocg_state.is_visible() {
1123                    continue;
1124                }
1125
1126                let transfer_function = context.get().graphics_state.transfer_function.clone();
1127
1128                if let Some(sp) = resources
1129                    .get_shading(s.0)
1130                    .and_then(|o| dict_or_stream(&o))
1131                    .and_then(|s| {
1132                        Shading::new(
1133                            &s.0,
1134                            s.1.as_ref(),
1135                            &context.object_cache,
1136                            &context.settings.warning_sink,
1137                        )
1138                    })
1139                    .map(|s| {
1140                        Pattern::Shading(ShadingPattern {
1141                            shading: Arc::new(s),
1142                            matrix: Affine::IDENTITY,
1143                            opacity: context.get().graphics_state.non_stroke_alpha,
1144                            transfer_function: transfer_function.clone(),
1145                        })
1146                    })
1147                {
1148                    context.save_state();
1149                    context.push_root_transform();
1150                    let st = context.get_mut();
1151                    st.graphics_state.non_stroke_pattern = Some(sp);
1152                    st.graphics_state.none_stroke_cs = ColorSpace::pattern();
1153
1154                    device.set_soft_mask(st.graphics_state.soft_mask.clone());
1155                    device.set_blend_mode(st.graphics_state.blend_mode);
1156
1157                    let bbox = context.bbox().to_path(0.1);
1158                    let inverted_bbox = context.get().ctm.inverse() * bbox;
1159                    fill_path_impl(context, device, FillRule::NonZero, Some(&inverted_bbox));
1160
1161                    context.pop_root_transform();
1162                    context.restore_state(device);
1163                } else {
1164                    warn!("failed to process shading");
1165                }
1166            }
1167            TypedInstruction::BeginCompatibility(_) => {}
1168            TypedInstruction::EndCompatibility(_) => {}
1169            // d0: colored glyph header.  The advance width (wx) argument is
1170            // intentionally ignored here for the same reason as d1 above.
1171            TypedInstruction::ColorGlyph(_) => {}
1172            TypedInstruction::ShowTextWithParameters(t) => {
1173                context.get_mut().text_state.word_space = t.0.as_f32();
1174                context.get_mut().text_state.char_space = t.1.as_f32();
1175                text::next_line(context, 0.0, -context.get().text_state.leading as f64);
1176                text::show_text_string(context, device, resources, t.2);
1177            }
1178            _ => {
1179                warn!("failed to read an operator");
1180            }
1181        }
1182    }
1183
1184    while context.num_states() > num_states {
1185        context.restore_state(device);
1186    }
1187
1188    // Released here rather than on a guard object because every exit from this
1189    // function is this line: the early `return` above happens before the claim.
1190    // A counter that only rose would refuse the fifty-first XObject a page draws
1191    // in sequence, which is not nesting at all.
1192    context.end_nested_interpretation();
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use crate::device::Device;
1198    use crate::font::Glyph;
1199    use crate::soft_mask::SoftMask;
1200    use crate::util::PageExt;
1201    use crate::{
1202        BlendMode, ClipPath, Context, GlyphDrawMode, Image, InterpreterSettings,
1203        InterpreterWarning, Paint, PathDrawMode, interpret_page,
1204    };
1205    use kurbo::{Affine, BezPath, Shape};
1206    use pdf_syntax::Pdf;
1207
1208    /// A device that records the bounding-box width (in path coordinates) of
1209    /// every filled/stroked path, so tests can assert exactly which appearance
1210    /// stream's marks were interpreted.
1211    #[derive(Default)]
1212    struct CountingDevice {
1213        path_widths: Vec<f64>,
1214    }
1215
1216    impl Device<'_> for CountingDevice {
1217        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1218        fn set_blend_mode(&mut self, _: BlendMode) {}
1219        fn draw_path(&mut self, path: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {
1220            self.path_widths.push(path.bounding_box().width());
1221        }
1222        fn push_clip_path(&mut self, _: &ClipPath) {}
1223        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1224        fn draw_glyph(
1225            &mut self,
1226            _: &Glyph<'_>,
1227            _: Affine,
1228            _: Affine,
1229            _: &Paint<'_>,
1230            _: &GlyphDrawMode,
1231        ) {
1232        }
1233        fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1234        fn pop_clip_path(&mut self) {}
1235        fn pop_transparency_group(&mut self) {}
1236    }
1237
1238    /// Assemble a PDF from numbered object bodies (index `i` becomes object
1239    /// `i + 1`), computing byte-accurate xref offsets.
1240    fn build_pdf(objects: &[Vec<u8>]) -> Vec<u8> {
1241        let mut out = b"%PDF-1.7\n".to_vec();
1242        let mut offsets = Vec::with_capacity(objects.len());
1243
1244        for (i, body) in objects.iter().enumerate() {
1245            offsets.push(out.len());
1246            out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
1247            out.extend_from_slice(body);
1248            out.extend_from_slice(b"\nendobj\n");
1249        }
1250
1251        let xref_pos = out.len();
1252        out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1253        out.extend_from_slice(b"0000000000 65535 f \n");
1254        for offset in offsets {
1255            out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1256        }
1257        out.extend_from_slice(
1258            format!(
1259                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
1260                objects.len() + 1
1261            )
1262            .as_bytes(),
1263        );
1264
1265        out
1266    }
1267
1268    /// Build a Form XObject stream object body.
1269    fn form_stream(bbox: &str, content: &str) -> Vec<u8> {
1270        format!(
1271            "<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} >>\nstream\n{content}\nendstream",
1272            content.len()
1273        )
1274        .into_bytes()
1275    }
1276
1277    /// Build a single-page PDF with one widget annotation.
1278    ///
1279    /// Object layout: 1 catalog, 2 page tree, 3 page, 4 the annotation
1280    /// (`annot_body`), 5 the "on" appearance stream (two fills, path widths
1281    /// 10 and 4), 6 the "off" appearance stream (one fill, path width 7),
1282    /// 7 empty page contents, 8.. `extra_objects`. The "on" stream's BBox is
1283    /// `on_bbox` so degenerate-BBox behaviour can be exercised.
1284    fn checkbox_pdf(annot_body: &[u8], on_bbox: &str, extra_objects: &[Vec<u8>]) -> Vec<u8> {
1285        let mut objects = vec![
1286            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1287            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1288            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1289              /Annots [4 0 R] /Contents 7 0 R >>"
1290                .to_vec(),
1291            annot_body.to_vec(),
1292            form_stream(on_bbox, "0 0 10 10 re f\n12 12 4 4 re f"),
1293            form_stream("[0 0 20 20]", "0 0 7 7 re f"),
1294            b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
1295        ];
1296        objects.extend_from_slice(extra_objects);
1297        build_pdf(&objects)
1298    }
1299
1300    /// Interpret the first page of `pdf_bytes` and return the recorded path
1301    /// widths.
1302    fn interpret_widths(pdf_bytes: Vec<u8>) -> Vec<f64> {
1303        interpret_widths_and_warnings(pdf_bytes).1
1304    }
1305
1306    /// Interpret the first page of `pdf_bytes`, capturing interpreter
1307    /// warnings, and return both.
1308    fn interpret_widths_and_warnings(pdf_bytes: Vec<u8>) -> (Vec<InterpreterWarning>, Vec<f64>) {
1309        use std::sync::{Arc, Mutex};
1310
1311        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1312        let pages = pdf.pages();
1313        let page = pages.first().expect("test PDF must have one page");
1314
1315        let warnings = Arc::new(Mutex::new(Vec::new()));
1316        let warnings_sink = warnings.clone();
1317        let settings = InterpreterSettings {
1318            warning_sink: Arc::new(move |w| warnings_sink.lock().expect("test lock").push(w)),
1319            ..Default::default()
1320        };
1321        let initial_transform = page.initial_transform(true);
1322        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
1323        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1324        let mut device = CountingDevice::default();
1325
1326        interpret_page(page, &mut context, &mut device);
1327        (
1328            warnings.lock().expect("test lock").clone(),
1329            device.path_widths,
1330        )
1331    }
1332
1333    fn assert_widths(widths: &[f64], expected: &[f64]) {
1334        assert_eq!(
1335            widths.len(),
1336            expected.len(),
1337            "expected {expected:?}, got {widths:?}"
1338        );
1339        for (got, want) in widths.iter().zip(expected) {
1340            assert!(
1341                (got - want).abs() < 1e-6,
1342                "expected {expected:?}, got {widths:?}"
1343            );
1344        }
1345    }
1346
1347    /// /AP /N substate dictionary with /AS /Yes: the Yes stream (and only the
1348    /// Yes stream) must be drawn.
1349    #[test]
1350    fn widget_substate_as_on_state() {
1351        let pdf = checkbox_pdf(
1352            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1353              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1354            "[0 0 20 20]",
1355            &[],
1356        );
1357        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1358    }
1359
1360    /// Same widget with /AS /Off: the Off stream is drawn, and none of the
1361    /// Yes stream's marks appear.
1362    #[test]
1363    fn widget_substate_as_off_state() {
1364        let pdf = checkbox_pdf(
1365            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1366              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Off >>",
1367            "[0 0 20 20]",
1368            &[],
1369        );
1370        assert_widths(&interpret_widths(pdf), &[7.0]);
1371    }
1372
1373    /// /N has only the on-state and /AS is /Off: nothing must be drawn and
1374    /// nothing must panic (ISO 32000 §12.5.5 — no applicable appearance).
1375    #[test]
1376    fn widget_substate_as_off_without_off_entry() {
1377        let pdf = checkbox_pdf(
1378            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1379              /AP << /N << /Yes 5 0 R >> >> /AS /Off >>",
1380            "[0 0 20 20]",
1381            &[],
1382        );
1383        assert_widths(&interpret_widths(pdf), &[]);
1384    }
1385
1386    /// /AS absent but /V /Yes on the widget: the pdfium V-fallback selects
1387    /// the Yes stream.
1388    #[test]
1389    fn widget_substate_v_fallback() {
1390        let pdf = checkbox_pdf(
1391            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1392              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /V /Yes >>",
1393            "[0 0 20 20]",
1394            &[],
1395        );
1396        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1397    }
1398
1399    /// /AS and /V absent but the /Parent field dict carries /V /Yes (radio
1400    /// button group pattern): the one-level parent V-fallback selects the Yes
1401    /// stream.
1402    #[test]
1403    fn widget_substate_parent_v_fallback() {
1404        let pdf = checkbox_pdf(
1405            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1406              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /Parent 8 0 R >>",
1407            "[0 0 20 20]",
1408            &[b"<< /FT /Btn /V /Yes >>".to_vec()],
1409        );
1410        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1411    }
1412
1413    /// Non-ASCII appearance-state name: the /N dict key contains raw byte
1414    /// 0xF6 and /AS spells the identical bytes via a #F6 hex escape. Matching
1415    /// must happen on raw decoded name bytes, never through lossy UTF-8.
1416    #[test]
1417    fn widget_substate_non_ascii_state_name() {
1418        let annot = b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1419              /AP << /N << /Stra\xf6m 5 0 R /Off 6 0 R >> >> /AS /Stra#F6m >>";
1420        // Sanity: the raw 0xF6 byte really is in the annotation dict bytes.
1421        assert!(annot.contains(&0xf6));
1422        let pdf = checkbox_pdf(annot, "[0 0 20 20]", &[]);
1423        assert_widths(&interpret_widths(pdf), &[10.0, 4.0]);
1424    }
1425
1426    /// A degenerate (zero-width) appearance BBox must not produce a
1427    /// non-finite scale matrix: the annotation is skipped without panicking.
1428    #[test]
1429    fn widget_degenerate_bbox_skipped() {
1430        let pdf = checkbox_pdf(
1431            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1432              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1433            "[0 0 0 20]",
1434            &[],
1435        );
1436        assert_widths(&interpret_widths(pdf), &[]);
1437    }
1438
1439    /// Regression guard: a plain (non-substate) /AP /N stream still renders.
1440    #[test]
1441    fn widget_direct_stream_still_renders() {
1442        let pdf = checkbox_pdf(
1443            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1444              /AP << /N 6 0 R >> >>",
1445            "[0 0 20 20]",
1446            &[],
1447        );
1448        assert_widths(&interpret_widths(pdf), &[7.0]);
1449    }
1450
1451    /// Ported from LaurenzV/hayro 36be86c1 (#1359): an annotation whose
1452    /// /AP exists but whose normal appearance cannot be selected warns
1453    /// instead of silently drawing nothing. /AS /Off with no /Off entry
1454    /// and no /V resolves to nothing and draws nothing.
1455    #[test]
1456    fn widget_unresolved_appearance_warns() {
1457        let pdf = checkbox_pdf(
1458            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1459              /AP << /N << /Yes 5 0 R >> >> /AS /Off >>",
1460            "[0 0 20 20]",
1461            &[],
1462        );
1463        let (warnings, widths) = interpret_widths_and_warnings(pdf);
1464        assert_widths(&widths, &[]);
1465        assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
1466        assert!(
1467            matches!(
1468                warnings[0],
1469                InterpreterWarning::UnresolvedAnnotationAppearance
1470            ),
1471            "expected UnresolvedAnnotationAppearance, got {:?}",
1472            warnings[0]
1473        );
1474    }
1475
1476    /// Control for the above: a resolved appearance draws and warns about
1477    /// nothing.
1478    #[test]
1479    fn widget_resolved_appearance_warns_nothing() {
1480        let pdf = checkbox_pdf(
1481            b"<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
1482              /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> /AS /Yes >>",
1483            "[0 0 20 20]",
1484            &[],
1485        );
1486        let (warnings, widths) = interpret_widths_and_warnings(pdf);
1487        assert_widths(&widths, &[10.0, 4.0]);
1488        assert!(
1489            warnings.is_empty(),
1490            "expected no warnings, got {warnings:?}"
1491        );
1492    }
1493
1494    /// A device that records the first RGB pixel of every raster image it
1495    /// is asked to draw.
1496    #[derive(Default)]
1497    struct FirstPixelDevice {
1498        first_pixels: Vec<[u8; 3]>,
1499    }
1500
1501    impl Device<'_> for FirstPixelDevice {
1502        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1503        fn set_blend_mode(&mut self, _: BlendMode) {}
1504        fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
1505        fn push_clip_path(&mut self, _: &ClipPath) {}
1506        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1507        fn draw_glyph(
1508            &mut self,
1509            _: &Glyph<'_>,
1510            _: Affine,
1511            _: Affine,
1512            _: &Paint<'_>,
1513            _: &GlyphDrawMode,
1514        ) {
1515        }
1516        fn draw_image(&mut self, image: Image<'_, '_>, _: Affine) {
1517            if let Image::Raster(raster) = image {
1518                raster.with_rgba(
1519                    |rgb, _| {
1520                        if rgb.data.len() >= 3 {
1521                            self.first_pixels
1522                                .push([rgb.data[0], rgb.data[1], rgb.data[2]]);
1523                        }
1524                    },
1525                    None,
1526                );
1527            }
1528        }
1529        fn pop_clip_path(&mut self) {}
1530        fn pop_transparency_group(&mut self) {}
1531    }
1532
1533    /// Interpret the first page of `pdf_bytes` and return the first RGB
1534    /// pixel of every raster image drawn.
1535    fn interpret_first_pixels(pdf_bytes: Vec<u8>) -> Vec<[u8; 3]> {
1536        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1537        let pages = pdf.pages();
1538        let page = pages.first().expect("test PDF must have one page");
1539
1540        let settings = InterpreterSettings::default();
1541        let initial_transform = page.initial_transform(true);
1542        let bbox = kurbo::Rect::new(0.0, 0.0, 200.0, 200.0);
1543        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1544        let mut device = FirstPixelDevice::default();
1545
1546        interpret_page(page, &mut context, &mut device);
1547        device.first_pixels
1548    }
1549
1550    /// Ported from LaurenzV/hayro a05f51eb (#1311): an image XObject whose
1551    /// /ColorSpace is a bare resource name (spec-forbidden, seen in the
1552    /// wild) must resolve it against the page resources instead of being
1553    /// dropped. Mirrors upstream's `image_named_colorspace.pdf`: an 8x8
1554    /// /Indexed image whose first index maps to pure red.
1555    #[test]
1556    fn image_xobject_with_bare_name_colorspace_renders() {
1557        let mut pixels = Vec::with_capacity(64);
1558        for _ in 0..8 {
1559            pixels.extend_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]);
1560        }
1561        let mut image = format!(
1562            "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 8 \
1563             /ColorSpace /CS0 /Length {} >>\nstream\n",
1564            pixels.len()
1565        )
1566        .into_bytes();
1567        image.extend_from_slice(&pixels);
1568        image.extend_from_slice(b"\nendstream");
1569
1570        let content = b"q\n200 0 0 200 0 0 cm\n/Im0 Do\nQ";
1571        let mut content_stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
1572        content_stream.extend_from_slice(content);
1573        content_stream.extend_from_slice(b"\nendstream");
1574
1575        let pdf = build_pdf(&[
1576            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1577            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1578            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
1579              /Resources << /XObject << /Im0 4 0 R >> /ColorSpace << /CS0 6 0 R >> >> \
1580              /Contents 5 0 R >>"
1581                .to_vec(),
1582            image,
1583            content_stream,
1584            b"[/Indexed /DeviceRGB 7 <ff0000ff8000ffff0000ff0000ffff0000ffff00ff000000>]".to_vec(),
1585        ]);
1586
1587        // Index 0 of the palette is pure red; without the fix the image
1588        // decodes against a gray fallback (first pixel black) instead.
1589        assert_eq!(interpret_first_pixels(pdf), [[255, 0, 0]]);
1590    }
1591
1592    /// A device that collects the Unicode text of every glyph it is asked
1593    /// to draw.
1594    #[derive(Default)]
1595    struct TextDevice {
1596        text: String,
1597    }
1598
1599    impl Device<'_> for TextDevice {
1600        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1601        fn set_blend_mode(&mut self, _: BlendMode) {}
1602        fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
1603        fn push_clip_path(&mut self, _: &ClipPath) {}
1604        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1605        fn draw_glyph(
1606            &mut self,
1607            glyph: &Glyph<'_>,
1608            _: Affine,
1609            _: Affine,
1610            _: &Paint<'_>,
1611            _: &GlyphDrawMode,
1612        ) {
1613            match glyph.as_unicode() {
1614                Some(pdf_font::cmap::BfString::Char(c)) => self.text.push(c),
1615                Some(pdf_font::cmap::BfString::String(s)) => self.text.push_str(&s),
1616                None => {}
1617            }
1618        }
1619        fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1620        fn pop_clip_path(&mut self) {}
1621        fn pop_transparency_group(&mut self) {}
1622    }
1623
1624    /// Interpret the first page of `pdf_bytes` and return the drawn text.
1625    fn interpret_text(pdf_bytes: Vec<u8>) -> String {
1626        let pdf = Pdf::new(pdf_bytes).expect("test PDF must parse");
1627        let pages = pdf.pages();
1628        let page = pages.first().expect("test PDF must have one page");
1629
1630        let settings = InterpreterSettings::default();
1631        let initial_transform = page.initial_transform(true);
1632        let bbox = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
1633        let mut context = Context::new(initial_transform, bbox, page.xref(), settings);
1634        let mut device = TextDevice::default();
1635
1636        interpret_page(page, &mut context, &mut device);
1637        device.text
1638    }
1639
1640    /// A Type1 font whose /Differences remap S to X, so resolving it (as
1641    /// opposed to falling back to Helvetica) is visible in the text.
1642    fn differences_font() -> Vec<u8> {
1643        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << \
1644          /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [83 /X] >> >>"
1645            .to_vec()
1646    }
1647
1648    /// Ported from LaurenzV/hayro 42ceaf44 (#1387), page-tree half: the
1649    /// page's own /Resources shadows the /Pages dict wholesale. /F1 lives
1650    /// only on the /Pages node and maps S to X; the page carries its own
1651    /// /Resources without /F1, so the text falls back to Helvetica
1652    /// ("Shadowed"). Without the fix the ancestor font leaks in
1653    /// ("Xhadowed").
1654    #[test]
1655    fn page_resources_shadow_pages_resources() {
1656        let content = b"BT /F1 12 Tf 72 700 Td (Shadowed) Tj ET";
1657        let content_stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
1658        let mut content_stream_body = content_stream;
1659        content_stream_body.extend_from_slice(content);
1660        content_stream_body.extend_from_slice(b"\nendstream");
1661
1662        let pdf = build_pdf(&[
1663            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1664            b"<< /Type /Pages /Kids [3 0 R] /Count 1 \
1665              /Resources << /Font << /F1 5 0 R >> >> >>"
1666                .to_vec(),
1667            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1668              /Resources << /Font << /F2 6 0 R >> >> /Contents 4 0 R >>"
1669                .to_vec(),
1670            content_stream_body,
1671            differences_font(),
1672            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
1673        ]);
1674
1675        assert_eq!(interpret_text(pdf), "Shadowed");
1676    }
1677
1678    /// Ported from LaurenzV/hayro 42ceaf44 (#1387), form-XObject half: a
1679    /// form with its own /Resources does not see the page's. The page
1680    /// holds /F1 (S to X); the form holds only /F2 and shows text with
1681    /// /F1, which falls back to Helvetica ("Shadowed"). Without the fix
1682    /// the page font leaks into the form ("Xhadowed").
1683    #[test]
1684    fn form_resources_shadow_page_resources() {
1685        let form_content = b"BT /F1 12 Tf 10 10 Td (Shadowed) Tj ET";
1686        let mut form = format!(
1687            "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \
1688             /Resources << /Font << /F2 7 0 R >> >> /Length {} >>\nstream\n",
1689            form_content.len()
1690        )
1691        .into_bytes();
1692        form.extend_from_slice(form_content);
1693        form.extend_from_slice(b"\nendstream");
1694
1695        let page_content = b"q 1 0 0 1 0 0 cm /Fm0 Do Q";
1696        let mut page_stream =
1697            format!("<< /Length {} >>\nstream\n", page_content.len()).into_bytes();
1698        page_stream.extend_from_slice(page_content);
1699        page_stream.extend_from_slice(b"\nendstream");
1700
1701        let pdf = build_pdf(&[
1702            b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
1703            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
1704            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
1705              /Resources << /Font << /F1 6 0 R >> /XObject << /Fm0 4 0 R >> >> \
1706              /Contents 5 0 R >>"
1707                .to_vec(),
1708            form,
1709            page_stream,
1710            differences_font(),
1711            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
1712        ]);
1713
1714        assert_eq!(interpret_text(pdf), "Shadowed");
1715    }
1716}