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