oxideav-pdf 0.1.4

Pure-Rust PDF writer for the oxideav framework — vector-stays-vector path
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
//! Linearized PDF writer (ISO 32000-1 §7.5.6 + Annex F).
//!
//! "Fast Web View" — a structural reorganisation that lets a PDF
//! viewer render page 1 without first downloading the entire file.
//! The on-wire form is a strict superset of standard PDF: linearized
//! files start with a linearization parameter dictionary, a first-page
//! cross-reference section, the document catalog, the first-page
//! section, a primary hint stream, the remaining pages, and finally a
//! main cross-reference table at the end. A reader that ignores
//! `/Linearized` still sees a valid PDF; one that recognises it can
//! stream the first page from the head of the file.
//!
//! # Layout (Annex F.3)
//!
//! ```text
//! Part 1:  %PDF-1.5\n + binary marker        (header)
//! Part 2:  <lin param dict>                  (first object body)
//! Part 3:  first-page xref + trailer         (with /Prev → main xref)
//! Part 4:  Catalog + document-level objects  (Info, Pages tree)
//! Part 5:  Primary hint stream               (page offset hint table)
//! Part 6:  First-page section                (page object + resources + contents)
//! Part 7:  Remaining pages                   (pages 2..N if N > 1)
//! Part 11: Main xref + trailer               (referenced by startxref)
//! ```
//!
//! Hint streams (Part 5) follow Tables F.3 / F.4 — we emit the
//! mandatory page offset hint table with full per-page entries
//! (round 13: items 1, 2, 6, 7 — object count, page length, content
//! stream offset relative to page start, content stream length).
//! Shared object hints (F.4.2), thumbnails (F.4.3), and the various
//! generic hint tables (F.4.4 / F.4.5 / F.4.6) are emitted as empty
//! headers because we generate no shared objects / thumbnails /
//! outlines / threads / named destinations.
//!
//! # Two-pass emission
//!
//! Several values in the linearization parameter dictionary depend on
//! the final byte layout (`/L` file length, `/E` end-of-first-page,
//! `/H` hint stream byte range, `/T` main xref offset). The first-page
//! trailer's `/Prev` similarly points at the main xref offset. We use
//! a two-pass scheme: emit each part with placeholder values padded to
//! a 10-digit fixed width, record their byte positions, then patch
//! the placeholders in-place once every offset is known. PDF integers
//! accept leading zeros, so 10-digit padding is on-spec.

use std::io::Write;

use oxideav_scene::Scene;

use crate::error::PdfError;
use crate::info::{build_info_dict, has_metadata};
use crate::objects::{Dict, Document, IndirectObject, Object, ObjectId, Stream};
use crate::resources::ResourceCollector;
use crate::writer::render_frame_for_linearize;

