Skip to main content

appcore_filemaker/export/
core.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: core.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded core contracts and behavior for this crate.
12
13use 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/// Output selected by the export call, never by YAML.
25#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ExportFormat {
28    /// Editable, flattened, or hybrid PDF.
29    Pdf,
30    /// Vector SVG.
31    Svg,
32    /// Lossless raster PNG.
33    Png,
34    /// Lossy raster JPEG.
35    Jpeg,
36    /// Semantic or fixed HTML.
37    Html,
38}
39
40/// Caller-selected fidelity behavior.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Fidelity {
44    /// Reject every unsupported/lossy conversion.
45    #[default]
46    Strict,
47    /// Continue only after recording each loss.
48    BestEffort,
49}
50
51/// PDF text/graphics mode.
52#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum PdfMode {
55    /// Retain editable text with subsetted embedded fonts.
56    #[default]
57    Editable,
58    /// Convert text to vector outlines.
59    Flattened,
60    /// Draw vector outlines and add an invisible searchable text layer.
61    Hybrid,
62}
63
64/// HTML structure mode.
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum HtmlMode {
68    /// Prefer meaningful HTML elements and reading order.
69    #[default]
70    Semantic,
71    /// Reproduce resolved page geometry with absolute positioning.
72    Fixed,
73}
74
75/// Paint-only export layer applied after layout without changing geometry.
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(default, deny_unknown_fields)]
78pub struct ExportStyleOverride {
79    /// Replacement fill for every resolved element and table cell.
80    pub fill: Option<Color>,
81    /// Replacement stroke for every resolved element and table cell.
82    pub stroke: Option<Color>,
83    /// Replacement opacity in millionths.
84    pub opacity: Option<u32>,
85    /// Replacement text foreground.
86    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/// One complete export request.
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct ExportRequest {
122    /// Output format.
123    pub format: ExportFormat,
124    /// Strict or reported-loss behavior.
125    pub fidelity: Fidelity,
126    /// Optional zero-based page; absent exports all pages where supported.
127    pub page: Option<usize>,
128    /// Raster DPI, ignored by vector exporters.
129    pub dpi: u32,
130    /// JPEG quality from 1 through 100.
131    pub jpeg_quality: u8,
132    /// PDF mode.
133    pub pdf_mode: PdfMode,
134    /// HTML mode.
135    pub html_mode: HtmlMode,
136    /// Optional final paint layer; geometry-affecting style is intentionally absent.
137    #[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/// Exporter feature declaration.
157#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ExportCapabilities {
160    /// Multiple pages in one output.
161    MultiPage,
162    /// Editable text.
163    EditableText,
164    /// Embedded fonts.
165    EmbeddedFonts,
166    /// Vector geometry.
167    Vector,
168    /// Raster geometry.
169    Raster,
170    /// Alpha transparency.
171    Transparency,
172    /// Native CMYK.
173    Cmyk,
174    /// Embedded raster/vector images.
175    Images,
176    /// Semantic reading structure.
177    Semantic,
178    /// Deterministic document metadata.
179    Metadata,
180}
181
182/// Stable reason a feature could not be preserved exactly.
183#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum ExportLossKind {
186    /// Only one page could be represented.
187    AdditionalPagesOmitted,
188    /// CMYK was converted to RGB.
189    CmykConvertedToRgb,
190    /// Alpha was composited or removed.
191    TransparencyFlattened,
192    /// Semantic structure was replaced with fixed geometry.
193    SemanticsFlattened,
194    /// Text was converted to outlines/pixels.
195    TextFlattened,
196    /// An element kind is only prepared, not renderable.
197    UnsupportedElement,
198    /// Image could not be represented.
199    ImageOmitted,
200    /// A prepared text capability could not be represented.
201    TextCapabilityUnsupported,
202}
203
204/// One bounded loss item.
205#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
206pub struct ExportLoss {
207    /// Loss kind.
208    pub kind: ExportLossKind,
209    /// Optional element ID.
210    pub element: Option<String>,
211    /// Bounded explanation.
212    pub message: String,
213}
214
215/// First-class accumulated loss report.
216#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
217pub struct ExportLossReport {
218    /// Losses in deterministic discovery order.
219    pub losses: Vec<ExportLoss>,
220}
221
222impl ExportLossReport {
223    /// Records a loss with at most 512 UTF-8 bytes of complete-character message text.
224    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    /// Rejects a non-empty report under strict fidelity.
239    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
308/// Explicit resources available during export.
309pub struct ExportContext<'a> {
310    /// Resource limits.
311    pub limits: &'a ResourceLimits,
312    /// Explicit font registry.
313    pub fonts: &'a FontManager,
314    /// Optional explicit asset resolver.
315    pub assets: Option<&'a dyn AssetResolver>,
316}
317
318/// Completed export metadata.
319#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
320pub struct ExportOutcome {
321    /// Bytes written to the caller's writer.
322    pub bytes_written: usize,
323    /// Explicit loss report.
324    pub loss_report: ExportLossReport,
325    /// Declared exporter capabilities.
326    pub capabilities: BTreeSet<ExportCapabilities>,
327}
328
329/// Dispatches to the exact requested exporter.
330pub 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
361/// Exports into a bounded in-memory byte vector.
362pub 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
416/// Exports with cancellation checks and progress at page/element boundaries.
417pub 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}