Skip to main content

pdf_oxide/writer/
content_stream.rs

1//! PDF content stream builder.
2//!
3//! Builds PDF content streams containing graphics and text operators
4//! according to PDF specification ISO 32000-1:2008 Section 8-9.
5
6use crate::elements::{
7    ContentElement, ImageContent, PathContent, PathOperation, StructureElement, TableCellAlign,
8    TableContent, TextContent,
9};
10use crate::error::Result;
11use crate::fonts::GlyphRemapper;
12use crate::layout::Color;
13use std::collections::HashMap;
14use std::io::Write;
15
16/// Map an arbitrary requested font name (+ bold flag) to the Standard-14
17/// PostScript base-font name actually emitted by the `Tf` operator.
18///
19/// This is the single source of truth for the name that ends up in a
20/// content stream, so any code that must *register* that font in a page's
21/// `/Resources/Font` (see `DocumentEditor`'s overlay-additions path) keys
22/// off this same function — otherwise the registered name and the emitted
23/// `Tf` name diverge for styled / generic / Symbol requests, leaving a
24/// dangling font tag.
25///
26/// Symbol and ZapfDingbats are deliberately NOT routed to their own faces:
27/// they use built-in encodings (not WinAnsi) and are not pre-registered in
28/// the page `/Font` dict, so emitting their names would produce a dangling
29/// `Tf` and the wrong encoding. They fall through to the Helvetica
30/// fallback — the markdown / text / HTML renderers never request them, and
31/// any caller who genuinely needs them should use the embedded-font path.
32pub(crate) fn map_base14_font_name(name: &str, bold: bool) -> String {
33    let lower = name.to_lowercase();
34
35    // Resolve the family. `sans` must be tested before `serif`
36    // because "sans-serif" contains "serif". Unknown names keep the
37    // historical Helvetica fallback — embedded fonts never reach this
38    // path (they are emitted as ShowEmbeddedText), so this only
39    // governs Base-14 substitution for generic family names.
40    enum Family {
41        Helvetica,
42        Times,
43        Courier,
44    }
45    let family = if lower.contains("courier") || lower.contains("mono") {
46        Family::Courier
47    } else if lower.contains("sans") || lower.contains("helvetica") || lower.contains("arial") {
48        Family::Helvetica
49    } else if lower.contains("times") || lower.contains("serif") {
50        Family::Times
51    } else {
52        Family::Helvetica
53    };
54
55    // Weight/slant come from the caller's flag *or* an explicit
56    // Standard-14 PostScript name (e.g. "Helvetica-Bold",
57    // "Times-Italic"), so callers can request a styled face by name
58    // without also threading a style struct through every layer.
59    let want_bold = bold || lower.contains("bold");
60    let want_italic = lower.contains("italic") || lower.contains("oblique");
61
62    match family {
63        Family::Helvetica => match (want_bold, want_italic) {
64            (false, false) => "Helvetica",
65            (true, false) => "Helvetica-Bold",
66            (false, true) => "Helvetica-Oblique",
67            (true, true) => "Helvetica-BoldOblique",
68        },
69        Family::Times => match (want_bold, want_italic) {
70            (false, false) => "Times-Roman",
71            (true, false) => "Times-Bold",
72            (false, true) => "Times-Italic",
73            (true, true) => "Times-BoldItalic",
74        },
75        Family::Courier => match (want_bold, want_italic) {
76            (false, false) => "Courier",
77            (true, false) => "Courier-Bold",
78            (false, true) => "Courier-Oblique",
79            (true, true) => "Courier-BoldOblique",
80        },
81    }
82    .to_string()
83}
84
85/// Operations that can be added to a content stream.
86#[derive(Debug, Clone)]
87pub enum ContentStreamOp {
88    /// Save graphics state (q)
89    SaveState,
90    /// Restore graphics state (Q)
91    RestoreState,
92    /// Set transformation matrix (cm)
93    Transform(f32, f32, f32, f32, f32, f32),
94    /// Begin text object (BT)
95    BeginText,
96    /// End text object (ET)
97    EndText,
98    /// Set font and size (Tf)
99    SetFont(String, f32),
100    /// Move text position (Td)
101    MoveText(f32, f32),
102    /// Set text matrix (Tm)
103    SetTextMatrix(f32, f32, f32, f32, f32, f32),
104    /// Show text (Tj) - literal string
105    ShowText(String),
106    /// Show hex-encoded text (Tj) - for CIDFonts/Unicode
107    ShowHexText(String),
108    /// Show text from a registered embedded font, carrying *original-face*
109    /// glyph IDs together with the font's PDF resource name (e.g. `"EF1"`).
110    ///
111    /// The concrete hex bytes emitted into the content stream are computed
112    /// at serialization time ([`ContentStreamBuilder::build_with_remappers`])
113    /// so that every GID can be remapped through the font's subset
114    /// [`GlyphRemapper`]. This is what makes FONT-3b — real font subsetting
115    /// with GID remapping in already-emitted content streams — possible.
116    ShowEmbeddedText {
117        /// PDF resource name of the embedded font (e.g. `"EF1"`).
118        font_name: String,
119        /// Original-face glyph IDs in logical text order.
120        glyph_ids: Vec<u16>,
121    },
122    /// Show text with positioning (TJ)
123    ShowTextArray(Vec<TextArrayItem>),
124    /// Set character spacing (Tc)
125    SetCharacterSpacing(f32),
126    /// Set word spacing (Tw)
127    SetWordSpacing(f32),
128    /// Set text leading (TL)
129    SetTextLeading(f32),
130    /// Move to next line (T*)
131    NextLine,
132    /// Set fill color RGB (rg)
133    SetFillColorRGB(f32, f32, f32),
134    /// Set stroke color RGB (RG)
135    SetStrokeColorRGB(f32, f32, f32),
136    /// Set fill color gray (g)
137    SetFillColorGray(f32),
138    /// Set stroke color gray (G)
139    SetStrokeColorGray(f32),
140    /// Set line width (w)
141    SetLineWidth(f32),
142    /// Move to (m)
143    MoveTo(f32, f32),
144    /// Line to (l)
145    LineTo(f32, f32),
146    /// Curve to (c)
147    CurveTo(f32, f32, f32, f32, f32, f32),
148    /// Rectangle (re)
149    Rectangle(f32, f32, f32, f32),
150    /// Close path (h)
151    ClosePath,
152    /// Stroke (S)
153    Stroke,
154    /// Fill (f)
155    Fill,
156    /// Fill and stroke (B)
157    FillStroke,
158    /// Close and stroke (s)
159    CloseStroke,
160    /// End path without filling/stroking (n)
161    EndPath,
162    /// Paint XObject (Do)
163    PaintXObject(String),
164
165    // === Marked Content Operations ===
166    /// Begin marked content with dictionary (BDC) - for tagged PDF structure
167    BeginMarkedContentDict {
168        /// The tag/structure type (e.g., "P" for paragraph, "H1" for heading)
169        tag: String,
170        /// Marked Content ID for linking to structure tree
171        mcid: u32,
172    },
173    /// End marked content (EMC)
174    EndMarkedContent,
175
176    /// Begin an Artifact marked-content section (BDC /Artifact).
177    /// Used for pagination artifacts (headers, footers, page numbers) that
178    /// should be ignored by AT (Assistive Technology). F-3.
179    BeginArtifact {
180        /// Artifact type, e.g. "Pagination", "Layout", "Page".
181        artifact_type: String,
182        /// Optional subtype, e.g. "Header", "Footer".
183        subtype: Option<String>,
184    },
185    /// End an Artifact marked-content section (EMC).
186    EndArtifact,
187
188    // === Clipping Operations ===
189    /// Clip using non-zero winding rule (W)
190    Clip,
191    /// Clip using even-odd rule (W*)
192    ClipEvenOdd,
193
194    // === Extended Graphics State ===
195    /// Set graphics state from ExtGState dictionary (gs)
196    SetExtGState(String),
197
198    // === Color Space Operations ===
199    /// Set fill color space (cs)
200    SetFillColorSpace(String),
201    /// Set stroke color space (CS)
202    SetStrokeColorSpace(String),
203    /// Set fill color in current color space (sc/scn)
204    SetFillColorN(Vec<f32>),
205    /// Set stroke color in current color space (SC/SCN)
206    SetStrokeColorN(Vec<f32>),
207    /// Set fill color with pattern (scn with pattern name)
208    SetFillPattern(String, Vec<f32>),
209    /// Set stroke color with pattern (SCN with pattern name)
210    SetStrokePattern(String, Vec<f32>),
211
212    // === Shading Operations ===
213    /// Paint shading (sh)
214    PaintShading(String),
215
216    // === Additional Path Operations ===
217    /// Curve with first control point on current point (v)
218    CurveToV(f32, f32, f32, f32),
219    /// Curve with second control point on end point (y)
220    CurveToY(f32, f32, f32, f32),
221    /// Fill using even-odd rule (f*)
222    FillEvenOdd,
223    /// Fill and stroke using even-odd rule (B*)
224    FillStrokeEvenOdd,
225    /// Close, fill and stroke (b)
226    CloseFillStroke,
227    /// Close, fill and stroke using even-odd rule (b*)
228    CloseFillStrokeEvenOdd,
229
230    // === Line Style Operations ===
231    /// Set line cap style (J)
232    SetLineCap(LineCap),
233    /// Set line join style (j)
234    SetLineJoin(LineJoin),
235    /// Set miter limit (M)
236    SetMiterLimit(f32),
237    /// Set dash pattern (d)
238    SetDashPattern(Vec<f32>, f32),
239
240    // === CMYK Color Operations ===
241    /// Set fill color CMYK (k)
242    SetFillColorCMYK(f32, f32, f32, f32),
243    /// Set stroke color CMYK (K)
244    SetStrokeColorCMYK(f32, f32, f32, f32),
245
246    /// Raw operator (for extensibility)
247    Raw(String),
248}
249
250/// Line cap styles for path stroking.
251#[derive(Debug, Clone, Copy, Default)]
252pub enum LineCap {
253    /// Square butt cap (default)
254    #[default]
255    Butt = 0,
256    /// Round cap
257    Round = 1,
258    /// Projecting square cap
259    Square = 2,
260}
261
262/// Line join styles for path stroking.
263#[derive(Debug, Clone, Copy, Default)]
264pub enum LineJoin {
265    /// Miter join (default)
266    #[default]
267    Miter = 0,
268    /// Round join
269    Round = 1,
270    /// Bevel join
271    Bevel = 2,
272}
273
274/// Blend modes for transparency.
275#[derive(Debug, Clone, Copy, Default)]
276pub enum BlendMode {
277    /// Normal blend (default)
278    #[default]
279    Normal,
280    /// Multiply
281    Multiply,
282    /// Screen
283    Screen,
284    /// Overlay
285    Overlay,
286    /// Darken
287    Darken,
288    /// Lighten
289    Lighten,
290    /// Color dodge
291    ColorDodge,
292    /// Color burn
293    ColorBurn,
294    /// Hard light
295    HardLight,
296    /// Soft light
297    SoftLight,
298    /// Difference
299    Difference,
300    /// Exclusion
301    Exclusion,
302}
303
304impl BlendMode {
305    /// Get the PDF name for this blend mode.
306    pub fn as_pdf_name(&self) -> &'static str {
307        match self {
308            BlendMode::Normal => "Normal",
309            BlendMode::Multiply => "Multiply",
310            BlendMode::Screen => "Screen",
311            BlendMode::Overlay => "Overlay",
312            BlendMode::Darken => "Darken",
313            BlendMode::Lighten => "Lighten",
314            BlendMode::ColorDodge => "ColorDodge",
315            BlendMode::ColorBurn => "ColorBurn",
316            BlendMode::HardLight => "HardLight",
317            BlendMode::SoftLight => "SoftLight",
318            BlendMode::Difference => "Difference",
319            BlendMode::Exclusion => "Exclusion",
320        }
321    }
322}
323
324/// Item in a TJ array (text or positioning adjustment).
325#[derive(Debug, Clone)]
326pub enum TextArrayItem {
327    /// Text string (literal)
328    Text(String),
329    /// Hex-encoded text string (for CIDFonts/Unicode)
330    HexText(String),
331    /// Positioning adjustment (negative = move right, positive = move left)
332    Adjustment(f32),
333}
334
335/// A record of a structure element and its marked-content IDs, collected
336/// during content-stream construction for StructTreeRoot emission.
337///
338/// Each `StructElemRecord` corresponds to one `StructureElement` that was
339/// added via `add_element` / `add_structure_element`. The `mcid` field
340/// is the Marked Content ID emitted for this element's BDC bracket;
341/// `children` holds nested records from child `StructureElement`s.
342#[derive(Debug, Clone)]
343pub struct StructElemRecord {
344    /// The PDF structure type tag (e.g. "P", "H1", "Figure").
345    pub structure_type: String,
346    /// Marked Content ID emitted for this element's BDC operator.
347    pub mcid: u32,
348    /// Alternate text for accessibility (/Alt in StructElem dict).
349    pub alt_text: Option<String>,
350    /// Language override for this element (/Lang in StructElem dict).
351    pub language: Option<String>,
352    /// Nested structure records from child StructureElements.
353    pub children: Vec<StructElemRecord>,
354}
355
356/// An image that needs to be registered as an XObject.
357///
358/// When ContentStreamBuilder encounters an ImageContent, it generates
359/// the content stream operators but also tracks the image data so it
360/// can be registered as an XObject when the PDF is saved.
361#[derive(Debug, Clone)]
362pub struct PendingImage {
363    /// The image content
364    pub image: ImageContent,
365    /// The resource ID assigned to this image (e.g., "Im1")
366    pub resource_id: String,
367}
368
369/// Builder for PDF content streams.
370///
371/// Creates the byte sequence for a PDF content stream from operations
372/// or ContentElements.
373#[derive(Debug, Default)]
374pub struct ContentStreamBuilder {
375    /// Operations in the stream
376    operations: Vec<ContentStreamOp>,
377    /// Current font name
378    current_font: Option<String>,
379    /// Current font size
380    current_font_size: f32,
381    /// Whether we're in a text object
382    in_text_object: bool,
383    /// MCID (Marked Content ID) counter for tagged PDF structure
384    mcid_counter: u32,
385    /// Images that need to be registered as XObjects
386    pending_images: Vec<PendingImage>,
387    /// Next image resource ID counter
388    next_image_id: u32,
389    /// Structure element records accumulated from add_element(Structure(...))
390    /// calls. Used by PdfWriter::finish to build the StructTreeRoot when
391    /// tagged PDF mode is enabled.
392    struct_records: Vec<StructElemRecord>,
393}
394
395impl ContentStreamBuilder {
396    /// Create a new content stream builder.
397    pub fn new() -> Self {
398        Self::default()
399    }
400
401    /// Add an operation to the stream.
402    pub fn op(&mut self, op: ContentStreamOp) -> &mut Self {
403        self.operations.push(op);
404        self
405    }
406
407    /// Add multiple operations.
408    pub fn ops(&mut self, ops: impl IntoIterator<Item = ContentStreamOp>) -> &mut Self {
409        self.operations.extend(ops);
410        self
411    }
412
413    /// Begin a text object.
414    pub fn begin_text(&mut self) -> &mut Self {
415        if !self.in_text_object {
416            self.op(ContentStreamOp::BeginText);
417            self.in_text_object = true;
418        }
419        self
420    }
421
422    /// End a text object.
423    pub fn end_text(&mut self) -> &mut Self {
424        if self.in_text_object {
425            self.op(ContentStreamOp::EndText);
426            self.in_text_object = false;
427        }
428        self
429    }
430
431    /// Set font for text operations.
432    pub fn set_font(&mut self, font_name: &str, size: f32) -> &mut Self {
433        if self.current_font.as_deref() != Some(font_name) || self.current_font_size != size {
434            self.op(ContentStreamOp::SetFont(font_name.to_string(), size));
435            self.current_font = Some(font_name.to_string());
436            self.current_font_size = size;
437        }
438        self
439    }
440
441    /// Add text at a position (literal string for Base-14 fonts).
442    pub fn text(&mut self, text: &str, x: f32, y: f32) -> &mut Self {
443        self.begin_text();
444        self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
445        self.op(ContentStreamOp::ShowText(text.to_string()));
446        self
447    }
448
449    /// Add hex-encoded text at a position (for CIDFonts/Unicode).
450    ///
451    /// The hex_string should already be formatted as "<XXXX...>" where each
452    /// 4-digit hex value is a glyph ID.
453    pub fn hex_text(&mut self, hex_string: &str, x: f32, y: f32) -> &mut Self {
454        self.begin_text();
455        self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
456        self.op(ContentStreamOp::ShowHexText(hex_string.to_string()));
457        self
458    }
459
460    /// Add text from a registered embedded font at a position, deferring
461    /// hex encoding to serialization time so that subsetting can remap
462    /// GIDs into subset-local indices.
463    ///
464    /// `font_name` is the PDF resource name of the font (e.g. `"EF1"`).
465    /// `glyph_ids` are *original-face* glyph IDs; the remapper paired
466    /// with the same resource name in
467    /// [`ContentStreamBuilder::build_with_remappers`] maps them to the
468    /// subset-local IDs actually emitted as hex.
469    pub fn embedded_text(
470        &mut self,
471        font_name: &str,
472        glyph_ids: Vec<u16>,
473        x: f32,
474        y: f32,
475    ) -> &mut Self {
476        self.begin_text();
477        self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
478        self.op(ContentStreamOp::ShowEmbeddedText {
479            font_name: font_name.to_string(),
480            glyph_ids,
481        });
482        self
483    }
484
485    /// Set fill color.
486    pub fn fill_color(&mut self, color: Color) -> &mut Self {
487        self.op(ContentStreamOp::SetFillColorRGB(color.r, color.g, color.b))
488    }
489
490    /// Draw an image XObject at the specified position and size.
491    ///
492    /// # Arguments
493    /// * `resource_id` - The XObject resource ID (e.g., "Im1")
494    /// * `x` - X position (left edge)
495    /// * `y` - Y position (bottom edge)
496    /// * `width` - Display width
497    /// * `height` - Display height
498    pub fn draw_image(
499        &mut self,
500        resource_id: &str,
501        x: f32,
502        y: f32,
503        width: f32,
504        height: f32,
505    ) -> &mut Self {
506        // End any open text object
507        self.end_text();
508
509        // Save graphics state, apply transform, draw image, restore state
510        self.op(ContentStreamOp::SaveState);
511        self.op(ContentStreamOp::Transform(width, 0.0, 0.0, height, x, y));
512        self.op(ContentStreamOp::PaintXObject(resource_id.to_string()));
513        self.op(ContentStreamOp::RestoreState);
514        self
515    }
516
517    /// Draw an image using an ImagePlacement specification.
518    pub fn draw_image_at(
519        &mut self,
520        resource_id: &str,
521        placement: &super::image_handler::ImagePlacement,
522    ) -> &mut Self {
523        self.draw_image(resource_id, placement.x, placement.y, placement.width, placement.height)
524    }
525
526    /// Set stroke color.
527    pub fn stroke_color(&mut self, color: Color) -> &mut Self {
528        self.op(ContentStreamOp::SetStrokeColorRGB(color.r, color.g, color.b))
529    }
530
531    /// Set fill color with RGB values.
532    pub fn set_fill_color(&mut self, r: f32, g: f32, b: f32) -> &mut Self {
533        self.op(ContentStreamOp::SetFillColorRGB(r, g, b))
534    }
535
536    /// Set stroke color with RGB values.
537    pub fn set_stroke_color(&mut self, r: f32, g: f32, b: f32) -> &mut Self {
538        self.op(ContentStreamOp::SetStrokeColorRGB(r, g, b))
539    }
540
541    /// Set line width.
542    pub fn set_line_width(&mut self, width: f32) -> &mut Self {
543        self.op(ContentStreamOp::SetLineWidth(width))
544    }
545
546    /// Move to a point (start a new subpath).
547    pub fn move_to(&mut self, x: f32, y: f32) -> &mut Self {
548        self.op(ContentStreamOp::MoveTo(x, y))
549    }
550
551    /// Draw a line to a point.
552    pub fn line_to(&mut self, x: f32, y: f32) -> &mut Self {
553        self.op(ContentStreamOp::LineTo(x, y))
554    }
555
556    /// Draw a rectangle.
557    pub fn rect(&mut self, x: f32, y: f32, width: f32, height: f32) -> &mut Self {
558        self.op(ContentStreamOp::Rectangle(x, y, width, height))
559    }
560
561    /// Stroke the current path.
562    pub fn stroke(&mut self) -> &mut Self {
563        self.op(ContentStreamOp::Stroke)
564    }
565
566    /// Fill the current path.
567    pub fn fill(&mut self) -> &mut Self {
568        self.op(ContentStreamOp::Fill)
569    }
570
571    /// Fill using even-odd rule.
572    pub fn fill_even_odd(&mut self) -> &mut Self {
573        self.op(ContentStreamOp::FillEvenOdd)
574    }
575
576    /// Fill and stroke the current path.
577    pub fn fill_stroke(&mut self) -> &mut Self {
578        self.op(ContentStreamOp::FillStroke)
579    }
580
581    /// Fill and stroke using even-odd rule.
582    pub fn fill_stroke_even_odd(&mut self) -> &mut Self {
583        self.op(ContentStreamOp::FillStrokeEvenOdd)
584    }
585
586    /// Close, fill, and stroke the path.
587    pub fn close_fill_stroke(&mut self) -> &mut Self {
588        self.op(ContentStreamOp::CloseFillStroke)
589    }
590
591    /// Close path.
592    pub fn close_path(&mut self) -> &mut Self {
593        self.op(ContentStreamOp::ClosePath)
594    }
595
596    // === Clipping Path Methods ===
597
598    /// Clip to the current path using non-zero winding rule.
599    ///
600    /// After calling this, use `end_path()` to consume the path without painting,
601    /// or combine with stroke/fill operations.
602    pub fn clip(&mut self) -> &mut Self {
603        self.op(ContentStreamOp::Clip)
604    }
605
606    /// Clip to the current path using even-odd rule.
607    pub fn clip_even_odd(&mut self) -> &mut Self {
608        self.op(ContentStreamOp::ClipEvenOdd)
609    }
610
611    /// End path without painting (use after clip).
612    pub fn end_path(&mut self) -> &mut Self {
613        self.op(ContentStreamOp::EndPath)
614    }
615
616    /// Create a rectangular clipping region.
617    ///
618    /// This is a convenience method that creates a rectangle path and clips to it.
619    pub fn clip_rect(&mut self, x: f32, y: f32, width: f32, height: f32) -> &mut Self {
620        self.rect(x, y, width, height).clip().end_path()
621    }
622
623    // === Graphics State Methods ===
624
625    /// Save the current graphics state.
626    pub fn save_state(&mut self) -> &mut Self {
627        self.op(ContentStreamOp::SaveState)
628    }
629
630    /// Restore the previous graphics state.
631    pub fn restore_state(&mut self) -> &mut Self {
632        self.op(ContentStreamOp::RestoreState)
633    }
634
635    /// Set extended graphics state (for transparency, blend modes, etc.).
636    ///
637    /// The `gs_name` should reference an ExtGState resource defined in the page.
638    pub fn set_ext_gstate(&mut self, gs_name: &str) -> &mut Self {
639        self.op(ContentStreamOp::SetExtGState(gs_name.to_string()))
640    }
641
642    // === Transform Methods ===
643
644    /// Apply a transformation matrix.
645    ///
646    /// Matrix is specified as [a b c d e f] where:
647    /// - a, d: scaling
648    /// - b, c: rotation/skewing
649    /// - e, f: translation
650    pub fn transform(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> &mut Self {
651        self.op(ContentStreamOp::Transform(a, b, c, d, e, f))
652    }
653
654    /// Translate (move) the coordinate system.
655    pub fn translate(&mut self, tx: f32, ty: f32) -> &mut Self {
656        self.transform(1.0, 0.0, 0.0, 1.0, tx, ty)
657    }
658
659    /// Scale the coordinate system.
660    pub fn scale(&mut self, sx: f32, sy: f32) -> &mut Self {
661        self.transform(sx, 0.0, 0.0, sy, 0.0, 0.0)
662    }
663
664    /// Rotate the coordinate system by angle in radians.
665    pub fn rotate(&mut self, angle: f32) -> &mut Self {
666        let cos = angle.cos();
667        let sin = angle.sin();
668        self.transform(cos, sin, -sin, cos, 0.0, 0.0)
669    }
670
671    /// Rotate the coordinate system by angle in degrees.
672    pub fn rotate_degrees(&mut self, degrees: f32) -> &mut Self {
673        self.rotate(degrees * std::f32::consts::PI / 180.0)
674    }
675
676    // === Line Style Methods ===
677
678    /// Set line cap style.
679    pub fn set_line_cap(&mut self, cap: LineCap) -> &mut Self {
680        self.op(ContentStreamOp::SetLineCap(cap))
681    }
682
683    /// Set line join style.
684    pub fn set_line_join(&mut self, join: LineJoin) -> &mut Self {
685        self.op(ContentStreamOp::SetLineJoin(join))
686    }
687
688    /// Set miter limit.
689    pub fn set_miter_limit(&mut self, limit: f32) -> &mut Self {
690        self.op(ContentStreamOp::SetMiterLimit(limit))
691    }
692
693    /// Set dash pattern.
694    ///
695    /// # Arguments
696    /// * `pattern` - Array of dash lengths (e.g., [3.0, 2.0] for 3pt dash, 2pt gap)
697    /// * `phase` - Starting offset into the pattern
698    pub fn set_dash_pattern(&mut self, pattern: Vec<f32>, phase: f32) -> &mut Self {
699        self.op(ContentStreamOp::SetDashPattern(pattern, phase))
700    }
701
702    /// Set solid line (no dashing).
703    pub fn set_solid_line(&mut self) -> &mut Self {
704        self.set_dash_pattern(vec![], 0.0)
705    }
706
707    // === Color Space Methods ===
708
709    /// Set fill color space.
710    pub fn set_fill_color_space(&mut self, name: &str) -> &mut Self {
711        self.op(ContentStreamOp::SetFillColorSpace(name.to_string()))
712    }
713
714    /// Set stroke color space.
715    pub fn set_stroke_color_space(&mut self, name: &str) -> &mut Self {
716        self.op(ContentStreamOp::SetStrokeColorSpace(name.to_string()))
717    }
718
719    /// Set fill color in current color space.
720    pub fn set_fill_color_n(&mut self, components: Vec<f32>) -> &mut Self {
721        self.op(ContentStreamOp::SetFillColorN(components))
722    }
723
724    /// Set stroke color in current color space.
725    pub fn set_stroke_color_n(&mut self, components: Vec<f32>) -> &mut Self {
726        self.op(ContentStreamOp::SetStrokeColorN(components))
727    }
728
729    /// Set fill color with CMYK values.
730    pub fn set_fill_color_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) -> &mut Self {
731        self.op(ContentStreamOp::SetFillColorCMYK(c, m, y, k))
732    }
733
734    /// Set stroke color with CMYK values.
735    pub fn set_stroke_color_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) -> &mut Self {
736        self.op(ContentStreamOp::SetStrokeColorCMYK(c, m, y, k))
737    }
738
739    // === Pattern Methods ===
740
741    /// Set fill pattern.
742    ///
743    /// # Arguments
744    /// * `pattern_name` - Name of the pattern resource
745    /// * `components` - Additional color components (empty for colored patterns)
746    pub fn set_fill_pattern(&mut self, pattern_name: &str, components: Vec<f32>) -> &mut Self {
747        self.op(ContentStreamOp::SetFillPattern(pattern_name.to_string(), components))
748    }
749
750    /// Set stroke pattern.
751    pub fn set_stroke_pattern(&mut self, pattern_name: &str, components: Vec<f32>) -> &mut Self {
752        self.op(ContentStreamOp::SetStrokePattern(pattern_name.to_string(), components))
753    }
754
755    // === Shading Methods ===
756
757    /// Paint a shading (gradient).
758    ///
759    /// The shading fills the current clipping path. Use with `save_state()`,
760    /// `clip_rect()`, and `restore_state()` to control the painted area.
761    pub fn paint_shading(&mut self, shading_name: &str) -> &mut Self {
762        self.op(ContentStreamOp::PaintShading(shading_name.to_string()))
763    }
764
765    /// Draw a linear gradient within a rectangle.
766    ///
767    /// This is a convenience method that clips to the rectangle and paints the shading.
768    /// The shading resource must be defined separately.
769    pub fn draw_gradient_rect(
770        &mut self,
771        shading_name: &str,
772        x: f32,
773        y: f32,
774        width: f32,
775        height: f32,
776    ) -> &mut Self {
777        self.save_state()
778            .rect(x, y, width, height)
779            .clip()
780            .end_path()
781            .paint_shading(shading_name)
782            .restore_state()
783    }
784
785    // === Additional Path Methods ===
786
787    /// Draw a Bézier curve (full control).
788    pub fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) -> &mut Self {
789        self.op(ContentStreamOp::CurveTo(x1, y1, x2, y2, x3, y3))
790    }
791
792    /// Draw a Bézier curve with first control point at current position.
793    pub fn curve_to_v(&mut self, x2: f32, y2: f32, x3: f32, y3: f32) -> &mut Self {
794        self.op(ContentStreamOp::CurveToV(x2, y2, x3, y3))
795    }
796
797    /// Draw a Bézier curve with second control point at end point.
798    pub fn curve_to_y(&mut self, x1: f32, y1: f32, x3: f32, y3: f32) -> &mut Self {
799        self.op(ContentStreamOp::CurveToY(x1, y1, x3, y3))
800    }
801
802    /// Draw a circle.
803    ///
804    /// Uses Bézier curves to approximate a circle.
805    pub fn circle(&mut self, cx: f32, cy: f32, radius: f32) -> &mut Self {
806        // Bézier approximation constant for circles
807        let k = 0.552_284_8; // 4/3 * (sqrt(2) - 1)
808        let c = radius * k;
809
810        self.move_to(cx + radius, cy)
811            .curve_to(cx + radius, cy + c, cx + c, cy + radius, cx, cy + radius)
812            .curve_to(cx - c, cy + radius, cx - radius, cy + c, cx - radius, cy)
813            .curve_to(cx - radius, cy - c, cx - c, cy - radius, cx, cy - radius)
814            .curve_to(cx + c, cy - radius, cx + radius, cy - c, cx + radius, cy)
815            .close_path()
816    }
817
818    /// Draw an ellipse.
819    pub fn ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) -> &mut Self {
820        let kx = rx * 0.552_284_8;
821        let ky = ry * 0.552_284_8;
822
823        self.move_to(cx + rx, cy)
824            .curve_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
825            .curve_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
826            .curve_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
827            .curve_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
828            .close_path()
829    }
830
831    /// Draw a rounded rectangle.
832    pub fn rounded_rect(
833        &mut self,
834        x: f32,
835        y: f32,
836        width: f32,
837        height: f32,
838        radius: f32,
839    ) -> &mut Self {
840        let r = radius.min(width / 2.0).min(height / 2.0);
841        let k = r * 0.552_284_8;
842
843        // Start at top-left corner (after radius)
844        self.move_to(x + r, y)
845            // Top edge
846            .line_to(x + width - r, y)
847            // Top-right corner
848            .curve_to(x + width - r + k, y, x + width, y + k, x + width, y + r)
849            // Right edge
850            .line_to(x + width, y + height - r)
851            // Bottom-right corner
852            .curve_to(
853                x + width,
854                y + height - r + k,
855                x + width - k,
856                y + height,
857                x + width - r,
858                y + height,
859            )
860            // Bottom edge
861            .line_to(x + r, y + height)
862            // Bottom-left corner
863            .curve_to(x + r - k, y + height, x, y + height - k, x, y + height - r)
864            // Left edge
865            .line_to(x, y + r)
866            // Top-left corner
867            .curve_to(x, y + r - k, x + r - k, y, x + r, y)
868            .close_path()
869    }
870
871    /// Add a ContentElement to the stream.
872    pub fn add_element(&mut self, element: &ContentElement) -> &mut Self {
873        match element {
874            ContentElement::Text(text) => self.add_text_content(text),
875            ContentElement::Path(path) => self.add_path_content(path),
876            ContentElement::Image(image) => self.add_image_content(image),
877            ContentElement::Structure(s) => {
878                // Build the BDC/EMC brackets and collect the StructElemRecord
879                // so PdfWriter::finish can build the StructTreeRoot.
880                let record = self.add_structure_element_impl(s);
881                self.struct_records.push(record);
882                self
883            },
884            ContentElement::Table(table) => self.add_table_content(table),
885        }
886    }
887
888    /// Add text content element.
889    fn add_text_content(&mut self, text: &TextContent) -> &mut Self {
890        // F-3: If this text has an artifact type, wrap it in /Artifact BDC/EMC
891        // so Assistive Technology skips it.
892        let is_artifact = text.artifact_type.is_some();
893        if is_artifact {
894            use crate::extractors::text::ArtifactType;
895            // End any open text object before BDC (BDC must be outside BT/ET).
896            self.end_text();
897            let (artifact_type, subtype) = match &text.artifact_type {
898                Some(ArtifactType::Pagination(sub)) => {
899                    use crate::extractors::text::PaginationSubtype;
900                    let sub_str = match sub {
901                        PaginationSubtype::Header => Some("Header".to_string()),
902                        PaginationSubtype::Footer => Some("Footer".to_string()),
903                        PaginationSubtype::PageNumber => Some("PageNum".to_string()),
904                        PaginationSubtype::Watermark => Some("Watermark".to_string()),
905                        PaginationSubtype::Other => None,
906                    };
907                    ("Pagination".to_string(), sub_str)
908                },
909                Some(ArtifactType::Layout) => ("Layout".to_string(), None),
910                Some(ArtifactType::Page) => ("Page".to_string(), None),
911                Some(ArtifactType::Background) => ("Background".to_string(), None),
912                None => unreachable!(),
913            };
914            self.op(ContentStreamOp::BeginArtifact {
915                artifact_type,
916                subtype,
917            });
918        }
919
920        self.begin_text();
921
922        // Always set the fill colour explicitly. Without this, a previous
923        // `rg` operation in the content stream (e.g. a table cell's gray
924        // background fill before the text is drawn) bleeds into the text
925        // and renders body content in pale gray instead of black —
926        // exactly the "no text at all" symptom we hit on XLSX→PDF tables.
927        self.fill_color(text.style.color);
928
929        // Set font
930        let font_name = self.map_font_name(&text.font.name, text.style.weight.is_bold());
931        self.set_font(&font_name, text.font.size);
932
933        // Position and show text
934        self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, text.bbox.x, text.bbox.y));
935        self.op(ContentStreamOp::ShowText(text.text.clone()));
936
937        if is_artifact {
938            self.end_text();
939            self.op(ContentStreamOp::EndArtifact);
940        }
941
942        self
943    }
944
945    /// Map a font name to a PDF base font name.
946    fn map_font_name(&self, name: &str, bold: bool) -> String {
947        map_base14_font_name(name, bold)
948    }
949
950    /// Add path content element.
951    fn add_path_content(&mut self, path: &PathContent) -> &mut Self {
952        // End any text object first
953        self.end_text();
954
955        // Artifact wrapping (e.g. footnote separator line).
956        let is_artifact = path.artifact_type.is_some();
957        if is_artifact {
958            use crate::extractors::text::ArtifactType;
959            let (artifact_type, subtype) = match &path.artifact_type {
960                Some(ArtifactType::Pagination(sub)) => {
961                    use crate::extractors::text::PaginationSubtype;
962                    let sub_str = match sub {
963                        PaginationSubtype::Header => Some("Header".to_string()),
964                        PaginationSubtype::Footer => Some("Footer".to_string()),
965                        PaginationSubtype::PageNumber => Some("PageNum".to_string()),
966                        PaginationSubtype::Watermark => Some("Watermark".to_string()),
967                        PaginationSubtype::Other => None,
968                    };
969                    ("Pagination".to_string(), sub_str)
970                },
971                Some(ArtifactType::Layout) => ("Layout".to_string(), None),
972                Some(ArtifactType::Page) => ("Page".to_string(), None),
973                Some(ArtifactType::Background) => ("Background".to_string(), None),
974                None => unreachable!(),
975            };
976            self.op(ContentStreamOp::BeginArtifact {
977                artifact_type,
978                subtype,
979            });
980        }
981
982        // If the path carries a 2D affine transform, bracket it in
983        // `q cm ... Q` so graphics state stays scoped to this path
984        // (#393 Bundle A-2 follow-up). The `had_matrix` flag drives
985        // the matching `Q` after the stroke/fill op below.
986        let had_matrix = if let Some(m) = path.matrix {
987            self.op(ContentStreamOp::SaveState);
988            self.op(ContentStreamOp::Transform(m[0], m[1], m[2], m[3], m[4], m[5]));
989            true
990        } else {
991            false
992        };
993
994        // Set stroke properties
995        if let Some(color) = path.stroke_color {
996            self.stroke_color(color);
997        }
998        if let Some(color) = path.fill_color {
999            self.fill_color(color);
1000        }
1001        self.op(ContentStreamOp::SetLineWidth(path.stroke_width));
1002
1003        // Dash pattern (if any) must come before stroke ops. Reset to
1004        // solid afterwards so subsequent paths don't inherit a stale
1005        // pattern. (PDF graphics state bleeds across uncontained
1006        // operations; this is safer than assuming a surrounding q/Q.)
1007        let had_dash = if let Some((dashes, phase)) = path.dash_pattern.as_ref() {
1008            self.set_dash_pattern(dashes.clone(), *phase);
1009            true
1010        } else {
1011            false
1012        };
1013
1014        // Add path operations
1015        for op in &path.operations {
1016            match op {
1017                PathOperation::MoveTo(x, y) => {
1018                    self.op(ContentStreamOp::MoveTo(*x, *y));
1019                },
1020                PathOperation::LineTo(x, y) => {
1021                    self.op(ContentStreamOp::LineTo(*x, *y));
1022                },
1023                PathOperation::CurveTo(x1, y1, x2, y2, x3, y3) => {
1024                    self.op(ContentStreamOp::CurveTo(*x1, *y1, *x2, *y2, *x3, *y3));
1025                },
1026                PathOperation::Rectangle(x, y, w, h) => {
1027                    self.op(ContentStreamOp::Rectangle(*x, *y, *w, *h));
1028                },
1029                PathOperation::ClosePath => {
1030                    self.op(ContentStreamOp::ClosePath);
1031                },
1032            }
1033        }
1034
1035        // Apply stroke/fill
1036        match (path.stroke_color.is_some(), path.fill_color.is_some()) {
1037            (true, true) => self.op(ContentStreamOp::FillStroke),
1038            (true, false) => self.op(ContentStreamOp::Stroke),
1039            (false, true) => self.op(ContentStreamOp::Fill),
1040            (false, false) => self.op(ContentStreamOp::EndPath),
1041        };
1042
1043        // Restore solid strokes for subsequent paths.
1044        if had_dash {
1045            self.set_dash_pattern(Vec::new(), 0.0);
1046        }
1047
1048        // Close the `q cm` bracket if we opened one above. RestoreState
1049        // also rolls back the line-width + colours + dash pattern, so
1050        // the explicit `set_dash_pattern([], 0)` above is redundant in
1051        // the had_matrix case — harmless, but documented here so a
1052        // reader doesn't think we're leaking dash state on transforms.
1053        if had_matrix {
1054            self.op(ContentStreamOp::RestoreState);
1055        }
1056
1057        if is_artifact {
1058            self.op(ContentStreamOp::EndArtifact);
1059        }
1060
1061        self
1062    }
1063
1064    /// Add table content element.
1065    ///
1066    /// Renders the table directly to the content stream using:
1067    /// - Rectangle operations for cell backgrounds
1068    /// - Line operations for borders
1069    /// - Text operations for cell content
1070    fn add_table_content(&mut self, table: &TableContent) -> &mut Self {
1071        // End any text object first
1072        self.end_text();
1073
1074        let style = &table.style;
1075        let padding = style.cell_padding;
1076
1077        // Save graphics state for table rendering
1078        self.op(ContentStreamOp::SaveState);
1079
1080        // Calculate row positions based on bounding boxes
1081        let mut current_y = table.bbox.y + table.bbox.height;
1082
1083        for (row_idx, row) in table.rows.iter().enumerate() {
1084            let row_height = row
1085                .height
1086                .unwrap_or_else(|| table.bbox.height / table.rows.len() as f32);
1087            current_y -= row_height;
1088
1089            let mut current_x = table.bbox.x;
1090
1091            // Draw row background if specified
1092            if let Some((r, g, b)) = row.background {
1093                self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
1094                self.op(ContentStreamOp::Rectangle(
1095                    table.bbox.x,
1096                    current_y,
1097                    table.bbox.width,
1098                    row_height,
1099                ));
1100                self.op(ContentStreamOp::Fill);
1101            }
1102
1103            // Draw stripe background for alternating rows
1104            if row_idx % 2 == 1 {
1105                if let Some((r, g, b)) = style.stripe_background {
1106                    self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
1107                    self.op(ContentStreamOp::Rectangle(
1108                        table.bbox.x,
1109                        current_y,
1110                        table.bbox.width,
1111                        row_height,
1112                    ));
1113                    self.op(ContentStreamOp::Fill);
1114                }
1115            }
1116
1117            // Draw header background if this is a header row
1118            if row.is_header {
1119                if let Some((r, g, b)) = style.header_background {
1120                    self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
1121                    self.op(ContentStreamOp::Rectangle(
1122                        table.bbox.x,
1123                        current_y,
1124                        table.bbox.width,
1125                        row_height,
1126                    ));
1127                    self.op(ContentStreamOp::Fill);
1128                }
1129            }
1130
1131            for (col_idx, cell) in row.cells.iter().enumerate() {
1132                // Calculate cell width
1133                let cell_width = if col_idx < table.column_widths.len() {
1134                    table.column_widths[col_idx] * cell.colspan as f32
1135                } else if !table.column_widths.is_empty() {
1136                    table.column_widths[0]
1137                } else {
1138                    table.bbox.width / row.cells.len() as f32
1139                };
1140
1141                // Draw cell background if specified
1142                if let Some((r, g, b)) = cell.background {
1143                    self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
1144                    self.op(ContentStreamOp::Rectangle(
1145                        current_x, current_y, cell_width, row_height,
1146                    ));
1147                    self.op(ContentStreamOp::Fill);
1148                }
1149
1150                // Draw cell text
1151                if !cell.text.is_empty() {
1152                    let font_size = cell.font_size.unwrap_or(10.0);
1153                    let font_name = if cell.bold {
1154                        "Helvetica-Bold"
1155                    } else {
1156                        "Helvetica"
1157                    };
1158
1159                    // Calculate text position based on alignment
1160                    let text_x = match cell.align {
1161                        TableCellAlign::Left => current_x + padding,
1162                        TableCellAlign::Center => current_x + cell_width / 2.0,
1163                        TableCellAlign::Right => current_x + cell_width - padding,
1164                    };
1165
1166                    // Position text at top of cell with padding
1167                    let text_y = current_y + row_height - padding - font_size;
1168
1169                    self.begin_text();
1170                    self.op(ContentStreamOp::SetFillColorRGB(0.0, 0.0, 0.0)); // Black text
1171                    self.set_font(font_name, font_size);
1172                    self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, text_x, text_y));
1173                    self.op(ContentStreamOp::ShowText(cell.text.clone()));
1174                    self.end_text();
1175                }
1176
1177                current_x += cell_width;
1178            }
1179        }
1180
1181        // Draw borders
1182        if style.border_width > 0.0 {
1183            let (r, g, b) = style.border_color;
1184            self.op(ContentStreamOp::SetStrokeColorRGB(r, g, b));
1185            self.op(ContentStreamOp::SetLineWidth(style.border_width));
1186
1187            // Outer border
1188            if style.outer_border {
1189                self.op(ContentStreamOp::Rectangle(
1190                    table.bbox.x,
1191                    table.bbox.y,
1192                    table.bbox.width,
1193                    table.bbox.height,
1194                ));
1195                self.op(ContentStreamOp::Stroke);
1196            }
1197
1198            // Horizontal borders
1199            if style.horizontal_borders {
1200                let mut y = table.bbox.y + table.bbox.height;
1201                for row in &table.rows {
1202                    let row_height = row
1203                        .height
1204                        .unwrap_or_else(|| table.bbox.height / table.rows.len() as f32);
1205                    y -= row_height;
1206                    if y > table.bbox.y {
1207                        self.op(ContentStreamOp::MoveTo(table.bbox.x, y));
1208                        self.op(ContentStreamOp::LineTo(table.bbox.x + table.bbox.width, y));
1209                        self.op(ContentStreamOp::Stroke);
1210                    }
1211                }
1212            }
1213
1214            // Vertical borders
1215            if style.vertical_borders && !table.column_widths.is_empty() {
1216                let mut x = table.bbox.x;
1217                for (i, &width) in table.column_widths.iter().enumerate() {
1218                    x += width;
1219                    if i < table.column_widths.len() - 1 {
1220                        self.op(ContentStreamOp::MoveTo(x, table.bbox.y));
1221                        self.op(ContentStreamOp::LineTo(x, table.bbox.y + table.bbox.height));
1222                        self.op(ContentStreamOp::Stroke);
1223                    }
1224                }
1225            }
1226        }
1227
1228        // Restore graphics state
1229        self.op(ContentStreamOp::RestoreState);
1230
1231        self
1232    }
1233
1234    /// Add image content element.
1235    ///
1236    /// Registers the image for XObject creation and emits a Do operator
1237    /// to paint the image at its specified position.
1238    ///
1239    /// After calling `build()`, use `take_pending_images()` to retrieve
1240    /// the images that need to be registered as XObjects.
1241    fn add_image_content(&mut self, image: &ImageContent) -> &mut Self {
1242        // End any text object first
1243        self.end_text();
1244
1245        // PDF/UA-1 F-3: decorative images → /Artifact BDC/EMC.
1246        // PDF/UA-1 F-1: images with alt text → /Figure BDC/EMC + StructElemRecord.
1247        let is_artifact = image.is_artifact;
1248        let has_alt = image.alt_text.is_some() && !is_artifact;
1249
1250        let mcid = if has_alt {
1251            let mcid = self.next_mcid();
1252            self.op(ContentStreamOp::BeginMarkedContentDict {
1253                tag: "Figure".to_string(),
1254                mcid,
1255            });
1256            Some(mcid)
1257        } else if is_artifact {
1258            self.op(ContentStreamOp::BeginArtifact {
1259                artifact_type: "Layout".to_string(),
1260                subtype: None,
1261            });
1262            None
1263        } else {
1264            None
1265        };
1266
1267        // If the image carries a 2D affine transform, bracket it in
1268        // `q cm ... Q`. #393 Bundle A-2 follow-up.
1269        let had_matrix = if let Some(m) = image.matrix {
1270            self.op(ContentStreamOp::SaveState);
1271            self.op(ContentStreamOp::Transform(m[0], m[1], m[2], m[3], m[4], m[5]));
1272            true
1273        } else {
1274            false
1275        };
1276
1277        // Allocate resource ID for this image
1278        self.next_image_id += 1;
1279        let resource_id = format!("Im{}", self.next_image_id);
1280
1281        // Track the image for XObject registration
1282        self.pending_images.push(PendingImage {
1283            image: image.clone(),
1284            resource_id: resource_id.clone(),
1285        });
1286
1287        // Draw the image using the transformation matrix
1288        self.draw_image(
1289            &resource_id,
1290            image.bbox.x,
1291            image.bbox.y,
1292            image.bbox.width,
1293            image.bbox.height,
1294        );
1295
1296        if had_matrix {
1297            self.op(ContentStreamOp::RestoreState);
1298        }
1299
1300        if has_alt {
1301            self.op(ContentStreamOp::EndMarkedContent);
1302            // Push a StructElemRecord so pdf_writer.rs builds the /Figure
1303            // StructElem with /Alt when assembling the StructTreeRoot.
1304            self.struct_records.push(StructElemRecord {
1305                structure_type: "Figure".to_string(),
1306                mcid: mcid.unwrap(),
1307                alt_text: image.alt_text.clone(),
1308                language: None,
1309                children: Vec::new(),
1310            });
1311        } else if is_artifact {
1312            self.op(ContentStreamOp::EndArtifact);
1313        }
1314
1315        self
1316    }
1317
1318    /// Take the pending images that need to be registered as XObjects.
1319    ///
1320    /// This should be called after `build()` to retrieve images that
1321    /// need to be added to the page's Resources dictionary.
1322    pub fn take_pending_images(&mut self) -> Vec<PendingImage> {
1323        std::mem::take(&mut self.pending_images)
1324    }
1325
1326    /// Get a reference to pending images without removing them.
1327    pub fn pending_images(&self) -> &[PendingImage] {
1328        &self.pending_images
1329    }
1330
1331    /// Build multiple elements into the stream.
1332    pub fn add_elements(&mut self, elements: &[ContentElement]) -> &mut Self {
1333        for element in elements {
1334            self.add_element(element);
1335        }
1336        // Make sure to end any open text object
1337        self.end_text();
1338        self
1339    }
1340
1341    /// Get the next MCID value and increment the counter.
1342    pub fn next_mcid(&mut self) -> u32 {
1343        let mcid = self.mcid_counter;
1344        self.mcid_counter += 1;
1345        mcid
1346    }
1347
1348    /// Add a StructureElement with marked content wrapping.
1349    ///
1350    /// This wraps the structure element's children in BDC/EMC (Begin/End Marked Content)
1351    /// operators to enable tagged PDF support. Each content element gets a unique MCID.
1352    ///
1353    /// # Arguments
1354    ///
1355    /// * `elem` - The structure element to add, containing the hierarchy and content
1356    ///
1357    /// # PDF Spec Compliance
1358    ///
1359    /// - ISO 32000-1:2008, Section 14.7.4 - Marked Content Sequences
1360    /// - BDC operator with tag and MCID property dictionary
1361    /// - EMC operator for proper nesting
1362    pub fn add_structure_element(&mut self, elem: &StructureElement) -> &mut Self {
1363        let record = self.add_structure_element_impl(elem);
1364        self.struct_records.push(record);
1365        self
1366    }
1367
1368    /// Internal recursive implementation for adding structure elements.
1369    ///
1370    /// Returns a [`StructElemRecord`] capturing the allocated MCID and any
1371    /// nested records from child `StructureElement`s. The caller is
1372    /// responsible for storing or discarding the record.
1373    fn add_structure_element_impl(&mut self, elem: &StructureElement) -> StructElemRecord {
1374        // Allocate MCID for this structure element
1375        let mcid = self.next_mcid();
1376
1377        // Begin marked content with structure type as tag and MCID property
1378        self.op(ContentStreamOp::BeginMarkedContentDict {
1379            tag: elem.structure_type.clone(),
1380            mcid,
1381        });
1382
1383        // Add children (recursively for nested structures), accumulating records
1384        let mut child_records: Vec<StructElemRecord> = Vec::new();
1385        for child in &elem.children {
1386            match child {
1387                ContentElement::Structure(nested_elem) => {
1388                    // Recursively add nested structure element and collect record
1389                    let child_record = self.add_structure_element_impl(nested_elem);
1390                    child_records.push(child_record);
1391                },
1392                _ => {
1393                    // Add regular content element (no MCID record for leaf content)
1394                    self.add_element(child);
1395                },
1396            }
1397        }
1398
1399        // End marked content
1400        self.op(ContentStreamOp::EndMarkedContent);
1401
1402        StructElemRecord {
1403            structure_type: elem.structure_type.clone(),
1404            mcid,
1405            alt_text: elem.alt_text.clone(),
1406            language: elem.language.clone(),
1407            children: child_records,
1408        }
1409    }
1410
1411    /// Take the accumulated structure element records from this page's content stream.
1412    ///
1413    /// Called by `PdfWriter::finish` after processing each page to collect
1414    /// the structure records needed to build the StructTreeRoot dict.
1415    pub fn take_struct_records(&mut self) -> Vec<StructElemRecord> {
1416        std::mem::take(&mut self.struct_records)
1417    }
1418
1419    /// Build the content stream to bytes.
1420    ///
1421    /// Any [`ContentStreamOp::ShowEmbeddedText`] ops are serialized as-is
1422    /// with *original-face* GIDs. For correct subset-indexed output,
1423    /// use [`ContentStreamBuilder::build_with_remappers`] instead — the
1424    /// production writer pipeline ([`crate::writer::PdfWriter::finish`])
1425    /// always goes through the remapper-aware path.
1426    pub fn build(&self) -> Result<Vec<u8>> {
1427        self.build_with_remappers(&HashMap::new())
1428    }
1429
1430    /// Build the content stream to bytes, remapping every embedded-font
1431    /// glyph ID through its per-font [`GlyphRemapper`].
1432    ///
1433    /// `remappers` is keyed by the PDF resource name (e.g. `"EF1"`) used
1434    /// in the matching [`ContentStreamOp::ShowEmbeddedText::font_name`].
1435    /// Missing remappers fall back to emitting the original GID unchanged
1436    /// — a defensive path; in practice `PdfWriter::finish` always
1437    /// supplies a remapper for every embedded font it has registered.
1438    pub fn build_with_remappers(
1439        &self,
1440        remappers: &HashMap<String, GlyphRemapper>,
1441    ) -> Result<Vec<u8>> {
1442        let mut buf = Vec::new();
1443
1444        for op in &self.operations {
1445            self.write_op(&mut buf, op, remappers)?;
1446            writeln!(buf)?;
1447        }
1448
1449        Ok(buf)
1450    }
1451
1452    /// Write a single operation to the buffer.
1453    fn write_op<W: Write>(
1454        &self,
1455        w: &mut W,
1456        op: &ContentStreamOp,
1457        remappers: &HashMap<String, GlyphRemapper>,
1458    ) -> std::io::Result<()> {
1459        match op {
1460            ContentStreamOp::SaveState => write!(w, "q"),
1461            ContentStreamOp::RestoreState => write!(w, "Q"),
1462            ContentStreamOp::Transform(a, b, c, d, e, f) => {
1463                write!(w, "{} {} {} {} {} {} cm", a, b, c, d, e, f)
1464            },
1465            ContentStreamOp::BeginText => write!(w, "BT"),
1466            ContentStreamOp::EndText => write!(w, "ET"),
1467            ContentStreamOp::SetFont(name, size) => write!(w, "/{} {} Tf", name, size),
1468            ContentStreamOp::MoveText(tx, ty) => write!(w, "{} {} Td", tx, ty),
1469            ContentStreamOp::SetTextMatrix(a, b, c, d, e, f) => {
1470                write!(w, "{} {} {} {} {} {} Tm", a, b, c, d, e, f)
1471            },
1472            ContentStreamOp::ShowText(text) => {
1473                write!(w, "(")?;
1474                self.write_escaped_string(w, text)?;
1475                write!(w, ") Tj")
1476            },
1477            ContentStreamOp::ShowHexText(hex) => {
1478                // Hex string already formatted as <XXXX...>
1479                write!(w, "{} Tj", hex)
1480            },
1481            ContentStreamOp::ShowEmbeddedText {
1482                font_name,
1483                glyph_ids,
1484            } => {
1485                // Resolve original GIDs through the font's subset remapper.
1486                // Missing remapper is a defensive fallback — production
1487                // writer always supplies one.
1488                let remapper = remappers.get(font_name);
1489                write!(w, "<")?;
1490                for &orig in glyph_ids {
1491                    let emitted = remapper.and_then(|r| r.get(orig)).unwrap_or(orig);
1492                    write!(w, "{:04X}", emitted)?;
1493                }
1494                write!(w, "> Tj")
1495            },
1496            ContentStreamOp::ShowTextArray(items) => {
1497                write!(w, "[")?;
1498                for item in items {
1499                    match item {
1500                        TextArrayItem::Text(t) => {
1501                            write!(w, "(")?;
1502                            self.write_escaped_string(w, t)?;
1503                            write!(w, ")")?;
1504                        },
1505                        TextArrayItem::HexText(hex) => {
1506                            // Hex string already formatted as <XXXX...>
1507                            write!(w, "{}", hex)?;
1508                        },
1509                        TextArrayItem::Adjustment(adj) => {
1510                            write!(w, "{}", adj)?;
1511                        },
1512                    }
1513                    write!(w, " ")?;
1514                }
1515                write!(w, "] TJ")
1516            },
1517            ContentStreamOp::SetCharacterSpacing(spacing) => write!(w, "{} Tc", spacing),
1518            ContentStreamOp::SetWordSpacing(spacing) => write!(w, "{} Tw", spacing),
1519            ContentStreamOp::SetTextLeading(leading) => write!(w, "{} TL", leading),
1520            ContentStreamOp::NextLine => write!(w, "T*"),
1521            ContentStreamOp::SetFillColorRGB(r, g, b) => write!(w, "{} {} {} rg", r, g, b),
1522            ContentStreamOp::SetStrokeColorRGB(r, g, b) => write!(w, "{} {} {} RG", r, g, b),
1523            ContentStreamOp::SetFillColorGray(g) => write!(w, "{} g", g),
1524            ContentStreamOp::SetStrokeColorGray(g) => write!(w, "{} G", g),
1525            ContentStreamOp::SetLineWidth(width) => write!(w, "{} w", width),
1526            ContentStreamOp::MoveTo(x, y) => write!(w, "{} {} m", x, y),
1527            ContentStreamOp::LineTo(x, y) => write!(w, "{} {} l", x, y),
1528            ContentStreamOp::CurveTo(x1, y1, x2, y2, x3, y3) => {
1529                write!(w, "{} {} {} {} {} {} c", x1, y1, x2, y2, x3, y3)
1530            },
1531            ContentStreamOp::Rectangle(x, y, w_val, h) => {
1532                write!(w, "{} {} {} {} re", x, y, w_val, h)
1533            },
1534            ContentStreamOp::ClosePath => write!(w, "h"),
1535            ContentStreamOp::Stroke => write!(w, "S"),
1536            ContentStreamOp::Fill => write!(w, "f"),
1537            ContentStreamOp::FillStroke => write!(w, "B"),
1538            ContentStreamOp::CloseStroke => write!(w, "s"),
1539            ContentStreamOp::EndPath => write!(w, "n"),
1540            ContentStreamOp::PaintXObject(name) => write!(w, "/{} Do", name),
1541
1542            // Marked content operations
1543            ContentStreamOp::BeginMarkedContentDict { tag, mcid } => {
1544                write!(w, "/{} <</MCID {}>> BDC", tag, mcid)
1545            },
1546            ContentStreamOp::EndMarkedContent => write!(w, "EMC"),
1547
1548            // Artifact marked content (F-3)
1549            ContentStreamOp::BeginArtifact {
1550                artifact_type,
1551                subtype,
1552            } => {
1553                write!(w, "/Artifact <<")?;
1554                write!(w, "/Type /{}", artifact_type)?;
1555                if let Some(sub) = subtype {
1556                    write!(w, " /Subtype /{}", sub)?;
1557                }
1558                write!(w, ">> BDC")
1559            },
1560            ContentStreamOp::EndArtifact => write!(w, "EMC"),
1561
1562            // Clipping operations
1563            ContentStreamOp::Clip => write!(w, "W"),
1564            ContentStreamOp::ClipEvenOdd => write!(w, "W*"),
1565
1566            // Extended graphics state
1567            ContentStreamOp::SetExtGState(name) => write!(w, "/{} gs", name),
1568
1569            // Color space operations
1570            ContentStreamOp::SetFillColorSpace(name) => write!(w, "/{} cs", name),
1571            ContentStreamOp::SetStrokeColorSpace(name) => write!(w, "/{} CS", name),
1572            ContentStreamOp::SetFillColorN(components) => {
1573                for c in components {
1574                    write!(w, "{} ", c)?;
1575                }
1576                write!(w, "scn")
1577            },
1578            ContentStreamOp::SetStrokeColorN(components) => {
1579                for c in components {
1580                    write!(w, "{} ", c)?;
1581                }
1582                write!(w, "SCN")
1583            },
1584            ContentStreamOp::SetFillPattern(name, components) => {
1585                for c in components {
1586                    write!(w, "{} ", c)?;
1587                }
1588                write!(w, "/{} scn", name)
1589            },
1590            ContentStreamOp::SetStrokePattern(name, components) => {
1591                for c in components {
1592                    write!(w, "{} ", c)?;
1593                }
1594                write!(w, "/{} SCN", name)
1595            },
1596
1597            // Shading
1598            ContentStreamOp::PaintShading(name) => write!(w, "/{} sh", name),
1599
1600            // Additional path operations
1601            ContentStreamOp::CurveToV(x2, y2, x3, y3) => {
1602                write!(w, "{} {} {} {} v", x2, y2, x3, y3)
1603            },
1604            ContentStreamOp::CurveToY(x1, y1, x3, y3) => {
1605                write!(w, "{} {} {} {} y", x1, y1, x3, y3)
1606            },
1607            ContentStreamOp::FillEvenOdd => write!(w, "f*"),
1608            ContentStreamOp::FillStrokeEvenOdd => write!(w, "B*"),
1609            ContentStreamOp::CloseFillStroke => write!(w, "b"),
1610            ContentStreamOp::CloseFillStrokeEvenOdd => write!(w, "b*"),
1611
1612            // Line style operations
1613            ContentStreamOp::SetLineCap(cap) => write!(w, "{} J", *cap as u8),
1614            ContentStreamOp::SetLineJoin(join) => write!(w, "{} j", *join as u8),
1615            ContentStreamOp::SetMiterLimit(limit) => write!(w, "{} M", limit),
1616            ContentStreamOp::SetDashPattern(pattern, phase) => {
1617                write!(w, "[")?;
1618                for (i, p) in pattern.iter().enumerate() {
1619                    if i > 0 {
1620                        write!(w, " ")?;
1621                    }
1622                    write!(w, "{}", p)?;
1623                }
1624                write!(w, "] {} d", phase)
1625            },
1626
1627            // CMYK colors
1628            ContentStreamOp::SetFillColorCMYK(c, m, y, k) => {
1629                write!(w, "{} {} {} {} k", c, m, y, k)
1630            },
1631            ContentStreamOp::SetStrokeColorCMYK(c, m, y, k) => {
1632                write!(w, "{} {} {} {} K", c, m, y, k)
1633            },
1634
1635            ContentStreamOp::Raw(raw) => write!(w, "{}", raw),
1636        }
1637    }
1638
1639    /// Write an escaped PDF string for Base-14 font content streams (WinAnsiEncoding).
1640    ///
1641    /// Iterates Unicode scalar values and maps each to its WinAnsi/Latin-1 byte
1642    /// (code-point value for U+0000–U+00FF).  Characters above U+00FF cannot be
1643    /// represented in WinAnsiEncoding and are replaced with '?'; those require an
1644    /// embedded font with Identity-H encoding.
1645    fn write_escaped_string<W: Write>(&self, w: &mut W, text: &str) -> std::io::Result<()> {
1646        for ch in text.chars() {
1647            let cp = ch as u32;
1648            // First, collapse Mathematical Alphanumeric Symbols (U+1D400-1D7FF)
1649            // — italic/bold/script/etc. styled letters used in formulae — to
1650            // their plain Latin/Greek base. None of these have glyphs in the
1651            // standard 14 fonts, but `𝑥`→`x`, `𝛽`→`β`, `𝟗`→`9` is lossless
1652            // for word-level text recovery and only loses the styling.
1653            let cp = crate::fonts::encoding::math_alphanumeric_base(cp).unwrap_or(cp);
1654            // Then map to WinAnsi. Most chars below 0xFF map directly; chars
1655            // in 0x80-0x9F (smart quotes, em-dash, ellipsis, bullet, …) and
1656            // a handful above 0xFF (Euro, OE-ligature, …) go through
1657            // `unicode_to_winansi`. Genuine non-WinAnsi (Greek, CJK, …)
1658            // degrades to `?` — those need an embedded Unicode font.
1659            let b = match crate::fonts::encoding::unicode_to_winansi(cp) {
1660                Some(b) => b,
1661                None => {
1662                    w.write_all(b"?")?;
1663                    continue;
1664                },
1665            };
1666            match b {
1667                b'(' => write!(w, "\\(")?,
1668                b')' => write!(w, "\\)")?,
1669                b'\\' => write!(w, "\\\\")?,
1670                b'\n' => write!(w, "\\n")?,
1671                b'\r' => write!(w, "\\r")?,
1672                b'\t' => write!(w, "\\t")?,
1673                _ => w.write_all(&[b])?,
1674            }
1675        }
1676        Ok(())
1677    }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682    use super::*;
1683    use crate::elements::{FontSpec, TextStyle};
1684    use crate::geometry::Rect;
1685
1686    #[test]
1687    fn test_simple_text() {
1688        let mut builder = ContentStreamBuilder::new();
1689        builder
1690            .begin_text()
1691            .set_font("Helvetica", 12.0)
1692            .text("Hello, World!", 72.0, 720.0)
1693            .end_text();
1694
1695        let bytes = builder.build().unwrap();
1696        let content = String::from_utf8_lossy(&bytes);
1697
1698        assert!(content.contains("BT"));
1699        assert!(content.contains("/Helvetica 12 Tf"));
1700        assert!(content.contains("(Hello, World!) Tj"));
1701        assert!(content.contains("ET"));
1702    }
1703
1704    #[test]
1705    fn test_text_content_element() {
1706        let text_content = TextContent {
1707            artifact_type: None,
1708            text: "Test".to_string(),
1709            bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
1710            font: FontSpec::new("Helvetica", 12.0),
1711            style: TextStyle::default(),
1712            reading_order: Some(0),
1713            origin: None,
1714            rotation_degrees: None,
1715            matrix: None,
1716        };
1717
1718        let mut builder = ContentStreamBuilder::new();
1719        builder.add_element(&ContentElement::Text(text_content));
1720        builder.end_text();
1721
1722        let bytes = builder.build().unwrap();
1723        let content = String::from_utf8_lossy(&bytes);
1724
1725        assert!(content.contains("BT"));
1726        assert!(content.contains("100 700"));
1727        assert!(content.contains("(Test) Tj"));
1728        assert!(content.contains("ET"));
1729    }
1730
1731    #[test]
1732    fn test_path_operations() {
1733        let mut builder = ContentStreamBuilder::new();
1734        builder
1735            .stroke_color(Color::black())
1736            .op(ContentStreamOp::SetLineWidth(1.0))
1737            .op(ContentStreamOp::MoveTo(0.0, 0.0))
1738            .op(ContentStreamOp::LineTo(100.0, 100.0))
1739            .stroke();
1740
1741        let bytes = builder.build().unwrap();
1742        let content = String::from_utf8_lossy(&bytes);
1743
1744        assert!(content.contains("0 0 0 RG"));
1745        assert!(content.contains("1 w"));
1746        assert!(content.contains("0 0 m"));
1747        assert!(content.contains("100 100 l"));
1748        assert!(content.contains("S"));
1749    }
1750
1751    #[test]
1752    fn test_marked_content_operators() {
1753        let mut builder = ContentStreamBuilder::new();
1754
1755        builder
1756            .op(ContentStreamOp::BeginMarkedContentDict {
1757                tag: "P".to_string(),
1758                mcid: 0,
1759            })
1760            .op(ContentStreamOp::EndMarkedContent);
1761
1762        let bytes = builder.build().unwrap();
1763        let content = String::from_utf8_lossy(&bytes);
1764
1765        assert!(content.contains("/P <</MCID 0>> BDC"));
1766        assert!(content.contains("EMC"));
1767    }
1768
1769    #[test]
1770    fn test_mcid_allocation() {
1771        let mut builder = ContentStreamBuilder::new();
1772        assert_eq!(builder.next_mcid(), 0);
1773        assert_eq!(builder.next_mcid(), 1);
1774        assert_eq!(builder.next_mcid(), 2);
1775    }
1776
1777    #[test]
1778    fn test_structure_element_with_text() {
1779        use crate::elements::FontSpec;
1780        use crate::geometry::Rect;
1781
1782        let text_content = TextContent {
1783            artifact_type: None,
1784            text: "Hello".to_string(),
1785            bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
1786            font: FontSpec::new("Helvetica", 12.0),
1787            style: TextStyle::default(),
1788            reading_order: Some(0),
1789            origin: None,
1790            rotation_degrees: None,
1791            matrix: None,
1792        };
1793
1794        let structure = StructureElement {
1795            structure_type: "P".to_string(),
1796            bbox: Rect::new(100.0, 700.0, 200.0, 50.0),
1797            children: vec![ContentElement::Text(text_content)],
1798            reading_order: Some(0),
1799            alt_text: None,
1800            language: None,
1801        };
1802
1803        let mut builder = ContentStreamBuilder::new();
1804        builder.add_structure_element(&structure);
1805        builder.end_text();
1806
1807        let bytes = builder.build().unwrap();
1808        let content = String::from_utf8_lossy(&bytes);
1809
1810        assert!(content.contains("/P <</MCID 0>> BDC"));
1811        assert!(content.contains("EMC"));
1812        assert!(content.contains("(Hello) Tj"));
1813    }
1814
1815    #[test]
1816    fn test_nested_structure_elements() {
1817        use crate::geometry::Rect;
1818
1819        let inner_structure = StructureElement {
1820            structure_type: "Span".to_string(),
1821            bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
1822            children: vec![],
1823            reading_order: None,
1824            alt_text: None,
1825            language: None,
1826        };
1827
1828        let outer_structure = StructureElement {
1829            structure_type: "P".to_string(),
1830            bbox: Rect::new(100.0, 700.0, 200.0, 50.0),
1831            children: vec![ContentElement::Structure(inner_structure)],
1832            reading_order: Some(0),
1833            alt_text: None,
1834            language: None,
1835        };
1836
1837        let mut builder = ContentStreamBuilder::new();
1838        builder.add_structure_element(&outer_structure);
1839
1840        let bytes = builder.build().unwrap();
1841        let content = String::from_utf8_lossy(&bytes);
1842
1843        // Should have BDC/EMC pairs for both outer and inner structures
1844        assert!(content.contains("/P <</MCID 0>> BDC"));
1845        assert!(content.contains("/Span <</MCID 1>> BDC"));
1846
1847        // Count EMC to ensure proper nesting
1848        let emc_count = content.matches("EMC").count();
1849        assert_eq!(emc_count, 2);
1850    }
1851
1852    #[test]
1853    fn test_rectangle() {
1854        let mut builder = ContentStreamBuilder::new();
1855        builder.rect(72.0, 72.0, 468.0, 648.0).stroke();
1856
1857        let bytes = builder.build().unwrap();
1858        let content = String::from_utf8_lossy(&bytes);
1859
1860        assert!(content.contains("72 72 468 648 re"));
1861        assert!(content.contains("S"));
1862    }
1863
1864    #[test]
1865    fn test_escaped_text() {
1866        let mut builder = ContentStreamBuilder::new();
1867        builder
1868            .begin_text()
1869            .set_font("Helvetica", 12.0)
1870            .text("Text with (parens) and \\backslash", 72.0, 720.0)
1871            .end_text();
1872
1873        let bytes = builder.build().unwrap();
1874        let content = String::from_utf8_lossy(&bytes);
1875
1876        assert!(content.contains("\\(parens\\)"));
1877        assert!(content.contains("\\\\backslash"));
1878    }
1879
1880    #[test]
1881    fn test_font_mapping() {
1882        let builder = ContentStreamBuilder::new();
1883
1884        assert_eq!(builder.map_font_name("Arial", false), "Helvetica");
1885        assert_eq!(builder.map_font_name("Arial", true), "Helvetica-Bold");
1886        assert_eq!(builder.map_font_name("Times New Roman", false), "Times-Roman");
1887        assert_eq!(builder.map_font_name("Courier", false), "Courier");
1888    }
1889
1890    /// Issue #525 core: an explicit Standard-14 PostScript name (with or
1891    /// without a style flag) must resolve to *itself*, not collapse to
1892    /// the regular family face. Also covers the oblique path that did
1893    /// not exist before. Symbol/ZapfDingbats are intentionally NOT
1894    /// routed (they need built-in non-WinAnsi encodings and aren't in
1895    /// the page font set) — they fall through to Helvetica.
1896    #[test]
1897    fn test_font_mapping_explicit_standard14() {
1898        let b = ContentStreamBuilder::new();
1899
1900        // Every Latin Standard-14 face round-trips by name.
1901        for f in [
1902            "Helvetica",
1903            "Helvetica-Bold",
1904            "Helvetica-Oblique",
1905            "Helvetica-BoldOblique",
1906            "Times-Roman",
1907            "Times-Bold",
1908            "Times-Italic",
1909            "Times-BoldItalic",
1910            "Courier",
1911            "Courier-Bold",
1912            "Courier-Oblique",
1913            "Courier-BoldOblique",
1914        ] {
1915            assert_eq!(b.map_font_name(f, false), f, "{f} did not round-trip");
1916        }
1917
1918        // The style flag composes with a name-derived style.
1919        assert_eq!(b.map_font_name("Helvetica", true), "Helvetica-Bold");
1920        assert_eq!(b.map_font_name("Helvetica-Oblique", true), "Helvetica-BoldOblique");
1921
1922        // Case-insensitive, and generic styled aliases.
1923        assert_eq!(b.map_font_name("helvetica-bold", false), "Helvetica-Bold");
1924        assert_eq!(b.map_font_name("Arial Bold", false), "Helvetica-Bold");
1925        assert_eq!(b.map_font_name("Times New Roman Italic", false), "Times-Italic");
1926
1927        // Symbol / ZapfDingbats are NOT routed by this function: they
1928        // use built-in (non-WinAnsi) encodings and aren't pre-registered
1929        // in the page /Font dict, so emitting their names would yield a
1930        // dangling Tf (Copilot review on PR #523 caught this). They
1931        // fall through to the Helvetica fallback; callers who actually
1932        // need Symbol/ZapfDingbats must use the embedded-font path.
1933        assert_eq!(b.map_font_name("Symbol", false), "Helvetica");
1934        assert_eq!(b.map_font_name("Symbol", true), "Helvetica-Bold");
1935        assert_eq!(b.map_font_name("ZapfDingbats", true), "Helvetica-Bold");
1936    }
1937
1938    #[test]
1939    fn test_table_content_rendering() {
1940        use crate::elements::{TableCellContent, TableContent, TableContentStyle, TableRowContent};
1941
1942        // Create a simple 2x2 table
1943        let mut table = TableContent::new(Rect::new(72.0, 600.0, 200.0, 100.0));
1944        table.column_widths = vec![100.0, 100.0];
1945        table.style = TableContentStyle::bordered();
1946
1947        // Header row
1948        let header = TableRowContent::header(vec![
1949            TableCellContent::header("Name"),
1950            TableCellContent::header("Value"),
1951        ]);
1952        table.add_row(header);
1953
1954        // Data row
1955        let row =
1956            TableRowContent::new(vec![TableCellContent::new("Item"), TableCellContent::new("100")]);
1957        table.add_row(row);
1958
1959        let mut builder = ContentStreamBuilder::new();
1960        builder.add_element(&ContentElement::Table(table));
1961
1962        let bytes = builder.build().unwrap();
1963        let content = String::from_utf8_lossy(&bytes);
1964
1965        // Should contain graphics state operations
1966        assert!(content.contains("q")); // Save state
1967        assert!(content.contains("Q")); // Restore state
1968
1969        // Should contain text for cells
1970        assert!(content.contains("(Name) Tj"));
1971        assert!(content.contains("(Value) Tj"));
1972        assert!(content.contains("(Item) Tj"));
1973        assert!(content.contains("(100) Tj"));
1974
1975        // Should contain stroke operations for borders
1976        assert!(content.contains("re")); // Rectangle
1977        assert!(content.contains("S")); // Stroke
1978
1979        // No pending images
1980        assert!(builder.pending_images().is_empty());
1981    }
1982
1983    #[test]
1984    fn test_image_content_rendering() {
1985        use crate::elements::{ColorSpace, ImageContent, ImageFormat};
1986
1987        // Create a test image
1988        let image = ImageContent {
1989            bbox: Rect::new(100.0, 500.0, 200.0, 150.0),
1990            format: ImageFormat::Jpeg,
1991            data: vec![0xFF, 0xD8, 0xFF, 0xE0], // JPEG magic bytes
1992            width: 800,
1993            height: 600,
1994            bits_per_component: 8,
1995            color_space: ColorSpace::RGB,
1996            reading_order: Some(0),
1997            alt_text: Some("Test image".to_string()),
1998            horizontal_dpi: None,
1999            vertical_dpi: None,
2000            soft_mask: None,
2001            matrix: None,
2002            is_artifact: false,
2003        };
2004
2005        let mut builder = ContentStreamBuilder::new();
2006        builder.add_element(&ContentElement::Image(image));
2007
2008        let bytes = builder.build().unwrap();
2009        let content = String::from_utf8_lossy(&bytes);
2010
2011        // Should contain image drawing operations
2012        assert!(content.contains("q")); // Save state
2013        assert!(content.contains("Q")); // Restore state
2014        assert!(content.contains("cm")); // Transform matrix
2015        assert!(content.contains("Do")); // Paint XObject
2016
2017        // Should have one pending image
2018        let pending = builder.pending_images();
2019        assert_eq!(pending.len(), 1);
2020        assert_eq!(pending[0].resource_id, "Im1");
2021        assert_eq!(pending[0].image.width, 800);
2022        assert_eq!(pending[0].image.height, 600);
2023    }
2024
2025    #[test]
2026    fn test_mixed_content_elements() {
2027        use crate::elements::{
2028            ColorSpace, ImageContent, ImageFormat, TableCellContent, TableContent,
2029            TableContentStyle, TableRowContent,
2030        };
2031
2032        let mut builder = ContentStreamBuilder::new();
2033
2034        // Add text
2035        let text_content = TextContent {
2036            artifact_type: None,
2037            text: "Header".to_string(),
2038            bbox: Rect::new(72.0, 720.0, 100.0, 14.0),
2039            font: FontSpec::new("Helvetica", 14.0),
2040            style: TextStyle::default(),
2041            reading_order: Some(0),
2042            origin: None,
2043            rotation_degrees: None,
2044            matrix: None,
2045        };
2046        builder.add_element(&ContentElement::Text(text_content));
2047
2048        // Add table
2049        let mut table = TableContent::new(Rect::new(72.0, 600.0, 200.0, 50.0));
2050        table.column_widths = vec![200.0];
2051        table.style = TableContentStyle::minimal();
2052        table.add_row(TableRowContent::new(vec![TableCellContent::new("Row 1")]));
2053        builder.add_element(&ContentElement::Table(table));
2054
2055        // Add image
2056        let image = ImageContent {
2057            bbox: Rect::new(72.0, 400.0, 100.0, 100.0),
2058            format: ImageFormat::Png,
2059            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
2060            width: 200,
2061            height: 200,
2062            bits_per_component: 8,
2063            color_space: ColorSpace::RGB,
2064            reading_order: Some(2),
2065            alt_text: None,
2066            horizontal_dpi: None,
2067            vertical_dpi: None,
2068            soft_mask: None,
2069            matrix: None,
2070            is_artifact: false,
2071        };
2072        builder.add_element(&ContentElement::Image(image));
2073
2074        let bytes = builder.build().unwrap();
2075        let content = String::from_utf8_lossy(&bytes);
2076
2077        // Verify all content types are present
2078        assert!(content.contains("(Header) Tj")); // Text
2079        assert!(content.contains("(Row 1) Tj")); // Table cell text
2080        assert!(content.contains("/Im1 Do")); // Image
2081
2082        // Should have one pending image
2083        assert_eq!(builder.pending_images().len(), 1);
2084    }
2085
2086    #[test]
2087    fn test_take_pending_images() {
2088        use crate::elements::{ColorSpace, ImageContent, ImageFormat};
2089
2090        let image = ImageContent {
2091            bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
2092            format: ImageFormat::Jpeg,
2093            data: vec![0xFF, 0xD8],
2094            width: 100,
2095            height: 100,
2096            bits_per_component: 8,
2097            color_space: ColorSpace::RGB,
2098            reading_order: None,
2099            alt_text: None,
2100            horizontal_dpi: None,
2101            vertical_dpi: None,
2102            soft_mask: None,
2103            matrix: None,
2104            is_artifact: false,
2105        };
2106
2107        let mut builder = ContentStreamBuilder::new();
2108        builder.add_element(&ContentElement::Image(image));
2109
2110        // Take pending images
2111        let pending = builder.take_pending_images();
2112        assert_eq!(pending.len(), 1);
2113
2114        // After taking, should be empty
2115        assert!(builder.pending_images().is_empty());
2116        assert!(builder.take_pending_images().is_empty());
2117    }
2118
2119    // ========== Additional Coverage Tests ==========
2120
2121    #[test]
2122    fn test_save_restore_state() {
2123        let mut builder = ContentStreamBuilder::new();
2124        builder.save_state().restore_state();
2125
2126        let bytes = builder.build().unwrap();
2127        let content = String::from_utf8_lossy(&bytes);
2128        assert!(content.contains("q\n"));
2129        assert!(content.contains("Q\n"));
2130    }
2131
2132    #[test]
2133    fn test_transform_matrix() {
2134        let mut builder = ContentStreamBuilder::new();
2135        builder.transform(1.0, 0.0, 0.0, 1.0, 100.0, 200.0);
2136
2137        let bytes = builder.build().unwrap();
2138        let content = String::from_utf8_lossy(&bytes);
2139        assert!(content.contains("1 0 0 1 100 200 cm"));
2140    }
2141
2142    #[test]
2143    fn test_translate() {
2144        let mut builder = ContentStreamBuilder::new();
2145        builder.translate(50.0, 75.0);
2146
2147        let bytes = builder.build().unwrap();
2148        let content = String::from_utf8_lossy(&bytes);
2149        assert!(content.contains("1 0 0 1 50 75 cm"));
2150    }
2151
2152    #[test]
2153    fn test_scale() {
2154        let mut builder = ContentStreamBuilder::new();
2155        builder.scale(2.0, 3.0);
2156
2157        let bytes = builder.build().unwrap();
2158        let content = String::from_utf8_lossy(&bytes);
2159        assert!(content.contains("2 0 0 3 0 0 cm"));
2160    }
2161
2162    #[test]
2163    fn test_rotate() {
2164        let mut builder = ContentStreamBuilder::new();
2165        builder.rotate(std::f32::consts::PI / 2.0);
2166
2167        let bytes = builder.build().unwrap();
2168        let content = String::from_utf8_lossy(&bytes);
2169        assert!(content.contains("cm"));
2170    }
2171
2172    #[test]
2173    fn test_rotate_degrees() {
2174        let mut builder = ContentStreamBuilder::new();
2175        builder.rotate_degrees(90.0);
2176
2177        let bytes = builder.build().unwrap();
2178        let content = String::from_utf8_lossy(&bytes);
2179        assert!(content.contains("cm"));
2180    }
2181
2182    #[test]
2183    fn test_fill_color() {
2184        let mut builder = ContentStreamBuilder::new();
2185        builder.fill_color(Color {
2186            r: 1.0,
2187            g: 0.0,
2188            b: 0.0,
2189        });
2190
2191        let bytes = builder.build().unwrap();
2192        let content = String::from_utf8_lossy(&bytes);
2193        assert!(content.contains("1 0 0 rg"));
2194    }
2195
2196    #[test]
2197    fn test_stroke_color() {
2198        let mut builder = ContentStreamBuilder::new();
2199        builder.stroke_color(Color {
2200            r: 0.0,
2201            g: 1.0,
2202            b: 0.0,
2203        });
2204
2205        let bytes = builder.build().unwrap();
2206        let content = String::from_utf8_lossy(&bytes);
2207        assert!(content.contains("0 1 0 RG"));
2208    }
2209
2210    #[test]
2211    fn test_set_fill_color_rgb() {
2212        let mut builder = ContentStreamBuilder::new();
2213        builder.set_fill_color(0.5, 0.6, 0.7);
2214
2215        let bytes = builder.build().unwrap();
2216        let content = String::from_utf8_lossy(&bytes);
2217        assert!(content.contains("0.5 0.6 0.7 rg"));
2218    }
2219
2220    #[test]
2221    fn test_set_stroke_color_rgb() {
2222        let mut builder = ContentStreamBuilder::new();
2223        builder.set_stroke_color(0.1, 0.2, 0.3);
2224
2225        let bytes = builder.build().unwrap();
2226        let content = String::from_utf8_lossy(&bytes);
2227        assert!(content.contains("0.1 0.2 0.3 RG"));
2228    }
2229
2230    #[test]
2231    fn test_set_line_width() {
2232        let mut builder = ContentStreamBuilder::new();
2233        builder.set_line_width(2.5);
2234
2235        let bytes = builder.build().unwrap();
2236        let content = String::from_utf8_lossy(&bytes);
2237        assert!(content.contains("2.5 w"));
2238    }
2239
2240    #[test]
2241    fn test_move_to_and_line_to() {
2242        let mut builder = ContentStreamBuilder::new();
2243        builder.move_to(10.0, 20.0).line_to(30.0, 40.0);
2244
2245        let bytes = builder.build().unwrap();
2246        let content = String::from_utf8_lossy(&bytes);
2247        assert!(content.contains("10 20 m"));
2248        assert!(content.contains("30 40 l"));
2249    }
2250
2251    #[test]
2252    fn test_close_path() {
2253        let mut builder = ContentStreamBuilder::new();
2254        builder
2255            .move_to(0.0, 0.0)
2256            .line_to(100.0, 0.0)
2257            .line_to(100.0, 100.0)
2258            .close_path();
2259
2260        let bytes = builder.build().unwrap();
2261        let content = String::from_utf8_lossy(&bytes);
2262        assert!(content.contains("h\n"));
2263    }
2264
2265    #[test]
2266    fn test_fill() {
2267        let mut builder = ContentStreamBuilder::new();
2268        builder.rect(0.0, 0.0, 100.0, 100.0).fill();
2269
2270        let bytes = builder.build().unwrap();
2271        let content = String::from_utf8_lossy(&bytes);
2272        assert!(content.contains("re\n"));
2273        assert!(content.contains("f\n"));
2274    }
2275
2276    #[test]
2277    fn test_fill_stroke() {
2278        let mut builder = ContentStreamBuilder::new();
2279        builder.rect(0.0, 0.0, 100.0, 100.0).fill_stroke();
2280
2281        let bytes = builder.build().unwrap();
2282        let content = String::from_utf8_lossy(&bytes);
2283        assert!(content.contains("B\n"));
2284    }
2285
2286    #[test]
2287    fn test_fill_even_odd() {
2288        let mut builder = ContentStreamBuilder::new();
2289        builder.rect(0.0, 0.0, 100.0, 100.0).fill_even_odd();
2290
2291        let bytes = builder.build().unwrap();
2292        let content = String::from_utf8_lossy(&bytes);
2293        assert!(content.contains("f*\n"));
2294    }
2295
2296    #[test]
2297    fn test_fill_stroke_even_odd() {
2298        let mut builder = ContentStreamBuilder::new();
2299        builder.rect(0.0, 0.0, 100.0, 100.0).fill_stroke_even_odd();
2300
2301        let bytes = builder.build().unwrap();
2302        let content = String::from_utf8_lossy(&bytes);
2303        assert!(content.contains("B*\n"));
2304    }
2305
2306    #[test]
2307    fn test_close_fill_stroke() {
2308        let mut builder = ContentStreamBuilder::new();
2309        builder
2310            .move_to(0.0, 0.0)
2311            .line_to(100.0, 0.0)
2312            .close_fill_stroke();
2313
2314        let bytes = builder.build().unwrap();
2315        let content = String::from_utf8_lossy(&bytes);
2316        assert!(content.contains("b\n"));
2317    }
2318
2319    #[test]
2320    fn test_clip() {
2321        let mut builder = ContentStreamBuilder::new();
2322        builder.rect(10.0, 10.0, 200.0, 200.0).clip().end_path();
2323
2324        let bytes = builder.build().unwrap();
2325        let content = String::from_utf8_lossy(&bytes);
2326        assert!(content.contains("W\n"));
2327        assert!(content.contains("n\n"));
2328    }
2329
2330    #[test]
2331    fn test_clip_even_odd() {
2332        let mut builder = ContentStreamBuilder::new();
2333        builder
2334            .rect(10.0, 10.0, 200.0, 200.0)
2335            .clip_even_odd()
2336            .end_path();
2337
2338        let bytes = builder.build().unwrap();
2339        let content = String::from_utf8_lossy(&bytes);
2340        assert!(content.contains("W*\n"));
2341    }
2342
2343    #[test]
2344    fn test_clip_rect() {
2345        let mut builder = ContentStreamBuilder::new();
2346        builder.clip_rect(10.0, 10.0, 200.0, 200.0);
2347
2348        let bytes = builder.build().unwrap();
2349        let content = String::from_utf8_lossy(&bytes);
2350        assert!(content.contains("10 10 200 200 re"));
2351        assert!(content.contains("W\n"));
2352        assert!(content.contains("n\n"));
2353    }
2354
2355    #[test]
2356    fn test_end_path() {
2357        let mut builder = ContentStreamBuilder::new();
2358        builder.rect(0.0, 0.0, 100.0, 100.0).end_path();
2359
2360        let bytes = builder.build().unwrap();
2361        let content = String::from_utf8_lossy(&bytes);
2362        assert!(content.contains("n\n"));
2363    }
2364
2365    #[test]
2366    fn test_set_ext_gstate() {
2367        let mut builder = ContentStreamBuilder::new();
2368        builder.set_ext_gstate("GS0");
2369
2370        let bytes = builder.build().unwrap();
2371        let content = String::from_utf8_lossy(&bytes);
2372        assert!(content.contains("/GS0 gs"));
2373    }
2374
2375    #[test]
2376    fn test_curve_to() {
2377        let mut builder = ContentStreamBuilder::new();
2378        builder
2379            .move_to(0.0, 0.0)
2380            .curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 60.0);
2381
2382        let bytes = builder.build().unwrap();
2383        let content = String::from_utf8_lossy(&bytes);
2384        assert!(content.contains("10 20 30 40 50 60 c"));
2385    }
2386
2387    #[test]
2388    fn test_curve_to_v() {
2389        let mut builder = ContentStreamBuilder::new();
2390        builder.move_to(0.0, 0.0).curve_to_v(10.0, 20.0, 30.0, 40.0);
2391
2392        let bytes = builder.build().unwrap();
2393        let content = String::from_utf8_lossy(&bytes);
2394        assert!(content.contains("10 20 30 40 v"));
2395    }
2396
2397    #[test]
2398    fn test_curve_to_y() {
2399        let mut builder = ContentStreamBuilder::new();
2400        builder.move_to(0.0, 0.0).curve_to_y(10.0, 20.0, 30.0, 40.0);
2401
2402        let bytes = builder.build().unwrap();
2403        let content = String::from_utf8_lossy(&bytes);
2404        assert!(content.contains("10 20 30 40 y"));
2405    }
2406
2407    #[test]
2408    fn test_circle() {
2409        let mut builder = ContentStreamBuilder::new();
2410        builder.circle(100.0, 100.0, 50.0);
2411
2412        let bytes = builder.build().unwrap();
2413        let content = String::from_utf8_lossy(&bytes);
2414        // Circle uses move_to and curve_to
2415        assert!(content.contains("m\n"));
2416        assert!(content.contains("c\n"));
2417        assert!(content.contains("h\n")); // close_path
2418    }
2419
2420    #[test]
2421    fn test_ellipse() {
2422        let mut builder = ContentStreamBuilder::new();
2423        builder.ellipse(200.0, 200.0, 80.0, 40.0);
2424
2425        let bytes = builder.build().unwrap();
2426        let content = String::from_utf8_lossy(&bytes);
2427        assert!(content.contains("m\n"));
2428        assert!(content.contains("c\n"));
2429        assert!(content.contains("h\n"));
2430    }
2431
2432    #[test]
2433    fn test_rounded_rect() {
2434        let mut builder = ContentStreamBuilder::new();
2435        builder.rounded_rect(50.0, 50.0, 200.0, 100.0, 10.0);
2436
2437        let bytes = builder.build().unwrap();
2438        let content = String::from_utf8_lossy(&bytes);
2439        // Should contain move, line, and curve operations
2440        assert!(content.contains("m\n"));
2441        assert!(content.contains("l\n"));
2442        assert!(content.contains("c\n"));
2443        assert!(content.contains("h\n"));
2444    }
2445
2446    #[test]
2447    fn test_rounded_rect_large_radius() {
2448        let mut builder = ContentStreamBuilder::new();
2449        // Radius larger than half width -- should be clamped
2450        builder.rounded_rect(0.0, 0.0, 20.0, 40.0, 50.0);
2451
2452        let bytes = builder.build().unwrap();
2453        let content = String::from_utf8_lossy(&bytes);
2454        assert!(content.contains("m\n"));
2455    }
2456
2457    #[test]
2458    fn test_set_line_cap() {
2459        let mut builder = ContentStreamBuilder::new();
2460        builder.set_line_cap(LineCap::Round);
2461
2462        let bytes = builder.build().unwrap();
2463        let content = String::from_utf8_lossy(&bytes);
2464        assert!(content.contains("1 J"));
2465    }
2466
2467    #[test]
2468    fn test_set_line_cap_square() {
2469        let mut builder = ContentStreamBuilder::new();
2470        builder.set_line_cap(LineCap::Square);
2471
2472        let bytes = builder.build().unwrap();
2473        let content = String::from_utf8_lossy(&bytes);
2474        assert!(content.contains("2 J"));
2475    }
2476
2477    #[test]
2478    fn test_set_line_join() {
2479        let mut builder = ContentStreamBuilder::new();
2480        builder.set_line_join(LineJoin::Round);
2481
2482        let bytes = builder.build().unwrap();
2483        let content = String::from_utf8_lossy(&bytes);
2484        assert!(content.contains("1 j"));
2485    }
2486
2487    #[test]
2488    fn test_set_line_join_bevel() {
2489        let mut builder = ContentStreamBuilder::new();
2490        builder.set_line_join(LineJoin::Bevel);
2491
2492        let bytes = builder.build().unwrap();
2493        let content = String::from_utf8_lossy(&bytes);
2494        assert!(content.contains("2 j"));
2495    }
2496
2497    #[test]
2498    fn test_set_miter_limit() {
2499        let mut builder = ContentStreamBuilder::new();
2500        builder.set_miter_limit(10.0);
2501
2502        let bytes = builder.build().unwrap();
2503        let content = String::from_utf8_lossy(&bytes);
2504        assert!(content.contains("10 M"));
2505    }
2506
2507    #[test]
2508    fn test_set_dash_pattern() {
2509        let mut builder = ContentStreamBuilder::new();
2510        builder.set_dash_pattern(vec![3.0, 2.0], 0.0);
2511
2512        let bytes = builder.build().unwrap();
2513        let content = String::from_utf8_lossy(&bytes);
2514        assert!(content.contains("[3 2] 0 d"));
2515    }
2516
2517    #[test]
2518    fn test_set_solid_line() {
2519        let mut builder = ContentStreamBuilder::new();
2520        builder.set_solid_line();
2521
2522        let bytes = builder.build().unwrap();
2523        let content = String::from_utf8_lossy(&bytes);
2524        assert!(content.contains("[] 0 d"));
2525    }
2526
2527    #[test]
2528    fn test_set_fill_color_space() {
2529        let mut builder = ContentStreamBuilder::new();
2530        builder.set_fill_color_space("DeviceRGB");
2531
2532        let bytes = builder.build().unwrap();
2533        let content = String::from_utf8_lossy(&bytes);
2534        assert!(content.contains("/DeviceRGB cs"));
2535    }
2536
2537    #[test]
2538    fn test_set_stroke_color_space() {
2539        let mut builder = ContentStreamBuilder::new();
2540        builder.set_stroke_color_space("DeviceCMYK");
2541
2542        let bytes = builder.build().unwrap();
2543        let content = String::from_utf8_lossy(&bytes);
2544        assert!(content.contains("/DeviceCMYK CS"));
2545    }
2546
2547    #[test]
2548    fn test_set_fill_color_n() {
2549        let mut builder = ContentStreamBuilder::new();
2550        builder.set_fill_color_n(vec![0.1, 0.2, 0.3]);
2551
2552        let bytes = builder.build().unwrap();
2553        let content = String::from_utf8_lossy(&bytes);
2554        assert!(content.contains("0.1 0.2 0.3 scn"));
2555    }
2556
2557    #[test]
2558    fn test_set_stroke_color_n() {
2559        let mut builder = ContentStreamBuilder::new();
2560        builder.set_stroke_color_n(vec![0.4, 0.5]);
2561
2562        let bytes = builder.build().unwrap();
2563        let content = String::from_utf8_lossy(&bytes);
2564        assert!(content.contains("0.4 0.5 SCN"));
2565    }
2566
2567    #[test]
2568    fn test_set_fill_color_cmyk() {
2569        let mut builder = ContentStreamBuilder::new();
2570        builder.set_fill_color_cmyk(0.0, 1.0, 1.0, 0.0);
2571
2572        let bytes = builder.build().unwrap();
2573        let content = String::from_utf8_lossy(&bytes);
2574        assert!(content.contains("0 1 1 0 k"));
2575    }
2576
2577    #[test]
2578    fn test_set_stroke_color_cmyk() {
2579        let mut builder = ContentStreamBuilder::new();
2580        builder.set_stroke_color_cmyk(1.0, 0.0, 0.0, 0.0);
2581
2582        let bytes = builder.build().unwrap();
2583        let content = String::from_utf8_lossy(&bytes);
2584        assert!(content.contains("1 0 0 0 K"));
2585    }
2586
2587    #[test]
2588    fn test_set_fill_pattern() {
2589        let mut builder = ContentStreamBuilder::new();
2590        builder.set_fill_pattern("P1", vec![]);
2591
2592        let bytes = builder.build().unwrap();
2593        let content = String::from_utf8_lossy(&bytes);
2594        assert!(content.contains("/P1 scn"));
2595    }
2596
2597    #[test]
2598    fn test_set_stroke_pattern() {
2599        let mut builder = ContentStreamBuilder::new();
2600        builder.set_stroke_pattern("P2", vec![0.5]);
2601
2602        let bytes = builder.build().unwrap();
2603        let content = String::from_utf8_lossy(&bytes);
2604        assert!(content.contains("0.5 /P2 SCN"));
2605    }
2606
2607    #[test]
2608    fn test_paint_shading() {
2609        let mut builder = ContentStreamBuilder::new();
2610        builder.paint_shading("Sh1");
2611
2612        let bytes = builder.build().unwrap();
2613        let content = String::from_utf8_lossy(&bytes);
2614        assert!(content.contains("/Sh1 sh"));
2615    }
2616
2617    #[test]
2618    fn test_draw_gradient_rect() {
2619        let mut builder = ContentStreamBuilder::new();
2620        builder.draw_gradient_rect("Sh0", 10.0, 20.0, 200.0, 100.0);
2621
2622        let bytes = builder.build().unwrap();
2623        let content = String::from_utf8_lossy(&bytes);
2624        assert!(content.contains("q\n")); // save
2625        assert!(content.contains("10 20 200 100 re"));
2626        assert!(content.contains("W\n")); // clip
2627        assert!(content.contains("n\n")); // end path
2628        assert!(content.contains("/Sh0 sh"));
2629        assert!(content.contains("Q\n")); // restore
2630    }
2631
2632    #[test]
2633    fn test_paint_xobject() {
2634        let mut builder = ContentStreamBuilder::new();
2635        builder.op(ContentStreamOp::PaintXObject("Img0".to_string()));
2636
2637        let bytes = builder.build().unwrap();
2638        let content = String::from_utf8_lossy(&bytes);
2639        assert!(content.contains("/Img0 Do"));
2640    }
2641
2642    #[test]
2643    fn test_close_stroke() {
2644        let mut builder = ContentStreamBuilder::new();
2645        builder.op(ContentStreamOp::CloseStroke);
2646
2647        let bytes = builder.build().unwrap();
2648        let content = String::from_utf8_lossy(&bytes);
2649        assert!(content.contains("s\n"));
2650    }
2651
2652    #[test]
2653    fn test_close_fill_stroke_even_odd() {
2654        let mut builder = ContentStreamBuilder::new();
2655        builder.op(ContentStreamOp::CloseFillStrokeEvenOdd);
2656
2657        let bytes = builder.build().unwrap();
2658        let content = String::from_utf8_lossy(&bytes);
2659        assert!(content.contains("b*\n"));
2660    }
2661
2662    #[test]
2663    fn test_set_fill_color_gray() {
2664        let mut builder = ContentStreamBuilder::new();
2665        builder.op(ContentStreamOp::SetFillColorGray(0.5));
2666
2667        let bytes = builder.build().unwrap();
2668        let content = String::from_utf8_lossy(&bytes);
2669        assert!(content.contains("0.5 g"));
2670    }
2671
2672    #[test]
2673    fn test_set_stroke_color_gray() {
2674        let mut builder = ContentStreamBuilder::new();
2675        builder.op(ContentStreamOp::SetStrokeColorGray(0.75));
2676
2677        let bytes = builder.build().unwrap();
2678        let content = String::from_utf8_lossy(&bytes);
2679        assert!(content.contains("0.75 G"));
2680    }
2681
2682    #[test]
2683    fn test_set_character_spacing() {
2684        let mut builder = ContentStreamBuilder::new();
2685        builder.op(ContentStreamOp::SetCharacterSpacing(2.0));
2686
2687        let bytes = builder.build().unwrap();
2688        let content = String::from_utf8_lossy(&bytes);
2689        assert!(content.contains("2 Tc"));
2690    }
2691
2692    #[test]
2693    fn test_set_word_spacing() {
2694        let mut builder = ContentStreamBuilder::new();
2695        builder.op(ContentStreamOp::SetWordSpacing(5.0));
2696
2697        let bytes = builder.build().unwrap();
2698        let content = String::from_utf8_lossy(&bytes);
2699        assert!(content.contains("5 Tw"));
2700    }
2701
2702    #[test]
2703    fn test_set_text_leading() {
2704        let mut builder = ContentStreamBuilder::new();
2705        builder.op(ContentStreamOp::SetTextLeading(14.0));
2706
2707        let bytes = builder.build().unwrap();
2708        let content = String::from_utf8_lossy(&bytes);
2709        assert!(content.contains("14 TL"));
2710    }
2711
2712    #[test]
2713    fn test_next_line() {
2714        let mut builder = ContentStreamBuilder::new();
2715        builder.op(ContentStreamOp::NextLine);
2716
2717        let bytes = builder.build().unwrap();
2718        let content = String::from_utf8_lossy(&bytes);
2719        assert!(content.contains("T*"));
2720    }
2721
2722    #[test]
2723    fn test_move_text() {
2724        let mut builder = ContentStreamBuilder::new();
2725        builder.op(ContentStreamOp::MoveText(10.0, -14.0));
2726
2727        let bytes = builder.build().unwrap();
2728        let content = String::from_utf8_lossy(&bytes);
2729        assert!(content.contains("10 -14 Td"));
2730    }
2731
2732    #[test]
2733    fn test_set_text_matrix() {
2734        let mut builder = ContentStreamBuilder::new();
2735        builder.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 72.0, 720.0));
2736
2737        let bytes = builder.build().unwrap();
2738        let content = String::from_utf8_lossy(&bytes);
2739        assert!(content.contains("1 0 0 1 72 720 Tm"));
2740    }
2741
2742    #[test]
2743    fn test_show_hex_text() {
2744        let mut builder = ContentStreamBuilder::new();
2745        builder.begin_text();
2746        builder.op(ContentStreamOp::ShowHexText("<0041004200>".to_string()));
2747        builder.end_text();
2748
2749        let bytes = builder.build().unwrap();
2750        let content = String::from_utf8_lossy(&bytes);
2751        assert!(content.contains("<0041004200> Tj"));
2752    }
2753
2754    #[test]
2755    fn test_show_text_array() {
2756        let mut builder = ContentStreamBuilder::new();
2757        builder.begin_text();
2758        builder.op(ContentStreamOp::ShowTextArray(vec![
2759            TextArrayItem::Text("Hello".to_string()),
2760            TextArrayItem::Adjustment(-10.0),
2761            TextArrayItem::Text("World".to_string()),
2762        ]));
2763        builder.end_text();
2764
2765        let bytes = builder.build().unwrap();
2766        let content = String::from_utf8_lossy(&bytes);
2767        assert!(content.contains("[(Hello) -10 (World) ] TJ"));
2768    }
2769
2770    #[test]
2771    fn test_show_text_array_with_hex() {
2772        let mut builder = ContentStreamBuilder::new();
2773        builder.begin_text();
2774        builder.op(ContentStreamOp::ShowTextArray(vec![
2775            TextArrayItem::HexText("<0041>".to_string()),
2776            TextArrayItem::Adjustment(-50.0),
2777            TextArrayItem::HexText("<0042>".to_string()),
2778        ]));
2779        builder.end_text();
2780
2781        let bytes = builder.build().unwrap();
2782        let content = String::from_utf8_lossy(&bytes);
2783        assert!(content.contains("<0041>"));
2784        assert!(content.contains("<0042>"));
2785        assert!(content.contains("TJ"));
2786    }
2787
2788    #[test]
2789    fn test_raw_operator() {
2790        let mut builder = ContentStreamBuilder::new();
2791        builder.op(ContentStreamOp::Raw("% custom comment".to_string()));
2792
2793        let bytes = builder.build().unwrap();
2794        let content = String::from_utf8_lossy(&bytes);
2795        assert!(content.contains("% custom comment"));
2796    }
2797
2798    #[test]
2799    fn test_draw_image() {
2800        let mut builder = ContentStreamBuilder::new();
2801        builder.draw_image("Im1", 100.0, 200.0, 300.0, 400.0);
2802
2803        let bytes = builder.build().unwrap();
2804        let content = String::from_utf8_lossy(&bytes);
2805        assert!(content.contains("q\n"));
2806        assert!(content.contains("300 0 0 400 100 200 cm"));
2807        assert!(content.contains("/Im1 Do"));
2808        assert!(content.contains("Q\n"));
2809    }
2810
2811    #[test]
2812    fn test_hex_text_method() {
2813        let mut builder = ContentStreamBuilder::new();
2814        builder.begin_text();
2815        builder.set_font("F1", 12.0);
2816        builder.hex_text("<00410042>", 72.0, 720.0);
2817        builder.end_text();
2818
2819        let bytes = builder.build().unwrap();
2820        let content = String::from_utf8_lossy(&bytes);
2821        assert!(content.contains("<00410042> Tj"));
2822    }
2823
2824    #[test]
2825    fn test_begin_text_idempotent() {
2826        let mut builder = ContentStreamBuilder::new();
2827        builder.begin_text();
2828        builder.begin_text(); // Should not add another BT
2829        builder.end_text();
2830
2831        let bytes = builder.build().unwrap();
2832        let content = String::from_utf8_lossy(&bytes);
2833        let bt_count = content.matches("BT\n").count();
2834        assert_eq!(bt_count, 1);
2835    }
2836
2837    #[test]
2838    fn test_end_text_idempotent() {
2839        let mut builder = ContentStreamBuilder::new();
2840        builder.end_text(); // Not in text -- should be no-op
2841        builder.begin_text();
2842        builder.end_text();
2843        builder.end_text(); // Should be no-op
2844
2845        let bytes = builder.build().unwrap();
2846        let content = String::from_utf8_lossy(&bytes);
2847        let et_count = content.matches("ET\n").count();
2848        assert_eq!(et_count, 1);
2849    }
2850
2851    #[test]
2852    fn test_set_font_caching() {
2853        let mut builder = ContentStreamBuilder::new();
2854        builder.begin_text();
2855        builder.set_font("Helvetica", 12.0);
2856        builder.set_font("Helvetica", 12.0); // Same font, should not emit again
2857        builder.set_font("Helvetica", 14.0); // Different size, should emit
2858        builder.end_text();
2859
2860        let bytes = builder.build().unwrap();
2861        let content = String::from_utf8_lossy(&bytes);
2862        // Should have 2 Tf operations (not 3)
2863        let tf_count = content.matches("Tf\n").count();
2864        assert_eq!(tf_count, 2);
2865    }
2866
2867    #[test]
2868    fn test_ops_method() {
2869        let mut builder = ContentStreamBuilder::new();
2870        builder.ops(vec![
2871            ContentStreamOp::SaveState,
2872            ContentStreamOp::SetLineWidth(2.0),
2873            ContentStreamOp::RestoreState,
2874        ]);
2875
2876        let bytes = builder.build().unwrap();
2877        let content = String::from_utf8_lossy(&bytes);
2878        assert!(content.contains("q\n"));
2879        assert!(content.contains("2 w\n"));
2880        assert!(content.contains("Q\n"));
2881    }
2882
2883    #[test]
2884    fn test_add_elements() {
2885        let text1 = TextContent {
2886            artifact_type: None,
2887            text: "First".to_string(),
2888            bbox: Rect::new(72.0, 720.0, 50.0, 12.0),
2889            font: FontSpec::new("Helvetica", 12.0),
2890            style: TextStyle::default(),
2891            reading_order: Some(0),
2892            origin: None,
2893            rotation_degrees: None,
2894            matrix: None,
2895        };
2896        let text2 = TextContent {
2897            artifact_type: None,
2898            text: "Second".to_string(),
2899            bbox: Rect::new(72.0, 700.0, 50.0, 12.0),
2900            font: FontSpec::new("Helvetica", 12.0),
2901            style: TextStyle::default(),
2902            reading_order: Some(1),
2903            origin: None,
2904            rotation_degrees: None,
2905            matrix: None,
2906        };
2907
2908        let mut builder = ContentStreamBuilder::new();
2909        builder.add_elements(&[ContentElement::Text(text1), ContentElement::Text(text2)]);
2910
2911        let bytes = builder.build().unwrap();
2912        let content = String::from_utf8_lossy(&bytes);
2913        assert!(content.contains("(First) Tj"));
2914        assert!(content.contains("(Second) Tj"));
2915    }
2916
2917    #[test]
2918    fn test_escaped_special_chars() {
2919        let mut builder = ContentStreamBuilder::new();
2920        builder
2921            .begin_text()
2922            .set_font("Helvetica", 12.0)
2923            .text("line1\nline2\rtab\there", 72.0, 720.0)
2924            .end_text();
2925
2926        let bytes = builder.build().unwrap();
2927        let content = String::from_utf8_lossy(&bytes);
2928        assert!(content.contains("\\n"));
2929        assert!(content.contains("\\r"));
2930        assert!(content.contains("\\t"));
2931    }
2932
2933    #[test]
2934    fn test_font_mapping_sans_serif() {
2935        let builder = ContentStreamBuilder::new();
2936        assert_eq!(builder.map_font_name("sans-serif", false), "Helvetica");
2937    }
2938
2939    #[test]
2940    fn test_font_mapping_serif() {
2941        let builder = ContentStreamBuilder::new();
2942        assert_eq!(builder.map_font_name("serif", false), "Times-Roman");
2943        // Standard-14 bold serif is "Times-Bold" (issue #525): the old
2944        // "Times-Roman-Bold" was not a real Base-14 name and selected no
2945        // embedded resource, so bold serif text rendered as regular.
2946        assert_eq!(builder.map_font_name("serif", true), "Times-Bold");
2947    }
2948
2949    #[test]
2950    fn test_font_mapping_monospace() {
2951        let builder = ContentStreamBuilder::new();
2952        assert_eq!(builder.map_font_name("monospace", false), "Courier");
2953        assert_eq!(builder.map_font_name("monospace", true), "Courier-Bold");
2954    }
2955
2956    #[test]
2957    fn test_font_mapping_unknown() {
2958        let builder = ContentStreamBuilder::new();
2959        assert_eq!(builder.map_font_name("Unknown Font", false), "Helvetica");
2960        assert_eq!(builder.map_font_name("Unknown Font", true), "Helvetica-Bold");
2961    }
2962
2963    #[test]
2964    fn test_blend_mode_names() {
2965        assert_eq!(BlendMode::Normal.as_pdf_name(), "Normal");
2966        assert_eq!(BlendMode::Multiply.as_pdf_name(), "Multiply");
2967        assert_eq!(BlendMode::Screen.as_pdf_name(), "Screen");
2968        assert_eq!(BlendMode::Overlay.as_pdf_name(), "Overlay");
2969        assert_eq!(BlendMode::Darken.as_pdf_name(), "Darken");
2970        assert_eq!(BlendMode::Lighten.as_pdf_name(), "Lighten");
2971        assert_eq!(BlendMode::ColorDodge.as_pdf_name(), "ColorDodge");
2972        assert_eq!(BlendMode::ColorBurn.as_pdf_name(), "ColorBurn");
2973        assert_eq!(BlendMode::HardLight.as_pdf_name(), "HardLight");
2974        assert_eq!(BlendMode::SoftLight.as_pdf_name(), "SoftLight");
2975        assert_eq!(BlendMode::Difference.as_pdf_name(), "Difference");
2976        assert_eq!(BlendMode::Exclusion.as_pdf_name(), "Exclusion");
2977    }
2978
2979    #[test]
2980    fn test_blend_mode_default() {
2981        let mode = BlendMode::default();
2982        assert_eq!(mode.as_pdf_name(), "Normal");
2983    }
2984
2985    #[test]
2986    fn test_line_cap_default() {
2987        let cap = LineCap::default();
2988        assert_eq!(cap as u8, 0);
2989    }
2990
2991    #[test]
2992    fn test_line_join_default() {
2993        let join = LineJoin::default();
2994        assert_eq!(join as u8, 0);
2995    }
2996
2997    #[test]
2998    fn test_path_content_stroke_and_fill() {
2999        use crate::elements::PathContent;
3000
3001        let path = PathContent {
3002            operations: vec![
3003                PathOperation::MoveTo(0.0, 0.0),
3004                PathOperation::LineTo(100.0, 0.0),
3005                PathOperation::LineTo(100.0, 100.0),
3006                PathOperation::ClosePath,
3007            ],
3008            stroke_color: Some(Color::black()),
3009            fill_color: Some(Color {
3010                r: 1.0,
3011                g: 0.0,
3012                b: 0.0,
3013            }),
3014            stroke_width: 2.0,
3015            bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
3016            line_cap: Default::default(),
3017            line_join: Default::default(),
3018            dash_pattern: None,
3019            matrix: None,
3020            reading_order: None,
3021            artifact_type: None,
3022            layer: None,
3023        };
3024
3025        let mut builder = ContentStreamBuilder::new();
3026        builder.add_element(&ContentElement::Path(path));
3027
3028        let bytes = builder.build().unwrap();
3029        let content = String::from_utf8_lossy(&bytes);
3030        assert!(content.contains("B\n")); // FillStroke
3031    }
3032
3033    #[test]
3034    fn test_path_content_stroke_only() {
3035        use crate::elements::PathContent;
3036
3037        let path = PathContent {
3038            operations: vec![
3039                PathOperation::MoveTo(0.0, 0.0),
3040                PathOperation::LineTo(100.0, 100.0),
3041            ],
3042            stroke_color: Some(Color::black()),
3043            fill_color: None,
3044            stroke_width: 1.0,
3045            bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
3046            line_cap: Default::default(),
3047            line_join: Default::default(),
3048            dash_pattern: None,
3049            matrix: None,
3050            reading_order: None,
3051            artifact_type: None,
3052            layer: None,
3053        };
3054
3055        let mut builder = ContentStreamBuilder::new();
3056        builder.add_element(&ContentElement::Path(path));
3057
3058        let bytes = builder.build().unwrap();
3059        let content = String::from_utf8_lossy(&bytes);
3060        assert!(content.contains("S\n")); // Stroke only
3061    }
3062
3063    #[test]
3064    fn test_path_content_fill_only() {
3065        use crate::elements::PathContent;
3066
3067        let path = PathContent {
3068            operations: vec![PathOperation::Rectangle(0.0, 0.0, 100.0, 100.0)],
3069            stroke_color: None,
3070            fill_color: Some(Color {
3071                r: 0.0,
3072                g: 0.0,
3073                b: 1.0,
3074            }),
3075            stroke_width: 0.0,
3076            bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
3077            line_cap: Default::default(),
3078            line_join: Default::default(),
3079            dash_pattern: None,
3080            matrix: None,
3081            reading_order: None,
3082            artifact_type: None,
3083            layer: None,
3084        };
3085
3086        let mut builder = ContentStreamBuilder::new();
3087        builder.add_element(&ContentElement::Path(path));
3088
3089        let bytes = builder.build().unwrap();
3090        let content = String::from_utf8_lossy(&bytes);
3091        assert!(content.contains("f\n")); // Fill only
3092    }
3093
3094    #[test]
3095    fn test_path_content_no_stroke_no_fill() {
3096        use crate::elements::PathContent;
3097
3098        let path = PathContent {
3099            operations: vec![
3100                PathOperation::MoveTo(0.0, 0.0),
3101                PathOperation::CurveTo(10.0, 20.0, 30.0, 40.0, 50.0, 60.0),
3102            ],
3103            stroke_color: None,
3104            fill_color: None,
3105            stroke_width: 0.0,
3106            bbox: Rect::new(0.0, 0.0, 50.0, 60.0),
3107            line_cap: Default::default(),
3108            line_join: Default::default(),
3109            dash_pattern: None,
3110            matrix: None,
3111            reading_order: None,
3112            artifact_type: None,
3113            layer: None,
3114        };
3115
3116        let mut builder = ContentStreamBuilder::new();
3117        builder.add_element(&ContentElement::Path(path));
3118
3119        let bytes = builder.build().unwrap();
3120        let content = String::from_utf8_lossy(&bytes);
3121        assert!(content.contains("n\n")); // EndPath
3122    }
3123
3124    #[test]
3125    fn test_empty_build() {
3126        let builder = ContentStreamBuilder::new();
3127        let bytes = builder.build().unwrap();
3128        assert!(bytes.is_empty());
3129    }
3130}