1use std::collections::BTreeSet;
14use std::io::Write;
15
16use serde::{Deserialize, Serialize};
17
18use super::progress::ExportProgress;
19use crate::{
20 AssetResolver, Color, ComputedStyle, ErrorCode, FileMakerError, FontManager, OperationControl,
21 ResolvedScene, ResourceLimits, Result,
22};
23
24#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ExportFormat {
28 Pdf,
30 Svg,
32 Png,
34 Jpeg,
36 Html,
38}
39
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Fidelity {
44 #[default]
46 Strict,
47 BestEffort,
49}
50
51#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum PdfMode {
55 #[default]
57 Editable,
58 Flattened,
60 Hybrid,
62}
63
64#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum HtmlMode {
68 #[default]
70 Semantic,
71 Fixed,
73}
74
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(default, deny_unknown_fields)]
78pub struct ExportStyleOverride {
79 pub fill: Option<Color>,
81 pub stroke: Option<Color>,
83 pub opacity: Option<u32>,
85 pub color: Option<Color>,
87}
88
89impl ExportStyleOverride {
90 fn validate(self) -> Result<()> {
91 for color in [self.fill, self.stroke, self.color].into_iter().flatten() {
92 color.validate()?;
93 }
94 if self.opacity.is_some_and(|opacity| opacity > 1_000_000) {
95 return Err(FileMakerError::new(
96 ErrorCode::ExportUnsupported,
97 "export style opacity must be at most 1000000",
98 ));
99 }
100 Ok(())
101 }
102
103 fn apply(self, style: &mut ComputedStyle) {
104 if self.fill.is_some() {
105 style.fill = self.fill;
106 }
107 if self.stroke.is_some() {
108 style.stroke = self.stroke;
109 }
110 if let Some(opacity) = self.opacity {
111 style.opacity = opacity;
112 }
113 if let Some(color) = self.color {
114 style.color = color;
115 }
116 }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct ExportRequest {
122 pub format: ExportFormat,
124 pub fidelity: Fidelity,
126 pub page: Option<usize>,
128 pub dpi: u32,
130 pub jpeg_quality: u8,
132 pub pdf_mode: PdfMode,
134 pub html_mode: HtmlMode,
136 #[serde(default)]
138 pub style_override: Option<ExportStyleOverride>,
139}
140
141impl Default for ExportRequest {
142 fn default() -> Self {
143 Self {
144 format: ExportFormat::Svg,
145 fidelity: Fidelity::Strict,
146 page: None,
147 dpi: 144,
148 jpeg_quality: 90,
149 pdf_mode: PdfMode::Editable,
150 html_mode: HtmlMode::Semantic,
151 style_override: None,
152 }
153 }
154}
155
156#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ExportCapabilities {
160 MultiPage,
162 EditableText,
164 EmbeddedFonts,
166 Vector,
168 Raster,
170 Transparency,
172 Cmyk,
174 Images,
176 Semantic,
178 Metadata,
180}
181
182#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum ExportLossKind {
186 AdditionalPagesOmitted,
188 CmykConvertedToRgb,
190 TransparencyFlattened,
192 SemanticsFlattened,
194 TextFlattened,
196 UnsupportedElement,
198 ImageOmitted,
200 TextCapabilityUnsupported,
202}
203
204#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
206pub struct ExportLoss {
207 pub kind: ExportLossKind,
209 pub element: Option<String>,
211 pub message: String,
213}
214
215#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
217pub struct ExportLossReport {
218 pub losses: Vec<ExportLoss>,
220}
221
222impl ExportLossReport {
223 pub fn push(
225 &mut self,
226 kind: ExportLossKind,
227 element: Option<&str>,
228 message: impl Into<String>,
229 ) {
230 let message = crate::error::bounded_diagnostic(message.into(), 512);
231 self.losses.push(ExportLoss {
232 kind,
233 element: element.map(ToOwned::to_owned),
234 message,
235 });
236 }
237
238 pub fn enforce(&self, fidelity: Fidelity) -> Result<()> {
240 if fidelity == Fidelity::Strict && !self.losses.is_empty() {
241 let first = &self.losses[0];
242 return Err(FileMakerError::new(
243 ErrorCode::ExportUnsupported,
244 format!("strict export rejected {:?}: {}", first.kind, first.message),
245 ));
246 }
247 Ok(())
248 }
249}
250
251pub(super) fn record_text_capability_losses(
252 element: &crate::ResolvedElement,
253 losses: &mut ExportLossReport,
254) {
255 for diagnostic in text_layouts(element)
256 .into_iter()
257 .flat_map(|layout| &layout.diagnostics)
258 {
259 let message = match diagnostic {
260 crate::TextDiagnostic::VerticalWritingUnavailable => {
261 Some("vertical writing is a prepared capability")
262 }
263 crate::TextDiagnostic::ColorEmojiRequiresExporter => {
264 Some("color emoji requires an exporter-specific implementation")
265 }
266 crate::TextDiagnostic::Clipped
267 | crate::TextDiagnostic::Ellipsized
268 | crate::TextDiagnostic::Shrunk => None,
269 };
270 if let Some(message) = message {
271 losses.push(
272 ExportLossKind::TextCapabilityUnsupported,
273 Some(element.id.as_str()),
274 message,
275 );
276 }
277 }
278}
279
280pub(super) fn text_layouts(element: &crate::ResolvedElement) -> Vec<&crate::TextLayout> {
281 let mut layouts = Vec::new();
282 if let Some(layout) = &element.text_layout {
283 layouts.push(layout);
284 }
285 if let Some(table) = &element.table {
286 layouts.extend(table.header.iter().map(|cell| &cell.text_layout));
287 layouts.extend(
288 table
289 .rows
290 .iter()
291 .flat_map(|row| &row.cells)
292 .map(|cell| &cell.text_layout),
293 );
294 layouts.extend(table.totals.iter().map(|cell| &cell.text_layout));
295 }
296 layouts
297}
298
299pub(super) fn text_fonts(element: &crate::ResolvedElement) -> Vec<&str> {
300 text_layouts(element)
301 .into_iter()
302 .flat_map(|layout| &layout.lines)
303 .flat_map(|line| &line.runs)
304 .map(|run| run.font.as_str())
305 .collect()
306}
307
308pub struct ExportContext<'a> {
310 pub limits: &'a ResourceLimits,
312 pub fonts: &'a FontManager,
314 pub assets: Option<&'a dyn AssetResolver>,
316}
317
318#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
320pub struct ExportOutcome {
321 pub bytes_written: usize,
323 pub loss_report: ExportLossReport,
325 pub capabilities: BTreeSet<ExportCapabilities>,
327}
328
329pub fn export(
331 scene: &ResolvedScene,
332 request: &ExportRequest,
333 context: &ExportContext<'_>,
334 writer: &mut dyn Write,
335) -> Result<ExportOutcome> {
336 export_with_control(scene, request, context, None, None, writer)
337}
338
339pub(super) fn export_with_control(
340 scene: &ResolvedScene,
341 request: &ExportRequest,
342 context: &ExportContext<'_>,
343 control: Option<&OperationControl>,
344 raster: Option<super::RasterOptions>,
345 writer: &mut dyn Write,
346) -> Result<ExportOutcome> {
347 validate_request(scene, request, context.limits)?;
348 let mut progress = ExportProgress::new(scene, request, control)?;
349 if let Some(style) = request.style_override {
350 let mut scene = scene.clone();
351 apply_export_style(&mut scene, style);
352 let outcome = export_prepared(&scene, request, context, &mut progress, raster, writer)?;
353 progress.finish()?;
354 return Ok(outcome);
355 }
356 let outcome = export_prepared(scene, request, context, &mut progress, raster, writer)?;
357 progress.finish()?;
358 Ok(outcome)
359}
360
361pub fn export_bytes(
363 scene: &ResolvedScene,
364 request: &ExportRequest,
365 context: &ExportContext<'_>,
366) -> Result<(Vec<u8>, ExportOutcome)> {
367 let mut bytes = Vec::new();
368 let outcome = export(scene, request, context, &mut bytes)?;
369 Ok((bytes, outcome))
370}
371
372fn export_prepared(
373 scene: &ResolvedScene,
374 request: &ExportRequest,
375 context: &ExportContext<'_>,
376 progress: &mut ExportProgress<'_>,
377 raster: Option<super::RasterOptions>,
378 writer: &mut dyn Write,
379) -> Result<ExportOutcome> {
380 match request.format {
381 ExportFormat::Svg => super::svg::export(scene, request, context, progress, writer),
382 ExportFormat::Png | ExportFormat::Jpeg => super::raster::export(
383 scene,
384 request,
385 context,
386 progress,
387 raster.unwrap_or_default(),
388 writer,
389 ),
390 ExportFormat::Html => super::html::export(scene, request, context, progress, writer),
391 ExportFormat::Pdf => super::pdf::export(scene, request, context, progress, writer),
392 }
393}
394
395fn apply_export_style(scene: &mut ResolvedScene, style: ExportStyleOverride) {
396 for page in &mut scene.pages {
397 for element in &mut page.elements {
398 style.apply(&mut element.style);
399 if let Some(table) = &mut element.table {
400 for cell in table
401 .header
402 .iter_mut()
403 .chain(table.rows.iter_mut().flat_map(|row| {
404 style.apply(&mut row.style);
405 row.cells.iter_mut()
406 }))
407 .chain(table.totals.iter_mut())
408 {
409 style.apply(&mut cell.style);
410 }
411 }
412 }
413 }
414}
415
416pub fn export_controlled(
418 scene: &ResolvedScene,
419 request: &ExportRequest,
420 context: &ExportContext<'_>,
421 control: &OperationControl,
422 writer: &mut dyn Write,
423) -> Result<ExportOutcome> {
424 export_with_control(scene, request, context, Some(control), None, writer)
425}
426
427pub(super) fn selected_pages<'a>(
428 scene: &'a ResolvedScene,
429 request: &ExportRequest,
430) -> Result<Vec<&'a crate::ResolvedPage>> {
431 if let Some(index) = request.page {
432 return scene
433 .pages
434 .get(index)
435 .map(|page| vec![page])
436 .ok_or_else(|| {
437 FileMakerError::new(ErrorCode::ExportUnsupported, "requested page was not found")
438 });
439 }
440 Ok(scene.pages.iter().collect())
441}
442
443pub(crate) fn validate_request(
444 scene: &ResolvedScene,
445 request: &ExportRequest,
446 limits: &ResourceLimits,
447) -> Result<()> {
448 crate::resolved::validate_scene_contract(scene, limits)?;
449 if let Some(style) = request.style_override {
450 style.validate()?;
451 }
452 let invalid_dpi = matches!(request.format, ExportFormat::Png | ExportFormat::Jpeg)
453 && (request.dpi == 0 || request.dpi > 9_600);
454 let invalid_quality = request.format == ExportFormat::Jpeg
455 && (request.jpeg_quality == 0 || request.jpeg_quality > 100);
456 if scene.pages.is_empty() || invalid_dpi || invalid_quality {
457 return Err(FileMakerError::new(
458 ErrorCode::ExportUnsupported,
459 "export request or scene is invalid",
460 ));
461 }
462 Ok(())
463}