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 bounded loss.
224    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    /// Rejects a non-empty report under strict fidelity.
240    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
309/// Explicit resources available during export.
310pub struct ExportContext<'a> {
311    /// Resource limits.
312    pub limits: &'a ResourceLimits,
313    /// Explicit font registry.
314    pub fonts: &'a FontManager,
315    /// Optional explicit asset resolver.
316    pub assets: Option<&'a dyn AssetResolver>,
317}
318
319/// Completed export metadata.
320#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
321pub struct ExportOutcome {
322    /// Bytes written to the caller's writer.
323    pub bytes_written: usize,
324    /// Explicit loss report.
325    pub loss_report: ExportLossReport,
326    /// Declared exporter capabilities.
327    pub capabilities: BTreeSet<ExportCapabilities>,
328}
329
330/// Dispatches to the exact requested exporter.
331pub 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
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    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
410/// Exports with cancellation checks and progress at page/element boundaries.
411pub 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}