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, Object, ObjectId, Stream};
14use std::collections::HashSet;
15
16/// Pick a `/XObject` resource name of the form `FlatAnnotN` that is not already
17/// present in `xobjects`. `next` is advanced past the chosen index so repeated
18/// calls stay monotonic. This makes flattening collision-proof: it never
19/// overwrites a pre-existing XObject, and a second flatten pass on an
20/// already-flattened page cannot clobber the names from the first.
21fn unique_xobject_name(xobjects: &lopdf::Dictionary, next: &mut usize) -> String {
22    loop {
23        let candidate = format!("FlatAnnot{}", *next);
24        *next += 1;
25        if !xobjects.has(candidate.as_bytes()) {
26            return candidate;
27        }
28    }
29}
30
31/// Convert a PDF object to a f64 number.
32fn as_number(obj: &Object) -> Option<f64> {
33    match obj {
34        Object::Integer(i) => Some(*i as f64),
35        Object::Real(f) => Some(*f as f64),
36        _ => None,
37    }
38}
39
40/// Walk parent Pages chain to resolve inherited Resources dictionary.
41fn resolve_inherited_resources_clone(
42    doc: &lopdf::Document,
43    page_id: ObjectId,
44) -> Option<lopdf::Dictionary> {
45    let mut current = page_id;
46    let mut visited = HashSet::new();
47    loop {
48        if !visited.insert(current) {
49            return None;
50        }
51        let dict = doc.get_dictionary(current).ok()?;
52        if let Ok(res_obj) = dict.get(b"Resources") {
53            return match res_obj {
54                Object::Dictionary(d) => Some(d.clone()),
55                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
56                _ => None,
57            };
58        }
59        match dict.get(b"Parent") {
60            Ok(Object::Reference(id)) => current = *id,
61            _ => return None,
62        }
63    }
64}
65
66/// Resolve page /Contents to a flat list of stream ObjectIds.
67fn resolve_content_streams(doc: &lopdf::Document, page_id: ObjectId) -> Vec<ObjectId> {
68    let page_obj = match doc.get_object(page_id) {
69        Ok(obj) => obj,
70        Err(_) => return Vec::new(),
71    };
72    let page_dict = match page_obj {
73        Object::Dictionary(ref d) => d,
74        _ => return Vec::new(),
75    };
76    match page_dict.get(b"Contents").ok() {
77        Some(c) => flatten_content_refs(doc, c),
78        None => Vec::new(),
79    }
80}
81
82fn flatten_content_refs(doc: &lopdf::Document, obj: &Object) -> Vec<ObjectId> {
83    match obj {
84        Object::Reference(id) => {
85            if let Ok(Object::Array(arr)) = doc.get_object(*id) {
86                return arr
87                    .iter()
88                    .flat_map(|o| flatten_content_refs(doc, o))
89                    .collect();
90            }
91            vec![*id]
92        }
93        Object::Array(arr) => arr
94            .iter()
95            .flat_map(|o| flatten_content_refs(doc, o))
96            .collect(),
97        _ => Vec::new(),
98    }
99}
100
101/// Wrap existing page content in q/Q to isolate graphics state.
102fn wrap_existing_content_in_save_restore(doc: &mut lopdf::Document, page_id: ObjectId) {
103    let existing = resolve_content_streams(doc, page_id);
104    if existing.is_empty() {
105        return;
106    }
107
108    let q_stream = Stream::new(dictionary! {}, b"q\n".to_vec());
109    let q_id = doc.add_object(Object::Stream(q_stream));
110
111    let big_q_stream = Stream::new(dictionary! {}, b"\nQ\n".to_vec());
112    let big_q_id = doc.add_object(Object::Stream(big_q_stream));
113
114    // Build flat array: [q, ...existing streams..., Q]
115    let mut new_arr = Vec::with_capacity(existing.len() + 2);
116    new_arr.push(Object::Reference(q_id));
117    for id in existing {
118        new_arr.push(Object::Reference(id));
119    }
120    new_arr.push(Object::Reference(big_q_id));
121    let wrapped = Object::Array(new_arr);
122
123    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
124        d.set("Contents", wrapped);
125    }
126}
127
128/// Append a content stream reference to a page's Contents.
129fn append_content_to_page(doc: &mut lopdf::Document, page_id: ObjectId, content_id: ObjectId) {
130    let existing = resolve_content_streams(doc, page_id);
131    let new_contents = if existing.is_empty() {
132        Object::Reference(content_id)
133    } else {
134        let mut arr: Vec<Object> = existing.into_iter().map(Object::Reference).collect();
135        arr.push(Object::Reference(content_id));
136        Object::Array(arr)
137    };
138
139    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
140        d.set("Contents", new_contents);
141    }
142}
143
144/// Compute the coordinate transformation mapping appearance BBox to Rect.
145fn get_matrix_and_bbox(n_stream_dict: &lopdf::Dictionary, rect: &[f64; 4]) -> Option<[f64; 6]> {
146    let bbox = n_stream_dict.get(b"BBox").ok()?;
147    let bbox_arr = match bbox {
148        Object::Array(ref arr) if arr.len() >= 4 => arr,
149        _ => return None,
150    };
151    let bx0 = as_number(&bbox_arr[0])?;
152    let by0 = as_number(&bbox_arr[1])?;
153    let bx1 = as_number(&bbox_arr[2])?;
154    let by1 = as_number(&bbox_arr[3])?;
155
156    let bw = bx1 - bx0;
157    let bh = by1 - by0;
158    if bw == 0.0 || bh == 0.0 {
159        return None;
160    }
161
162    let rx0 = rect[0];
163    let ry0 = rect[1];
164    let rx1 = rect[2];
165    let ry1 = rect[3];
166    let rw = rx1 - rx0;
167    let rh = ry1 - ry0;
168
169    // Optional Matrix in appearance stream
170    let matrix_arr = n_stream_dict.get(b"Matrix").ok().and_then(|m| match m {
171        Object::Array(ref arr) if arr.len() >= 6 => Some(arr),
172        _ => None,
173    });
174
175    if let Some(m) = matrix_arr {
176        let ma = as_number(&m[0]).unwrap_or(1.0);
177        let mb = as_number(&m[1]).unwrap_or(0.0);
178        let mc = as_number(&m[2]).unwrap_or(0.0);
179        let md = as_number(&m[3]).unwrap_or(1.0);
180        let me = as_number(&m[4]).unwrap_or(0.0);
181        let mf = as_number(&m[5]).unwrap_or(0.0);
182
183        let corners = [(bx0, by0), (bx1, by0), (bx0, by1), (bx1, by1)];
184        let mut t_corners = [(0.0, 0.0); 4];
185        for (i, &(x, y)) in corners.iter().enumerate() {
186            t_corners[i] = (ma * x + mc * y + me, mb * x + md * y + mf);
187        }
188        let t_bx0 = t_corners.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
189        let t_by0 = t_corners.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
190        let t_bx1 = t_corners
191            .iter()
192            .map(|p| p.0)
193            .fold(f64::NEG_INFINITY, f64::max);
194        let t_by1 = t_corners
195            .iter()
196            .map(|p| p.1)
197            .fold(f64::NEG_INFINITY, f64::max);
198
199        let t_bw = t_bx1 - t_bx0;
200        let t_bh = t_by1 - t_by0;
201        if t_bw == 0.0 || t_bh == 0.0 {
202            return None;
203        }
204
205        let sx = rw / t_bw;
206        let sy = rh / t_bh;
207        let tx = rx0 - t_bx0 * sx;
208        let ty = ry0 - t_by0 * sy;
209
210        Some([
211            ma * sx,
212            mb * sy,
213            mc * sx,
214            md * sy,
215            me * sx + tx,
216            mf * sy + ty,
217        ])
218    } else {
219        let sx = rw / bw;
220        let sy = rh / bh;
221        let tx = rx0 - bx0 * sx;
222        let ty = ry0 - by0 * sy;
223        Some([sx, 0.0, 0.0, sy, tx, ty])
224    }
225}
226
227/// Flatten all non-widget, non-link annotations on all pages.
228///
229/// Reference integrity: when a markup annotation is flattened, its associated
230/// `/Popup` companion annotation is removed as well, so no `/Parent` reference
231/// is left dangling. Cross-annotation `/IRT` (reply threads) and structure-tree
232/// `OBJR` references that point at a flattened annotation are NOT rewritten;
233/// per ISO 32000-1 §7.3.10 a reference to a removed object resolves to the null
234/// object, which conforming readers tolerate. Rewriting those references is
235/// intentionally out of scope (tracked as a follow-up).
236pub fn flatten_annotations(doc: &mut lopdf::Document) -> Result<(), crate::error::AnnotBuildError> {
237    let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
238    let mut deleted_objects = HashSet::new();
239    // Popup companions of flattened markup annotations. Removed alongside their
240    // parent so the popup's /Parent back-reference does not dangle.
241    let mut popups_to_remove: HashSet<ObjectId> = HashSet::new();
242
243    for page_id in page_ids {
244        // Retrieve /Annots array
245        let page_dict = match doc.get_object(page_id) {
246            Ok(Object::Dictionary(ref d)) => d.clone(),
247            _ => continue,
248        };
249
250        let annots_arr = match page_dict.get(b"Annots") {
251            Ok(Object::Array(ref arr)) => arr.clone(),
252            Ok(Object::Reference(id)) => match doc.get_object(*id) {
253                Ok(Object::Array(ref arr)) => arr.clone(),
254                _ => continue,
255            },
256            _ => continue,
257        };
258
259        if annots_arr.is_empty() {
260            continue;
261        }
262
263        let mut remaining_annots = Vec::new();
264        let mut draw_ops = Vec::new();
265
266        let mut resources = if let Ok(res_obj) = page_dict.get(b"Resources") {
267            match res_obj {
268                Object::Dictionary(ref d) => d.clone(),
269                Object::Reference(id) => match doc.get_object(*id) {
270                    Ok(Object::Dictionary(ref d)) => d.clone(),
271                    _ => lopdf::Dictionary::new(),
272                },
273                _ => lopdf::Dictionary::new(),
274            }
275        } else {
276            resolve_inherited_resources_clone(doc, page_id).unwrap_or_default()
277        };
278
279        let mut xobjects = match resources.get(b"XObject") {
280            Ok(Object::Dictionary(ref d)) => d.clone(),
281            Ok(Object::Reference(id)) => match doc.get_object(*id) {
282                Ok(Object::Dictionary(ref d)) => d.clone(),
283                _ => lopdf::Dictionary::new(),
284            },
285            _ => lopdf::Dictionary::new(),
286        };
287
288        let mut flat_annot_count = 0;
289
290        for annot_obj in &annots_arr {
291            let annot_id = match annot_obj {
292                Object::Reference(id) => *id,
293                _ => {
294                    remaining_annots.push(annot_obj.clone());
295                    continue;
296                }
297            };
298
299            let annot_dict = match doc.get_object(annot_id) {
300                Ok(Object::Dictionary(ref d)) => d.clone(),
301                _ => {
302                    remaining_annots.push(annot_obj.clone());
303                    continue;
304                }
305            };
306
307            let subtype = match annot_dict.get(b"Subtype") {
308                Ok(Object::Name(ref name)) => name.as_slice(),
309                _ => {
310                    remaining_annots.push(annot_obj.clone());
311                    continue;
312                }
313            };
314
315            // Skip Widget and Link annotations
316            if subtype == b"Widget" || subtype == b"Link" {
317                remaining_annots.push(annot_obj.clone());
318                continue;
319            }
320
321            // Must have appearance dictionary /AP with normal appearance /N
322            let ap = match annot_dict.get(b"AP") {
323                Ok(Object::Dictionary(ref d)) => d.clone(),
324                Ok(Object::Reference(id)) => match doc.get_object(*id) {
325                    Ok(Object::Dictionary(ref d)) => d.clone(),
326                    _ => {
327                        remaining_annots.push(annot_obj.clone());
328                        continue;
329                    }
330                },
331                _ => {
332                    remaining_annots.push(annot_obj.clone());
333                    continue;
334                }
335            };
336
337            let n_obj = match ap.get(b"N") {
338                Ok(obj) => obj.clone(),
339                _ => {
340                    remaining_annots.push(annot_obj.clone());
341                    continue;
342                }
343            };
344
345            let (n_stream_id, n_stream) = match n_obj {
346                Object::Reference(id) => match doc.get_object(id) {
347                    Ok(Object::Stream(ref s)) => (Some(id), s.clone()),
348                    _ => {
349                        remaining_annots.push(annot_obj.clone());
350                        continue;
351                    }
352                },
353                Object::Stream(ref s) => (None, s.clone()),
354                _ => {
355                    remaining_annots.push(annot_obj.clone());
356                    continue;
357                }
358            };
359
360            // Get Rect
361            let rect_obj = match annot_dict.get(b"Rect") {
362                Ok(Object::Array(ref arr)) if arr.len() >= 4 => arr,
363                _ => {
364                    remaining_annots.push(annot_obj.clone());
365                    continue;
366                }
367            };
368            let rx0 = match as_number(&rect_obj[0]) {
369                Some(v) => v,
370                None => {
371                    remaining_annots.push(annot_obj.clone());
372                    continue;
373                }
374            };
375            let ry0 = match as_number(&rect_obj[1]) {
376                Some(v) => v,
377                None => {
378                    remaining_annots.push(annot_obj.clone());
379                    continue;
380                }
381            };
382            let rx1 = match as_number(&rect_obj[2]) {
383                Some(v) => v,
384                None => {
385                    remaining_annots.push(annot_obj.clone());
386                    continue;
387                }
388            };
389            let ry1 = match as_number(&rect_obj[3]) {
390                Some(v) => v,
391                None => {
392                    remaining_annots.push(annot_obj.clone());
393                    continue;
394                }
395            };
396            let rect = [rx0, ry0, rx1, ry1];
397
398            // Compute matrix
399            let matrix = match get_matrix_and_bbox(&n_stream.dict, &rect) {
400                Some(m) => m,
401                None => {
402                    remaining_annots.push(annot_obj.clone());
403                    continue;
404                }
405            };
406
407            // Set Form XObject type/subtype
408            let stream_id = match n_stream_id {
409                Some(id) => {
410                    if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(id) {
411                        s.dict.set("Type", Object::Name(b"XObject".to_vec()));
412                        s.dict.set("Subtype", Object::Name(b"Form".to_vec()));
413                    }
414                    id
415                }
416                None => {
417                    let mut s = n_stream.clone();
418                    s.dict.set("Type", Object::Name(b"XObject".to_vec()));
419                    s.dict.set("Subtype", Object::Name(b"Form".to_vec()));
420                    doc.add_object(Object::Stream(s))
421                }
422            };
423
424            // Insert into Resources /XObject under a name that does not collide
425            // with any existing entry (including names left by a previous flatten
426            // pass), so flattening never overwrites an unrelated XObject.
427            let ap_name = unique_xobject_name(&xobjects, &mut flat_annot_count);
428            xobjects.set(ap_name.as_bytes().to_vec(), Object::Reference(stream_id));
429
430            // Append Do operator
431            let draw_op = format!(
432                "q\n{} {} {} {} {} {} cm\n/{} Do\nQ\n",
433                matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], ap_name
434            );
435            draw_ops.extend_from_slice(draw_op.as_bytes());
436
437            deleted_objects.insert(annot_id);
438            // Mark the markup annotation's popup companion for removal so its
439            // /Parent back-reference does not dangle after flattening.
440            if let Ok(Object::Reference(popup_id)) = annot_dict.get(b"Popup") {
441                popups_to_remove.insert(*popup_id);
442            }
443        }
444
445        if !draw_ops.is_empty() {
446            // Write /XObject resources back to resources dict
447            resources.set("XObject", Object::Dictionary(xobjects));
448
449            if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
450                pd.set("Resources", Object::Dictionary(resources));
451            }
452
453            // Wrap existing page content in q/Q
454            wrap_existing_content_in_save_restore(doc, page_id);
455
456            // Create new content stream and append
457            let new_content_stream = Stream::new(dictionary! {}, draw_ops);
458            let new_content_id = doc.add_object(Object::Stream(new_content_stream));
459            append_content_to_page(doc, page_id, new_content_id);
460
461            // Drop popup companions of flattened annots so the array does not
462            // keep an interactive popup whose /Parent now points at nothing.
463            remaining_annots.retain(|o| match o {
464                Object::Reference(id) => !popups_to_remove.contains(id),
465                _ => true,
466            });
467
468            // Write modified /Annots array or remove it if empty
469            if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
470                if remaining_annots.is_empty() {
471                    pd.remove(b"Annots");
472                } else {
473                    pd.set("Annots", Object::Array(remaining_annots));
474                }
475            }
476        }
477    }
478
479    // Purge the deleted annotation dictionaries and their popup companions.
480    // `delete_object` also strips every remaining reference to each id across the
481    // whole document — page `/Annots`, `/IRT` reply links, structure-tree `OBJR`
482    // `/Obj` entries, and `/Popup` back-references — so no dangling reference to a
483    // flattened annotation survives. (Cost is O(deleted × objects); flattening is
484    // not a hot path.)
485    for id in deleted_objects.into_iter().chain(popups_to_remove) {
486        doc.delete_object(id);
487    }
488
489    Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::unique_xobject_name;
495    use lopdf::{Dictionary, Object};
496
497    #[test]
498    fn unique_xobject_name_skips_existing_entries() {
499        let mut xobjects = Dictionary::new();
500        xobjects.set("FlatAnnot0", Object::Null);
501        xobjects.set("FlatAnnot1", Object::Null);
502
503        // FlatAnnot0/1 already exist, so the first free name is FlatAnnot2.
504        let mut next = 0usize;
505        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot2");
506
507        // After registering it, the next call stays monotonic and skips it.
508        xobjects.set("FlatAnnot2", Object::Null);
509        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot3");
510    }
511
512    #[test]
513    fn unique_xobject_name_empty_dict_starts_at_zero() {
514        let xobjects = Dictionary::new();
515        let mut next = 0usize;
516        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot0");
517        assert_eq!(unique_xobject_name(&xobjects, &mut next), "FlatAnnot1");
518    }
519}