Skip to main content

pdfrum_edit/content/
regen.rs

1//! Rewriting a mutated page's `/Contents` (ISO 32000-1 §7.8.2).
2//!
3//! [`crate::content::emit`] turns one page object into operator bytes. This
4//! module is everything around that: deciding which `//Contents` elements have
5//! to be written again, framing each one, naming the resources the objects
6//! refer to, and reshaping the `/Contents` entry itself when elements are
7//! added or emptied.
8//!
9//! # A page nobody touched is not rewritten at all
10//!
11//! [`regenerate`] returns `None` when no stream is dirty, and the caller then
12//! writes nothing — not the streams, not the resource dictionary. That is the
13//! early-out the whole save path depends on: an ordinary save of an
14//! unmodified document must leave every page's bytes exactly as they were, and
15//! a regeneration is lossy enough that doing it speculatively would be
16//! destructive.
17//!
18//! # Each dirty stream gets a frame, and the frame carries the transform
19//!
20//! A stream is rewritten whole, so it must begin from a state it can state:
21//!
22//! ```text
23//! q
24//! [ <inverse of the transform this stream inherited> cm ]
25//! 0 0 0 RG 0 0 0 rg 1 w 0 J 0 j
26//! /<default gs> gs
27//! …objects…
28//! [ EMC per still-open mark ]
29//! Q
30//! [ <the transform change this stream passes on> cm ]
31//! ```
32//!
33//! The two `cm`s are the awkward part and they are not optional. A content
34//! stream may leave the transform changed for the streams after it — an
35//! unbalanced `q`/`cm` across an element boundary is legal and real files do
36//! it — so a rewritten stream has to undo what it inherited before stating its
37//! own state, and then restate what the streams after it were relying on.
38//! Without the second, rewriting element 0 silently moves everything in
39//! element 1.
40//!
41//! # An empty stream is a deletion, unless it still moves the transform
42//!
43//! A stream that produced no objects has its buffer cleared, and a cleared
44//! buffer is the signal to drop that `/Contents` element. But a stream that
45//! produced nothing and *does* change the transform keeps its whole frame:
46//! deleting it would leave the streams after it drawing under the wrong
47//! matrix. This is why "empty" and "removable" are two different questions.
48//!
49//! # Objects are visited once, in painting order, writing into several buffers
50//!
51//! The buffers for the dirty streams are all open at once and the single walk
52//! over the page's objects appends each object to whichever one it belongs to.
53//! Objects in clean streams are skipped, and so are inactive ones — an
54//! inactive object's stream is regenerated *without* it, which is exactly how
55//! it disappears.
56
57use std::collections::{BTreeMap, BTreeSet};
58
59use pdfrum_common::kurbo::Affine;
60use pdfrum_object::{Dict, Name, ObjRef, Object, Resolve};
61use pdfrum_page::{Page, PageObject};
62
63use crate::content::emit::{DEFAULT_GRAPHICS, GraphicsKey, ResourceNames, default_graphics};
64use crate::content::marks::{emit_mark_diff, finish_marks};
65use crate::content::num::write_matrix;
66use crate::content::resource::ResourceTable;
67
68/// One regenerated `/Contents` element.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Regenerated {
71    /// Which element this is, or `None` for one that did not exist before.
72    pub stream: Option<usize>,
73    /// The bytes. **Empty means delete this element**, not "write an empty
74    /// stream".
75    pub bytes: String,
76}
77
78/// Everything a save has to do to a page whose objects were edited.
79#[derive(Debug, Clone, PartialEq)]
80pub struct PageRewrite {
81    /// The elements to write, in ascending index order with the streamless
82    /// one first.
83    pub streams: Vec<Regenerated>,
84    /// The page's `/Resources`, with the three maintained categories swept and
85    /// every other key carried through.
86    pub resources: Dict,
87}
88
89/// Rewrite the dirty content streams of `page`, or `None` when none is dirty.
90///
91/// `resources` is the page's own `/Resources`, which supplies the names
92/// already in use so a regenerated stream reuses them rather than minting
93/// duplicates.
94#[must_use]
95pub fn regenerate(page: &Page, resources: &Dict, r: &impl Resolve) -> Option<PageRewrite> {
96    let dirty = page.dirty_stream_set();
97    if dirty.is_empty() {
98        return None;
99    }
100
101    let mut table = ResourceTable::load(resources, r);
102    // The prologue's `/ExtGState` is realized before anything else, so it
103    // takes the lowest free name and is never swept away.
104    let default_gs = table.realize_dict("ExtGState", &default_graphics());
105
106    let mut buffers: BTreeMap<Option<usize>, StreamBuffer> = dirty
107        .iter()
108        .map(|stream| {
109            let mut buffer = StreamBuffer::default();
110            open_frame(
111                &mut buffer.bytes,
112                page.ctm_at_start_of_stream(*stream),
113                &default_gs,
114            );
115            (*stream, buffer)
116        })
117        .collect();
118
119    let mut used: BTreeMap<String, BTreeSet<Name>> = BTreeMap::new();
120    used.entry("ExtGState".to_owned())
121        .or_default()
122        .insert(default_gs.clone());
123
124    for object in &page.objects {
125        if !object.is_active() {
126            continue;
127        }
128        // Every active object's resources are recorded, not just those of the
129        // objects being written: an object in a stream this run leaves alone
130        // still refers to its font, and sweeping that font away would break a
131        // stream nobody asked to change.
132        //
133        // The two cases differ in whether a *name may be minted*. An object
134        // being written needs one and gets one; an object in a clean stream
135        // refers to its resource by whatever name that stream already spells,
136        // so it can only record the name the dictionary already holds. Minting
137        // one for it would add an entry nothing refers to.
138        let writing = buffers.contains_key(&object.content_stream());
139        let names = realize_for(object, &mut table, &mut used, writing);
140        let Some(buffer) = buffers.get_mut(&object.content_stream()) else {
141            continue;
142        };
143        let marks = std::mem::take(&mut buffer.marks);
144        let mut body = String::new();
145        let open = emit_mark_diff(&mut body, &marks, object.marks(), &|_| None);
146        if crate::content::emit::emit_object(&mut body, object, &names) {
147            buffer.bytes.push_str(&body);
148            buffer.open_marks = open;
149            buffer.marks = object.marks().clone();
150            buffer.wrote_something = true;
151        } else {
152            // The object could not be expressed, so its marks were not opened
153            // either: leave the buffer's mark state where it was.
154            buffer.marks = marks;
155        }
156    }
157
158    let streams = buffers
159        .into_iter()
160        .map(|(stream, buffer)| close_frame(page, stream, buffer))
161        .collect();
162
163    table.sweep(&used);
164    Some(PageRewrite {
165        streams,
166        resources: table.to_dict(resources),
167    })
168}
169
170/// One stream's bytes plus the mark state the next object diffs against.
171#[derive(Debug, Default)]
172struct StreamBuffer {
173    bytes: String,
174    marks: pdfrum_page::ContentMarks,
175    open_marks: usize,
176    wrote_something: bool,
177}
178
179/// The per-stream prologue: save, undo the inherited transform, state every
180/// default.
181fn open_frame(out: &mut String, inherited: Affine, default_gs: &Name) {
182    out.push_str("q\n");
183    if inherited != Affine::IDENTITY {
184        write_matrix(out, inherited.inverse());
185        out.push_str(" cm\n");
186    }
187    out.push_str(DEFAULT_GRAPHICS);
188    out.push('/');
189    out.push_str(&String::from_utf8_lossy(&pdfrum_object::name_encode(
190        default_gs.as_bytes(),
191    )));
192    out.push_str(" gs ");
193}
194
195/// The per-stream epilogue, and the decision whether the element survives.
196fn close_frame(page: &Page, stream: Option<usize>, mut buffer: StreamBuffer) -> Regenerated {
197    let affects_ctm = stream_affects_ctm(page, stream);
198
199    // A stream that drew nothing and passes nothing on is deleted. One that
200    // drew nothing but still moves the transform keeps its whole frame,
201    // because the streams after it are relying on the move.
202    if !buffer.wrote_something && !affects_ctm {
203        return Regenerated {
204            stream,
205            bytes: String::new(),
206        };
207    }
208
209    if buffer.wrote_something {
210        finish_marks(&mut buffer.bytes, buffer.open_marks);
211    }
212    buffer.bytes.push_str("Q\n");
213
214    // `affects_ctm` is false for a streamless element, so this only runs
215    // where `stream` is a real index.
216    if let Some(index) = stream.filter(|_| affects_ctm) {
217        let previous = previous_ctm(page, index);
218        let difference = previous.inverse() * page.ctm_at_end_of_stream(index);
219        if difference != Affine::IDENTITY {
220            write_matrix(&mut buffer.bytes, difference);
221            buffer.bytes.push_str(" cm\n");
222        }
223    }
224
225    Regenerated {
226        stream,
227        bytes: buffer.bytes,
228    }
229}
230
231/// The transform in force where `stream` began, as the epilogue reckons it.
232fn previous_ctm(page: &Page, stream: usize) -> Affine {
233    if stream == 0 {
234        Affine::IDENTITY
235    } else {
236        page.ctm_at_end_of_stream(stream.saturating_sub(1))
237    }
238}
239
240/// Whether rewriting `stream` changes what the streams after it inherit.
241///
242/// A streamless element is appended after everything, so nothing follows it
243/// and it can never affect anything.
244fn stream_affects_ctm(page: &Page, stream: Option<usize>) -> bool {
245    let Some(stream) = stream else {
246        return false;
247    };
248    previous_ctm(page, stream) != page.ctm_at_end_of_stream(stream)
249}
250
251/// Record — and, when `writing`, allocate — the resource names one object
252/// needs.
253fn realize_for(
254    object: &PageObject,
255    table: &mut ResourceTable,
256    used: &mut BTreeMap<String, BTreeSet<Name>>,
257    writing: bool,
258) -> ResourceNames {
259    let mut names = ResourceNames::default();
260
261    if let Some(key) = GraphicsKey::of(object.state()) {
262        let dict = key.to_dict();
263        let name = if writing {
264            Some(table.realize_dict("ExtGState", &dict))
265        } else {
266            table.name_of_dict("ExtGState", &dict)
267        };
268        names.ext_gstate = record(used, "ExtGState", name);
269    }
270
271    match object {
272        PageObject::Text(text) => {
273            if let Some(source) = text.object.font_source {
274                let name = named(table, "Font", source, writing);
275                names.font = record(used, "Font", name);
276            }
277        }
278        PageObject::Image(image) => {
279            if let Some(source) = image.object.source {
280                let name = named(table, "XObject", source, writing);
281                names.xobject = record(used, "XObject", name);
282            }
283        }
284        PageObject::Form(form) => {
285            if let Some(source) = form.object.source {
286                let name = named(table, "XObject", source, writing);
287                names.xobject = record(used, "XObject", name);
288            }
289        }
290        // A path needs no named resource beyond its graphics state, and a
291        // shading object is never written at all.
292        PageObject::Path(_) | PageObject::Shading(_) => {}
293    }
294    names
295}
296
297/// The name `source` goes by in `category`, minting one only when the stream
298/// naming it is being written.
299fn named(table: &mut ResourceTable, category: &str, source: ObjRef, writing: bool) -> Option<Name> {
300    if writing {
301        Some(table.realize(category, source))
302    } else {
303        table.name_of(category, source)
304    }
305}
306
307/// Note `name` as still in use, and hand it back.
308fn record(
309    used: &mut BTreeMap<String, BTreeSet<Name>>,
310    category: &str,
311    name: Option<Name>,
312) -> Option<Name> {
313    let name = name?;
314    used.entry(category.to_owned())
315        .or_default()
316        .insert(name.clone());
317    Some(name)
318}
319
320/// Where a regenerated element lands in the page's `/Contents`.
321///
322/// The `/Contents` entry is a stream, an array of streams, or absent, and
323/// adding or removing an element moves it between those shapes. The rules are
324/// not symmetric, which is the point of naming them:
325///
326/// - **absent** gaining an element becomes a lone stream at index 0;
327/// - **a lone stream** gaining a second becomes an array `[old new]`, and the
328///   new one is index 1;
329/// - **an array** gaining one appends, at `len - 1`;
330/// - **a lone stream** losing index 0 loses the `/Contents` key entirely;
331/// - **an array** losing elements keeps being an array — *even down to one
332///   element*, and even down to none. Collapsing a one-element array back to a
333///   bare stream would be tidier and is deliberately not done: a second stream
334///   may well be added next, and every object's recorded index would have to
335///   move again.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum ContentsShape {
338    /// No `/Contents` at all.
339    Absent,
340    /// One stream, reached through `/Contents` directly.
341    Single(ObjRef),
342    /// An array of stream references.
343    Array(Vec<ObjRef>),
344}
345
346impl ContentsShape {
347    /// Read the shape out of a page dictionary.
348    ///
349    /// Anything that is neither a stream nor an array of streams — a dangling
350    /// reference, a number, a name — reads as [`ContentsShape::Absent`], which
351    /// is how a page with unusable contents behaves everywhere else.
352    #[must_use]
353    pub fn read(page_dict: &Dict, r: &impl Resolve) -> Self {
354        let Some(contents) = page_dict.raw(pdfrum_object::names::CONTENTS) else {
355            return Self::Absent;
356        };
357        // An array may be written inline or reached through a reference.
358        let direct = match contents {
359            Object::Ref(reference) => match r.fetch(*reference) {
360                Ok(object) => (*object).clone(),
361                Err(_) => return Self::Absent,
362            },
363            other => other.clone(),
364        };
365        match direct {
366            Object::Stream(_) => match contents {
367                Object::Ref(reference) => Self::Single(*reference),
368                // A stream written inline in the page dictionary is not
369                // something a PDF can express, so there is nothing to name.
370                _ => Self::Absent,
371            },
372            Object::Array(array) => {
373                Self::Array(array.iter().filter_map(Object::as_ref_id).collect())
374            }
375            _ => Self::Absent,
376        }
377    }
378
379    /// The index a newly added element takes, and the shape afterwards.
380    #[must_use]
381    pub fn with_added(&self, added: ObjRef) -> (usize, Self) {
382        match self {
383            Self::Absent => (0, Self::Single(added)),
384            Self::Single(existing) => (1, Self::Array(vec![*existing, added])),
385            Self::Array(elements) => {
386                let mut next = elements.clone();
387                next.push(added);
388                (next.len().saturating_sub(1), Self::Array(next))
389            }
390        }
391    }
392
393    /// The shape after `removed` elements are dropped, and the map from each
394    /// surviving element's old index to its new one.
395    ///
396    /// Every object whose index is *not* in the map — one whose own element
397    /// was removed, and one that was still streamless — collapses to index 0.
398    /// That is the C++'s default-inserting map read literally, and it is
399    /// deliberate: those objects were not written by this regeneration and
400    /// their recorded index has to point somewhere.
401    ///
402    /// Both halves of the map are `usize`: a `/Contents` index is a position
403    /// in an array, and there is no negative sentinel.
404    #[must_use]
405    pub fn with_removed(&self, removed: &BTreeSet<usize>) -> (Self, BTreeMap<usize, usize>) {
406        match self {
407            Self::Absent => (Self::Absent, BTreeMap::new()),
408            Self::Single(_) => {
409                let shape = if removed.contains(&0) {
410                    // The whole key goes, rather than becoming an empty
411                    // stream.
412                    Self::Absent
413                } else {
414                    self.clone()
415                };
416                (shape, BTreeMap::new())
417            }
418            Self::Array(elements) => {
419                let mut mapping = BTreeMap::new();
420                let mut kept = Vec::new();
421                for (old, element) in elements.iter().enumerate() {
422                    if removed.contains(&old) {
423                        continue;
424                    }
425                    let new = kept.len();
426                    kept.push(*element);
427                    mapping.insert(old, new);
428                }
429                // Still an array, whatever is left of it.
430                (Self::Array(kept), mapping)
431            }
432        }
433    }
434
435    /// The `/Contents` value this shape writes, given the object number a
436    /// fresh array would take.
437    ///
438    /// `None` for [`ContentsShape::Absent`], which removes the key.
439    #[must_use]
440    pub fn to_object(&self, array_ref: Option<ObjRef>) -> Option<Object> {
441        match self {
442            Self::Absent => None,
443            Self::Single(reference) => Some(Object::Ref(*reference)),
444            Self::Array(elements) => {
445                let array = pdfrum_object::Array::of(elements.iter().map(|e| Object::Ref(*e)));
446                Some(match array_ref {
447                    Some(reference) => {
448                        let _ = &array;
449                        Object::Ref(reference)
450                    }
451                    None => Object::Array(array),
452                })
453            }
454        }
455    }
456
457    /// The element references, in order.
458    #[must_use]
459    pub fn elements(&self) -> Vec<ObjRef> {
460        match self {
461            Self::Absent => Vec::new(),
462            Self::Single(reference) => vec![*reference],
463            Self::Array(elements) => elements.clone(),
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    #![expect(
471        clippy::indexing_slicing,
472        reason = "test fixtures index collections whose length the fixture fixes"
473    )]
474
475    use super::{ContentsShape, Regenerated, regenerate};
476    use pdfrum_common::kurbo::{Affine, BezPath};
477    use pdfrum_object::{Dict, Name, NoResolve, ObjRef, Object};
478    use pdfrum_page::{Content, FillRule, Page, PageObject, PathObject};
479    use pdfrum_page::{ContentMarks, GraphicsState};
480    use std::collections::{BTreeMap, BTreeSet};
481
482    fn path(stream: usize, dirty: bool) -> PageObject {
483        let mut p = BezPath::new();
484        p.move_to((0.0, 0.0));
485        p.line_to((1.0, 0.0));
486        p.line_to((1.0, 1.0));
487        p.close_path();
488        PageObject::Path(Box::new(Content {
489            object: PathObject {
490                path: p,
491                matrix: Affine::IDENTITY,
492                fill_rule: FillRule::Winding,
493                stroke: false,
494            },
495            state: GraphicsState::default(),
496            marks: ContentMarks::new(),
497            content_stream: Some(stream),
498            dirty,
499            active: true,
500        }))
501    }
502
503    fn page_of(objects: Vec<PageObject>) -> Page {
504        Page {
505            objects,
506            ..Page::empty()
507        }
508    }
509
510    // `GenerateContent` (:336-338) — the early-out. An untouched page is not
511    // rewritten at all, which is what keeps an ordinary save byte-identical.
512    #[test]
513    fn an_untouched_page_regenerates_nothing() {
514        let page = page_of(vec![path(0, false), path(0, false)]);
515        assert!(regenerate(&page, &Dict::new(), &NoResolve).is_none());
516    }
517
518    // The per-stream frame, verbatim.
519    #[test]
520    fn a_dirty_stream_gets_the_whole_frame() {
521        let page = page_of(vec![path(0, true)]);
522        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
523        assert_eq!(rewrite.streams.len(), 1);
524        let bytes = &rewrite.streams[0].bytes;
525        assert!(
526            bytes.starts_with("q\n0 0 0 RG 0 0 0 rg 1 w 0 J 0 j\n/FXE1 gs "),
527            "got {bytes}"
528        );
529        assert!(bytes.ends_with("Q\n"), "got {bytes}");
530        // The default graphics state is in the resources, under the name the
531        // prologue used.
532        let Some(Object::Dict(gs)) = rewrite.resources.raw(&Name::from("ExtGState")) else {
533            panic!("no ExtGState");
534        };
535        assert!(gs.contains_key(&Name::from("FXE1")));
536    }
537
538    // A clean stream beside a dirty one is left alone entirely.
539    #[test]
540    fn only_dirty_streams_are_written() {
541        let page = page_of(vec![path(0, false), path(1, true)]);
542        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
543        assert_eq!(rewrite.streams.len(), 1);
544        assert_eq!(rewrite.streams[0].stream, Some(1));
545    }
546
547    // An object in a dirty stream is written even though it is itself clean —
548    // the stream is rewritten whole, so everything in it has to be restated.
549    #[test]
550    fn a_clean_object_in_a_dirty_stream_is_still_written() {
551        let mut page = page_of(vec![path(0, false), path(0, true)]);
552        page.objects[0].mark_clean();
553        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
554        let bytes = &rewrite.streams[0].bytes;
555        assert_eq!(bytes.matches(" f Q\n").count(), 2, "got {bytes}");
556    }
557
558    // `SetIsActive` (fpdf_editpage_embeddertest.cpp:490): an inactive object's
559    // stream is regenerated *without* it, which is how it disappears.
560    #[test]
561    fn an_inactive_object_is_left_out_of_its_regenerated_stream() {
562        let mut page = page_of(vec![path(0, false), path(0, false)]);
563        page.objects[1].set_active(false);
564        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
565        let bytes = &rewrite.streams[0].bytes;
566        assert_eq!(bytes.matches(" f Q\n").count(), 1, "got {bytes}");
567    }
568
569    // `Bug378120423` (fpdf_editpage_embeddertest.cpp:563): deactivating the
570    // only object empties the stream, and an empty stream is a deletion.
571    #[test]
572    fn a_stream_left_with_nothing_comes_back_empty() {
573        let mut page = page_of(vec![path(0, false)]);
574        page.objects[0].set_active(false);
575        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
576        assert_eq!(
577            rewrite.streams,
578            vec![Regenerated {
579                stream: Some(0),
580                bytes: String::new()
581            }]
582        );
583    }
584
585    // The removal case: nothing is left to name the stream, so the page's own
586    // set is what says it must be written.
587    #[test]
588    fn a_removal_regenerates_the_stream_it_emptied() {
589        let mut page = page_of(vec![path(0, false), path(1, false)]);
590        assert!(page.remove_object(1).is_some());
591        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
592        assert_eq!(rewrite.streams.len(), 1);
593        assert_eq!(rewrite.streams[0].stream, Some(1));
594        assert!(rewrite.streams[0].bytes.is_empty());
595    }
596
597    // A stream inheriting a transform undoes it, and restates what it passes
598    // on. Both `cm`s, or element 1 draws in the wrong place.
599    #[test]
600    fn an_inherited_transform_is_undone_and_restated() {
601        let mut page = page_of(vec![path(1, true)]);
602        page.stream_ctms.insert(0, Affine::scale(2.0));
603        page.stream_ctms.insert(1, Affine::scale(2.0));
604        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
605        let bytes = &rewrite.streams[0].bytes;
606        // The inverse of the scale, undone at the top.
607        assert!(bytes.starts_with("q\n.5 0 0 .5 0 0 cm\n"), "got {bytes}");
608        // Stream 1 ends where it began, so it passes nothing on.
609        assert!(bytes.ends_with("Q\n"), "got {bytes}");
610    }
611
612    #[test]
613    fn a_stream_that_moves_the_transform_restates_the_move() {
614        let mut page = page_of(vec![path(0, true)]);
615        page.stream_ctms.insert(0, Affine::scale(3.0));
616        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
617        let bytes = &rewrite.streams[0].bytes;
618        assert!(
619            bytes.starts_with("q\n"),
620            "no inverse: stream 0 inherits none"
621        );
622        assert!(bytes.ends_with("Q\n3 0 0 3 0 0 cm\n"), "got {bytes}");
623    }
624
625    // An empty stream that still moves the transform keeps its frame: deleting
626    // it would move everything after it.
627    #[test]
628    fn an_empty_stream_that_moves_the_transform_survives() {
629        let mut page = page_of(vec![path(1, true)]);
630        page.dirty_streams.insert(Some(0));
631        page.stream_ctms.insert(0, Affine::scale(2.0));
632        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
633        let zero = rewrite
634            .streams
635            .iter()
636            .find(|s| s.stream == Some(0))
637            .expect("stream 0");
638        assert!(!zero.bytes.is_empty(), "it moves the transform");
639        assert!(
640            zero.bytes.ends_with("2 0 0 2 0 0 cm\n"),
641            "got {}",
642            zero.bytes
643        );
644    }
645
646    // A streamless object sorts first (`None < Some(0)`), so a brand-new
647    // object is written before any existing stream is rewritten.
648    #[test]
649    fn a_streamless_object_is_written_first() {
650        let mut page = page_of(vec![path(0, true)]);
651        page.push_object(path(0, false));
652        let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
653        let order: Vec<Option<usize>> = rewrite.streams.iter().map(|s| s.stream).collect();
654        assert_eq!(order, vec![None, Some(0)]);
655    }
656
657    // ---- `/Contents` shape transitions (cpdf_pagecontentmanager.cpp) ----
658
659    #[test]
660    fn nothing_gaining_a_stream_becomes_a_lone_stream_at_zero() {
661        let (index, shape) = ContentsShape::Absent.with_added(ObjRef::new(5, 0));
662        assert_eq!(index, 0);
663        assert_eq!(shape, ContentsShape::Single(ObjRef::new(5, 0)));
664    }
665
666    #[test]
667    fn a_lone_stream_gaining_a_second_becomes_an_array_and_the_new_one_is_one() {
668        let shape = ContentsShape::Single(ObjRef::new(4, 0));
669        let (index, next) = shape.with_added(ObjRef::new(9, 0));
670        assert_eq!(index, 1);
671        assert_eq!(
672            next,
673            ContentsShape::Array(vec![ObjRef::new(4, 0), ObjRef::new(9, 0)])
674        );
675    }
676
677    #[test]
678    fn an_array_gaining_one_appends_at_the_end() {
679        let shape = ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(2, 0)]);
680        let (index, next) = shape.with_added(ObjRef::new(3, 0));
681        assert_eq!(index, 2);
682        assert_eq!(next.elements().len(), 3);
683    }
684
685    // A single stream losing index 0 loses the whole key.
686    #[test]
687    fn a_lone_stream_removed_leaves_no_contents_key_at_all() {
688        let shape = ContentsShape::Single(ObjRef::new(4, 0));
689        let (next, mapping) = shape.with_removed(&[0].into_iter().collect());
690        assert_eq!(next, ContentsShape::Absent);
691        assert!(mapping.is_empty());
692        assert_eq!(next.to_object(None), None);
693    }
694
695    // `RemoveAllFromStream` (fpdf_edit_embeddertest.cpp:2033): removing
696    // element 1 of three shifts element 2 down to 1.
697    #[test]
698    fn removing_an_array_element_shifts_the_ones_after_it_down() {
699        let shape = ContentsShape::Array(vec![
700            ObjRef::new(1, 0),
701            ObjRef::new(2, 0),
702            ObjRef::new(3, 0),
703        ]);
704        let (next, mapping) = shape.with_removed(&[1].into_iter().collect());
705        assert_eq!(
706            next,
707            ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(3, 0)])
708        );
709        assert_eq!(mapping, BTreeMap::from([(0, 0), (2, 1)]));
710    }
711
712    // An array stays an array even at one element, and even at none.
713    #[test]
714    fn an_array_is_never_collapsed_back_to_a_bare_stream() {
715        let shape = ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(2, 0)]);
716        let (next, _) = shape.with_removed(&[1].into_iter().collect());
717        assert!(matches!(next, ContentsShape::Array(ref e) if e.len() == 1));
718        let (empty, _) = next.with_removed(&[0].into_iter().collect());
719        assert_eq!(empty, ContentsShape::Array(Vec::new()));
720    }
721
722    #[test]
723    fn removing_several_elements_renumbers_the_survivors_in_one_pass() {
724        let shape = ContentsShape::Array((1..=5).map(|n| ObjRef::new(n, 0)).collect());
725        let removed: BTreeSet<usize> = [0, 3].into_iter().collect();
726        let (next, mapping) = shape.with_removed(&removed);
727        assert_eq!(next.elements().len(), 3);
728        assert_eq!(mapping, BTreeMap::from([(1, 0), (2, 1), (4, 2)]));
729    }
730
731    // Reading the shape out of a page dictionary.
732    #[test]
733    fn a_page_with_no_contents_reads_as_absent() {
734        assert_eq!(
735            ContentsShape::read(&Dict::new(), &NoResolve),
736            ContentsShape::Absent
737        );
738        // A `/Contents` naming something that is neither stream nor array is
739        // absent too.
740        let odd = Dict::from_pairs([(pdfrum_object::names::CONTENTS.clone(), Object::Int(7))]);
741        assert_eq!(ContentsShape::read(&odd, &NoResolve), ContentsShape::Absent);
742    }
743
744    #[test]
745    fn an_inline_array_of_references_reads_as_an_array() {
746        let array = pdfrum_object::Array::of([
747            Object::Ref(ObjRef::new(2, 0)),
748            Object::Ref(ObjRef::new(3, 0)),
749        ]);
750        let dict =
751            Dict::from_pairs([(pdfrum_object::names::CONTENTS.clone(), Object::Array(array))]);
752        assert_eq!(
753            ContentsShape::read(&dict, &NoResolve),
754            ContentsShape::Array(vec![ObjRef::new(2, 0), ObjRef::new(3, 0)])
755        );
756    }
757}