Skip to main content

pdfrum_doc/ap/
freetext.rs

1//! The free-text annotation generator: the one per-subtype generator that
2//! sets actual text.
3//!
4//! It is also the only one that writes through the **shortest** float writer
5//! rather than the six-digit one every other per-subtype generator uses, and
6//! the only one with preconditions that can decline outright: it needs a
7//! default-appearance string, a `/DR` on the form, and a `/DR /Font` that is
8//! a dictionary of font dictionaries. Any of those missing and no appearance
9//! is produced at all — which is visible in the dump, because the two colour
10//! lines then report a colour instead of failing.
11
12use pdfrum_common::{DiagKind, Diagnostics, Severity};
13use pdfrum_object::{Dict, Object, PdfString, Resolve, names as obj_names};
14
15use crate::ap::border;
16use crate::ap::da;
17use crate::ap::emit::{Content, Float, PaintOp, color_op};
18#[cfg(test)]
19use crate::ap::fmt;
20use crate::ap::markup::Generated;
21use crate::color::Color;
22use crate::geom;
23use crate::names;
24use crate::vt;
25
26/// A `FreeText` annotation's appearance.
27///
28/// Returns nothing when a precondition fails, which is not the same as
29/// returning an empty appearance: nothing means the annotation keeps having
30/// none at all.
31#[must_use]
32pub(crate) fn free_text<R: Resolve>(
33    dict: &Dict,
34    catalog: &Dict,
35    r: &R,
36    metrics: &vt::Metrics<'_>,
37    encode: &dyn Fn(u32) -> Vec<u8>,
38    diags: &mut Diagnostics,
39) -> Option<Generated> {
40    // A document with no interactive form gets one **created** for it here —
41    // a real mutation upstream, and the reason a free-text annotation in a
42    // file that has never heard of forms still gets an appearance. We build
43    // the same dictionary as a value instead.
44    let form = match catalog.dict(names::ACRO_FORM, r) {
45        Some(form) => form,
46        None => synthesized_form(),
47    };
48    let appearance = default_appearance(dict, &form, r)?;
49    // A `/DA` that parsed but named no font: upstream returns a
50    // default-constructed `FontNameAndSize` from `GetFont`
51    // (`cpdf_defaultappearance.cpp:95-97`) rather than failing, so the size is
52    // zero and the name is empty and the text cannot be placed.
53    if appearance.font_name.is_empty() {
54        diags.record(
55            Severity::Suspicious,
56            DiagKind::DefaultAppearanceMalformed,
57            None,
58        );
59    }
60    let resources = form.dict(names::DR, r)?;
61    let fonts = resources.dict(names::FONT, r)?;
62    if !valid_font_resources(&fonts, r) {
63        // `/DR /Font` is not a dictionary of font dictionaries, so no font can
64        // be resolved and no appearance is generated at all
65        // (`cpdf_generateap.cpp:1071-1074`).
66        diags.record(Severity::Suspicious, DiagKind::FormResourcesInvalid, None);
67        return None;
68    }
69    // The appearance stream names this font in a `Tf`, so the stream's own
70    // `/Resources /Font` has to carry it or the operator resolves to nothing
71    // and the text does not draw at all. Upstream builds exactly this
72    // one-entry dictionary — `GenerateResourceFontDict(doc, font_name,
73    // font_dict->GetObjNum())` at `cpdf_generateap.cpp:1141-1142` — from the
74    // font `GetFontFromDrFontDictOrGenerateFallback` returned, which is the
75    // `/DR /Font` entry under that name or, when there is none, a fresh
76    // Helvetica that it also writes back into `/DR /Font`.
77    let name = pdfrum_object::Name::new(appearance.font_name.clone());
78    let font = fonts.dict(&name, r).unwrap_or_else(fallback_font);
79    let font_resources = Dict::from_pairs([(name, Object::Dict(font))]);
80
81    let mut out = Content::new();
82    out.raw("/GS gs ");
83
84    let info = border::border_style_info(dict.dict(names::BS, r).as_ref(), r);
85    let rect = dict.rect(obj_names::RECT, r);
86    let half = info.width / 2.0;
87    let background = geom::deflate(rect, half, half);
88    let body = geom::deflate(background, half, half);
89
90    // A present `/C` paints the background, whatever it names — including an
91    // array that names no colour at all, which paints nothing but still
92    // writes the surrounding save and restore.
93    if let Some(array) = dict.array(names::C, r) {
94        out.raw("q\n");
95        out.raw(&color_op(Color::from_array(&array), PaintOp::Fill));
96        out.rect(background, Float::Shortest);
97        out.raw("re f\nQ\n");
98    }
99
100    // The border takes the **text** colour, not `/C`.
101    let border_path = border::border_path(rect, info, appearance.color);
102    if !border_path.is_empty() {
103        out.raw("q\n");
104        out.raw(&border_path);
105        out.raw("Q\n");
106    }
107
108    let config = vt::Config {
109        plate: body,
110        alignment: vt::Alignment::from_quadding(dict.int(names::Q, r).unwrap_or(0)),
111        // A size of zero asks for automatic sizing; a negative one is used
112        // as written and then suppresses the font operator.
113        font_size: appearance.size,
114        ..vt::Config::default()
115    };
116    let text = dict.text(obj_names::CONTENTS, r).unwrap_or_default();
117    let layout = vt::layout(&text, &config, metrics);
118    let content = layout.content_rect_pdf(body);
119    let offset = (0.0, (geom::height(content) - geom::height(body)) / 2.0);
120
121    let written = vt::edit_ap::generate(
122        &layout,
123        &config,
124        metrics,
125        offset,
126        vt::edit_ap::Grouping::Continuous,
127        |code| vt::edit_ap::Face::single(appearance.font_name.as_slice(), encode(code)),
128    );
129    if !written.is_empty() {
130        out.raw("/Tx BMC\nq\n");
131        // The clip is written **only on overflow**, which is why a short
132        // free-text annotation has no `W n` in its stream at all.
133        if geom::width(content) > geom::width(body) || geom::height(content) > geom::height(body) {
134            out.rect(body, Float::Shortest);
135            out.raw("re\nW\nn\n");
136        }
137        out.raw("BT\n");
138        out.raw(&color_op(appearance.color, PaintOp::Fill));
139        out.raw(&written);
140        out.raw("ET\n");
141        out.raw("Q\nEMC\n");
142    }
143
144    Some(Generated {
145        stream: out.into_bytes(),
146        rect_override: None,
147        is_text_markup: false,
148        blend_multiply: false,
149        font_resources: Some(font_resources),
150    })
151}
152
153/// The resource name and size a `Tf` names, and the colour a `g`/`rg`/`k`
154/// does.
155#[derive(Debug, Clone, PartialEq)]
156pub struct Appearance {
157    /// The font resource name.
158    pub font_name: Vec<u8>,
159    /// The size, zero meaning "choose one".
160    pub size: f32,
161    /// The text colour, transparent when the string names none.
162    pub color: Color,
163}
164
165/// Reads the default appearance an annotation is set with.
166///
167/// The string comes from the annotation's own inherited `/DA`, falling back
168/// to the form's. Returns nothing only when neither has one — a string that
169/// parses to no font still answers, with an empty name and a size of zero.
170#[must_use]
171pub fn default_appearance<R: Resolve>(dict: &Dict, form: &Dict, r: &R) -> Option<Appearance> {
172    let (limits, mut diags) = (
173        pdfrum_common::Limits::default(),
174        pdfrum_common::Diagnostics::default(),
175    );
176    let mut string = crate::form::attr::field_attr(dict, names::DA, r, &limits, &mut diags)
177        .map(|value| value.to_byte_string())
178        .unwrap_or_default();
179    if string.is_empty() {
180        string = form.byte_string(names::DA, r).unwrap_or_default();
181    }
182    let font = da::font(&string)?;
183    Some(Appearance {
184        font_name: font.name,
185        size: font.size,
186        // A string with no colour operator leaves the text transparent, which
187        // writes no colour operator and lets it inherit.
188        color: da::color(&string).unwrap_or(Color::Transparent),
189    })
190}
191
192/// Whether a `/DR /Font` is a dictionary of font dictionaries.
193///
194/// **An empty one passes**, vacuously; only a present entry that is not a
195/// `/Type /Font` dictionary fails. The name is checked as a name, so a string
196/// `/Type (Font)` is rejected.
197#[must_use]
198pub(crate) fn valid_font_resources<R: Resolve>(fonts: &Dict, r: &R) -> bool {
199    fonts.keys().all(|key| {
200        fonts
201            .dict(key, r)
202            .is_some_and(|font| font.name(obj_names::TYPE) == Some(names::FONT))
203    })
204}
205
206/// The interactive form a document without one is given.
207///
208/// One stock Helvetica under a four-character alias — the resource-name
209/// generator takes the first four characters of the base font name — and a
210/// default appearance naming it at size 12 in black.
211#[must_use]
212pub(crate) fn synthesized_form() -> Dict {
213    let alias = pdfrum_object::Name::new(font_resource_alias(b"Helvetica"));
214    let mut da = Vec::from(b"/".as_slice());
215    da.extend_from_slice(alias.as_bytes());
216    da.extend_from_slice(b" 12 Tf 0 g");
217    Dict::from_pairs([
218        (
219            names::DR.clone(),
220            Object::Dict(Dict::from_pairs([(
221                names::FONT.clone(),
222                Object::Dict(Dict::from_pairs([(alias, Object::Dict(fallback_font()))])),
223            )])),
224        ),
225        (names::DA.clone(), Object::Str(PdfString::literal(da))),
226    ])
227}
228
229/// The resource name a font is filed under.
230///
231/// Four characters: the base font name's own, padded from a digit sequence
232/// when it is shorter, and spaces removed. A nameless font takes the fixed
233/// placeholder.
234#[must_use]
235pub(crate) fn font_resource_alias(base_font_name: &[u8]) -> Vec<u8> {
236    const PLACEHOLDER: &[u8] = b"ZiTi";
237    const WIDTH: usize = 4;
238
239    let prefix: Vec<u8> = base_font_name
240        .iter()
241        .copied()
242        .filter(|byte| *byte != b' ')
243        .collect();
244    if prefix.is_empty() {
245        return PLACEHOLDER.to_vec();
246    }
247    let mut alias: Vec<u8> = prefix.iter().copied().take(WIDTH).collect();
248    // Short names are padded with the digits of the positions they lack.
249    let mut position = alias.len();
250    while alias.len() < WIDTH {
251        alias.push(b'0' + u8::try_from(position % 10).unwrap_or(0));
252        position += 1;
253    }
254    alias
255}
256
257/// A fallback font dictionary, for a `/DR /Font` that names none.
258#[must_use]
259pub(crate) fn fallback_font() -> Dict {
260    Dict::from_pairs([
261        (obj_names::TYPE.clone(), Object::Name(names::FONT.clone())),
262        (
263            obj_names::SUBTYPE.clone(),
264            Object::Name(names::TYPE1.clone()),
265        ),
266        (
267            names::BASE_FONT.clone(),
268            Object::Name(pdfrum_object::Name::from("Helvetica")),
269        ),
270        (
271            names::ENCODING.clone(),
272            Object::Name(obj_names::WIN_ANSI_ENCODING.clone()),
273        ),
274    ])
275}
276
277/// The size a `Tf` operand of zero really means.
278///
279/// Zero asks for automatic sizing. A **negative** size is not zero, so it is
280/// used as written — and the font operator's own `size > 0` gate then
281/// suppresses the `Tf`, leaving the text at whatever the enclosing stream set.
282#[must_use]
283#[cfg(test)]
284pub(crate) fn resolved_size(size: f32) -> Option<f32> {
285    (!crate::geom::is_float_zero(size)).then_some(size)
286}
287
288/// The `/DA` a colour change rewrites, for the facade's benefit.
289#[must_use]
290#[cfg(test)]
291pub(crate) fn default_appearance_string(appearance: &Appearance) -> Vec<u8> {
292    let mut out = Vec::new();
293    if !appearance.font_name.is_empty() && appearance.size > 0.0 {
294        out.push(b'/');
295        out.extend_from_slice(&appearance.font_name);
296        out.push(b' ');
297        out.extend_from_slice(fmt::shortest(appearance.size).as_bytes());
298        out.extend_from_slice(b" Tf");
299    }
300    let color = color_op(appearance.color, PaintOp::Fill);
301    if !color.is_empty() {
302        if !out.is_empty() {
303            out.push(b' ');
304        }
305        out.extend_from_slice(color.trim_end().as_bytes());
306    }
307    out
308}
309
310#[cfg(test)]
311mod tests {
312    use super::{
313        default_appearance, default_appearance_string, fallback_font, free_text, resolved_size,
314        valid_font_resources,
315    };
316    use crate::color::Color;
317    use crate::vt::stub;
318    use pdfrum_common::{DiagKind, Diagnostics};
319    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
320
321    fn dict(pairs: &[(&str, Object)]) -> Dict {
322        Dict::from_pairs(
323            pairs
324                .iter()
325                .map(|(k, v)| (Name::from(*k), v.clone()))
326                .collect::<Vec<_>>(),
327        )
328    }
329
330    fn numbers(values: &[f32]) -> Object {
331        Object::Array(Array::of(values.iter().copied().map(Object::from)))
332    }
333
334    /// A catalog whose form carries one usable font resource.
335    fn catalog() -> Dict {
336        dict(&[(
337            "AcroForm",
338            Object::Dict(dict(&[(
339                "DR",
340                Object::Dict(dict(&[(
341                    "Font",
342                    Object::Dict(dict(&[("Helv", Object::Dict(fallback_font()))])),
343                )])),
344            )])),
345        )])
346    }
347
348    fn annot(extra: &[(&str, Object)]) -> Dict {
349        let mut pairs = vec![
350            ("Subtype", Object::Name(Name::from("FreeText"))),
351            ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
352            (
353                "DA",
354                Object::Str(PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
355            ),
356        ];
357        pairs.extend_from_slice(extra);
358        dict(&pairs)
359    }
360
361    fn one_byte(code: u32) -> Vec<u8> {
362        vec![u8::try_from(code).unwrap_or(b'?')]
363    }
364
365    fn generate(annot: &Dict, catalog: &Dict) -> Option<String> {
366        let mut diags = Diagnostics::default();
367        free_text(
368            annot,
369            catalog,
370            &NoResolve,
371            &stub::metrics(),
372            &one_byte,
373            &mut diags,
374        )
375        .map(|got| String::from_utf8_lossy(&got.stream).into_owned())
376    }
377
378    #[test]
379    fn a_missing_precondition_declines_rather_than_producing_an_empty_stream() {
380        // A form that exists but carries no default resources, and one whose
381        // resources carry no fonts. Both decline: the synthesized form only
382        // stands in for a form that is *absent*, not for one that is there
383        // and unusable.
384        let bare = dict(&[("AcroForm", Object::Dict(Dict::new()))]);
385        assert!(generate(&annot(&[]), &bare).is_none());
386        let no_font = dict(&[(
387            "AcroForm",
388            Object::Dict(dict(&[("DR", Object::Dict(Dict::new()))])),
389        )]);
390        assert!(generate(&annot(&[]), &no_font).is_none());
391    }
392
393    #[test]
394    fn a_document_with_no_form_at_all_gets_one_synthesized() {
395        // The generator creates an interactive form where there is none,
396        // which is why a free-text annotation in a file that never mentions
397        // forms still gets an appearance.
398        let got = generate(
399            &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
400            &Dict::new(),
401        )
402        .expect("synthesizes a form");
403        assert!(got.contains("(Hi) Tj\n"), "{got}");
404    }
405
406    /// The stream names a font in a `Tf`; the stream's own `/Resources /Font`
407    /// has to carry it under that name or the operator resolves to nothing and
408    /// the text draws as blank paper.
409    #[test]
410    fn the_generated_appearance_carries_the_font_its_tf_names() {
411        let got = free_text(
412            &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
413            &catalog(),
414            &NoResolve,
415            &stub::metrics(),
416            &one_byte,
417            &mut Diagnostics::default(),
418        )
419        .expect("generates");
420        let fonts = got.font_resources.expect("a text generator names a font");
421        assert_eq!(fonts.keys().collect::<Vec<_>>(), [&Name::from("Helv")]);
422        assert_eq!(
423            fonts.dict(&Name::from("Helv"), &NoResolve),
424            Some(fallback_font())
425        );
426    }
427
428    /// `GetFontFromDrFontDictOrGenerateFallback` returns a fresh Helvetica for
429    /// a `/DA` naming a font the `/DR` does not have, rather than declining —
430    /// so the appearance is still produced, and still resolvable.
431    #[test]
432    fn a_da_naming_an_absent_font_falls_back_rather_than_declining() {
433        let empty_dr = dict(&[(
434            "AcroForm",
435            Object::Dict(dict(&[(
436                "DR",
437                Object::Dict(dict(&[("Font", Object::Dict(Dict::new()))])),
438            )])),
439        )]);
440        let got = free_text(
441            &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
442            &empty_dr,
443            &NoResolve,
444            &stub::metrics(),
445            &one_byte,
446            &mut Diagnostics::default(),
447        )
448        .expect("falls back rather than declining");
449        let fonts = got.font_resources.expect("names a font");
450        assert_eq!(
451            fonts.dict(&Name::from("Helv"), &NoResolve),
452            Some(fallback_font())
453        );
454    }
455
456    #[test]
457    fn the_synthesized_forms_font_alias_is_four_characters() {
458        assert_eq!(super::font_resource_alias(b"Helvetica"), b"Helv");
459        // A short name is padded from the positions it lacks.
460        assert_eq!(super::font_resource_alias(b"AB"), b"AB23");
461        // Spaces are removed before the truncation.
462        assert_eq!(super::font_resource_alias(b"H e l v e t"), b"Helv");
463        // A nameless font takes the fixed placeholder.
464        assert_eq!(super::font_resource_alias(b""), b"ZiTi");
465        assert_eq!(super::font_resource_alias(b"   "), b"ZiTi");
466    }
467
468    #[test]
469    fn an_annotation_with_no_default_appearance_declines() {
470        let no_da = dict(&[
471            ("Subtype", Object::Name(Name::from("FreeText"))),
472            ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
473        ]);
474        assert!(generate(&no_da, &catalog()).is_none());
475    }
476
477    #[test]
478    fn an_empty_font_resource_dictionary_passes_vacuously() {
479        assert!(valid_font_resources(&Dict::new(), &NoResolve));
480        let good = dict(&[("Helv", Object::Dict(fallback_font()))]);
481        assert!(valid_font_resources(&good, &NoResolve));
482        // A non-font entry fails the whole dictionary.
483        let bad = dict(&[("Helv", Object::Dict(dict(&[("Type", Object::Int(4))])))]);
484        assert!(!valid_font_resources(&bad, &NoResolve));
485        // And the type must be a *name*.
486        let stringly = dict(&[(
487            "Helv",
488            Object::Dict(dict(&[("Type", Object::Str(PdfString::literal(b"Font")))])),
489        )]);
490        assert!(!valid_font_resources(&stringly, &NoResolve));
491    }
492
493    #[test]
494    fn the_text_reaches_the_stream_between_its_markers() {
495        let got = generate(
496            &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
497            &catalog(),
498        )
499        .expect("generates");
500        assert!(got.starts_with("/GS gs "), "{got}");
501        assert!(got.contains("/Tx BMC\nq\n"), "{got}");
502        assert!(got.contains("BT\n"), "{got}");
503        assert!(got.contains("(Hi) Tj\n"), "{got}");
504        assert!(got.ends_with("ET\nQ\nEMC\n"), "{got}");
505    }
506
507    #[test]
508    fn a_short_annotation_writes_no_clip_at_all() {
509        let short = generate(
510            &annot(&[("Contents", Object::Str(PdfString::literal(b"a")))]),
511            &catalog(),
512        )
513        .expect("generates");
514        assert!(!short.contains("W\nn\n"), "{short}");
515    }
516
517    #[test]
518    fn a_background_colour_paints_before_the_border() {
519        let got = generate(
520            &annot(&[
521                ("C", numbers(&[1.0, 0.0, 0.0])),
522                ("Contents", Object::Str(PdfString::literal(b"x"))),
523            ]),
524            &catalog(),
525        )
526        .expect("generates");
527        assert!(got.contains("q\n1 0 0 rg\n"), "{got}");
528        assert!(got.contains("re f\nQ\n"), "{got}");
529    }
530
531    #[test]
532    fn a_colour_array_naming_no_space_still_writes_its_save_and_restore() {
533        let got = generate(
534            &annot(&[
535                ("C", Object::Array(Array::new())),
536                ("Contents", Object::Str(PdfString::literal(b"x"))),
537            ]),
538            &catalog(),
539        )
540        .expect("generates");
541        assert!(got.contains("q\n"), "{got}");
542        assert!(got.contains(" re f\nQ\n"), "{got}");
543    }
544
545    #[test]
546    fn empty_contents_produce_a_stream_with_no_text_block() {
547        let got = generate(&annot(&[]), &catalog()).expect("generates");
548        assert!(!got.contains("BT\n"), "{got}");
549    }
550
551    #[test]
552    fn the_default_appearance_falls_back_to_the_forms_own() {
553        let annot = dict(&[("Subtype", Object::Name(Name::from("FreeText")))]);
554        let form = dict(&[("DA", Object::Str(PdfString::literal(b"/Helv 8 Tf")))]);
555        let got = default_appearance(&annot, &form, &NoResolve).expect("has one");
556        assert_eq!(got.font_name, b"Helv");
557        assert!((got.size - 8.0).abs() < f32::EPSILON);
558        // No colour operator leaves the text transparent.
559        assert_eq!(got.color, Color::Transparent);
560    }
561
562    #[test]
563    fn a_zero_size_asks_for_automatic_sizing_while_a_negative_one_does_not() {
564        assert_eq!(resolved_size(0.0), None);
565        assert_eq!(resolved_size(-12.0), Some(-12.0));
566        assert_eq!(resolved_size(12.0), Some(12.0));
567    }
568
569    #[test]
570    fn the_rewritten_default_appearance_joins_its_two_halves() {
571        let got = default_appearance_string(&super::Appearance {
572            font_name: b"Helv".to_vec(),
573            size: 12.0,
574            color: Color::Rgb(1.0, 0.0, 0.0),
575        });
576        assert_eq!(got, b"/Helv 12 Tf 1 0 0 rg");
577
578        // A size of zero suppresses the font half.
579        let sizeless = default_appearance_string(&super::Appearance {
580            font_name: b"Helv".to_vec(),
581            size: 0.0,
582            color: Color::Gray(0.0),
583        });
584        assert_eq!(sizeless, b"0 g");
585    }
586
587    /// A `/DR /Font` entry that is not a font dictionary stops the generation
588    /// dead, and now says so.
589    #[test]
590    fn a_malformed_dr_font_declines_and_is_recorded() {
591        let bad_dr = dict(&[(
592            "AcroForm",
593            Object::Dict(dict(&[(
594                "DR",
595                Object::Dict(dict(&[(
596                    "Font",
597                    // `/Helv` present but not a `/Type /Font` dictionary.
598                    Object::Dict(dict(&[("Helv", Object::Int(7))])),
599                )])),
600            )])),
601        )]);
602        let mut diags = Diagnostics::default();
603        let got = free_text(
604            &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
605            &bad_dr,
606            &NoResolve,
607            &stub::metrics(),
608            &one_byte,
609            &mut diags,
610        );
611        assert!(got.is_none(), "no appearance can be generated");
612        assert!(diags.contains(&DiagKind::FormResourcesInvalid));
613    }
614}