/// Render a [`Scene`] in pages mode as a Linearized PDF 1.5 document
/// per ISO 32000-1 §7.5.6 + Annex F (Fast Web View). The output is a
/// strict superset of plain PDF: a viewer that ignores `/Linearized`
/// still sees a valid Catalog + Pages tree + page content, just
/// without the streaming optimisation.
pub fn write_pdf_linearized(scene: &Scene) -> Result<Vec<u8>, PdfError> {
    let pages = scene
        .pages
        .as_ref()
        .filter(|p| !p.is_empty())
        .ok_or_else(|| {
            PdfError::other(
                "write_pdf_linearized: scene is not in pages mode (scene.pages is None or empty)",
            )
        })?;

    // ---- Render every page's content + resources -------------------
    let rendered: Vec<RenderedOwned> = pages
        .iter()
        .map(|page| {
            let (content_bytes, resources) = render_frame_for_linearize(&page.content);
            RenderedOwned {
                width: page.width,
                height: page.height,
                content_bytes,
                resources,
            }
        })
        .collect();

    let n_pages = rendered.len();

    // ---- Pre-flatten resource sub-objects --------------------------
    // Each page's ResourceCollector may add gradient / image
    // sub-objects to a Document. We need to know how many up front
    // so the id allocator can lay out all ids before any byte is
    // written. Use a fresh Document per page so each page's extras
    // get ids starting at 1 (predictable seed for the remap step).
    let mut owned_pages: Vec<OwnedPage> = Vec::with_capacity(n_pages);
    for r in &rendered {
        let mut sub_doc = Document::new();
        sub_doc.set_next_id(1);
        let res_obj = r.resources.flatten_into_resources_dict(&mut sub_doc);
        let extras = crate::objects::take_objects(&mut sub_doc);
        owned_pages.push(OwnedPage {
            width: r.width,
            height: r.height,
            content_bytes: r.content_bytes.clone(),
            resources_dict: res_obj,
            extra_objects: extras,
        });
    }

    // ---- Allocate object ids per Annex F.3.1 -----------------------
    // Group 2 (pages 2..N): one (page, resources, contents) triple
    // each, plus their per-page resource extras. Group 1 starts at
    // the next id.
    let mut next_id_n = 1u32;
    let mut alloc = || {
        let id = ObjectId::new(next_id_n);
        next_id_n += 1;
        id
    };

    let mut g2_page_ids = Vec::with_capacity(n_pages.saturating_sub(1));
    let mut g2_resources_ids = Vec::with_capacity(n_pages.saturating_sub(1));
    let mut g2_contents_ids = Vec::with_capacity(n_pages.saturating_sub(1));
    // Per-page extras get fresh ids in the same group; track them per
    // page so we can stitch the references together below.
    let mut g2_extra_ids: Vec<Vec<ObjectId>> = Vec::with_capacity(n_pages.saturating_sub(1));
    for owned in owned_pages.iter().skip(1) {
        g2_page_ids.push(alloc());
        g2_resources_ids.push(alloc());
        g2_contents_ids.push(alloc());
        let mut extras = Vec::with_capacity(owned.extra_objects.len());
        for _ in 0..owned.extra_objects.len() {
            extras.push(alloc());
        }
        g2_extra_ids.push(extras);
    }

    // Group 1
    let catalog_id = alloc();
    let pages_tree_id = alloc();
    let info_id_opt = if has_metadata(&scene.metadata) {
        Some(alloc())
    } else {
        None
    };
    let lin_param_id = alloc();
    let first_page_id = alloc();
    let first_page_resources_id = alloc();
    let first_page_contents_id = alloc();
    let mut first_page_extra_ids = Vec::with_capacity(owned_pages[0].extra_objects.len());
    for _ in 0..owned_pages[0].extra_objects.len() {
        first_page_extra_ids.push(alloc());
    }
    let hint_stream_id = alloc();
    let total_ids = next_id_n; // ids in use are 1..total_ids; /Size = total_ids

    // ---- Re-target references inside resource dicts ---------------
    // Each page's resource dict refers to its extras by their
    // placeholder ids (assigned 1.. by the per-page throwaway
    // Document). Remap the placeholders to the final allocation.
    let first_page_owned = remap_owned_page(&owned_pages[0], &first_page_extra_ids);
    let mut g2_owned: Vec<OwnedPageRemapped> = Vec::with_capacity(n_pages.saturating_sub(1));
    for (i, p) in owned_pages.iter().enumerate().skip(1) {
        g2_owned.push(remap_owned_page(p, &g2_extra_ids[i - 1]));
    }

    // Build the Pages tree's /Kids array.
    let mut kids: Vec<Object> = Vec::with_capacity(n_pages);
    kids.push(Object::Reference(first_page_id));
    for id in &g2_page_ids {
        kids.push(Object::Reference(*id));
    }

    // ---- Pass 1: emit the layout with placeholder values -----------
    let mut out = Vec::with_capacity(8192);

    // Part 1: header
    out.extend_from_slice(b"%PDF-1.5\n");
    out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");

    // Part 2: linearization parameter dict
    let lin_param_off = out.len() as u64;
    write!(
        &mut out,
        "{} 0 obj\n<< /Linearized 1 /L {:010} /H [ {:010} {:010} ] /O {} /E {:010} /N {} /T {:010} >>\nendobj\n",
        lin_param_id.number,
        0u64,
        0u64,
        0u64,
        first_page_id.number,
        0u64,
        n_pages,
        0u64,
    )
    .map_err(|e| PdfError::other(format!("linearize lin-dict format: {e}")))?;

    // Part 3: first-page xref + trailer
    let first_xref_off = out.len() as u64;
    let first_group_start = catalog_id.number;
    let first_group_count = total_ids - first_group_start;

    out.extend_from_slice(b"xref\n");
    writeln!(&mut out, "{} {}", first_group_start, first_group_count)
        .map_err(|e| PdfError::other(format!("linearize first-xref header: {e}")))?;
    let first_xref_entries_off = out.len() as u64;
    for _ in 0..first_group_count {
        out.extend_from_slice(b"0000000000 00000 n \n");
    }

    out.extend_from_slice(b"trailer\n");
    // Build trailer dict but emit /Prev with a 10-digit zero placeholder
    // by hand so we can patch it post-hoc without changing layout.
    write_first_page_trailer_dict(
        &mut out,
        total_ids,
        catalog_id,
        info_id_opt,
        /*prev_placeholder*/ 0,
    )?;
    let prev_patch_off = first_trailer_prev_offset(&out, first_xref_off as usize);
    out.extend_from_slice(b"\nstartxref\n");
    let first_startxref_value_off = out.len() as u64;
    out.extend_from_slice(b"0000000000\n%%EOF\n");

    // Part 4: Catalog
    let catalog_off = out.len() as u64;
    write_indirect(
        &mut out,
        &IndirectObject {
            id: catalog_id,
            object: Object::Dict(
                Dict::new()
                    .with("Type", Object::Name("Catalog".into()))
                    .with("Pages", Object::Reference(pages_tree_id)),
            ),
        },
    )?;

    // Pages tree
    let pages_tree_off = out.len() as u64;
    write_indirect(
        &mut out,
        &IndirectObject {
            id: pages_tree_id,
            object: Object::Dict(
                Dict::new()
                    .with("Type", Object::Name("Pages".into()))
                    .with("Kids", Object::Array(kids))
                    .with("Count", Object::Integer(n_pages as i64)),
            ),
        },
    )?;

    // /Info
    let info_off_opt = if let Some(info_id) = info_id_opt {
        let off = out.len() as u64;
        write_indirect(
            &mut out,
            &IndirectObject {
                id: info_id,
                object: Object::Dict(build_info_dict(&scene.metadata)),
            },
        )?;
        Some(off)
    } else {
        None
    };

    // Part 5: hint stream — page-offset table (F.4.1) followed by
    // empty-shape shared-object (F.4.2) + thumbnail (F.4.3) +
    // outline (F.4.4) tables. The page-offset table now carries
    // genuine per-page entries (round 13): items 1, 2, 6, 7 (object
    // count, page length, content offset relative to page start,
    // content stream length). Bit widths are pinned at 32 bits so the
    // hint stream's byte size is deterministic from `n_pages` alone.
    //
    // Per-page object counts ARE known at this point (they depend on
    // the resource extras we already pre-flattened above), so we
    // populate item 1 immediately. Items 2, 6, 7 plus the header
    // "least *" placeholders are filled with zero now and patched in
    // pass 2 once every page's byte offset is known.
    let mut objects_per_page: Vec<u32> = Vec::with_capacity(n_pages);
    objects_per_page.push(3 + first_page_owned.extra_objects.len() as u32);
    for owned in &g2_owned {
        objects_per_page.push(3 + owned.extra_objects.len() as u32);
    }
    let least_obj_per_page = *objects_per_page.iter().min().unwrap_or(&0);

    let hint_stream_off = out.len() as u64;
    let mut page_offset_table = build_page_offset_hint_table(least_obj_per_page);
    // Offset of the page-offset table's per-page section so we can
    // patch byte-position values without re-scanning.
    let per_page_section_off = page_offset_table.len();
    // Reserve per-page bytes: items 1, 2, 6, 7 each fill 4 bytes per
    // page (32-bit fixed width); item 3 collapses to 0 bits.
    let per_page_section_size = n_pages * 16;
    page_offset_table.resize(per_page_section_off + per_page_section_size, 0);
    // Pre-fill item 1 (object count per page) — known up front. The
    // encoded value is `count - least_obj_per_page` per F.4 item 1
    // (a delta added to item 1 of Table F.3 to recover the count).
    for (i, &count) in objects_per_page.iter().enumerate() {
        let delta = count - least_obj_per_page;
        let off = per_page_section_off + i * 4;
        page_offset_table[off..off + 4].copy_from_slice(&delta.to_be_bytes());
    }
    let shared_object_table = build_shared_object_hint_table();
    let thumbnail_table = build_thumbnail_hint_table();
    let outline_table = build_outline_hint_table();
    // Offsets within the decoded hint stream — these are what /S, /T
    // and /O point at (Annex F.3.6 + F.4.x section headers).
    let s_off = page_offset_table.len();
    let t_off = s_off + shared_object_table.len();
    let o_off = t_off + thumbnail_table.len();
    let mut hint_data = Vec::with_capacity(o_off + outline_table.len());
    hint_data.extend_from_slice(&page_offset_table);
    hint_data.extend_from_slice(&shared_object_table);
    hint_data.extend_from_slice(&thumbnail_table);
    hint_data.extend_from_slice(&outline_table);
    let hint_dict = Dict::new()
        // /S = byte offset of shared-object hint table (F.4.2).
        .with("S", Object::Integer(s_off as i64))
        // /T = byte offset of thumbnail hint table (F.4.3).
        .with("T", Object::Integer(t_off as i64))
        // /O = byte offset of outline hint table (F.4.4).
        .with("O", Object::Integer(o_off as i64));
    // Record where the hint-stream data starts inside the indirect
    // object body so we can patch placeholder values in pass 2.
    let hint_obj_start = out.len();
    write_indirect(
        &mut out,
        &IndirectObject {
            id: hint_stream_id,
            object: Object::Stream(Stream::new(hint_dict, hint_data)),
        },
    )?;
    let hint_stream_end = out.len() as u64;
    let hint_stream_total_len = hint_stream_end - hint_stream_off;
    // Locate the byte offset of the hint stream's payload start
    // (just past `stream\n`). The Stream serializer always emits
    // `<dict>\nstream\n<data>\nendstream` — find the `stream\n`
    // anchor inside the hint indirect-object body.
    let hint_payload_off = {
        let needle = b"\nstream\n";
        let pos = out[hint_obj_start..]
            .windows(needle.len())
            .position(|w| w == needle)
            .ok_or_else(|| PdfError::other("linearize: hint stream payload not found"))?;
        hint_obj_start + pos + needle.len()
    };

    // Part 6: first-page section
    let first_page_off = out.len() as u64;
    write_indirect(
        &mut out,
        &IndirectObject {
            id: first_page_id,
            object: Object::Dict(
                Dict::new()
                    .with("Type", Object::Name("Page".into()))
                    .with("Parent", Object::Reference(pages_tree_id))
                    .with(
                        "MediaBox",
                        Object::Array(vec![
                            Object::Real(0.0),
                            Object::Real(0.0),
                            Object::Real(first_page_owned.width as f64),
                            Object::Real(first_page_owned.height as f64),
                        ]),
                    )
                    .with("Resources", Object::Reference(first_page_resources_id))
                    .with("Contents", Object::Reference(first_page_contents_id)),
            ),
        },
    )?;

    let first_resources_off = out.len() as u64;
    write_indirect(
        &mut out,
        &IndirectObject {
            id: first_page_resources_id,
            object: first_page_owned.resources_dict.clone(),
        },
    )?;

    // First-page resource extras
    let mut first_extra_offs: Vec<u64> = Vec::with_capacity(first_page_owned.extra_objects.len());
    for (i, body) in first_page_owned.extra_objects.iter().enumerate() {
        first_extra_offs.push(out.len() as u64);
        write_indirect(
            &mut out,
            &IndirectObject {
                id: first_page_extra_ids[i],
                object: body.clone(),
            },
        )?;
    }

    let first_contents_off = out.len() as u64;
    write_indirect(
        &mut out,
        &IndirectObject {
            id: first_page_contents_id,
            object: Object::Stream(Stream::new(
                Dict::new(),
                first_page_owned.content_bytes.clone(),
            )),
        },
    )?;

    // /E — end of first-page section.
    let end_of_first_page = out.len() as u64;

    // Part 7: remaining pages
    let mut g2_page_offs = Vec::with_capacity(g2_owned.len());
    let mut g2_resources_offs = Vec::with_capacity(g2_owned.len());
    let mut g2_contents_offs = Vec::with_capacity(g2_owned.len());
    let mut g2_extra_offs: Vec<Vec<u64>> = Vec::with_capacity(g2_owned.len());

    for (idx, owned) in g2_owned.iter().enumerate() {
        g2_page_offs.push(out.len() as u64);
        write_indirect(
            &mut out,
            &IndirectObject {
                id: g2_page_ids[idx],
                object: Object::Dict(
                    Dict::new()
                        .with("Type", Object::Name("Page".into()))
                        .with("Parent", Object::Reference(pages_tree_id))
                        .with(
                            "MediaBox",
                            Object::Array(vec![
                                Object::Real(0.0),
                                Object::Real(0.0),
                                Object::Real(owned.width as f64),
                                Object::Real(owned.height as f64),
                            ]),
                        )
                        .with("Resources", Object::Reference(g2_resources_ids[idx]))
                        .with("Contents", Object::Reference(g2_contents_ids[idx])),
                ),
            },
        )?;

        g2_resources_offs.push(out.len() as u64);
        write_indirect(
            &mut out,
            &IndirectObject {
                id: g2_resources_ids[idx],
                object: owned.resources_dict.clone(),
            },
        )?;

        let mut extras_offs = Vec::with_capacity(owned.extra_objects.len());
        for (i, body) in owned.extra_objects.iter().enumerate() {
            extras_offs.push(out.len() as u64);
            write_indirect(
                &mut out,
                &IndirectObject {
                    id: g2_extra_ids[idx][i],
                    object: body.clone(),
                },
            )?;
        }
        g2_extra_offs.push(extras_offs);

        g2_contents_offs.push(out.len() as u64);
        write_indirect(
            &mut out,
            &IndirectObject {
                id: g2_contents_ids[idx],
                object: Object::Stream(Stream::new(Dict::new(), owned.content_bytes.clone())),
            },
        )?;
    }

    // ---- Per-page metrics for the page-offset hint table ----------
    // Compute page_length (item 2) and content stream offset/length
    // (items 6, 8) per page. For each page, page_end = offset of the
    // very next byte after the page's last object. For pages that
    // have a successor page in the byte stream, that's just the next
    // page's page-object offset; for the final page it's the start
    // of the main xref section.
    let mut page_starts: Vec<u64> = Vec::with_capacity(n_pages);
    let mut content_starts: Vec<u64> = Vec::with_capacity(n_pages);
    page_starts.push(first_page_off);
    content_starts.push(first_contents_off);
    for (i, _) in g2_owned.iter().enumerate() {
        page_starts.push(g2_page_offs[i]);
        content_starts.push(g2_contents_offs[i]);
    }
    // page_end[i] = page_starts[i+1] for i < n_pages-1, else main_xref_off
    let mut page_ends: Vec<u64> = Vec::with_capacity(n_pages);
    // First page's end is the explicit end-of-first-page marker — the
    // first-page section is laid out contiguously, so end_of_first_page
    // equals page_starts[1] when n_pages > 1.
    page_ends.push(end_of_first_page);
    for i in 1..n_pages {
        let end = if i + 1 < n_pages {
            page_starts[i + 1]
        } else {
            // Last page — its tail extends up to (but not including)
            // the main xref section we're about to emit.
            out.len() as u64
        };
        page_ends.push(end);
    }
    // content_end[i] = same logic (content stream is the last object
    // on the page, so its end coincides with page_end).
    let content_ends: Vec<u64> = page_ends.clone();

    let page_lengths: Vec<u32> = (0..n_pages)
        .map(|i| (page_ends[i] - page_starts[i]) as u32)
        .collect();
    let content_offsets: Vec<u32> = (0..n_pages)
        .map(|i| (content_starts[i] - page_starts[i]) as u32)
        .collect();
    let content_lengths: Vec<u32> = (0..n_pages)
        .map(|i| (content_ends[i] - content_starts[i]) as u32)
        .collect();

    // Item 4 / 6 / 8 header values are the per-document minima.
    let least_page_length = *page_lengths.iter().min().unwrap_or(&0);
    let least_content_off = *content_offsets.iter().min().unwrap_or(&0);
    let least_content_len = *content_lengths.iter().min().unwrap_or(&0);

    // Patch the page-offset hint table in place. The hint stream
    // payload starts at hint_payload_off; the page-offset table is
    // the first table inside it.
    // Item 2 (32-bit): location of first page's page object — at
    // header offset 4..8.
    let hdr = hint_payload_off;
    out[hdr + 4..hdr + 8].copy_from_slice(&(first_page_off as u32).to_be_bytes());
    // Item 4 (32-bit): least page length — at header offset 10..14.
    out[hdr + 10..hdr + 14].copy_from_slice(&least_page_length.to_be_bytes());
    // Item 6 (32-bit): least content stream offset — at header offset 16..20.
    out[hdr + 16..hdr + 20].copy_from_slice(&least_content_off.to_be_bytes());
    // Item 8 (32-bit): least content stream length — at header offset 22..26.
    out[hdr + 22..hdr + 26].copy_from_slice(&least_content_len.to_be_bytes());

    // Patch per-page section. Layout (each item is 4 bytes per page):
    //   block A (item 1, object count): pages 0..N — already filled
    //   block B (item 2, page length delta): pages 0..N
    //   block C (item 6, content offset delta): pages 0..N
    //   block D (item 7, content length delta): pages 0..N
    let pp_off = hdr + per_page_section_off;
    let block_size = n_pages * 4;
    // Block B: page length deltas (item 2 of per-page entry, sized by
    // header item 5). Each value = page_lengths[i] - least_page_length.
    for (i, len) in page_lengths.iter().enumerate() {
        let delta = len - least_page_length;
        let off = pp_off + block_size + i * 4;
        out[off..off + 4].copy_from_slice(&delta.to_be_bytes());
    }
    // Block C: content offset deltas (item 6 of per-page entry).
    for (i, co) in content_offsets.iter().enumerate() {
        let delta = co - least_content_off;
        let off = pp_off + 2 * block_size + i * 4;
        out[off..off + 4].copy_from_slice(&delta.to_be_bytes());
    }
    // Block D: content length deltas (item 7 of per-page entry).
    for (i, cl) in content_lengths.iter().enumerate() {
        let delta = cl - least_content_len;
        let off = pp_off + 3 * block_size + i * 4;
        out[off..off + 4].copy_from_slice(&delta.to_be_bytes());
    }

    // Part 11: main xref + trailer
    let main_xref_off = out.len() as u64;
    out.extend_from_slice(b"xref\n");
    writeln!(&mut out, "0 {}", total_ids)
        .map_err(|e| PdfError::other(format!("linearize main-xref header: {e}")))?;
    out.extend_from_slice(b"0000000000 65535 f \n");

    // Build a flat id → offset table.
    let mut all_offs: Vec<u64> = vec![0; total_ids as usize];
    for (i, page_id) in g2_page_ids.iter().enumerate() {
        all_offs[page_id.number as usize] = g2_page_offs[i];
        all_offs[g2_resources_ids[i].number as usize] = g2_resources_offs[i];
        all_offs[g2_contents_ids[i].number as usize] = g2_contents_offs[i];
        for (j, off) in g2_extra_offs[i].iter().enumerate() {
            all_offs[g2_extra_ids[i][j].number as usize] = *off;
        }
    }
    all_offs[catalog_id.number as usize] = catalog_off;
    all_offs[pages_tree_id.number as usize] = pages_tree_off;
    if let (Some(info_id), Some(off)) = (info_id_opt, info_off_opt) {
        all_offs[info_id.number as usize] = off;
    }
    all_offs[lin_param_id.number as usize] = lin_param_off;
    all_offs[first_page_id.number as usize] = first_page_off;
    all_offs[first_page_resources_id.number as usize] = first_resources_off;
    all_offs[first_page_contents_id.number as usize] = first_contents_off;
    for (i, off) in first_extra_offs.iter().enumerate() {
        all_offs[first_page_extra_ids[i].number as usize] = *off;
    }
    all_offs[hint_stream_id.number as usize] = hint_stream_off;

    for id in 1..total_ids {
        let off = all_offs[id as usize];
        writeln!(&mut out, "{:010} {:05} n ", off, 0)
            .map_err(|e| PdfError::other(format!("linearize main-xref entry: {e}")))?;
    }

    out.extend_from_slice(b"trailer\n");
    let mut main_trailer = Dict::new()
        .with("Size", Object::Integer(total_ids as i64))
        .with("Root", Object::Reference(catalog_id));
    if let Some(info_id) = info_id_opt {
        main_trailer.set("Info", Object::Reference(info_id));
    }
    let mut main_trailer_bytes = Vec::new();
    write_object_to_vec(&mut main_trailer_bytes, &Object::Dict(main_trailer))?;
    out.extend_from_slice(&main_trailer_bytes);
    out.extend_from_slice(b"\nstartxref\n");
    writeln!(&mut out, "{}", first_xref_off)
        .map_err(|e| PdfError::other(format!("linearize startxref: {e}")))?;
    out.extend_from_slice(b"%%EOF\n");

    let total_file_length = out.len() as u64;

    // ---- Pass 2: patch placeholder values --------------------------
    // /L, /H[0], /H[1], /E, /T in the linearization param dict
    patch_padded_int(&mut out, lin_param_off as usize, b"/L ", total_file_length)?;
    patch_padded_int(&mut out, lin_param_off as usize, b"/H [ ", hint_stream_off)?;
    {
        // /H's second integer immediately follows the first (10 digits + 1 space)
        let anchor = b"/H [ ";
        let pos = find_anchor(&out, lin_param_off as usize, anchor)?;
        let after_first = pos + anchor.len() + 11; // 10 digits + 1 space
        write_padded_at(&mut out, after_first, hint_stream_total_len)?;
    }
    patch_padded_int(&mut out, lin_param_off as usize, b"/E ", end_of_first_page)?;
    patch_padded_int(&mut out, lin_param_off as usize, b"/T ", main_xref_off)?;

    // /Prev in the first-page trailer (also 10-digit padded)
    write_padded_at(&mut out, prev_patch_off, main_xref_off)?;

    // First-page startxref (points at first-page xref offset itself)
    write_padded_at(&mut out, first_startxref_value_off as usize, first_xref_off)?;

    // First-page xref entries
    {
        let mut entry_off = first_xref_entries_off as usize;
        for id in first_group_start..(first_group_start + first_group_count) {
            let off = all_offs[id as usize];
            let line = format!("{:010} {:05} n \n", off, 0);
            debug_assert_eq!(line.len(), 20);
            out[entry_off..entry_off + 20].copy_from_slice(line.as_bytes());
            entry_off += 20;
        }
    }

    Ok(out)
}

