Skip to main content

pdfrum_edit/
flatten.rs

1//! Flattening: an annotation's appearance baked into the page content, so
2//! the page looks the same with the annotation gone.
3
4use crate::{EditDoc, Error};
5use kurbo::{Affine, Rect};
6use pdfrum_common::{Diagnostics, Limits, PageIndex};
7use pdfrum_object::{Array, ByteSpan, Dict, Name, ObjRef, Object, Resolve, Stream};
8use std::collections::HashSet;
9
10/// A flatten either applies or names why it could not.
11type Result<T> = core::result::Result<T, Error>;
12
13/// Which annotations flattening draws.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum FlattenMode {
16    /// What a viewer shows: every annotation not flagged invisible.
17    Display,
18    /// What a printer shows: only annotations flagged for print.
19    Print,
20}
21
22/// What flattening a page did.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Flattened {
25    /// The page's annotations were drawn into its content and removed.
26    Done,
27    /// The page had no annotations to draw.
28    NothingToDo,
29}
30
31/// The error [`FlattenMode`]'s [`FromStr`](std::str::FromStr) returns.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33#[error("not a flatten mode: {0}")]
34pub struct UnknownFlattenMode(String);
35
36impl std::fmt::Display for FlattenMode {
37    /// `display` or `print`, which round-trip through
38    /// [`FromStr`](std::str::FromStr).
39    ///
40    /// ```
41    /// assert_eq!(pdfrum::FlattenMode::Print.to_string(), "print");
42    /// ```
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(match self {
45            FlattenMode::Display => "display",
46            FlattenMode::Print => "print",
47        })
48    }
49}
50
51impl std::str::FromStr for FlattenMode {
52    type Err = UnknownFlattenMode;
53
54    /// The inverse of [`Display`](std::fmt::Display) — what a `--mode` flag
55    /// parses.
56    ///
57    /// # Errors
58    ///
59    /// [`UnknownFlattenMode`] for anything but `display` and `print`.
60    fn from_str(s: &str) -> core::result::Result<FlattenMode, UnknownFlattenMode> {
61        match s {
62            "display" => Ok(FlattenMode::Display),
63            "print" => Ok(FlattenMode::Print),
64            other => Err(UnknownFlattenMode(other.to_owned())),
65        }
66    }
67}
68
69impl std::fmt::Display for Flattened {
70    /// What happened, for a log line: `flattened` or `nothing to do`.
71    ///
72    /// No [`FromStr`](std::str::FromStr) pairs with it — this is an outcome
73    /// a call reports, never a value a caller writes down.
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(match self {
76            Flattened::Done => "flattened",
77            Flattened::NothingToDo => "nothing to do",
78        })
79    }
80}
81
82/// The annotations flatten draws for `mode`, in `/Annots` order: pop-ups
83/// and hidden ones never; for display every non-invisible one, for print
84/// only those flagged for print.
85fn flatten_candidates(edit: &EditDoc<'_>, page: &Dict, mode: FlattenMode) -> Vec<(usize, Dict)> {
86    let Some(annots) = page.array(&Name::from("Annots"), edit) else {
87        return Vec::new();
88    };
89    let mut out = Vec::new();
90    for index in 0..annots.len() {
91        let Some(dict) = annots.dict_at(index, edit) else {
92            continue;
93        };
94        let is_popup = dict
95            .get(&Name::from("Subtype"), edit)
96            .is_some_and(|o| o.get().as_name().is_some_and(|n| n.as_bytes() == b"Popup"));
97        if is_popup {
98            continue;
99        }
100        let flags = dict.int(&Name::from("F"), edit).unwrap_or(0);
101        if flags & 2 != 0 {
102            continue;
103        }
104        let keep = match mode {
105            FlattenMode::Display => flags & 1 == 0,
106            FlattenMode::Print => flags & 4 != 0,
107        };
108        if keep {
109            out.push((index, dict));
110        }
111    }
112    out
113}
114
115/// The normal appearance flatten draws — `/AP /N` when it is a stream;
116/// otherwise the `/AS` state's entry of the `/N` dictionary, or with no
117/// `/AS` its first entry when that is a stream — as a reference and the
118/// stream: an inline stream is copied into a new indirect object first.
119fn flatten_appearance(edit: &mut EditDoc<'_>, annot: &Dict) -> Option<(ObjRef, Stream)> {
120    let ap = annot.dict(&Name::from("AP"), edit)?;
121    let states: Dict = match ap.raw(&Name::from("N"))? {
122        Object::Ref(reference) => {
123            let object = edit.fetch(*reference).ok()?;
124            if let Some(stream) = object.as_stream() {
125                return Some((*reference, stream.clone()));
126            }
127            object.as_dict()?.clone()
128        }
129        Object::Stream(stream) => {
130            let stream = stream.clone();
131            let reference = edit.add(Object::Stream(stream.clone()));
132            return Some((reference, *stream));
133        }
134        Object::Dict(states) => states.clone(),
135        _ => return None,
136    };
137    let state = annot
138        .get(&Name::from("AS"), edit)
139        .and_then(|o| o.get().as_name().cloned());
140    let entry = match state {
141        Some(state) => states.raw(&state).cloned()?,
142        None => states.iter().next().map(|(_, o)| o.clone())?,
143    };
144    match entry {
145        Object::Ref(reference) => {
146            let object = edit.fetch(reference).ok()?;
147            Some((reference, object.as_stream()?.clone()))
148        }
149        Object::Stream(stream) => {
150            let reference = edit.add(Object::Stream(stream.clone()));
151            Some((reference, *stream))
152        }
153        _ => None,
154    }
155}
156
157/// Drops every font `/Encoding` in `resources`' `/Font` whose
158/// `/BaseEncoding` is not one of the three standard names (an absent
159/// `/BaseEncoding` is fine): an indirect font dictionary is replaced in
160/// the document, an inline one rewritten in the returned copy of
161/// `resources`.
162fn sanitize_resources(edit: &mut EditDoc<'_>, mut resources: Dict) -> Dict {
163    let font_key = Name::from("Font");
164    let Some(fonts) = resources.dict(&font_key, edit) else {
165        return resources;
166    };
167    let mut fonts_out = fonts.clone();
168    for (key, value) in fonts.iter() {
169        let (reference, font): (Option<ObjRef>, Dict) = match value {
170            Object::Ref(reference) => {
171                let Ok(object) = edit.fetch(*reference) else {
172                    continue;
173                };
174                let Some(dict) = object.as_dict().cloned() else {
175                    continue;
176                };
177                (Some(*reference), dict)
178            }
179            Object::Dict(dict) => (None, dict.clone()),
180            _ => continue,
181        };
182        let Some(encoding) = font.dict(&Name::from("Encoding"), edit) else {
183            continue;
184        };
185        let base = encoding
186            .get(&Name::from("BaseEncoding"), edit)
187            .and_then(|o| o.get().as_name().cloned());
188        let valid = base.is_none_or(|name| {
189            matches!(
190                name.as_bytes(),
191                b"WinAnsiEncoding" | b"MacRomanEncoding" | b"MacExpertEncoding"
192            )
193        });
194        if valid {
195            continue;
196        }
197        let mut fixed = font.clone();
198        fixed.remove(&Name::from("Encoding"));
199        match reference {
200            Some(reference) => edit.replace(reference, Object::Dict(fixed)),
201            None => fonts_out.insert(key.clone(), Object::Dict(fixed)),
202        }
203    }
204    match resources.raw(&font_key).cloned() {
205        Some(Object::Ref(reference)) => edit.replace(reference, Object::Dict(fonts_out)),
206        Some(Object::Dict(_)) => resources.insert(font_key, Object::Dict(fonts_out)),
207        _ => {}
208    }
209    resources
210}
211
212/// Wraps the page's content in `q … Q` and appends the flattened form's
213/// `Do`, the reference's way: no `/Contents` gets one new stream; an
214/// array gets a `q` stream inserted first and `Q` then the `Do` streams
215/// appended; a single stream is rewritten unfiltered as
216/// `q\n<its decoded bytes>\nQ` and becomes the first element of a new
217/// array followed by the `Do` stream.
218fn set_page_contents(edit: &mut EditDoc<'_>, limits: &Limits, page: &mut Dict, key: &str) {
219    let contents = Name::from("Contents");
220    let do_text = format!("q 1 0 0 1 0 0 cm /{key} Do Q");
221    let mut array: Option<(Array, Option<ObjRef>)> = None;
222    let mut single: Option<(Stream, ObjRef)> = None;
223    match page.raw(&contents).cloned() {
224        Some(Object::Ref(reference)) => {
225            if let Ok(object) = edit.fetch(reference) {
226                if let Some(a) = object.as_array() {
227                    array = Some((a.clone(), Some(reference)));
228                } else if let Some(s) = object.as_stream() {
229                    single = Some((s.clone(), reference));
230                }
231            }
232        }
233        Some(Object::Array(a)) => array = Some((a, None)),
234        Some(Object::Stream(s)) => {
235            let reference = edit.add(Object::Stream(s.clone()));
236            single = Some((*s, reference));
237        }
238        _ => {}
239    }
240    if let Some((mut a, reference)) = array {
241        let q = raw_stream(edit, b"q".to_vec());
242        a.insert(0, Object::Ref(q));
243        let restore = raw_stream(edit, b"Q".to_vec());
244        a.push(Object::Ref(restore));
245        let draw = raw_stream(edit, do_text.into_bytes());
246        a.push(Object::Ref(draw));
247        match reference {
248            Some(reference) => edit.replace(reference, Object::Array(a)),
249            None => page.insert(contents, Object::Array(a)),
250        }
251    } else if let Some((stream, reference)) = single {
252        let mut diags = Diagnostics::default();
253        let decoded = pdfrum_filters::decode_chain(&stream, 0, edit, limits, &mut diags).data;
254        let mut bytes = b"q\n".to_vec();
255        bytes.extend_from_slice(&decoded);
256        bytes.extend_from_slice(b"\nQ");
257        let mut dict = stream.dict.clone();
258        dict.remove(&Name::from("Filter"));
259        dict.remove(&Name::from("DecodeParms"));
260        dict.remove(&Name::from("Length"));
261        edit.replace(
262            reference,
263            Object::Stream(Box::new(Stream::new(dict, ByteSpan::from(bytes)))),
264        );
265        let draw = raw_stream(edit, do_text.into_bytes());
266        let array_ref = edit.add(Object::Array(Array::of([
267            Object::Ref(reference),
268            Object::Ref(draw),
269        ])));
270        page.insert(contents, Object::Ref(array_ref));
271    } else {
272        let draw = raw_stream(edit, do_text.into_bytes());
273        page.insert(contents, Object::Ref(draw));
274    }
275}
276
277/// A new unfiltered stream holding `bytes`.
278fn raw_stream(edit: &mut EditDoc<'_>, bytes: Vec<u8>) -> ObjRef {
279    edit.add(Object::Stream(Box::new(Stream::new(
280        Dict::new(),
281        ByteSpan::from(bytes),
282    ))))
283}
284
285/// The widget annotations of `page` that no other page also lists, by
286/// reference: the fields flattening retires.
287fn flattened_widgets(edit: &EditDoc<'_>, page: &Dict, page_ref: Option<ObjRef>) -> HashSet<ObjRef> {
288    let annots_key = Name::from("Annots");
289    let mut widgets = HashSet::new();
290    if let Some(annots) = page.array(&annots_key, edit) {
291        for index in 0..annots.len() {
292            let (Some(reference), Some(dict)) =
293                (annots.reference_at(index), annots.dict_at(index, edit))
294            else {
295                continue;
296            };
297            let is_widget = dict
298                .get(&Name::from("Subtype"), edit)
299                .is_some_and(|o| o.get().as_name().is_some_and(|n| n.as_bytes() == b"Widget"));
300            if is_widget {
301                widgets.insert(reference);
302            }
303        }
304    }
305    for index in 0..edit.base().page_count() {
306        if widgets.is_empty() {
307            break;
308        }
309        let Ok(other) = edit.base().page(index) else {
310            continue;
311        };
312        if other.reference.is_some() && other.reference == page_ref {
313            continue;
314        }
315        // Through the edits: a page flattened earlier in this session no
316        // longer lists anything, and must not keep its neighbour's widgets
317        // alive.
318        let other_dict = other
319            .reference
320            .and_then(|reference| edit.fetch(reference).ok())
321            .and_then(|object| object.as_dict().cloned())
322            .unwrap_or(other.dict);
323        if let Some(annots) = other_dict.array(&annots_key, edit) {
324            for slot in 0..annots.len() {
325                if let Some(reference) = annots.reference_at(slot) {
326                    widgets.remove(&reference);
327                }
328            }
329        }
330    }
331    widgets
332}
333
334/// Removes the retired widgets from `fields`, recursing through `/Kids`
335/// (a field whose kids all go goes too); whether `fields` is empty
336/// afterwards.
337fn prune_fields(
338    edit: &mut EditDoc<'_>,
339    fields: &mut Array,
340    widgets: &HashSet<ObjRef>,
341    level: u32,
342) -> bool {
343    if level > 32 {
344        return fields.is_empty();
345    }
346    let kids_key = Name::from("Kids");
347    for index in (0..fields.len()).rev() {
348        let Some(reference) = fields.reference_at(index) else {
349            continue;
350        };
351        let mut prune = widgets.contains(&reference);
352        if !prune
353            && let Ok(object) = edit.fetch(reference)
354            && let Some(field) = object.as_dict()
355        {
356            match field.raw(&kids_key).cloned() {
357                Some(Object::Ref(kids_ref)) => {
358                    if let Ok(kids_object) = edit.fetch(kids_ref)
359                        && let Some(kids) = kids_object.as_array()
360                    {
361                        let mut kids = kids.clone();
362                        prune = prune_fields(edit, &mut kids, widgets, level + 1);
363                        edit.replace(kids_ref, Object::Array(kids));
364                    }
365                }
366                Some(Object::Array(mut kids)) => {
367                    prune = prune_fields(edit, &mut kids, widgets, level + 1);
368                    let mut field = field.clone();
369                    field.insert(kids_key.clone(), Object::Array(kids));
370                    edit.replace(reference, Object::Dict(field));
371                }
372                _ => {}
373            }
374        }
375        if prune {
376            fields.remove(index);
377        }
378    }
379    fields.is_empty()
380}
381
382/// Prunes the retired widgets from `/AcroForm /Fields` and removes the
383/// form itself when its fields are empty and it has no `/XFA`; whatever
384/// was rewritten is replaced where indirect and rewritten in its parent
385/// where inline.
386fn remove_flattened_fields(edit: &mut EditDoc<'_>, widgets: &HashSet<ObjRef>) {
387    if widgets.is_empty() {
388        return;
389    }
390    let Some(root) = edit.base().trailer().reference(&Name::from("Root")) else {
391        return;
392    };
393    let Some(mut catalog) = edit
394        .fetch(root)
395        .ok()
396        .as_deref()
397        .and_then(Object::as_dict)
398        .cloned()
399    else {
400        return;
401    };
402    let acro_key = Name::from("AcroForm");
403    let fields_key = Name::from("Fields");
404    let (acro_ref, mut acro) = match catalog.raw(&acro_key).cloned() {
405        Some(Object::Ref(reference)) => {
406            let Some(dict) = edit
407                .fetch(reference)
408                .ok()
409                .as_deref()
410                .and_then(Object::as_dict)
411                .cloned()
412            else {
413                return;
414            };
415            (Some(reference), dict)
416        }
417        Some(Object::Dict(dict)) => (None, dict),
418        _ => return,
419    };
420    let (fields_ref, mut fields) = match acro.raw(&fields_key).cloned() {
421        Some(Object::Ref(reference)) => {
422            let Some(array) = edit
423                .fetch(reference)
424                .ok()
425                .as_deref()
426                .and_then(Object::as_array)
427                .cloned()
428            else {
429                return;
430            };
431            (Some(reference), array)
432        }
433        Some(Object::Array(array)) => (None, array),
434        _ => return,
435    };
436    let empty = prune_fields(edit, &mut fields, widgets, 0);
437    match fields_ref {
438        Some(reference) => edit.replace(reference, Object::Array(fields)),
439        None => acro.insert(fields_key, Object::Array(fields)),
440    }
441    if empty && !acro.contains_key(&Name::from("XFA")) {
442        catalog.remove(&acro_key);
443        edit.replace(root, Object::Dict(catalog));
444        return;
445    }
446    if let Some(reference) = acro_ref {
447        edit.replace(reference, Object::Dict(acro));
448    } else {
449        catalog.insert(acro_key, Object::Dict(acro));
450        edit.replace(root, Object::Dict(catalog));
451    }
452}
453
454/// Draws one annotation's appearance into the flattened form: the
455/// appearance stream becomes form `XObject` `F<index>` of the form's
456/// resources and `content` gains its `Do` through the flattening matrix.
457fn flatten_one(
458    edit: &mut EditDoc<'_>,
459    index: usize,
460    annot: &Dict,
461    generated: Option<&pdfrum_doc::ap::GeneratedAp>,
462    xobject_dict: &mut Dict,
463    content: &mut String,
464) {
465    use std::fmt::Write as _;
466    let mut annot_rect = annot.rect(&Name::from("Rect"), edit).abs();
467    // What a viewer draws: the appearance generated for a widget that has
468    // none, or whose form asks for regeneration, is the one flattened —
469    // the reference generates it at load time and flattens that.
470    let appearance = match generated {
471        Some(made) if !made.stream.is_empty() => {
472            if let Some(rect) = made.rect_override {
473                annot_rect = rect.abs();
474            }
475            let stream = Stream::new(
476                pdfrum_doc::ap::stream_dict(made),
477                ByteSpan::from(made.stream.clone()),
478            );
479            let reference = edit.add(Object::Stream(Box::new(stream.clone())));
480            Some((reference, stream))
481        }
482        _ => flatten_appearance(edit, annot),
483    };
484    let Some((ap_ref, ap_stream)) = appearance else {
485        return;
486    };
487    let mut ap_dict = ap_stream.dict.clone();
488    let box_key = if ap_dict.contains_key(&Name::from("Rect")) {
489        Name::from("Rect")
490    } else {
491        Name::from("BBox")
492    };
493    let mut stream_rect = ap_dict.rect(&box_key, edit).abs();
494    if stream_rect.width() <= 0.0 || stream_rect.height() <= 0.0 {
495        // An appearance with no box of its own is drawn at the origin
496        // over the annotation's own extent, which is what a viewer
497        // shows; the reference skips it and the page goes blank.
498        if annot_rect.width() <= 0.0 || annot_rect.height() <= 0.0 {
499            return;
500        }
501        stream_rect = Rect::new(0.0, 0.0, annot_rect.width(), annot_rect.height());
502        ap_dict.insert(Name::from("BBox"), rect_object(stream_rect));
503    }
504    ap_dict.insert(Name::from("Type"), Object::Name(Name::from("XObject")));
505    ap_dict.insert(Name::from("Subtype"), Object::Name(Name::from("Form")));
506    if let Some(res) = ap_dict.dict(&Name::from("Resources"), edit) {
507        let res = sanitize_resources(edit, res);
508        ap_dict.insert(Name::from("Resources"), Object::Dict(res));
509    }
510    let matrix = flatten_matrix(
511        annot_rect,
512        stream_rect,
513        ap_stream.dict.matrix(&Name::from("Matrix"), edit),
514    );
515    edit.replace(
516        ap_ref,
517        Object::Stream(Box::new(Stream::new(ap_dict, ap_stream.data.clone()))),
518    );
519    let name = format!("F{index}");
520    xobject_dict.insert(Name::from(name.as_str()), Object::Ref(ap_ref));
521    let [sx, ky, kx, sy, tx, ty] = matrix.as_coeffs();
522    let _ = writeln!(content, "q {sx} {ky} {kx} {sy} {tx} {ty} cm /{name} Do Q");
523}
524
525/// Bakes the page's annotation appearances into its content and removes
526/// the annotations, the reference implementation's way: one form `XObject`
527/// `FFT<n>` (the first such name free in the page's `/XObject`
528/// resources), its box the crop box, drawing each appearance stream
529/// through the matrix that lands its box on the annotation's rectangle;
530/// the page's own content wrapped in `q … Q` with the form's `Do` after
531/// it; `/MediaBox` and `/CropBox` normalized and written; the retired
532/// widgets pruned from the interactive form, which goes when it empties.
533/// A widget another page also lists keeps the form.
534///
535/// ```
536/// use pdfrum::{Document, FlattenMode, Flattened, SaveOptions};
537///
538/// let doc = Document::open("tests/fixtures/annotiter.pdf")?;
539/// let mut edit = doc.edit();
540/// assert_eq!(edit.flatten(0, FlattenMode::Display)?, Flattened::Done);
541/// let mut flat = Vec::new();
542/// edit.write_to(&mut flat, &SaveOptions::default())?;
543///
544/// let plain = Document::open("tests/fixtures/hello_world.pdf")?;
545/// assert_eq!(plain.edit().flatten(0, FlattenMode::Display)?, Flattened::NothingToDo);
546/// # Ok::<(), pdfrum::Error>(())
547/// ```
548///
549/// # Errors
550///
551/// When `page` is out of range or the document has no catalog.
552pub fn flatten(
553    edit: &mut EditDoc<'_>,
554    limits: &Limits,
555    page: impl Into<PageIndex>,
556    mode: FlattenMode,
557    diags: &mut Diagnostics,
558) -> Result<Flattened> {
559    let index = page.into();
560    let page_dict = edit
561        .base()
562        .page(index)
563        .map_err(|_| Error::PageIndexOutOfRange(index))?;
564    let page_ref = page_dict.reference;
565    let mut dict = page_dict.dict.clone();
566    if dict.array(&Name::from("Annots"), edit).is_none() {
567        return Ok(Flattened::NothingToDo);
568    }
569    let candidates = flatten_candidates(edit, &dict, mode);
570    let catalog = edit.base().catalog().unwrap_or_default();
571    let mut build = pdfrum_page::BuildContext::new();
572    let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, edit, &mut build);
573    let generated =
574        pdfrum_doc::ap::generate_appearances_with_text(&dict, &catalog, Some(&fonts), edit, diags);
575
576    let crop_key = Name::from("CropBox");
577    let mut media = dict.rect(&Name::from("MediaBox"), edit).abs();
578    if dict.contains_key(&crop_key) {
579        media = dict.rect(&crop_key, edit).abs();
580    }
581    if media.width() <= 0.0 || media.height() <= 0.0 {
582        media = Rect::new(0.0, 0.0, 612.0, 792.0);
583    }
584    let mut crop = if dict.contains_key(&crop_key) {
585        dict.rect(&crop_key, edit).abs()
586    } else {
587        Rect::ZERO
588    };
589    if crop.width() <= 0.0 || crop.height() <= 0.0 {
590        crop = media;
591    }
592    dict.insert(Name::from("MediaBox"), rect_object(media));
593    dict.insert(crop_key, rect_object(crop));
594
595    let mut resources = dict
596        .dict(&Name::from("Resources"), edit)
597        .unwrap_or_default();
598    let mut xobjects = resources
599        .dict(&Name::from("XObject"), edit)
600        .unwrap_or_default();
601    let key = if candidates.is_empty() {
602        None
603    } else {
604        (0..u32::MAX)
605            .map(|i| format!("FFT{i}"))
606            .find(|k| !xobjects.contains_key(&Name::from(k.as_str())))
607    };
608    if let Some(key) = key {
609        set_page_contents(edit, limits, &mut dict, &key);
610        let mut xobject_dict = Dict::new();
611        let mut content = String::new();
612        for (slot, (index, annot)) in candidates.iter().enumerate() {
613            let made_appearance = generated.get(*index);
614            flatten_one(
615                edit,
616                slot,
617                annot,
618                made_appearance,
619                &mut xobject_dict,
620                &mut content,
621            );
622        }
623        let mut form_resources = Dict::new();
624        form_resources.insert(Name::from("XObject"), Object::Dict(xobject_dict));
625        let mut form_dict = Dict::new();
626        form_dict.insert(Name::from("Type"), Object::Name(Name::from("XObject")));
627        form_dict.insert(Name::from("Subtype"), Object::Name(Name::from("Form")));
628        form_dict.insert(Name::from("FormType"), Object::Int(1));
629        form_dict.insert(Name::from("BBox"), rect_object(crop));
630        form_dict.insert(Name::from("Resources"), Object::Dict(form_resources));
631        let form_ref = edit.add(Object::Stream(Box::new(Stream::new(
632            form_dict,
633            ByteSpan::from(content.into_bytes()),
634        ))));
635        xobjects.insert(Name::from(key.as_str()), Object::Ref(form_ref));
636        resources.insert(Name::from("XObject"), Object::Dict(xobjects));
637        dict.insert(Name::from("Resources"), Object::Dict(resources));
638    }
639
640    let widgets = flattened_widgets(edit, &dict, page_ref);
641    dict.remove(&Name::from("Annots"));
642    if let Some(reference) = page_ref {
643        edit.replace(reference, Object::Dict(dict));
644    }
645    remove_flattened_fields(edit, &widgets);
646    Ok(Flattened::Done)
647}
648
649/// A rectangle as the four-number array the file writes.
650#[expect(
651    clippy::cast_possible_truncation,
652    reason = "page geometry fits f32, as the file wrote it"
653)]
654fn rect_object(rect: Rect) -> Object {
655    Object::Array(Array::of([
656        Object::Real(rect.x0 as f32),
657        Object::Real(rect.y0 as f32),
658        Object::Real(rect.x1 as f32),
659        Object::Real(rect.y1 as f32),
660    ]))
661}
662
663/// The matrix that lands the appearance's bounding box, taken through its own
664/// `/Matrix`, on the annotation's rectangle — a scale and a translation only,
665/// `b` and `c` zeroed as the reference does.
666fn flatten_matrix(annot_rect: Rect, stream_rect: Rect, matrix: Affine) -> Affine {
667    let transformed = matrix.transform_rect_bbox(stream_rect).abs();
668    if transformed.width() <= 0.0 || transformed.height() <= 0.0 {
669        return Affine::IDENTITY;
670    }
671    let a = annot_rect.width() / transformed.width();
672    let d = annot_rect.height() / transformed.height();
673    Affine::new([
674        a,
675        0.0,
676        0.0,
677        d,
678        annot_rect.x0 - transformed.x0 * a,
679        annot_rect.y0 - transformed.y0 * d,
680    ])
681}