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 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 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}