// ---- Internal helpers ------------------------------------------------

struct RenderedOwned {
    width: f32,
    height: f32,
    content_bytes: Vec<u8>,
    resources: ResourceCollector,
}

struct OwnedPage {
    width: f32,
    height: f32,
    content_bytes: Vec<u8>,
    /// The flattened /Resources dict (an [`Object::Dict`]). It refers
    /// to extras by [`ObjectId`] — those ids are seeded by the
    /// throwaway Document used during pre-flattening; we remap them
    /// to the final ids in [`remap_owned_page`].
    resources_dict: Object,
    /// Sub-objects (IndirectObject = id + body) allocated by
    /// [`ResourceCollector`] — gradient streams, image XObjects,
    /// function dicts. Their `id.number` values are placeholders and
    /// get rewritten in [`remap_owned_page`] too.
    extra_objects: Vec<IndirectObject>,
}

struct OwnedPageRemapped {
    width: f32,
    height: f32,
    content_bytes: Vec<u8>,
    resources_dict: Object,
    /// Bodies only — the final ids are tracked separately via the
    /// per-page id arrays passed to [`remap_owned_page`].
    extra_objects: Vec<Object>,
}

/// Walk `page` and rewrite every reference whose target matches a
/// placeholder id (the id assigned by the throwaway Document during
/// pre-flatten) to the matching `final_ids[i]`. The mapping is
/// established by the position of each extra in `page.extra_objects`
/// — extras[i] had placeholder id `extras[i].id`, and the desired
/// final id is `final_ids[i]`.
fn remap_owned_page(page: &OwnedPage, final_ids: &[ObjectId]) -> OwnedPageRemapped {
    use std::collections::HashMap;
    let placeholder_to_final: HashMap<u32, ObjectId> = page
        .extra_objects
        .iter()
        .enumerate()
        .map(|(i, ind)| (ind.id.number, final_ids[i]))
        .collect();
    let mut resources_dict = page.resources_dict.clone();
    remap_object(&mut resources_dict, &placeholder_to_final);
    let extras: Vec<Object> = page
        .extra_objects
        .iter()
        .map(|ind| {
            let mut body = ind.object.clone();
            remap_object(&mut body, &placeholder_to_final);
            body
        })
        .collect();
    OwnedPageRemapped {
        width: page.width,
        height: page.height,
        content_bytes: page.content_bytes.clone(),
        resources_dict,
        extra_objects: extras,
    }
}

