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}