Skip to main content

pdfrum_page/
type3.rs

1//! Type 3 glyph metrics: what a glyph procedure says about itself.
2//!
3//! A Type 3 font has no glyph program — each "glyph" is a content stream, and
4//! its advance and bounding box come from the `d0`/`d1` operator that stream
5//! is required to start with. So the only way to know how wide a Type 3
6//! character is, or what box it occupies, is to look inside the procedure,
7//! which is why this lives here rather than in `pdfrum-font`.
8//!
9//! Two rules decide the box:
10//!
11//! - **`d1` states it**, as `wx wy llx lly urx ury`. `d0` states only the
12//!   advance and leaves the box to be computed.
13//! - **A stated box that is degenerate is ignored** and the procedure's own
14//!   painted extent is used instead — `right <= left` or `bottom >= top` in
15//!   the C++'s y-down reading, which is `x1 <= x0 || y0 >= y1` here.
16//!
17//! Either way the result is scaled from text space to glyph units (×1000) and
18//! then transformed by the font's `/FontMatrix`, which is what puts a
19//! `/FontMatrix [0.001 0 0 0.001 0 0]` font's glyphs at the same scale as
20//! every other font's.
21
22use crate::build::BuildContext;
23use crate::ops::Op;
24use crate::page::PageObject;
25use crate::resources::Resources;
26use kurbo::{Affine, Rect};
27use pdfrum_common::{Diagnostics, Limits};
28use pdfrum_font::{CharCode, Type3Font};
29use pdfrum_object::Resolve;
30
31/// Text space is a thousandth of glyph space.
32const TEXT_UNIT_IN_GLYPH_UNITS: f64 = 1000.0;
33
34/// What a Type 3 glyph procedure declares about one character, and what it
35/// paints.
36#[derive(Debug, Clone, PartialEq)]
37pub struct Type3Metrics {
38    /// The advance in glyph units, rounded as the C++ rounds it.
39    pub width: f32,
40    /// The bounding box in glyph units, after the font matrix.
41    ///
42    /// This is what the glyph *declares*, and it is the right box for
43    /// measurement. It is the wrong box for a buffer: `d1`'s operands are
44    /// scaled by a thousand on the way in and put back through the font matrix
45    /// on the way out, so a font whose `/FontMatrix` is not the conventional
46    /// thousandth leaves the scale uncancelled and the box far off the page.
47    /// Use [`Type3Metrics::painted`] to size anything.
48    pub bbox: Rect,
49    /// The extent the procedure's objects actually cover, in **glyph space**.
50    ///
51    /// The objects' own union, with neither the thousandth scale nor the font
52    /// matrix applied — `CalcBoundingBox`'s answer, and unaffected by an
53    /// unconventional `/FontMatrix` because it never consults one.
54    pub painted: Rect,
55    /// Whether the procedure declared its own colour with `d0` rather than
56    /// only a width and box with `d1`.
57    ///
58    /// This is the coloured/uncoloured distinction the renderer needs: a `d1`
59    /// glyph takes the *text object's* colour for every drawing operation
60    /// inside it, whatever colours the procedure sets, while a `d0` one keeps
61    /// whatever it sets and falls back to the text object's only where it
62    /// sets none.
63    pub colored: bool,
64    /// The objects the procedure paints, in **glyph space** — the procedure's
65    /// own coordinate system, before the font matrix.
66    ///
67    /// A Type 3 glyph has no outline to cache: it is a content stream, and
68    /// drawing it means walking these. Interpreting them here rather than at
69    /// render time is what keeps the renderer free of the resolver, and it
70    /// costs nothing extra — the metrics already had to open the stream.
71    pub objects: Vec<PageObject>,
72}
73
74/// Reads one Type 3 character's metrics out of its glyph procedure.
75///
76/// `None` when the code names no procedure, which for a font with no
77/// `/Differences` is every code.
78#[must_use]
79pub fn metrics<R: Resolve>(
80    font: &Type3Font,
81    code: CharCode,
82    page_resources: Option<&pdfrum_object::Dict>,
83    r: &R,
84    ctx: &mut BuildContext,
85    limits: &Limits,
86    diags: &mut Diagnostics,
87) -> Option<Type3Metrics> {
88    let stream = font.char_proc(code, r)?;
89    let content = pdfrum_filters::decode_chain(&stream, 0, r, limits, diags).data;
90    let ops = crate::parse_content(&content, limits, diags);
91
92    // The declaration is whichever of the two operators the procedure used;
93    // a procedure that used neither declares nothing and gets a computed box.
94    // `d0` is the coloured form and `d1` the uncoloured one, and which
95    // appeared decides whether the glyph keeps its own colours.
96    let mut declared: Option<(f32, Option<Rect>, bool)> = None;
97    for op in &ops {
98        match op {
99            Op::Type3Width(wx, _) => {
100                declared = Some((*wx, None, true));
101                break;
102            }
103            Op::Type3WidthBBox(wx, _, llx, lly, urx, ury) => {
104                declared = Some((
105                    *wx,
106                    Some(Rect::new(
107                        f64::from(*llx),
108                        f64::from(*lly),
109                        f64::from(*urx),
110                        f64::from(*ury),
111                    )),
112                    false,
113                ));
114                break;
115            }
116            _ => {}
117        }
118    }
119    let (width, stated, colored) = declared.unwrap_or((0.0, None, false));
120
121    // The procedure's objects are needed either way — to *draw* the glyph
122    // always, and to measure it when the stated box is not believed — so they
123    // are built once here rather than conditionally.
124    //
125    // A procedure may show text in a Type 3 font, so this recurses; the form
126    // guard catches a procedure that invokes *itself*, and `kMaxType3FormLevel`
127    // is what bounds a pair that invoke each other through two distinct
128    // streams. Without it such a pair overflows the stack.
129    let objects = if ctx.enter_type3() {
130        let resources = Resources::choose(
131            font.resources.clone(),
132            page_resources.cloned(),
133            page_resources.cloned(),
134        );
135        let page = crate::build_page(&ops, &resources, r, ctx, limits, diags);
136        ctx.leave_type3();
137        page.objects
138    } else {
139        diags.record(
140            pdfrum_common::Severity::Recovered,
141            pdfrum_common::DiagKind::FormRecursionRefused,
142            None,
143        );
144        Vec::new()
145    };
146
147    // A stated box with no positive extent in either direction is not
148    // believed; the procedure's own painted extent stands in for it.
149    let usable = stated.filter(|b| b.x1 > b.x0 && b.y1 > b.y0);
150    let painted = painted_extent(&objects);
151    let text_box = match usable {
152        Some(rect) => scale(rect, TEXT_UNIT_IN_GLYPH_UNITS),
153        None => scale(painted, TEXT_UNIT_IN_GLYPH_UNITS),
154    };
155
156    Some(Type3Metrics {
157        width: round(f64::from(width) * TEXT_UNIT_IN_GLYPH_UNITS),
158        bbox: transform_rect(font.font_matrix, text_box),
159        painted,
160        colored,
161        objects,
162    })
163}
164
165/// The union of every object's extent, or an empty rectangle when the
166/// procedure painted nothing.
167fn painted_extent(objects: &[PageObject]) -> Rect {
168    let mut out: Option<Rect> = None;
169    let mut add = |rect: Rect| {
170        out = Some(match out {
171            Some(current) => current.union(rect),
172            None => rect,
173        });
174    };
175    for object in objects {
176        match object {
177            PageObject::Path(content) => {
178                let path = &content.object;
179                add(transform_rect(
180                    path.matrix,
181                    kurbo::Shape::bounding_box(&path.path),
182                ));
183            }
184            PageObject::Image(content) => {
185                add(transform_rect(
186                    content.object.matrix,
187                    Rect::new(0.0, 0.0, 1.0, 1.0),
188                ));
189            }
190            PageObject::Shading(content) => add(content.object.bounds),
191            PageObject::Form(content) => {
192                let inner = painted_extent(&content.object.objects);
193                if inner.width() > 0.0 || inner.height() > 0.0 {
194                    add(inner);
195                }
196            }
197            // A Type 3 glyph procedure that shows text is legal but does not
198            // contribute an extent here, matching the C++'s own bounding-box
199            // walk, which measures painted geometry.
200            PageObject::Text(_) => {}
201        }
202    }
203    out.unwrap_or(Rect::ZERO)
204}
205
206fn scale(rect: Rect, factor: f64) -> Rect {
207    Rect::new(
208        rect.x0 * factor,
209        rect.y0 * factor,
210        rect.x1 * factor,
211        rect.y1 * factor,
212    )
213}
214
215fn transform_rect(matrix: Affine, rect: Rect) -> Rect {
216    let corners = [
217        matrix * kurbo::Point::new(rect.x0, rect.y0),
218        matrix * kurbo::Point::new(rect.x1, rect.y0),
219        matrix * kurbo::Point::new(rect.x0, rect.y1),
220        matrix * kurbo::Point::new(rect.x1, rect.y1),
221    ];
222    let xs = corners.map(|p| p.x);
223    let ys = corners.map(|p| p.y);
224    Rect::new(
225        xs.iter().copied().fold(f64::INFINITY, f64::min),
226        ys.iter().copied().fold(f64::INFINITY, f64::min),
227        xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
228        ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
229    )
230}
231
232/// Half-away-from-zero rounding, which is `FXSYS_roundf`.
233fn round(value: f64) -> f32 {
234    #[expect(
235        clippy::cast_possible_truncation,
236        reason = "a glyph advance outside f32 is nonsense whatever we do"
237    )]
238    let rounded = value.round() as f32;
239    rounded
240}
241
242#[cfg(test)]
243mod tests {
244    // Test fixtures quote the oracle's own vectors, compare floats exactly
245    // where the behaviour being pinned is exact, and index arrays whose
246    // length the fixture itself fixes.
247    #![allow(
248        clippy::float_cmp,
249        clippy::indexing_slicing,
250        clippy::unreadable_literal,
251        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
252    )]
253
254    use super::*;
255
256    #[test]
257    fn a_text_space_box_scales_by_a_thousand() {
258        assert_eq!(
259            scale(Rect::new(0.0, 0.0, 0.5, 0.75), 1000.0),
260            Rect::new(0.0, 0.0, 500.0, 750.0)
261        );
262    }
263
264    #[test]
265    fn rounding_is_half_away_from_zero() {
266        assert_eq!(round(0.5), 1.0);
267        assert_eq!(round(-0.5), -1.0);
268        assert_eq!(round(1.4), 1.0);
269        assert_eq!(round(1.6), 2.0);
270    }
271
272    #[test]
273    fn a_procedure_that_paints_nothing_has_an_empty_extent() {
274        assert_eq!(painted_extent(&[]), Rect::ZERO);
275    }
276
277    /// One indirect object, which is all a one-glyph font needs to resolve.
278    struct OneStream(std::sync::Arc<pdfrum_object::Object>);
279
280    impl pdfrum_object::Resolve for OneStream {
281        fn fetch(
282            &self,
283            r: pdfrum_object::ObjRef,
284        ) -> Result<std::sync::Arc<pdfrum_object::Object>, pdfrum_object::Error> {
285            if r.num == 1 {
286                Ok(std::sync::Arc::clone(&self.0))
287            } else {
288                Err(pdfrum_object::Error::UnresolvedRef(r))
289            }
290        }
291    }
292
293    /// A Type 3 font with one glyph procedure, loaded the way the page
294    /// interpreter loads one.
295    fn one_glyph_font(proc_body: &[u8]) -> (pdfrum_font::Font, OneStream) {
296        use pdfrum_object::{Array, ByteSpan, Dict, Name, Object, Stream};
297        let proc = Stream::new(Dict::new(), ByteSpan::from(proc_body.to_vec()));
298        let store = OneStream(std::sync::Arc::new(Object::Stream(Box::new(proc))));
299        let font = Dict::from_pairs([
300            (Name::from("Type"), Object::Name(Name::from("Font"))),
301            (Name::from("Subtype"), Object::Name(Name::from("Type3"))),
302            (
303                Name::from("CharProcs"),
304                Object::Dict(Dict::from_pairs([(
305                    Name::from("g"),
306                    Object::Ref(pdfrum_object::ObjRef {
307                        num: 1,
308                        generation: 0,
309                    }),
310                )])),
311            ),
312            (
313                Name::from("Encoding"),
314                Object::Dict(Dict::from_pairs([(
315                    Name::from("Differences"),
316                    Object::Array(
317                        [Object::Int(65), Object::Name(Name::from("g"))]
318                            .into_iter()
319                            .collect::<Array>(),
320                    ),
321                )])),
322            ),
323            (Name::from("FirstChar"), Object::Int(65)),
324            (Name::from("LastChar"), Object::Int(65)),
325            (
326                Name::from("Widths"),
327                Object::Array([Object::Int(100)].into_iter().collect::<Array>()),
328            ),
329        ]);
330        let cache = pdfrum_font::FontCache::new();
331        let mut diags = Diagnostics::default();
332        let loaded = pdfrum_font::load(&font, &store, &cache, &Limits::default(), &mut diags)
333            .expect("a type 3 font");
334        (loaded, store)
335    }
336
337    fn glyph_metrics(proc_body: &[u8]) -> Type3Metrics {
338        let (font, store) = one_glyph_font(proc_body);
339        let type3 = font.type3().expect("a type 3 font");
340        let mut ctx = BuildContext::new();
341        let mut diags = Diagnostics::default();
342        metrics(
343            type3,
344            pdfrum_font::CharCode(65),
345            None,
346            &store,
347            &mut ctx,
348            &Limits::default(),
349            &mut diags,
350        )
351        .expect("the glyph names a procedure")
352    }
353
354    #[test]
355    fn a_d1_procedure_is_uncoloured_and_a_d0_one_is_coloured() {
356        // The whole colour rule turns on which operator opened the procedure:
357        // `d1` glyphs take the text object's colour for every operation
358        // inside, `d0` glyphs keep their own.
359        assert!(!glyph_metrics(b"100 0 0 0 50 50 d1 0 0 50 50 re f").colored);
360        assert!(glyph_metrics(b"100 0 d0 1 0 0 rg 0 0 50 50 re f").colored);
361        // A procedure that declares neither is uncoloured, which is the
362        // default the C++ constructs `CPDF_Type3Char` with.
363        assert!(!glyph_metrics(b"0 0 50 50 re f").colored);
364    }
365
366    #[test]
367    fn a_procedure_yields_the_objects_it_paints() {
368        // The renderer draws a Type 3 glyph by walking these; an empty list
369        // means the glyph paints nothing at all.
370        let m = glyph_metrics(b"100 0 0 0 50 50 d1 0 0 50 50 re f");
371        assert_eq!(m.objects.len(), 1, "one rectangle: {:?}", m.objects);
372        assert!(matches!(m.objects.first(), Some(PageObject::Path(_))));
373        // And a procedure that paints nothing yields none.
374        assert!(glyph_metrics(b"100 0 0 0 50 50 d1").objects.is_empty());
375    }
376
377    #[test]
378    fn a_declared_box_is_believed_and_a_degenerate_one_is_not() {
379        // `d1` states 0 0 10 10; with no font matrix that scales by a
380        // thousand into glyph units.
381        let stated = glyph_metrics(b"100 0 0 0 10 10 d1 0 0 40 40 re f");
382        assert_eq!(stated.bbox, Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
383        // A degenerate stated box is discarded for the painted extent, which
384        // is the rectangle the procedure actually drew.
385        let computed = glyph_metrics(b"100 0 10 10 0 0 d1 0 0 40 40 re f");
386        assert_eq!(computed.bbox, Rect::new(0.0, 0.0, 40_000.0, 40_000.0));
387    }
388
389    #[test]
390    fn the_advance_is_the_declared_width_in_glyph_units() {
391        // `100` in text space is 100_000 in glyph units, rounded.
392        assert_eq!(glyph_metrics(b"100 0 d0 0 0 1 1 re f").width, 100_000.0);
393    }
394
395    #[test]
396    fn the_painted_extent_is_the_objects_own_and_ignores_the_declared_box() {
397        // `painted` is what a buffer must be sized from. It is the objects'
398        // union in glyph space, with neither the thousandth scale nor the font
399        // matrix applied — so a declared box that disagrees with the drawing
400        // does not move it, and a font matrix that is not the conventional
401        // thousandth cannot push it off the page.
402        let m = glyph_metrics(b"100 0 0 0 10 10 d1 0 0 40 40 re f");
403        assert_eq!(m.painted, Rect::new(0.0, 0.0, 40.0, 40.0));
404        // The declared box says 10x10 and scales to 10 000 glyph units; the
405        // painted extent is unmoved by either.
406        assert_eq!(m.bbox, Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
407        // A procedure that paints nothing has an empty one, and the buffer
408        // sized from it is empty rather than wrong.
409        assert_eq!(glyph_metrics(b"100 0 0 0 10 10 d1").painted, Rect::ZERO);
410    }
411
412    #[test]
413    fn a_font_matrix_transforms_the_glyph_box() {
414        // The usual thousandth-scale matrix takes a 1000-unit box back to 1.
415        let matrix = Affine::new([0.001, 0.0, 0.0, 0.001, 0.0, 0.0]);
416        let out = transform_rect(matrix, Rect::new(0.0, 0.0, 1000.0, 1000.0));
417        assert_eq!(out, Rect::new(0.0, 0.0, 1.0, 1.0));
418    }
419}