fn remap_object(obj: &mut Object, map: &std::collections::HashMap<u32, ObjectId>) {
    match obj {
        Object::Reference(id) => {
            if let Some(&new_id) = map.get(&id.number) {
                *id = new_id;
            }
        }
        Object::Array(items) => {
            for it in items {
                remap_object(it, map);
            }
        }
        Object::Dict(d) => {
            let entries = d.entries().to_vec();
            *d = Dict::new();
            for (k, mut v) in entries {
                remap_object(&mut v, map);
                d.set(&k, v);
            }
        }
        Object::Stream(s) => {
            let entries = s.dict.entries().to_vec();
            s.dict = Dict::new();
            for (k, mut v) in entries {
                remap_object(&mut v, map);
                s.dict.set(&k, v);
            }
        }
        _ => {}
    }
}

fn write_indirect(out: &mut Vec<u8>, ind: &IndirectObject) -> Result<(), PdfError> {
    writeln!(out, "{} {} obj", ind.id.number, ind.id.generation)
        .map_err(|e| PdfError::other(format!("write_indirect: {e}")))?;
    write_object_to_vec(out, &ind.object)?;
    out.extend_from_slice(b"\nendobj\n");
    Ok(())
}

fn write_object_to_vec(out: &mut Vec<u8>, obj: &Object) -> Result<(), PdfError> {
    crate::objects::write_object_to(out, obj).map_err(PdfError::Io)
}

