Skip to main content

pdf_annot/
flatten.rs

1#![cfg(feature = "write")]
2//! Annotation flattening implementation.
3//!
4//! Flattens non-widget, non-link annotations containing appearance streams (/AP /N)
5//! directly into the page content stream.
6
7// Copyright (c) 2026 Innovation Trigger B.V.
8//
9// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
10// the PDFluent Commercial Licence. See the LICENSE file in this repository --
11// that file travels with the copy you received, which a URL does not.
12
13use lopdf::{dictionary, Document, Object, ObjectId, Stream};
14use std::collections::HashSet;
15
16use crate::builder::{AnnotRect, AnnotSubtype, AnnotationBuilder};
17
18/// Pick a `/XObject` resource name of the form `FlatAnnotN` that is not already
19/// present in `xobjects`. `next` is advanced past the chosen index so repeated
20/// calls stay monotonic. This makes flattening collision-proof: it never
21/// overwrites a pre-existing XObject, and a second flatten pass on an
22/// already-flattened page cannot clobber the names from the first.
23fn unique_xobject_name(xobjects: &lopdf::Dictionary, next: &mut usize) -> String {
24    loop {
25        let candidate = format!("FlatAnnot{}", *next);
26        *next += 1;
27        if !xobjects.has(candidate.as_bytes()) {
28            return candidate;
29        }
30    }
31}
32
33/// Convert a PDF object to a f64 number.
34fn as_number(obj: &Object) -> Option<f64> {
35    match obj {
36        Object::Integer(i) => Some(*i as f64),
37        Object::Real(f) => Some(*f as f64),
38        _ => None,
39    }
40}
41
42/// Walk parent Pages chain to resolve inherited Resources dictionary.
43fn resolve_inherited_resources_clone(
44    doc: &lopdf::Document,
45    page_id: ObjectId,
46) -> Option<lopdf::Dictionary> {
47    let mut current = page_id;
48    let mut visited = HashSet::new();
49    loop {
50        if !visited.insert(current) {
51            return None;
52        }
53        let dict = doc.get_dictionary(current).ok()?;
54        if let Ok(res_obj) = dict.get(b"Resources") {
55            return match res_obj {
56                Object::Dictionary(d) => Some(d.clone()),
57                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
58                _ => None,
59            };
60        }
61        match dict.get(b"Parent") {
62            Ok(Object::Reference(id)) => current = *id,
63            _ => return None,
64        }
65    }
66}
67
68/// Resolve page /Contents to a flat list of stream ObjectIds.
69fn resolve_content_streams(doc: &lopdf::Document, page_id: ObjectId) -> Vec<ObjectId> {
70    let page_obj = match doc.get_object(page_id) {
71        Ok(obj) => obj,
72        Err(_) => return Vec::new(),
73    };
74    let page_dict = match page_obj {
75        Object::Dictionary(ref d) => d,
76        _ => return Vec::new(),
77    };
78    match page_dict.get(b"Contents").ok() {
79        Some(c) => flatten_content_refs(doc, c),
80        None => Vec::new(),
81    }
82}
83
84fn flatten_content_refs(doc: &lopdf::Document, obj: &Object) -> Vec<ObjectId> {
85    match obj {
86        Object::Reference(id) => {
87            if let Ok(Object::Array(arr)) = doc.get_object(*id) {
88                return arr
89                    .iter()
90                    .flat_map(|o| flatten_content_refs(doc, o))
91                    .collect();
92            }
93            vec![*id]
94        }
95        Object::Array(arr) => arr
96            .iter()
97            .flat_map(|o| flatten_content_refs(doc, o))
98            .collect(),
99        _ => Vec::new(),
100    }
101}
102
103/// Wrap existing page content in q/Q to isolate graphics state.
104fn wrap_existing_content_in_save_restore(doc: &mut lopdf::Document, page_id: ObjectId) {
105    let existing = resolve_content_streams(doc, page_id);
106    if existing.is_empty() {
107        return;
108    }
109
110    let q_stream = Stream::new(dictionary! {}, b"q\n".to_vec());
111    let q_id = doc.add_object(Object::Stream(q_stream));
112
113    let big_q_stream = Stream::new(dictionary! {}, b"\nQ\n".to_vec());
114    let big_q_id = doc.add_object(Object::Stream(big_q_stream));
115
116    // Build flat array: [q, ...existing streams..., Q]
117    let mut new_arr = Vec::with_capacity(existing.len() + 2);
118    new_arr.push(Object::Reference(q_id));
119    for id in existing {
120        new_arr.push(Object::Reference(id));
121    }
122    new_arr.push(Object::Reference(big_q_id));
123    let wrapped = Object::Array(new_arr);
124
125    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
126        d.set("Contents", wrapped);
127    }
128}
129
130/// Append a content stream reference to a page's Contents.
131fn append_content_to_page(doc: &mut lopdf::Document, page_id: ObjectId, content_id: ObjectId) {
132    let existing = resolve_content_streams(doc, page_id);
133    let new_contents = if existing.is_empty() {
134        Object::Reference(content_id)
135    } else {
136        let mut arr: Vec<Object> = existing.into_iter().map(Object::Reference).collect();
137        arr.push(Object::Reference(content_id));
138        Object::Array(arr)
139    };
140
141    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
142        d.set("Contents", new_contents);
143    }
144}
145
146/// Compute the coordinate transformation mapping appearance BBox to Rect.
147fn get_matrix_and_bbox(n_stream_dict: &lopdf::Dictionary, rect: &[f64; 4]) -> Option<[f64; 6]> {
148    let bbox = n_stream_dict.get(b"BBox").ok()?;
149    let bbox_arr = match bbox {
150        Object::Array(ref arr) if arr.len() >= 4 => arr,
151        _ => return None,
152    };
153    let bx0 = as_number(&bbox_arr[0])?;
154    let by0 = as_number(&bbox_arr[1])?;
155    let bx1 = as_number(&bbox_arr[2])?;
156    let by1 = as_number(&bbox_arr[3])?;
157
158    let bw = bx1 - bx0;
159    let bh = by1 - by0;
160    if bw == 0.0 || bh == 0.0 {
161        return None;
162    }
163
164    let rx0 = rect[0];
165    let ry0 = rect[1];
166    let rx1 = rect[2];
167    let ry1 = rect[3];
168    let rw = rx1 - rx0;
169    let rh = ry1 - ry0;
170
171    // Optional Matrix in appearance stream
172    let matrix_arr = n_stream_dict.get(b"Matrix").ok().and_then(|m| match m {
173        Object::Array(ref arr) if arr.len() >= 6 => Some(arr),
174        _ => None,
175    });
176
177    if let Some(m) = matrix_arr {
178        let ma = as_number(&m[0]).unwrap_or(1.0);
179        let mb = as_number(&m[1]).unwrap_or(0.0);
180        let mc = as_number(&m[2]).unwrap_or(0.0);
181        let md = as_number(&m[3]).unwrap_or(1.0);
182        let me = as_number(&m[4]).unwrap_or(0.0);
183        let mf = as_number(&m[5]).unwrap_or(0.0);
184
185        let corners = [(bx0, by0), (bx1, by0), (bx0, by1), (bx1, by1)];
186        let mut t_corners = [(0.0, 0.0); 4];
187        for (i, &(x, y)) in corners.iter().enumerate() {
188            t_corners[i] = (ma * x + mc * y + me, mb * x + md * y + mf);
189        }
190        let t_bx0 = t_corners.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
191        let t_by0 = t_corners.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
192        let t_bx1 = t_corners
193            .iter()
194            .map(|p| p.0)
195            .fold(f64::NEG_INFINITY, f64::max);
196        let t_by1 = t_corners
197            .iter()
198            .map(|p| p.1)
199            .fold(f64::NEG_INFINITY, f64::max);
200
201        let t_bw = t_bx1 - t_bx0;
202        let t_bh = t_by1 - t_by0;
203        if t_bw == 0.0 || t_bh == 0.0 {
204            return None;
205        }
206
207        let sx = rw / t_bw;
208        let sy = rh / t_bh;
209        let tx = rx0 - t_bx0 * sx;
210        let ty = ry0 - t_by0 * sy;
211
212        Some([
213            ma * sx,
214            mb * sy,
215            mc * sx,
216            md * sy,
217            me * sx + tx,
218            mf * sy + ty,
219        ])
220    } else {
221        let sx = rw / bw;
222        let sy = rh / bh;
223        let tx = rx0 - bx0 * sx;
224        let ty = ry0 - by0 * sy;
225        Some([sx, 0.0, 0.0, sy, tx, ty])
226    }
227}
228
229/// Whether an annotation dictionary already carries a normal appearance
230/// that [`flatten_annotations`] can paint: `/AP` with a `/N` stream.
231fn has_usable_appearance(doc: &Document, annot_dict: &lopdf::Dictionary) -> bool {
232    let ap = match annot_dict.get(b"AP") {
233        Ok(Object::Dictionary(d)) => d.clone(),
234        Ok(Object::Reference(id)) => match doc.get_object(*id) {
235            Ok(Object::Dictionary(d)) => d.clone(),
236            _ => return false,
237        },
238        _ => return false,
239    };
240    match ap.get(b"N") {
241        Ok(Object::Stream(_)) => true,
242        Ok(Object::Reference(id)) => {
243            matches!(doc.get_object(*id), Ok(Object::Stream(_)))
244        }
245        _ => false,
246    }
247}
248
249/// Read `/C` or `/IC` as an RGB triple: grey repeats, CMYK converts naively.
250fn annot_rgb(value: &Object) -> Option<(f64, f64, f64)> {
251    let arr = value.as_array().ok()?;
252    let nums: Vec<f64> = arr.iter().filter_map(as_number).collect();
253    match nums.as_slice() {
254        [g] => Some((*g, *g, *g)),
255        [r, g, b] => Some((*r, *g, *b)),
256        [c, m, y, k] => Some((
257            (1.0 - (c + k).min(1.0)).max(0.0),
258            (1.0 - (m + k).min(1.0)).max(0.0),
259            (1.0 - (y + k).min(1.0)).max(0.0),
260        )),
261        _ => None,
262    }
263}
264
265/// Border width from `/BS /W`, else the `/Border` array's third number.
266fn annot_border_width(doc: &Document, annot_dict: &lopdf::Dictionary) -> Option<f64> {
267    if let Ok(bs) = annot_dict.get(b"BS") {
268        let dict = match bs {
269            Object::Dictionary(d) => Some(d.clone()),
270            Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
271            _ => None,
272        };
273        if let Some(d) = dict {
274            if let Ok(w) = d.get(b"W") {
275                if let Some(w) = as_number(w) {
276                    return Some(w);
277                }
278            }
279        }
280    }
281    if let Ok(Object::Array(border)) = annot_dict.get(b"Border") {
282        if let Some(w) = border.get(2).and_then(as_number) {
283            return Some(w);
284        }
285    }
286    None
287}
288
289/// Dash array from `/BS /D`.
290fn annot_dash(doc: &Document, annot_dict: &lopdf::Dictionary) -> Option<Vec<f64>> {
291    let bs = annot_dict.get(b"BS").ok()?;
292    let dict = match bs {
293        Object::Dictionary(d) => d.clone(),
294        Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
295        _ => return None,
296    };
297    let dash = dict.get(b"D").ok()?.as_array().ok()?.clone();
298    let nums: Vec<f64> = dash.iter().filter_map(as_number).collect();
299    if nums.is_empty() {
300        None
301    } else {
302        Some(nums)
303    }
304}
305
306/// Text of a direct `/Contents` string.
307fn annot_text(value: &Object) -> Option<String> {
308    match value {
309        Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
310        _ => None,
311    }
312}
313
314/// Give every flattenable annotation without one a default appearance.
315///
316/// [`flatten_annotations`] only paints annotations that carry `/AP /N`; this
317/// pass runs first so annotations without an appearance flatten too instead
318/// of silently staying live. Widget and link annotations are left alone, as
319/// are subtypes the builder cannot draw. Returns how many appearances were
320/// added.
321pub fn ensure_appearances(doc: &mut Document) -> Result<usize, crate::error::AnnotBuildError> {
322    let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
323    let mut added = 0usize;
324    for page_id in page_ids {
325        let page_dict = match doc.get_object(page_id) {
326            Ok(Object::Dictionary(d)) => d.clone(),
327            _ => continue,
328        };
329        let annots_arr = match page_dict.get(b"Annots") {
330            Ok(Object::Array(arr)) => arr.clone(),
331            Ok(Object::Reference(id)) => match doc.get_object(*id) {
332                Ok(Object::Array(arr)) => arr.clone(),
333                _ => continue,
334            },
335            _ => continue,
336        };
337        for annot_obj in &annots_arr {
338            let annot_id = match annot_obj {
339                Object::Reference(id) => *id,
340                _ => continue,
341            };
342            let annot_dict = match doc.get_object(annot_id) {
343                Ok(Object::Dictionary(d)) => d.clone(),
344                _ => continue,
345            };
346            let subtype = match annot_dict.get(b"Subtype") {
347                Ok(Object::Name(name)) => String::from_utf8_lossy(name).into_owned(),
348                _ => continue,
349            };
350            if subtype == "Widget" || subtype == "Link" {
351                continue;
352            }
353            let parsed = match AnnotSubtype::from_name(&subtype) {
354                Some(found) => found,
355                None => continue,
356            };
357            if has_usable_appearance(doc, &annot_dict) {
358                continue;
359            }
360            let rect = match annot_dict.get(b"Rect") {
361                Ok(Object::Array(arr)) if arr.len() >= 4 => [
362                    as_number(&arr[0]),
363                    as_number(&arr[1]),
364                    as_number(&arr[2]),
365                    as_number(&arr[3]),
366                ],
367                _ => continue,
368            };
369            let [Some(x0), Some(y0), Some(x1), Some(y1)] = rect else {
370                continue;
371            };
372            if (x1 - x0).abs() < f64::EPSILON || (y1 - y0).abs() < f64::EPSILON {
373                continue;
374            }
375            let rect = AnnotRect::new(x0, y0, x1, y1);
376            let mut builder = match parsed {
377                AnnotSubtype::Ink => {
378                    let strokes = match annot_dict.get(b"InkList") {
379                        Ok(Object::Array(paths)) => paths
380                            .iter()
381                            .filter_map(|p| p.as_array().ok())
382                            .map(|a| a.iter().filter_map(as_number).collect::<Vec<_>>())
383                            .filter(|p| p.len() >= 2)
384                            .collect(),
385                        _ => Vec::new(),
386                    };
387                    AnnotationBuilder::ink(rect, strokes)
388                }
389                AnnotSubtype::Polygon => {
390                    let verts = match annot_dict.get(b"Vertices") {
391                        Ok(Object::Array(arr)) => {
392                            arr.iter().filter_map(as_number).collect::<Vec<_>>()
393                        }
394                        _ => Vec::new(),
395                    };
396                    AnnotationBuilder::polygon(rect, verts)
397                }
398                AnnotSubtype::PolyLine => {
399                    let verts = match annot_dict.get(b"Vertices") {
400                        Ok(Object::Array(arr)) => {
401                            arr.iter().filter_map(as_number).collect::<Vec<_>>()
402                        }
403                        _ => Vec::new(),
404                    };
405                    AnnotationBuilder::polyline(rect, verts)
406                }
407                other => AnnotationBuilder::new(other, rect),
408            };
409            if parsed == AnnotSubtype::Line {
410                if let Ok(Object::Array(line)) = annot_dict.get(b"L") {
411                    if line.len() >= 4 {
412                        let ends = [
413                            as_number(&line[0]),
414                            as_number(&line[1]),
415                            as_number(&line[2]),
416                            as_number(&line[3]),
417                        ];
418                        if let [Some(x1), Some(y1), Some(x2), Some(y2)] = ends {
419                            builder.set_line_endpoints([x1, y1, x2, y2]);
420                        }
421                    }
422                }
423            }
424            if let Ok(color) = annot_dict.get(b"C") {
425                if let Some((r, g, b)) = annot_rgb(color) {
426                    builder = builder.color(r, g, b);
427                }
428            }
429            if let Ok(ic) = annot_dict.get(b"IC") {
430                if let Some((r, g, b)) = annot_rgb(ic) {
431                    builder = builder.interior_color(r, g, b);
432                }
433            }
434            if let Ok(ca) = annot_dict.get(b"CA") {
435                if let Some(alpha) = as_number(ca) {
436                    builder = builder.opacity(alpha);
437                }
438            }
439            if let Some(w) = annot_border_width(doc, &annot_dict) {
440                builder = builder.border_width(w);
441            }
442            if let Some(dash) = annot_dash(doc, &annot_dict) {
443                builder = builder.dash(dash);
444            }
445            if let Ok(contents) = annot_dict.get(b"Contents") {
446                if let Some(text) = annot_text(contents) {
447                    builder = builder.contents(text);
448                }
449            }
450            if let Ok(Object::Integer(q)) = annot_dict.get(b"Q") {
451                builder = builder.alignment(*q);
452            }
453            if matches!(parsed, AnnotSubtype::Stamp | AnnotSubtype::Text) {
454                if let Ok(Object::Name(name)) = annot_dict.get(b"Name") {
455                    builder.set_icon_name(String::from_utf8_lossy(name).into_owned());
456                }
457            }
458            if let Ok(Object::Array(qp)) = annot_dict.get(b"QuadPoints") {
459                let nums: Vec<f64> = qp.iter().filter_map(as_number).collect();
460                if !nums.is_empty() {
461                    builder = builder.quad_points(nums);
462                }
463            }
464            let ap_id = match builder.appearance_stream(doc) {
465                Ok(id) => id,
466                Err(crate::error::AnnotBuildError::InvalidRect) => continue,
467                Err(other) => return Err(other),
468            };
469            // Keep any existing /R or /D entries beside the new /N.
470            let ap_ref = Object::Reference(ap_id);
471            match annot_dict.get(b"AP").cloned() {
472                Ok(Object::Dictionary(mut ap)) => {
473                    ap.set("N", ap_ref);
474                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(annot_id) {
475                        d.set("AP", Object::Dictionary(ap));
476                        added += 1;
477                    }
478                }
479                Ok(Object::Reference(id)) => {
480                    if let Ok(Object::Dictionary(ap)) = doc.get_object_mut(id) {
481                        ap.set("N", ap_ref);
482                        added += 1;
483                    } else if let Ok(Object::Dictionary(d)) = doc.get_object_mut(annot_id) {
484                        let ap = dictionary! { "N" => ap_ref };
485                        d.set("AP", Object::Dictionary(ap));
486                        added += 1;
487                    }
488                }
489                _ => {
490                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(annot_id) {
491                        let ap = dictionary! { "N" => ap_ref };
492                        d.set("AP", Object::Dictionary(ap));
493                        added += 1;
494                    }
495                }
496            }
497        }
498    }
499    Ok(added)
500}
501
502/// Flatten all non-widget, non-link annotations on all pages.
503///
504/// Reference integrity: when a markup annotation is flattened, its associated
505/// `/Popup` companion annotation is removed as well, so no `/Parent` reference
506/// is left dangling. Cross-annotation `/IRT` (reply threads) and structure-tree
507/// `OBJR` references that point at a flattened annotation are NOT rewritten;
508/// per ISO 32000-1 §7.3.10 a reference to a removed object resolves to the null
509/// object, which conforming readers tolerate. Rewriting those references is
510/// intentionally out of scope (tracked as a follow-up).
511pub fn flatten_annotations(doc: &mut lopdf::Document) -> Result<(), crate::error::AnnotBuildError> {
512    let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
513    let mut deleted_objects = HashSet::new();
514    // Popup companions of flattened markup annotations. Removed alongside their
515    // parent so the popup's /Parent back-reference does not dangle.
516    let mut popups_to_remove: HashSet<ObjectId> = HashSet::new();
517
518    for page_id in page_ids {
519        // Retrieve /Annots array
520        let page_dict = match doc.get_object(page_id) {
521            Ok(Object::Dictionary(ref d)) => d.clone(),
522            _ => continue,
523        };
524
525        let annots_arr = match page_dict.get(b"Annots") {
526            Ok(Object::Array(ref arr)) => arr.clone(),
527            Ok(Object::Reference(id)) => match doc.get_object(*id) {
528                Ok(Object::Array(ref arr)) => arr.clone(),
529                _ => continue,
530            },
531            _ => continue,
532        };
533
534        if annots_arr.is_empty() {
535            continue;
536        }
537
538        let mut remaining_annots = Vec::new();
539        let mut draw_ops = Vec::new();
540
541        let mut resources = if let Ok(res_obj) = page_dict.get(b"Resources") {
542            match res_obj {
543                Object::Dictionary(ref d) => d.clone(),
544                Object::Reference(id) => match doc.get_object(*id) {
545                    Ok(Object::Dictionary(ref d)) => d.clone(),
546                    _ => lopdf::Dictionary::new(),
547                },
548                _ => lopdf::Dictionary::new(),
549            }
550        } else {
551            resolve_inherited_resources_clone(doc, page_id).unwrap_or_default()
552        };
553
554        let mut xobjects = match resources.get(b"XObject") {
555            Ok(Object::Dictionary(ref d)) => d.clone(),
556            Ok(Object::Reference(id)) => match doc.get_object(*id) {
557                Ok(Object::Dictionary(ref d)) => d.clone(),
558                _ => lopdf::Dictionary::new(),
559            },
560            _ => lopdf::Dictionary::new(),
561        };
562
563        let mut flat_annot_count = 0;
564
565        for annot_obj in &annots_arr {
566            let annot_id = match annot_obj {
567                Object::Reference(id) => *id,
568                _ => {
569                    remaining_annots.push(annot_obj.clone());
570                    continue;
571                }
572            };
573
574            let annot_dict = match doc.get_object(annot_id) {
575                Ok(Object::Dictionary(ref d)) => d.clone(),
576                _ => {
577                    remaining_annots.push(annot_obj.clone());
578                    continue;
579                }
580            };
581
582            let subtype = match annot_dict.get(b"Subtype") {
583                Ok(Object::Name(ref name)) => name.as_slice(),
584                _ => {
585                    remaining_annots.push(annot_obj.clone());
586                    continue;
587                }
588            };
589
590            // Skip Widget and Link annotations
591            if subtype == b"Widget" || subtype == b"Link" {
592                remaining_annots.push(annot_obj.clone());
593                continue;
594            }
595
596            // Must have appearance dictionary /AP with normal appearance /N
597            let ap = match annot_dict.get(b"AP") {
598                Ok(Object::Dictionary(ref d)) => d.clone(),
599                Ok(Object::Reference(id)) => match doc.get_object(*id) {
600                    Ok(Object::Dictionary(ref d)) => d.clone(),
601                    _ => {
602                        remaining_annots.push(annot_obj.clone());
603                        continue;
604                    }
605                },
606                _ => {
607                    remaining_annots.push(annot_obj.clone());
608                    continue;
609                }
610            };
611
612            let n_obj = match ap.get(b"N") {
613                Ok(obj) => obj.clone(),
614                _ => {
615                    remaining_annots.push(annot_obj.clone());
616                    continue;
617                }
618            };
619
620            let (n_stream_id, n_stream) = match n_obj {
621                Object::Reference(id) => match doc.get_object(id) {
622                    Ok(Object::Stream(ref s)) => (Some(id), s.clone()),
623                    _ => {
624                        remaining_annots.push(annot_obj.clone());
625                        continue;
626                    }
627                },
628                Object::Stream(ref s) => (None, s.clone()),
629                _ => {
630                    remaining_annots.push(annot_obj.clone());
631                    continue;
632                }
633            };
634
635            // Get Rect
636            let rect_obj = match annot_dict.get(b"Rect") {
637                Ok(Object::Array(ref arr)) if arr.len() >= 4 => arr,
638                _ => {
639                    remaining_annots.push(annot_obj.clone());
640                    continue;
641                }
642            };
643            let rx0 = match as_number(&rect_obj[0]) {
644                Some(v) => v,
645                None => {
646                    remaining_annots.push(annot_obj.clone());
647                    continue;
648                }
649            };
650            let ry0 = match as_number(&rect_obj[1]) {
651                Some(v) => v,
652                None => {
653                    remaining_annots.push(annot_obj.clone());
654                    continue;
655                }
656            };
657            let rx1 = match as_number(&rect_obj[2]) {
658                Some(v) => v,
659                None => {
660                    remaining_annots.push(annot_obj.clone());
661                    continue;
662                }
663            };
664            let ry1 = match as_number(&rect_obj[3]) {
665                Some(v) => v,
666                None => {
667                    remaining_annots.push(annot_obj.clone());
668                    continue;
669                }
670            };
671            let rect = [rx0, ry0, rx1, ry1];
672
673            // Compute matrix
674            let matrix = match get_matrix_and_bbox(&n_stream.dict, &rect) {
675                Some(m) => m,
676                None => {
677                    remaining_annots.push(annot_obj.clone());
678                    continue;
679                }
680            };
681
682            // Set Form XObject type/subtype
683            let stream_id = match n_stream_id {
684                Some(id) => {
685                    if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(id) {
686                        s.dict.set("Type", Object::Name(b"XObject".to_vec()));
687                        s.dict.set("Subtype", Object::Name(b"Form".to_vec()));
688                    }
689                    id
690                }
691                None => {
692                    let mut s = n_stream.clone();
693                    s.dict.set("Type", Object::Name(b"XObject".to_vec()));
694                    s.dict.set("Subtype", Object::Name(b"Form".to_vec()));
695                    doc.add_object(Object::Stream(s))
696                }
697            };
698
699            // Insert into Resources /XObject under a name that does not collide
700            // with any existing entry (including names left by a previous flatten
701            // pass), so flattening never overwrites an unrelated XObject.
702            let ap_name = unique_xobject_name(&xobjects, &mut flat_annot_count);
703            xobjects.set(ap_name.as_bytes().to_vec(), Object::Reference(stream_id));
704
705            // Append Do operator
706            let draw_op = format!(
707                "q\n{} {} {} {} {} {} cm\n/{} Do\nQ\n",
708                matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], ap_name
709            );
710            draw_ops.extend_from_slice(draw_op.as_bytes());
711
712            deleted_objects.insert(annot_id);
713            // Mark the markup annotation's popup companion for removal so its
714            // /Parent back-reference does not dangle after flattening.
715            if let Ok(Object::Reference(popup_id)) = annot_dict.get(b"Popup") {
716                popups_to_remove.insert(*popup_id);
717            }
718        }
719
720        if !draw_ops.is_empty() {
721            // Write /XObject resources back to resources dict
722            resources.set("XObject", Object::Dictionary(xobjects));
723
724            if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
725                pd.set("Resources", Object::Dictionary(resources));
726            }
727
728            // Wrap existing page content in q/Q
729            wrap_existing_content_in_save_restore(doc, page_id);
730
731            // Create new content stream and append
732            let new_content_stream = Stream::new(dictionary! {}, draw_ops);
733            let new_content_id = doc.add_object(Object::Stream(new_content_stream));
734            append_content_to_page(doc, page_id, new_content_id);
735
736            // Drop popup companions of flattened annots so the array does not
737            // keep an interactive popup whose /Parent now points at nothing.
738            remaining_annots.retain(|o| match o {
739                Object::Reference(id) => !popups_to_remove.contains(id),
740                _ => true,
741            });
742
743            // Write modified /Annots array or remove it if empty
744            if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
745                if remaining_annots.is_empty() {
746                    pd.remove(b"Annots");
747                } else {
748                    pd.set("Annots", Object::Array(remaining_annots));
749                }
750            }
751        }
752    }
753
754    // Purge the deleted annotation dictionaries and their popup companions.
755    // `delete_object` also strips every remaining reference to each id across the
756    // whole document — page `/Annots`, `/IRT` reply links, structure-tree `OBJR`
757    // `/Obj` entries, and `/Popup` back-references — so no dangling reference to a
758    // flattened annotation survives. (Cost is O(deleted × objects); flattening is
759    // not a hot path.)
760    for id in deleted_objects.into_iter().chain(popups_to_remove) {
761        doc.delete_object(id);
762    }
763
764    Ok(())
765}
766
767#[cfg(test)]
768mod tests {
769    use super::{ensure_appearances, flatten_annotations, unique_xobject_name};
770    use lopdf::Dictionary;
771    use lopdf::{dictionary, Document, Object, ObjectId, Stream};
772
773    #[test]
774    fn unique_xobject_name_skips_existing_entries() {
775        let mut xobjects = Dictionary::new();
776        xobjects.set("FlatAnnot0", Object::Null);
777        xobjects.set("FlatAnnot1", Object::Null);
778
779        // FlatAnnot0/1 already exist, so the first free name is FlatAnnot2.
780        let mut next = 0usize;
781        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot2");
782
783        // After registering it, the next call stays monotonic and skips it.
784        xobjects.set("FlatAnnot2", Object::Null);
785        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot3");
786    }
787
788    #[test]
789    fn unique_xobject_name_empty_dict_starts_at_zero() {
790        let xobjects = Dictionary::new();
791        let mut next = 0usize;
792        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot0");
793        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot1");
794    }
795
796    fn one_page_doc() -> (Document, ObjectId) {
797        let mut doc = Document::with_version("1.7");
798        let pages_id = doc.new_object_id();
799        let content_id = doc.add_object(Stream::new(dictionary! {}, b"".to_vec()));
800        let page_id = doc.add_object(dictionary! {
801            "Type" => "Page",
802            "Parent" => pages_id,
803            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
804            "Contents" => content_id,
805            "Resources" => dictionary! {},
806        });
807        doc.objects.insert(
808            pages_id,
809            Object::Dictionary(dictionary! {
810                "Type" => "Pages",
811                "Kids" => vec![Object::Reference(page_id)],
812                "Count" => 1_i64,
813            }),
814        );
815        let catalog_id = doc.add_object(dictionary! {
816            "Type" => "Catalog",
817            "Pages" => pages_id,
818        });
819        doc.trailer.set("Root", catalog_id);
820        (doc, page_id)
821    }
822
823    fn add_annot(doc: &mut Document, page_id: ObjectId, subtype: &str, with_ap: bool) -> ObjectId {
824        let mut dict = dictionary! {
825            "Type" => "Annot",
826            "Subtype" => Object::Name(subtype.as_bytes().to_vec()),
827            "Rect" => vec![10.into(), 10.into(), 110.into(), 60.into()],
828        };
829        if with_ap {
830            let ap = Stream::new(
831                dictionary! {
832                    "Type" => "XObject",
833                    "Subtype" => "Form",
834                    "BBox" => vec![0.into(), 0.into(), 100.into(), 50.into()],
835                },
836                b"0 0 1 RG 0 0 100 50 re S".to_vec(),
837            );
838            let ap_id = doc.add_object(Object::Stream(ap));
839            dict.set(
840                "AP",
841                Object::Dictionary(dictionary! { "N" => Object::Reference(ap_id) }),
842            );
843        }
844        let id = doc.add_object(Object::Dictionary(dict));
845        let page = doc.get_dictionary_mut(page_id).unwrap();
846        match page.get(b"Annots").cloned() {
847            Ok(Object::Array(mut arr)) => {
848                arr.push(Object::Reference(id));
849                page.set("Annots", Object::Array(arr));
850            }
851            _ => {
852                page.set("Annots", Object::Array(vec![Object::Reference(id)]));
853            }
854        }
855        id
856    }
857
858    fn ap_normal(doc: &Document, annot_id: ObjectId) -> Option<ObjectId> {
859        let dict = doc.get_dictionary(annot_id).ok()?;
860        let ap = dict.get(b"AP").ok()?.clone();
861        let ap_dict = match ap {
862            Object::Dictionary(d) => d,
863            Object::Reference(id) => doc.get_dictionary(id).ok()?.clone(),
864            _ => return None,
865        };
866        match ap_dict.get(b"N").ok()?.clone() {
867            Object::Reference(id) => Some(id),
868            _ => None,
869        }
870    }
871
872    #[test]
873    fn ensure_appearances_backfills_only_what_flatten_paints() {
874        let (mut doc, page_id) = one_page_doc();
875        let square = add_annot(&mut doc, page_id, "Square", false);
876        let highlight = add_annot(&mut doc, page_id, "Highlight", false);
877        let kept = add_annot(&mut doc, page_id, "Square", true);
878        let kept_ap = ap_normal(&doc, kept).expect("fixture has an appearance");
879        let link = add_annot(&mut doc, page_id, "Link", false);
880        let widget = add_annot(&mut doc, page_id, "Widget", false);
881        let sound = add_annot(&mut doc, page_id, "Sound", false);
882
883        assert_eq!(ensure_appearances(&mut doc).unwrap(), 2);
884        assert!(ap_normal(&doc, square).is_some());
885        assert!(ap_normal(&doc, highlight).is_some());
886        // Untouched: existing appearance, link, widget, undrawable subtype.
887        assert_eq!(ap_normal(&doc, kept), Some(kept_ap));
888        assert_eq!(ap_normal(&doc, link), None);
889        assert_eq!(ap_normal(&doc, widget), None);
890        assert_eq!(ap_normal(&doc, sound), None);
891    }
892
893    #[test]
894    fn backfilled_appearances_flatten_away() {
895        let (mut doc, page_id) = one_page_doc();
896        add_annot(&mut doc, page_id, "Circle", false);
897        add_annot(&mut doc, page_id, "Link", false);
898
899        assert_eq!(ensure_appearances(&mut doc).unwrap(), 1);
900        flatten_annotations(&mut doc).unwrap();
901
902        let page = doc.get_dictionary(page_id).unwrap();
903        let annots = page.get(b"Annots").unwrap().as_array().unwrap().clone();
904        assert_eq!(annots.len(), 1);
905        let survivor = doc
906            .get_dictionary(annots[0].as_reference().unwrap())
907            .unwrap();
908        assert_eq!(
909            survivor.get(b"Subtype").unwrap().as_name().unwrap(),
910            b"Link"
911        );
912    }
913}