Skip to main content

mos_pdf/
images.rs

1//! Raster image `XObject` emission for the PDF backend.
2//!
3//! The layout engine hands us a [`mos_layout::PageGraph::images`]
4//! table: one entry per unique on-disk image, dedup'd by resolved
5//! path. For each entry we emit a single Image `XObject`
6//! (`/Type /XObject /Subtype /Image`) with `/Width`, `/Height`,
7//! `/BitsPerComponent`, `/ColorSpace /DeviceRGB`, and the raw RGB8
8//! pixel buffer flate-compressed under `/Filter /FlateDecode`. Each
9//! page's `/Resources /XObject` dict lists every image, so per-page
10//! resource dicts stay byte-stable across pages.
11//!
12//! Per-page placements arrive as [`ImagePlacement`]s; the content
13//! stream wraps each in `q  w 0 0 h x y cm /Im<id> Do Q` so the image
14//! occupies the requested width/height rectangle at the requested
15//! page-relative position (after the same top→bottom flip the text
16//! emit path applies).
17//!
18//! Alpha/soft-mask support is deferred. The eval layer composites
19//! every input image onto opaque white before handing the bytes off,
20//! so the emit path can stay on a single `/DeviceRGB` /Filter
21//! /`FlateDecode` codepath.
22
23use flate2::Compression;
24use flate2::write::ZlibEncoder;
25use mos_layout::{ImageHandle, ImagePlacement};
26use pdf_writer::{Content, Filter, Finish, Name, Pdf, Ref};
27use std::io::Write;
28
29/// Stable PDF resource name for the image with `handle.id`. Mirrors
30/// the `/F<n>` convention used by the font emitter; every page's
31/// resource dict declares `/Im<n>` so PDF readers can resolve the
32/// references in the content stream.
33#[must_use]
34pub(crate) fn resource_name(handle: &ImageHandle) -> String {
35    format!("Im{}", handle.id)
36}
37
38/// Compress a flat RGB8 byte buffer with zlib (the format PDF expects
39/// behind `/FlateDecode`). Uses default compression for a reasonable
40/// size/speed tradeoff; image `XObject` byte stability across runs is
41/// preserved because `flate2`'s default settings are deterministic.
42pub(crate) fn flate_compress(bytes: &[u8]) -> Vec<u8> {
43    let mut encoder = ZlibEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
44    // `write_all` and `finish` on a `Vec<u8>` sink cannot fail except
45    // under OOM (which aborts the process anyway). Both branches below
46    // are unreachable in practice; the `debug_assert!`s fire in tests
47    // if that invariant is ever violated. Returning uncompressed bytes
48    // is *not* a correctness path: `emit_image_xobject` unconditionally
49    // sets `/Filter /FlateDecode`, so an uncompressed fallback would
50    // produce a PDF the reader chokes on. It exists purely as a
51    // last-resort escape from a release-mode panic.
52    if let Err(err) = encoder.write_all(bytes) {
53        debug_assert!(false, "flate sink failed: {err}");
54        return bytes.to_vec();
55    }
56    encoder.finish().unwrap_or_else(|err| {
57        debug_assert!(false, "flate finish failed: {err}");
58        bytes.to_vec()
59    })
60}
61
62/// Emit one Image `XObject` (`/Subtype /Image`) for `handle` at `id`.
63/// `compressed` is the zlib-compressed pixel stream produced by
64/// [`flate_compress`]; passed in pre-compressed so the caller can hold
65/// onto the bytes for byte-stability assertions in tests.
66pub(crate) fn emit_image_xobject(pdf: &mut Pdf, id: Ref, handle: &ImageHandle, compressed: &[u8]) {
67    let mut img = pdf.image_xobject(id, compressed);
68    img.filter(Filter::FlateDecode);
69    img.width(i32::try_from(handle.pixel_width).unwrap_or(i32::MAX));
70    img.height(i32::try_from(handle.pixel_height).unwrap_or(i32::MAX));
71    img.color_space_name(Name(b"DeviceRGB"));
72    img.bits_per_component(8);
73    img.finish();
74}
75
76/// Emit the `q w 0 0 h x y cm /Im<id> Do Q` block for one placement,
77/// translated into PDF's bottom-origin coordinate space using
78/// `page_height_pt`.
79pub(crate) fn emit_placement(
80    content: &mut Content,
81    page_height_pt: f32,
82    placement: &ImagePlacement,
83) {
84    let resource = resource_name(&placement.handle);
85    let y_top_from_bottom = page_height_pt - placement.top_from_top_pt;
86    let y_bottom = y_top_from_bottom - placement.height_pt;
87    content.save_state();
88    // PDF `cm` operator: [a b c d e f] places the unit square at
89    // (e, f) scaled by (a, d). We want an axis-aligned image whose
90    // bottom-left corner sits at (x, y_bottom) and whose extents are
91    // (width_pt, height_pt). The image XObject's natural coordinate
92    // system is [0,1]×[0,1], so the matrix is [w 0 0 h x y].
93    content.transform([
94        placement.width_pt,
95        0.0,
96        0.0,
97        placement.height_pt,
98        placement.x_pt,
99        y_bottom,
100    ]);
101    content.x_object(Name(resource.as_bytes()));
102    content.restore_state();
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn resource_name_uses_im_prefix() {
111        let h = ImageHandle {
112            id: 7,
113            resolved_path: "/x.png".to_owned(),
114            pixel_width: 1,
115            pixel_height: 1,
116            rgb8: std::sync::Arc::from(vec![0_u8; 3]),
117        };
118        assert_eq!(resource_name(&h), "Im7");
119    }
120
121    #[test]
122    fn flate_compress_round_trips_short_input() {
123        // Smoke test: the zlib stream we produce must decompress back
124        // to the original bytes, otherwise PDF readers will choke on
125        // /FlateDecode'd image streams.
126        use flate2::read::ZlibDecoder;
127        use std::io::Read;
128        let raw = b"\x00\x01\x02\x03\xff\xfe\xfd repeat me repeat me repeat me";
129        let compressed = flate_compress(raw);
130        let mut out = Vec::new();
131        ZlibDecoder::new(&compressed[..])
132            .read_to_end(&mut out)
133            .unwrap();
134        assert_eq!(out.as_slice(), raw);
135    }
136}