/// Emit the first-page trailer dict with an explicit 10-digit /Prev
/// placeholder. We hand-roll this rather than use `write_object`
/// because the Object tree's Integer printer would emit `0`, not
/// `0000000000`, and `write_object` doesn't expose the formatter.
fn write_first_page_trailer_dict(
    out: &mut Vec<u8>,
    size: u32,
    root: ObjectId,
    info: Option<ObjectId>,
    prev_placeholder: u64,
) -> Result<(), PdfError> {
    write!(out, "<< /Size {} /Root {} 0 R", size, root.number)
        .map_err(|e| PdfError::other(format!("first-trailer dict: {e}")))?;
    if let Some(info_id) = info {
        write!(out, " /Info {} 0 R", info_id.number)
            .map_err(|e| PdfError::other(format!("first-trailer dict: {e}")))?;
    }
    write!(out, " /Prev {:010} >>", prev_placeholder)
        .map_err(|e| PdfError::other(format!("first-trailer dict: {e}")))?;
    Ok(())
}

/// Locate the byte offset of the first digit of `/Prev <int>` inside
/// the first-page trailer dict, scanning from `first_xref_section_off`
/// onwards.
fn first_trailer_prev_offset(out: &[u8], first_xref_section_off: usize) -> usize {
    let needle = b"/Prev ";
    let pos = out[first_xref_section_off..]
        .windows(needle.len())
        .position(|w| w == needle)
        .expect("first-page trailer must carry /Prev");
    first_xref_section_off + pos + needle.len()
}

