Skip to main content

justpdf_render/
svg_device.rs

1//! SVG output device — converts PDF content stream operations into SVG XML.
2
3use std::collections::HashMap;
4use std::fmt::Write as FmtWrite;
5
6use justpdf_core::color::{Color as PdfColor, ColorSpace};
7use justpdf_core::content::{ContentOp, Operand, parse_content_stream};
8use justpdf_core::font::{FontInfo, ToUnicodeCMap, parse_font_info};
9use justpdf_core::image;
10use justpdf_core::object::{PdfDict, PdfObject};
11use justpdf_core::page::PageInfo;
12use justpdf_core::PdfDocument;
13
14use crate::error::{RenderError, Result};
15use crate::graphics_state::{
16    GraphicsState, LineCap, LineJoin, Matrix, PdfBlendMode,
17};
18
19/// Resolved font for SVG rendering.
20struct ResolvedFont {
21    info: FontInfo,
22    cmap: Option<ToUnicodeCMap>,
23    #[allow(dead_code)]
24    font_data: Option<Vec<u8>>,
25}
26
27/// SVG rendering interpreter: walks content stream ops and builds SVG XML.
28pub struct SvgRenderer<'a> {
29    doc: &'a PdfDocument,
30    state: GraphicsState,
31    state_stack: Vec<GraphicsState>,
32    fonts: HashMap<Vec<u8>, ResolvedFont>,
33    /// Transform from PDF user space to SVG space.
34    page_transform: Matrix,
35    /// Current path being constructed (SVG path data string).
36    path_data: Option<String>,
37    /// Collected SVG elements (body content).
38    elements: Vec<String>,
39    /// Collected SVG defs (clip paths, gradients, etc.).
40    defs: Vec<String>,
41    /// Counter for unique IDs (clip paths, etc.).
42    id_counter: u32,
43    /// Active clip path ID for the current graphics state.
44    active_clip_id: Option<String>,
45    /// Stack of clip path IDs.
46    clip_id_stack: Vec<Option<String>>,
47    /// Form XObject recursion depth limit.
48    xobject_depth: u32,
49    /// Page dimensions in points.
50    page_width: f64,
51    page_height: f64,
52}
53
54impl<'a> SvgRenderer<'a> {
55    pub fn new(
56        doc: &'a PdfDocument,
57        page_transform: Matrix,
58        page_width: f64,
59        page_height: f64,
60    ) -> Self {
61        Self {
62            doc,
63            state: GraphicsState::default(),
64            state_stack: Vec::new(),
65            fonts: HashMap::new(),
66            page_transform,
67            path_data: None,
68            elements: Vec::new(),
69            defs: Vec::new(),
70            id_counter: 0,
71            active_clip_id: None,
72            clip_id_stack: Vec::new(),
73            xobject_depth: 0,
74            page_width,
75            page_height,
76        }
77    }
78
79    fn next_id(&mut self, prefix: &str) -> String {
80        self.id_counter += 1;
81        format!("{}{}", prefix, self.id_counter)
82    }
83
84    /// Render a page's content streams and return the SVG XML string.
85    pub fn render_page(mut self, page: &PageInfo) -> Result<String> {
86        let _ = self.resolve_page_fonts(page);
87
88        let content_data = self.get_page_content(page)?;
89        if !content_data.is_empty() {
90            let ops = parse_content_stream(&content_data).map_err(RenderError::Core)?;
91            self.execute_ops(&ops, page)?;
92        }
93
94        Ok(self.build_svg())
95    }
96
97    fn build_svg(&self) -> String {
98        let mut svg = String::new();
99        let _ = write!(
100            svg,
101            r#"<?xml version="1.0" encoding="UTF-8"?>
102<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 {} {}" width="{}" height="{}">"#,
103            self.page_width, self.page_height, self.page_width, self.page_height
104        );
105
106        // White background
107        let _ = write!(
108            svg,
109            r#"
110<rect width="{}" height="{}" fill="white"/>"#,
111            self.page_width, self.page_height
112        );
113
114        if !self.defs.is_empty() {
115            svg.push_str("\n<defs>");
116            for d in &self.defs {
117                svg.push('\n');
118                svg.push_str(d);
119            }
120            svg.push_str("\n</defs>");
121        }
122
123        for el in &self.elements {
124            svg.push('\n');
125            svg.push_str(el);
126        }
127
128        svg.push_str("\n</svg>\n");
129        svg
130    }
131
132    // -----------------------------------------------------------------------
133    // Font resolution (mirrors RenderInterpreter)
134    // -----------------------------------------------------------------------
135
136    fn resolve_page_fonts(&mut self, page: &PageInfo) -> Result<()> {
137        let resources_obj = match &page.resources_ref {
138            Some(obj) => self.resolve_object(obj)?,
139            None => return Ok(()),
140        };
141
142        let resources_dict = match &resources_obj {
143            PdfObject::Dict(d) => d.clone(),
144            _ => return Ok(()),
145        };
146
147        let font_dict_obj = match resources_dict.get(b"Font") {
148            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
149            Some(PdfObject::Reference(r)) => {
150                let r = r.clone();
151                self.doc.resolve(&r)?
152            }
153            _ => return Ok(()),
154        };
155
156        if let PdfObject::Dict(font_dict) = &font_dict_obj {
157            for (name, val) in font_dict.iter() {
158                let font_obj = match val {
159                    PdfObject::Reference(r) => {
160                        let r = r.clone();
161                        self.doc.resolve(&r)?
162                    }
163                    other => other.clone(),
164                };
165
166                if let PdfObject::Dict(fd) = &font_obj {
167                    let mut info = parse_font_info(fd);
168
169                    let cmap = if let Some(PdfObject::Reference(tu_ref)) = fd.get(b"ToUnicode") {
170                        let tu_ref = tu_ref.clone();
171                        if let Ok(tu_obj) = self.doc.resolve(&tu_ref) {
172                            if let PdfObject::Stream { dict, data } = tu_obj {
173                                let decoded = self.doc.decode_stream(&dict, &data).ok();
174                                decoded.map(|d| ToUnicodeCMap::parse(&d))
175                            } else {
176                                None
177                            }
178                        } else {
179                            None
180                        }
181                    } else {
182                        None
183                    };
184
185                    // Resolve CID font widths for Type0
186                    if info.subtype == b"Type0" {
187                        if let Some(PdfObject::Array(descendants)) = fd.get(b"DescendantFonts") {
188                            if let Some(desc_ref) = descendants.first() {
189                                let desc_obj = match desc_ref {
190                                    PdfObject::Reference(r) => {
191                                        let r = r.clone();
192                                        self.doc.resolve(&r)?
193                                    }
194                                    other => other.clone(),
195                                };
196                                if let PdfObject::Dict(cid_dict) = &desc_obj {
197                                    let cid_info = parse_font_info(cid_dict);
198                                    info.widths = cid_info.widths;
199                                }
200                            }
201                        }
202                    }
203
204                    self.fonts.insert(
205                        name.clone(),
206                        ResolvedFont {
207                            info,
208                            cmap,
209                            font_data: None, // SVG uses <text>, not glyph outlines
210                        },
211                    );
212                }
213            }
214        }
215
216        Ok(())
217    }
218
219    fn resolve_object(&mut self, obj: &PdfObject) -> Result<PdfObject> {
220        match obj {
221            PdfObject::Reference(r) => {
222                let r = r.clone();
223                Ok(self.doc.resolve(&r)?)
224            }
225            other => Ok(other.clone()),
226        }
227    }
228
229    fn get_page_content(&mut self, page: &PageInfo) -> Result<Vec<u8>> {
230        let contents = match &page.contents_ref {
231            Some(c) => c.clone(),
232            None => return Ok(Vec::new()),
233        };
234
235        match &contents {
236            PdfObject::Reference(r) => {
237                let r = r.clone();
238                let obj = self.doc.resolve(&r)?;
239                match obj {
240                    PdfObject::Stream { dict, data } => {
241                        Ok(self.doc.decode_stream(&dict, &data).unwrap_or_default())
242                    }
243                    PdfObject::Array(arr) => self.concat_content_streams(&arr),
244                    _ => Ok(Vec::new()),
245                }
246            }
247            PdfObject::Array(arr) => {
248                let arr = arr.clone();
249                self.concat_content_streams(&arr)
250            }
251            PdfObject::Stream { dict, data } => {
252                Ok(self.doc.decode_stream(dict, data).unwrap_or_default())
253            }
254            _ => Ok(Vec::new()),
255        }
256    }
257
258    fn concat_content_streams(&mut self, arr: &[PdfObject]) -> Result<Vec<u8>> {
259        let mut combined = Vec::new();
260        for item in arr {
261            let obj = match item {
262                PdfObject::Reference(r) => {
263                    let r = r.clone();
264                    self.doc.resolve(&r)?
265                }
266                other => other.clone(),
267            };
268            if let PdfObject::Stream { dict, data } = obj {
269                if let Ok(decoded) = self.doc.decode_stream(&dict, &data) {
270                    combined.extend_from_slice(&decoded);
271                    combined.push(b' ');
272                }
273            }
274        }
275        Ok(combined)
276    }
277
278    // -----------------------------------------------------------------------
279    // Operator dispatch
280    // -----------------------------------------------------------------------
281
282    fn execute_ops(&mut self, ops: &[ContentOp], page: &PageInfo) -> Result<()> {
283        for op in ops {
284            self.execute_op(op, page)?;
285        }
286        Ok(())
287    }
288
289    fn execute_op(&mut self, op: &ContentOp, page: &PageInfo) -> Result<()> {
290        let operator = op.operator_str();
291        let operands = &op.operands;
292
293        match operator {
294            // --- Graphics state ---
295            "q" => {
296                self.state_stack.push(self.state.clone());
297                self.clip_id_stack.push(self.active_clip_id.clone());
298            }
299            "Q" => {
300                if let Some(s) = self.state_stack.pop() {
301                    self.state = s;
302                }
303                if let Some(clip_id) = self.clip_id_stack.pop() {
304                    self.active_clip_id = clip_id;
305                }
306            }
307            "cm" => {
308                if operands.len() >= 6 {
309                    let m = Matrix {
310                        a: f(operands, 0),
311                        b: f(operands, 1),
312                        c: f(operands, 2),
313                        d: f(operands, 3),
314                        e: f(operands, 4),
315                        f: f(operands, 5),
316                    };
317                    self.state.ctm = m.concat(&self.state.ctm);
318                }
319            }
320
321            // Line parameters
322            "w" => {
323                if let Some(v) = operands.first().and_then(|o| o.as_f64()) {
324                    self.state.line_width = v;
325                }
326            }
327            "J" => {
328                if let Some(v) = operands.first().and_then(|o| o.as_i64()) {
329                    self.state.line_cap = match v {
330                        1 => LineCap::Round,
331                        2 => LineCap::Square,
332                        _ => LineCap::Butt,
333                    };
334                }
335            }
336            "j" => {
337                if let Some(v) = operands.first().and_then(|o| o.as_i64()) {
338                    self.state.line_join = match v {
339                        1 => LineJoin::Round,
340                        2 => LineJoin::Bevel,
341                        _ => LineJoin::Miter,
342                    };
343                }
344            }
345            "M" => {
346                if let Some(v) = operands.first().and_then(|o| o.as_f64()) {
347                    self.state.miter_limit = v;
348                }
349            }
350            "d" => {
351                if operands.len() >= 2 {
352                    if let Some(arr) = operands[0].as_array() {
353                        self.state.dash_pattern =
354                            arr.iter().filter_map(|o| o.as_f64()).collect();
355                    }
356                    self.state.dash_phase = f(operands, 1);
357                }
358            }
359
360            // ExtGState
361            "gs" => {
362                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
363                    let _ = self.apply_extgstate(name, page);
364                }
365            }
366
367            // --- Path construction ---
368            "m" => {
369                let pd = self.path_data.get_or_insert_with(String::new);
370                let _ = write!(pd, "M{} {} ", fmt_f(f(operands, 0)), fmt_f(f(operands, 1)));
371            }
372            "l" => {
373                if let Some(pd) = &mut self.path_data {
374                    let _ = write!(pd, "L{} {} ", fmt_f(f(operands, 0)), fmt_f(f(operands, 1)));
375                }
376            }
377            "c" => {
378                if let Some(pd) = &mut self.path_data {
379                    let _ = write!(
380                        pd,
381                        "C{} {} {} {} {} {} ",
382                        fmt_f(f(operands, 0)),
383                        fmt_f(f(operands, 1)),
384                        fmt_f(f(operands, 2)),
385                        fmt_f(f(operands, 3)),
386                        fmt_f(f(operands, 4)),
387                        fmt_f(f(operands, 5)),
388                    );
389                }
390            }
391            "v" => {
392                if let Some(pd) = &mut self.path_data {
393                    // 'v': first control point = current point (approximate with same coords)
394                    let _ = write!(
395                        pd,
396                        "C{} {} {} {} {} {} ",
397                        fmt_f(f(operands, 0)),
398                        fmt_f(f(operands, 1)),
399                        fmt_f(f(operands, 0)),
400                        fmt_f(f(operands, 1)),
401                        fmt_f(f(operands, 2)),
402                        fmt_f(f(operands, 3)),
403                    );
404                }
405            }
406            "y" => {
407                if let Some(pd) = &mut self.path_data {
408                    // 'y': second control point = endpoint
409                    let _ = write!(
410                        pd,
411                        "C{} {} {} {} {} {} ",
412                        fmt_f(f(operands, 0)),
413                        fmt_f(f(operands, 1)),
414                        fmt_f(f(operands, 2)),
415                        fmt_f(f(operands, 3)),
416                        fmt_f(f(operands, 2)),
417                        fmt_f(f(operands, 3)),
418                    );
419                }
420            }
421            "h" => {
422                if let Some(pd) = &mut self.path_data {
423                    pd.push_str("Z ");
424                }
425            }
426            "re" => {
427                if operands.len() >= 4 {
428                    let x = f(operands, 0);
429                    let y = f(operands, 1);
430                    let w = f(operands, 2);
431                    let h = f(operands, 3);
432                    let pd = self.path_data.get_or_insert_with(String::new);
433                    let _ = write!(
434                        pd,
435                        "M{} {} L{} {} L{} {} L{} {} Z ",
436                        fmt_f(x), fmt_f(y),
437                        fmt_f(x + w), fmt_f(y),
438                        fmt_f(x + w), fmt_f(y + h),
439                        fmt_f(x), fmt_f(y + h),
440                    );
441                }
442            }
443
444            // --- Path painting ---
445            "S" => {
446                self.stroke_current_path();
447            }
448            "s" => {
449                if let Some(pd) = &mut self.path_data {
450                    pd.push_str("Z ");
451                }
452                self.stroke_current_path();
453            }
454            "f" | "F" => {
455                self.fill_current_path("nonzero");
456            }
457            "f*" => {
458                self.fill_current_path("evenodd");
459            }
460            "B" => {
461                self.fill_and_stroke_path("nonzero");
462            }
463            "B*" => {
464                self.fill_and_stroke_path("evenodd");
465            }
466            "b" => {
467                if let Some(pd) = &mut self.path_data {
468                    pd.push_str("Z ");
469                }
470                self.fill_and_stroke_path("nonzero");
471            }
472            "b*" => {
473                if let Some(pd) = &mut self.path_data {
474                    pd.push_str("Z ");
475                }
476                self.fill_and_stroke_path("evenodd");
477            }
478            "n" => {
479                self.path_data = None;
480            }
481
482            // --- Clipping ---
483            "W" => {
484                self.apply_clip("nonzero");
485            }
486            "W*" => {
487                self.apply_clip("evenodd");
488            }
489
490            // --- Color operators ---
491            "CS" => {
492                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
493                    self.state.stroke_cs = cs_from_name(name);
494                    if name != b"Pattern" {
495                        self.state.stroke_pattern_name = None;
496                    }
497                }
498            }
499            "cs" => {
500                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
501                    self.state.fill_cs = cs_from_name(name);
502                    if name != b"Pattern" {
503                        self.state.fill_pattern_name = None;
504                    }
505                }
506            }
507            "SC" | "SCN" => {
508                let last_is_name = operands.last().and_then(|o| o.as_name());
509                if last_is_name.is_some() {
510                    self.state.stroke_pattern_name = last_is_name.map(|n| n.to_vec());
511                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
512                    if !comps.is_empty() {
513                        self.state.stroke_color = PdfColor { components: comps };
514                    }
515                } else {
516                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
517                    if !comps.is_empty() {
518                        self.state.stroke_color = PdfColor { components: comps };
519                    }
520                }
521            }
522            "sc" | "scn" => {
523                let last_is_name = operands.last().and_then(|o| o.as_name());
524                if last_is_name.is_some() {
525                    self.state.fill_pattern_name = last_is_name.map(|n| n.to_vec());
526                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
527                    if !comps.is_empty() {
528                        self.state.fill_color = PdfColor { components: comps };
529                    }
530                } else {
531                    let comps: Vec<f64> = operands.iter().filter_map(|o| o.as_f64()).collect();
532                    if !comps.is_empty() {
533                        self.state.fill_color = PdfColor { components: comps };
534                    }
535                }
536            }
537            "G" => {
538                self.state.stroke_cs = ColorSpace::DeviceGray;
539                self.state.stroke_color = PdfColor::gray(f(operands, 0));
540            }
541            "g" => {
542                self.state.fill_cs = ColorSpace::DeviceGray;
543                self.state.fill_color = PdfColor::gray(f(operands, 0));
544            }
545            "RG" => {
546                self.state.stroke_cs = ColorSpace::DeviceRGB;
547                self.state.stroke_color =
548                    PdfColor::rgb(f(operands, 0), f(operands, 1), f(operands, 2));
549            }
550            "rg" => {
551                self.state.fill_cs = ColorSpace::DeviceRGB;
552                self.state.fill_color =
553                    PdfColor::rgb(f(operands, 0), f(operands, 1), f(operands, 2));
554            }
555            "K" => {
556                self.state.stroke_cs = ColorSpace::DeviceCMYK;
557                self.state.stroke_color = PdfColor::cmyk(
558                    f(operands, 0),
559                    f(operands, 1),
560                    f(operands, 2),
561                    f(operands, 3),
562                );
563            }
564            "k" => {
565                self.state.fill_cs = ColorSpace::DeviceCMYK;
566                self.state.fill_color = PdfColor::cmyk(
567                    f(operands, 0),
568                    f(operands, 1),
569                    f(operands, 2),
570                    f(operands, 3),
571                );
572            }
573
574            // --- Text operators ---
575            "BT" => {
576                self.state.text_matrix = Matrix::identity();
577                self.state.text_line_matrix = Matrix::identity();
578            }
579            "ET" => {}
580            "Tc" => {
581                self.state.text.char_spacing = f(operands, 0);
582            }
583            "Tw" => {
584                self.state.text.word_spacing = f(operands, 0);
585            }
586            "Tz" => {
587                self.state.text.horiz_scaling = f(operands, 0) / 100.0;
588            }
589            "TL" => {
590                self.state.text.leading = f(operands, 0);
591            }
592            "Tf" => {
593                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
594                    self.state.text.font_name = name.to_vec();
595                }
596                if operands.len() > 1 {
597                    self.state.text.font_size = f(operands, 1);
598                }
599            }
600            "Tr" => {
601                self.state.text.render_mode =
602                    operands.first().and_then(|o| o.as_i64()).unwrap_or(0);
603            }
604            "Ts" => {
605                self.state.text.text_rise = f(operands, 0);
606            }
607            "Td" => {
608                let tx = f(operands, 0);
609                let ty = f(operands, 1);
610                let t = Matrix::translate(tx, ty);
611                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
612                self.state.text_matrix = self.state.text_line_matrix;
613            }
614            "TD" => {
615                let tx = f(operands, 0);
616                let ty = f(operands, 1);
617                self.state.text.leading = -ty;
618                let t = Matrix::translate(tx, ty);
619                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
620                self.state.text_matrix = self.state.text_line_matrix;
621            }
622            "Tm" => {
623                if operands.len() >= 6 {
624                    let m = Matrix {
625                        a: f(operands, 0),
626                        b: f(operands, 1),
627                        c: f(operands, 2),
628                        d: f(operands, 3),
629                        e: f(operands, 4),
630                        f: f(operands, 5),
631                    };
632                    self.state.text_matrix = m;
633                    self.state.text_line_matrix = m;
634                }
635            }
636            "T*" => {
637                let leading = self.state.text.leading;
638                let t = Matrix::translate(0.0, -leading);
639                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
640                self.state.text_matrix = self.state.text_line_matrix;
641            }
642            "Tj" => {
643                if let Some(s) = operands.first().and_then(|o| o.as_str()) {
644                    self.render_text_string(s);
645                }
646            }
647            "TJ" => {
648                if let Some(arr) = operands.first().and_then(|o| o.as_array()) {
649                    for item in arr {
650                        match item {
651                            Operand::String(s) => {
652                                self.render_text_string(s);
653                            }
654                            Operand::Integer(n) => {
655                                self.adjust_text_position(*n as f64);
656                            }
657                            Operand::Real(n) => {
658                                self.adjust_text_position(*n);
659                            }
660                            _ => {}
661                        }
662                    }
663                }
664            }
665            "'" => {
666                let leading = self.state.text.leading;
667                let t = Matrix::translate(0.0, -leading);
668                self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
669                self.state.text_matrix = self.state.text_line_matrix;
670                if let Some(s) = operands.first().and_then(|o| o.as_str()) {
671                    self.render_text_string(s);
672                }
673            }
674            "\"" => {
675                if operands.len() >= 3 {
676                    self.state.text.word_spacing = f(operands, 0);
677                    self.state.text.char_spacing = f(operands, 1);
678                    let leading = self.state.text.leading;
679                    let t = Matrix::translate(0.0, -leading);
680                    self.state.text_line_matrix = t.concat(&self.state.text_line_matrix);
681                    self.state.text_matrix = self.state.text_line_matrix;
682                    if let Some(s) = operands.get(2).and_then(|o| o.as_str()) {
683                        self.render_text_string(s);
684                    }
685                }
686            }
687
688            // --- XObject (images and forms) ---
689            "Do" => {
690                if let Some(name) = operands.first().and_then(|o| o.as_name()) {
691                    let _ = self.do_xobject(name, page);
692                }
693            }
694
695            // --- Inline image (skip for SVG) ---
696            "BI" => {}
697
698            // --- Marked content (ignore) ---
699            "BMC" | "BDC" | "EMC" | "MP" | "DP" => {}
700
701            // --- Shading (skip for now) ---
702            "sh" => {}
703
704            // --- Type3 font ---
705            "d0" | "d1" => {}
706
707            // --- Compatibility ---
708            "BX" | "EX" => {}
709
710            _ => {}
711        }
712
713        Ok(())
714    }
715
716    // -----------------------------------------------------------------------
717    // Path rendering helpers
718    // -----------------------------------------------------------------------
719
720    fn effective_transform(&self) -> Matrix {
721        self.state.ctm.concat(&self.page_transform)
722    }
723
724    fn svg_transform_attr(&self) -> String {
725        let m = self.effective_transform();
726        format!(
727            "transform=\"matrix({},{},{},{},{},{})\"",
728            fmt_f(m.a), fmt_f(m.b), fmt_f(m.c), fmt_f(m.d), fmt_f(m.e), fmt_f(m.f)
729        )
730    }
731
732    fn fill_color_svg(&self) -> String {
733        let rgb = self.state.fill_color.to_rgb(&self.state.fill_cs);
734        format!(
735            "rgb({},{},{})",
736            (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8,
737            (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8,
738            (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8,
739        )
740    }
741
742    fn stroke_color_svg(&self) -> String {
743        let rgb = self.state.stroke_color.to_rgb(&self.state.stroke_cs);
744        format!(
745            "rgb({},{},{})",
746            (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8,
747            (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8,
748            (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8,
749        )
750    }
751
752    fn opacity_attrs(&self, for_fill: bool, for_stroke: bool) -> String {
753        let mut attrs = String::new();
754        if for_fill && self.state.fill_alpha < 1.0 {
755            let _ = write!(attrs, " fill-opacity=\"{}\"", fmt_f(self.state.fill_alpha));
756        }
757        if for_stroke && self.state.stroke_alpha < 1.0 {
758            let _ = write!(attrs, " stroke-opacity=\"{}\"", fmt_f(self.state.stroke_alpha));
759        }
760        attrs
761    }
762
763    fn blend_mode_attr(&self) -> String {
764        let bm = match self.state.blend_mode {
765            PdfBlendMode::Normal => return String::new(),
766            PdfBlendMode::Multiply => "multiply",
767            PdfBlendMode::Screen => "screen",
768            PdfBlendMode::Overlay => "overlay",
769            PdfBlendMode::Darken => "darken",
770            PdfBlendMode::Lighten => "lighten",
771            PdfBlendMode::ColorDodge => "color-dodge",
772            PdfBlendMode::ColorBurn => "color-burn",
773            PdfBlendMode::HardLight => "hard-light",
774            PdfBlendMode::SoftLight => "soft-light",
775            PdfBlendMode::Difference => "difference",
776            PdfBlendMode::Exclusion => "exclusion",
777            PdfBlendMode::Hue => "hue",
778            PdfBlendMode::Saturation => "saturation",
779            PdfBlendMode::Color => "color",
780            PdfBlendMode::Luminosity => "luminosity",
781        };
782        format!(" style=\"mix-blend-mode:{}\"", bm)
783    }
784
785    fn clip_attr(&self) -> String {
786        match &self.active_clip_id {
787            Some(id) => format!(" clip-path=\"url(#{})\"", id),
788            None => String::new(),
789        }
790    }
791
792    fn stroke_attrs(&self) -> String {
793        let mut attrs = String::new();
794        let _ = write!(attrs, " stroke-width=\"{}\"", fmt_f(self.state.line_width));
795        match self.state.line_cap {
796            LineCap::Butt => {}
797            LineCap::Round => attrs.push_str(" stroke-linecap=\"round\""),
798            LineCap::Square => attrs.push_str(" stroke-linecap=\"square\""),
799        }
800        match self.state.line_join {
801            LineJoin::Miter => {}
802            LineJoin::Round => attrs.push_str(" stroke-linejoin=\"round\""),
803            LineJoin::Bevel => attrs.push_str(" stroke-linejoin=\"bevel\""),
804        }
805        if self.state.miter_limit != 4.0 {
806            let _ = write!(attrs, " stroke-miterlimit=\"{}\"", fmt_f(self.state.miter_limit));
807        }
808        if !self.state.dash_pattern.is_empty() {
809            let dashes: Vec<String> = self.state.dash_pattern.iter().map(|d| fmt_f(*d)).collect();
810            let _ = write!(attrs, " stroke-dasharray=\"{}\"", dashes.join(","));
811            if self.state.dash_phase != 0.0 {
812                let _ = write!(attrs, " stroke-dashoffset=\"{}\"", fmt_f(self.state.dash_phase));
813            }
814        }
815        attrs
816    }
817
818    fn fill_current_path(&mut self, fill_rule: &str) {
819        if let Some(pd) = self.path_data.take() {
820            let transform = self.svg_transform_attr();
821            let fill = self.fill_color_svg();
822            let opacity = self.opacity_attrs(true, false);
823            let clip = self.clip_attr();
824            let bm = self.blend_mode_attr();
825            let rule = if fill_rule == "evenodd" {
826                " fill-rule=\"evenodd\""
827            } else {
828                ""
829            };
830            self.elements.push(format!(
831                "<path d=\"{}\" fill=\"{}\"{} stroke=\"none\" {}{}{}{}/>",
832                pd.trim(), fill, rule, transform, opacity, clip, bm,
833            ));
834        }
835    }
836
837    fn stroke_current_path(&mut self) {
838        if let Some(pd) = self.path_data.take() {
839            let transform = self.svg_transform_attr();
840            let stroke = self.stroke_color_svg();
841            let opacity = self.opacity_attrs(false, true);
842            let clip = self.clip_attr();
843            let bm = self.blend_mode_attr();
844            let stroke_attrs = self.stroke_attrs();
845            self.elements.push(format!(
846                "<path d=\"{}\" fill=\"none\" stroke=\"{}\"{}{}{}{}{}/>",
847                pd.trim(), stroke, stroke_attrs, transform, opacity, clip, bm,
848            ));
849        }
850    }
851
852    fn fill_and_stroke_path(&mut self, fill_rule: &str) {
853        if let Some(pd) = self.path_data.take() {
854            let transform = self.svg_transform_attr();
855            let fill = self.fill_color_svg();
856            let stroke = self.stroke_color_svg();
857            let opacity = self.opacity_attrs(true, true);
858            let clip = self.clip_attr();
859            let bm = self.blend_mode_attr();
860            let stroke_attrs = self.stroke_attrs();
861            let rule = if fill_rule == "evenodd" {
862                " fill-rule=\"evenodd\""
863            } else {
864                ""
865            };
866            self.elements.push(format!(
867                "<path d=\"{}\" fill=\"{}\"{} stroke=\"{}\"{}{}{}{}{}/>",
868                pd.trim(), fill, rule, stroke, stroke_attrs, transform, opacity, clip, bm,
869            ));
870        }
871    }
872
873    fn apply_clip(&mut self, clip_rule: &str) {
874        if let Some(pd) = self.path_data.clone() {
875            let clip_id = self.next_id("clip");
876            let transform = self.svg_transform_attr();
877            let rule = if clip_rule == "evenodd" {
878                " clip-rule=\"evenodd\""
879            } else {
880                ""
881            };
882            self.defs.push(format!(
883                "<clipPath id=\"{}\"><path d=\"{}\"{}  {}/></clipPath>",
884                clip_id, pd.trim(), rule, transform,
885            ));
886            self.active_clip_id = Some(clip_id);
887        }
888    }
889
890    // -----------------------------------------------------------------------
891    // Text rendering
892    // -----------------------------------------------------------------------
893
894    fn render_text_string(&mut self, string_bytes: &[u8]) {
895        let font_name = self.state.text.font_name.clone();
896        let font = match self.fonts.get(&font_name) {
897            Some(f) => f,
898            None => return,
899        };
900
901        let font_size = self.state.text.font_size;
902        let horiz_scaling = self.state.text.horiz_scaling;
903        let char_spacing = self.state.text.char_spacing;
904        let word_spacing = self.state.text.word_spacing;
905        let text_rise = self.state.text.text_rise;
906        let render_mode = self.state.text.render_mode;
907        let is_cid = font.info.subtype == b"Type0";
908
909        // Decode char codes
910        let char_codes: Vec<u32> = if is_cid {
911            string_bytes
912                .chunks(2)
913                .map(|c| {
914                    if c.len() == 2 {
915                        ((c[0] as u32) << 8) | (c[1] as u32)
916                    } else {
917                        c[0] as u32
918                    }
919                })
920                .collect()
921        } else {
922            string_bytes.iter().map(|b| *b as u32).collect()
923        };
924
925        // Collect Unicode text using the CMap
926        let cmap = font.cmap.as_ref();
927
928        // Get widths
929        let widths: Vec<f64> = char_codes.iter().map(|code| font.info.widths.get_width(*code)).collect();
930
931        // Get font family name from font info
932        let font_family = extract_font_family(&font.info);
933
934        for (i, code) in char_codes.iter().enumerate() {
935            let width = widths[i];
936            let w0 = width / 1000.0;
937
938            if render_mode != 3 {
939                // Try to get Unicode text
940                let text_char = if let Some(cm) = cmap {
941                    cm.lookup(*code)
942                } else if *code < 128 {
943                    // Basic ASCII fallback
944                    char::from_u32(*code).map(|c| c.to_string())
945                } else {
946                    None
947                };
948
949                if let Some(text) = text_char {
950                    // Emit a <text> element
951                    let trm = Matrix {
952                        a: font_size * horiz_scaling,
953                        b: 0.0,
954                        c: 0.0,
955                        d: font_size,
956                        e: 0.0,
957                        f: text_rise,
958                    }
959                    .concat(&self.state.text_matrix)
960                    .concat(&self.state.ctm)
961                    .concat(&self.page_transform);
962
963                    let fill_color = self.fill_color_svg();
964                    let opacity = self.opacity_attrs(true, false);
965                    let clip = self.clip_attr();
966                    let bm = self.blend_mode_attr();
967
968                    // Extract effective font size from the matrix
969                    let effective_size = trm.font_size_scale();
970
971                    // Position: the text matrix gives us x, y in SVG space
972                    let x = trm.e;
973                    let y = trm.f;
974
975                    let escaped = xml_escape(&text);
976                    self.elements.push(format!(
977                        "<text x=\"{}\" y=\"{}\" font-family=\"{}\" font-size=\"{}\" fill=\"{}\"{}{}{}>{}</text>",
978                        fmt_f(x), fmt_f(y), xml_escape(&font_family), fmt_f(effective_size),
979                        fill_color, opacity, clip, bm, escaped,
980                    ));
981                } else {
982                    // Fallback: draw a rectangle placeholder
983                    let glyph_width = w0 * font_size;
984                    if glyph_width.abs() > 0.001 {
985                        let trm = self
986                            .state
987                            .text_matrix
988                            .concat(&self.state.ctm)
989                            .concat(&self.page_transform);
990
991                        let fill_color = self.fill_color_svg();
992                        let opacity = self.opacity_attrs(true, false);
993                        let clip = self.clip_attr();
994
995                        let rx = 0.0_f64;
996                        let ry = text_rise - font_size * 0.2;
997                        let rw = glyph_width;
998                        let rh = font_size * 0.8;
999
1000                        let pd = format!(
1001                            "M{} {} L{} {} L{} {} L{} {} Z",
1002                            fmt_f(rx), fmt_f(ry),
1003                            fmt_f(rx + rw), fmt_f(ry),
1004                            fmt_f(rx + rw), fmt_f(ry + rh),
1005                            fmt_f(rx), fmt_f(ry + rh),
1006                        );
1007
1008                        self.elements.push(format!(
1009                            "<path d=\"{}\" fill=\"{}\" stroke=\"none\" transform=\"matrix({},{},{},{},{},{})\"{}{}/>",
1010                            pd, fill_color,
1011                            fmt_f(trm.a), fmt_f(trm.b), fmt_f(trm.c), fmt_f(trm.d), fmt_f(trm.e), fmt_f(trm.f),
1012                            opacity, clip,
1013                        ));
1014                    }
1015                }
1016            }
1017
1018            // Advance text matrix
1019            let tx = (w0 * font_size + char_spacing) * horiz_scaling;
1020            let tx = if *code == 32 {
1021                tx + word_spacing * horiz_scaling
1022            } else {
1023                tx
1024            };
1025
1026            let advance = Matrix::translate(tx, 0.0);
1027            self.state.text_matrix = advance.concat(&self.state.text_matrix);
1028        }
1029    }
1030
1031    fn adjust_text_position(&mut self, amount: f64) {
1032        let font_size = self.state.text.font_size;
1033        let horiz_scaling = self.state.text.horiz_scaling;
1034        let tx = -amount / 1000.0 * font_size * horiz_scaling;
1035        let advance = Matrix::translate(tx, 0.0);
1036        self.state.text_matrix = advance.concat(&self.state.text_matrix);
1037    }
1038
1039    // -----------------------------------------------------------------------
1040    // XObject handling
1041    // -----------------------------------------------------------------------
1042
1043    fn do_xobject(&mut self, name: &[u8], page: &PageInfo) -> Result<()> {
1044        let xobj = self.resolve_xobject(name, page)?;
1045        let xobj = match xobj {
1046            Some(x) => x,
1047            None => return Ok(()),
1048        };
1049
1050        match xobj {
1051            XObjectData::Image { dict, data } => {
1052                let _ = self.render_image(&dict, &data);
1053            }
1054            XObjectData::Form { dict, data } => {
1055                if self.xobject_depth > 10 {
1056                    return Ok(());
1057                }
1058                self.xobject_depth += 1;
1059                let _ = self.render_form_xobject(&dict, &data, page);
1060                self.xobject_depth -= 1;
1061            }
1062        }
1063
1064        Ok(())
1065    }
1066
1067    fn resolve_xobject(&mut self, name: &[u8], page: &PageInfo) -> Result<Option<XObjectData>> {
1068        let resources_obj = match &page.resources_ref {
1069            Some(obj) => self.resolve_object(obj)?,
1070            None => return Ok(None),
1071        };
1072
1073        let resources_dict = match &resources_obj {
1074            PdfObject::Dict(d) => d.clone(),
1075            _ => return Ok(None),
1076        };
1077
1078        let xobject_dict_obj = match resources_dict.get(b"XObject") {
1079            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
1080            Some(PdfObject::Reference(r)) => {
1081                let r = r.clone();
1082                self.doc.resolve(&r)?
1083            }
1084            _ => return Ok(None),
1085        };
1086
1087        let xobject_dict = match &xobject_dict_obj {
1088            PdfObject::Dict(d) => d,
1089            _ => return Ok(None),
1090        };
1091
1092        let xobj_ref = match xobject_dict.get(name) {
1093            Some(PdfObject::Reference(r)) => r.clone(),
1094            _ => return Ok(None),
1095        };
1096
1097        let xobj = self.doc.resolve(&xobj_ref)?;
1098
1099        match xobj {
1100            PdfObject::Stream { dict, data } => {
1101                let subtype = dict.get_name(b"Subtype").unwrap_or(b"");
1102                match subtype {
1103                    b"Image" => {
1104                        let filter = dict.get(b"Filter").and_then(|o| o.as_name());
1105                        let image_data = if filter == Some(b"DCTDecode") {
1106                            data.clone()
1107                        } else {
1108                            match self.doc.decode_stream(&dict, &data) {
1109                                Ok(d) => d,
1110                                Err(_) => return Ok(None),
1111                            }
1112                        };
1113                        Ok(Some(XObjectData::Image {
1114                            dict,
1115                            data: image_data,
1116                        }))
1117                    }
1118                    b"Form" => match self.doc.decode_stream(&dict, &data) {
1119                        Ok(decoded) => Ok(Some(XObjectData::Form {
1120                            dict,
1121                            data: decoded,
1122                        })),
1123                        Err(_) => Ok(None),
1124                    },
1125                    _ => Ok(None),
1126                }
1127            }
1128            _ => Ok(None),
1129        }
1130    }
1131
1132    fn render_image(&mut self, dict: &PdfDict, data: &[u8]) -> Result<()> {
1133        let decoded = image::decode_image(data, dict).map_err(RenderError::Core)?;
1134
1135        let rgba_data = image_to_rgba(&decoded);
1136        let w = decoded.width;
1137        let h = decoded.height;
1138
1139        // Encode as PNG, then base64
1140        let png_bytes = encode_rgba_to_png(&rgba_data, w, h);
1141        let b64 = base64_encode(&png_bytes);
1142
1143        // PDF images are placed in a 1x1 unit square, scaled by the CTM
1144        let image_transform = Matrix {
1145            a: 1.0 / w as f64,
1146            b: 0.0,
1147            c: 0.0,
1148            d: -1.0 / h as f64,
1149            e: 0.0,
1150            f: 1.0,
1151        };
1152
1153        let full_transform = image_transform
1154            .concat(&self.state.ctm)
1155            .concat(&self.page_transform);
1156
1157        let opacity = if self.state.fill_alpha < 1.0 {
1158            format!(" opacity=\"{}\"", fmt_f(self.state.fill_alpha))
1159        } else {
1160            String::new()
1161        };
1162        let clip = self.clip_attr();
1163        let bm = self.blend_mode_attr();
1164
1165        self.elements.push(format!(
1166            "<image width=\"{}\" height=\"{}\" href=\"data:image/png;base64,{}\" transform=\"matrix({},{},{},{},{},{})\" preserveAspectRatio=\"none\"{}{}{}/>",
1167            w, h, b64,
1168            fmt_f(full_transform.a), fmt_f(full_transform.b),
1169            fmt_f(full_transform.c), fmt_f(full_transform.d),
1170            fmt_f(full_transform.e), fmt_f(full_transform.f),
1171            opacity, clip, bm,
1172        ));
1173
1174        Ok(())
1175    }
1176
1177    fn render_form_xobject(
1178        &mut self,
1179        dict: &PdfDict,
1180        data: &[u8],
1181        page: &PageInfo,
1182    ) -> Result<()> {
1183        self.state_stack.push(self.state.clone());
1184        self.clip_id_stack.push(self.active_clip_id.clone());
1185
1186        // Apply form matrix if present
1187        if let Some(matrix_arr) = dict.get_array(b"Matrix") {
1188            if matrix_arr.len() >= 6 {
1189                let m = Matrix {
1190                    a: matrix_arr[0].as_f64().unwrap_or(1.0),
1191                    b: matrix_arr[1].as_f64().unwrap_or(0.0),
1192                    c: matrix_arr[2].as_f64().unwrap_or(0.0),
1193                    d: matrix_arr[3].as_f64().unwrap_or(1.0),
1194                    e: matrix_arr[4].as_f64().unwrap_or(0.0),
1195                    f: matrix_arr[5].as_f64().unwrap_or(0.0),
1196                };
1197                self.state.ctm = m.concat(&self.state.ctm);
1198            }
1199        }
1200
1201        let ops = parse_content_stream(data).map_err(RenderError::Core)?;
1202        let _ = self.execute_ops(&ops, page);
1203
1204        if let Some(s) = self.state_stack.pop() {
1205            self.state = s;
1206        }
1207        if let Some(clip_id) = self.clip_id_stack.pop() {
1208            self.active_clip_id = clip_id;
1209        }
1210
1211        Ok(())
1212    }
1213
1214    // -----------------------------------------------------------------------
1215    // ExtGState
1216    // -----------------------------------------------------------------------
1217
1218    fn apply_extgstate(&mut self, name: &[u8], page: &PageInfo) -> Result<()> {
1219        let resources_obj = match &page.resources_ref {
1220            Some(obj) => self.resolve_object(obj)?,
1221            None => return Ok(()),
1222        };
1223
1224        let resources_dict = match &resources_obj {
1225            PdfObject::Dict(d) => d.clone(),
1226            _ => return Ok(()),
1227        };
1228
1229        let extgstate_dict_obj = match resources_dict.get(b"ExtGState") {
1230            Some(PdfObject::Dict(d)) => PdfObject::Dict(d.clone()),
1231            Some(PdfObject::Reference(r)) => {
1232                let r = r.clone();
1233                self.doc.resolve(&r)?
1234            }
1235            _ => return Ok(()),
1236        };
1237
1238        let extgstate_dict = match &extgstate_dict_obj {
1239            PdfObject::Dict(d) => d,
1240            _ => return Ok(()),
1241        };
1242
1243        let gs_obj = match extgstate_dict.get(name) {
1244            Some(PdfObject::Reference(r)) => {
1245                let r = r.clone();
1246                self.doc.resolve(&r)?
1247            }
1248            Some(other) => other.clone(),
1249            None => return Ok(()),
1250        };
1251
1252        if let PdfObject::Dict(gs_dict) = &gs_obj {
1253            if let Some(lw) = gs_dict.get(b"LW").and_then(|o| o.as_f64()) {
1254                self.state.line_width = lw;
1255            }
1256            if let Some(lc) = gs_dict.get(b"LC").and_then(|o| o.as_i64()) {
1257                self.state.line_cap = match lc {
1258                    1 => LineCap::Round,
1259                    2 => LineCap::Square,
1260                    _ => LineCap::Butt,
1261                };
1262            }
1263            if let Some(lj) = gs_dict.get(b"LJ").and_then(|o| o.as_i64()) {
1264                self.state.line_join = match lj {
1265                    1 => LineJoin::Round,
1266                    2 => LineJoin::Bevel,
1267                    _ => LineJoin::Miter,
1268                };
1269            }
1270            if let Some(ml) = gs_dict.get(b"ML").and_then(|o| o.as_f64()) {
1271                self.state.miter_limit = ml;
1272            }
1273            if let Some(a) = gs_dict.get(b"ca").and_then(|o| o.as_f64()) {
1274                self.state.fill_alpha = a;
1275            }
1276            if let Some(a) = gs_dict.get(b"CA").and_then(|o| o.as_f64()) {
1277                self.state.stroke_alpha = a;
1278            }
1279            if let Some(bm_name) = gs_dict.get(b"BM").and_then(|o| o.as_name()) {
1280                self.state.blend_mode = PdfBlendMode::from_name(bm_name);
1281            }
1282        }
1283
1284        Ok(())
1285    }
1286}
1287
1288// ---------------------------------------------------------------------------
1289// Helper types and functions
1290// ---------------------------------------------------------------------------
1291
1292enum XObjectData {
1293    Image { dict: PdfDict, data: Vec<u8> },
1294    Form { dict: PdfDict, data: Vec<u8> },
1295}
1296
1297/// Extract operand as f64 at the given index.
1298fn f(operands: &[Operand], idx: usize) -> f64 {
1299    operands.get(idx).and_then(|o| o.as_f64()).unwrap_or(0.0)
1300}
1301
1302/// Map a PDF color space name to ColorSpace.
1303fn cs_from_name(name: &[u8]) -> ColorSpace {
1304    match name {
1305        b"DeviceRGB" => ColorSpace::DeviceRGB,
1306        b"DeviceCMYK" => ColorSpace::DeviceCMYK,
1307        b"DeviceGray" => ColorSpace::DeviceGray,
1308        _ => ColorSpace::DeviceGray,
1309    }
1310}
1311
1312/// Format a float compactly (avoid trailing zeros).
1313fn fmt_f(v: f64) -> String {
1314    if (v - v.round()).abs() < 1e-6 {
1315        format!("{}", v.round() as i64)
1316    } else {
1317        format!("{:.4}", v)
1318            .trim_end_matches('0')
1319            .trim_end_matches('.')
1320            .to_string()
1321    }
1322}
1323
1324/// Escape special XML characters.
1325fn xml_escape(s: &str) -> String {
1326    s.replace('&', "&amp;")
1327        .replace('<', "&lt;")
1328        .replace('>', "&gt;")
1329        .replace('"', "&quot;")
1330        .replace('\'', "&apos;")
1331}
1332
1333/// Extract a reasonable font-family name from FontInfo.
1334fn extract_font_family(info: &FontInfo) -> String {
1335    let base = String::from_utf8_lossy(&info.base_font).to_string();
1336    if base.is_empty() {
1337        return "sans-serif".to_string();
1338    }
1339    // Strip the subset prefix (6 chars + '+') if present, e.g. "ABCDEF+Arial" -> "Arial"
1340    let name = if base.len() > 7 && base.as_bytes()[6] == b'+' {
1341        &base[7..]
1342    } else {
1343        &base
1344    };
1345    // Replace common separators
1346    name.replace(',', " ").replace('-', " ")
1347}
1348
1349/// Convert decoded image data to RGBA.
1350fn image_to_rgba(img: &image::DecodedImage) -> Vec<u8> {
1351    let pixel_count = (img.width * img.height) as usize;
1352    let mut rgba = vec![255u8; pixel_count * 4];
1353
1354    match img.components {
1355        1 => {
1356            // Grayscale
1357            for i in 0..pixel_count.min(img.data.len()) {
1358                let g = img.data[i];
1359                rgba[i * 4] = g;
1360                rgba[i * 4 + 1] = g;
1361                rgba[i * 4 + 2] = g;
1362            }
1363        }
1364        3 => {
1365            // RGB
1366            for i in 0..pixel_count.min(img.data.len() / 3) {
1367                rgba[i * 4] = img.data[i * 3];
1368                rgba[i * 4 + 1] = img.data[i * 3 + 1];
1369                rgba[i * 4 + 2] = img.data[i * 3 + 2];
1370            }
1371        }
1372        4 => {
1373            // CMYK -> RGB
1374            for i in 0..pixel_count.min(img.data.len() / 4) {
1375                let c = img.data[i * 4] as f64 / 255.0;
1376                let m = img.data[i * 4 + 1] as f64 / 255.0;
1377                let y = img.data[i * 4 + 2] as f64 / 255.0;
1378                let k = img.data[i * 4 + 3] as f64 / 255.0;
1379                rgba[i * 4] = ((1.0 - c) * (1.0 - k) * 255.0) as u8;
1380                rgba[i * 4 + 1] = ((1.0 - m) * (1.0 - k) * 255.0) as u8;
1381                rgba[i * 4 + 2] = ((1.0 - y) * (1.0 - k) * 255.0) as u8;
1382            }
1383        }
1384        _ => {
1385            // Fill with black
1386            for i in 0..pixel_count {
1387                rgba[i * 4] = 0;
1388                rgba[i * 4 + 1] = 0;
1389                rgba[i * 4 + 2] = 0;
1390            }
1391        }
1392    }
1393
1394    rgba
1395}
1396
1397/// Encode RGBA data as PNG bytes (minimal encoder without external crate).
1398fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
1399    // Use the image crate which is already a dependency
1400    let mut buf = std::io::Cursor::new(Vec::new());
1401    let encoder = ::image::codecs::png::PngEncoder::new(&mut buf);
1402    let _ = ::image::ImageEncoder::write_image(
1403        encoder,
1404        rgba,
1405        width,
1406        height,
1407        ::image::ColorType::Rgba8.into(),
1408    );
1409    buf.into_inner()
1410}
1411
1412/// Simple base64 encoder (no external dependency).
1413fn base64_encode(data: &[u8]) -> String {
1414    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1415    let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
1416
1417    for chunk in data.chunks(3) {
1418        let b0 = chunk[0] as u32;
1419        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1420        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1421
1422        let n = (b0 << 16) | (b1 << 8) | b2;
1423
1424        result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
1425        result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
1426
1427        if chunk.len() > 1 {
1428            result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
1429        } else {
1430            result.push('=');
1431        }
1432
1433        if chunk.len() > 2 {
1434            result.push(CHARS[(n & 0x3F) as usize] as char);
1435        } else {
1436            result.push('=');
1437        }
1438    }
1439
1440    result
1441}