Skip to main content

vole_document/adapter/pdf/
layout.rs

1//! PDF layout candidate: regenerate classic cross-reference entry offsets and
2//! the `startxref` value from positions marked during materialization.
3//!
4//! This is the first Phase-5 candidate that replaces literal structural bytes
5//! with *procedurally determined* ones. The mechanism is deliberately narrow and
6//! conservative:
7//!
8//! * It applies only to files that already carry a classic cross-reference
9//!   section ([`PhysicalKind::XrefSection`]) and contain no
10//!   [`ObjRole::XRefStream`] object. Anything else declines (`Ok(None)`).
11//! * Every literal byte is accumulated into a single data object and the whole
12//!   reconstruction is one compact [`Op::PackSegments`] item table, so the
13//!   per-segment framing is paid once rather than once per span or xref entry.
14//! * It records each indirect object's introducer offset with a
15//!   [`PackItem::Mark`] (slot = the object's index in [`PdfPhysical::objects`])
16//!   and, for every `n`-status xref entry whose 10-digit offset field equals the
17//!   marked position of its target object, emits that field with a
18//!   [`PackItem::Emit`] rather than storing the digits literally.
19//! * The most recent `xref` section start is marked in the reserved slot
20//!   [`XREF_SLOT`] (`255`); each `startxref` value is regenerated from it only
21//!   when the emitted position equals the source value.
22//! * Whenever a precondition fails — a non-standard offset field, a mismatched
23//!   position, a malformed table, too many objects — the site (or the whole
24//!   section) falls back to a literal [`PackItem::Literal`]. Prediction never
25//!   invents bytes: a fallback is always byte-exact, and a prediction is only
26//!   emitted when it reproduces the source digits exactly.
27//!
28//! After building the program the candidate is verified end-to-end (serialize,
29//! parse, materialize, byte-compare) before it is returned; if that round trip
30//! is not exact, the proposal declines rather than emitting an inexact
31//! candidate.
32
33use crate::SOURCE_FORMAT_PDF;
34use crate::container::{Descriptor, ObjectSource, UNIVERSE};
35use crate::dra::op::PackItem;
36use crate::dra::{Op, Program};
37use crate::encode::candidates::{Candidate, CandidateKind};
38use crate::error::Result;
39use crate::integrity::sha256;
40use crate::limits::Limits;
41
42#[cfg(feature = "rans")]
43use crate::entropy::{
44    ALPHABET, CODER_ORDER0_BYTE_RANS, CODER_VERSION_1, EntropyChannelDescriptor, EntropyModel,
45    encode_channel,
46};
47
48use super::physical::{ObjRole, PdfObjectSpan, PdfPhysical, PhysicalKind, scan};
49
50/// Reserved slot index for the most recent classic `xref` section start.
51pub const XREF_SLOT: u8 = u8::MAX;
52
53/// Largest number of indirect objects that can be marked (indices `0..=254`),
54/// leaving slot `255` free for [`XREF_SLOT`].
55pub const MAX_MARKED_OBJECTS: usize = XREF_SLOT as usize;
56
57/// The structural layout plan for a classic-cross-reference PDF: the ordered
58/// packed item table, the single literal data object it consumes, and the
59/// prediction counters used to describe the plan.
60pub struct LayoutPlan {
61    /// Ordered reconstruction items (literal runs, position marks, emitted
62    /// offsets), as consumed by [`Op::PackSegments`] and [`Op::PackedChannels`].
63    pub items: Vec<PackItem>,
64    /// Every literal byte, in item order. Must be consumed exactly by `items`.
65    pub data: Vec<u8>,
66    /// Number of xref entry offsets regenerated from a marked position.
67    pub xref_predicted: usize,
68    /// Number of xref entry offsets stored literally (precondition failed).
69    pub xref_literal: usize,
70    /// Whether at least one `startxref` value was regenerated.
71    pub startxref_predicted: bool,
72}
73
74/// Build the structural layout plan for `input`, or `None` when the input is not
75/// a classic-cross-reference PDF this mechanism can express exactly.
76///
77/// Declines (`Ok(None)`) whenever a precondition fails — no classic `xref`
78/// section, a cross-reference stream present, too many objects, a non-contiguous
79/// span cover, or a literal run that cannot fit a `u32`. See the module
80/// documentation for the algorithm. The caller is responsible for the final
81/// byte-exactness check through the normative decoder.
82pub fn build_layout_plan(input: &[u8], limits: Limits) -> Result<Option<LayoutPlan>> {
83    let physical = match scan(input, limits) {
84        Ok(p) => p,
85        Err(_) => return Ok(None),
86    };
87    build_layout_plan_with(input, limits, &physical)
88}
89
90/// [`build_layout_plan`] against an already-computed physical scan.
91pub(crate) fn build_layout_plan_with(
92    input: &[u8],
93    _limits: Limits,
94    physical: &PdfPhysical,
95) -> Result<Option<LayoutPlan>> {
96    // Precondition: a classic cross-reference section must exist, and no
97    // cross-reference stream may be present.
98    if !physical
99        .spans
100        .iter()
101        .any(|s| s.kind == PhysicalKind::XrefSection)
102    {
103        return Ok(None);
104    }
105    if physical
106        .objects
107        .iter()
108        .any(|o| o.role == ObjRole::XRefStream)
109    {
110        return Ok(None);
111    }
112    if physical.objects.len() > MAX_MARKED_OBJECTS {
113        return Ok(None);
114    }
115
116    // The data object carries every literal byte; the compact item table
117    // interleaves literal runs, position marks, and regenerated offsets so the
118    // per-segment framing is paid once rather than once per span/entry.
119    let mut data: Vec<u8> = Vec::new();
120    let mut items: Vec<PackItem> = Vec::new();
121    // Simulated output position. The physical cover is contiguous, so this
122    // tracks the source offset of the next byte exactly: literals add their
123    // length, emits add their width, and marks add nothing.
124    let mut pos: u64 = 0;
125    let mut slot_value = [0u64; 256];
126    let mut slot_marked = [false; 256];
127    let mut xref_predicted: usize = 0;
128    let mut xref_literal: usize = 0;
129    let mut startxref_predicted = false;
130
131    for span in &physical.spans {
132        let start = span.start as usize;
133        let end = start + span.len as usize;
134        let bytes = &input[start..end];
135
136        if pos != span.start {
137            // A non-contiguous simulation would break the offset contract; bail.
138            return Ok(None);
139        }
140
141        match span.kind {
142            PhysicalKind::ObjHeader => {
143                if let Some(idx) = object_index_at(&physical.objects, span.start) {
144                    let slot = idx as u8;
145                    items.push(PackItem::Mark { slot });
146                    slot_value[slot as usize] = pos;
147                    slot_marked[slot as usize] = true;
148                }
149                if !push_pack_literal(&mut data, &mut items, bytes, &mut pos) {
150                    return Ok(None);
151                }
152            }
153            PhysicalKind::XrefSection => {
154                // Mark this section's start in the reserved slot, then emit.
155                items.push(PackItem::Mark { slot: XREF_SLOT });
156                slot_value[XREF_SLOT as usize] = pos;
157                slot_marked[XREF_SLOT as usize] = true;
158
159                match parse_classic_xref(bytes) {
160                    Some(pieces) => {
161                        for piece in pieces {
162                            match piece {
163                                XrefPiece::Literal { start, len } => {
164                                    if !push_pack_literal(
165                                        &mut data,
166                                        &mut items,
167                                        &bytes[start..start + len],
168                                        &mut pos,
169                                    ) {
170                                        return Ok(None);
171                                    }
172                                }
173                                XrefPiece::Entry {
174                                    start,
175                                    number,
176                                    offset,
177                                    in_use,
178                                } => {
179                                    let slot = if in_use {
180                                        offset.and_then(|value| {
181                                            predicted_slot(
182                                                &physical.objects,
183                                                &slot_value,
184                                                &slot_marked,
185                                                number,
186                                                value,
187                                            )
188                                        })
189                                    } else {
190                                        None
191                                    };
192                                    match slot {
193                                        Some(slot) => {
194                                            items.push(PackItem::Emit { slot, width: 10 });
195                                            pos += 10;
196                                            if !push_pack_literal(
197                                                &mut data,
198                                                &mut items,
199                                                &bytes[start + 10..start + 20],
200                                                &mut pos,
201                                            ) {
202                                                return Ok(None);
203                                            }
204                                            xref_predicted += 1;
205                                        }
206                                        None => {
207                                            if !push_pack_literal(
208                                                &mut data,
209                                                &mut items,
210                                                &bytes[start..start + 20],
211                                                &mut pos,
212                                            ) {
213                                                return Ok(None);
214                                            }
215                                            xref_literal += 1;
216                                        }
217                                    }
218                                }
219                            }
220                        }
221                    }
222                    None => {
223                        // Not a classic table we understand: literal whole section.
224                        if !push_pack_literal(&mut data, &mut items, bytes, &mut pos) {
225                            return Ok(None);
226                        }
227                    }
228                }
229            }
230            PhysicalKind::StartXref => match predict_startxref(bytes, &slot_value, &slot_marked) {
231                Some((prefix_len, width)) => {
232                    if !push_pack_literal(&mut data, &mut items, &bytes[..prefix_len], &mut pos) {
233                        return Ok(None);
234                    }
235                    items.push(PackItem::Emit {
236                        slot: XREF_SLOT,
237                        width,
238                    });
239                    pos += width as u64;
240                    startxref_predicted = true;
241                }
242                None => {
243                    if !push_pack_literal(&mut data, &mut items, bytes, &mut pos) {
244                        return Ok(None);
245                    }
246                }
247            },
248            _ => {
249                if !push_pack_literal(&mut data, &mut items, bytes, &mut pos) {
250                    return Ok(None);
251                }
252            }
253        }
254    }
255
256    Ok(Some(LayoutPlan {
257        items,
258        data,
259        xref_predicted,
260        xref_literal,
261        startxref_predicted,
262    }))
263}
264
265/// Propose a layout candidate that regenerates xref offsets / startxref, or
266/// `None`.
267///
268/// Wraps [`build_layout_plan`] into a single [`Op::PackSegments`] program over
269/// one literal data object. Declines (`Ok(None)`) whenever the plan cannot be
270/// built or the assembled program does not materialize byte-for-byte. See the
271/// module documentation for the algorithm.
272pub fn propose_pdf_layout(input: &[u8], limits: Limits) -> Result<Option<Candidate>> {
273    let physical = match scan(input, limits) {
274        Ok(p) => p,
275        Err(_) => return Ok(None),
276    };
277    propose_pdf_layout_with(input, limits, &physical)
278}
279
280/// [`propose_pdf_layout`] against an already-computed physical scan.
281pub(crate) fn propose_pdf_layout_with(
282    input: &[u8],
283    limits: Limits,
284    physical: &PdfPhysical,
285) -> Result<Option<Candidate>> {
286    let plan = match build_layout_plan_with(input, limits, physical)? {
287        Some(p) => p,
288        None => return Ok(None),
289    };
290
291    // `startxref` is predicted once per `StartXref` span; every `Emit` is either
292    // a predicted xref entry or a predicted startxref, so the count is exact.
293    let startxref_predicted = emit_count(&plan.items).saturating_sub(plan.xref_predicted);
294    let format_basis = format!(
295        "pdf-layout;objects={};xref_predicted={};xref_literal={};startxref_predicted={}",
296        marked_object_count(&plan.items),
297        plan.xref_predicted,
298        plan.xref_literal,
299        startxref_predicted
300    );
301
302    let descriptor = Descriptor {
303        universe: UNIVERSE.to_string(),
304        source_format: SOURCE_FORMAT_PDF,
305        format_basis,
306        models: vec![],
307        channels: vec![],
308        objects: vec![ObjectSource::Inline(plan.data)],
309        program: Program::new(vec![Op::PackSegments {
310            data_object: 0,
311            items: plan.items,
312        }]),
313        observation_index: None,
314        seek_directory: false,
315        checkpoints: None,
316        source_sha256: sha256(input),
317        source_len: input.len() as u64,
318    };
319
320    let candidate = Candidate {
321        kind: CandidateKind::PdfLayout,
322        descriptor,
323    };
324
325    // Verify byte-exactness through the normative decoder before returning. An
326    // inexact program must never be emitted.
327    let (encoded, _) = candidate.descriptor.serialize()?;
328    let parsed = match Descriptor::parse(&encoded, limits) {
329        Ok(p) => p,
330        Err(_) => return Ok(None),
331    };
332    let out = match crate::materialize::materialize(&parsed, limits) {
333        Ok(o) => o,
334        Err(_) => return Ok(None),
335    };
336    if out != input {
337        return Ok(None);
338    }
339
340    Ok(Some(candidate))
341}
342
343/// Propose a layout + rANS candidate, or `None`.
344///
345/// Builds the same [`LayoutPlan`] as [`propose_pdf_layout`], then entropy-codes
346/// its parts into two rANS channels: channel `0` carries the plan's literal data
347/// object, and channel `1` carries `encode_items` of the plan's item table. A
348/// single [`Op::PackedChannels`] reconstructs the source from both, so the whole
349/// plan — data *and* item table — pays entropy-coding cost instead of being
350/// stored as literal bytes. Each channel uses its own order-0 byte model
351/// normalized from its own byte histogram at `scale_bits` 12.
352///
353/// Exactly as with the literal layout lane, an end-to-end serialize / parse /
354/// materialize / byte-compare check gates the return: an inexact program yields
355/// `Ok(None)` rather than an inexact candidate.
356#[cfg(feature = "rans")]
357pub fn propose_pdf_layout_rans(input: &[u8], limits: Limits) -> Result<Option<Candidate>> {
358    let physical = match scan(input, limits) {
359        Ok(p) => p,
360        Err(_) => return Ok(None),
361    };
362    propose_pdf_layout_rans_with(input, limits, &physical)
363}
364
365/// [`propose_pdf_layout_rans`] against an already-computed physical scan.
366#[cfg(feature = "rans")]
367pub(crate) fn propose_pdf_layout_rans_with(
368    input: &[u8],
369    limits: Limits,
370    physical: &PdfPhysical,
371) -> Result<Option<Candidate>> {
372    let plan = match build_layout_plan_with(input, limits, physical)? {
373        Some(p) => p,
374        None => return Ok(None),
375    };
376
377    let plan_bytes = crate::dra::op::encode_items(&plan.items)?;
378
379    // Channel 0: the literal data object, coded against its own byte histogram.
380    let mut data_counts = [0u64; ALPHABET];
381    for &b in &plan.data {
382        data_counts[b as usize] += 1;
383    }
384    let data_model = EntropyModel::from_counts(&data_counts, 12)?;
385    let data_capsule = encode_channel(&data_model, &plan.data)?;
386
387    // Channel 1: the serialized item table, coded against its own histogram.
388    let mut plan_counts = [0u64; ALPHABET];
389    for &b in &plan_bytes {
390        plan_counts[b as usize] += 1;
391    }
392    let plan_model = EntropyModel::from_counts(&plan_counts, 12)?;
393    let plan_capsule = encode_channel(&plan_model, &plan_bytes)?;
394
395    let data_channel = EntropyChannelDescriptor {
396        coder: CODER_ORDER0_BYTE_RANS,
397        coder_version: CODER_VERSION_1,
398        scale_bits: data_model.scale_bits,
399        lane_count: 1,
400        model_id: 0,
401        symbol_count: data_capsule.symbol_count,
402        decoded_length: data_capsule.decoded_length,
403        initial_state: data_capsule.initial_state,
404        payload: data_capsule.payload,
405    };
406    let plan_channel = EntropyChannelDescriptor {
407        coder: CODER_ORDER0_BYTE_RANS,
408        coder_version: CODER_VERSION_1,
409        scale_bits: plan_model.scale_bits,
410        lane_count: 1,
411        model_id: 1,
412        symbol_count: plan_capsule.symbol_count,
413        decoded_length: plan_capsule.decoded_length,
414        initial_state: plan_capsule.initial_state,
415        payload: plan_capsule.payload,
416    };
417
418    let format_basis = format!(
419        "pdf-layout-rans;objects={};xref_predicted={};xref_literal={}",
420        marked_object_count(&plan.items),
421        plan.xref_predicted,
422        plan.xref_literal
423    );
424
425    let descriptor = Descriptor {
426        universe: UNIVERSE.to_string(),
427        source_format: SOURCE_FORMAT_PDF,
428        format_basis,
429        models: vec![data_model, plan_model],
430        channels: vec![data_channel, plan_channel],
431        objects: vec![],
432        program: Program::new(vec![Op::PackedChannels {
433            data_channel: 0,
434            plan_channel: 1,
435            declared_output_len: input.len() as u64,
436        }]),
437        observation_index: None,
438        seek_directory: false,
439        checkpoints: None,
440        source_sha256: sha256(input),
441        source_len: input.len() as u64,
442    };
443
444    let candidate = Candidate {
445        kind: CandidateKind::PdfLayoutRans,
446        descriptor,
447    };
448
449    // Verify byte-exactness through the normative decoder before returning. An
450    // inexact program must never be emitted.
451    let (encoded, _) = candidate.descriptor.serialize()?;
452    let parsed = match Descriptor::parse(&encoded, limits) {
453        Ok(p) => p,
454        Err(_) => return Ok(None),
455    };
456    let out = match crate::materialize::materialize(&parsed, limits) {
457        Ok(o) => o,
458        Err(_) => return Ok(None),
459    };
460    if out != input {
461        return Ok(None);
462    }
463
464    Ok(Some(candidate))
465}
466
467/// Number of indirect objects whose introducer was marked in the item table.
468fn marked_object_count(items: &[PackItem]) -> usize {
469    items
470        .iter()
471        .filter(|item| matches!(item, PackItem::Mark { slot } if *slot != XREF_SLOT))
472        .count()
473}
474
475/// Number of emitted offsets in the item table.
476fn emit_count(items: &[PackItem]) -> usize {
477    items
478        .iter()
479        .filter(|item| matches!(item, PackItem::Emit { .. }))
480        .count()
481}
482
483/// Append `bytes` to the packed data object, recording one [`PackItem::Literal`]
484/// when non-empty, and advance the simulated output position. Returns `false`
485/// (so the caller declines) when a single run would not fit a `u32` length.
486fn push_pack_literal(
487    data: &mut Vec<u8>,
488    items: &mut Vec<PackItem>,
489    bytes: &[u8],
490    pos: &mut u64,
491) -> bool {
492    if !push_literal(items, data, bytes) {
493        return false;
494    }
495    *pos += bytes.len() as u64;
496    true
497}
498
499/// Append `bytes` to the packed data object, coalescing them into the immediately
500/// preceding [`PackItem::Literal`] when one is present so that consecutive literal
501/// runs collapse into the fewest possible items. The merge never crosses a
502/// [`PackItem::Mark`] or [`PackItem::Emit`], empty pushes are ignored, and the
503/// combined length must remain expressible as a `u32`. Returns `false` (so the
504/// caller declines) when no safe item shape exists.
505pub(crate) fn push_literal(items: &mut Vec<PackItem>, data: &mut Vec<u8>, bytes: &[u8]) -> bool {
506    if bytes.is_empty() {
507        return true;
508    }
509    let Ok(len) = u32::try_from(bytes.len()) else {
510        return false;
511    };
512    if let Some(PackItem::Literal { len: prev }) = items.last_mut() {
513        let Some(total) = prev.checked_add(len) else {
514            return false;
515        };
516        *prev = total;
517    } else {
518        items.push(PackItem::Literal { len });
519    }
520    data.extend_from_slice(bytes);
521    true
522}
523
524/// Index of the first object whose introducer starts at `start`.
525fn object_index_at(objects: &[PdfObjectSpan], start: u64) -> Option<usize> {
526    objects.iter().position(|o| o.start == start)
527}
528
529/// The slot marking the target object `number` at position `value`, if any
530/// earlier [`Op::MarkOffset`] recorded exactly that position.
531fn predicted_slot(
532    objects: &[PdfObjectSpan],
533    slot_value: &[u64; 256],
534    slot_marked: &[bool; 256],
535    number: u64,
536    value: u64,
537) -> Option<u8> {
538    objects.iter().enumerate().find_map(|(i, o)| {
539        let slot = i as u8;
540        (o.number == number && slot_marked[slot as usize] && slot_value[slot as usize] == value)
541            .then_some(slot)
542    })
543}
544
545/// Predict a whole `startxref` span: the trailing run of decimal digits is the
546/// value. Returns `(prefix_len, width)` when the value equals the marked `xref`
547/// position and its width is in `1..=20`.
548fn predict_startxref(
549    bytes: &[u8],
550    slot_value: &[u64; 256],
551    slot_marked: &[bool; 256],
552) -> Option<(usize, u8)> {
553    if !slot_marked[XREF_SLOT as usize] {
554        return None;
555    }
556    let mut i = bytes.len();
557    while i > 0 && bytes[i - 1].is_ascii_digit() {
558        i -= 1;
559    }
560    let width = bytes.len() - i;
561    if width == 0 || width > 20 {
562        return None;
563    }
564    let value = parse_digits(&bytes[i..])?;
565    if value != slot_value[XREF_SLOT as usize] {
566        return None;
567    }
568    Some((i, width as u8))
569}
570
571/// One ordered slice of a classic `xref` section: either literal bytes or a
572/// 20-byte entry whose offset field may be regenerated.
573pub(crate) enum XrefPiece {
574    /// Verbatim bytes `[start, start + len)` of the section.
575    Literal { start: usize, len: usize },
576    /// A 20-byte entry `[start, start + 20)`.
577    Entry {
578        /// Offset of the entry within the section.
579        start: usize,
580        /// Target object number (`subsection_start + i`).
581        number: u64,
582        /// Parsed value of the 10-digit offset field, if it is all digits.
583        offset: Option<u64>,
584        /// Whether the status byte is `n` (in use).
585        in_use: bool,
586    },
587}
588
589/// Parse a classic cross-reference table from a section's bytes, returning an
590/// ordered tiling of the section. Returns `None` (so the caller emits the whole
591/// section literally) when the bytes do not match the classic grammar.
592///
593/// Grammar accepted: `xref` EOL, then one or more `<start> <count>` EOL headers
594/// each followed by exactly `count` 20-byte entries of the shape
595/// `10-digit-offset SP 5-digit-generation SP status 2-byte-EOL`. The two EOL
596/// bytes may be `CR LF`, `LF CR`, `SP LF`, or `SP CR`.
597pub(crate) fn parse_classic_xref(bytes: &[u8]) -> Option<Vec<XrefPiece>> {
598    if !bytes.starts_with(b"xref") {
599        return None;
600    }
601    let mut pieces = Vec::new();
602    let mut pos = 4usize;
603
604    // EOL after the `xref` keyword.
605    let eol = eol_len(&bytes[pos..])?;
606    pieces.push(XrefPiece::Literal {
607        start: 0,
608        len: pos + eol,
609    });
610    pos += eol;
611
612    let mut any = false;
613    while pos < bytes.len() {
614        // Subsection header: `<start> <count>` EOL.
615        let header_start = pos;
616        let (start, after_start) = parse_uint_at(bytes, pos)?;
617        pos = after_start;
618        let spaces_start = pos;
619        while pos < bytes.len() && bytes[pos] == b' ' {
620            pos += 1;
621        }
622        if pos == spaces_start {
623            return None;
624        }
625        let (count, after_count) = parse_uint_at(bytes, pos)?;
626        pos = after_count;
627        let eol = eol_len(&bytes[pos..])?;
628        let header_end = pos + eol;
629        pieces.push(XrefPiece::Literal {
630            start: header_start,
631            len: header_end - header_start,
632        });
633        pos = header_end;
634
635        for i in 0..count {
636            let end = pos.checked_add(20)?;
637            if end > bytes.len() {
638                return None;
639            }
640            let entry = &bytes[pos..end];
641            if !is_entry_shape(entry) {
642                return None;
643            }
644            let number = start.checked_add(i)?;
645            let in_use = entry[17] == b'n';
646            let offset = parse_digits(&entry[0..10]);
647            pieces.push(XrefPiece::Entry {
648                start: pos,
649                number,
650                offset,
651                in_use,
652            });
653            pos = end;
654        }
655        any = true;
656    }
657
658    if !any || pos != bytes.len() {
659        return None;
660    }
661    Some(pieces)
662}
663
664/// Whether a 20-byte window matches the classic cross-reference entry shape.
665fn is_entry_shape(entry: &[u8]) -> bool {
666    if entry.len() != 20 {
667        return false;
668    }
669    if entry[10] != b' ' || entry[16] != b' ' {
670        return false;
671    }
672    if !entry[11..16].iter().all(u8::is_ascii_digit) {
673        return false;
674    }
675    if entry[17] != b'n' && entry[17] != b'f' {
676        return false;
677    }
678    matches!(
679        (entry[18], entry[19]),
680        (b'\r', b'\n') | (b'\n', b'\r') | (b' ', b'\n') | (b' ', b'\r')
681    )
682}
683
684/// Length of an end-of-line marker at the start of `bytes`, if any.
685fn eol_len(bytes: &[u8]) -> Option<usize> {
686    match bytes {
687        [b'\r', b'\n', ..] => Some(2),
688        [b'\n', ..] | [b'\r', ..] => Some(1),
689        _ => None,
690    }
691}
692
693/// Parse a non-negative decimal integer at `at`, returning `(value, next)`.
694fn parse_uint_at(bytes: &[u8], at: usize) -> Option<(u64, usize)> {
695    let mut i = at;
696    let mut value: u64 = 0;
697    while i < bytes.len() && bytes[i].is_ascii_digit() {
698        value = value
699            .checked_mul(10)?
700            .checked_add(u64::from(bytes[i] - b'0'))?;
701        i += 1;
702    }
703    if i == at {
704        return None;
705    }
706    Some((value, i))
707}
708
709/// Parse an all-digit slice as a non-negative decimal integer.
710pub(crate) fn parse_digits(digits: &[u8]) -> Option<u64> {
711    if digits.is_empty() {
712        return None;
713    }
714    let mut value: u64 = 0;
715    for &b in digits {
716        if !b.is_ascii_digit() {
717            return None;
718        }
719        value = value.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
720    }
721    Some(value)
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::adapter::pdf::samples::{is_negative_control, sample_pdfs};
728    use crate::container::Descriptor;
729
730    fn sample(name: &str) -> Vec<u8> {
731        sample_pdfs()
732            .into_iter()
733            .find(|(n, _)| *n == name)
734            .unwrap_or_else(|| panic!("sample {name} missing"))
735            .1
736    }
737
738    fn basis_field(basis: &str, key: &str) -> Option<u64> {
739        basis.split(';').find_map(|part| {
740            let (k, v) = part.split_once('=')?;
741            (k == key).then(|| v.parse().ok()).flatten()
742        })
743    }
744
745    /// The item table of the single `PackSegments` op this candidate builds.
746    fn pack_items(cand: &Candidate) -> &[PackItem] {
747        cand.descriptor
748            .program
749            .ops
750            .iter()
751            .find_map(|op| match op {
752                Op::PackSegments { items, .. } => Some(items.as_slice()),
753                _ => None,
754            })
755            .expect("layout program is one PackSegments op")
756    }
757
758    /// Number of regenerated offsets in the packed item table.
759    fn pack_emit_count(cand: &Candidate) -> usize {
760        pack_items(cand)
761            .iter()
762            .filter(|item| matches!(item, PackItem::Emit { .. }))
763            .count()
764    }
765
766    fn assert_materializes_exactly(name: &str, bytes: &[u8]) {
767        let cand = propose_pdf_layout(bytes, Limits::DEFAULT)
768            .unwrap()
769            .unwrap_or_else(|| panic!("{name} must propose a layout candidate"));
770        assert_eq!(cand.kind, CandidateKind::PdfLayout);
771        assert_eq!(cand.descriptor.source_format, SOURCE_FORMAT_PDF);
772        assert_eq!(cand.descriptor.source_len, bytes.len() as u64);
773        // Exactly one object: the packed data object holding every literal byte.
774        assert_eq!(
775            cand.descriptor.objects.len(),
776            1,
777            "{name} layout carries one packed data object"
778        );
779        assert!(matches!(
780            cand.descriptor.program.ops.as_slice(),
781            [Op::PackSegments { .. }]
782        ));
783        assert!(cand.descriptor.models.is_empty());
784        assert!(cand.descriptor.channels.is_empty());
785
786        let (encoded, _) = cand.descriptor.serialize().unwrap();
787        let parsed = Descriptor::parse(&encoded, Limits::DEFAULT).unwrap();
788        let out = crate::materialize::materialize(&parsed, Limits::DEFAULT).unwrap();
789        assert_eq!(out, bytes, "{name} layout candidate materializes exactly");
790        assert_eq!(sha256(&out), sha256(bytes), "{name} layout sha");
791    }
792
793    #[test]
794    fn layout_is_exact_on_corpus() {
795        let mut accepted = 0usize;
796        for (name, bytes) in sample_pdfs() {
797            if is_negative_control(name) {
798                assert!(
799                    propose_pdf_layout(&bytes, Limits::DEFAULT)
800                        .unwrap()
801                        .is_none(),
802                    "{name} is not a classic-xref PDF and must decline"
803                );
804                continue;
805            }
806            if propose_pdf_layout(&bytes, Limits::DEFAULT)
807                .unwrap()
808                .is_some()
809            {
810                assert_materializes_exactly(name, &bytes);
811                accepted += 1;
812            }
813        }
814        assert!(
815            accepted >= 3,
816            "expected several accepted classic-xref samples, found {accepted}"
817        );
818    }
819
820    #[test]
821    fn layout_v2_exact() {
822        for name in ["classic.pdf", "many.pdf", "bigtext.pdf"] {
823            assert_materializes_exactly(name, &sample(name));
824        }
825    }
826
827    #[test]
828    fn layout_v2_predicts_many_entries() {
829        let bytes = sample("many.pdf");
830        let cand = propose_pdf_layout(&bytes, Limits::DEFAULT)
831            .unwrap()
832            .unwrap();
833        let emits = pack_emit_count(&cand);
834        assert!(
835            emits >= 100,
836            "many.pdf must predict at least 100 xref offsets, got {emits}"
837        );
838        // Every Emit is either a predicted xref entry or the predicted startxref.
839        let predicted = basis_field(&cand.descriptor.format_basis, "xref_predicted")
840            .expect("format basis must report xref_predicted");
841        let startxref = basis_field(&cand.descriptor.format_basis, "startxref_predicted")
842            .expect("format basis must report startxref_predicted");
843        assert_eq!(predicted + startxref, emits as u64);
844        assert!(predicted >= 100, "many.pdf xref predictions: {predicted}");
845
846        // The classic sample still predicts, and the pack framing stays compact.
847        let classic = propose_pdf_layout(&sample("classic.pdf"), Limits::DEFAULT)
848            .unwrap()
849            .unwrap();
850        assert!(pack_emit_count(&classic) > 0);
851    }
852
853    #[test]
854    fn layout_falls_back_on_bad_offset() {
855        // Object 1's real offset is `off1`, but the xref entry records a wrong
856        // value. The entry must stay literal. `startxref` is still correct, so it
857        // is the only predicted offset in the whole program.
858        let mut b: Vec<u8> = Vec::new();
859        b.extend_from_slice(b"%PDF-1.4\n");
860        let off1 = b.len() as u64;
861        b.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
862        let xref = b.len() as u64;
863        let wrong = off1 + 3;
864        b.extend_from_slice(
865            format!("xref\n0 2\n0000000000 65535 f \n{wrong:010} 00000 n \n").as_bytes(),
866        );
867        b.extend_from_slice(
868            format!("trailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
869        );
870
871        let cand = propose_pdf_layout(&b, Limits::DEFAULT).unwrap().unwrap();
872        assert_eq!(
873            basis_field(&cand.descriptor.format_basis, "xref_predicted"),
874            Some(0),
875            "a mismatched offset must never be predicted"
876        );
877
878        // The only regenerated offset is the correctly predicted `startxref`.
879        let emits = pack_emit_count(&cand);
880        assert_eq!(
881            emits, 1,
882            "only startxref is predicted; bad entry is literal"
883        );
884
885        let (encoded, _) = cand.descriptor.serialize().unwrap();
886        let parsed = Descriptor::parse(&encoded, Limits::DEFAULT).unwrap();
887        let out = crate::materialize::materialize(&parsed, Limits::DEFAULT).unwrap();
888        assert_eq!(out, b, "fallback must still be byte-exact");
889    }
890
891    #[test]
892    fn layout_v2_declines() {
893        // A cross-reference-stream PDF has no classic table to regenerate.
894        assert!(
895            propose_pdf_layout(&sample("xrefstream.pdf"), Limits::DEFAULT)
896                .unwrap()
897                .is_none(),
898            "an xref-stream PDF must decline the layout candidate"
899        );
900        // More objects than the 255 markable slots cannot be expressed exactly
901        // under the slot bound, so the candidate must decline rather than guess.
902        assert!(
903            propose_pdf_layout(&classic_with_objects(256), Limits::DEFAULT)
904                .unwrap()
905                .is_none(),
906            "256 objects exceeds the 255 markable slots"
907        );
908        // 255 objects still fit: indices 0..=254, slot 255 reserved for xref.
909        assert!(
910            propose_pdf_layout(&classic_with_objects(255), Limits::DEFAULT)
911                .unwrap()
912                .is_some(),
913            "255 objects must still be markable"
914        );
915    }
916
917    /// Build a classic-xref PDF with `n` indirect objects and correct offsets.
918    fn classic_with_objects(n: usize) -> Vec<u8> {
919        let mut b: Vec<u8> = Vec::new();
920        b.extend_from_slice(b"%PDF-1.4\n");
921        let mut offsets = Vec::with_capacity(n);
922        for number in 1..=n {
923            offsets.push(b.len() as u64);
924            b.extend_from_slice(format!("{number} 0 obj\n<< >>\nendobj\n").as_bytes());
925        }
926        let xref = b.len() as u64;
927        b.extend_from_slice(format!("xref\n0 {}\n", n + 1).as_bytes());
928        b.extend_from_slice(b"0000000000 65535 f \n");
929        for &off in &offsets {
930            b.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
931        }
932        b.extend_from_slice(
933            format!(
934                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
935                n + 1
936            )
937            .as_bytes(),
938        );
939        b
940    }
941
942    #[test]
943    fn layout_v2_deterministic() {
944        for name in ["classic.pdf", "many.pdf", "bigtext.pdf"] {
945            let bytes = sample(name);
946            let a = propose_pdf_layout(&bytes, Limits::DEFAULT)
947                .unwrap()
948                .unwrap()
949                .descriptor
950                .serialize()
951                .unwrap()
952                .0;
953            let b = propose_pdf_layout(&bytes, Limits::DEFAULT)
954                .unwrap()
955                .unwrap()
956                .descriptor
957                .serialize()
958                .unwrap()
959                .0;
960            assert_eq!(a, b, "{name} layout bytes must be deterministic");
961        }
962    }
963
964    /// Force the layout+rANS candidate for `bytes` and assert the full exact
965    /// triple end-to-end through the normative decoder.
966    #[cfg(feature = "rans")]
967    fn assert_rans_materializes_exactly(name: &str, bytes: &[u8]) {
968        let cand = propose_pdf_layout_rans(bytes, Limits::DEFAULT)
969            .unwrap()
970            .unwrap_or_else(|| panic!("{name} must propose a layout-rANS candidate"));
971        assert_eq!(cand.kind, CandidateKind::PdfLayoutRans);
972        assert_eq!(cand.descriptor.source_format, SOURCE_FORMAT_PDF);
973        assert_eq!(cand.descriptor.source_len, bytes.len() as u64);
974        // No literal objects: the data and the plan both travel in channels.
975        assert!(cand.descriptor.objects.is_empty());
976        assert_eq!(cand.descriptor.models.len(), 2);
977        assert_eq!(cand.descriptor.channels.len(), 2);
978        assert_eq!(cand.descriptor.channels[0].model_id, 0);
979        assert_eq!(cand.descriptor.channels[1].model_id, 1);
980        assert!(matches!(
981            cand.descriptor.program.ops.as_slice(),
982            [Op::PackedChannels {
983                data_channel: 0,
984                plan_channel: 1,
985                ..
986            }]
987        ));
988
989        let (encoded, _) = cand.descriptor.serialize().unwrap();
990        let parsed = Descriptor::parse(&encoded, Limits::DEFAULT).unwrap();
991        let out = crate::materialize::materialize(&parsed, Limits::DEFAULT).unwrap();
992        assert_eq!(out, bytes, "{name} layout-rANS materializes exactly");
993        assert_eq!(sha256(&out), sha256(bytes), "{name} layout-rANS sha");
994
995        // The forced lane must survive the court's own decode-before-commit.
996        let (forced, report) =
997            crate::encode::encode_with(bytes, Limits::DEFAULT, Some(CandidateKind::PdfLayoutRans))
998                .unwrap();
999        assert_eq!(report.kind, CandidateKind::PdfLayoutRans);
1000        let (forced_out, _) =
1001            crate::materialize::decode_to_bytes(&forced, Limits::DEFAULT).unwrap();
1002        assert_eq!(forced_out, bytes, "{name} forced layout-rANS bytes");
1003        assert_eq!(
1004            sha256(&forced_out),
1005            sha256(bytes),
1006            "{name} forced layout-rANS sha"
1007        );
1008    }
1009
1010    #[cfg(feature = "rans")]
1011    #[test]
1012    fn layout_rans_exact() {
1013        for name in ["classic.pdf", "bigtext.pdf", "many.pdf"] {
1014            assert_rans_materializes_exactly(name, &sample(name));
1015        }
1016    }
1017
1018    #[cfg(feature = "rans")]
1019    #[test]
1020    fn layout_rans_deterministic() {
1021        for name in ["classic.pdf", "bigtext.pdf", "many.pdf"] {
1022            let bytes = sample(name);
1023            let a = propose_pdf_layout_rans(&bytes, Limits::DEFAULT)
1024                .unwrap()
1025                .unwrap()
1026                .descriptor
1027                .serialize()
1028                .unwrap()
1029                .0;
1030            let b = propose_pdf_layout_rans(&bytes, Limits::DEFAULT)
1031                .unwrap()
1032                .unwrap()
1033                .descriptor
1034                .serialize()
1035                .unwrap()
1036                .0;
1037            assert_eq!(a, b, "{name} layout-rANS bytes must be deterministic");
1038        }
1039    }
1040
1041    #[cfg(feature = "rans")]
1042    #[test]
1043    fn layout_rans_declines_on_non_pdf() {
1044        // Non-PDF controls and cross-reference-stream PDFs: nothing to plan, so
1045        // the candidate must decline rather than store a non-plan.
1046        for name in ["notpdf.bin", "malformed.pdf", "xrefstream.pdf"] {
1047            assert!(
1048                propose_pdf_layout_rans(&sample(name), Limits::DEFAULT)
1049                    .unwrap()
1050                    .is_none(),
1051                "{name} must decline the layout-rANS candidate"
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn layout_measurements_report() {
1058        for (name, bytes) in sample_pdfs() {
1059            let Some(cand) = propose_pdf_layout(&bytes, Limits::DEFAULT).unwrap() else {
1060                eprintln!("layout[{name}]: declined");
1061                continue;
1062            };
1063            let (layout_bytes, _) = cand.descriptor.serialize().unwrap();
1064            let emits = pack_emit_count(&cand);
1065            eprintln!(
1066                "layout[{name}] source={} items={} emits={emits} layout={}",
1067                bytes.len(),
1068                pack_items(&cand).len(),
1069                layout_bytes.len(),
1070            );
1071        }
1072
1073        for name in ["classic.pdf", "bigtext.pdf", "many.pdf"] {
1074            let bytes = sample(name);
1075            let (layout_bytes, _) =
1076                crate::encode::encode_with(&bytes, Limits::DEFAULT, Some(CandidateKind::PdfLayout))
1077                    .unwrap();
1078            let (raw_bytes, _) =
1079                crate::encode::encode_with(&bytes, Limits::DEFAULT, Some(CandidateKind::Raw))
1080                    .unwrap();
1081            #[cfg(feature = "rans")]
1082            let (byte_rans_bytes, _) =
1083                crate::encode::encode_with(&bytes, Limits::DEFAULT, Some(CandidateKind::ByteRans))
1084                    .unwrap();
1085            #[cfg(not(feature = "rans"))]
1086            let byte_rans_bytes: Vec<u8> = Vec::new();
1087            #[cfg(feature = "rans")]
1088            let (layout_rans_bytes, _) = crate::encode::encode_with(
1089                &bytes,
1090                Limits::DEFAULT,
1091                Some(CandidateKind::PdfLayoutRans),
1092            )
1093            .unwrap();
1094            #[cfg(not(feature = "rans"))]
1095            let layout_rans_bytes: Vec<u8> = Vec::new();
1096            let (_, auto) = crate::encode::encode(&bytes, Limits::DEFAULT).unwrap();
1097            eprintln!(
1098                "sizes[{name}] source={} raw={} byte_rans={} layout={} layout_rans={} auto={}({})",
1099                bytes.len(),
1100                raw_bytes.len(),
1101                byte_rans_bytes.len(),
1102                layout_bytes.len(),
1103                layout_rans_bytes.len(),
1104                auto.kind.name(),
1105                auto.encoded_len,
1106            );
1107
1108            #[cfg(feature = "rans")]
1109            {
1110                let plan = build_layout_plan(&bytes, Limits::DEFAULT).unwrap().unwrap();
1111                let plan_bytes_len = crate::dra::op::encode_items(&plan.items).unwrap().len();
1112                let cand = propose_pdf_layout_rans(&bytes, Limits::DEFAULT)
1113                    .unwrap()
1114                    .unwrap();
1115                let model_bytes: usize = cand
1116                    .descriptor
1117                    .models
1118                    .iter()
1119                    .map(|m| m.encode().unwrap().len())
1120                    .sum();
1121                let payload_bytes: usize = cand
1122                    .descriptor
1123                    .channels
1124                    .iter()
1125                    .map(|c| c.payload.len())
1126                    .sum();
1127                eprintln!(
1128                    "rans[{name}] data={} plan_bytes={} models={} payloads={} total={}",
1129                    plan.data.len(),
1130                    plan_bytes_len,
1131                    model_bytes,
1132                    payload_bytes,
1133                    layout_rans_bytes.len(),
1134                );
1135            }
1136        }
1137    }
1138}