use std::collections::BTreeSet;
use std::io::Write;
use serde::{Deserialize, Serialize};
use super::progress::ExportProgress;
use crate::{
AssetResolver, Color, ComputedStyle, ErrorCode, FileMakerError, FontManager, OperationControl,
ResolvedScene, ResourceLimits, Result,
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportFormat {
Pdf,
Svg,
Png,
Jpeg,
Html,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Fidelity {
#[default]
Strict,
BestEffort,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PdfMode {
#[default]
Editable,
Flattened,
Hybrid,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HtmlMode {
#[default]
Semantic,
Fixed,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExportStyleOverride {
pub fill: Option<Color>,
pub stroke: Option<Color>,
pub opacity: Option<u32>,
pub color: Option<Color>,
}
impl ExportStyleOverride {
fn validate(self) -> Result<()> {
for color in [self.fill, self.stroke, self.color].into_iter().flatten() {
color.validate()?;
}
if self.opacity.is_some_and(|opacity| opacity > 1_000_000) {
return Err(FileMakerError::new(
ErrorCode::ExportUnsupported,
"export style opacity must be at most 1000000",
));
}
Ok(())
}
fn apply(self, style: &mut ComputedStyle) {
if self.fill.is_some() {
style.fill = self.fill;
}
if self.stroke.is_some() {
style.stroke = self.stroke;
}
if let Some(opacity) = self.opacity {
style.opacity = opacity;
}
if let Some(color) = self.color {
style.color = color;
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ExportRequest {
pub format: ExportFormat,
pub fidelity: Fidelity,
pub page: Option<usize>,
pub dpi: u32,
pub jpeg_quality: u8,
pub pdf_mode: PdfMode,
pub html_mode: HtmlMode,
#[serde(default)]
pub style_override: Option<ExportStyleOverride>,
}
impl Default for ExportRequest {
fn default() -> Self {
Self {
format: ExportFormat::Svg,
fidelity: Fidelity::Strict,
page: None,
dpi: 144,
jpeg_quality: 90,
pdf_mode: PdfMode::Editable,
html_mode: HtmlMode::Semantic,
style_override: None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportCapabilities {
MultiPage,
EditableText,
EmbeddedFonts,
Vector,
Raster,
Transparency,
Cmyk,
Images,
Semantic,
Metadata,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportLossKind {
AdditionalPagesOmitted,
CmykConvertedToRgb,
TransparencyFlattened,
SemanticsFlattened,
TextFlattened,
UnsupportedElement,
ImageOmitted,
TextCapabilityUnsupported,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ExportLoss {
pub kind: ExportLossKind,
pub element: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ExportLossReport {
pub losses: Vec<ExportLoss>,
}
impl ExportLossReport {
pub fn push(
&mut self,
kind: ExportLossKind,
element: Option<&str>,
message: impl Into<String>,
) {
let mut message = message.into();
message.truncate(512);
self.losses.push(ExportLoss {
kind,
element: element.map(ToOwned::to_owned),
message,
});
}
pub fn enforce(&self, fidelity: Fidelity) -> Result<()> {
if fidelity == Fidelity::Strict && !self.losses.is_empty() {
let first = &self.losses[0];
return Err(FileMakerError::new(
ErrorCode::ExportUnsupported,
format!("strict export rejected {:?}: {}", first.kind, first.message),
));
}
Ok(())
}
}
pub(super) fn record_text_capability_losses(
element: &crate::ResolvedElement,
losses: &mut ExportLossReport,
) {
for diagnostic in text_layouts(element)
.into_iter()
.flat_map(|layout| &layout.diagnostics)
{
let message = match diagnostic {
crate::TextDiagnostic::VerticalWritingUnavailable => {
Some("vertical writing is a prepared capability")
}
crate::TextDiagnostic::ColorEmojiRequiresExporter => {
Some("color emoji requires an exporter-specific implementation")
}
crate::TextDiagnostic::Clipped
| crate::TextDiagnostic::Ellipsized
| crate::TextDiagnostic::Shrunk => None,
};
if let Some(message) = message {
losses.push(
ExportLossKind::TextCapabilityUnsupported,
Some(element.id.as_str()),
message,
);
}
}
}
pub(super) fn text_layouts(element: &crate::ResolvedElement) -> Vec<&crate::TextLayout> {
let mut layouts = Vec::new();
if let Some(layout) = &element.text_layout {
layouts.push(layout);
}
if let Some(table) = &element.table {
layouts.extend(table.header.iter().map(|cell| &cell.text_layout));
layouts.extend(
table
.rows
.iter()
.flat_map(|row| &row.cells)
.map(|cell| &cell.text_layout),
);
layouts.extend(table.totals.iter().map(|cell| &cell.text_layout));
}
layouts
}
pub(super) fn text_fonts(element: &crate::ResolvedElement) -> Vec<&str> {
text_layouts(element)
.into_iter()
.flat_map(|layout| &layout.lines)
.flat_map(|line| &line.runs)
.map(|run| run.font.as_str())
.collect()
}
pub struct ExportContext<'a> {
pub limits: &'a ResourceLimits,
pub fonts: &'a FontManager,
pub assets: Option<&'a dyn AssetResolver>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ExportOutcome {
pub bytes_written: usize,
pub loss_report: ExportLossReport,
pub capabilities: BTreeSet<ExportCapabilities>,
}
pub fn export(
scene: &ResolvedScene,
request: &ExportRequest,
context: &ExportContext<'_>,
writer: &mut dyn Write,
) -> Result<ExportOutcome> {
export_with_control(scene, request, context, None, writer)
}
fn export_with_control(
scene: &ResolvedScene,
request: &ExportRequest,
context: &ExportContext<'_>,
control: Option<&OperationControl>,
writer: &mut dyn Write,
) -> Result<ExportOutcome> {
validate_request(scene, request, context.limits)?;
let mut progress = ExportProgress::new(scene, request, control)?;
if let Some(style) = request.style_override {
let mut scene = scene.clone();
apply_export_style(&mut scene, style);
let outcome = export_prepared(&scene, request, context, &mut progress, writer)?;
progress.finish()?;
return Ok(outcome);
}
let outcome = export_prepared(scene, request, context, &mut progress, writer)?;
progress.finish()?;
Ok(outcome)
}
pub fn export_bytes(
scene: &ResolvedScene,
request: &ExportRequest,
context: &ExportContext<'_>,
) -> Result<(Vec<u8>, ExportOutcome)> {
let mut bytes = Vec::new();
let outcome = export(scene, request, context, &mut bytes)?;
Ok((bytes, outcome))
}
fn export_prepared(
scene: &ResolvedScene,
request: &ExportRequest,
context: &ExportContext<'_>,
progress: &mut ExportProgress<'_>,
writer: &mut dyn Write,
) -> Result<ExportOutcome> {
match request.format {
ExportFormat::Svg => super::svg::export(scene, request, context, progress, writer),
ExportFormat::Png | ExportFormat::Jpeg => {
super::raster::export(scene, request, context, progress, writer)
}
ExportFormat::Html => super::html::export(scene, request, context, progress, writer),
ExportFormat::Pdf => super::pdf::export(scene, request, context, progress, writer),
}
}
fn apply_export_style(scene: &mut ResolvedScene, style: ExportStyleOverride) {
for page in &mut scene.pages {
for element in &mut page.elements {
style.apply(&mut element.style);
if let Some(table) = &mut element.table {
for cell in table
.header
.iter_mut()
.chain(table.rows.iter_mut().flat_map(|row| {
style.apply(&mut row.style);
row.cells.iter_mut()
}))
.chain(table.totals.iter_mut())
{
style.apply(&mut cell.style);
}
}
}
}
}
pub fn export_controlled(
scene: &ResolvedScene,
request: &ExportRequest,
context: &ExportContext<'_>,
control: &OperationControl,
writer: &mut dyn Write,
) -> Result<ExportOutcome> {
export_with_control(scene, request, context, Some(control), writer)
}
pub(super) fn selected_pages<'a>(
scene: &'a ResolvedScene,
request: &ExportRequest,
) -> Result<Vec<&'a crate::ResolvedPage>> {
if let Some(index) = request.page {
return scene
.pages
.get(index)
.map(|page| vec![page])
.ok_or_else(|| {
FileMakerError::new(ErrorCode::ExportUnsupported, "requested page was not found")
});
}
Ok(scene.pages.iter().collect())
}
pub(crate) fn validate_request(
scene: &ResolvedScene,
request: &ExportRequest,
limits: &ResourceLimits,
) -> Result<()> {
crate::resolved::validate_scene_contract(scene, limits)?;
if let Some(style) = request.style_override {
style.validate()?;
}
let invalid_dpi = matches!(request.format, ExportFormat::Png | ExportFormat::Jpeg)
&& (request.dpi == 0 || request.dpi > 9_600);
let invalid_quality = request.format == ExportFormat::Jpeg
&& (request.jpeg_quality == 0 || request.jpeg_quality > 100);
if scene.pages.is_empty() || invalid_dpi || invalid_quality {
return Err(FileMakerError::new(
ErrorCode::ExportUnsupported,
"export request or scene is invalid",
));
}
Ok(())
}