duc2pdf 4.0.2

A library to convert DUC files to PDF format.
Documentation
use std::collections::HashMap;
use wasm_bindgen::prelude::*;

pub mod builder;

// Initialize logger for WASM
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(start)]
pub fn init_logger() {
    console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger");
}
pub mod scaling;
pub mod streaming;
pub mod utils;

mod error_handling;

// Coordinate system constants
pub const MAX_COORDINATE_MM: f64 = 4_800.0; // Safe maximum coordinate in mm
pub const MIN_PRECISION_MM: f64 = 50.0; // Minimum precision in mm
pub const PDF_USER_UNIT: f32 = 72.0 / 25.4; // Convert mm to PDF units (1 inch = 25.4mm = 72 points)

fn normalize_background_color(color: Option<String>) -> Option<String> {
    color.and_then(|value| {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            None
        } else if trimmed.eq_ignore_ascii_case("transparent") {
            None
        } else {
            Some(trimmed.to_string())
        }
    })
}

#[derive(Debug)]
pub enum ConversionMode {
    Plot,
    Crop {
        offset_x: f64,
        offset_y: f64,
        width: Option<f64>, // Optional crop width in mm (None = use full viewport)
        height: Option<f64>, // Optional crop height in mm (None = use full viewport)
    },
}

#[derive(Debug)]
pub struct ConversionOptions {
    pub mode: ConversionMode,
    pub scale: Option<f64>, // Optional scale factor (e.g., 1.0/50.0, 1.0/10.0)
    pub background_color: Option<String>,
    pub metadata_title: Option<String>,
    pub metadata_author: Option<String>,
    pub metadata_subject: Option<String>,
}

impl Default for ConversionOptions {
    fn default() -> Self {
        Self {
            mode: ConversionMode::Plot,
            scale: None, // No scale by default, will auto-scale if needed
            background_color: None,
            metadata_title: None,
            metadata_author: None,
            metadata_subject: None,
        }
    }
}

#[derive(Debug)]
pub enum ConversionError {
    InvalidDucData(String),
    CoordinateOutOfBounds(f64, f64),
    ScaleExceedsBounds(f64, f64, f64), // (x, y, scale) - when user provided scale still exceeds bounds
    PrecisionTooHigh(f64),
    PdfGenerationError(String),
    ResourceLoadError(String),
}

impl std::fmt::Display for ConversionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConversionError::InvalidDucData(msg) => write!(f, "Invalid DUC data: {}", msg),
            ConversionError::CoordinateOutOfBounds(x, y) => {
                write!(
                    f,
                    "Coordinate ({}, {}) exceeds safe bounds of ±{}mm",
                    x, y, MAX_COORDINATE_MM
                )
            }
            ConversionError::ScaleExceedsBounds(x, y, scale) => {
                write!(f, "Coordinate ({}, {}) with user-provided scale {} still exceeds safe bounds of ±{}mm", x, y, scale, MAX_COORDINATE_MM)
            }
            ConversionError::PrecisionTooHigh(precision) => {
                write!(
                    f,
                    "Precision {} exceeds minimum allowed precision of {}mm",
                    precision, MIN_PRECISION_MM
                )
            }
            ConversionError::PdfGenerationError(msg) => write!(f, "PDF generation error: {}", msg),
            ConversionError::ResourceLoadError(msg) => write!(f, "Resource loading error: {}", msg),
        }
    }
}

impl std::error::Error for ConversionError {}

pub type ConversionResult<T> = Result<T, ConversionError>;

/// Validates coordinates are within safe bounds with optional scaling
pub fn validate_coordinates_with_scale(
    x: f64,
    y: f64,
    scale: Option<f64>,
) -> ConversionResult<f64> {
    let scaled_x = x * scale.unwrap_or(1.0);
    let scaled_y = y * scale.unwrap_or(1.0);

    if scaled_x.abs() > MAX_COORDINATE_MM || scaled_y.abs() > MAX_COORDINATE_MM {
        if scale.is_some() {
            // User provided scale but it still exceeds bounds - this is an error
            return Err(ConversionError::ScaleExceedsBounds(x, y, scale.unwrap()));
        } else {
            // No scale provided, calculate required scale to fit within bounds
            let max_coord = x.abs().max(y.abs());
            let required_scale = MAX_COORDINATE_MM / max_coord * 0.95; // 5% safety margin
            return Ok(required_scale);
        }
    }

    // Coordinates are within bounds
    Ok(scale.unwrap_or(1.0))
}