/// Scan `hay[start..]` for `anchor`. Returns the byte position of
/// `anchor` (relative to `hay[0]`).
fn find_anchor(hay: &[u8], start: usize, anchor: &[u8]) -> Result<usize, PdfError> {
    hay[start..]
        .windows(anchor.len())
        .position(|w| w == anchor)
        .map(|p| start + p)
        .ok_or_else(|| {
            PdfError::other(format!(
                "linearize patch: anchor `{}` not found",
                String::from_utf8_lossy(anchor)
            ))
        })
}

/// Patch a 10-digit zero-padded integer that follows `anchor` in
/// `out[start..]` to `value`.
fn patch_padded_int(
    out: &mut [u8],
    start: usize,
    anchor: &[u8],
    value: u64,
) -> Result<(), PdfError> {
    let anchor_pos = out[start..]
        .windows(anchor.len())
        .position(|w| w == anchor)
        .ok_or_else(|| {
            PdfError::other(format!(
                "linearize patch: anchor `{}` not found",
                String::from_utf8_lossy(anchor)
            ))
        })?;
    write_padded_at(out, start + anchor_pos + anchor.len(), value)
}

fn write_padded_at(out: &mut [u8], at: usize, value: u64) -> Result<(), PdfError> {
    if value > 9_999_999_999 {
        return Err(PdfError::other(format!(
            "linearize patch: value {} exceeds 10-digit width",
            value
        )));
    }
    let s = format!("{:010}", value);
    if at + s.len() > out.len() {
        return Err(PdfError::other("linearize patch: write past EOF"));
    }
    out[at..at + s.len()].copy_from_slice(s.as_bytes());
    Ok(())
}

/// Build the shared-object hint table per Table F.5 (Annex F.4.2).
/// We emit the 24-byte header only; the entry section is empty
/// because the Pages tree we generate has no shared objects (every
/// page's resources / contents live in their own indirect objects,
/// per the round-9 [`Page`] flatten). All fields are zero except
/// "object number of first object in shared objects section" which
/// is reported as zero too — the reader treats a zero
/// "shared-object count" as "no shared objects to inspect."
///
/// Header field widths (bits): 32 + 32 + 32 + 32 + 16 + 32 + 16
/// = 192 bits = 24 bytes.
fn build_shared_object_hint_table() -> Vec<u8> {
    let mut buf = Vec::with_capacity(24);
    // Item 1 (32): object number of first object in shared section = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 2 (32): location of first object in shared section = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 3 (32): shared object entries for first page (incl. non-shared) = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 4 (32): shared object entries for shared section = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 5 (16): bits-needed for greatest number of objects per group = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 6 (32): least length in bytes of any shared object group = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 7 (16): bits-needed for greatest-vs-least group length = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    debug_assert_eq!(buf.len(), 24);
    buf
}

/// Build the thumbnail hint table per Table F.6 (Annex F.4.3). Same
/// minimum-information shape as the shared-object header — we
/// generate no thumbnails so the entry counts are all zero. Header
/// field widths (bits): 32 + 32 + 16 + 32 + 16 + 32 + 16 + 32 + 16
/// = 224 bits = 28 bytes.
fn build_thumbnail_hint_table() -> Vec<u8> {
    let mut buf = Vec::with_capacity(28);
    // Item 1 (32): least-thumbnail object number = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 2 (32): location of first thumbnail = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 3 (16): bits-needed for thumbnail count delta = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 4 (32): least thumbnail length in bytes = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 5 (16): bits-needed for thumbnail length delta = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 6 (32): least width of thumbnail in samples = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 7 (16): bits-needed for thumbnail width delta = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 8 (32): least height of thumbnail in samples = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 9 (16): bits-needed for thumbnail height delta = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    debug_assert_eq!(buf.len(), 28);
    buf
}

/// Build the outline hint table per Table F.7 (Annex F.4.4). Same
/// minimum-information shape — we generate no outlines. Header
/// widths (bits): 32 + 32 + 32 + 16 = 112 bits = 14 bytes.
fn build_outline_hint_table() -> Vec<u8> {
    let mut buf = Vec::with_capacity(14);
    // Item 1 (32): least outline object number = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 2 (32): location of first outline = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 3 (32): least number of outline items = 0
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 4 (16): bits-needed for outline-count delta = 0
    buf.extend_from_slice(&0u16.to_be_bytes());
    debug_assert_eq!(buf.len(), 14);
    buf
}

