Skip to main content

document_svg/
convert.rs

1use std::fs;
2use std::io::{BufWriter, Write};
3use std::path::Path;
4use std::time::Instant;
5
6use serde::Serialize;
7
8use crate::error::{Error, Result};
9use crate::ir::Page;
10use crate::svg::{SvgOptions, write_page};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum SourceFormat {
15    Pdf,
16    Pptx,
17    Xlsx,
18    Docx,
19}
20
21impl SourceFormat {
22    pub fn detect(path: &Path) -> Result<Self> {
23        let extension = path
24            .extension()
25            .and_then(|value| value.to_str())
26            .map(str::to_ascii_lowercase)
27            .ok_or_else(|| Error::InvalidInput("input has no file extension".into()))?;
28        match extension.as_str() {
29            "pdf" => Ok(Self::Pdf),
30            "pptx" => Ok(Self::Pptx),
31            "xlsx" => Ok(Self::Xlsx),
32            "docx" => Ok(Self::Docx),
33            _ => Err(Error::Unsupported(format!(
34                "extension .{extension}; expected PDF, PPTX, XLSX, or DOCX"
35            ))),
36        }
37    }
38}
39
40impl std::fmt::Display for SourceFormat {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        formatter.write_str(match self {
43            Self::Pdf => "PDF",
44            Self::Pptx => "PPTX",
45            Self::Xlsx => "XLSX",
46            Self::Docx => "DOCX",
47        })
48    }
49}
50
51#[derive(Clone, Debug)]
52pub struct ConvertOptions {
53    pub max_input_bytes: u64,
54    pub max_zip_entry_bytes: u64,
55    pub max_pages: usize,
56    pub max_xml_events: usize,
57    pub include_metadata: bool,
58    pub precision: usize,
59    pub jobs: usize,
60    /// Render embedded PDF fonts as exact glyph outlines instead of editable
61    /// substitute-font text. This improves fidelity but requires the caller to
62    /// verify the source font's outline/embedding rights.
63    pub outline_embedded_pdf_text: bool,
64}
65
66impl Default for ConvertOptions {
67    fn default() -> Self {
68        Self {
69            max_input_bytes: 512 * 1024 * 1024,
70            max_zip_entry_bytes: 128 * 1024 * 1024,
71            max_pages: 10_000,
72            max_xml_events: 5_000_000,
73            include_metadata: true,
74            precision: 5,
75            jobs: 1,
76            outline_embedded_pdf_text: false,
77        }
78    }
79}
80
81#[derive(Clone, Debug, Serialize)]
82pub struct PageReport {
83    pub number: usize,
84    pub svg: String,
85    pub width_points: f64,
86    pub height_points: f64,
87    pub node_count: usize,
88    pub warning_count: usize,
89    pub warnings: Vec<String>,
90    pub estimated_ir_bytes: usize,
91}
92
93#[derive(Clone, Debug, Serialize)]
94pub struct ConversionReport {
95    pub converter: &'static str,
96    pub version: &'static str,
97    pub source: String,
98    pub source_format: SourceFormat,
99    pub output_directory: String,
100    pub elapsed_ms: u128,
101    pub input_bytes: u64,
102    pub page_count: usize,
103    pub largest_page_ir_bytes: usize,
104    pub pages: Vec<PageReport>,
105    pub warnings: Vec<String>,
106}
107
108pub fn convert_path(
109    input: impl AsRef<Path>,
110    output_directory: impl AsRef<Path>,
111    options: &ConvertOptions,
112) -> Result<ConversionReport> {
113    let started = Instant::now();
114    let input = input.as_ref();
115    let output_directory = output_directory.as_ref();
116    let metadata = fs::metadata(input)?;
117    if !metadata.is_file() {
118        return Err(Error::InvalidInput(format!(
119            "{} is not a regular file",
120            input.display()
121        )));
122    }
123    if metadata.len() > options.max_input_bytes {
124        return Err(Error::LimitExceeded(format!(
125            "input is {} bytes; maximum is {} bytes",
126            metadata.len(),
127            options.max_input_bytes
128        )));
129    }
130    if options.jobs == 0 {
131        return Err(Error::InvalidInput("jobs must be at least 1".into()));
132    }
133    fs::create_dir_all(output_directory)?;
134    if fs::read_dir(output_directory)?.next().is_some() {
135        return Err(Error::InvalidInput("output directory must be empty".into()));
136    }
137    let format = SourceFormat::detect(input)?;
138    let mut sink = PageSink::new(output_directory, options);
139    let warnings = match format {
140        SourceFormat::Pdf => crate::pdf::convert(input, options, &mut sink)?,
141        SourceFormat::Pptx => crate::ooxml::pptx::convert(input, options, &mut sink)?,
142        SourceFormat::Xlsx => crate::ooxml::xlsx::convert(input, options, &mut sink)?,
143        SourceFormat::Docx => crate::ooxml::docx::convert(input, options, &mut sink)?,
144    };
145    let pages = sink.finish()?;
146    let largest_page_ir_bytes = pages
147        .iter()
148        .map(|page| page.estimated_ir_bytes)
149        .max()
150        .unwrap_or(0);
151    let report = ConversionReport {
152        converter: "document-svg",
153        version: env!("CARGO_PKG_VERSION"),
154        source: input.to_string_lossy().into_owned(),
155        source_format: format,
156        output_directory: output_directory.to_string_lossy().into_owned(),
157        elapsed_ms: started.elapsed().as_millis(),
158        input_bytes: metadata.len(),
159        page_count: pages.len(),
160        largest_page_ir_bytes,
161        pages,
162        warnings,
163    };
164    let manifest_path = output_directory.join("conversion.json");
165    let mut temporary = tempfile::NamedTempFile::new_in(output_directory)?;
166    {
167        let mut writer = BufWriter::new(temporary.as_file_mut());
168        serde_json::to_writer_pretty(&mut writer, &report)?;
169        writer.write_all(b"\n")?;
170        writer.flush()?;
171    }
172    temporary
173        .persist_noclobber(manifest_path)
174        .map_err(|error| error.error)?;
175    Ok(report)
176}
177
178pub(crate) trait PageConsumer {
179    fn consume(&mut self, page: Page) -> Result<()>;
180}
181
182pub(crate) struct PageSink<'a> {
183    output_directory: &'a Path,
184    svg_options: SvgOptions,
185    reports: Vec<PageReport>,
186    max_pages: usize,
187}
188
189impl<'a> PageSink<'a> {
190    fn new(output_directory: &'a Path, options: &ConvertOptions) -> Self {
191        Self {
192            output_directory,
193            svg_options: SvgOptions {
194                include_metadata: options.include_metadata,
195                precision: options.precision,
196            },
197            reports: Vec::new(),
198            max_pages: options.max_pages,
199        }
200    }
201
202    fn finish(self) -> Result<Vec<PageReport>> {
203        if self.reports.is_empty() {
204            return Err(Error::InvalidInput(
205                "input contains no renderable pages".into(),
206            ));
207        }
208        Ok(self.reports)
209    }
210}
211
212impl PageConsumer for PageSink<'_> {
213    fn consume(&mut self, page: Page) -> Result<()> {
214        if self.reports.len() >= self.max_pages {
215            return Err(Error::LimitExceeded(format!(
216                "page count exceeds {}",
217                self.max_pages
218            )));
219        }
220        let file_name = format!("page-{:04}.svg", page.number);
221        let final_path = self.output_directory.join(&file_name);
222        let mut temporary = tempfile::NamedTempFile::new_in(self.output_directory)?;
223        {
224            let mut writer = BufWriter::with_capacity(64 * 1024, temporary.as_file_mut());
225            write_page(&page, &mut writer, self.svg_options)?;
226            writer.flush()?;
227        }
228        temporary
229            .persist_noclobber(&final_path)
230            .map_err(|error| error.error)?;
231        let estimated_ir_bytes = estimate_page_bytes(&page);
232        self.reports.push(PageReport {
233            number: page.number,
234            svg: file_name,
235            width_points: page.width,
236            height_points: page.height,
237            node_count: page.nodes.len(),
238            warning_count: page.warnings.len(),
239            warnings: page.warnings,
240            estimated_ir_bytes,
241        });
242        Ok(())
243    }
244}
245
246fn estimate_page_bytes(page: &Page) -> usize {
247    // Keep the existing serialized-size metric without allocating a second
248    // copy of image data and paths for every page.
249    let mut counter = ByteCounter::default();
250    serde_json::to_writer(&mut counter, page).map_or(0, |()| counter.0)
251}
252
253#[derive(Default)]
254struct ByteCounter(usize);
255
256impl Write for ByteCounter {
257    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
258        self.0 = self
259            .0
260            .checked_add(bytes.len())
261            .ok_or_else(|| std::io::Error::other("serialized page size overflow"))?;
262        Ok(bytes.len())
263    }
264
265    fn flush(&mut self) -> std::io::Result<()> {
266        Ok(())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn page_size_counter_matches_serialized_bytes_without_retaining_json() {
276        let mut page = Page::new(1, 612.0, 792.0, "test");
277        page.title = "日本語 / 中文 / quotes: \" \\ \n".into();
278        page.description = "large path or image data".repeat(100_000);
279        page.warnings.push("control: \t\r".into());
280        assert_eq!(
281            estimate_page_bytes(&page),
282            serde_json::to_vec(&page).unwrap().len()
283        );
284    }
285}