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