Skip to main content

lightweight_pdf_writer/
content.rs

1//! Flat content-stream operations (`plan/00a-contracts-and-artifacts.md`
2//! point 3: `PdfTextRun`, path resources — never `Element`/`RenderNode`).
3//! The facade crate translates layout output into calls on this builder.
4
5use crate::writer::fmt_num;
6
7#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
8pub struct Rgb(pub u8, pub u8, pub u8);
9
10/// The position/orientation operands for [`ContentBuilder::text_rotated`],
11/// grouped into one argument so the method stays under clippy's
12/// too-many-arguments threshold without merging it into [`ContentBuilder::text`].
13#[derive(Clone, Copy, PartialEq, Debug)]
14pub struct TextRotation {
15    /// Rotation center, x.
16    pub cx: f32,
17    /// Rotation center, y.
18    pub cy: f32,
19    /// Counter-clockwise rotation angle in degrees.
20    pub angle_deg: f32,
21    /// Half the text run's width, used to horizontally center it on `cx`.
22    pub half_width: f32,
23}
24
25fn color_component(c: u8) -> String {
26    fmt_num(c as f32 / 255.0)
27}
28
29/// Formats a color-setting operator, e.g. `"0 0 0 rg"` (fill) or
30/// `"0.5 0.5 0.5 RG"` (stroke) — shared by every drawing/text primitive
31/// below that sets a fill or stroke color.
32fn color_op(color: Rgb, op: &str) -> String {
33    format!(
34        "{} {} {} {}",
35        color_component(color.0),
36        color_component(color.1),
37        color_component(color.2),
38        op
39    )
40}
41
42pub struct ContentBuilder {
43    buf: Vec<u8>,
44}
45
46impl Default for ContentBuilder {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl ContentBuilder {
53    pub fn new() -> Self {
54        ContentBuilder { buf: Vec::new() }
55    }
56
57    fn op(&mut self, s: &str) {
58        self.buf.extend_from_slice(s.as_bytes());
59        self.buf.push(b'\n');
60    }
61
62    /// `q` — push graphics state (used before every clip scope, Grundprinzip 4).
63    pub fn save(&mut self) {
64        self.op("q");
65    }
66
67    /// `Q` — pop graphics state, closing the matching clip scope.
68    pub fn restore(&mut self) {
69        self.op("Q");
70    }
71
72    /// `/{tag} << /MCID n >> BDC` — begins a marked-content sequence tied
73    /// to a structure-tree leaf (issue #27, ISO 32000-1 14.6). Matching
74    /// [`Self::end_marked_content`] closes it.
75    pub fn begin_marked_content(&mut self, tag: &str, mcid: u32) {
76        self.op(&format!("/{tag} << /MCID {mcid} >> BDC"));
77    }
78
79    /// `/Artifact BMC` — begins a marked-content sequence explicitly
80    /// excluded from the structure tree (pagination/decoration: running
81    /// headers/footers, watermarks — ISO 32000-1 14.8.2.2). `BMC`
82    /// (single operand, no properties dict), not `BDC`: nothing here
83    /// needs a `/Properties` lookup or an inline dict, and `BDC` without
84    /// one is a malformed operator call, not merely a stylistic choice —
85    /// found via an actual veraPDF PDF/UA run flagging "Undefined
86    /// property /Artifact in a content stream", not from the spec text
87    /// alone.
88    pub fn begin_artifact(&mut self) {
89        self.op("/Artifact BMC");
90    }
91
92    /// `EMC` — ends a `begin_marked_content`/`begin_artifact` scope.
93    pub fn end_marked_content(&mut self) {
94        self.op("EMC");
95    }
96
97    /// Formats `<x> <y> <w> <h>` — the rectangle-operand pair shared by
98    /// every rectangle-drawing primitive ([`Self::clip_rect`],
99    /// [`Self::rect_op`], and transitively [`Self::fill_rect`]/
100    /// [`Self::stroke_rect`]) ahead of their `re` operator.
101    fn rect_operands(x: f32, y: f32, w: f32, h: f32) -> String {
102        format!("{} {} {} {}", fmt_num(x), fmt_num(y), fmt_num(w), fmt_num(h))
103    }
104
105    /// Intersects the clip path with a rectangle: `re W n`. Must be called
106    /// right after `save()` and before any drawing in that scope
107    /// (Grundprinzip 4: a clip set at the end protects nothing).
108    pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
109        self.op(&format!("{} re W n", Self::rect_operands(x, y, w, h)));
110    }
111
112    /// Emits `<rect_prefix> <x> <y> <w> <h> re <op>` — the rectangle-operand
113    /// skeleton shared by [`Self::fill_rect`] (`rect_prefix` is just the
114    /// fill color operator, `op` is `"f"`) and [`Self::stroke_rect`]
115    /// (`rect_prefix` also carries the line width, `op` is `"S"`).
116    fn rect_op(&mut self, rect_prefix: &str, x: f32, y: f32, w: f32, h: f32, op: &str) {
117        self.op(&format!("{} {} re {}", rect_prefix, Self::rect_operands(x, y, w, h), op));
118    }
119
120    pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
121        self.rect_op(&color_op(color, "rg"), x, y, w, h, "f");
122    }
123
124    pub fn stroke_rect(&mut self, x: f32, y: f32, w: f32, h: f32, line_width: f32, color: Rgb) {
125        let prefix = format!("{} {} w", color_op(color, "RG"), fmt_num(line_width));
126        self.rect_op(&prefix, x, y, w, h, "S");
127    }
128
129    pub fn set_dash(&mut self, dash: f32, gap: f32) {
130        self.op(&format!("[{} {}] 0 d", fmt_num(dash), fmt_num(gap)));
131    }
132
133    pub fn reset_dash(&mut self) {
134        self.op("[] 0 d");
135    }
136
137    #[allow(clippy::too_many_arguments)]
138    pub fn draw_rounded_rect(
139        &mut self,
140        x: f32,
141        y: f32,
142        w: f32,
143        h: f32,
144        radius: f32,
145        fill: Option<Rgb>,
146        stroke: Option<(f32, Rgb)>,
147        dash: Option<(f32, f32)>,
148    ) {
149        let r = radius.min(w / 2.0).min(h / 2.0);
150        let k = r * 0.552_284_8;
151        let mut path = String::new();
152
153        path.push_str(&format!("{} {} m\n", fmt_num(x + r), fmt_num(y)));
154        path.push_str(&format!("{} {} l\n", fmt_num(x + w - r), fmt_num(y)));
155        path.push_str(&format!(
156            "{} {} {} {} {} {} c\n",
157            fmt_num(x + w - r + k),
158            fmt_num(y),
159            fmt_num(x + w),
160            fmt_num(y + r - k),
161            fmt_num(x + w),
162            fmt_num(y + r)
163        ));
164        path.push_str(&format!("{} {} l\n", fmt_num(x + w), fmt_num(y + h - r)));
165        path.push_str(&format!(
166            "{} {} {} {} {} {} c\n",
167            fmt_num(x + w),
168            fmt_num(y + h - r + k),
169            fmt_num(x + w - r + k),
170            fmt_num(y + h),
171            fmt_num(x + w - r),
172            fmt_num(y + h)
173        ));
174        path.push_str(&format!("{} {} l\n", fmt_num(x + r), fmt_num(y + h)));
175        path.push_str(&format!(
176            "{} {} {} {} {} {} c\n",
177            fmt_num(x + r - k),
178            fmt_num(y + h),
179            fmt_num(x),
180            fmt_num(y + h - r + k),
181            fmt_num(x),
182            fmt_num(y + h - r)
183        ));
184        path.push_str(&format!("{} {} l\n", fmt_num(x), fmt_num(y + r)));
185        path.push_str(&format!(
186            "{} {} {} {} {} {} c\nh",
187            fmt_num(x),
188            fmt_num(y + r - k),
189            fmt_num(x + r - k),
190            fmt_num(y),
191            fmt_num(x + r),
192            fmt_num(y)
193        ));
194
195        if let Some((dash_len, gap_len)) = dash {
196            self.set_dash(dash_len, gap_len);
197        }
198
199        match (fill, stroke) {
200            (Some(f), Some((sw, s))) => {
201                let fill_str = color_op(f, "rg");
202                let stroke_str = color_op(s, "RG");
203                self.op(&format!("{} {} {} w\n{} b", fill_str, stroke_str, fmt_num(sw), path));
204            }
205            (Some(f), None) => {
206                let fill_str = color_op(f, "rg");
207                self.op(&format!("{}\n{} f", fill_str, path));
208            }
209            (None, Some((sw, s))) => {
210                let stroke_str = color_op(s, "RG");
211                self.op(&format!("{} {} w\n{} S", stroke_str, fmt_num(sw), path));
212            }
213            (None, None) => {}
214        }
215
216        if dash.is_some() {
217            self.reset_dash();
218        }
219    }
220
221    pub fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, line_width: f32, color: Rgb) {
222        self.op(&format!(
223            "{} {} w {} {} m {} {} l S",
224            color_op(color, "RG"),
225            fmt_num(line_width),
226            fmt_num(x1),
227            fmt_num(y1),
228            fmt_num(x2),
229            fmt_num(y2)
230        ));
231    }
232
233    /// Writes one text-showing run: `BT /F1 12 Tf 1 0 0 rg 100 700 Td
234    /// <0001 0002> Tj ET`. `encoded_bytes` are the already-encoded character
235    /// codes for the target font's encoding (Identity-H: big-endian 2-byte
236    /// CIDs). Written as a PDF hex string, not a literal `(...)` string —
237    /// binary-safe by construction, no escaping and no UTF-8 involved (CIDs
238    /// are not Unicode and must never be routed through `str`/`String`).
239    pub fn text(&mut self, font_resource: &str, size: f32, x: f32, y: f32, color: Rgb, encoded_bytes: &[u8]) {
240        self.text_block(font_resource, size, color, x, y, encoded_bytes);
241        self.buf.push(b'\n');
242    }
243
244    /// Writes `encoded_bytes` as a PDF hex string body (without the
245    /// surrounding `<`/`>` delimiters) — shared by [`Self::text`] and
246    /// [`Self::text_rotated`], the two text-showing primitives.
247    fn write_hex_string(&mut self, encoded_bytes: &[u8]) {
248        for b in encoded_bytes {
249            self.buf.extend_from_slice(format!("{b:02X}").as_bytes());
250        }
251    }
252
253    /// Emits the `BT /{font} {size} Tf {color} {tx} {ty} Td <hex> Tj ET`
254    /// text-showing skeleton — shared by [`Self::text`] (called with no
255    /// surrounding matrix) and [`Self::text_rotated`] (called inside a
256    /// `q <matrix> cm` / `Q` rotation scope). `tx`/`ty` are the raw `Td`
257    /// operands, formatted here so neither caller repeats the formatting.
258    fn text_block(&mut self, font_resource: &str, size: f32, color: Rgb, tx: f32, ty: f32, encoded_bytes: &[u8]) {
259        self.buf.extend_from_slice(
260            format!(
261                "BT /{} {} Tf {} {} {} Td <",
262                font_resource,
263                fmt_num(size),
264                color_op(color, "rg"),
265                fmt_num(tx),
266                fmt_num(ty),
267            )
268            .as_bytes(),
269        );
270        self.write_hex_string(encoded_bytes);
271        self.buf.extend_from_slice(b"> Tj ET");
272    }
273
274    /// Writes one text-showing run rotated around `(cx, cy)` (Phase 6
275    /// watermark support, `05-overflow-and-robustness.md`: "keine
276    /// allgemeine Transform-API" — this is the one narrow, watermark-
277    /// specific rotation primitive, not a general element transform).
278    /// `angle_deg` is counter-clockwise; `half_width` horizontally centers
279    /// the text on `cx` (the baseline sits exactly on `cy` in the rotated
280    /// frame — a documented, deliberately simple approximation of vertical
281    /// centering, adequate for a decorative diagonal stamp).
282    pub fn text_rotated(&mut self, font_resource: &str, size: f32, rotation: TextRotation, color: Rgb, encoded_bytes: &[u8]) {
283        let rad = rotation.angle_deg.to_radians();
284        let cos = rad.cos();
285        let sin = rad.sin();
286        self.buf.extend_from_slice(
287            format!(
288                "q {} {} {} {} {} {} cm ",
289                fmt_num(cos),
290                fmt_num(sin),
291                fmt_num(-sin),
292                fmt_num(cos),
293                fmt_num(rotation.cx),
294                fmt_num(rotation.cy),
295            )
296            .as_bytes(),
297        );
298        self.text_block(font_resource, size, color, -rotation.half_width, 0.0, encoded_bytes);
299        self.buf.extend_from_slice(b" Q\n");
300    }
301
302    /// Draws a registered image XObject into the unit square, scaled to
303    /// `w`x`h` at `(x, y)`: `q <w> 0 0 <h> <x> <y> cm /Im1 Do Q` — the
304    /// standard PDF idiom for placing an image (an XObject is always
305    /// defined over the 1x1 unit square, `cm` maps that to the target box).
306    pub fn draw_image(&mut self, image_resource: &str, x: f32, y: f32, w: f32, h: f32) {
307        self.op(&format!(
308            "q {} 0 0 {} {} {} cm /{} Do Q",
309            fmt_num(w),
310            fmt_num(h),
311            fmt_num(x),
312            fmt_num(y),
313            image_resource
314        ));
315    }
316
317    pub fn into_bytes(self) -> Vec<u8> {
318        self.buf
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn builds_expected_operators() {
328        let mut c = ContentBuilder::new();
329        c.save();
330        c.clip_rect(0.0, 0.0, 100.0, 50.0);
331        // CIDs 0x0001 0x0002 0x0003, big-endian 2-byte codes (Identity-H).
332        c.text("F1", 12.0, 10.0, 20.0, Rgb(0, 0, 0), &[0x00, 0x01, 0x00, 0x02, 0x00, 0x03]);
333        c.restore();
334        let bytes = c.into_bytes();
335        let s = String::from_utf8(bytes).unwrap();
336        assert!(s.contains("q\n"));
337        assert!(s.contains("0 0 100 50 re W n"));
338        assert!(s.contains("<000100020003> Tj"));
339        assert!(s.trim_end().ends_with('Q'));
340    }
341
342    #[test]
343    fn draw_image_emits_the_cm_do_idiom() {
344        let mut c = ContentBuilder::new();
345        c.draw_image("Im1", 10.0, 20.0, 100.0, 50.0);
346        let s = String::from_utf8(c.into_bytes()).unwrap();
347        assert!(s.contains("100 0 0 50 10 20 cm /Im1 Do"));
348    }
349
350    #[test]
351    fn text_rotated_emits_a_rotation_matrix_and_centers_horizontally() {
352        let mut c = ContentBuilder::new();
353        c.text_rotated(
354            "F1",
355            72.0,
356            TextRotation {
357                cx: 300.0,
358                cy: 400.0,
359                angle_deg: 45.0,
360                half_width: 50.0,
361            },
362            Rgb(210, 210, 210),
363            &[0x00, 0x01],
364        );
365        let s = String::from_utf8(c.into_bytes()).unwrap();
366        // cos(45)=sin(45)=0.707
367        assert!(s.contains("0.707 0.707 -0.707 0.707 300 400 cm"));
368        assert!(
369            s.contains("-50 0 Td"),
370            "text must be horizontally centered via a -half_width offset"
371        );
372        assert!(s.trim_end().ends_with("Q"));
373    }
374}