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 mut message = message.into();
231 message.truncate(512);
232 self.losses.push(ExportLoss {
233 kind,
234 element: element.map(ToOwned::to_owned),
235 message,
236 });
237 }
238
239 pub fn enforce(&self, fidelity: Fidelity) -> Result<()> {
241 if fidelity == Fidelity::Strict && !self.losses.is_empty() {
242 let first = &self.losses[0];
243 return Err(FileMakerError::new(
244 ErrorCode::ExportUnsupported,
245 format!("strict export rejected {:?}: {}", first.kind, first.message),
246 ));
247 }
248 Ok(())
249 }
250}
251
252pub(super) fn record_text_capability_losses(
253 element: &crate::ResolvedElement,
254 losses: &mut ExportLossReport,
255) {
256 for diagnostic in text_layouts(element)
257 .into_iter()
258 .flat_map(|layout| &layout.diagnostics)
259 {
260 let message = match diagnostic {
261 crate::TextDiagnostic::VerticalWritingUnavailable => {
262 Some("vertical writing is a prepared capability")
263 }
264 crate::TextDiagnostic::ColorEmojiRequiresExporter => {
265 Some("color emoji requires an exporter-specific implementation")
266 }
267 crate::TextDiagnostic::Clipped
268 | crate::TextDiagnostic::Ellipsized
269 | crate::TextDiagnostic::Shrunk => None,
270 };
271 if let Some(message) = message {
272 losses.push(
273 ExportLossKind::TextCapabilityUnsupported,
274 Some(element.id.as_str()),
275 message,
276 );
277 }
278 }
279}
280
281pub(super) fn text_layouts(element: &crate::ResolvedElement) -> Vec<&crate::TextLayout> {
282 let mut layouts = Vec::new();
283 if let Some(layout) = &element.text_layout {
284 layouts.push(layout);
285 }
286 if let Some(table) = &element.table {
287 layouts.extend(table.header.iter().map(|cell| &cell.text_layout));
288 layouts.extend(
289 table
290 .rows
291 .iter()
292 .flat_map(|row| &row.cells)
293 .map(|cell| &cell.text_layout),
294 );
295 layouts.extend(table.totals.iter().map(|cell| &cell.text_layout));
296 }
297 layouts
298}
299
300pub(super) fn text_fonts(element: &crate::ResolvedElement) -> Vec<&str> {
301 text_layouts(element)
302 .into_iter()
303 .flat_map(|layout| &layout.lines)
304 .flat_map(|line| &line.runs)
305 .map(|run| run.font.as_str())
306 .collect()
307}
308
309pub struct ExportContext<'a> {
311 pub limits: &'a ResourceLimits,
313 pub fonts: &'a FontManager,
315 pub assets: Option<&'a dyn AssetResolver>,
317}
318
319#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
321pub struct ExportOutcome {
322 pub bytes_written: usize,
324 pub loss_report: ExportLossReport,
326 pub capabilities: BTreeSet<ExportCapabilities>,
328}
329
330pub fn export(
332 scene: &ResolvedScene,
333 request: &ExportRequest,
334 context: &ExportContext<'_>,
335 writer: &mut dyn Write,
336) -> Result<ExportOutcome> {
337 export_with_control(scene, request, context, None, writer)
338}
339
340fn export_with_control(
341 scene: &ResolvedScene,
342 request: &ExportRequest,
343 context: &ExportContext<'_>,
344 control: Option<&OperationControl>,
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, writer)?;
353 progress.finish()?;
354 return Ok(outcome);
355 }
356 let outcome = export_prepared(scene, request, context, &mut progress, 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 writer: &mut dyn Write,
378) -> Result<ExportOutcome> {
379 match request.format {
380 ExportFormat::Svg => super::svg::export(scene, request, context, progress, writer),
381 ExportFormat::Png | ExportFormat::Jpeg => {
382 super::raster::export(scene, request, context, progress, writer)
383 }
384 ExportFormat::Html => super::html::export(scene, request, context, progress, writer),
385 ExportFormat::Pdf => super::pdf::export(scene, request, context, progress, writer),
386 }
387}
388
389fn apply_export_style(scene: &mut ResolvedScene, style: ExportStyleOverride) {
390 for page in &mut scene.pages {
391 for element in &mut page.elements {
392 style.apply(&mut element.style);
393 if let Some(table) = &mut element.table {
394 for cell in table
395 .header
396 .iter_mut()
397 .chain(table.rows.iter_mut().flat_map(|row| {
398 style.apply(&mut row.style);
399 row.cells.iter_mut()
400 }))
401 .chain(table.totals.iter_mut())
402 {
403 style.apply(&mut cell.style);
404 }
405 }
406 }
407 }
408}
409
410pub fn export_controlled(
412 scene: &ResolvedScene,
413 request: &ExportRequest,
414 context: &ExportContext<'_>,
415 control: &OperationControl,
416 writer: &mut dyn Write,
417) -> Result<ExportOutcome> {
418 export_with_control(scene, request, context, Some(control), writer)
419}
420
421pub(super) fn selected_pages<'a>(
422 scene: &'a ResolvedScene,
423 request: &ExportRequest,
424) -> Result<Vec<&'a crate::ResolvedPage>> {
425 if let Some(index) = request.page {
426 return scene
427 .pages
428 .get(index)
429 .map(|page| vec![page])
430 .ok_or_else(|| {
431 FileMakerError::new(ErrorCode::ExportUnsupported, "requested page was not found")
432 });
433 }
434 Ok(scene.pages.iter().collect())
435}
436
437pub(crate) fn validate_request(
438 scene: &ResolvedScene,
439 request: &ExportRequest,
440 limits: &ResourceLimits,
441) -> Result<()> {
442 crate::resolved::validate_scene_contract(scene, limits)?;
443 if let Some(style) = request.style_override {
444 style.validate()?;
445 }
446 let invalid_dpi = matches!(request.format, ExportFormat::Png | ExportFormat::Jpeg)
447 && (request.dpi == 0 || request.dpi > 9_600);
448 let invalid_quality = request.format == ExportFormat::Jpeg
449 && (request.jpeg_quality == 0 || request.jpeg_quality > 100);
450 if scene.pages.is_empty() || invalid_dpi || invalid_quality {
451 return Err(FileMakerError::new(
452 ErrorCode::ExportUnsupported,
453 "export request or scene is invalid",
454 ));
455 }
456 Ok(())
457}