stet-pdf 0.6.0

PDF output device for stet PostScript interpreter
Documentation
// stet - A PostScript Interpreter
// Copyright (c) 2026 Scott Bowman
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! PDF `/Names /Dests` name-tree writer.
//!
//! Consumes [`stet_graphics::document_structure::DestRecord`] entries from
//! `Context::doc_structure` and emits a single-leaf name tree that the PDF
//! reader resolves named destinations against. PDF's name-tree shape is a
//! B-tree-style hierarchy for efficient lookup at scale; for MVP-sized
//! documents (~hundreds of dests at most), one flat leaf suffices and Acrobat
//! / poppler / pdfium handle it correctly. If the document grows past that,
//! the writer can be extended to split into intermediate /Kids nodes.

use stet_graphics::document_structure::{DestRecord, ViewSpec};

use crate::pdf_objects::PdfObj;
use crate::pdf_writer::PdfWriter;

/// Build the `/Dests` leaf indirect object from a slice of destination
/// records. Returns the leaf's indirect ref, ready to drop into the
/// `/Names` catalog entry under `/Dests`. Returns `None` when no
/// records were issued or every record targeted an out-of-range page.
///
/// `page_refs` maps 1-based page numbers to PDF page-object refs.
pub fn build_dests_leaf(
    writer: &mut PdfWriter,
    records: &[DestRecord],
    page_refs: &[u32],
) -> Option<u32> {
    if records.is_empty() {
        return None;
    }

    // Sort records by name — PDF name trees are required to be sorted.
    // Late entries with the same name win (matches "later record wins"
    // resolution we apply elsewhere).
    let mut by_name: std::collections::BTreeMap<String, &DestRecord> =
        std::collections::BTreeMap::new();
    for record in records {
        by_name.insert(record.name.clone(), record);
    }

    // Build /Names array: [(name1) [pageref /XYZ ...] (name2) [...] ...]
    let mut names_array: Vec<PdfObj> = Vec::with_capacity(by_name.len() * 2);
    for (name, record) in &by_name {
        let Some(page_ref) = page_to_ref(record.page, page_refs) else {
            continue;
        };
        names_array.push(PdfObj::LitString(name.clone().into_bytes()));
        names_array.push(page_view_dest_array(page_ref, &record.view));
    }
    if names_array.is_empty() {
        return None;
    }

    // Compute /Limits from the actual emitted entries (not the input,
    // since some records may have been dropped above).
    let limits_low = match &names_array[0] {
        PdfObj::LitString(s) => s.clone(),
        _ => return None,
    };
    let limits_high = match &names_array[names_array.len() - 2] {
        PdfObj::LitString(s) => s.clone(),
        _ => return None,
    };

    // Single leaf — no /Kids, just /Names + /Limits.
    Some(writer.add_object(&PdfObj::Dict(vec![
        (
            b"Limits".to_vec(),
            PdfObj::Array(vec![
                PdfObj::LitString(limits_low),
                PdfObj::LitString(limits_high),
            ]),
        ),
        (b"Names".to_vec(), PdfObj::Array(names_array)),
    ])))
}

/// Build the document-level `/Names` catalog entry from any combination
/// of name-tree leaves. Returns `None` when every input is `None`.
pub fn write_names_root(
    writer: &mut PdfWriter,
    dests_leaf: Option<u32>,
    embedded_files_leaf: Option<u32>,
) -> Option<u32> {
    let mut entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
    if let Some(r) = dests_leaf {
        entries.push((b"Dests".to_vec(), PdfObj::Ref(r)));
    }
    if let Some(r) = embedded_files_leaf {
        entries.push((b"EmbeddedFiles".to_vec(), PdfObj::Ref(r)));
    }
    if entries.is_empty() {
        return None;
    }
    Some(writer.add_object(&PdfObj::Dict(entries)))
}

fn page_to_ref(page: u32, page_refs: &[u32]) -> Option<u32> {
    if page == 0 {
        return None;
    }
    page_refs.get(page as usize - 1).copied()
}

fn page_view_dest_array(page_ref: u32, view: &ViewSpec) -> PdfObj {
    let mut elems: Vec<PdfObj> = vec![PdfObj::Ref(page_ref)];
    match view {
        ViewSpec::Xyz { left, top, zoom } => {
            elems.push(PdfObj::name("XYZ"));
            elems.push(opt_real(*left));
            elems.push(opt_real(*top));
            elems.push(opt_real(*zoom));
        }
        ViewSpec::Fit => elems.push(PdfObj::name("Fit")),
        ViewSpec::FitH(top) => {
            elems.push(PdfObj::name("FitH"));
            elems.push(opt_real(*top));
        }
        ViewSpec::FitV(left) => {
            elems.push(PdfObj::name("FitV"));
            elems.push(opt_real(*left));
        }
        ViewSpec::FitR {
            left,
            bottom,
            right,
            top,
        } => {
            elems.push(PdfObj::name("FitR"));
            elems.push(PdfObj::Real(*left));
            elems.push(PdfObj::Real(*bottom));
            elems.push(PdfObj::Real(*right));
            elems.push(PdfObj::Real(*top));
        }
        ViewSpec::FitB => elems.push(PdfObj::name("FitB")),
        ViewSpec::FitBH(top) => {
            elems.push(PdfObj::name("FitBH"));
            elems.push(opt_real(*top));
        }
        ViewSpec::FitBV(left) => {
            elems.push(PdfObj::name("FitBV"));
            elems.push(opt_real(*left));
        }
        _ => elems.push(PdfObj::name("Fit")),
    }
    PdfObj::Array(elems)
}

fn opt_real(v: Option<f64>) -> PdfObj {
    match v {
        Some(v) => PdfObj::Real(v),
        None => PdfObj::Null,
    }
}