Skip to main content

pdfboss_write/
writer.rs

1//! The object-level PDF writer: numbered objects in, finished file bytes
2//! out. Handles the header, stream `/Length` bookkeeping, optional Flate
3//! compression, object streams, both cross-reference styles, the trailer
4//! and a deterministic `/ID`.
5//!
6//! Determinism contract: the same sequence of calls with the same options
7//! produces byte-identical output. Nothing here reads clocks or RNGs; the
8//! `/ID` derives from a SHA-256 of the emitted body.
9
10use std::io::Write;
11
12use flate2::write::ZlibEncoder;
13use flate2::Compression;
14use pdfboss_core::crypt::Sha256;
15use pdfboss_core::{block_on, Dict, Name, ObjRef, Object, Stream};
16
17use crate::error::{Error, Result};
18use crate::ser::{serialize_dict, serialize_object};
19use crate::sink::{AsyncByteSink, Immediate};
20
21/// Which cross-reference flavor `finish` emits.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum XrefStyle {
24    /// A classic `xref` table with a `trailer` dictionary (readable by
25    /// PDF 1.0-era consumers).
26    Table,
27    /// A cross-reference stream (`/Type /XRef`, PDF 1.5+), the compact
28    /// modern form.
29    #[default]
30    Stream,
31}
32
33/// Options governing file emission.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct WriteOptions {
36    /// Cross-reference flavor. Object streams require [`XrefStyle::Stream`].
37    pub xref: XrefStyle,
38    /// Flate-compress stream data that carries no filter of its own.
39    pub compress: bool,
40    /// Pack non-stream objects into object streams (only effective with
41    /// [`XrefStyle::Stream`]).
42    pub object_streams: bool,
43    /// PDF version written in the header.
44    pub version: (u8, u8),
45}
46
47impl Default for WriteOptions {
48    fn default() -> WriteOptions {
49        WriteOptions {
50            xref: XrefStyle::Stream,
51            compress: true,
52            object_streams: true,
53            version: (1, 7),
54        }
55    }
56}
57
58/// Accumulates numbered objects and serializes them into a complete PDF
59/// file. Objects are numbered in the order they are first claimed
60/// (`put`, `put_stream`, or `reserve`), starting at 1, generation 0.
61#[derive(Debug)]
62pub struct Writer {
63    options: WriteOptions,
64    slots: Vec<Slot>,
65    info: Option<ObjRef>,
66}
67
68/// One numbered object: reserved, or holding its body.
69#[derive(Debug)]
70enum Slot {
71    Reserved,
72    Filled(Object),
73}
74
75impl Writer {
76    /// Creates a writer with the given options.
77    pub fn new(options: WriteOptions) -> Writer {
78        Writer {
79            options,
80            slots: Vec::new(),
81            info: None,
82        }
83    }
84
85    /// Claims an object number now to be filled later — for cycles like
86    /// the page tree, where children point at a parent not yet built.
87    pub fn reserve(&mut self) -> ObjRef {
88        self.push(Slot::Reserved)
89    }
90
91    /// Adds a complete object and returns its reference.
92    pub fn put(&mut self, obj: Object) -> ObjRef {
93        self.push(Slot::Filled(obj))
94    }
95
96    /// Adds a stream object. `/Length` is computed on emission; when
97    /// [`WriteOptions::compress`] is set and `dict` names no `/Filter`,
98    /// the data is Flate-compressed and `/Filter /FlateDecode` added.
99    pub fn put_stream(&mut self, mut dict: Dict, data: Vec<u8>) -> ObjRef {
100        let data = compress_into(&mut dict, data, self.options.compress);
101        self.push(Slot::Filled(Object::Stream(Stream { dict, data })))
102    }
103
104    /// Adds a stream object without touching its filters — for data that
105    /// is already encoded (e.g. a JPEG passed through as `/DCTDecode`,
106    /// with `/Filter` set by the caller). `/Length` is still computed.
107    pub fn put_stream_raw(&mut self, dict: Dict, data: Vec<u8>) -> ObjRef {
108        self.push(Slot::Filled(Object::Stream(Stream { dict, data })))
109    }
110
111    /// Fills a previously [`reserve`](Writer::reserve)d object.
112    pub fn fill(&mut self, r: ObjRef, obj: Object) -> Result<()> {
113        if r.gen != 0 {
114            return Err(Error::Other(format!(
115                "cannot fill {} {} R: this writer only issues generation 0",
116                r.num, r.gen
117            )));
118        }
119        if r.num == 0 || r.num as usize > self.slots.len() {
120            return Err(Error::Other(format!(
121                "cannot fill {} 0 R: this writer never allocated that object number",
122                r.num
123            )));
124        }
125        let slot = &mut self.slots[r.num as usize - 1];
126        if matches!(slot, Slot::Filled(_)) {
127            return Err(Error::AlreadyFilled(r));
128        }
129        *slot = Slot::Filled(obj);
130        Ok(())
131    }
132
133    fn push(&mut self, slot: Slot) -> ObjRef {
134        self.slots.push(slot);
135        ObjRef {
136            num: self.slots.len() as u32,
137            gen: 0,
138        }
139    }
140
141    /// Registers the document information dictionary for the trailer.
142    pub fn set_info(&mut self, info: ObjRef) {
143        self.info = Some(info);
144    }
145
146    /// Serializes everything into a complete PDF file: header with binary
147    /// comment, all objects (packed into object streams where options
148    /// allow), the cross-reference, and the trailer with `root`, the
149    /// registered info dictionary, and a `/ID` pair derived from a
150    /// SHA-256 of the emitted body.
151    pub fn finish(self, root: ObjRef) -> Result<Vec<u8>> {
152        block_on(self.finish_into_with(root, Vec::new()))
153    }
154
155    /// [`Writer::finish`] streaming into a [`std::io::Write`]: the same
156    /// bytes, delivered in bounded chunks, so the whole file never sits in
157    /// one buffer. Unlike `finish`, an error can leave a prefix of the
158    /// file already written to `out`. No flush is performed.
159    pub fn finish_into(self, root: ObjRef, out: impl Write) -> Result<()> {
160        block_on(self.finish_into_with(root, Immediate(out)))?;
161        Ok(())
162    }
163
164    /// [`Writer::finish`] streaming into any [`AsyncByteSink`] — the
165    /// asynchronous twin of [`Writer::finish_into`], and the one emission
166    /// implementation all three finishes drive. Bytes arrive in bounded
167    /// chunks (per header, object and cross-reference section; a stream's
168    /// data is its own chunk). An error can leave a prefix of the file
169    /// already written. Hands the sink back unflushed.
170    pub async fn finish_into_with<S: AsyncByteSink>(self, root: ObjRef, sink: S) -> Result<S> {
171        let Writer {
172            options,
173            slots,
174            info,
175        } = self;
176        let mut bodies = Vec::with_capacity(slots.len());
177        for (index, slot) in slots.into_iter().enumerate() {
178            match slot {
179                Slot::Reserved => {
180                    return Err(Error::Unfilled(ObjRef {
181                        num: index as u32 + 1,
182                        gen: 0,
183                    }))
184                }
185                Slot::Filled(obj) => bodies.push(obj),
186            }
187        }
188        let mut emit = Emit::new(sink);
189        match options.xref {
190            XrefStyle::Table => emit_table(options, &bodies, root, info, &mut emit).await?,
191            XrefStyle::Stream => emit_stream(options, &bodies, root, info, &mut emit).await?,
192        }
193        Ok(emit.sink)
194    }
195}
196
197/// Counts and hashes every byte on its way to the sink: cross-reference
198/// offsets come from `count` and the `/ID` digest from `hasher`, so
199/// emission never needs the finished file in one buffer.
200struct Emit<S> {
201    sink: S,
202    count: usize,
203    hasher: Sha256,
204}
205
206impl<S: AsyncByteSink> Emit<S> {
207    fn new(sink: S) -> Emit<S> {
208        Emit {
209            sink,
210            count: 0,
211            hasher: Sha256::new(),
212        }
213    }
214
215    async fn write(&mut self, bytes: &[u8]) -> Result<()> {
216        self.hasher.update(bytes);
217        self.count += bytes.len();
218        self.sink.write_all(bytes).await
219    }
220
221    /// The `/ID` array at this point of emission: two identical 16-byte
222    /// strings from a SHA-256 of every byte written so far.
223    fn file_id(&self) -> Object {
224        let digest = self.hasher.clone().finalize();
225        let id = Object::String(digest[..16].to_vec());
226        Object::Array(vec![id.clone(), id])
227    }
228}
229
230/// Objects a single object stream may hold before the next one starts.
231const OBJSTM_CAPACITY: usize = 200;
232
233/// One cross-reference row for the stream flavor: a top-level object at a
234/// byte offset (type 1) or an object packed into an object stream (type 2).
235#[derive(Clone, Copy)]
236enum Row {
237    Top(u32),
238    Packed { container: u32, index: u16 },
239}
240
241/// Emits the classic-table flavor: bodies, `xref` table, `trailer`
242/// dictionary, `startxref` and `%%EOF`.
243async fn emit_table<S: AsyncByteSink>(
244    options: WriteOptions,
245    bodies: &[Object],
246    root: ObjRef,
247    info: Option<ObjRef>,
248    emit: &mut Emit<S>,
249) -> Result<()> {
250    let mut head = Vec::new();
251    write_header(&mut head, options.version);
252    emit.write(&head).await?;
253    let mut offsets = Vec::with_capacity(bodies.len());
254    for (index, body) in bodies.iter().enumerate() {
255        offsets.push(emit.count);
256        write_indirect(emit, index as u32 + 1, body).await?;
257    }
258    let id = emit.file_id();
259    let xref_off = emit.count;
260    let mut section = format!("xref\n0 {}\n", bodies.len() + 1).into_bytes();
261    section.extend_from_slice(b"0000000000 65535 f\r\n");
262    for offset in offsets {
263        let offset = table_offset(offset)?;
264        section.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
265    }
266    section.extend_from_slice(b"trailer\n");
267    let trailer = trailer_dict(bodies.len() as i64 + 1, root, info, id);
268    serialize_dict(&trailer, &mut section)?;
269    section.extend_from_slice(format!("\nstartxref\n{xref_off}\n%%EOF").as_bytes());
270    emit.write(&section).await
271}
272
273/// Emits the cross-reference-stream flavor: bodies (non-stream objects
274/// packed into object streams when the option is set), then a `/Type /XRef`
275/// stream as the last object, `startxref` and `%%EOF`. Object numbering is
276/// dense from 0 through the xref stream itself, so `/Index` is never
277/// needed.
278async fn emit_stream<S: AsyncByteSink>(
279    options: WriteOptions,
280    bodies: &[Object],
281    root: ObjRef,
282    info: Option<ObjRef>,
283    emit: &mut Emit<S>,
284) -> Result<()> {
285    let mut head = Vec::new();
286    write_header(&mut head, options.version);
287    emit.write(&head).await?;
288    let user_count = bodies.len() as u32;
289
290    let packed: Vec<u32> = if options.object_streams {
291        bodies
292            .iter()
293            .enumerate()
294            .filter(|(_, body)| !matches!(body, Object::Stream(_)))
295            .map(|(index, _)| index as u32 + 1)
296            .collect()
297    } else {
298        Vec::new()
299    };
300    let chunks: Vec<&[u32]> = packed.chunks(OBJSTM_CAPACITY).collect();
301
302    let mut rows: Vec<Row> = vec![Row::Top(0); bodies.len()];
303    for (c, chunk) in chunks.iter().enumerate() {
304        for (index, num) in chunk.iter().enumerate() {
305            rows[*num as usize - 1] = Row::Packed {
306                container: user_count + c as u32 + 1,
307                index: index as u16,
308            };
309        }
310    }
311
312    for (index, body) in bodies.iter().enumerate() {
313        if matches!(rows[index], Row::Packed { .. }) {
314            continue;
315        }
316        rows[index] = Row::Top(field_offset(emit.count)?);
317        write_indirect(emit, index as u32 + 1, body).await?;
318    }
319
320    let mut container_offsets = Vec::with_capacity(chunks.len());
321    for (c, chunk) in chunks.iter().enumerate() {
322        let pairs: Vec<(u32, &Object)> = chunk
323            .iter()
324            .map(|&num| (num, &bodies[num as usize - 1]))
325            .collect();
326        let container = build_objstm(&pairs, options.compress)?;
327        container_offsets.push(field_offset(emit.count)?);
328        write_indirect(emit, user_count + c as u32 + 1, &Object::Stream(container)).await?;
329    }
330
331    let xref_num = user_count + chunks.len() as u32 + 1;
332    let id = emit.file_id();
333    let xref_off = field_offset(emit.count)?;
334
335    let mut data = Vec::with_capacity(7 * (xref_num as usize + 1));
336    push_row(&mut data, 0, 0, 65535);
337    for row in rows {
338        match row {
339            Row::Top(offset) => push_row(&mut data, 1, offset, 0),
340            Row::Packed { container, index } => push_row(&mut data, 2, container, index),
341        }
342    }
343    for offset in container_offsets {
344        push_row(&mut data, 1, offset, 0);
345    }
346    push_row(&mut data, 1, xref_off, 0);
347
348    let mut dict = trailer_dict(xref_num as i64 + 1, root, info, id);
349    dict.insert(literal("Type"), Object::Name(literal("XRef")));
350    dict.insert(
351        literal("W"),
352        Object::Array(vec![Object::Int(1), Object::Int(4), Object::Int(2)]),
353    );
354    let data = compress_into(&mut dict, data, options.compress);
355    write_indirect(emit, xref_num, &Object::Stream(Stream { dict, data })).await?;
356    emit.write(format!("startxref\n{xref_off}\n%%EOF").as_bytes())
357        .await
358}
359
360/// `%PDF-M.m` plus the binary comment marking the file as 8-bit data.
361fn write_header(out: &mut Vec<u8>, version: (u8, u8)) {
362    out.extend_from_slice(format!("%PDF-{}.{}\n", version.0, version.1).as_bytes());
363    out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
364}
365
366/// Emits `num 0 obj` through `endobj`. Every top-level `Object::Stream` —
367/// however it entered the writer — is framed as a stream with a direct
368/// `/Length` of its stored byte count; everything else serializes through
369/// [`crate::ser`]. A stream's data goes to the sink as its own chunk,
370/// borrowed rather than copied; everything else is one chunk per object.
371async fn write_indirect<S: AsyncByteSink>(
372    emit: &mut Emit<S>,
373    num: u32,
374    obj: &Object,
375) -> Result<()> {
376    let mut lead = format!("{num} 0 obj\n").into_bytes();
377    match obj {
378        Object::Stream(s) => {
379            let mut dict = s.dict.clone();
380            dict.insert(literal("Length"), Object::Int(s.data.len() as i64));
381            serialize_dict(&dict, &mut lead)?;
382            lead.extend_from_slice(b"\nstream\n");
383            emit.write(&lead).await?;
384            emit.write(&s.data).await?;
385            emit.write(b"\nendstream\nendobj\n").await
386        }
387        direct => {
388            serialize_object(direct, &mut lead)?;
389            lead.extend_from_slice(b"\nendobj\n");
390            emit.write(&lead).await
391        }
392    }
393}
394
395/// Serializes one object-stream container from `(num, body)` pairs: `2·N`
396/// header integers, then the bodies, each followed by a space so adjacent
397/// tokens cannot fuse.
398fn build_objstm(pairs: &[(u32, &Object)], compress: bool) -> Result<Stream> {
399    let mut header = Vec::new();
400    let mut payload = Vec::new();
401    for (num, body) in pairs {
402        header.extend_from_slice(format!("{num} {} ", payload.len()).as_bytes());
403        serialize_object(body, &mut payload)?;
404        payload.push(b' ');
405    }
406    let mut dict = Dict::new();
407    dict.insert(literal("Type"), Object::Name(literal("ObjStm")));
408    dict.insert(literal("N"), Object::Int(pairs.len() as i64));
409    dict.insert(literal("First"), Object::Int(header.len() as i64));
410    header.extend_from_slice(&payload);
411    let data = compress_into(&mut dict, header, compress);
412    Ok(Stream { dict, data })
413}
414
415/// The shared trailer entries: `/Size`, `/Root`, the optional `/Info`, and
416/// the `/ID` pair.
417fn trailer_dict(size: i64, root: ObjRef, info: Option<ObjRef>, id: Object) -> Dict {
418    let mut trailer = Dict::new();
419    trailer.insert(literal("Size"), Object::Int(size));
420    trailer.insert(literal("Root"), Object::Ref(root));
421    if let Some(info) = info {
422        trailer.insert(literal("Info"), Object::Ref(info));
423    }
424    trailer.insert(literal("ID"), id);
425    trailer
426}
427
428/// Flate-compresses `data` and records `/Filter /FlateDecode` in `dict`
429/// when `compress` is set and the dictionary names no filter of its own;
430/// otherwise the data passes through untouched.
431fn compress_into(dict: &mut Dict, data: Vec<u8>, compress: bool) -> Vec<u8> {
432    if !compress || dict.get("Filter").is_some() {
433        return data;
434    }
435    dict.insert(literal("Filter"), Object::Name(literal("FlateDecode")));
436    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
437    encoder
438        .write_all(&data)
439        .expect("writing into a Vec cannot fail");
440    encoder
441        .finish()
442        .expect("finishing an in-memory zlib stream cannot fail")
443}
444
445/// One `[1 4 2]` cross-reference-stream row.
446fn push_row(rows: &mut Vec<u8>, kind: u8, second: u32, third: u16) {
447    rows.push(kind);
448    rows.extend_from_slice(&second.to_be_bytes());
449    rows.extend_from_slice(&third.to_be_bytes());
450}
451
452/// A byte position as the 4-byte offset field of the cross-reference.
453fn field_offset(position: usize) -> Result<u32> {
454    u32::try_from(position)
455        .map_err(|_| Error::Other("file offset exceeds the 4-byte xref field".to_string()))
456}
457
458/// A byte position as the 10-digit offset field of a classic xref table
459/// (ISO 32000-1 §7.5.4 mandates exactly-20-byte entries; a wider offset
460/// would silently desynchronize every later entry).
461fn table_offset(position: usize) -> Result<usize> {
462    if position as u64 <= 9_999_999_999 {
463        return Ok(position);
464    }
465    Err(Error::Other(
466        "file offset exceeds the 10-digit xref table field".to_string(),
467    ))
468}
469
470/// A `Name` from a string literal.
471fn literal(text: &str) -> Name {
472    Name(text.to_string())
473}
474
475#[cfg(test)]
476mod tests {
477    use pdfboss_core::xref::load_xref;
478    use pdfboss_core::{Dict, Document, Name, ObjRef, Object, Stream};
479
480    use super::*;
481    use crate::error::Error;
482
483    const CONTENT: &[u8] = b"BT /F1 12 Tf 72 720 Td (Hello, writer) Tj ET";
484
485    fn name(text: &str) -> Name {
486        Name(text.to_string())
487    }
488
489    fn table_options() -> WriteOptions {
490        WriteOptions {
491            xref: XrefStyle::Table,
492            compress: false,
493            object_streams: false,
494            version: (1, 7),
495        }
496    }
497
498    fn stream_options() -> WriteOptions {
499        WriteOptions {
500            xref: XrefStyle::Stream,
501            compress: false,
502            object_streams: false,
503            version: (1, 5),
504        }
505    }
506
507    fn objstm_options() -> WriteOptions {
508        WriteOptions {
509            xref: XrefStyle::Stream,
510            compress: true,
511            object_streams: true,
512            version: (1, 7),
513        }
514    }
515
516    struct Refs {
517        content: ObjRef,
518        pages: ObjRef,
519        page: ObjRef,
520        root: ObjRef,
521    }
522
523    fn page_dict(pages: ObjRef, content: ObjRef) -> Dict {
524        let mut page = Dict::new();
525        page.insert(name("Type"), Object::Name(name("Page")));
526        page.insert(name("Parent"), Object::Ref(pages));
527        page.insert(
528            name("MediaBox"),
529            Object::Array(vec![
530                Object::Int(0),
531                Object::Int(0),
532                Object::Int(612),
533                Object::Int(792),
534            ]),
535        );
536        page.insert(name("Contents"), Object::Ref(content));
537        page
538    }
539
540    /// Builds a one-page document around the given content stream without
541    /// finishing it, going through `put_stream_raw` when `raw` is set.
542    fn build(
543        options: WriteOptions,
544        content_dict: Dict,
545        content_data: Vec<u8>,
546        raw: bool,
547    ) -> (Writer, Refs) {
548        let mut w = Writer::new(options);
549        let content = if raw {
550            w.put_stream_raw(content_dict, content_data)
551        } else {
552            w.put_stream(content_dict, content_data)
553        };
554        let pages = w.reserve();
555        let page = w.put(Object::Dict(page_dict(pages, content)));
556        let mut tree = Dict::new();
557        tree.insert(name("Type"), Object::Name(name("Pages")));
558        tree.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
559        tree.insert(name("Count"), Object::Int(1));
560        w.fill(pages, Object::Dict(tree))
561            .expect("pages slot is fillable");
562        let mut catalog = Dict::new();
563        catalog.insert(name("Type"), Object::Name(name("Catalog")));
564        catalog.insert(name("Pages"), Object::Ref(pages));
565        let root = w.put(Object::Dict(catalog));
566        (
567            w,
568            Refs {
569                content,
570                pages,
571                page,
572                root,
573            },
574        )
575    }
576
577    /// [`build`], finished into bytes.
578    fn skeleton(
579        options: WriteOptions,
580        content_dict: Dict,
581        content_data: Vec<u8>,
582        raw: bool,
583    ) -> (Vec<u8>, Refs) {
584        let (w, refs) = build(options, content_dict, content_data, raw);
585        let bytes = w.finish(refs.root).expect("minimal document finishes");
586        (bytes, refs)
587    }
588
589    fn minimal_pdf(options: WriteOptions) -> (Vec<u8>, Refs) {
590        skeleton(options, Dict::new(), CONTENT.to_vec(), false)
591    }
592
593    fn assert_minimal_loads(bytes: &[u8], refs: &Refs) -> Document {
594        let doc = Document::load(bytes.to_vec()).expect("document loads");
595        assert_eq!(doc.page_count(), 1);
596        let page = doc.page(0).expect("page 0 exists");
597        assert_eq!(page.object_ref(), Some(refs.page));
598        assert_eq!(page.dict(), &page_dict(refs.pages, refs.content));
599        assert_eq!(page.content(&doc).expect("content decodes"), CONTENT);
600        doc
601    }
602
603    fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
604        haystack
605            .windows(needle.len())
606            .filter(|window| *window == needle)
607            .count()
608    }
609
610    #[test]
611    fn table_mode_minimal_document_loads() {
612        let (bytes, refs) = minimal_pdf(table_options());
613        assert!(bytes.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
614        assert!(bytes.ends_with(b"%%EOF"));
615        assert_eq!(count_occurrences(&bytes, b"xref\n0 5\n"), 1);
616        assert_eq!(count_occurrences(&bytes, b"0000000000 65535 f\r\n"), 1);
617        assert_eq!(count_occurrences(&bytes, b"trailer\n"), 1);
618        assert_minimal_loads(&bytes, &refs);
619    }
620
621    #[test]
622    fn table_mode_ignores_object_streams_option() {
623        let options = WriteOptions {
624            object_streams: true,
625            ..table_options()
626        };
627        let (bytes, refs) = minimal_pdf(options);
628        assert_eq!(count_occurrences(&bytes, b"/ObjStm"), 0);
629        assert_minimal_loads(&bytes, &refs);
630    }
631
632    #[test]
633    fn stream_mode_minimal_document_loads() {
634        let (bytes, refs) = minimal_pdf(stream_options());
635        assert!(bytes.starts_with(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n"));
636        assert!(bytes.ends_with(b"%%EOF"));
637        assert_eq!(count_occurrences(&bytes, b"/ObjStm"), 0);
638        assert_eq!(count_occurrences(&bytes, b"/XRef"), 1);
639        assert_minimal_loads(&bytes, &refs);
640    }
641
642    #[test]
643    fn object_streams_pack_and_resolve() {
644        let (bytes, refs) = minimal_pdf(objstm_options());
645        assert_eq!(count_occurrences(&bytes, b"/ObjStm"), 1);
646        let doc = assert_minimal_loads(&bytes, &refs);
647        let root = doc
648            .resolve(&Object::Ref(refs.root))
649            .expect("catalog resolves");
650        let catalog = root.as_dict().expect("catalog is a dictionary");
651        assert_eq!(catalog.get_name("Type"), Some(&name("Catalog")));
652        assert_eq!(catalog.get_ref("Pages"), Some(refs.pages));
653    }
654
655    #[test]
656    fn object_streams_chunk_at_two_hundred() {
657        let options = WriteOptions {
658            compress: false,
659            ..objstm_options()
660        };
661        let mut w = Writer::new(options);
662        let content = w.put_stream(Dict::new(), CONTENT.to_vec());
663        let int_refs: Vec<ObjRef> = (0..205).map(|i| w.put(Object::Int(i))).collect();
664        let pages = w.reserve();
665        let page = w.put(Object::Dict(page_dict(pages, content)));
666        let mut tree = Dict::new();
667        tree.insert(name("Type"), Object::Name(name("Pages")));
668        tree.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
669        tree.insert(name("Count"), Object::Int(1));
670        w.fill(pages, Object::Dict(tree))
671            .expect("pages slot is fillable");
672        let mut catalog = Dict::new();
673        catalog.insert(name("Type"), Object::Name(name("Catalog")));
674        catalog.insert(name("Pages"), Object::Ref(pages));
675        let root = w.put(Object::Dict(catalog));
676        let bytes = w.finish(root).expect("document finishes");
677        assert_eq!(count_occurrences(&bytes, b"/ObjStm"), 2);
678        let doc = Document::load(bytes).expect("document loads");
679        assert_eq!(
680            doc.resolve(&Object::Ref(int_refs[0])).expect("resolves"),
681            Object::Int(0)
682        );
683        assert_eq!(
684            doc.resolve(&Object::Ref(int_refs[204])).expect("resolves"),
685            Object::Int(204)
686        );
687        assert_eq!(doc.page_count(), 1);
688    }
689
690    #[test]
691    fn refs_ascend_from_one_in_call_order() {
692        let mut w = Writer::new(table_options());
693        assert_eq!(w.put(Object::Null), ObjRef { num: 1, gen: 0 });
694        assert_eq!(w.reserve(), ObjRef { num: 2, gen: 0 });
695        assert_eq!(
696            w.put_stream(Dict::new(), Vec::new()),
697            ObjRef { num: 3, gen: 0 }
698        );
699        assert_eq!(
700            w.put_stream_raw(Dict::new(), Vec::new()),
701            ObjRef { num: 4, gen: 0 }
702        );
703    }
704
705    #[test]
706    fn fill_twice_reports_already_filled() {
707        let mut w = Writer::new(table_options());
708        let r = w.reserve();
709        w.fill(r, Object::Int(1)).expect("first fill lands");
710        match w.fill(r, Object::Int(2)) {
711            Err(Error::AlreadyFilled(seen)) => assert_eq!(seen, r),
712            other => panic!("expected AlreadyFilled, got {other:?}"),
713        }
714    }
715
716    #[test]
717    fn fill_rejects_foreign_and_wrong_generation_refs() {
718        let mut w = Writer::new(table_options());
719        let r = w.reserve();
720        let unallocated = w.fill(ObjRef { num: 99, gen: 0 }, Object::Null);
721        assert!(matches!(unallocated, Err(Error::Other(msg)) if msg.contains("99")));
722        let zero = w.fill(ObjRef { num: 0, gen: 0 }, Object::Null);
723        assert!(matches!(zero, Err(Error::Other(msg)) if !msg.is_empty()));
724        let wrong_gen = w.fill(ObjRef { num: r.num, gen: 1 }, Object::Null);
725        assert!(matches!(wrong_gen, Err(Error::Other(msg)) if msg.contains("generation")));
726    }
727
728    #[test]
729    fn finish_with_unfilled_reserve_reports_the_ref() {
730        let mut w = Writer::new(table_options());
731        let root = w.put(Object::Dict(Dict::new()));
732        let reserved = w.reserve();
733        match w.finish(root) {
734            Err(Error::Unfilled(seen)) => assert_eq!(seen, reserved),
735            other => panic!("expected Unfilled, got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn nested_stream_surfaces_from_finish() {
741        for options in [table_options(), objstm_options()] {
742            let mut w = Writer::new(options);
743            let root = w.put(Object::Array(vec![Object::Stream(Stream {
744                dict: Dict::new(),
745                data: b"x".to_vec(),
746            })]));
747            assert!(matches!(w.finish(root), Err(Error::NestedStream)));
748        }
749    }
750
751    #[test]
752    fn compressed_stream_round_trips() {
753        let options = WriteOptions {
754            compress: true,
755            ..table_options()
756        };
757        let data: Vec<u8> = b"q 0.5 0 0 0.5 36 36 cm Q\n".repeat(40);
758        let (bytes, refs) = skeleton(options, Dict::new(), data.clone(), false);
759        let doc = Document::load(bytes).expect("document loads");
760        let resolved = doc
761            .resolve(&Object::Ref(refs.content))
762            .expect("content stream resolves");
763        let stream = resolved.as_stream().expect("content is a stream");
764        assert_eq!(stream.dict.get_name("Filter"), Some(&name("FlateDecode")));
765        assert_eq!(
766            stream.dict.get_int("Length"),
767            Some(stream.data.len() as i64)
768        );
769        assert!(stream.data.len() < data.len());
770        assert_eq!(doc.stream_data(stream).expect("stream decodes"), data);
771    }
772
773    #[test]
774    fn preset_filter_is_not_recompressed() {
775        let options = WriteOptions {
776            compress: true,
777            ..table_options()
778        };
779        let payload = b"Hello writer";
780        let encoded: Vec<u8> = payload
781            .iter()
782            .flat_map(|b| format!("{b:02X}").into_bytes())
783            .chain(*b">")
784            .collect();
785        let mut dict = Dict::new();
786        dict.insert(name("Filter"), Object::Name(name("ASCIIHexDecode")));
787        let (bytes, refs) = skeleton(options, dict, encoded.clone(), false);
788        let doc = Document::load(bytes).expect("document loads");
789        let resolved = doc
790            .resolve(&Object::Ref(refs.content))
791            .expect("content stream resolves");
792        let stream = resolved.as_stream().expect("content is a stream");
793        assert_eq!(stream.data, encoded, "pre-filtered data stays untouched");
794        assert_eq!(
795            stream.dict.get_name("Filter"),
796            Some(&name("ASCIIHexDecode"))
797        );
798        assert_eq!(doc.stream_data(stream).expect("stream decodes"), payload);
799    }
800
801    #[test]
802    fn put_stream_raw_never_compresses() {
803        let options = WriteOptions {
804            compress: true,
805            ..table_options()
806        };
807        let (bytes, refs) = skeleton(options, Dict::new(), CONTENT.to_vec(), true);
808        let doc = Document::load(bytes).expect("document loads");
809        let resolved = doc
810            .resolve(&Object::Ref(refs.content))
811            .expect("content stream resolves");
812        let stream = resolved.as_stream().expect("content is a stream");
813        assert_eq!(stream.data, CONTENT);
814        assert!(stream.dict.get("Filter").is_none());
815        assert_eq!(stream.dict.get_int("Length"), Some(CONTENT.len() as i64));
816    }
817
818    #[test]
819    fn table_offsets_past_ten_digits_are_rejected() {
820        assert_eq!(table_offset(9_999_999_999).ok(), Some(9_999_999_999));
821        assert!(table_offset(10_000_000_000).is_err());
822    }
823
824    #[test]
825    fn id_derives_from_the_emitted_content() {
826        let (a, refs) = minimal_pdf(table_options());
827        assert_eq!(refs.root.gen, 0);
828        let (b, other_refs) = skeleton(
829            table_options(),
830            Dict::new(),
831            b"BT /F1 12 Tf 72 720 Td (Hello, other) Tj ET".to_vec(),
832            false,
833        );
834        assert_eq!(other_refs.root.gen, 0);
835        let id_of = |bytes: &[u8]| {
836            let xref = load_xref(bytes).expect("xref loads");
837            let id = xref.trailer.get_array("ID").expect("/ID array present");
838            id[0]
839                .as_str_bytes()
840                .expect("/ID entry is a string")
841                .to_vec()
842        };
843        assert_ne!(
844            id_of(&a),
845            id_of(&b),
846            "/ID must depend on the emitted content"
847        );
848    }
849
850    #[test]
851    fn id_pair_is_present_and_identical() {
852        for options in [table_options(), stream_options(), objstm_options()] {
853            let (bytes, refs) = minimal_pdf(options);
854            assert_eq!(refs.root.gen, 0);
855            let xref = load_xref(&bytes).expect("xref loads");
856            let id = xref.trailer.get_array("ID").expect("/ID array present");
857            assert_eq!(id.len(), 2);
858            let first = id[0].as_str_bytes().expect("/ID entry is a string");
859            let second = id[1].as_str_bytes().expect("/ID entry is a string");
860            assert_eq!(first.len(), 16);
861            assert_eq!(first, second);
862        }
863    }
864
865    #[test]
866    fn output_is_deterministic() {
867        for options in [table_options(), stream_options(), objstm_options()] {
868            let (first, refs) = minimal_pdf(options);
869            let (second, again) = minimal_pdf(options);
870            assert_eq!(refs.content, again.content);
871            assert_eq!(first, second, "options {options:?} must be deterministic");
872        }
873    }
874
875    #[test]
876    fn finish_into_matches_finish() {
877        for options in [table_options(), stream_options(), objstm_options()] {
878            let (bytes, _) = minimal_pdf(options);
879            let (w, refs) = build(options, Dict::new(), CONTENT.to_vec(), false);
880            let mut out = Vec::new();
881            w.finish_into(refs.root, &mut out)
882                .expect("finish_into succeeds");
883            assert_eq!(out, bytes, "options {options:?}");
884        }
885    }
886
887    #[test]
888    fn finish_into_with_matches_finish() {
889        for options in [table_options(), stream_options(), objstm_options()] {
890            let (bytes, _) = minimal_pdf(options);
891            let (w, refs) = build(options, Dict::new(), CONTENT.to_vec(), false);
892            let sink = pdfboss_core::block_on(w.finish_into_with(refs.root, Vec::new()))
893                .expect("finish_into_with succeeds");
894            assert_eq!(sink, bytes, "options {options:?}");
895        }
896    }
897
898    /// Records every chunk it is handed, so tests can see how emission
899    /// arrives — the write happens eagerly, the future is already complete.
900    struct Recording {
901        chunks: Vec<Vec<u8>>,
902    }
903
904    impl crate::sink::AsyncByteSink for Recording {
905        fn write_all<'a>(
906            &'a mut self,
907            buf: &'a [u8],
908        ) -> pdfboss_core::source::BoxFuture<'a, Result<()>> {
909            self.chunks.push(buf.to_vec());
910            Box::pin(std::future::ready(Ok(())))
911        }
912    }
913
914    /// Emission must actually stream: many bounded chunks, never one
915    /// whole-file buffer — and their concatenation must be the `finish`
916    /// bytes exactly.
917    #[test]
918    fn emission_arrives_in_bounded_chunks() {
919        for options in [table_options(), stream_options(), objstm_options()] {
920            let (bytes, _) = minimal_pdf(options);
921            let (w, refs) = build(options, Dict::new(), CONTENT.to_vec(), false);
922            let sink = pdfboss_core::block_on(
923                w.finish_into_with(refs.root, Recording { chunks: Vec::new() }),
924            )
925            .expect("finish_into_with succeeds");
926            assert_eq!(sink.chunks.concat(), bytes, "options {options:?}");
927            assert!(
928                sink.chunks.len() > 3,
929                "options {options:?}: emission must arrive in many chunks, got {}",
930                sink.chunks.len()
931            );
932            assert!(
933                sink.chunks.iter().all(|chunk| chunk.len() < bytes.len()),
934                "options {options:?}: no chunk may be the whole file"
935            );
936        }
937    }
938
939    /// The emission future over an owned sink must be `Send + 'static`,
940    /// so it can cross a runtime's `spawn` — the write-side counterpart of
941    /// the source module's by-value rule.
942    #[test]
943    fn finish_into_with_over_an_owned_sink_is_spawnable() {
944        fn assert_send_static<F: std::future::Future + Send + 'static>(_: &F) {}
945
946        let (w, refs) = build(stream_options(), Dict::new(), CONTENT.to_vec(), false);
947        let future = w.finish_into_with(refs.root, Vec::new());
948        assert_send_static(&future);
949        let bytes = pdfboss_core::block_on(future).expect("emission succeeds");
950        assert!(bytes.ends_with(b"%%EOF"));
951    }
952
953    /// An unfilled reserve must surface from the streaming finishes too,
954    /// before any byte reaches the sink.
955    #[test]
956    fn finish_into_with_reports_unfilled_reserves() {
957        let mut w = Writer::new(table_options());
958        let root = w.put(Object::Dict(Dict::new()));
959        let reserved = w.reserve();
960        let sink = Recording { chunks: Vec::new() };
961        match pdfboss_core::block_on(w.finish_into_with(root, sink)) {
962            Err(Error::Unfilled(seen)) => assert_eq!(seen, reserved),
963            other => panic!("expected Unfilled, got {:?}", other.map(|s| s.chunks.len())),
964        }
965    }
966}