Skip to main content

document_svg/
reverse.rs

1//! Package SVG pages as vector images in PPTX, DOCX, XLSX or draw.io files.
2//!
3//! This preserves the rendered SVG, not the source document's semantic
4//! structure. The one exception is a draw.io output built from SVG pages that
5//! still carry their diagram source in the `content` attribute draw.io writes:
6//! those are restored as the editable diagrams they came from.
7
8use std::fmt::{Display, Formatter};
9use std::fs;
10use std::io::{BufWriter, Cursor, Write};
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14static SYSTEM_FONTDB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
15
16use base64::Engine;
17use flate2::Compression;
18use flate2::write::ZlibEncoder;
19use image_webp::WebPEncoder;
20use lopdf::{Document, Object, Stream, dictionary};
21use quick_xml::Reader;
22use quick_xml::events::Event;
23use serde::Serialize;
24use zip::ZipWriter;
25use zip::write::SimpleFileOptions;
26
27use crate::convert::read_limited_file;
28use crate::error::{Error, Result};
29use crate::ooxml::{attribute, decode_xml_reference, local_name};
30
31const EMU_PER_POINT: f64 = 12_700.0;
32const FALLBACK_DPI: f64 = 96.0;
33const MAX_FALLBACK_DIMENSION: f64 = 4_096.0;
34const MAX_FALLBACK_PIXELS: f64 = 16_777_216.0;
35const MAX_NESTED_SVG_DEPTH: usize = 4;
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum ReverseFormat {
40    Pptx,
41    Docx,
42    Xlsx,
43    Pdf,
44    Drawio,
45    Dxf,
46    Dot,
47    Mermaid,
48    Markdown,
49    Csv,
50    Tex,
51    Jsx,
52    Tsx,
53    Vue,
54    DataUri,
55    Png,
56    Gcode,
57    Gerber,
58    Hpgl,
59    Excellon,
60    Stl,
61    Obj,
62    Ply,
63    Step,
64    Gmsh,
65    Vtk,
66    ThreeMf,
67    Iges,
68    Svelte,
69    PathData,
70    Html,
71    Webp,
72}
73
74impl ReverseFormat {
75    fn detect(path: &Path) -> Result<Self> {
76        let filename = path
77            .file_name()
78            .and_then(|value| value.to_str())
79            .unwrap_or("")
80            .to_ascii_lowercase();
81        if filename.ends_with(".chart.json") {
82            return Ok(Self::Csv);
83        }
84        let extension = path
85            .extension()
86            .and_then(|value| value.to_str())
87            .map(str::to_ascii_lowercase)
88            .ok_or_else(|| Error::InvalidInput("output has no file extension".into()))?;
89        match extension.as_str() {
90            "pptx" | "pptm" | "potx" | "potm" | "ppsx" | "ppam" | "sldx" | "sldm" => Ok(Self::Pptx),
91            "docx" | "docm" | "dotx" | "dotm" => Ok(Self::Docx),
92            "xlsx" | "xlsm" | "xltx" | "xltm" | "xlam" => Ok(Self::Xlsx),
93            "drawio" | "dio" => Ok(Self::Drawio),
94            "dxf" => Ok(Self::Dxf),
95            "gcode" | "nc" | "ngc" | "tap" | "gco" | "cnc" => Ok(Self::Gcode),
96            "gbr" | "gerber" | "gtl" | "gbl" | "gts" | "gbs" | "gto" | "gbo" | "gko" | "gm1"
97            | "gm2" | "art" | "pho" => Ok(Self::Gerber),
98            "plt" | "hpgl" | "hpg" | "gl2" | "prn" => Ok(Self::Hpgl),
99            "drl" | "drd" | "xln" | "exc" => Ok(Self::Excellon),
100            "stl" => Ok(Self::Stl),
101            "obj" => Ok(Self::Obj),
102            "ply" => Ok(Self::Ply),
103            "3mf" => Ok(Self::ThreeMf),
104            "step" | "stp" | "p21" | "stpnc" => Ok(Self::Step),
105            "iges" | "igs" => Ok(Self::Iges),
106            "msh" => Ok(Self::Gmsh),
107            "vtk" | "vtu" => Ok(Self::Vtk),
108            "dot" | "gv" => Ok(Self::Dot),
109            "mmd" | "mermaid" | "puml" | "plantuml" | "pu" | "wsd" => Ok(Self::Mermaid),
110            "md" | "markdown" | "mdown" | "mkd" | "mdx" | "txt" => Ok(Self::Markdown),
111            "csv" | "tsv" | "tab" | "chart" => Ok(Self::Csv),
112            "tex" | "latex" | "ltx" => Ok(Self::Tex),
113            "jsx" => Ok(Self::Jsx),
114            "tsx" => Ok(Self::Tsx),
115            "vue" => Ok(Self::Vue),
116            "svelte" => Ok(Self::Svelte),
117            "path" | "icon" => Ok(Self::PathData),
118            "datauri" => Ok(Self::DataUri),
119            "png" => Ok(Self::Png),
120            "pdf" => Ok(Self::Pdf),
121            "html" | "htm" => Ok(Self::Html),
122            "webp" => Ok(Self::Webp),
123            _ => Err(Error::Unsupported(format!(
124                "output extension .{extension}; expected PPTX, DOCX, XLSX, PDF, DRAWIO, DXF, GCODE, GERBER, HPGL, EXCELLON, STL, OBJ, PLY, 3MF, STEP, IGES, MSH, VTK, DOT, MERMAID, MD, CSV, TEX, JSX, TSX, VUE, SVELTE, PATH/ICON, DATAURI, PNG, HTML, or WEBP"
125            ))),
126        }
127    }
128
129    /// Whether the output embeds each page as a picture that a viewer without
130    /// SVG support still has to be able to show.
131    const fn needs_raster_fallback(self) -> bool {
132        matches!(
133            self,
134            Self::Pptx | Self::Docx | Self::Xlsx | Self::Pdf | Self::Png | Self::Webp
135        )
136    }
137}
138
139impl Display for ReverseFormat {
140    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
141        formatter.write_str(match self {
142            Self::Pptx => "PPTX",
143            Self::Docx => "DOCX",
144            Self::Xlsx => "XLSX",
145            Self::Pdf => "PDF",
146            Self::Drawio => "DRAWIO",
147            Self::Dxf => "DXF",
148            Self::Dot => "DOT",
149            Self::Mermaid => "MERMAID",
150            Self::Markdown => "MARKDOWN",
151            Self::Csv => "CSV",
152            Self::Tex => "TEX",
153            Self::Jsx => "JSX",
154            Self::Tsx => "TSX",
155            Self::Vue => "VUE",
156            Self::DataUri => "DATAURI",
157            Self::Png => "PNG",
158            Self::Gcode => "GCODE",
159            Self::Gerber => "GERBER",
160            Self::Hpgl => "HPGL",
161            Self::Excellon => "EXCELLON",
162            Self::Stl => "STL",
163            Self::Obj => "OBJ",
164            Self::Ply => "PLY",
165            Self::Step => "STEP",
166            Self::Gmsh => "GMSH",
167            Self::Vtk => "VTK",
168            Self::ThreeMf => "3MF",
169            Self::Iges => "IGES",
170            Self::Svelte => "SVELTE",
171            Self::PathData => "PATH",
172            Self::Html => "HTML",
173            Self::Webp => "WEBP",
174        })
175    }
176}
177
178#[derive(Clone, Debug)]
179pub struct ReverseOptions {
180    pub max_input_bytes: u64,
181    pub max_pages: usize,
182}
183
184impl Default for ReverseOptions {
185    fn default() -> Self {
186        Self {
187            max_input_bytes: 512 * 1024 * 1024,
188            max_pages: 10_000,
189        }
190    }
191}
192
193#[derive(Clone, Debug, Serialize)]
194pub struct ReverseReport {
195    pub converter: &'static str,
196    pub version: &'static str,
197    pub source: String,
198    pub output: String,
199    pub output_format: ReverseFormat,
200    pub page_count: usize,
201    pub input_bytes: u64,
202    pub warnings: Vec<String>,
203}
204
205#[derive(Clone, Debug)]
206struct SvgPage {
207    bytes: Vec<u8>,
208    fallback_png: Vec<u8>,
209    fallback_webp: Vec<u8>,
210    width_points: f64,
211    height_points: f64,
212    /// The `<diagram>` elements of the draw.io source this page was exported
213    /// from, when the SVG still carries it.
214    diagrams: Vec<String>,
215}
216
217pub fn svg_to_document(
218    input: impl AsRef<Path>,
219    output: impl AsRef<Path>,
220    options: &ReverseOptions,
221) -> Result<ReverseReport> {
222    let input = input.as_ref();
223    let output = output.as_ref();
224    if output.exists() {
225        return Err(Error::InvalidInput(format!(
226            "output already exists: {}",
227            output.display()
228        )));
229    }
230    if options.max_pages == 0 {
231        return Err(Error::InvalidInput("max_pages must be at least 1".into()));
232    }
233    let format = ReverseFormat::detect(output)?;
234    let paths = collect_svg_paths(input, options.max_pages)?;
235    let mut pages = Vec::with_capacity(paths.len());
236    let mut input_bytes = 0u64;
237    let mut render_options = None;
238    for path in paths {
239        let metadata = fs::metadata(&path)?;
240        let previous_bytes = input_bytes;
241        input_bytes = input_bytes.saturating_add(metadata.len());
242        if input_bytes > options.max_input_bytes {
243            return Err(Error::LimitExceeded(format!(
244                "SVG input is {input_bytes} bytes; maximum is {} bytes",
245                options.max_input_bytes
246            )));
247        }
248        let bytes = read_limited_file(
249            &path,
250            options.max_input_bytes.saturating_sub(previous_bytes),
251            "SVG input",
252        )?;
253        input_bytes = previous_bytes.saturating_add(bytes.len() as u64);
254        let diagrams = if format == ReverseFormat::Drawio {
255            embedded_diagrams(&bytes)?
256        } else {
257            Vec::new()
258        };
259        // A page restored from its own diagram source contributes no bytes to
260        // the output: the picture is dropped and the shapes are rebuilt. The
261        // rules that keep an embedded picture inert therefore do not apply to
262        // it, which is what lets a real draw.io export — whose labels are HTML
263        // in `foreignObject` — be turned back into a diagram.
264        if diagrams.is_empty() {
265            validate_svg_document(&bytes, 0)?;
266        }
267        let (width_points, height_points) = svg_dimensions(&bytes)?;
268        let fallback_png = if format.needs_raster_fallback() && format != ReverseFormat::Webp {
269            let render_options = render_options.get_or_insert_with(|| {
270                let mut options = resvg::usvg::Options::default();
271                let db = SYSTEM_FONTDB.get_or_init(|| {
272                    let mut db = resvg::usvg::fontdb::Database::new();
273                    db.load_system_fonts();
274                    db
275                });
276                *options.fontdb_mut() = db.clone();
277                options
278            });
279            render_svg_fallback(&bytes, width_points, height_points, render_options)?
280        } else {
281            Vec::new()
282        };
283        let fallback_webp = if format == ReverseFormat::Webp {
284            let render_options = render_options.get_or_insert_with(|| {
285                let mut options = resvg::usvg::Options::default();
286                let db = SYSTEM_FONTDB.get_or_init(|| {
287                    let mut db = resvg::usvg::fontdb::Database::new();
288                    db.load_system_fonts();
289                    db
290                });
291                *options.fontdb_mut() = db.clone();
292                options
293            });
294            render_svg_webp(&bytes, width_points, height_points, render_options)?
295        } else {
296            Vec::new()
297        };
298        pages.push(SvgPage {
299            bytes,
300            fallback_png,
301            fallback_webp,
302            width_points,
303            height_points,
304            diagrams,
305        });
306    }
307
308    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
309        fs::create_dir_all(parent)?;
310    }
311    let parent = output
312        .parent()
313        .filter(|path| !path.as_os_str().is_empty())
314        .unwrap_or(Path::new("."));
315    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
316    let restored = pages
317        .iter()
318        .filter(|page| !page.diagrams.is_empty())
319        .count();
320    if format == ReverseFormat::Drawio {
321        let mut writer = BufWriter::new(temporary.as_file_mut());
322        writer.write_all(write_drawio(&pages).as_bytes())?;
323        writer.flush()?;
324    } else if format == ReverseFormat::Dxf {
325        let mut writer = BufWriter::new(temporary.as_file_mut());
326        for page in &pages {
327            let svg_str = std::str::from_utf8(&page.bytes)
328                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
329            crate::cad::dxf::writer::write_svg_to_dxf(svg_str, &mut writer)?;
330        }
331        writer.flush()?;
332    } else if format == ReverseFormat::Gcode {
333        let mut writer = BufWriter::new(temporary.as_file_mut());
334        for page in &pages {
335            let svg_str = std::str::from_utf8(&page.bytes)
336                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
337            crate::cad::gcode::writer::write_svg_to_gcode(svg_str, &mut writer)?;
338        }
339        writer.flush()?;
340    } else if format == ReverseFormat::Gerber {
341        let mut writer = BufWriter::new(temporary.as_file_mut());
342        for page in &pages {
343            let svg_str = std::str::from_utf8(&page.bytes)
344                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
345            crate::cad::gerber::writer::write_svg_to_gerber(svg_str, &mut writer)?;
346        }
347        writer.flush()?;
348    } else if format == ReverseFormat::Hpgl {
349        let mut writer = BufWriter::new(temporary.as_file_mut());
350        for page in &pages {
351            let svg_str = std::str::from_utf8(&page.bytes)
352                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
353            crate::cad::hpgl::writer::write_svg_to_hpgl(svg_str, &mut writer)?;
354        }
355        writer.flush()?;
356    } else if format == ReverseFormat::Excellon {
357        let mut writer = BufWriter::new(temporary.as_file_mut());
358        for page in &pages {
359            let svg_str = std::str::from_utf8(&page.bytes)
360                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
361            crate::cad::excellon::writer::write_svg_to_excellon(svg_str, &mut writer)?;
362        }
363        writer.flush()?;
364    } else if format == ReverseFormat::Stl {
365        let mut writer = BufWriter::new(temporary.as_file_mut());
366        for page in &pages {
367            let svg_str = std::str::from_utf8(&page.bytes)
368                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
369            crate::cad::stl::writer::write_svg_to_stl(svg_str, &mut writer)?;
370        }
371        writer.flush()?;
372    } else if format == ReverseFormat::Obj {
373        let mut writer = BufWriter::new(temporary.as_file_mut());
374        for page in &pages {
375            let svg_str = std::str::from_utf8(&page.bytes)
376                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
377            crate::cad::obj::writer::write_svg_to_obj(svg_str, &mut writer)?;
378        }
379        writer.flush()?;
380    } else if format == ReverseFormat::Ply {
381        let mut writer = BufWriter::new(temporary.as_file_mut());
382        for page in &pages {
383            let svg_str = std::str::from_utf8(&page.bytes)
384                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
385            crate::cad::ply::writer::write_svg_to_ply(svg_str, &mut writer)?;
386        }
387        writer.flush()?;
388    } else if format == ReverseFormat::Step {
389        let mut writer = BufWriter::new(temporary.as_file_mut());
390        for page in &pages {
391            let svg_str = std::str::from_utf8(&page.bytes)
392                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
393            crate::cad::step::writer::write_svg_to_step(svg_str, &mut writer)?;
394        }
395        writer.flush()?;
396    } else if format == ReverseFormat::ThreeMf {
397        for page in &pages {
398            let svg_str = std::str::from_utf8(&page.bytes)
399                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
400            crate::cad::threemf::write_svg_to_threemf(svg_str, temporary.as_file_mut())?;
401        }
402    } else if format == ReverseFormat::Iges {
403        let mut writer = BufWriter::new(temporary.as_file_mut());
404        for page in &pages {
405            let svg_str = std::str::from_utf8(&page.bytes)
406                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
407            crate::cad::iges::write_svg_to_iges(svg_str, &mut writer)?;
408        }
409        writer.flush()?;
410    } else if format == ReverseFormat::Gmsh {
411        let mut writer = BufWriter::new(temporary.as_file_mut());
412        for page in &pages {
413            let svg_str = std::str::from_utf8(&page.bytes)
414                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
415            crate::cad::simulation::writer::write_svg_to_gmsh(svg_str, &mut writer)?;
416        }
417        writer.flush()?;
418    } else if format == ReverseFormat::Vtk {
419        let mut writer = BufWriter::new(temporary.as_file_mut());
420        for page in &pages {
421            let svg_str = std::str::from_utf8(&page.bytes)
422                .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
423            crate::cad::simulation::writer::write_svg_to_vtk(svg_str, &mut writer)?;
424        }
425        writer.flush()?;
426    } else if matches!(format, ReverseFormat::Dot | ReverseFormat::Mermaid) {
427        let mut writer = BufWriter::new(temporary.as_file_mut());
428        for page in &pages {
429            let dot = crate::diagram::extract_diagram_from_svg(&page.bytes)?;
430            writer.write_all(dot.as_bytes())?;
431        }
432        writer.flush()?;
433    } else if format == ReverseFormat::Markdown {
434        let mut writer = BufWriter::new(temporary.as_file_mut());
435        for page in &pages {
436            let md = crate::table::extract_markdown_table_from_svg(&page.bytes)?;
437            writer.write_all(md.as_bytes())?;
438        }
439        writer.flush()?;
440    } else if format == ReverseFormat::Csv {
441        let mut writer = BufWriter::new(temporary.as_file_mut());
442        let embedded_csv = pages.iter().find_map(|page| {
443            (crate::svg::reader::extract_source_format(&page.bytes).as_deref() == Some("csv"))
444                .then(|| crate::svg::reader::extract_embedded_source(&page.bytes))
445                .flatten()
446        });
447        if let Some(csv) = embedded_csv {
448            writer.write_all(csv.as_bytes())?;
449        } else {
450            for page in &pages {
451                let csv = crate::chart::extract_csv_from_chart_svg(&page.bytes)?;
452                writer.write_all(csv.as_bytes())?;
453            }
454        }
455        writer.flush()?;
456    } else if format == ReverseFormat::Tex {
457        let mut writer = BufWriter::new(temporary.as_file_mut());
458        for page in &pages {
459            let tex = crate::math::extract_latex_from_svg(&page.bytes)?;
460            writer.write_all(tex.as_bytes())?;
461        }
462        writer.flush()?;
463    } else if matches!(format, ReverseFormat::Jsx | ReverseFormat::Tsx) {
464        let mut writer = BufWriter::new(temporary.as_file_mut());
465        let comp_name = output
466            .file_stem()
467            .and_then(|s| s.to_str())
468            .unwrap_or("SvgIcon");
469        let mut comp = String::new();
470        let mut capitalize = true;
471        for c in comp_name.chars() {
472            if c == '-' || c == '_' {
473                capitalize = true;
474            } else if capitalize {
475                comp.extend(c.to_uppercase());
476                capitalize = false;
477            } else {
478                comp.push(c);
479            }
480        }
481        if comp.is_empty() || comp.chars().next().is_some_and(|c| !c.is_alphabetic()) {
482            comp = format!("Icon{comp}");
483        }
484        for page in &pages {
485            let jsx = crate::code::svg_to_jsx(&page.bytes, &comp, format == ReverseFormat::Tsx)?;
486            writer.write_all(jsx.as_bytes())?;
487        }
488        writer.flush()?;
489    } else if format == ReverseFormat::Vue {
490        let mut writer = BufWriter::new(temporary.as_file_mut());
491        for page in &pages {
492            let vue = crate::code::svg_to_vue(&page.bytes)?;
493            writer.write_all(vue.as_bytes())?;
494        }
495        writer.flush()?;
496    } else if format == ReverseFormat::Svelte {
497        let mut writer = BufWriter::new(temporary.as_file_mut());
498        for page in &pages {
499            let svelte = crate::code::svg_to_svelte(&page.bytes)?;
500            writer.write_all(svelte.as_bytes())?;
501        }
502        writer.flush()?;
503    } else if format == ReverseFormat::PathData {
504        let mut writer = BufWriter::new(temporary.as_file_mut());
505        for page in &pages {
506            let path_data = crate::code::svg_to_path_data(&page.bytes)?;
507            writer.write_all(path_data.as_bytes())?;
508        }
509        writer.flush()?;
510    } else if format == ReverseFormat::DataUri {
511        let mut writer = BufWriter::new(temporary.as_file_mut());
512        for page in &pages {
513            let uri = crate::code::svg_to_data_uri(&page.bytes)?;
514            writer.write_all(uri.as_bytes())?;
515        }
516        writer.flush()?;
517    } else if format == ReverseFormat::Png {
518        let mut writer = BufWriter::new(temporary.as_file_mut());
519        if let Some(page) = pages.first() {
520            writer.write_all(&page.fallback_png)?;
521        }
522        writer.flush()?;
523    } else if format == ReverseFormat::Html {
524        let mut writer = BufWriter::new(temporary.as_file_mut());
525        let doc_title = output
526            .file_stem()
527            .and_then(|s| s.to_str())
528            .unwrap_or("Document SVG");
529        for page in &pages {
530            let html = crate::code::svg_to_html(&page.bytes, doc_title)?;
531            writer.write_all(html.as_bytes())?;
532        }
533        writer.flush()?;
534    } else if format == ReverseFormat::Pdf {
535        write_pdf(&pages, temporary.as_file_mut())?;
536    } else if format == ReverseFormat::Webp {
537        let mut writer = BufWriter::new(temporary.as_file_mut());
538        if let Some(page) = pages.first() {
539            writer.write_all(&page.fallback_webp)?;
540        }
541        writer.flush()?;
542    } else {
543        let writer = BufWriter::new(temporary.as_file_mut());
544        let mut package = Package::new(writer);
545        match format {
546            ReverseFormat::Pptx => write_pptx(&mut package, &pages)?,
547            ReverseFormat::Docx => write_docx(&mut package, &pages)?,
548            ReverseFormat::Xlsx => write_xlsx(&mut package, &pages)?,
549            ReverseFormat::Drawio
550            | ReverseFormat::Dxf
551            | ReverseFormat::Dot
552            | ReverseFormat::Mermaid
553            | ReverseFormat::Markdown
554            | ReverseFormat::Csv
555            | ReverseFormat::Tex
556            | ReverseFormat::Jsx
557            | ReverseFormat::Tsx
558            | ReverseFormat::Vue
559            | ReverseFormat::DataUri
560            | ReverseFormat::Png
561            | ReverseFormat::Pdf
562            | ReverseFormat::Html
563            | ReverseFormat::Webp
564            | ReverseFormat::Gcode
565            | ReverseFormat::Gerber
566            | ReverseFormat::Hpgl
567            | ReverseFormat::Excellon
568            | ReverseFormat::Stl
569            | ReverseFormat::Obj
570            | ReverseFormat::Ply
571            | ReverseFormat::Step
572            | ReverseFormat::Gmsh
573            | ReverseFormat::Vtk
574            | ReverseFormat::ThreeMf
575            | ReverseFormat::Iges
576            | ReverseFormat::Svelte
577            | ReverseFormat::PathData => {
578                unreachable!("non-package formats are written directly")
579            }
580        }
581        package.finish()?;
582    }
583    temporary
584        .persist_noclobber(output)
585        .map_err(|error| error.error)?;
586
587    let warnings = match (format, restored, pages.len()) {
588        (ReverseFormat::Drawio, restored, total) if restored == total => vec![format!(
589            "{restored} page(s) were restored from the diagram source the SVG carries; they are editable shapes again"
590        )],
591        (ReverseFormat::Drawio, 0, _) => vec![
592            "SVG pages are embedded as pictures in the diagram; they are not editable shapes, because the SVG carries no diagram source"
593                .into(),
594        ],
595        (ReverseFormat::Drawio, restored, total) => vec![format!(
596            "{restored} of {total} page(s) were restored from the diagram source the SVG carries; the rest are embedded as pictures"
597        )],
598        (ReverseFormat::Dxf, _, _) => vec![
599            "SVG vector shapes and text were converted to AutoCAD DXF entities"
600                .into(),
601        ],
602        (ReverseFormat::Gcode, _, _) => vec![
603            "SVG vector paths were converted to CNC G-code toolpath commands"
604                .into(),
605        ],
606        (ReverseFormat::Gerber, _, _) => vec![
607            "SVG pads, lines, and filled polygons were converted to Gerber RS-274X PCB artwork"
608                .into(),
609        ],
610        (ReverseFormat::Hpgl, _, _) => vec![
611            "SVG vector paths and circles were converted to HP-GL plotter commands"
612                .into(),
613        ],
614        (ReverseFormat::Excellon, _, _) => vec![
615            "SVG circles and drill pads were converted to Excellon NC drill commands"
616                .into(),
617        ],
618        (ReverseFormat::Stl, _, _) => vec![
619            "SVG vector contours were extruded into a 3D printable STL triangle mesh"
620                .into(),
621        ],
622        (ReverseFormat::Obj, _, _) => vec![
623            "SVG vector contours were extruded into a 3D Wavefront OBJ polygonal mesh"
624                .into(),
625        ],
626        (ReverseFormat::Ply, _, _) => vec![
627            "SVG vector contours were extruded into a Stanford PLY 3D mesh"
628                .into(),
629        ],
630        (ReverseFormat::Step, _, _) => vec![
631            "SVG vector elements were converted to ISO 10303-21 STEP mechanical CAD wireframe entities"
632                .into(),
633        ],
634        (ReverseFormat::Gmsh, _, _) => vec![
635            "SVG vector geometry was converted to a Gmsh 2.2 finite element mesh"
636                .into(),
637        ],
638        (ReverseFormat::Vtk, _, _) => vec![
639            "SVG vector polygons were converted to a VTK Legacy polygonal dataset"
640                .into(),
641        ],
642        (ReverseFormat::ThreeMf, _, _) => vec![
643            "SVG vector contours were extruded into a 3MF 3D manufacturing package"
644                .into(),
645        ],
646        (ReverseFormat::Iges, _, _) => vec![
647            "SVG vector elements were converted to ANSI IGES mechanical CAD entities"
648                .into(),
649        ],
650        (ReverseFormat::Dot | ReverseFormat::Mermaid, _, _) => vec![
651            "SVG shapes and text were extracted into a graph structure; topology is approximated from geometric elements"
652                .into(),
653        ],
654        (ReverseFormat::Markdown, _, _) => vec![
655            "SVG table grid lines and cell text were extracted into a Markdown table"
656                .into(),
657        ],
658        (ReverseFormat::Csv, _, _) => vec![
659            "SVG chart elements and text were extracted into tabular CSV data"
660                .into(),
661        ],
662        (ReverseFormat::Tex, _, _) => vec![
663            "SVG mathematical elements were reconstructed into LaTeX math expressions"
664                .into(),
665        ],
666        (ReverseFormat::Jsx | ReverseFormat::Tsx, _, _) => vec![
667            "SVG element structure was compiled into a React component"
668                .into(),
669        ],
670        (ReverseFormat::Vue, _, _) => vec![
671            "SVG element structure was compiled into a Vue 3 component template"
672                .into(),
673        ],
674        (ReverseFormat::DataUri, _, _) => vec![
675            "SVG was base64-encoded into a Data URI"
676                .into(),
677        ],
678        (ReverseFormat::Png, _, _) => vec![
679            "SVG was rendered directly to a raster PNG image"
680                .into(),
681        ],
682        (ReverseFormat::Pdf, _, _) => vec![
683            "SVG pages were packaged into a PDF document with exact page dimensions and raster imagery"
684                .into(),
685        ],
686        _ => vec![
687            "SVG pages are embedded as vector images; original document semantics are not reconstructed"
688                .into(),
689        ],
690    };
691    Ok(ReverseReport {
692        converter: "document-svg",
693        version: env!("CARGO_PKG_VERSION"),
694        source: input.to_string_lossy().into_owned(),
695        output: output.to_string_lossy().into_owned(),
696        output_format: format,
697        page_count: pages.len(),
698        input_bytes,
699        warnings,
700    })
701}
702
703fn collect_svg_paths(input: &Path, max_pages: usize) -> Result<Vec<PathBuf>> {
704    let metadata = fs::metadata(input)?;
705    let mut paths = if metadata.is_file() {
706        if !has_svg_extension(input) {
707            return Err(Error::Unsupported(format!(
708                "input {}; expected an SVG file or directory",
709                input.display()
710            )));
711        }
712        vec![input.to_path_buf()]
713    } else if metadata.is_dir() {
714        let mut entries = Vec::new();
715        for entry in fs::read_dir(input)? {
716            let path = entry?.path();
717            if path.is_file() && has_svg_extension(&path) {
718                entries.push(path);
719            }
720        }
721        entries.sort();
722        entries
723    } else {
724        return Err(Error::InvalidInput(format!(
725            "{} is not a regular file or directory",
726            input.display()
727        )));
728    };
729    if paths.is_empty() {
730        return Err(Error::InvalidInput(format!(
731            "{} contains no SVG files",
732            input.display()
733        )));
734    }
735    if paths.len() > max_pages {
736        return Err(Error::LimitExceeded(format!(
737            "SVG input has {} pages; maximum is {max_pages}",
738            paths.len()
739        )));
740    }
741    paths.shrink_to_fit();
742    Ok(paths)
743}
744
745fn has_svg_extension(path: &Path) -> bool {
746    path.extension()
747        .and_then(|value| value.to_str())
748        .is_some_and(|value| value.eq_ignore_ascii_case("svg"))
749}
750
751fn svg_dimensions(bytes: &[u8]) -> Result<(f64, f64)> {
752    let mut reader = Reader::from_reader(bytes);
753    reader.config_mut().trim_text(true);
754    let mut buffer = Vec::new();
755    loop {
756        match reader.read_event_into(&mut buffer)? {
757            Event::Start(start) | Event::Empty(start)
758                if local_name(start.name().as_ref()) == b"svg" =>
759            {
760                let width = attribute(&start, b"width").and_then(|value| parse_svg_length(&value));
761                let height =
762                    attribute(&start, b"height").and_then(|value| parse_svg_length(&value));
763                let view_box = attribute(&start, b"viewBox").and_then(|value| {
764                    let tokens = value
765                        .split(|character: char| {
766                            character.is_ascii_whitespace() || character == ','
767                        })
768                        .filter(|value| !value.is_empty())
769                        .collect::<Vec<_>>();
770                    if tokens.len() != 4 {
771                        return None;
772                    }
773                    let values = tokens
774                        .iter()
775                        .map(|value| value.parse::<f64>())
776                        .collect::<std::result::Result<Vec<_>, _>>()
777                        .ok()?;
778                    (values.iter().all(|value| value.is_finite())
779                        && values[2] > 0.0
780                        && values[3] > 0.0)
781                        .then_some((values[2], values[3]))
782                });
783                let width = width.or_else(|| view_box.map(|value| value.0 * 0.75));
784                let height = height.or_else(|| view_box.map(|value| value.1 * 0.75));
785                let (width, height) = match (width, height) {
786                    (Some(width), Some(height)) if width > 0.0 && height > 0.0 => (width, height),
787                    _ => {
788                        return Err(Error::InvalidInput(
789                            "SVG root needs positive width/height or viewBox dimensions".into(),
790                        ));
791                    }
792                };
793                return Ok((width, height));
794            }
795            Event::Eof => {
796                return Err(Error::InvalidInput(
797                    "input does not contain an SVG root".into(),
798                ));
799            }
800            _ => {}
801        }
802        buffer.clear();
803    }
804}
805
806fn parse_svg_length(value: &str) -> Option<f64> {
807    let value = value.trim();
808    let lower = value.to_ascii_lowercase();
809    let (number, unit) = ["pt", "px", "in", "cm", "mm", "pc"]
810        .into_iter()
811        .find_map(|unit| lower.strip_suffix(unit).map(|number| (number.trim(), unit)))
812        .unwrap_or((value, ""));
813    let number = number.parse::<f64>().ok()?;
814    let points = match unit {
815        "pt" => number,
816        "px" | "" => number * 0.75,
817        "in" => number * 72.0,
818        "cm" => number * 72.0 / 2.54,
819        "mm" => number * 72.0 / 25.4,
820        "pc" => number * 12.0,
821        _ => unreachable!(),
822    };
823    points.is_finite().then_some(points)
824}
825
826/// Refuse an SVG that could do anything but draw: scripts, foreign content,
827/// external references, entity declarations and CSS that reaches outside the
828/// document. Shared with the draw.io reader, which embeds SVG pictures.
829pub(crate) fn validate_svg_document(bytes: &[u8], depth: usize) -> Result<()> {
830    if depth > MAX_NESTED_SVG_DEPTH {
831        return Err(Error::LimitExceeded(format!(
832            "nested SVG depth exceeds {MAX_NESTED_SVG_DEPTH}"
833        )));
834    }
835    let mut reader = Reader::from_reader(bytes);
836    reader.config_mut().trim_text(false);
837    let mut buffer = Vec::new();
838    let mut saw_root = false;
839    let mut root_closed = false;
840    let mut element_depth = 0usize;
841    let mut style_depth = None::<usize>;
842    let mut style_text = String::new();
843    loop {
844        match reader.read_event_into(&mut buffer)? {
845            Event::DocType(doctype) => {
846                // Every draw.io SVG export starts with the standard SVG 1.1
847                // document type, so refusing all of them would refuse the
848                // files this converter most needs to read. What has to stay
849                // out is an entity declaration, which needs an internal subset;
850                // an external DTD is never fetched by this reader or written to
851                // the output.
852                let text = doctype
853                    .decode()
854                    .map_err(|error| {
855                        Error::InvalidInput(format!("invalid SVG document type: {error}"))
856                    })?
857                    .to_ascii_uppercase();
858                if text.contains('[') || text.contains("ENTITY") {
859                    return Err(Error::InvalidInput(
860                        "SVG document type entities are not allowed".into(),
861                    ));
862                }
863            }
864            Event::PI(_) => {
865                return Err(Error::InvalidInput(
866                    "SVG processing instructions are not allowed".into(),
867                ));
868            }
869            // A reference inside a style rule is reported separately from the
870            // text around it, so `u&#114;l(...)` would slip past a check that
871            // only ever sees one chunk at a time. Resolving each reference into
872            // the same buffer as the text, and checking the whole rule once the
873            // element closes, is what makes the check see what CSS will.
874            Event::GeneralRef(reference) if style_depth.is_some() => {
875                push_style_text(
876                    &mut style_text,
877                    &decode_xml_reference(&reference, "SVG CSS")?,
878                )?;
879            }
880            Event::Start(start) => {
881                if root_closed {
882                    return Err(Error::InvalidInput(
883                        "input contains content after the SVG root".into(),
884                    ));
885                }
886                if !saw_root {
887                    if local_name(start.name().as_ref()) != b"svg" {
888                        return Err(Error::InvalidInput(
889                            "input must have an <svg> root element".into(),
890                        ));
891                    }
892                    validate_svg_root_namespace(&start)?;
893                    saw_root = true;
894                }
895                validate_svg_element(&start, reader.decoder(), depth)?;
896                if local_name(start.name().as_ref()).eq_ignore_ascii_case(b"style") {
897                    style_depth = Some(element_depth + 1);
898                }
899                element_depth += 1;
900            }
901            Event::Empty(start) => {
902                if root_closed {
903                    return Err(Error::InvalidInput(
904                        "input contains content after the SVG root".into(),
905                    ));
906                }
907                if !saw_root {
908                    if local_name(start.name().as_ref()) != b"svg" {
909                        return Err(Error::InvalidInput(
910                            "input must have an <svg> root element".into(),
911                        ));
912                    }
913                    validate_svg_root_namespace(&start)?;
914                    saw_root = true;
915                    root_closed = true;
916                }
917                validate_svg_element(&start, reader.decoder(), depth)?;
918            }
919            Event::Text(text) => {
920                let value = text.decode().map_err(|error| {
921                    Error::InvalidInput(format!("invalid SVG text encoding: {error}"))
922                })?;
923                if (!saw_root || root_closed) && !value.trim().is_empty() {
924                    return Err(Error::InvalidInput(
925                        "input contains text outside the SVG root".into(),
926                    ));
927                }
928                if style_depth.is_some() {
929                    push_style_text(&mut style_text, &value)?;
930                }
931            }
932            Event::CData(text) => {
933                if !saw_root || root_closed {
934                    return Err(Error::InvalidInput(
935                        "input contains CDATA outside the SVG root".into(),
936                    ));
937                }
938                if style_depth.is_some() {
939                    let value = String::from_utf8_lossy(text.as_ref());
940                    push_style_text(&mut style_text, &value)?;
941                }
942            }
943            Event::End(end) => {
944                element_depth = element_depth.saturating_sub(1);
945                if element_depth == 0 {
946                    root_closed = true;
947                }
948                if local_name(end.name().as_ref()).eq_ignore_ascii_case(b"style") {
949                    style_depth = None;
950                    validate_css_references(&style_text)?;
951                    style_text.clear();
952                }
953            }
954            Event::Eof => {
955                if !style_text.is_empty() {
956                    validate_css_references(&style_text)?;
957                }
958                break;
959            }
960            _ => {}
961        }
962        buffer.clear();
963    }
964    if !saw_root || !root_closed {
965        return Err(Error::InvalidInput(
966            "input does not contain an SVG root".into(),
967        ));
968    }
969    Ok(())
970}
971
972fn validate_svg_root_namespace(start: &quick_xml::events::BytesStart<'_>) -> Result<()> {
973    let raw_name = String::from_utf8_lossy(start.name().as_ref()).into_owned();
974    let namespace_key = raw_name.split_once(':').map_or_else(
975        || "xmlns".to_owned(),
976        |(prefix, _)| format!("xmlns:{prefix}"),
977    );
978    let mut namespace = None::<String>;
979    for item in start.attributes().with_checks(true) {
980        let item = item
981            .map_err(|error| Error::InvalidInput(format!("invalid SVG root attribute: {error}")))?;
982        if item.key.as_ref() == namespace_key.as_bytes() {
983            namespace = Some(String::from_utf8_lossy(item.value.as_ref()).into_owned());
984        }
985    }
986    if raw_name.contains(':') && namespace.as_deref() != Some("http://www.w3.org/2000/svg") {
987        return Err(Error::InvalidInput(
988            "prefixed SVG root must use the SVG namespace".into(),
989        ));
990    }
991    if namespace
992        .as_deref()
993        .is_some_and(|value| value != "http://www.w3.org/2000/svg")
994    {
995        return Err(Error::InvalidInput(
996            "SVG root uses an unsupported namespace".into(),
997        ));
998    }
999    Ok(())
1000}
1001
1002fn validate_svg_element(
1003    start: &quick_xml::events::BytesStart<'_>,
1004    decoder: quick_xml::encoding::Decoder,
1005    depth: usize,
1006) -> Result<()> {
1007    let name = String::from_utf8_lossy(local_name(start.name().as_ref())).to_ascii_lowercase();
1008    if matches!(
1009        name.as_str(),
1010        "script"
1011            | "foreignobject"
1012            | "iframe"
1013            | "object"
1014            | "embed"
1015            | "animate"
1016            | "animatetransform"
1017            | "animatemotion"
1018            | "discard"
1019            | "set"
1020    ) {
1021        return Err(Error::InvalidInput(format!(
1022            "active SVG element <{name}> is not allowed"
1023        )));
1024    }
1025    for item in start.attributes().with_checks(true) {
1026        let item =
1027            item.map_err(|error| Error::InvalidInput(format!("invalid SVG attribute: {error}")))?;
1028        let key = String::from_utf8_lossy(local_name(item.key.as_ref())).to_ascii_lowercase();
1029        let value = item
1030            .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, decoder)?
1031            .into_owned();
1032        if key.starts_with("on") || key == "base" {
1033            return Err(Error::InvalidInput(format!(
1034                "SVG event attribute {key} is not allowed"
1035            )));
1036        }
1037        if key == "href" {
1038            validate_svg_href(&value, depth)?;
1039        }
1040        // `content` is where draw.io keeps a copy of the diagram. No renderer
1041        // reads it and no CSS can reach it, and this converter parses it as
1042        // XML before trusting it, so the CSS rules do not apply and would
1043        // reject an ordinary diagram that happens to contain a backslash.
1044        if key != "content" {
1045            validate_css_references(&value)?;
1046        }
1047    }
1048    Ok(())
1049}
1050
1051fn validate_svg_href(value: &str, depth: usize) -> Result<()> {
1052    let value = value.trim();
1053    if value.is_empty() || value.starts_with('#') {
1054        return Ok(());
1055    }
1056    let lower = value.to_ascii_lowercase();
1057    if lower.starts_with("data:image/png;")
1058        || lower.starts_with("data:image/jpeg;")
1059        || lower.starts_with("data:image/gif;")
1060        || lower.starts_with("data:image/webp;")
1061    {
1062        return Ok(());
1063    }
1064    if lower.starts_with("data:image/svg+xml;base64,") {
1065        let encoded = value
1066            .split_once(',')
1067            .map(|(_, data)| data)
1068            .unwrap_or_default();
1069        let compact = encoded
1070            .bytes()
1071            .filter(|byte| !byte.is_ascii_whitespace())
1072            .collect::<Vec<_>>();
1073        let nested = base64::engine::general_purpose::STANDARD
1074            .decode(compact)
1075            .map_err(|error| {
1076                Error::InvalidInput(format!("invalid nested SVG data URI: {error}"))
1077            })?;
1078        return validate_svg_document(&nested, depth + 1);
1079    }
1080    Err(Error::InvalidInput(format!(
1081        "external or active SVG reference is not allowed: {}",
1082        value.chars().take(80).collect::<String>()
1083    )))
1084}
1085
1086fn validate_css_references(value: &str) -> Result<()> {
1087    let lower = value.to_ascii_lowercase();
1088    if lower.contains('\\') || lower.contains("/*") {
1089        return Err(Error::InvalidInput(
1090            "SVG CSS escapes and comments are not allowed".into(),
1091        ));
1092    }
1093    if lower.contains("@import") || lower.contains("javascript:") {
1094        return Err(Error::InvalidInput(
1095            "external or active SVG CSS is not allowed".into(),
1096        ));
1097    }
1098    let mut remainder = value;
1099    while let Some(index) = remainder.to_ascii_lowercase().find("url(") {
1100        let after = &remainder[index + 4..];
1101        let Some(end) = after.find(')') else {
1102            return Err(Error::InvalidInput("unterminated SVG CSS url()".into()));
1103        };
1104        let target = after[..end]
1105            .trim()
1106            .trim_matches(|character| matches!(character, '\'' | '"'));
1107        if !target.starts_with('#') {
1108            return Err(Error::InvalidInput(
1109                "external SVG CSS url() reference is not allowed".into(),
1110            ));
1111        }
1112        remainder = &after[end + 1..];
1113    }
1114    Ok(())
1115}
1116
1117fn render_svg_to_pixmap(
1118    bytes: &[u8],
1119    width_points: f64,
1120    height_points: f64,
1121    options: &resvg::usvg::Options<'_>,
1122) -> Result<resvg::tiny_skia::Pixmap> {
1123    let tree = resvg::usvg::Tree::from_data(bytes, options)
1124        .map_err(|error| Error::InvalidInput(format!("SVG fallback parse failed: {error}")))?;
1125    let (pixel_width, pixel_height) = fallback_pixel_size(width_points, height_points);
1126    let mut pixmap = resvg::tiny_skia::Pixmap::new(pixel_width, pixel_height).ok_or_else(|| {
1127        Error::LimitExceeded(format!(
1128            "SVG fallback raster allocation failed for {pixel_width}x{pixel_height} pixels"
1129        ))
1130    })?;
1131    let source = tree.size();
1132    let transform = resvg::tiny_skia::Transform::from_scale(
1133        pixel_width as f32 / source.width(),
1134        pixel_height as f32 / source.height(),
1135    );
1136    resvg::render(&tree, transform, &mut pixmap.as_mut());
1137    Ok(pixmap)
1138}
1139
1140fn render_svg_fallback(
1141    bytes: &[u8],
1142    width_points: f64,
1143    height_points: f64,
1144    options: &resvg::usvg::Options<'_>,
1145) -> Result<Vec<u8>> {
1146    let pixmap = render_svg_to_pixmap(bytes, width_points, height_points, options)?;
1147    pixmap
1148        .encode_png()
1149        .map_err(|error| Error::InvalidInput(format!("SVG fallback PNG encoding failed: {error}")))
1150}
1151
1152fn render_svg_webp(
1153    bytes: &[u8],
1154    width_points: f64,
1155    height_points: f64,
1156    options: &resvg::usvg::Options<'_>,
1157) -> Result<Vec<u8>> {
1158    let pixmap = render_svg_to_pixmap(bytes, width_points, height_points, options)?;
1159    let w = pixmap.width();
1160    let h = pixmap.height();
1161    let raw = pixmap.data();
1162    let mut unpremul = Vec::with_capacity(raw.len());
1163    for chunk in raw.chunks_exact(4) {
1164        let r = chunk[0];
1165        let g = chunk[1];
1166        let b = chunk[2];
1167        let a = chunk[3];
1168        if a == 0 {
1169            unpremul.extend_from_slice(&[0, 0, 0, 0]);
1170        } else if a == 255 {
1171            unpremul.extend_from_slice(&[r, g, b, 255]);
1172        } else {
1173            let fa = a as u32;
1174            let ur = ((r as u32 * 255 + fa / 2) / fa).min(255) as u8;
1175            let ug = ((g as u32 * 255 + fa / 2) / fa).min(255) as u8;
1176            let ub = ((b as u32 * 255 + fa / 2) / fa).min(255) as u8;
1177            unpremul.extend_from_slice(&[ur, ug, ub, a]);
1178        }
1179    }
1180    let mut out = Vec::new();
1181    let encoder = WebPEncoder::new(&mut out);
1182    encoder
1183        .encode(&unpremul, w, h, image_webp::ColorType::Rgba8)
1184        .map_err(|error| {
1185            Error::InvalidInput(format!("SVG fallback WebP encoding failed: {error}"))
1186        })?;
1187    Ok(out)
1188}
1189
1190fn fallback_pixel_size(width_points: f64, height_points: f64) -> (u32, u32) {
1191    let mut width = (width_points * FALLBACK_DPI / 72.0).max(1.0);
1192    let mut height = (height_points * FALLBACK_DPI / 72.0).max(1.0);
1193    let scale = (MAX_FALLBACK_DIMENSION / width)
1194        .min(MAX_FALLBACK_DIMENSION / height)
1195        .min((MAX_FALLBACK_PIXELS / (width * height)).sqrt())
1196        .min(1.0);
1197    width *= scale;
1198    height *= scale;
1199    (
1200        width.round().max(1.0) as u32,
1201        height.round().max(1.0) as u32,
1202    )
1203}
1204
1205struct Package<W: Write + std::io::Seek> {
1206    zip: ZipWriter<W>,
1207    options: SimpleFileOptions,
1208}
1209
1210impl<W: Write + std::io::Seek> Package<W> {
1211    fn new(writer: W) -> Self {
1212        Self {
1213            zip: ZipWriter::new(writer),
1214            options: SimpleFileOptions::default()
1215                .compression_method(zip::CompressionMethod::Deflated)
1216                .unix_permissions(0o644),
1217        }
1218    }
1219
1220    fn part(&mut self, name: &str, bytes: impl AsRef<[u8]>) -> Result<()> {
1221        self.zip.start_file(name, self.options)?;
1222        self.zip.write_all(bytes.as_ref())?;
1223        Ok(())
1224    }
1225
1226    fn finish(self) -> Result<()> {
1227        self.zip.finish()?;
1228        Ok(())
1229    }
1230}
1231
1232fn root_relationships(target: &str, relationship_type: &str) -> String {
1233    format!(
1234        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1235<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{relationship_type}" Target="{target}"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>"#
1236    )
1237}
1238
1239fn property_content_type_overrides() -> &'static str {
1240    r#"<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>"#
1241}
1242
1243fn write_common_properties<W: Write + std::io::Seek>(package: &mut Package<W>) -> Result<()> {
1244    package.part(
1245        "docProps/core.xml",
1246        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1247<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Document SVG</dc:title><dc:creator>document-svg</dc:creator><cp:lastModifiedBy>document-svg</cp:lastModifiedBy></cp:coreProperties>"#,
1248    )?;
1249    package.part(
1250        "docProps/app.xml",
1251        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1252<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>document-svg</Application><AppVersion>0.1</AppVersion></Properties>"#,
1253    )?;
1254    Ok(())
1255}
1256
1257fn write_pptx<W: Write + std::io::Seek>(package: &mut Package<W>, pages: &[SvgPage]) -> Result<()> {
1258    let mut overrides = String::new();
1259    let mut slide_ids = String::new();
1260    let mut presentation_rels = String::new();
1261    for (index, _) in pages.iter().enumerate() {
1262        let number = index + 1;
1263        overrides.push_str(&format!(r#"<Override PartName="/ppt/slides/slide{number}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>"#));
1264        slide_ids.push_str(&format!(
1265            r#"<p:sldId id="{}" r:id="rId{}"/>"#,
1266            256 + index,
1267            number + 1
1268        ));
1269        presentation_rels.push_str(&format!(r#"<Relationship Id="rId{}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{number}.xml"/>"#, number + 1));
1270    }
1271    let content_types = format!(
1272        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1273<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="svg" ContentType="image/svg+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>{property_overrides}{overrides}</Types>"#,
1274        property_overrides = property_content_type_overrides(),
1275    );
1276    package.part("[Content_Types].xml", content_types)?;
1277    package.part(
1278        "_rels/.rels",
1279        root_relationships("ppt/presentation.xml", "officeDocument"),
1280    )?;
1281    write_common_properties(package)?;
1282    let first = &pages[0];
1283    let slide_width = points_to_emu(first.width_points);
1284    let slide_height = points_to_emu(first.height_points);
1285    let presentation = format!(
1286        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1287<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>{slide_ids}</p:sldIdLst><p:sldSz cx="{slide_width}" cy="{slide_height}" type="custom"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>"#
1288    );
1289    package.part("ppt/presentation.xml", presentation)?;
1290    let presentation_relationships = format!(
1291        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1292<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>{presentation_rels}<Relationship Id="rId{pres_props_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" Target="presProps.xml"/></Relationships>"#,
1293        pres_props_id = pages.len() + 2,
1294    );
1295    package.part(
1296        "ppt/_rels/presentation.xml.rels",
1297        presentation_relationships,
1298    )?;
1299    package.part("ppt/slideMasters/slideMaster1.xml", pptx_slide_master())?;
1300    package.part("ppt/slideMasters/_rels/slideMaster1.xml.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1301<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/></Relationships>"#)?;
1302    package.part("ppt/slideLayouts/slideLayout1.xml", pptx_slide_layout())?;
1303    package.part("ppt/slideLayouts/_rels/slideLayout1.xml.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1304<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/></Relationships>"#)?;
1305    package.part("ppt/theme/theme1.xml", pptx_theme())?;
1306    package.part(
1307        "ppt/presProps.xml",
1308        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1309<p:presentationPr xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:showPr useTimings="0"/></p:presentationPr>"#,
1310    )?;
1311    for (index, page) in pages.iter().enumerate() {
1312        let number = index + 1;
1313        let (x, y, cx, cy) = fit_rect(
1314            page.width_points,
1315            page.height_points,
1316            first.width_points,
1317            first.height_points,
1318        );
1319        let slide = format!(
1320            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1321<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><mc:AlternateContent><mc:Choice Requires="asvg">{choice}</mc:Choice><mc:Fallback>{fallback}</mc:Fallback></mc:AlternateContent></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>"#,
1322            choice = pptx_svg_picture(number, x, y, cx, cy, true),
1323            fallback = pptx_svg_picture(number, x, y, cx, cy, false),
1324        );
1325        package.part(&format!("ppt/slides/slide{number}.xml"), slide)?;
1326        let relationships = format!(
1327            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1328<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{number}.svg"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{number}-fallback.png"/></Relationships>"#
1329        );
1330        package.part(
1331            &format!("ppt/slides/_rels/slide{number}.xml.rels"),
1332            relationships,
1333        )?;
1334        package.part(&format!("ppt/media/image{number}.svg"), &page.bytes)?;
1335        package.part(
1336            &format!("ppt/media/image{number}-fallback.png"),
1337            &page.fallback_png,
1338        )?;
1339    }
1340    Ok(())
1341}
1342
1343fn pptx_svg_picture(number: usize, x: i64, y: i64, cx: i64, cy: i64, svg: bool) -> String {
1344    let extension = if svg {
1345        r#"<a:extLst><a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}"><asvg:svgBlip r:embed="rId2"/></a:ext></a:extLst>"#
1346    } else {
1347        ""
1348    };
1349    format!(
1350        r#"<p:pic><p:nvPicPr><p:cNvPr id="2" name="SVG page {number}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId3">{extension}</a:blip><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr></p:pic>"#
1351    )
1352}
1353
1354fn pptx_slide_master() -> &'static str {
1355    r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1356<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/><p:sldLayoutIdLst><p:sldLayoutId id="1" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>"#
1357}
1358
1359fn pptx_slide_layout() -> &'static str {
1360    r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1361<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>"#
1362}
1363
1364fn pptx_theme() -> &'static str {
1365    r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1366<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Document SVG"><a:themeElements><a:clrScheme name="Document SVG"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="Document SVG"><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Document SVG"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="25400"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="38100"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#
1367}
1368
1369fn write_docx<W: Write + std::io::Seek>(package: &mut Package<W>, pages: &[SvgPage]) -> Result<()> {
1370    let content_types = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1371<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="svg" ContentType="image/svg+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
1372    package.part("[Content_Types].xml", content_types)?;
1373    package.part(
1374        "_rels/.rels",
1375        root_relationships("word/document.xml", "officeDocument"),
1376    )?;
1377    write_common_properties(package)?;
1378    let mut body = String::new();
1379    let mut relationships = String::new();
1380    for (index, page) in pages.iter().enumerate() {
1381        let number = index + 1;
1382        let cx = points_to_emu(page.width_points);
1383        let cy = points_to_emu(page.height_points);
1384        body.push_str("<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\"/>");
1385        if number < pages.len() {
1386            body.push_str(&docx_section_properties(page, true));
1387        }
1388        body.push_str("</w:pPr>");
1389        let png_rel = number * 2 - 1;
1390        let svg_rel = number * 2;
1391        body.push_str(&format!(r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:docPr id="{number}" name="SVG page {number}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="{number}" name="SVG page {number}"/><pic:cNvPicPr><a:picLocks noChangeAspect="1"/></pic:cNvPicPr></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId{png_rel}"><a:extLst><a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}"><asvg:svgBlip r:embed="rId{svg_rel}"/></a:ext></a:extLst></a:blip><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#));
1392        relationships.push_str(&format!(r#"<Relationship Id="rId{png_rel}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image{number}-fallback.png"/><Relationship Id="rId{svg_rel}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image{number}.svg"/>"#));
1393        package.part(&format!("word/media/image{number}.svg"), &page.bytes)?;
1394        package.part(
1395            &format!("word/media/image{number}-fallback.png"),
1396            &page.fallback_png,
1397        )?;
1398    }
1399    let final_section = docx_section_properties(&pages[pages.len() - 1], false);
1400    let document = format!(
1401        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1402<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main"><w:body>{body}{final_section}</w:body></w:document>"#
1403    );
1404    package.part("word/document.xml", document)?;
1405    package.part("word/_rels/document.xml.rels", format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1406<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{relationships}</Relationships>"#))?;
1407    Ok(())
1408}
1409
1410fn docx_section_properties(page: &SvgPage, section_break: bool) -> String {
1411    let page_width = points_to_twips(page.width_points);
1412    let page_height = points_to_twips(page.height_points);
1413    let orientation = if page.width_points > page.height_points {
1414        r#" w:orient="landscape""#
1415    } else {
1416        ""
1417    };
1418    let section_type = if section_break {
1419        r#"<w:type w:val="nextPage"/>"#
1420    } else {
1421        ""
1422    };
1423    format!(
1424        r#"<w:sectPr>{section_type}<w:pgSz w:w="{page_width}" w:h="{page_height}"{orientation}/><w:pgMar w:top="0" w:right="0" w:bottom="0" w:left="0" w:header="0" w:footer="0" w:gutter="0"/></w:sectPr>"#
1425    )
1426}
1427
1428fn write_xlsx<W: Write + std::io::Seek>(package: &mut Package<W>, pages: &[SvgPage]) -> Result<()> {
1429    let mut overrides = String::new();
1430    let mut sheets = String::new();
1431    let mut workbook_rels = String::new();
1432    for (index, _) in pages.iter().enumerate() {
1433        let number = index + 1;
1434        overrides.push_str(&format!(r#"<Override PartName="/xl/worksheets/sheet{number}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/drawings/drawing{number}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>"#));
1435        sheets.push_str(&format!(
1436            r#"<sheet name="Page {number}" sheetId="{number}" r:id="rId{number}"/>"#
1437        ));
1438        workbook_rels.push_str(&format!(r#"<Relationship Id="rId{number}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{number}.xml"/>"#));
1439    }
1440    package.part("[Content_Types].xml", format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1441<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="svg" ContentType="image/svg+xml"/><Default Extension="png" ContentType="image/png"/>{property_overrides}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>{overrides}</Types>"#,
1442        property_overrides = property_content_type_overrides(),
1443    ))?;
1444    package.part(
1445        "_rels/.rels",
1446        root_relationships("xl/workbook.xml", "officeDocument"),
1447    )?;
1448    write_common_properties(package)?;
1449    package.part("xl/workbook.xml", format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1450<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
1451    package.part("xl/_rels/workbook.xml.rels", format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1452<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{workbook_rels}</Relationships>"#))?;
1453    for (index, page) in pages.iter().enumerate() {
1454        let number = index + 1;
1455        let orientation = if page.width_points > page.height_points {
1456            "landscape"
1457        } else {
1458            "portrait"
1459        };
1460        package.part(&format!("xl/worksheets/sheet{number}.xml"), format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1461<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheetPr><pageSetUpPr fitToPage="1"/></sheetPr><dimension ref="A1"/><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData/><pageMargins left="0.25" right="0.25" top="0.25" bottom="0.25" header="0" footer="0"/><pageSetup paperSize="9" orientation="{orientation}" fitToWidth="1" fitToHeight="1"/><drawing r:id="rId1"/></worksheet>"#))?;
1462        package.part(&format!("xl/worksheets/_rels/sheet{number}.xml.rels"), format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1463<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing{number}.xml"/></Relationships>"#))?;
1464        let cx = points_to_emu(page.width_points);
1465        let cy = points_to_emu(page.height_points);
1466        package.part(&format!("xl/drawings/drawing{number}.xml"), format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1467<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main"><xdr:absoluteAnchor><xdr:pos x="0" y="0"/><xdr:ext cx="{cx}" cy="{cy}"/><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="1" name="SVG page {number}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"><a:extLst><a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}"><asvg:svgBlip r:embed="rId2"/></a:ext></a:extLst></a:blip><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></xdr:spPr></xdr:pic><xdr:clientData/></xdr:absoluteAnchor></xdr:wsDr>"#))?;
1468        package.part(&format!("xl/drawings/_rels/drawing{number}.xml.rels"), format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1469<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{number}-fallback.png"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{number}.svg"/></Relationships>"#))?;
1470        package.part(&format!("xl/media/image{number}.svg"), &page.bytes)?;
1471        package.part(
1472            &format!("xl/media/image{number}-fallback.png"),
1473            &page.fallback_png,
1474        )?;
1475    }
1476    Ok(())
1477}
1478
1479fn points_to_emu(points: f64) -> i64 {
1480    (points * EMU_PER_POINT).round().clamp(1.0, i64::MAX as f64) as i64
1481}
1482
1483fn points_to_twips(points: f64) -> i64 {
1484    (points * 20.0).round().clamp(1.0, 31_680.0) as i64
1485}
1486
1487fn fit_rect(
1488    width: f64,
1489    height: f64,
1490    container_width: f64,
1491    container_height: f64,
1492) -> (i64, i64, i64, i64) {
1493    let scale = (container_width / width).min(container_height / height);
1494    let width = width * scale;
1495    let height = height * scale;
1496    let x = (container_width - width) / 2.0;
1497    let y = (container_height - height) / 2.0;
1498    (
1499        points_to_emu(x),
1500        points_to_emu(y),
1501        points_to_emu(width),
1502        points_to_emu(height),
1503    )
1504}
1505
1506// ---------------------------------------------------------------------------
1507// draw.io
1508// ---------------------------------------------------------------------------
1509
1510/// draw.io lays out in CSS pixels; SVG pages arrive in points.
1511const PIXELS_PER_POINT: f64 = 4.0 / 3.0;
1512/// Upper bound on one page's embedded diagram source.
1513const MAX_EMBEDDED_SOURCE_BYTES: usize = 32 * 1024 * 1024;
1514
1515/// The `<diagram>` elements of the draw.io source an SVG carries.
1516///
1517/// draw.io puts a copy of the diagram in the root element's `content`
1518/// attribute when "Include a copy of my diagram" is on, which is what makes an
1519/// exported SVG openable as a diagram again. Returns an empty list for an SVG
1520/// that carries no source, which the caller turns into an embedded picture.
1521fn embedded_diagrams(bytes: &[u8]) -> Result<Vec<String>> {
1522    let mut reader = Reader::from_reader(bytes);
1523    reader.config_mut().trim_text(false);
1524    let mut buffer = Vec::new();
1525    let source = loop {
1526        match reader.read_event_into(&mut buffer)? {
1527            Event::Start(start) | Event::Empty(start) => {
1528                if local_name(start.name().as_ref()) != b"svg" {
1529                    return Ok(Vec::new());
1530                }
1531                break attribute(&start, b"content");
1532            }
1533            Event::Eof => return Ok(Vec::new()),
1534            _ => {}
1535        }
1536    };
1537    let Some(source) = source.filter(|value| !value.trim().is_empty()) else {
1538        return Ok(Vec::new());
1539    };
1540    if source.len() > MAX_EMBEDDED_SOURCE_BYTES {
1541        return Err(Error::LimitExceeded(format!(
1542            "embedded diagram source is {} bytes; maximum is {MAX_EMBEDDED_SOURCE_BYTES} bytes",
1543            source.len()
1544        )));
1545    }
1546    // Releases before 2018 stored the copy as `encodeURIComponent` output
1547    // rather than as the diagram itself, and those exports are still in
1548    // circulation.
1549    let source = if source.trim_start().starts_with('<') {
1550        source
1551    } else {
1552        crate::drawio::percent_decode(&source)?
1553    };
1554    split_embedded_diagrams(&source)
1555}
1556
1557/// Take the `<diagram>` elements out of an embedded `mxfile` verbatim.
1558///
1559/// The source is data that arrived inside someone else's SVG, so it is parsed
1560/// before it is trusted: anything that is not a well-formed `mxfile` carrying
1561/// diagrams, or that declares a document type, is refused rather than copied
1562/// into the output.
1563fn split_embedded_diagrams(source: &str) -> Result<Vec<String>> {
1564    let bytes = source.as_bytes();
1565    let mut reader = Reader::from_reader(bytes);
1566    reader.config_mut().trim_text(false);
1567    let mut buffer = Vec::new();
1568    let mut diagrams = Vec::new();
1569    let mut open = None::<usize>;
1570    let mut depth = 0usize;
1571    let mut saw_mxfile = false;
1572    loop {
1573        let position = usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX);
1574        match reader.read_event_into(&mut buffer).map_err(|error| {
1575            Error::InvalidInput(format!("embedded diagram source is not valid XML: {error}"))
1576        })? {
1577            Event::DocType(_) | Event::PI(_) => {
1578                return Err(Error::InvalidInput(
1579                    "embedded diagram source may not declare a document type".into(),
1580                ));
1581            }
1582            Event::Start(start) => {
1583                let name = local_name(start.name().as_ref()).to_vec();
1584                if name == b"mxfile" {
1585                    saw_mxfile = true;
1586                } else if name == b"diagram" && open.is_none() {
1587                    open = Some(position);
1588                    depth = 0;
1589                } else if open.is_some() {
1590                    depth += 1;
1591                }
1592            }
1593            Event::End(end) => {
1594                if local_name(end.name().as_ref()) == b"diagram" && depth == 0 {
1595                    if let Some(start_offset) = open.take() {
1596                        let element = source
1597                            .get(
1598                                start_offset
1599                                    ..usize::try_from(reader.buffer_position())
1600                                        .unwrap_or(usize::MAX),
1601                            )
1602                            .unwrap_or_default();
1603                        diagrams.push(element.to_owned());
1604                    }
1605                } else if open.is_some() {
1606                    depth = depth.saturating_sub(1);
1607                }
1608            }
1609            Event::Eof => break,
1610            _ => {}
1611        }
1612        buffer.clear();
1613    }
1614    if !saw_mxfile || diagrams.is_empty() {
1615        return Err(Error::InvalidInput(
1616            "embedded diagram source is not an mxfile with diagrams".into(),
1617        ));
1618    }
1619    Ok(diagrams)
1620}
1621
1622fn write_drawio(pages: &[SvgPage]) -> String {
1623    let mut diagrams = String::new();
1624    let mut count = 0usize;
1625    for (index, page) in pages.iter().enumerate() {
1626        let number = index + 1;
1627        if page.diagrams.is_empty() {
1628            diagrams.push_str(&drawio_picture_diagram(page, number));
1629            count += 1;
1630        } else {
1631            for diagram in &page.diagrams {
1632                diagrams.push_str(diagram);
1633                count += 1;
1634            }
1635        }
1636    }
1637    format!(
1638        "<mxfile host=\"document-svg\" agent=\"document-svg {}\" type=\"device\" pages=\"{count}\">{diagrams}</mxfile>\n",
1639        env!("CARGO_PKG_VERSION")
1640    )
1641}
1642
1643/// One page held as a picture, for an SVG that arrived without its source.
1644///
1645/// draw.io stores a picture shape's data URI in the style, and reads the
1646/// `data:<mime>,<base64>` spelling it writes itself.
1647fn drawio_picture_diagram(page: &SvgPage, number: usize) -> String {
1648    let width = (page.width_points * PIXELS_PER_POINT).round().max(1.0);
1649    let height = (page.height_points * PIXELS_PER_POINT).round().max(1.0);
1650    let encoded = base64::engine::general_purpose::STANDARD.encode(&page.bytes);
1651    format!(
1652        "<diagram id=\"page-{number}\" name=\"Page {number}\">\
1653         <mxGraphModel dx=\"{width}\" dy=\"{height}\" grid=\"0\" gridSize=\"10\" guides=\"1\" \
1654         tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" \
1655         pageWidth=\"{width}\" pageHeight=\"{height}\" math=\"0\" shadow=\"0\">\
1656         <root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>\
1657         <mxCell id=\"page-{number}-image\" value=\"\" \
1658         style=\"shape=image;verticalLabelPosition=bottom;verticalAlign=top;imageAspect=0;aspect=fixed;image=data:image/svg+xml,{encoded}\" \
1659         vertex=\"1\" parent=\"1\">\
1660         <mxGeometry x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" as=\"geometry\"/>\
1661         </mxCell></root></mxGraphModel></diagram>"
1662    )
1663}
1664
1665/// Collect one `<style>` element's content, bounded so a pathological document
1666/// cannot make the check itself the problem.
1667fn push_style_text(buffer: &mut String, value: &str) -> Result<()> {
1668    const MAX_STYLE_BYTES: usize = 4 * 1024 * 1024;
1669    if buffer.len().saturating_add(value.len()) > MAX_STYLE_BYTES {
1670        return Err(Error::LimitExceeded(format!(
1671            "SVG style content exceeds {MAX_STYLE_BYTES} bytes"
1672        )));
1673    }
1674    buffer.push_str(value);
1675    Ok(())
1676}
1677
1678fn write_pdf(pages: &[SvgPage], writer: &mut std::fs::File) -> Result<()> {
1679    let mut document = Document::with_version("1.4");
1680    let pages_id = document.add_object(lopdf::Dictionary::new());
1681    let mut page_ids = Vec::with_capacity(pages.len());
1682
1683    for page in pages {
1684        let width_pts = if page.width_points > 0.0 {
1685            page.width_points
1686        } else {
1687            612.0
1688        };
1689        let height_pts = if page.height_points > 0.0 {
1690            page.height_points
1691        } else {
1692            792.0
1693        };
1694
1695        if page.fallback_png.is_empty() {
1696            return Err(Error::InvalidInput(
1697                "missing raster fallback for PDF page".into(),
1698            ));
1699        }
1700
1701        let decoder = png::Decoder::new(Cursor::new(page.fallback_png.as_slice()));
1702        let mut reader = decoder.read_info().map_err(|e| {
1703            Error::InvalidInput(format!("failed to read fallback PNG info for PDF: {e}"))
1704        })?;
1705        let output_size = reader
1706            .output_buffer_size()
1707            .ok_or_else(|| Error::InvalidInput("PNG output buffer size overflow".into()))?;
1708        let mut img_buf = vec![0u8; output_size];
1709        let info = reader.next_frame(&mut img_buf).map_err(|e| {
1710            Error::InvalidInput(format!("failed to decode fallback PNG frame for PDF: {e}"))
1711        })?;
1712        let img_bytes = &img_buf[..info.buffer_size()];
1713
1714        let mut rgb = Vec::with_capacity((info.width * info.height * 3) as usize);
1715        let mut alpha = Vec::with_capacity((info.width * info.height) as usize);
1716        let mut has_transparency = false;
1717
1718        match info.color_type {
1719            png::ColorType::Rgba => {
1720                for chunk in img_bytes.chunks_exact(4) {
1721                    rgb.push(chunk[0]);
1722                    rgb.push(chunk[1]);
1723                    rgb.push(chunk[2]);
1724                    let a = chunk[3];
1725                    if a < 255 {
1726                        has_transparency = true;
1727                    }
1728                    alpha.push(a);
1729                }
1730            }
1731            png::ColorType::Rgb => {
1732                rgb.extend_from_slice(img_bytes);
1733            }
1734            png::ColorType::GrayscaleAlpha => {
1735                for chunk in img_bytes.chunks_exact(2) {
1736                    let g = chunk[0];
1737                    rgb.push(g);
1738                    rgb.push(g);
1739                    rgb.push(g);
1740                    let a = chunk[1];
1741                    if a < 255 {
1742                        has_transparency = true;
1743                    }
1744                    alpha.push(a);
1745                }
1746            }
1747            png::ColorType::Grayscale => {
1748                for &g in img_bytes {
1749                    rgb.push(g);
1750                    rgb.push(g);
1751                    rgb.push(g);
1752                }
1753            }
1754            _ => {
1755                rgb.extend_from_slice(img_bytes);
1756            }
1757        }
1758
1759        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1760        encoder.write_all(&rgb)?;
1761        let compressed_rgb = encoder.finish()?;
1762
1763        let mut image_dict = lopdf::dictionary! {
1764            "Type" => "XObject",
1765            "Subtype" => "Image",
1766            "Width" => info.width as i64,
1767            "Height" => info.height as i64,
1768            "ColorSpace" => "DeviceRGB",
1769            "BitsPerComponent" => 8,
1770            "Filter" => "FlateDecode",
1771        };
1772
1773        if has_transparency {
1774            let mut smask_encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1775            smask_encoder.write_all(&alpha)?;
1776            let compressed_alpha = smask_encoder.finish()?;
1777
1778            let smask_dict = lopdf::dictionary! {
1779                "Type" => "XObject",
1780                "Subtype" => "Image",
1781                "Width" => info.width as i64,
1782                "Height" => info.height as i64,
1783                "ColorSpace" => "DeviceGray",
1784                "BitsPerComponent" => 8,
1785                "Filter" => "FlateDecode",
1786            };
1787            let smask_stream = Stream::new(smask_dict, compressed_alpha);
1788            let smask_id = document.add_object(smask_stream);
1789            image_dict.set("SMask", Object::Reference(smask_id));
1790        }
1791
1792        let image_stream = Stream::new(image_dict, compressed_rgb);
1793        let image_id = document.add_object(image_stream);
1794
1795        let content_ops = format!(
1796            "q {:.2} 0 0 {:.2} 0 0 cm /Im1 Do Q\n",
1797            width_pts, height_pts
1798        );
1799        let content_stream = Stream::new(lopdf::Dictionary::new(), content_ops.into_bytes());
1800        let content_id = document.add_object(content_stream);
1801
1802        let page_dict = lopdf::dictionary! {
1803            "Type" => "Page",
1804            "Parent" => Object::Reference(pages_id),
1805            "MediaBox" => vec![0.into(), 0.into(), width_pts.into(), height_pts.into()],
1806            "Contents" => Object::Reference(content_id),
1807            "Resources" => lopdf::dictionary! {
1808                "XObject" => lopdf::dictionary! {
1809                    "Im1" => Object::Reference(image_id),
1810                },
1811            },
1812        };
1813        let page_id = document.add_object(page_dict);
1814        page_ids.push(page_id);
1815    }
1816
1817    let pages_dict = lopdf::dictionary! {
1818        "Type" => "Pages",
1819        "Kids" => page_ids.into_iter().map(Object::Reference).collect::<Vec<_>>(),
1820        "Count" => pages.len() as i64,
1821    };
1822    document.set_object(pages_id, pages_dict);
1823
1824    let catalog_dict = lopdf::dictionary! {
1825        "Type" => "Catalog",
1826        "Pages" => Object::Reference(pages_id),
1827    };
1828    let catalog_id = document.add_object(catalog_dict);
1829    document.trailer.set("Root", Object::Reference(catalog_id));
1830
1831    document.save_to(writer)?;
1832
1833    Ok(())
1834}