Skip to main content

pdfboss_core/
content.rs

1//! Content-stream operator parsing (ISO 32000 §8/§9), including inline
2//! images. Lenient: unknown operators and arity mismatches are skipped
3//! (the operand stack is cleared), never an error.
4
5use crate::elements::Span;
6use crate::error::{Error, Result};
7use crate::geom::Matrix;
8use crate::lexer::{decode_hex, is_whitespace, Lexer, RawToken, Token};
9use crate::object::{Dict, Name, Object};
10
11/// Maximum operand container (array/dictionary) nesting depth. Composing
12/// values recurses per nesting level, so without a bound a content stream
13/// of e.g. 50k `[`s overflows the stack and aborts the process. Genuine
14/// operands (dash arrays, `TJ` arrays, property dicts) nest one or two
15/// levels deep.
16const MAX_NESTING_DEPTH: usize = 128;
17
18/// The syntax error reported when `MAX_NESTING_DEPTH` is exceeded.
19fn too_deep(lexer: &Lexer) -> Error {
20    Error::Syntax {
21        offset: lexer.pos(),
22        msg: "operand nesting too deep".into(),
23    }
24}
25
26/// An inline image (`BI ... ID ... EI`) with its dictionary keys and
27/// colorspace abbreviations expanded to their canonical names
28/// (e.g. `/BPC` -> `/BitsPerComponent`, `/RGB` -> `/DeviceRGB`).
29#[derive(Debug, Clone, PartialEq)]
30pub struct ImageParams {
31    pub dict: Dict,
32    pub data: Vec<u8>,
33}
34
35/// One element of a `TJ` (show text with adjustments) array.
36#[derive(Debug, Clone, PartialEq)]
37pub enum TextItem {
38    /// A string to show.
39    Str(Vec<u8>),
40    /// A position adjustment in thousandths of text-space units.
41    Offset(f32),
42}
43
44/// A parsed content-stream operator with its operands.
45#[derive(Debug, Clone, PartialEq)]
46pub enum Op {
47    // Graphics state.
48    /// `q`
49    Save,
50    /// `Q`
51    Restore,
52    /// `cm`
53    Concat(Matrix),
54    /// `w`
55    SetLineWidth(f32),
56    /// `J`
57    SetLineCap(i32),
58    /// `j`
59    SetLineJoin(i32),
60    /// `M`
61    SetMiterLimit(f32),
62    /// `d` (dash array, phase)
63    SetDash(Vec<f32>, f32),
64    /// `ri`
65    SetRenderingIntent(Name),
66    /// `i`
67    SetFlatness(f32),
68    /// `gs`
69    SetExtGState(Name),
70
71    // Path construction.
72    /// `m`
73    MoveTo(f32, f32),
74    /// `l`
75    LineTo(f32, f32),
76    /// `c`
77    CurveTo(f32, f32, f32, f32, f32, f32),
78    /// `v` (first control point = current point)
79    CurveToV(f32, f32, f32, f32),
80    /// `y` (second control point = end point)
81    CurveToY(f32, f32, f32, f32),
82    /// `h`
83    ClosePath,
84    /// `re` (x, y, width, height)
85    Rect(f32, f32, f32, f32),
86
87    // Path painting.
88    /// `S`
89    Stroke,
90    /// `s`
91    CloseStroke,
92    /// `f` / `F`
93    Fill,
94    /// `f*`
95    FillEvenOdd,
96    /// `B`
97    FillStroke,
98    /// `B*`
99    FillStrokeEvenOdd,
100    /// `b`
101    CloseFillStroke,
102    /// `b*`
103    CloseFillStrokeEvenOdd,
104    /// `n`
105    EndPath,
106    /// `W`
107    ClipNonZero,
108    /// `W*`
109    ClipEvenOdd,
110
111    // Color.
112    /// `CS`
113    SetStrokeColorSpace(Name),
114    /// `cs`
115    SetFillColorSpace(Name),
116    /// `SC`
117    SetStrokeColor(Vec<f32>),
118    /// `SCN` (components, optional pattern name)
119    SetStrokeColorN(Vec<f32>, Option<Name>),
120    /// `sc`
121    SetFillColor(Vec<f32>),
122    /// `scn` (components, optional pattern name)
123    SetFillColorN(Vec<f32>, Option<Name>),
124    /// `G`
125    SetStrokeGray(f32),
126    /// `g`
127    SetFillGray(f32),
128    /// `RG`
129    SetStrokeRGB(f32, f32, f32),
130    /// `rg`
131    SetFillRGB(f32, f32, f32),
132    /// `K`
133    SetStrokeCMYK(f32, f32, f32, f32),
134    /// `k`
135    SetFillCMYK(f32, f32, f32, f32),
136
137    // Text.
138    /// `BT`
139    BeginText,
140    /// `ET`
141    EndText,
142    /// `Tc`
143    SetCharSpacing(f32),
144    /// `Tw`
145    SetWordSpacing(f32),
146    /// `Tz`
147    SetHorizScaling(f32),
148    /// `TL`
149    SetLeading(f32),
150    /// `Tf` (font resource name, size)
151    SetFont(Name, f32),
152    /// `d0` (wx, wy): sets the glyph width for a colored Type3 glyph,
153    /// whose content sets its own color (ISO 32000-1 Table 113).
154    SetGlyphWidth(f32, f32),
155    /// `d1` (wx, wy, llx, lly, urx, ury): sets the glyph width and bounding
156    /// box for an uncolored Type3 glyph description; color comes from the
157    /// text state and color operators in the proc are ignored
158    /// (ISO 32000-1 Table 113).
159    SetGlyphWidthBBox(f32, f32, f32, f32, f32, f32),
160    /// `Tr`
161    SetTextRender(i32),
162    /// `Ts`
163    SetTextRise(f32),
164    /// `Td`
165    TextMove(f32, f32),
166    /// `TD`
167    TextMoveSetLeading(f32, f32),
168    /// `Tm`
169    SetTextMatrix(Matrix),
170    /// `T*`
171    TextNextLine,
172    /// `Tj`
173    ShowText(Vec<u8>),
174    /// `TJ`
175    ShowTextAdjusted(Vec<TextItem>),
176    /// `'`
177    NextLineShowText(Vec<u8>),
178    /// `"` (word spacing, char spacing, string)
179    NextLineShowTextSpaced(f32, f32, Vec<u8>),
180
181    // XObjects, images, shading, marked content.
182    /// `Do`
183    XObject(Name),
184    /// `BI ... ID ... EI`
185    InlineImage(ImageParams),
186    /// `sh`
187    Shading(Name),
188    /// `MP`
189    MarkedContentPoint(Name),
190    /// `DP` (tag, properties: inline dict or resource name)
191    MarkedContentPointProps(Name, Object),
192    /// `BMC`
193    BeginMarkedContent(Name),
194    /// `BDC` (tag, properties: inline dict or resource name)
195    BeginMarkedContentProps(Name, Object),
196    /// `EMC`
197    EndMarkedContent,
198    /// `BX`
199    BeginCompat,
200    /// `EX`
201    EndCompat,
202}
203
204/// Parses a decoded content stream into a sequence of operators. Inline
205/// image data runs from after `ID` plus one whitespace byte to `EI` at a
206/// token boundary (or the declared `/L`ength when present, which is
207/// trusted).
208pub fn parse_content(data: &[u8]) -> Result<Vec<Op>> {
209    let mut ops = Vec::with_capacity(ops_estimate(data.len()));
210    parse_content_ops(data, |op, _| ops.push(op))?;
211    Ok(ops)
212}
213
214/// Like [`parse_content`], but also reports each operator's byte range —
215/// from the first token of its operand run through the operator keyword —
216/// within `data`.
217pub fn parse_content_spanned(data: &[u8]) -> Result<Vec<(Op, Span)>> {
218    let mut ops = Vec::with_capacity(ops_estimate(data.len()));
219    parse_content_ops(data, |op, span| ops.push((op, span)))?;
220    Ok(ops)
221}
222
223/// Initial operator-vector capacity for a content stream of `len` bytes.
224/// An operator run (operands, keyword, separators) averages well above 16
225/// bytes, so `len / 16` reserves slightly high instead of growing; the cap
226/// keeps a huge stream — usually mostly inline-image data — from
227/// pre-claiming space for operators it may never produce.
228fn ops_estimate(len: usize) -> usize {
229    (len / 16).min(1 << 17)
230}
231
232/// The parse loop behind both public entry points, as a wrapper over the
233/// pull parser: each finished operator is handed to `emit` — so the
234/// span-less caller never materializes spans it will throw away.
235fn parse_content_ops(data: &[u8], mut emit: impl FnMut(Op, Span)) -> Result<()> {
236    let mut ops = ContentOps::new(data);
237    while let Some((op, span)) = ops.next_op()? {
238        emit(op, span);
239    }
240    Ok(())
241}
242
243/// A pull parser over one content stream: each [`ContentOps::next_op`]
244/// yields the next operator with its byte range, in stream order — an
245/// executor consumes operators one at a time without materializing the
246/// whole vector.
247pub struct ContentOps<'a> {
248    lexer: Lexer<'a>,
249    stack: Vec<Object>,
250    /// Start of the current operand run; cleared whenever the run dies
251    /// (an operator was emitted, an unknown operator dropped it, or a
252    /// stray closer flushed it).
253    run_start: Option<usize>,
254}
255
256impl<'a> ContentOps<'a> {
257    /// Starts a pull parse at the beginning of `data`.
258    pub fn new(data: &'a [u8]) -> ContentOps<'a> {
259        ContentOps::at(data, 0)
260    }
261
262    /// Resumes a pull parse at byte offset `pos`. The operand stack is
263    /// empty at every operator boundary, so the offset [`ContentOps::pos`]
264    /// reported between two `next_op` calls is the parser's whole state —
265    /// an executor that suspends a stream mid-walk (a form invocation)
266    /// rebuilds from it exactly.
267    pub fn at(data: &'a [u8], pos: usize) -> ContentOps<'a> {
268        ContentOps {
269            lexer: Lexer::at(data, pos),
270            stack: Vec::new(),
271            run_start: None,
272        }
273    }
274
275    /// The cursor's byte offset — a resume point between `next_op` calls.
276    pub fn pos(&self) -> usize {
277        self.lexer.pos()
278    }
279
280    /// Parses forward to the next complete operator and returns it with
281    /// its span, or `None` at the end of the stream.
282    pub fn next_op(&mut self) -> Result<Option<(Op, Span)>> {
283        loop {
284            let (token_start, raw) = self.lexer.next_raw_token_spanned()?;
285            let kw = match raw {
286                RawToken::Keyword(kw) => {
287                    if self.run_start.is_none() {
288                        self.run_start = Some(token_start);
289                    }
290                    kw
291                }
292                RawToken::Hex(span) => {
293                    if self.run_start.is_none() {
294                        self.run_start = Some(token_start);
295                    }
296                    self.stack.push(Object::String(decode_hex(span)));
297                    continue;
298                }
299                RawToken::Owned(token) => {
300                    if !matches!(token, Token::Eof) && self.run_start.is_none() {
301                        self.run_start = Some(token_start);
302                    }
303                    match token {
304                        Token::Eof => return Ok(None),
305                        Token::Int(i) => self.stack.push(Object::Int(i)),
306                        Token::Real(r) => self.stack.push(Object::Real(r)),
307                        Token::Name(n) => self.stack.push(Object::Name(n)),
308                        Token::LitString(s) | Token::HexString(s) => {
309                            self.stack.push(Object::String(s))
310                        }
311                        Token::ArrayOpen => {
312                            let a = parse_array(&mut self.lexer, 0)?;
313                            self.stack.push(a);
314                        }
315                        Token::DictOpen => {
316                            let d = parse_dict(&mut self.lexer, 0)?;
317                            self.stack.push(Object::Dict(d));
318                        }
319                        // Stray closers: malformed input, drop pending operands.
320                        Token::ArrayClose | Token::DictClose => {
321                            self.stack.clear();
322                            self.run_start = None;
323                        }
324                        Token::Keyword(_) => {
325                            unreachable!("next_raw_token yields keywords borrowed")
326                        }
327                    }
328                    continue;
329                }
330            };
331            match kw {
332                b"true" => self.stack.push(Object::Bool(true)),
333                b"false" => self.stack.push(Object::Bool(false)),
334                b"null" => self.stack.push(Object::Null),
335                b"BI" => {
336                    let start = self.run_start.take().unwrap_or(token_start);
337                    let image = parse_inline_image(&mut self.lexer);
338                    self.stack.clear();
339                    if let Some(op) = image {
340                        let span = Span::new(start as u64, self.lexer.pos() as u64);
341                        return Ok(Some((op, span)));
342                    }
343                }
344                _ => {
345                    let start = self.run_start.take().unwrap_or(token_start);
346                    let op = dispatch(kw, &mut self.stack);
347                    self.stack.clear();
348                    if let Some(op) = op {
349                        let span = Span::new(start as u64, self.lexer.pos() as u64);
350                        return Ok(Some((op, span)));
351                    }
352                }
353            }
354        }
355    }
356}
357
358/// Composes an array value; the opening `[` has already been consumed.
359/// Unexpected tokens inside are skipped leniently; nesting deeper than
360/// `MAX_NESTING_DEPTH` is a syntax error.
361fn parse_array(lexer: &mut Lexer, depth: usize) -> Result<Object> {
362    if depth >= MAX_NESTING_DEPTH {
363        return Err(too_deep(lexer));
364    }
365    let mut items = Vec::new();
366    loop {
367        match lexer.next_raw_token()? {
368            RawToken::Owned(Token::ArrayClose | Token::Eof) => break,
369            raw => {
370                if let Some(v) = compose_raw_value(lexer, raw, depth)? {
371                    items.push(v);
372                }
373            }
374        }
375    }
376    Ok(Object::Array(items))
377}
378
379/// Numeric coercion for operands: `Int` and `Real` both become `f32`.
380fn num(o: &Object) -> Option<f32> {
381    match o {
382        Object::Int(i) => Some(*i as f32),
383        Object::Real(r) => Some(*r as f32),
384        _ => None,
385    }
386}
387
388/// The last `N` operands as numbers, or `None` on arity/type mismatch.
389fn nums<const N: usize>(stack: &[Object]) -> Option<[f32; N]> {
390    if stack.len() < N {
391        return None;
392    }
393    let tail = &stack[stack.len() - N..];
394    let mut out = [0.0f32; N];
395    for (slot, o) in out.iter_mut().zip(tail) {
396        *slot = num(o)?;
397    }
398    Some(out)
399}
400
401/// The last operand as an integer (`Real` truncated leniently).
402fn int1(stack: &[Object]) -> Option<i32> {
403    match stack.last()? {
404        Object::Int(i) => Some(*i as i32),
405        Object::Real(r) => Some(*r as i32),
406        _ => None,
407    }
408}
409
410/// The last operand as a name, taken off the stack: the caller clears the
411/// stack after every dispatch, so operands are moved out, never cloned.
412fn name1(stack: &mut [Object]) -> Option<Name> {
413    match stack.last_mut()? {
414        Object::Name(n) => Some(Name(std::mem::take(&mut n.0))),
415        _ => None,
416    }
417}
418
419/// The last operand as a string's bytes, taken off the stack like [`name1`].
420fn str1(stack: &mut [Object]) -> Option<Vec<u8>> {
421    match stack.last_mut()? {
422        Object::String(s) => Some(std::mem::take(s)),
423        _ => None,
424    }
425}
426
427/// Every operand on the stack as a number (for `SC`/`sc`).
428fn all_nums(stack: &[Object]) -> Option<Vec<f32>> {
429    stack.iter().map(num).collect()
430}
431
432/// A six-number tail as a [`Matrix`] (for `cm`/`Tm`).
433fn matrix(stack: &[Object]) -> Option<Matrix> {
434    let [a, b, c, d, e, f] = nums::<6>(stack)?;
435    Some(Matrix { a, b, c, d, e, f })
436}
437
438/// Composes a dictionary; the opening `<<` has already been consumed.
439/// Nesting deeper than `MAX_NESTING_DEPTH` is a syntax error.
440fn parse_dict(lexer: &mut Lexer, depth: usize) -> Result<Dict> {
441    if depth >= MAX_NESTING_DEPTH {
442        return Err(too_deep(lexer));
443    }
444    let mut dict = Dict::new();
445    loop {
446        match lexer.next_raw_token()? {
447            RawToken::Owned(Token::DictClose | Token::Eof) => break,
448            RawToken::Owned(Token::Name(key)) => {
449                let raw = lexer.next_raw_token()?;
450                if matches!(raw, RawToken::Owned(Token::DictClose | Token::Eof)) {
451                    // Key with no value: drop it.
452                    break;
453                }
454                if let Some(v) = compose_raw_value(lexer, raw, depth)? {
455                    dict.insert(key, v);
456                }
457            }
458            // Non-name key: malformed, skip the token.
459            _ => {}
460        }
461    }
462    Ok(dict)
463}
464
465/// Maps an operator keyword plus its operand stack to a typed [`Op`].
466/// Returns `None` (operator skipped) for unknown keywords or operand
467/// arity/type mismatches.
468///
469/// The stack is dispatch's to consume — the caller clears it after every
470/// keyword — so heap operands (strings, names) are moved into the `Op`
471/// rather than cloned, and a mismatch may leave the stack partially
472/// emptied.
473fn dispatch(kw: &[u8], stack: &mut [Object]) -> Option<Op> {
474    Some(match kw {
475        // Graphics state.
476        b"q" => Op::Save,
477        b"Q" => Op::Restore,
478        b"cm" => Op::Concat(matrix(stack)?),
479        b"w" => Op::SetLineWidth(nums::<1>(stack)?[0]),
480        b"J" => Op::SetLineCap(int1(stack)?),
481        b"j" => Op::SetLineJoin(int1(stack)?),
482        b"M" => Op::SetMiterLimit(nums::<1>(stack)?[0]),
483        b"d" => {
484            if stack.len() < 2 {
485                return None;
486            }
487            let phase = num(&stack[stack.len() - 1])?;
488            let Object::Array(items) = &stack[stack.len() - 2] else {
489                return None;
490            };
491            let dashes: Vec<f32> = items.iter().map(num).collect::<Option<_>>()?;
492            Op::SetDash(dashes, phase)
493        }
494        b"ri" => Op::SetRenderingIntent(name1(stack)?),
495        b"i" => Op::SetFlatness(nums::<1>(stack)?[0]),
496        b"gs" => Op::SetExtGState(name1(stack)?),
497
498        // Path construction.
499        b"m" => {
500            let [x, y] = nums(stack)?;
501            Op::MoveTo(x, y)
502        }
503        b"l" => {
504            let [x, y] = nums(stack)?;
505            Op::LineTo(x, y)
506        }
507        b"c" => {
508            let [x1, y1, x2, y2, x3, y3] = nums(stack)?;
509            Op::CurveTo(x1, y1, x2, y2, x3, y3)
510        }
511        b"v" => {
512            let [x2, y2, x3, y3] = nums(stack)?;
513            Op::CurveToV(x2, y2, x3, y3)
514        }
515        b"y" => {
516            let [x1, y1, x3, y3] = nums(stack)?;
517            Op::CurveToY(x1, y1, x3, y3)
518        }
519        b"h" => Op::ClosePath,
520        b"re" => {
521            let [x, y, w, h] = nums(stack)?;
522            Op::Rect(x, y, w, h)
523        }
524
525        // Path painting and clipping.
526        b"S" => Op::Stroke,
527        b"s" => Op::CloseStroke,
528        b"f" | b"F" => Op::Fill,
529        b"f*" => Op::FillEvenOdd,
530        b"B" => Op::FillStroke,
531        b"B*" => Op::FillStrokeEvenOdd,
532        b"b" => Op::CloseFillStroke,
533        b"b*" => Op::CloseFillStrokeEvenOdd,
534        b"n" => Op::EndPath,
535        b"W" => Op::ClipNonZero,
536        b"W*" => Op::ClipEvenOdd,
537
538        _ => return dispatch_color_text(kw, stack),
539    })
540}
541
542/// Continuation of [`dispatch`]: color and text operators.
543fn dispatch_color_text(kw: &[u8], stack: &mut [Object]) -> Option<Op> {
544    Some(match kw {
545        // Color.
546        b"CS" => Op::SetStrokeColorSpace(name1(stack)?),
547        b"cs" => Op::SetFillColorSpace(name1(stack)?),
548        b"SC" => Op::SetStrokeColor(all_nums(stack)?),
549        b"sc" => Op::SetFillColor(all_nums(stack)?),
550        b"SCN" => {
551            let (comps, pattern) = color_n(stack)?;
552            Op::SetStrokeColorN(comps, pattern)
553        }
554        b"scn" => {
555            let (comps, pattern) = color_n(stack)?;
556            Op::SetFillColorN(comps, pattern)
557        }
558        b"G" => Op::SetStrokeGray(nums::<1>(stack)?[0]),
559        b"g" => Op::SetFillGray(nums::<1>(stack)?[0]),
560        b"RG" => {
561            let [r, g, b] = nums(stack)?;
562            Op::SetStrokeRGB(r, g, b)
563        }
564        b"rg" => {
565            let [r, g, b] = nums(stack)?;
566            Op::SetFillRGB(r, g, b)
567        }
568        b"K" => {
569            let [c, m, y, k] = nums(stack)?;
570            Op::SetStrokeCMYK(c, m, y, k)
571        }
572        b"k" => {
573            let [c, m, y, k] = nums(stack)?;
574            Op::SetFillCMYK(c, m, y, k)
575        }
576
577        // Text.
578        b"BT" => Op::BeginText,
579        b"ET" => Op::EndText,
580        b"Tc" => Op::SetCharSpacing(nums::<1>(stack)?[0]),
581        b"Tw" => Op::SetWordSpacing(nums::<1>(stack)?[0]),
582        b"Tz" => Op::SetHorizScaling(nums::<1>(stack)?[0]),
583        b"TL" => Op::SetLeading(nums::<1>(stack)?[0]),
584        b"Tf" => {
585            if stack.len() < 2 {
586                return None;
587            }
588            let size = num(&stack[stack.len() - 1])?;
589            let at = stack.len() - 2;
590            let Object::Name(font) = &mut stack[at] else {
591                return None;
592            };
593            Op::SetFont(Name(std::mem::take(&mut font.0)), size)
594        }
595        b"d0" => {
596            let [wx, wy] = nums::<2>(stack)?;
597            Op::SetGlyphWidth(wx, wy)
598        }
599        b"d1" => {
600            let [wx, wy, llx, lly, urx, ury] = nums::<6>(stack)?;
601            Op::SetGlyphWidthBBox(wx, wy, llx, lly, urx, ury)
602        }
603        b"Tr" => Op::SetTextRender(int1(stack)?),
604        b"Ts" => Op::SetTextRise(nums::<1>(stack)?[0]),
605        b"Td" => {
606            let [tx, ty] = nums(stack)?;
607            Op::TextMove(tx, ty)
608        }
609        b"TD" => {
610            let [tx, ty] = nums(stack)?;
611            Op::TextMoveSetLeading(tx, ty)
612        }
613        b"Tm" => Op::SetTextMatrix(matrix(stack)?),
614        b"T*" => Op::TextNextLine,
615        b"Tj" => Op::ShowText(str1(stack)?),
616        b"TJ" => {
617            let Object::Array(items) = stack.last_mut()? else {
618                return None;
619            };
620            let mut adjusted = Vec::with_capacity(items.len());
621            adjusted.extend(items.iter_mut().filter_map(|o| match o {
622                Object::String(s) => Some(TextItem::Str(std::mem::take(s))),
623                other => num(other).map(TextItem::Offset),
624            }));
625            Op::ShowTextAdjusted(adjusted)
626        }
627        b"'" => Op::NextLineShowText(str1(stack)?),
628        b"\"" => {
629            if stack.len() < 3 {
630                return None;
631            }
632            let s = str1(stack)?;
633            let [aw, ac] = nums_at::<2>(stack, stack.len() - 3)?;
634            Op::NextLineShowTextSpaced(aw, ac, s)
635        }
636
637        _ => return dispatch_misc(kw, stack),
638    })
639}
640
641/// Continuation of [`dispatch`]: XObject, shading, marked-content, and
642/// compatibility operators.
643fn dispatch_misc(kw: &[u8], stack: &mut [Object]) -> Option<Op> {
644    Some(match kw {
645        b"Do" => Op::XObject(name1(stack)?),
646        b"sh" => Op::Shading(name1(stack)?),
647        b"MP" => Op::MarkedContentPoint(name1(stack)?),
648        b"DP" => {
649            let (tag, props) = tag_props(stack)?;
650            Op::MarkedContentPointProps(tag, props)
651        }
652        b"BMC" => Op::BeginMarkedContent(name1(stack)?),
653        b"BDC" => {
654            let (tag, props) = tag_props(stack)?;
655            Op::BeginMarkedContentProps(tag, props)
656        }
657        b"EMC" => Op::EndMarkedContent,
658        b"BX" => Op::BeginCompat,
659        b"EX" => Op::EndCompat,
660        _ => return None,
661    })
662}
663
664/// `N` numbers starting at `start` (for operators whose numeric operands
665/// are not last on the stack).
666fn nums_at<const N: usize>(stack: &[Object], start: usize) -> Option<[f32; N]> {
667    let slice = stack.get(start..start + N)?;
668    let mut out = [0.0f32; N];
669    for (slot, o) in out.iter_mut().zip(slice) {
670        *slot = num(o)?;
671    }
672    Some(out)
673}
674
675/// Operands of `SCN`/`scn`: numeric components optionally followed by a
676/// pattern name (taken off the stack like [`name1`]).
677fn color_n(stack: &mut [Object]) -> Option<(Vec<f32>, Option<Name>)> {
678    match stack.last_mut() {
679        Some(Object::Name(n)) => {
680            let pattern = Name(std::mem::take(&mut n.0));
681            let comps = all_nums(&stack[..stack.len() - 1])?;
682            Some((comps, Some(pattern)))
683        }
684        _ => Some((all_nums(stack)?, None)),
685    }
686}
687
688/// Operands of `DP`/`BDC`: a tag name and a properties value (an inline
689/// dictionary or a resource name), both taken off the stack like [`name1`].
690fn tag_props(stack: &mut [Object]) -> Option<(Name, Object)> {
691    if stack.len() < 2 {
692        return None;
693    }
694    let last = stack.len() - 1;
695    let props = match &stack[last] {
696        Object::Dict(_) | Object::Name(_) => std::mem::replace(&mut stack[last], Object::Null),
697        _ => return None,
698    };
699    let Object::Name(tag) = &mut stack[last - 1] else {
700        return None;
701    };
702    Some((Name(std::mem::take(&mut tag.0)), props))
703}
704
705/// Parses an inline image; the `BI` keyword has already been consumed.
706/// Returns `None` (image skipped) when the stream ends before `ID` or no
707/// `EI` terminator can be located.
708fn parse_inline_image(lexer: &mut Lexer) -> Option<Op> {
709    let mut dict = Dict::new();
710    loop {
711        match lexer.next_token().ok()? {
712            Token::Keyword(kw) if kw == b"ID" => break,
713            Token::Eof => return None,
714            Token::Name(key) => {
715                let tok = lexer.next_token().ok()?;
716                if matches!(tok, Token::Eof) {
717                    return None;
718                }
719                let Some(value) = compose_value(lexer, tok, 0).ok()? else {
720                    continue;
721                };
722                let canon = expand_image_key(&key.0);
723                let value = if canon == "ColorSpace" {
724                    expand_colorspace(value)
725                } else {
726                    value
727                };
728                dict.insert(Name(canon.to_string()), value);
729            }
730            // Malformed entry: skip the stray token.
731            _ => {}
732        }
733    }
734    let bytes = lexer.data();
735    let mut start = lexer.pos();
736    // Exactly one whitespace byte separates `ID` from the image data.
737    if bytes.get(start).is_some_and(|&b| is_whitespace(b)) {
738        start += 1;
739    }
740    let declared = dict.get_int("Length").or_else(|| dict.get_int("L"));
741    let data = if let Some(n) = declared.and_then(|n| usize::try_from(n).ok()) {
742        // A declared length is trusted.
743        let end = (start + n).min(bytes.len());
744        lexer.seek(end);
745        bytes[start..end].to_vec()
746    } else {
747        let Some(ei) = find_ei(bytes, start) else {
748            // No terminator anywhere: drop the image and stop lexing what
749            // can only be binary garbage.
750            lexer.seek(bytes.len());
751            return None;
752        };
753        // The whitespace byte before `EI` is a separator, not data.
754        let mut end = ei;
755        if end > start && is_whitespace(bytes[end - 1]) {
756            end -= 1;
757        }
758        lexer.seek(ei);
759        bytes[start..end].to_vec()
760    };
761    consume_ei(lexer);
762    Some(Op::InlineImage(ImageParams { dict, data }))
763}
764
765/// Finds the offset of an `EI` terminator at a token boundary: preceded
766/// by whitespace (or the data start) and followed by a non-regular byte
767/// or end of input.
768fn find_ei(bytes: &[u8], start: usize) -> Option<usize> {
769    let mut from = start;
770    while let Some(off) = memchr::memchr(b'E', &bytes[from..]) {
771        let p = from + off;
772        from = p + 1;
773        if bytes.get(p + 1) != Some(&b'I') {
774            continue;
775        }
776        let before_ok = p == start || is_whitespace(bytes[p - 1]);
777        let after_ok = match bytes.get(p + 2) {
778            None => true,
779            Some(&b) => !crate::lexer::is_regular(b),
780        };
781        if before_ok && after_ok {
782            return Some(p);
783        }
784    }
785    None
786}
787
788/// Consumes the `EI` keyword after inline image data, scanning forward
789/// leniently if the very next token is something else.
790fn consume_ei(lexer: &mut Lexer) {
791    let save = lexer.pos();
792    if let Ok(Token::Keyword(kw)) = lexer.next_token() {
793        if kw == b"EI" {
794            return;
795        }
796    }
797    match find_ei(lexer.data(), save) {
798        Some(p) => lexer.seek(p + 2),
799        None => lexer.seek(lexer.data().len()),
800    }
801}
802
803/// Canonical spelling of an inline image dictionary key (ISO 32000
804/// §8.9.7, Table 91).
805fn expand_image_key(key: &str) -> &str {
806    match key {
807        "BPC" => "BitsPerComponent",
808        "CS" => "ColorSpace",
809        "D" => "Decode",
810        "DP" => "DecodeParms",
811        "F" => "Filter",
812        "H" => "Height",
813        "W" => "Width",
814        "IM" => "ImageMask",
815        "I" => "Interpolate",
816        other => other,
817    }
818}
819
820/// Expands colorspace name abbreviations inside a `/CS` value, including
821/// names nested in an `Indexed` array.
822fn expand_colorspace(value: Object) -> Object {
823    match value {
824        Object::Name(Name(n)) => Object::Name(Name(match n.as_str() {
825            "G" => "DeviceGray".to_string(),
826            "RGB" => "DeviceRGB".to_string(),
827            "CMYK" => "DeviceCMYK".to_string(),
828            "I" => "Indexed".to_string(),
829            _ => n,
830        })),
831        Object::Array(items) => Object::Array(items.into_iter().map(expand_colorspace).collect()),
832        other => other,
833    }
834}
835
836/// [`compose_value`] over a raw token: a borrowed keyword matches the
837/// three value keywords without the copy [`Lexer::next_token`] would
838/// make, and a hex span decodes straight to its string bytes.
839fn compose_raw_value(lexer: &mut Lexer, raw: RawToken, depth: usize) -> Result<Option<Object>> {
840    match raw {
841        RawToken::Owned(tok) => compose_value(lexer, tok, depth),
842        RawToken::Keyword(kw) => Ok(match kw {
843            b"true" => Some(Object::Bool(true)),
844            b"false" => Some(Object::Bool(false)),
845            b"null" => Some(Object::Null),
846            _ => None,
847        }),
848        RawToken::Hex(span) => Ok(Some(Object::String(decode_hex(span)))),
849    }
850}
851
852/// Turns a leading token into an [`Object`], recursing for containers
853/// (bounded by `MAX_NESTING_DEPTH`). Returns `None` for tokens that
854/// carry no value (stray delimiters, unexpected keywords).
855fn compose_value(lexer: &mut Lexer, tok: Token, depth: usize) -> Result<Option<Object>> {
856    Ok(match tok {
857        Token::Int(i) => Some(Object::Int(i)),
858        Token::Real(r) => Some(Object::Real(r)),
859        Token::Name(n) => Some(Object::Name(n)),
860        Token::LitString(s) | Token::HexString(s) => Some(Object::String(s)),
861        Token::ArrayOpen => Some(parse_array(lexer, depth + 1)?),
862        Token::DictOpen => Some(Object::Dict(parse_dict(lexer, depth + 1)?)),
863        Token::Keyword(kw) => match kw.as_slice() {
864            b"true" => Some(Object::Bool(true)),
865            b"false" => Some(Object::Bool(false)),
866            b"null" => Some(Object::Null),
867            _ => None,
868        },
869        _ => None,
870    })
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    fn ops(src: &[u8]) -> Vec<Op> {
878        parse_content(src).expect("content parses")
879    }
880
881    fn name(s: &str) -> Name {
882        Name(s.to_string())
883    }
884
885    #[test]
886    fn shapes_fixture_parses_to_expected_ops() {
887        let path = concat!(
888            env!("CARGO_MANIFEST_DIR"),
889            "/../../tests/fixtures/shapes.pdf"
890        );
891        let doc = crate::document::Document::open(path).expect("open shapes.pdf");
892        let page = doc.page(0).expect("page 0");
893        let content = page.content(&doc).expect("page content");
894        let got = ops(&content);
895        let m = |a, b, c, d, e, f| Matrix { a, b, c, d, e, f };
896        assert_eq!(
897            got,
898            vec![
899                Op::SetFillRGB(1.0, 0.0, 0.0),
900                Op::Rect(72.0, 600.0, 100.0, 80.0),
901                Op::Fill,
902                Op::SetFillRGB(0.0, 0.5, 1.0),
903                Op::Rect(200.0, 600.0, 120.0, 60.0),
904                Op::Fill,
905                Op::SetFillRGB(0.2, 0.8, 0.2),
906                Op::Rect(340.0, 590.0, 90.0, 90.0),
907                Op::Fill,
908                Op::SetStrokeRGB(0.0, 0.0, 0.0),
909                Op::SetLineWidth(2.0),
910                Op::MoveTo(100.0, 300.0),
911                Op::CurveTo(150.0, 400.0, 250.0, 400.0, 300.0, 300.0),
912                Op::Stroke,
913                Op::Save,
914                Op::Concat(m(0.5, 0.0, 0.0, 0.5, 300.0, 100.0)),
915                Op::SetFillRGB(0.8, 0.0, 0.8),
916                Op::Rect(0.0, 0.0, 200.0, 200.0),
917                Op::Fill,
918                Op::Restore,
919            ]
920        );
921    }
922
923    #[test]
924    fn graphics_state_ops_round_trip() {
925        let got = ops(b"q Q 1 0 0 1 10 20 cm 2 w 1 J 2 j 3.5 M [3 1] 0.5 d \
926                        /Perceptual ri 1 i /GS1 gs");
927        assert_eq!(
928            got,
929            vec![
930                Op::Save,
931                Op::Restore,
932                Op::Concat(Matrix {
933                    a: 1.0,
934                    b: 0.0,
935                    c: 0.0,
936                    d: 1.0,
937                    e: 10.0,
938                    f: 20.0
939                }),
940                Op::SetLineWidth(2.0),
941                Op::SetLineCap(1),
942                Op::SetLineJoin(2),
943                Op::SetMiterLimit(3.5),
944                Op::SetDash(vec![3.0, 1.0], 0.5),
945                Op::SetRenderingIntent(name("Perceptual")),
946                Op::SetFlatness(1.0),
947                Op::SetExtGState(name("GS1")),
948            ]
949        );
950    }
951
952    #[test]
953    fn empty_dash_array_round_trips() {
954        assert_eq!(ops(b"[] 0 d"), vec![Op::SetDash(vec![], 0.0)]);
955    }
956
957    #[test]
958    fn path_ops_round_trip() {
959        let got = ops(b"10 20 m 30 40 l 1 2 3 4 5 6 c 1 2 3 4 v 5 6 7 8 y h \
960                        72 600 100 80 re");
961        assert_eq!(
962            got,
963            vec![
964                Op::MoveTo(10.0, 20.0),
965                Op::LineTo(30.0, 40.0),
966                Op::CurveTo(1.0, 2.0, 3.0, 4.0, 5.0, 6.0),
967                Op::CurveToV(1.0, 2.0, 3.0, 4.0),
968                Op::CurveToY(5.0, 6.0, 7.0, 8.0),
969                Op::ClosePath,
970                Op::Rect(72.0, 600.0, 100.0, 80.0),
971            ]
972        );
973    }
974
975    #[test]
976    fn negative_and_real_coordinates_coerce() {
977        assert_eq!(
978            ops(b"-1.5 +2 m .5 -3 l"),
979            vec![Op::MoveTo(-1.5, 2.0), Op::LineTo(0.5, -3.0)]
980        );
981    }
982
983    #[test]
984    fn xobject_shading_marked_content_round_trip() {
985        let got = ops(b"/Im1 Do /Sh1 sh /Tag MP /Tag /P DP /Span BMC \
986                        /Span << /MCID 3 >> BDC EMC BX EX");
987        let mut props = Dict::new();
988        props.insert(name("MCID"), Object::Int(3));
989        assert_eq!(
990            got,
991            vec![
992                Op::XObject(name("Im1")),
993                Op::Shading(name("Sh1")),
994                Op::MarkedContentPoint(name("Tag")),
995                Op::MarkedContentPointProps(name("Tag"), Object::Name(name("P"))),
996                Op::BeginMarkedContent(name("Span")),
997                Op::BeginMarkedContentProps(name("Span"), Object::Dict(props)),
998                Op::EndMarkedContent,
999                Op::BeginCompat,
1000                Op::EndCompat,
1001            ]
1002        );
1003    }
1004
1005    #[test]
1006    fn unknown_operator_is_skipped_without_breaking_following_ops() {
1007        assert_eq!(
1008            ops(b"zz 1 2 10 20 m 30 40 l"),
1009            vec![Op::MoveTo(10.0, 20.0), Op::LineTo(30.0, 40.0)]
1010        );
1011        // Unknown operator with operands before it: operands are dropped.
1012        assert_eq!(ops(b"1 2 zz 3 4 l"), vec![Op::LineTo(3.0, 4.0)]);
1013    }
1014
1015    #[test]
1016    fn malformed_operands_are_skipped() {
1017        // Too few operands.
1018        assert_eq!(ops(b"1 m 5 6 l"), vec![Op::LineTo(5.0, 6.0)]);
1019        // Wrong operand types.
1020        assert_eq!(ops(b"(a) (b) m S"), vec![Op::Stroke]);
1021        assert_eq!(ops(b"/N w"), Vec::<Op>::new());
1022        assert_eq!(ops(b"5 Tf"), Vec::<Op>::new());
1023        // `TJ` whose operand is not an array.
1024        assert_eq!(ops(b"(x) TJ q"), vec![Op::Save]);
1025        // Non-string, non-number entries inside a `TJ` array are dropped.
1026        assert_eq!(
1027            ops(b"[(y) /Bad 5] TJ"),
1028            vec![Op::ShowTextAdjusted(vec![
1029                TextItem::Str(b"y".to_vec()),
1030                TextItem::Offset(5.0),
1031            ])]
1032        );
1033        // Dash without an array operand.
1034        assert_eq!(ops(b"3 0 d"), Vec::<Op>::new());
1035        // Extra operands: the trailing ones are used.
1036        assert_eq!(ops(b"9 9 1 2 m"), vec![Op::MoveTo(1.0, 2.0)]);
1037    }
1038
1039    #[test]
1040    fn inline_image_with_hex_data_ending_in_ei() {
1041        let got = ops(b"q BI /W 2 /H 2 /BPC 8 /CS /G /F /AHx ID 00FF80FF> EI Q");
1042        assert_eq!(got.len(), 3);
1043        assert_eq!(got[0], Op::Save);
1044        assert_eq!(got[2], Op::Restore);
1045        let Op::InlineImage(img) = &got[1] else {
1046            panic!("expected inline image, got {:?}", got[1]);
1047        };
1048        assert_eq!(img.data, b"00FF80FF>");
1049        assert_eq!(img.dict.get_int("Width"), Some(2));
1050        assert_eq!(img.dict.get_int("Height"), Some(2));
1051        assert_eq!(img.dict.get_int("BitsPerComponent"), Some(8));
1052        assert_eq!(img.dict.get_name("ColorSpace"), Some(&name("DeviceGray")));
1053        assert_eq!(img.dict.get_name("Filter"), Some(&name("AHx")));
1054    }
1055
1056    #[test]
1057    fn inline_image_trusts_declared_length() {
1058        // Binary payload contains a spurious ` EI ` sequence; /L must win.
1059        let mut src = b"BI /W 3 /H 1 /BPC 8 /CS /RGB /L 9 ID ".to_vec();
1060        src.extend_from_slice(b"ab EI wxy");
1061        src.extend_from_slice(b" EI 1 2 m");
1062        let got = ops(&src);
1063        assert_eq!(got.len(), 2);
1064        let Op::InlineImage(img) = &got[0] else {
1065            panic!("expected inline image, got {:?}", got[0]);
1066        };
1067        assert_eq!(img.data, b"ab EI wxy");
1068        assert_eq!(img.dict.get_name("ColorSpace"), Some(&name("DeviceRGB")));
1069        assert_eq!(got[1], Op::MoveTo(1.0, 2.0));
1070    }
1071
1072    #[test]
1073    fn inline_image_expands_indexed_colorspace_array() {
1074        let got = ops(
1075            b"BI /W 1 /H 1 /BPC 8 /CS [/I /RGB 1 <FF0000>] /IM false /I true \
1076                        ID \xde\xad EI",
1077        );
1078        let Op::InlineImage(img) = &got[0] else {
1079            panic!("expected inline image, got {:?}", got[0]);
1080        };
1081        assert_eq!(img.data, b"\xde\xad");
1082        assert_eq!(img.dict.get("ImageMask"), Some(&Object::Bool(false)));
1083        assert_eq!(img.dict.get("Interpolate"), Some(&Object::Bool(true)));
1084        let cs = img.dict.get_array("ColorSpace").expect("CS array");
1085        assert_eq!(cs[0], Object::Name(name("Indexed")));
1086        assert_eq!(cs[1], Object::Name(name("DeviceRGB")));
1087        assert_eq!(cs[2], Object::Int(1));
1088        assert_eq!(cs[3], Object::String(b"\xff\x00\x00".to_vec()));
1089    }
1090
1091    #[test]
1092    fn inline_image_without_terminator_is_skipped() {
1093        assert_eq!(ops(b"BI /W 1 ID \x01\x02\x03"), Vec::<Op>::new());
1094        assert_eq!(ops(b"BI /W 1"), Vec::<Op>::new());
1095    }
1096
1097    #[test]
1098    fn inline_image_with_empty_data() {
1099        let got = ops(b"BI /W 0 /H 0 ID  EI n");
1100        assert_eq!(got.len(), 2);
1101        let Op::InlineImage(img) = &got[0] else {
1102            panic!("expected inline image, got {:?}", got[0]);
1103        };
1104        assert!(img.data.is_empty());
1105        assert_eq!(got[1], Op::EndPath);
1106    }
1107
1108    #[test]
1109    fn stray_delimiters_and_comments_are_tolerated() {
1110        assert_eq!(
1111            ops(b"% comment\n1 2 m ] >> 3 4 l"),
1112            vec![Op::MoveTo(1.0, 2.0), Op::LineTo(3.0, 4.0)]
1113        );
1114        assert_eq!(ops(b""), Vec::<Op>::new());
1115        assert_eq!(ops(b"   \n  "), Vec::<Op>::new());
1116    }
1117
1118    #[test]
1119    fn text_state_ops_round_trip() {
1120        let got = ops(b"BT 0.5 Tc 1 Tw 90 Tz 14 TL /F1 12 Tf 3 Tr 4.5 Ts ET");
1121        assert_eq!(
1122            got,
1123            vec![
1124                Op::BeginText,
1125                Op::SetCharSpacing(0.5),
1126                Op::SetWordSpacing(1.0),
1127                Op::SetHorizScaling(90.0),
1128                Op::SetLeading(14.0),
1129                Op::SetFont(name("F1"), 12.0),
1130                Op::SetTextRender(3),
1131                Op::SetTextRise(4.5),
1132                Op::EndText,
1133            ]
1134        );
1135    }
1136
1137    #[test]
1138    fn text_positioning_and_showing_round_trip() {
1139        let got = ops(b"BT 72 720 Td 0 -14 TD 1 0 0 1 50 60 Tm T* \
1140                        (Hi) Tj (there) ' 2 3 (spaced) \" ET");
1141        assert_eq!(
1142            got,
1143            vec![
1144                Op::BeginText,
1145                Op::TextMove(72.0, 720.0),
1146                Op::TextMoveSetLeading(0.0, -14.0),
1147                Op::SetTextMatrix(Matrix {
1148                    a: 1.0,
1149                    b: 0.0,
1150                    c: 0.0,
1151                    d: 1.0,
1152                    e: 50.0,
1153                    f: 60.0
1154                }),
1155                Op::TextNextLine,
1156                Op::ShowText(b"Hi".to_vec()),
1157                Op::NextLineShowText(b"there".to_vec()),
1158                Op::NextLineShowTextSpaced(2.0, 3.0, b"spaced".to_vec()),
1159                Op::EndText,
1160            ]
1161        );
1162    }
1163
1164    #[test]
1165    fn parses_d0_glyph_width() {
1166        let ops = parse_content(b"1000 0 d0").expect("parse");
1167        assert_eq!(ops, vec![Op::SetGlyphWidth(1000.0, 0.0)]);
1168    }
1169
1170    #[test]
1171    fn parses_d1_glyph_width_bbox() {
1172        let ops = parse_content(b"1000 0 0 0 750 700 d1").expect("parse");
1173        assert_eq!(
1174            ops,
1175            vec![Op::SetGlyphWidthBBox(1000.0, 0.0, 0.0, 0.0, 750.0, 700.0)]
1176        );
1177    }
1178
1179    #[test]
1180    fn d0_with_wrong_arity_is_skipped() {
1181        // Too few operands: leniently skipped (like any arity mismatch), not a panic.
1182        let ops = parse_content(b"1000 d0").expect("parse");
1183        assert!(ops.is_empty());
1184    }
1185
1186    #[test]
1187    fn tj_array_mixes_strings_and_numbers() {
1188        let got = ops(b"[(He) -120 (llo) 33.5 <20>] TJ");
1189        assert_eq!(
1190            got,
1191            vec![Op::ShowTextAdjusted(vec![
1192                TextItem::Str(b"He".to_vec()),
1193                TextItem::Offset(-120.0),
1194                TextItem::Str(b"llo".to_vec()),
1195                TextItem::Offset(33.5),
1196                TextItem::Str(b" ".to_vec()),
1197            ])]
1198        );
1199    }
1200
1201    #[test]
1202    fn color_ops_round_trip() {
1203        let got = ops(b"/DeviceRGB CS /DeviceGray cs 1 0 0 SC 0.5 sc \
1204                        0.3 G 0.7 g 1 0 0 RG 0 1 0 rg 0 0 0 1 K 1 0 0 0 k");
1205        assert_eq!(
1206            got,
1207            vec![
1208                Op::SetStrokeColorSpace(name("DeviceRGB")),
1209                Op::SetFillColorSpace(name("DeviceGray")),
1210                Op::SetStrokeColor(vec![1.0, 0.0, 0.0]),
1211                Op::SetFillColor(vec![0.5]),
1212                Op::SetStrokeGray(0.3),
1213                Op::SetFillGray(0.7),
1214                Op::SetStrokeRGB(1.0, 0.0, 0.0),
1215                Op::SetFillRGB(0.0, 1.0, 0.0),
1216                Op::SetStrokeCMYK(0.0, 0.0, 0.0, 1.0),
1217                Op::SetFillCMYK(1.0, 0.0, 0.0, 0.0),
1218            ]
1219        );
1220    }
1221
1222    #[test]
1223    fn scn_with_and_without_pattern_name() {
1224        let got = ops(b"0.2 0.4 0.6 scn /P1 scn 0.1 0.2 /P2 SCN 1 SCN");
1225        assert_eq!(
1226            got,
1227            vec![
1228                Op::SetFillColorN(vec![0.2, 0.4, 0.6], None),
1229                Op::SetFillColorN(vec![], Some(name("P1"))),
1230                Op::SetStrokeColorN(vec![0.1, 0.2], Some(name("P2"))),
1231                Op::SetStrokeColorN(vec![1.0], None),
1232            ]
1233        );
1234    }
1235
1236    #[test]
1237    fn painting_and_clipping_ops_round_trip() {
1238        let got = ops(b"S s f F f* B B* b b* n W W*");
1239        assert_eq!(
1240            got,
1241            vec![
1242                Op::Stroke,
1243                Op::CloseStroke,
1244                Op::Fill,
1245                Op::Fill,
1246                Op::FillEvenOdd,
1247                Op::FillStroke,
1248                Op::FillStrokeEvenOdd,
1249                Op::CloseFillStroke,
1250                Op::CloseFillStrokeEvenOdd,
1251                Op::EndPath,
1252                Op::ClipNonZero,
1253                Op::ClipEvenOdd,
1254            ]
1255        );
1256    }
1257
1258    /// Runs `f` on a deliberately small stack so that unbounded recursion
1259    /// would abort the test binary instead of silently passing on a large
1260    /// main-thread stack.
1261    fn on_small_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
1262        std::thread::Builder::new()
1263            .stack_size(512 * 1024)
1264            .spawn(f)
1265            .expect("spawn test thread")
1266            .join()
1267            .expect("content parser must not overflow the stack")
1268    }
1269
1270    #[test]
1271    fn deeply_nested_array_is_rejected_not_stack_overflow() {
1272        let data = vec![b'['; 50_000];
1273        let result = on_small_stack(move || parse_content(&data));
1274        assert!(matches!(result, Err(Error::Syntax { .. })));
1275    }
1276
1277    #[test]
1278    fn deeply_nested_dict_is_rejected_not_stack_overflow() {
1279        let mut data = Vec::new();
1280        for _ in 0..50_000 {
1281            data.extend_from_slice(b"<</K");
1282        }
1283        let result = on_small_stack(move || parse_content(&data));
1284        assert!(matches!(result, Err(Error::Syntax { .. })));
1285    }
1286
1287    #[test]
1288    fn nesting_within_the_limit_still_parses() {
1289        // A BDC property dict holding a modestly nested array operand.
1290        let mut data: Vec<u8> = b"/Tag <</Deep ".to_vec();
1291        data.extend(std::iter::repeat_n(b'[', 50));
1292        data.extend(std::iter::repeat_n(b']', 50));
1293        data.extend_from_slice(b" >> BDC");
1294        let got = ops(&data);
1295        assert_eq!(got.len(), 1);
1296        assert!(matches!(got[0], Op::BeginMarkedContentProps(_, _)));
1297    }
1298
1299    #[test]
1300    fn spanned_ops_cover_their_source_bytes() {
1301        let data = b"q 1 0 0 1 5 5 cm BT /F1 12 Tf (Hi) Tj ET Q";
1302        let spanned = parse_content_spanned(data).unwrap();
1303        let plain = parse_content(data).unwrap();
1304        assert_eq!(
1305            spanned
1306                .iter()
1307                .map(|pair| pair.0.clone())
1308                .collect::<Vec<_>>(),
1309            plain
1310        );
1311        for (op, span) in &spanned {
1312            assert!(span.start < span.end && span.end as usize <= data.len());
1313            // Re-parsing exactly the spanned bytes yields exactly this op.
1314            let slice = &data[span.start as usize..span.end as usize];
1315            let reparsed = parse_content(slice).unwrap();
1316            assert_eq!(reparsed.len(), 1, "span of {op:?} reparses to one op");
1317            assert_eq!(&reparsed[0], op);
1318        }
1319        // Spans are ordered and non-overlapping.
1320        for pair in spanned.windows(2) {
1321            assert!(pair[0].1.end <= pair[1].1.start);
1322        }
1323    }
1324
1325    #[test]
1326    fn unknown_operator_does_not_stretch_the_next_span() {
1327        // `zz` is unknown: its operands are dropped, and the following op's
1328        // span must start at `q`, not at `7`.
1329        let data = b"7 8 zz q";
1330        let spanned = parse_content_spanned(data).unwrap();
1331        assert_eq!(spanned.len(), 1);
1332        assert_eq!(spanned[0].0, Op::Save);
1333        assert_eq!(
1334            &data[spanned[0].1.start as usize..spanned[0].1.end as usize],
1335            b"q"
1336        );
1337    }
1338}