/// Calculate bounding box for DUC data in millimeters
pub fn calculate_bounding_box(data: &duc::types::ExportedDataState) -> (f64, f64, f64, f64) {
    if data.elements.is_empty() {
        return (0.0, 0.0, 0.0, 0.0);
    }

    let mut min_x = f64::MAX;
    let mut min_y = f64::MAX;
    let mut max_x = f64::MIN;
    let mut max_y = f64::MIN;

    for element_wrapper in &data.elements {
        let base = match &element_wrapper.element {
            duc::types::DucElementEnum::DucRectangleElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucPolygonElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucEllipseElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucEmbeddableElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucPdfElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucTableElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucImageElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucTextElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucLinearElement(elem) => &elem.linear_base.base,
            duc::types::DucElementEnum::DucArrowElement(elem) => &elem.linear_base.base,
            duc::types::DucElementEnum::DucFreeDrawElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucFrameElement(elem) => &elem.stack_element_base.base,
            duc::types::DucElementEnum::DucPlotElement(elem) => &elem.stack_element_base.base,
            duc::types::DucElementEnum::DucDocElement(elem) => &elem.base,
            duc::types::DucElementEnum::DucModelElement(elem) => &elem.base,
        };

        // Assume all coordinates are already in millimeters
        let (x_mm, y_mm, width_mm, height_mm) = (base.x, base.y, base.width, base.height);

        min_x = min_x.min(x_mm);
        min_y = min_y.min(y_mm);
        max_x = max_x.max(x_mm + width_mm);
        max_y = max_y.max(y_mm + height_mm);
    }

    (min_x, min_y, max_x - min_x, max_y - min_y)
}

/// Calculate required scale to fit content within safe bounds
pub fn calculate_required_scale(
    data: &duc::types::ExportedDataState,
    crop_offset: Option<(f64, f64)>,
) -> f64 {
    let (min_x, min_y, width, height) = calculate_bounding_box(data);

    // Apply offset if cropping - assume coordinates are already in millimeters
    let (effective_min_x, effective_min_y) = if let Some((offset_x_mm, offset_y_mm)) = crop_offset {
        // With offset, we're essentially moving the viewport, so adjust the bounding box accordingly
        (min_x - offset_x_mm, min_y - offset_y_mm)
    } else {
        (min_x, min_y)
    };

    let max_x = effective_min_x + width;
    let max_y = effective_min_y + height;

    // Find the maximum coordinate in any direction from the DUC content
    let max_coord_from_content = effective_min_x
        .abs()
        .max(effective_min_y.abs())
        .max(max_x.abs())
        .max(max_y.abs());

    // CRITICAL: Always check coordinate limits
    if max_coord_from_content <= MAX_COORDINATE_MM {
        return 1.0; // No scaling needed for content
    }

    // Calculate scale with 5% safety margin to ensure coordinates stay within limits
    MAX_COORDINATE_MM / max_coord_from_content * 0.95
}

/// Calculate required scale to fit both content AND crop dimensions within safe bounds
pub fn calculate_required_scale_with_crop_dimensions(
    data: &duc::types::ExportedDataState,
    crop_offset: Option<(f64, f64)>,
    crop_width: Option<f64>,
    crop_height: Option<f64>,
) -> f64 {
    // First, calculate scale based on content
    let content_scale = calculate_required_scale(data, crop_offset);

    // Then, calculate scale based on crop dimensions if provided
    let crop_scale = if let (Some(width), Some(height)) = (crop_width, crop_height) {
        // The crop dimensions define the viewport size, so we need to ensure they fit within PDF bounds
        let max_crop_dimension = width.max(height);

        if max_crop_dimension <= MAX_COORDINATE_MM {
            None // No scaling needed for crop dimensions
        } else {
            Some(MAX_COORDINATE_MM / max_crop_dimension * 0.95) // 5% safety margin
        }
    } else {
        None // No crop dimensions to consider
    };

    // Use the more restrictive scale (smaller value) to ensure both content and crop dimensions fit
    match crop_scale {
        Some(crop_scale) => content_scale.min(crop_scale),
        None => content_scale,
    }
}

/// Validates coordinates are within safe bounds
pub fn validate_coordinates(x: f64, y: f64) -> ConversionResult<()> {
    if x.abs() > MAX_COORDINATE_MM || y.abs() > MAX_COORDINATE_MM {
        return Err(ConversionError::CoordinateOutOfBounds(x, y));
    }
    Ok(())
}

