Skip to main content

pdfboss_write/
content.rs

1//! Content-stream serialization: `pdfboss_core::content::Op` values back to
2//! operator syntax. The writer emits from the same IR the reader parses
3//! into, so `parse_content(serialize_ops(ops)) == ops` is the module's
4//! defining property — every variant of [`Op`] must round-trip.
5//!
6//! Inline images emit exactly the dictionary entries present in
7//! [`ImageParams::dict`], never an invented `/L`: the parser keeps a
8//! declared length key (`/L` or `/Length`) in the parsed dictionary, so
9//! re-emitting the entries reproduces the trusted length, and data
10//! containing a spurious ` EI ` round-trips whenever the dictionary
11//! carries one — the only way the parser can produce such data. Without a
12//! length key the parser stops at the first `EI` token boundary, so
13//! parser-produced data never contains a premature boundary and re-locating
14//! `EI` finds the true end. The parser skips one whitespace byte after `ID`
15//! and strips one before `EI`; the writer emits exactly one of each.
16//!
17//! Two parser-producible corners cannot round-trip byte-exactly and are
18//! accepted: a dictionary or properties value holding an integral `Real`
19//! reparses as `Int` (the crate serializes `2.0` as `2`), and a truncated
20//! source whose declared length exceeds the actual data yields an op whose
21//! dictionary promises more bytes than `data` holds.
22
23use pdfboss_core::content::{ImageParams, Op, TextItem};
24use pdfboss_core::{Name, Object};
25
26use crate::ser::{serialize_object, write_name, write_real_f32, write_string};
27
28/// Serializes a sequence of content operators: operands space-separated,
29/// one space before each operator keyword, a newline after it.
30/// Inline-image dictionaries are written with their canonical
31/// (unabbreviated) keys, which the parser passes through unchanged.
32/// Infallible: a stream inside a `DP`/`BDC` properties value — impossible
33/// in parser-produced ops — is emitted as `null`.
34pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
35    let mut out = Vec::new();
36    for op in ops {
37        push_op(op, &mut out);
38    }
39    out
40}
41
42/// Emits one operator: each operand followed by a space, then the keyword
43/// and a newline.
44fn push_op(op: &Op, out: &mut Vec<u8>) {
45    match op {
46        Op::Save => push_kw(b"q", out),
47        Op::Restore => push_kw(b"Q", out),
48        Op::Concat(m) => push_nums(&[m.a, m.b, m.c, m.d, m.e, m.f], b"cm", out),
49        Op::SetLineWidth(w) => push_nums(&[*w], b"w", out),
50        Op::SetLineCap(cap) => push_int(*cap, b"J", out),
51        Op::SetLineJoin(join) => push_int(*join, b"j", out),
52        Op::SetMiterLimit(limit) => push_nums(&[*limit], b"M", out),
53        Op::SetDash(dashes, phase) => {
54            push_f32_array(dashes, out);
55            out.push(b' ');
56            push_nums(&[*phase], b"d", out);
57        }
58        Op::SetRenderingIntent(n) => push_name_op(n, b"ri", out),
59        Op::SetFlatness(f) => push_nums(&[*f], b"i", out),
60        Op::SetExtGState(n) => push_name_op(n, b"gs", out),
61        Op::MoveTo(x, y) => push_nums(&[*x, *y], b"m", out),
62        Op::LineTo(x, y) => push_nums(&[*x, *y], b"l", out),
63        Op::CurveTo(x1, y1, x2, y2, x3, y3) => {
64            push_nums(&[*x1, *y1, *x2, *y2, *x3, *y3], b"c", out);
65        }
66        Op::CurveToV(x2, y2, x3, y3) => push_nums(&[*x2, *y2, *x3, *y3], b"v", out),
67        Op::CurveToY(x1, y1, x3, y3) => push_nums(&[*x1, *y1, *x3, *y3], b"y", out),
68        Op::ClosePath => push_kw(b"h", out),
69        Op::Rect(x, y, w, h) => push_nums(&[*x, *y, *w, *h], b"re", out),
70        Op::Stroke => push_kw(b"S", out),
71        Op::CloseStroke => push_kw(b"s", out),
72        Op::Fill => push_kw(b"f", out),
73        Op::FillEvenOdd => push_kw(b"f*", out),
74        Op::FillStroke => push_kw(b"B", out),
75        Op::FillStrokeEvenOdd => push_kw(b"B*", out),
76        Op::CloseFillStroke => push_kw(b"b", out),
77        Op::CloseFillStrokeEvenOdd => push_kw(b"b*", out),
78        Op::EndPath => push_kw(b"n", out),
79        Op::ClipNonZero => push_kw(b"W", out),
80        Op::ClipEvenOdd => push_kw(b"W*", out),
81        Op::SetStrokeColorSpace(n) => push_name_op(n, b"CS", out),
82        Op::SetFillColorSpace(n) => push_name_op(n, b"cs", out),
83        Op::SetStrokeColor(comps) => push_nums(comps, b"SC", out),
84        Op::SetStrokeColorN(comps, pattern) => {
85            push_color_n(comps, pattern.as_ref(), b"SCN", out);
86        }
87        Op::SetFillColor(comps) => push_nums(comps, b"sc", out),
88        Op::SetFillColorN(comps, pattern) => push_color_n(comps, pattern.as_ref(), b"scn", out),
89        Op::SetStrokeGray(g) => push_nums(&[*g], b"G", out),
90        Op::SetFillGray(g) => push_nums(&[*g], b"g", out),
91        Op::SetStrokeRGB(r, g, b) => push_nums(&[*r, *g, *b], b"RG", out),
92        Op::SetFillRGB(r, g, b) => push_nums(&[*r, *g, *b], b"rg", out),
93        Op::SetStrokeCMYK(c, m, y, k) => push_nums(&[*c, *m, *y, *k], b"K", out),
94        Op::SetFillCMYK(c, m, y, k) => push_nums(&[*c, *m, *y, *k], b"k", out),
95        Op::BeginText => push_kw(b"BT", out),
96        Op::EndText => push_kw(b"ET", out),
97        Op::SetCharSpacing(v) => push_nums(&[*v], b"Tc", out),
98        Op::SetWordSpacing(v) => push_nums(&[*v], b"Tw", out),
99        Op::SetHorizScaling(v) => push_nums(&[*v], b"Tz", out),
100        Op::SetLeading(v) => push_nums(&[*v], b"TL", out),
101        Op::SetFont(n, size) => {
102            write_name(&n.0, out);
103            out.push(b' ');
104            push_nums(&[*size], b"Tf", out);
105        }
106        Op::SetGlyphWidth(wx, wy) => push_nums(&[*wx, *wy], b"d0", out),
107        Op::SetGlyphWidthBBox(wx, wy, llx, lly, urx, ury) => {
108            push_nums(&[*wx, *wy, *llx, *lly, *urx, *ury], b"d1", out);
109        }
110        Op::SetTextRender(mode) => push_int(*mode, b"Tr", out),
111        Op::SetTextRise(v) => push_nums(&[*v], b"Ts", out),
112        Op::TextMove(tx, ty) => push_nums(&[*tx, *ty], b"Td", out),
113        Op::TextMoveSetLeading(tx, ty) => push_nums(&[*tx, *ty], b"TD", out),
114        Op::SetTextMatrix(m) => push_nums(&[m.a, m.b, m.c, m.d, m.e, m.f], b"Tm", out),
115        Op::TextNextLine => push_kw(b"T*", out),
116        Op::ShowText(s) => push_string_op(s, b"Tj", out),
117        Op::ShowTextAdjusted(items) => push_text_adjusted(items, out),
118        Op::NextLineShowText(s) => push_string_op(s, b"'", out),
119        Op::NextLineShowTextSpaced(aw, ac, s) => {
120            write_real_f32(*aw, out);
121            out.push(b' ');
122            write_real_f32(*ac, out);
123            out.push(b' ');
124            push_string_op(s, b"\"", out);
125        }
126        Op::XObject(n) => push_name_op(n, b"Do", out),
127        Op::InlineImage(img) => push_inline_image(img, out),
128        Op::Shading(n) => push_name_op(n, b"sh", out),
129        Op::MarkedContentPoint(n) => push_name_op(n, b"MP", out),
130        Op::MarkedContentPointProps(tag, props) => push_tag_props(tag, props, b"DP", out),
131        Op::BeginMarkedContent(n) => push_name_op(n, b"BMC", out),
132        Op::BeginMarkedContentProps(tag, props) => push_tag_props(tag, props, b"BDC", out),
133        Op::EndMarkedContent => push_kw(b"EMC", out),
134        Op::BeginCompat => push_kw(b"BX", out),
135        Op::EndCompat => push_kw(b"EX", out),
136    }
137}
138
139/// Writes the operator keyword and its terminating newline.
140fn push_kw(kw: &[u8], out: &mut Vec<u8>) {
141    out.extend_from_slice(kw);
142    out.push(b'\n');
143}
144
145/// Writes numeric operands, each followed by a space, then the keyword.
146fn push_nums(vals: &[f32], kw: &[u8], out: &mut Vec<u8>) {
147    for v in vals {
148        write_real_f32(*v, out);
149        out.push(b' ');
150    }
151    push_kw(kw, out);
152}
153
154/// Writes a plain-integer operand, then the keyword.
155fn push_int(value: i32, kw: &[u8], out: &mut Vec<u8>) {
156    out.extend_from_slice(value.to_string().as_bytes());
157    out.push(b' ');
158    push_kw(kw, out);
159}
160
161/// Writes a single name operand, then the keyword.
162fn push_name_op(n: &Name, kw: &[u8], out: &mut Vec<u8>) {
163    write_name(&n.0, out);
164    out.push(b' ');
165    push_kw(kw, out);
166}
167
168/// Writes a single string operand, then the keyword.
169fn push_string_op(s: &[u8], kw: &[u8], out: &mut Vec<u8>) {
170    write_string(s, out);
171    out.push(b' ');
172    push_kw(kw, out);
173}
174
175/// Writes a `[n1 n2 …]` array of reals (the `d` dash array).
176fn push_f32_array(vals: &[f32], out: &mut Vec<u8>) {
177    out.push(b'[');
178    for (i, v) in vals.iter().enumerate() {
179        if i > 0 {
180            out.push(b' ');
181        }
182        write_real_f32(*v, out);
183    }
184    out.push(b']');
185}
186
187/// Writes color components, the optional pattern name, then `SCN`/`scn`.
188fn push_color_n(comps: &[f32], pattern: Option<&Name>, kw: &[u8], out: &mut Vec<u8>) {
189    for v in comps {
190        write_real_f32(*v, out);
191        out.push(b' ');
192    }
193    if let Some(n) = pattern {
194        write_name(&n.0, out);
195        out.push(b' ');
196    }
197    push_kw(kw, out);
198}
199
200/// Writes a `TJ` array: strings and offsets space-separated in brackets.
201fn push_text_adjusted(items: &[TextItem], out: &mut Vec<u8>) {
202    out.push(b'[');
203    for (i, item) in items.iter().enumerate() {
204        if i > 0 {
205            out.push(b' ');
206        }
207        match item {
208            TextItem::Str(s) => write_string(s, out),
209            TextItem::Offset(v) => write_real_f32(*v, out),
210        }
211    }
212    out.extend_from_slice(b"] ");
213    push_kw(b"TJ", out);
214}
215
216/// Writes the tag and properties operands of `DP`/`BDC`, then the keyword.
217fn push_tag_props(tag: &Name, props: &Object, kw: &[u8], out: &mut Vec<u8>) {
218    write_name(&tag.0, out);
219    out.push(b' ');
220    push_object_or_null(props, out);
221    out.push(b' ');
222    push_kw(kw, out);
223}
224
225/// Serializes an object, falling back to `null` on the impossible nested
226/// stream so [`serialize_ops`] stays infallible.
227fn push_object_or_null(obj: &Object, out: &mut Vec<u8>) {
228    let mark = out.len();
229    if serialize_object(obj, out).is_err() {
230        out.truncate(mark);
231        out.extend_from_slice(b"null");
232    }
233}
234
235/// Writes `BI`, the dictionary entries present (keys sorted bytewise),
236/// `ID`, one whitespace byte, the raw data, one whitespace byte, and `EI`.
237fn push_inline_image(img: &ImageParams, out: &mut Vec<u8>) {
238    out.extend_from_slice(b"BI");
239    let mut entries: Vec<(&Name, &Object)> = img.dict.iter().collect();
240    entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
241    for (key, value) in entries {
242        out.push(b' ');
243        write_name(&key.0, out);
244        out.push(b' ');
245        push_object_or_null(value, out);
246    }
247    out.extend_from_slice(b" ID ");
248    out.extend_from_slice(&img.data);
249    out.extend_from_slice(b" EI\n");
250}
251
252#[cfg(test)]
253mod tests {
254    use pdfboss_core::content::{parse_content, ImageParams, TextItem};
255    use pdfboss_core::geom::Matrix;
256    use pdfboss_core::{Dict, Name, Object};
257
258    use super::*;
259
260    /// Number of `Op` variants; [`variant_index`] fails to compile when the
261    /// enum grows, forcing this constant and the sample table to follow.
262    const VARIANT_COUNT: usize = 70;
263
264    fn name(s: &str) -> Name {
265        Name(s.to_string())
266    }
267
268    fn m(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> Matrix {
269        Matrix { a, b, c, d, e, f }
270    }
271
272    fn image(entries: &[(&str, Object)], data: &[u8]) -> Op {
273        let mut dict = Dict::new();
274        for (key, value) in entries {
275            dict.insert(name(key), value.clone());
276        }
277        Op::InlineImage(ImageParams {
278            dict,
279            data: data.to_vec(),
280        })
281    }
282
283    fn round_trip(ops: &[Op]) {
284        let bytes = serialize_ops(ops);
285        let parsed = parse_content(&bytes).unwrap_or_else(|e| {
286            panic!(
287                "serialized ops parse: {e:?}\n{}",
288                String::from_utf8_lossy(&bytes)
289            )
290        });
291        assert_eq!(
292            parsed,
293            ops,
294            "round-trip of {}",
295            String::from_utf8_lossy(&bytes)
296        );
297    }
298
299    fn variant_index(op: &Op) -> usize {
300        match op {
301            Op::Save => 0,
302            Op::Restore => 1,
303            Op::Concat(..) => 2,
304            Op::SetLineWidth(..) => 3,
305            Op::SetLineCap(..) => 4,
306            Op::SetLineJoin(..) => 5,
307            Op::SetMiterLimit(..) => 6,
308            Op::SetDash(..) => 7,
309            Op::SetRenderingIntent(..) => 8,
310            Op::SetFlatness(..) => 9,
311            Op::SetExtGState(..) => 10,
312            Op::MoveTo(..) => 11,
313            Op::LineTo(..) => 12,
314            Op::CurveTo(..) => 13,
315            Op::CurveToV(..) => 14,
316            Op::CurveToY(..) => 15,
317            Op::ClosePath => 16,
318            Op::Rect(..) => 17,
319            Op::Stroke => 18,
320            Op::CloseStroke => 19,
321            Op::Fill => 20,
322            Op::FillEvenOdd => 21,
323            Op::FillStroke => 22,
324            Op::FillStrokeEvenOdd => 23,
325            Op::CloseFillStroke => 24,
326            Op::CloseFillStrokeEvenOdd => 25,
327            Op::EndPath => 26,
328            Op::ClipNonZero => 27,
329            Op::ClipEvenOdd => 28,
330            Op::SetStrokeColorSpace(..) => 29,
331            Op::SetFillColorSpace(..) => 30,
332            Op::SetStrokeColor(..) => 31,
333            Op::SetStrokeColorN(..) => 32,
334            Op::SetFillColor(..) => 33,
335            Op::SetFillColorN(..) => 34,
336            Op::SetStrokeGray(..) => 35,
337            Op::SetFillGray(..) => 36,
338            Op::SetStrokeRGB(..) => 37,
339            Op::SetFillRGB(..) => 38,
340            Op::SetStrokeCMYK(..) => 39,
341            Op::SetFillCMYK(..) => 40,
342            Op::BeginText => 41,
343            Op::EndText => 42,
344            Op::SetCharSpacing(..) => 43,
345            Op::SetWordSpacing(..) => 44,
346            Op::SetHorizScaling(..) => 45,
347            Op::SetLeading(..) => 46,
348            Op::SetFont(..) => 47,
349            Op::SetGlyphWidth(..) => 48,
350            Op::SetGlyphWidthBBox(..) => 49,
351            Op::SetTextRender(..) => 50,
352            Op::SetTextRise(..) => 51,
353            Op::TextMove(..) => 52,
354            Op::TextMoveSetLeading(..) => 53,
355            Op::SetTextMatrix(..) => 54,
356            Op::TextNextLine => 55,
357            Op::ShowText(..) => 56,
358            Op::ShowTextAdjusted(..) => 57,
359            Op::NextLineShowText(..) => 58,
360            Op::NextLineShowTextSpaced(..) => 59,
361            Op::XObject(..) => 60,
362            Op::InlineImage(..) => 61,
363            Op::Shading(..) => 62,
364            Op::MarkedContentPoint(..) => 63,
365            Op::MarkedContentPointProps(..) => 64,
366            Op::BeginMarkedContent(..) => 65,
367            Op::BeginMarkedContentProps(..) => 66,
368            Op::EndMarkedContent => 67,
369            Op::BeginCompat => 68,
370            Op::EndCompat => 69,
371        }
372    }
373
374    fn sample_ops() -> Vec<Op> {
375        let mut props = Dict::new();
376        props.insert(name("MCID"), Object::Int(3));
377        vec![
378            Op::Save,
379            Op::Restore,
380            Op::Concat(m(1.0, 0.0, 0.0, 1.0, 10.5, 20.0)),
381            Op::SetLineWidth(2.5),
382            Op::SetLineCap(1),
383            Op::SetLineJoin(2),
384            Op::SetMiterLimit(3.5),
385            Op::SetDash(vec![3.0, 1.5], 0.5),
386            Op::SetRenderingIntent(name("Perceptual")),
387            Op::SetFlatness(1.5),
388            Op::SetExtGState(name("GS1")),
389            Op::MoveTo(10.0, 20.0),
390            Op::LineTo(-30.5, 40.0),
391            Op::CurveTo(1.0, 2.0, 3.0, 4.0, 5.0, 6.0),
392            Op::CurveToV(1.5, 2.5, 3.5, 4.5),
393            Op::CurveToY(5.0, 6.0, 7.0, 8.0),
394            Op::ClosePath,
395            Op::Rect(72.0, 600.0, 100.0, 80.0),
396            Op::Stroke,
397            Op::CloseStroke,
398            Op::Fill,
399            Op::FillEvenOdd,
400            Op::FillStroke,
401            Op::FillStrokeEvenOdd,
402            Op::CloseFillStroke,
403            Op::CloseFillStrokeEvenOdd,
404            Op::EndPath,
405            Op::ClipNonZero,
406            Op::ClipEvenOdd,
407            Op::SetStrokeColorSpace(name("DeviceRGB")),
408            Op::SetFillColorSpace(name("Pattern")),
409            Op::SetStrokeColor(vec![1.0, 0.5, 0.25]),
410            Op::SetStrokeColorN(vec![0.1, 0.2], Some(name("P2"))),
411            Op::SetFillColor(vec![0.5]),
412            Op::SetFillColorN(vec![0.2, 0.4, 0.6], None),
413            Op::SetStrokeGray(0.3),
414            Op::SetFillGray(0.7),
415            Op::SetStrokeRGB(1.0, 0.0, 0.5),
416            Op::SetFillRGB(0.0, 1.0, 0.0),
417            Op::SetStrokeCMYK(0.0, 0.25, 0.5, 1.0),
418            Op::SetFillCMYK(1.0, 0.0, 0.0, 0.125),
419            Op::BeginText,
420            Op::EndText,
421            Op::SetCharSpacing(0.5),
422            Op::SetWordSpacing(1.5),
423            Op::SetHorizScaling(90.0),
424            Op::SetLeading(14.5),
425            Op::SetFont(name("F1"), 12.0),
426            Op::SetGlyphWidth(1000.0, 0.0),
427            Op::SetGlyphWidthBBox(1000.0, 0.0, 0.0, 0.0, 750.0, 700.0),
428            Op::SetTextRender(3),
429            Op::SetTextRise(4.5),
430            Op::TextMove(72.0, 720.0),
431            Op::TextMoveSetLeading(0.0, -14.0),
432            Op::SetTextMatrix(m(2.0, 0.0, 0.0, 2.0, 50.5, 60.0)),
433            Op::TextNextLine,
434            Op::ShowText(b"Hi (there)\\".to_vec()),
435            Op::ShowTextAdjusted(vec![
436                TextItem::Str(b"He".to_vec()),
437                TextItem::Offset(-120.0),
438                TextItem::Str(b"llo".to_vec()),
439                TextItem::Offset(33.5),
440            ]),
441            Op::NextLineShowText(b"next line".to_vec()),
442            Op::NextLineShowTextSpaced(2.0, 3.0, b"spaced".to_vec()),
443            Op::XObject(name("Im1")),
444            image(
445                &[
446                    ("ColorSpace", Object::Name(name("DeviceGray"))),
447                    ("Width", Object::Int(2)),
448                    ("BitsPerComponent", Object::Int(8)),
449                    ("Height", Object::Int(2)),
450                ],
451                &[0, 255, 128, 127],
452            ),
453            Op::Shading(name("Sh1")),
454            Op::MarkedContentPoint(name("Tag")),
455            Op::MarkedContentPointProps(name("Tag"), Object::Name(name("P"))),
456            Op::BeginMarkedContent(name("Span")),
457            Op::BeginMarkedContentProps(name("Span"), Object::Dict(props)),
458            Op::EndMarkedContent,
459            Op::BeginCompat,
460            Op::EndCompat,
461        ]
462    }
463
464    #[test]
465    fn every_variant_round_trips() {
466        let samples = sample_ops();
467        let covered: std::collections::BTreeSet<usize> =
468            samples.iter().map(variant_index).collect();
469        assert_eq!(covered, (0..VARIANT_COUNT).collect());
470        for op in &samples {
471            round_trip(std::slice::from_ref(op));
472        }
473        round_trip(&samples);
474    }
475
476    #[test]
477    fn mixed_sequence_round_trips() {
478        let mut props = Dict::new();
479        props.insert(name("MCID"), Object::Int(0));
480        let ops = vec![
481            Op::Save,
482            Op::Concat(m(0.5, 0.0, 0.0, 0.5, 300.0, 100.0)),
483            Op::Rect(0.0, 0.0, 200.0, 200.0),
484            Op::Fill,
485            Op::BeginMarkedContentProps(name("P"), Object::Dict(props)),
486            Op::BeginText,
487            Op::SetFont(name("F1"), 12.0),
488            Op::TextMove(72.0, 720.0),
489            Op::ShowText(b"Hello, world".to_vec()),
490            Op::ShowTextAdjusted(vec![
491                TextItem::Str(b"kern".to_vec()),
492                TextItem::Offset(-15.5),
493                TextItem::Str(b"ed".to_vec()),
494            ]),
495            Op::EndText,
496            Op::EndMarkedContent,
497            Op::XObject(name("Im1")),
498            Op::Restore,
499        ];
500        round_trip(&ops);
501    }
502
503    #[test]
504    fn inline_image_hazardous_data_round_trips_via_declared_length() {
505        let op = image(
506            &[
507                ("Width", Object::Int(3)),
508                ("Height", Object::Int(1)),
509                ("BitsPerComponent", Object::Int(8)),
510                ("ColorSpace", Object::Name(name("DeviceRGB"))),
511                ("L", Object::Int(9)),
512            ],
513            b"ab EI wxy",
514        );
515        round_trip(std::slice::from_ref(&op));
516        round_trip(&[op, Op::MoveTo(1.0, 2.0)]);
517    }
518
519    #[test]
520    fn parser_produced_length_image_round_trips() {
521        let mut src = b"BI /W 3 /H 1 /BPC 8 /CS /RGB /L 9 ID ".to_vec();
522        src.extend_from_slice(b"ab EI wxy");
523        src.extend_from_slice(b" EI 1 2 m");
524        let parsed = parse_content(&src).expect("source parses");
525        assert_eq!(parsed.len(), 2);
526        round_trip(&parsed);
527    }
528
529    #[test]
530    fn inline_image_ei_boundary_data_round_trips_without_length() {
531        let base = [("Width", Object::Int(1)), ("Height", Object::Int(1))];
532        for data in [
533            b"noEIhazard".as_slice(),
534            b"EIx\x01".as_slice(),
535            b"zxEI".as_slice(),
536            b"tail ".as_slice(),
537        ] {
538            round_trip(std::slice::from_ref(&image(&base, data)));
539        }
540    }
541
542    #[test]
543    fn f32_hard_cases_survive_round_trip() {
544        let ops = vec![
545            Op::SetLineWidth(0.1),
546            Op::SetLineWidth(1e-7),
547            Op::SetLineWidth(-1e-7),
548            Op::MoveTo(-0.25, -123.456),
549            Op::SetDash(vec![0.1, 1e-7, -0.3], 16_777_216.0),
550            Op::ShowTextAdjusted(vec![TextItem::Offset(-120.25), TextItem::Offset(1e-7)]),
551            Op::SetTextRise(f32::MIN_POSITIVE),
552            Op::SetCharSpacing(3.4e38),
553        ];
554        for op in &ops {
555            round_trip(std::slice::from_ref(op));
556        }
557        round_trip(&ops);
558    }
559
560    #[test]
561    fn empty_operand_containers_round_trip() {
562        let ops = vec![
563            Op::SetDash(vec![], 0.0),
564            Op::ShowTextAdjusted(vec![]),
565            Op::SetStrokeColor(vec![]),
566            Op::SetStrokeColorN(vec![], None),
567            Op::SetFillColorN(vec![], Some(name("P1"))),
568            Op::InlineImage(ImageParams {
569                dict: Dict::new(),
570                data: Vec::new(),
571            }),
572        ];
573        for op in &ops {
574            round_trip(std::slice::from_ref(op));
575        }
576        round_trip(&ops);
577    }
578
579    #[test]
580    fn layout_pins_exact_bytes() {
581        assert_eq!(serialize_ops(&[Op::Save]), b"q\n");
582        assert_eq!(
583            serialize_ops(&[Op::Concat(m(1.0, 0.0, 0.0, 1.0, 10.0, 20.0))]),
584            b"1 0 0 1 10 20 cm\n"
585        );
586        assert_eq!(
587            serialize_ops(&[Op::SetDash(vec![3.0, 1.0], 0.5)]),
588            b"[3 1] 0.5 d\n"
589        );
590        assert_eq!(
591            serialize_ops(&[Op::ShowTextAdjusted(vec![
592                TextItem::Str(b"He".to_vec()),
593                TextItem::Offset(-120.0),
594                TextItem::Str(b"llo".to_vec()),
595            ])]),
596            b"[(He) -120 (llo)] TJ\n"
597        );
598        assert_eq!(
599            serialize_ops(&[Op::SetStrokeColorN(vec![0.1, 0.2], Some(name("P2")))]),
600            b"0.1 0.2 /P2 SCN\n"
601        );
602        assert_eq!(
603            serialize_ops(&[Op::NextLineShowTextSpaced(2.0, 3.0, b"spaced".to_vec())]),
604            b"2 3 (spaced) \"\n"
605        );
606        assert_eq!(
607            serialize_ops(&[Op::SetFont(name("F1"), 12.0)]),
608            b"/F1 12 Tf\n"
609        );
610        assert_eq!(serialize_ops(&[Op::SetTextRender(2)]), b"2 Tr\n");
611        let mut want = b"BI /Width 2 ID ".to_vec();
612        want.extend_from_slice(&[0, 255]);
613        want.extend_from_slice(b" EI\n");
614        assert_eq!(
615            serialize_ops(&[image(&[("Width", Object::Int(2))], &[0, 255])]),
616            want
617        );
618    }
619}