/// Build the page offset hint table header per Table F.3.
///
/// Round-13 pins the bits-needed fields for items 1, 2, 6, 7 at 32
/// bits so the per-page section stays at a deterministic 16 × n_pages
/// bytes — caller appends the per-page section after this header.
/// The "least *" placeholders (items 2, 4, 6, 8) start as zeros and
/// get patched once page byte offsets are known. Item 1 (least
/// objects per page) IS known up front and is filled here.
///
/// Header field widths (bits): 32 + 32 + 16 + 32 + 16 + 32 + 16 + 32
/// + 16 + 16 + 16 + 16 + 16 = 288 bits = 36 bytes.
fn build_page_offset_hint_table(least_obj_per_page: u32) -> Vec<u8> {
    let mut buf = Vec::with_capacity(36);
    // Item 1 (32): least objects per page (computed by caller).
    buf.extend_from_slice(&least_obj_per_page.to_be_bytes());
    // Item 2 (32): location of first page's page object = 0 (patched in pass 2).
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 3 (16): bits-needed for max-min objects/page delta = 32.
    buf.extend_from_slice(&32u16.to_be_bytes());
    // Item 4 (32): least page length in bytes = 0 (patched in pass 2).
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 5 (16): bits-needed for page length delta = 32.
    buf.extend_from_slice(&32u16.to_be_bytes());
    // Item 6 (32): least content stream offset = 0 (patched in pass 2).
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 7 (16): bits-needed for content stream offset delta = 32.
    buf.extend_from_slice(&32u16.to_be_bytes());
    // Item 8 (32): least content stream length = 0 (patched in pass 2).
    buf.extend_from_slice(&0u32.to_be_bytes());
    // Item 9 (16): bits-needed for content stream length delta = 32.
    buf.extend_from_slice(&32u16.to_be_bytes());
    // Item 10 (16): bits-needed for max shared-object count per page = 0.
    // We emit no shared objects, so item-3 entries collapse to 0 bits.
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 11 (16): bits-needed for shared-object id range = 0.
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 12 (16): bits-needed for fractional position numerator = 0.
    buf.extend_from_slice(&0u16.to_be_bytes());
    // Item 13 (16): denominator for fractional position = 4.
    buf.extend_from_slice(&4u16.to_be_bytes());
    debug_assert_eq!(buf.len(), 36);
    buf
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxideav_core::time::TimeBase;
    use oxideav_core::vector::{
        FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
    };
    use oxideav_scene::Page;

    fn rect_frame(w: f32, h: f32, color: Rgba) -> VectorFrame {
        let mut p = Path::new();
        p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
        p.commands
            .push(PathCommand::LineTo(Point::new(w - 10.0, 10.0)));
        p.commands
            .push(PathCommand::LineTo(Point::new(w - 10.0, h - 10.0)));
        p.commands
            .push(PathCommand::LineTo(Point::new(10.0, h - 10.0)));
        p.commands.push(PathCommand::Close);
        VectorFrame {
            width: w,
            height: h,
            view_box: None,
            root: Group {
                children: vec![Node::Path(PathNode {
                    path: p,
                    fill: Some(Paint::Solid(color)),
                    stroke: None,
                    fill_rule: FillRule::NonZero,
                })],
                ..Group::default()
            },
            pts: None,
            time_base: TimeBase::new(1, 1),
        }
    }

    fn page_with(w: f32, h: f32, color: Rgba) -> Page {
        let mut page = Page::new(w, h);
        page.content = rect_frame(w, h, color);
        page
    }

    fn single_page_scene() -> Scene {
        Scene {
            pages: Some(vec![page_with(100.0, 100.0, Rgba::opaque(255, 0, 0))]),
            ..Scene::default()
        }
    }

    fn multi_page_scene() -> Scene {
        Scene {
            pages: Some(vec![
                page_with(100.0, 100.0, Rgba::opaque(255, 0, 0)),
                page_with(200.0, 150.0, Rgba::opaque(0, 255, 0)),
                page_with(300.0, 200.0, Rgba::opaque(0, 0, 255)),
            ]),
            ..Scene::default()
        }
    }

    #[test]
    fn linearized_emits_pdf_1_5_header_and_marker() {
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        assert!(pdf.starts_with(b"%PDF-1.5\n"));
        // Binary marker on second line.
        assert_eq!(&pdf[9..14], &[0x25, 0xE2, 0xE3, 0xCF, 0xD3]);
        assert!(pdf.ends_with(b"%%EOF\n"));
    }

    /// Decode a PDF byte buffer to a String for substring scanning.
    /// `String::from_utf8_lossy` replaces the binary-marker bytes
    /// (0xE2 0xE3 0xCF 0xD3 on line 2) with U+FFFD, but keeps every
    /// other ASCII byte intact — which is what we need.
    fn pdf_lossy(bytes: &[u8]) -> String {
        String::from_utf8_lossy(bytes).into_owned()
    }

    #[test]
    fn linearization_dict_is_within_first_1024_bytes() {
        // F.3.3: "The linearization parameter dictionary shall be
        // entirely contained within the first 1024 bytes of the PDF
        // file."
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let head = &pdf[..1024.min(pdf.len())];
        let s = pdf_lossy(head);
        assert!(s.contains("/Linearized 1"), "head must carry /Linearized 1");
        // Find the lin-dict closer.
        let lin_idx = s.find("/Linearized 1").unwrap();
        let close_idx = s[lin_idx..].find(">>").expect("lin-dict close");
        assert!(
            lin_idx + close_idx < 1024,
            "lin-dict must close within first 1024 bytes"
        );
    }

    #[test]
    fn linearization_dict_carries_required_keys() {
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let s = pdf_lossy(&pdf);
        for key in ["/Linearized", "/L ", "/H [", "/O ", "/E ", "/N ", "/T "] {
            assert!(s.contains(key), "lin-dict missing {key:?}");
        }
    }

    #[test]
    fn linearized_l_matches_actual_file_length() {
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let actual = pdf.len();
        let head = pdf_lossy(&pdf[..1024.min(pdf.len())]);
        let l_idx = head.find("/L ").unwrap();
        let after = &head[l_idx + 3..];
        let value: u64 = after
            .split_ascii_whitespace()
            .next()
            .unwrap()
            .parse()
            .unwrap();
        assert_eq!(value as usize, actual, "/L must equal actual file length");
    }

    #[test]
    fn n_matches_page_count() {
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let head = pdf_lossy(&pdf[..1024.min(pdf.len())]);
        let n_idx = head.find("/N ").unwrap();
        let after = &head[n_idx + 3..];
        let value: u64 = after
            .split_ascii_whitespace()
            .next()
            .unwrap()
            .parse()
            .unwrap();
        assert_eq!(value, 3);
    }

    #[test]
    fn startxref_points_at_first_page_xref() {
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        let s = pdf_lossy(&pdf);
        let start_off = s.rfind("startxref\n").unwrap() + "startxref\n".len();
        let line: &str = s[start_off..].split('\n').next().unwrap();
        let off: usize = line.trim().parse().unwrap();
        // First-page xref appears at the FIRST `xref\n` occurrence in
        // the byte stream.
        let first_xref_off = pdf
            .windows(b"xref\n".len())
            .position(|w| w == b"xref\n")
            .unwrap();
        assert_eq!(off, first_xref_off);
    }

    #[test]
    fn first_page_trailer_carries_prev() {
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        let s = pdf_lossy(&pdf);
        let first_trailer_off = s.find("trailer\n").unwrap();
        let after = &s[first_trailer_off..];
        let close_off = after.find(">>").unwrap();
        let prev_off = after
            .find("/Prev ")
            .expect("first trailer must carry /Prev");
        assert!(prev_off < close_off);
    }

    #[test]
    fn round_trips_through_reader() {
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let scene = crate::reader::read_pdf_to_scene(&pdf).expect("reader accepts linearized");
        assert_eq!(scene.pages.as_ref().unwrap().len(), 3);
    }

    #[test]
    fn single_page_round_trips() {
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        let scene = crate::reader::read_pdf_to_scene(&pdf).expect("reader accepts");
        assert_eq!(scene.pages.as_ref().unwrap().len(), 1);
    }

    #[test]
    fn page_offset_hint_table_header_is_36_bytes() {
        let table = build_page_offset_hint_table(3);
        assert_eq!(table.len(), 36);
        // Item 1 (32-bit): least objects per page.
        assert_eq!(&table[0..4], &3u32.to_be_bytes());
        // Item 3 (16-bit): bits-needed for object-count delta = 32.
        assert_eq!(&table[8..10], &32u16.to_be_bytes());
        // Item 5 (16-bit): bits-needed for page-length delta = 32.
        assert_eq!(&table[14..16], &32u16.to_be_bytes());
        // Item 7 (16-bit): bits-needed for content-offset delta = 32.
        assert_eq!(&table[20..22], &32u16.to_be_bytes());
        // Item 9 (16-bit): bits-needed for content-length delta = 32.
        assert_eq!(&table[26..28], &32u16.to_be_bytes());
        // Item 13 (16-bit): denominator = 4.
        assert_eq!(&table[34..36], &4u16.to_be_bytes());
    }

    #[test]
    fn page_offset_per_page_section_size_is_16_bytes_per_page() {
        // Round-13: per-page section = 4 (item 1) + 4 (item 2) +
        // 0 (item 3 — no shared objects) + 4 (item 6) + 4 (item 7)
        // = 16 bytes per page. Verified end-to-end via the hint
        // stream's /Length field after a multi-page emit.
        let pdf = write_pdf_linearized(&multi_page_scene()).expect("linearize");
        let s = pdf_lossy(&pdf);
        // Locate the hint stream's /Length value. The /Length key
        // appears in the hint stream's dict (which also carries
        // /S /T /O — easy to disambiguate from any other stream).
        let hint_dict_start = s.find("/S ").expect("hint dict /S");
        let dict_open = s[..hint_dict_start].rfind("<<").unwrap();
        let dict_close = s[dict_open..].find(">>").unwrap() + dict_open;
        let dict_str = &s[dict_open..dict_close];
        let len_idx = dict_str.find("/Length ").expect("hint dict /Length");
        let after = &dict_str[len_idx + "/Length ".len()..];
        let value: usize = after
            .split_ascii_whitespace()
            .next()
            .unwrap()
            .parse()
            .unwrap();
        // Hint stream payload = 36 (page-offset header) + n*16
        // (per-page) + 24 (shared) + 28 (thumb) + 14 (outline).
        let n = 3usize;
        assert_eq!(value, 36 + n * 16 + 24 + 28 + 14);
    }

    #[test]
    fn page_offset_per_page_first_page_object_count_matches() {
        // Single page = 3 objects (Page + Resources + Contents); no
        // resource extras for a plain solid-fill rectangle. With one
        // page, least = 3 and the per-page item-1 delta = count -
        // least = 0.
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        // Find the hint stream payload by scanning past the first
        // `\nstream\n` marker (no other streams precede it because
        // a solid-fill scene generates no resource extras).
        let after_stream = pdf
            .windows(b"\nstream\n".len())
            .position(|w| w == b"\nstream\n")
            .unwrap()
            + b"\nstream\n".len();
        // Page-offset hint header is 36 bytes; per-page item 1 starts
        // at offset 36 (block A, page 0 item 1 = 4 bytes).
        let item1_p0 = u32::from_be_bytes(
            pdf[after_stream + 36..after_stream + 40]
                .try_into()
                .unwrap(),
        );
        assert_eq!(item1_p0, 0, "single-page item-1 delta = count - least = 0");
    }

    #[test]
    fn shared_object_hint_table_is_24_bytes_zero() {
        let table = build_shared_object_hint_table();
        assert_eq!(table.len(), 24);
        assert!(table.iter().all(|&b| b == 0));
    }

    #[test]
    fn thumbnail_hint_table_is_28_bytes_zero() {
        let table = build_thumbnail_hint_table();
        assert_eq!(table.len(), 28);
        assert!(table.iter().all(|&b| b == 0));
    }

    #[test]
    fn outline_hint_table_is_14_bytes_zero() {
        let table = build_outline_hint_table();
        assert_eq!(table.len(), 14);
        assert!(table.iter().all(|&b| b == 0));
    }

    #[test]
    fn hint_stream_dict_carries_s_t_o_offsets() {
        let pdf = write_pdf_linearized(&single_page_scene()).expect("linearize");
        let s = pdf_lossy(&pdf);
        // The hint stream's dict should now carry /S, /T, /O offsets
        // pointing within the decoded hint stream.
        assert!(s.contains("/S "), "hint dict must carry /S");
        assert!(s.contains("/T "), "hint dict must carry /T (thumbnail)");
        assert!(s.contains("/O "), "hint dict must carry /O (outline)");
    }
}