/// Validates precision is above minimum threshold
pub fn validate_precision(precision: f64) -> ConversionResult<()> {
    if precision < MIN_PRECISION_MM {
        return Err(ConversionError::PrecisionTooHigh(precision));
    }
    Ok(())
}

pub fn convert_exported_data_to_pdf_with_fonts_and_options(
    exported_data: duc::types::ExportedDataState,
    options: ConversionOptions,
    font_data: HashMap<String, Vec<u8>>,
) -> ConversionResult<Vec<u8>> {
    let mut normalized_options = options;
    normalized_options.background_color =
        normalize_background_color(normalized_options.background_color);

    builder::DucToPdfBuilder::new(exported_data, normalized_options, font_data)?.build()
}

/// Deserialize a JS font map (Map<string, Uint8Array>) into a Rust HashMap
fn deserialize_font_map(font_map_js: JsValue) -> HashMap<String, Vec<u8>> {
    let mut fonts = HashMap::new();
    if font_map_js.is_undefined() || font_map_js.is_null() {
        return fonts;
    }

    let entries = js_sys::try_iter(&font_map_js).ok().flatten();

    if let Some(iter) = entries {
        for entry_result in iter {
            if let Ok(entry) = entry_result {
                let pair = js_sys::Array::from(&entry);
                if pair.length() == 2 {
                    let key = pair.get(0);
                    let value = pair.get(1);
                    if let Some(family) = key.as_string() {
                        let bytes = js_sys::Uint8Array::new(&value);
                        fonts.insert(family, bytes.to_vec());
                    }
                }
            }
        }
    }
    fonts
}

fn deserialize_exported_data(
    exported_data_js: JsValue,
) -> Result<duc::types::ExportedDataState, String> {
    serde_wasm_bindgen::from_value(exported_data_js)
        .map_err(|e| format!("Failed to deserialize exported data: {}", e))
}

#[wasm_bindgen]
pub fn convert_exported_data_to_pdf_wasm(
    exported_data_js: JsValue,
    offset_x: Option<f64>,
    offset_y: Option<f64>,
    width: Option<f64>,
    height: Option<f64>,
    background_color: Option<String>,
    scale: Option<f64>,
    font_map_js: JsValue,
) -> Vec<u8> {
    if let Some(w) = width {
        if !w.is_finite() || w <= 0.0 {
            let error_info = error_handling::WasmErrorInfo {
                error: format!("Invalid width: {}", w),
                error_type: "ValidationError".to_string(),
                details: format!("width must be a positive finite number, got {}", w),
                duc_data_length: 0,
                conversion_context: None,
            };
            return error_handling::error_to_wasm_bytes(&error_info);
        }
    }

    if let Some(h) = height {
        if !h.is_finite() || h <= 0.0 {
            let error_info = error_handling::WasmErrorInfo {
                error: format!("Invalid height: {}", h),
                error_type: "ValidationError".to_string(),
                details: format!("height must be a positive finite number, got {}", h),
                duc_data_length: 0,
                conversion_context: None,
            };
            return error_handling::error_to_wasm_bytes(&error_info);
        }
    }

    let exported_data = match deserialize_exported_data(exported_data_js) {
        Ok(data) => data,
        Err(details) => {
            let error_info = error_handling::WasmErrorInfo {
                error: details.clone(),
                error_type: "ValidationError".to_string(),
                details,
                duc_data_length: 0,
                conversion_context: None,
            };
            return error_handling::error_to_wasm_bytes(&error_info);
        }
    };

    let font_data = deserialize_font_map(font_map_js);
    let normalized_background = normalize_background_color(background_color);

    let mode = if offset_x.is_some() || offset_y.is_some() {
        ConversionMode::Crop {
            offset_x: offset_x.unwrap_or(0.0),
            offset_y: offset_y.unwrap_or(0.0),
            width,
            height,
        }
    } else {
        ConversionMode::Plot
    };

    let options = ConversionOptions {
        mode,
        scale,
        background_color: normalized_background,
        ..Default::default()
    };

    match convert_exported_data_to_pdf_with_fonts_and_options(exported_data, options, font_data) {
        Ok(pdf_bytes) => pdf_bytes,
        Err(e) => {
            error_handling::log_error_details(&e, 0, "Direct exported data conversion");
            let error_info = error_handling::create_error_info(&e, 0, None);
            error_handling::error_to_wasm_bytes(&error_info)
        }
    }
}