use crate::scaling::DucDataScaler;
use crate::streaming::stream_elements::ElementStreamer;
use crate::streaming::stream_resources::ResourceStreamer;
use crate::utils::freedraw_bounds::{
calculate_freedraw_bbox, calculate_freedraw_point_bbox, format_number, FreeDrawBounds,
UNIT_EPSILON as FREEDRAW_EPSILON,
};
use crate::utils::style_resolver::StyleResolver;
use crate::utils::svg_to_pdf::{svg_to_pdf, svg_to_pdf_with_dimensions};
use crate::{
calculate_required_scale, calculate_required_scale_with_crop_dimensions,
validate_coordinates_with_scale, ConversionError, ConversionMode, ConversionOptions,
ConversionResult, PDF_USER_UNIT,
};
use bigcolor::BigColor;
use duc::types::{
DucBlock, DucElementEnum, DucExternalFile, ElementWrapper, ExportedDataState, TEXT_ALIGN,
};
use hipdf::embed_pdf::PdfEmbedder;
use hipdf::fonts::{Font, FontManager, StandardFont};
use hipdf::hatching::HatchingManager;
use hipdf::images::{Image, ImageManager};
use hipdf::lopdf::content::{Content, Operation};
use hipdf::lopdf::{Dictionary, Document, Object, Stream};
use hipdf::ocg::OCGManager;
use std::collections::{HashMap, HashSet};
macro_rules! log_info {
($($arg:tt)*) => {
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!($($arg)*)));
#[cfg(not(target_arch = "wasm32"))]
println!($($arg)*);
};
}
macro_rules! log_warn {
($($arg:tt)*) => {
#[cfg(target_arch = "wasm32")]
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!($($arg)*)));
#[cfg(not(target_arch = "wasm32"))]
eprintln!($($arg)*);
};
}
const ROBOTO_MONO_FONT_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/fonts/RobotoMono-Variable.ttf"
));
const EXPORT_SANITY_LIMIT_MM: f64 = 1.0e12;
#[derive(Default, Clone)]
pub struct ResourceCache {
pub images: HashMap<String, u32>,
pub fonts: HashMap<String, u32>,
pub embedded_pdfs: HashMap<String, u32>,
pub svg_objects: HashMap<String, u32>,
pub xobject_names: HashMap<String, String>, pub freedraw_bboxes: HashMap<String, FreeDrawBounds>, pub svg_dimensions: HashMap<String, (f64, f64)>, }
pub struct ConversionContext {
pub exported_data: ExportedDataState,
pub options: ConversionOptions,
pub scale: f64,
pub resource_cache: ResourceCache,
}
pub struct DucToPdfBuilder {
context: ConversionContext,
document: Document,
ocg_manager: OCGManager,
hatching_manager: HatchingManager,
pdf_embedder: PdfEmbedder,
image_manager: ImageManager,
font_manager: FontManager,
element_streamer: ElementStreamer,
resource_streamer: ResourceStreamer,
page_ids: Vec<u32>,
layer_refs: HashMap<String, Object>, layer_prop_names: HashMap<String, String>, page_height: f64, }
impl DucToPdfBuilder {
fn is_export_sane_value(value: f64) -> bool {
value.is_finite() && value.abs() <= EXPORT_SANITY_LIMIT_MM
}
fn element_id_and_type(element_wrapper: &ElementWrapper) -> (&str, &str) {
match &element_wrapper.element {
DucElementEnum::DucRectangleElement(elem) => (&elem.base.id, "rectangle"),
DucElementEnum::DucPolygonElement(elem) => (&elem.base.id, "polygon"),
DucElementEnum::DucEllipseElement(elem) => (&elem.base.id, "ellipse"),
DucElementEnum::DucEmbeddableElement(elem) => (&elem.base.id, "embeddable"),
DucElementEnum::DucPdfElement(elem) => (&elem.base.id, "pdf"),
DucElementEnum::DucTableElement(elem) => (&elem.base.id, "table"),
DucElementEnum::DucImageElement(elem) => (&elem.base.id, "image"),
DucElementEnum::DucTextElement(elem) => (&elem.base.id, "text"),
DucElementEnum::DucLinearElement(elem) => (&elem.linear_base.base.id, "line"),
DucElementEnum::DucArrowElement(elem) => (&elem.linear_base.base.id, "arrow"),
DucElementEnum::DucFreeDrawElement(elem) => (&elem.base.id, "freedraw"),
DucElementEnum::DucFrameElement(elem) => (&elem.stack_element_base.base.id, "frame"),
DucElementEnum::DucPlotElement(elem) => (&elem.stack_element_base.base.id, "plot"),
DucElementEnum::DucDocElement(elem) => (&elem.base.id, "doc"),
DucElementEnum::DucModelElement(elem) => (&elem.base.id, "model"),
}
}
fn element_has_sane_geometry(element_wrapper: &ElementWrapper) -> bool {
let base_is_sane = |base: &duc::types::DucElementBase| {
[base.x, base.y, base.width, base.height, base.angle]
.into_iter()
.all(Self::is_export_sane_value)
};
match &element_wrapper.element {
DucElementEnum::DucRectangleElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucPolygonElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucEllipseElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucEmbeddableElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucPdfElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucTableElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucImageElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucTextElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucDocElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucModelElement(elem) => base_is_sane(&elem.base),
DucElementEnum::DucFrameElement(elem) => base_is_sane(&elem.stack_element_base.base),
DucElementEnum::DucPlotElement(elem) => base_is_sane(&elem.stack_element_base.base),
DucElementEnum::DucLinearElement(elem) => {
base_is_sane(&elem.linear_base.base)
&& elem.linear_base.points.iter().all(|point| {
Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)
})
}
DucElementEnum::DucArrowElement(elem) => {
base_is_sane(&elem.linear_base.base)
&& elem.linear_base.points.iter().all(|point| {
Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)
})
}
DucElementEnum::DucFreeDrawElement(elem) => {
base_is_sane(&elem.base)
&& Self::is_export_sane_value(elem.size)
&& elem.points.iter().all(|point| {
Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)
})
}
}
}
fn filter_out_unusable_elements(exported_data: &mut ExportedDataState) {
let mut dropped_elements = Vec::new();
exported_data.elements.retain(|element_wrapper| {
let keep = Self::element_has_sane_geometry(element_wrapper);
if !keep {
let (id, element_type) = Self::element_id_and_type(element_wrapper);
dropped_elements.push((id.to_string(), element_type.to_string()));
}
keep
});
if !dropped_elements.is_empty() {
let preview = dropped_elements
.iter()
.take(5)
.map(|(id, element_type)| format!("{} ({})", id, element_type))
.collect::<Vec<_>>()
.join(", ");
log_warn!(
"Skipping {} export element(s) with absurd geometry before scaling: {}",
dropped_elements.len(),
preview
);
}
}
fn has_usable_dimension(value: f64) -> bool {
value.is_finite() && value > FREEDRAW_EPSILON
}
fn select_freedraw_bounds(
&self,
freedraw: &duc::types::DucFreeDrawElement,
) -> Option<FreeDrawBounds> {
let preferred_bounds = calculate_freedraw_bbox(freedraw);
let point_bounds = calculate_freedraw_point_bbox(&freedraw.points, freedraw.size);
if let Some(bounds) = preferred_bounds {
if Self::has_usable_dimension(bounds.width())
&& Self::has_usable_dimension(bounds.height())
{
return Some(bounds);
}
if let Some(point_bounds) = point_bounds {
if Self::has_usable_dimension(point_bounds.width())
&& Self::has_usable_dimension(point_bounds.height())
{
log_warn!(
"Recovered degenerate freedraw bbox for {} using point bounds (svg/path bbox: {}x{}, points bbox: {}x{})",
freedraw.base.id,
bounds.width(),
bounds.height(),
point_bounds.width(),
point_bounds.height()
);
return Some(point_bounds);
}
}
return Some(bounds);
}
point_bounds
}
fn sanitize_page_size(&self, width: f64, height: f64) -> (f64, f64) {
let fallback_bounds = self.calculate_overall_bounds();
let fallback_width = fallback_bounds.2.abs().max(210.0);
let fallback_height = fallback_bounds.3.abs().max(297.0);
let sanitized_width = if Self::has_usable_dimension(width) {
width
} else {
log_warn!(
"Recovered invalid PDF page width {} using fallback {}",
width,
fallback_width
);
fallback_width
};
let sanitized_height = if Self::has_usable_dimension(height) {
height
} else {
log_warn!(
"Recovered invalid PDF page height {} using fallback {}",
height,
fallback_height
);
fallback_height
};
(sanitized_width.max(0.001), sanitized_height.max(0.001))
}
fn parse_color(&self, color_str: &str) -> Option<(u8, u8, u8)> {
let color = BigColor::new(color_str);
let rgb = color.to_rgb();
Some((rgb.r, rgb.g, rgb.b))
}
pub fn new(
mut exported_data: ExportedDataState,
mut options: ConversionOptions,
font_data: HashMap<String, Vec<u8>>,
) -> ConversionResult<Self> {
let mut document = Document::with_version("1.7");
Self::filter_out_unusable_elements(&mut exported_data);
let crop_offset = match &options.mode {
ConversionMode::Crop {
offset_x, offset_y, ..
} => Some((*offset_x, *offset_y)),
ConversionMode::Plot => None,
};
let crop_dimensions = match &options.mode {
ConversionMode::Crop { width, height, .. } => (*width, *height),
ConversionMode::Plot => (None, None),
};
let scale = if let Some(user_scale) = options.scale {
let fits = Self::validate_all_coordinates_with_scale(
&exported_data,
Some(user_scale),
crop_offset,
)
.is_ok();
if fits {
user_scale
} else {
if crop_dimensions.0.is_some() || crop_dimensions.1.is_some() {
calculate_required_scale_with_crop_dimensions(
&exported_data,
crop_offset,
crop_dimensions.0,
crop_dimensions.1,
)
} else {
calculate_required_scale(&exported_data, crop_offset)
}
}
} else {
if crop_dimensions.0.is_some() || crop_dimensions.1.is_some() {
calculate_required_scale_with_crop_dimensions(
&exported_data,
crop_offset,
crop_dimensions.0,
crop_dimensions.1,
)
} else {
calculate_required_scale(&exported_data, crop_offset)
}
};
Self::scale_conversion_options(&mut options, scale);
DucDataScaler::scale_exported_data(&mut exported_data, scale);
let context = ConversionContext {
exported_data,
options,
scale,
resource_cache: ResourceCache::default(),
};
let style_resolver = StyleResolver::new();
let mut font_manager = FontManager::new();
let (primary_font, font_resource_name) =
Self::load_primary_font(&mut document, &mut font_manager)?;
let mut font_map: HashMap<String, (Font, String)> = HashMap::new();
let family = primary_font.metadata.family.clone();
if !family.is_empty() {
font_map.insert(family, (primary_font.clone(), font_resource_name.clone()));
}
font_map.insert(
"Roboto Mono".to_string(),
(primary_font.clone(), font_resource_name.clone()),
);
for (family_name, ttf_bytes) in font_data {
match Font::from_bytes(ttf_bytes, Some(format!("{}.ttf", family_name))) {
Ok(font) => match font_manager.embed_font(&mut document, font.clone()) {
Ok((_, res_name)) => {
log_info!("Embedded font '{}' as {}", family_name, res_name);
font_map.insert(family_name, (font, res_name));
}
Err(e) => {
log_warn!(
"Failed to embed font '{}': {}. Will use fallback.",
family_name,
e
);
}
},
Err(e) => {
log_warn!(
"Failed to parse font '{}': {}. Will use fallback.",
family_name,
e
);
}
}
}
let block_instances: HashMap<String, duc::types::DucBlockInstance> = context
.exported_data
.block_instances
.iter()
.map(|bi| (bi.id.clone(), bi.clone()))
.collect();
Ok(Self {
context,
document,
ocg_manager: OCGManager::new(),
hatching_manager: HatchingManager::new(),
pdf_embedder: PdfEmbedder::new(),
image_manager: ImageManager::new(),
font_manager,
element_streamer: ElementStreamer::new(
style_resolver,
0.0,
font_resource_name,
primary_font,
block_instances,
font_map,
), resource_streamer: ResourceStreamer::new(),
page_ids: Vec::new(),
layer_refs: HashMap::new(),
layer_prop_names: HashMap::new(),
page_height: 0.0, })
}
fn load_primary_font(
document: &mut Document,
font_manager: &mut FontManager,
) -> ConversionResult<(Font, String)> {
match Font::from_bytes(
ROBOTO_MONO_FONT_BYTES.to_vec(),
Some("RobotoMono-Variable.ttf".to_string()),
) {
Ok(font) => match font_manager.embed_font(document, font.clone()) {
Ok((_, resource_name)) => Ok((font, resource_name)),
Err(e) => {
log_warn!(
"⚠️ Failed to embed RobotoMono font: {}. Falling back to standard font.",
e
);
Self::embed_fallback_font(document, font_manager)
}
},
Err(e) => {
log_warn!(
"⚠️ Failed to load embedded RobotoMono-Variable.ttf: {}. Falling back to standard font.",
e
);
Self::embed_fallback_font(document, font_manager)
}
}
}
fn embed_fallback_font(
document: &mut Document,
font_manager: &mut FontManager,
) -> ConversionResult<(Font, String)> {
let fallback_font = Font::standard(StandardFont::Helvetica);
let (_, resource_name) = font_manager
.embed_font(document, fallback_font.clone())
.map_err(|e| {
ConversionError::ResourceLoadError(format!(
"Failed to embed fallback Helvetica font: {}",
e
))
})?;
log_info!(
"ℹ️ Using fallback Helvetica font embedded as {}",
resource_name
);
Ok((fallback_font, resource_name))
}
fn scale_conversion_options(options: &mut ConversionOptions, scale: f64) {
match &mut options.mode {
ConversionMode::Crop {
offset_x,
offset_y,
width,
height,
} => {
*offset_x *= scale;
*offset_y *= scale;
if let Some(w) = width {
*w *= scale;
}
if let Some(h) = height {
*h *= scale;
}
}
ConversionMode::Plot => {
}
}
}
pub fn get_element_base(element: &DucElementEnum) -> &duc::types::DucElementBase {
match element {
DucElementEnum::DucRectangleElement(elem) => &elem.base,
DucElementEnum::DucPolygonElement(elem) => &elem.base,
DucElementEnum::DucEllipseElement(elem) => &elem.base,
DucElementEnum::DucEmbeddableElement(elem) => &elem.base,
DucElementEnum::DucPdfElement(elem) => &elem.base,
DucElementEnum::DucTableElement(elem) => &elem.base,
DucElementEnum::DucImageElement(elem) => &elem.base,
DucElementEnum::DucTextElement(elem) => &elem.base,
DucElementEnum::DucLinearElement(elem) => &elem.linear_base.base,
DucElementEnum::DucArrowElement(elem) => &elem.linear_base.base,
DucElementEnum::DucFreeDrawElement(elem) => &elem.base,
DucElementEnum::DucFrameElement(elem) => &elem.stack_element_base.base,
DucElementEnum::DucPlotElement(elem) => &elem.stack_element_base.base,
DucElementEnum::DucDocElement(elem) => &elem.base,
DucElementEnum::DucModelElement(elem) => &elem.base,
}
}
fn validate_all_coordinates_with_scale(
data: &ExportedDataState,
scale: Option<f64>,
crop_offset: Option<(f64, f64)>,
) -> ConversionResult<()> {
if let Some((offset_x_mm, offset_y_mm)) = crop_offset {
for element_wrapper in &data.elements {
let base = Self::get_element_base(&element_wrapper.element);
let (x_mm, y_mm, width_mm, height_mm) = (base.x, base.y, base.width, base.height);
let adjusted_x = x_mm - offset_x_mm;
let adjusted_y = y_mm - offset_y_mm;
let final_scale = validate_coordinates_with_scale(adjusted_x, adjusted_y, scale)?;
let final_scale2 = validate_coordinates_with_scale(
adjusted_x + width_mm,
adjusted_y + height_mm,
scale,
)?;
if scale.is_none() && (final_scale < 1.0 || final_scale2 < 1.0) {
let required_scale = final_scale.min(final_scale2);
if required_scale < 1.0 {
return Err(ConversionError::InvalidDucData(format!(
"Crop specifications result in coordinates that exceed PDF limits. Required scale: {:.6}",
required_scale
)));
}
}
}
return Ok(());
}
for element_wrapper in &data.elements {
let base = Self::get_element_base(&element_wrapper.element);
let (x_mm, y_mm, width_mm, height_mm) = (base.x, base.y, base.width, base.height);
validate_coordinates_with_scale(x_mm, y_mm, scale)?;
validate_coordinates_with_scale(x_mm + width_mm, y_mm + height_mm, scale)?;
}
Ok(())
}
pub fn build(mut self) -> ConversionResult<Vec<u8>> {
self.phase1_precomputation()?;
self.phase2_page_generation()?;
self.phase4_finalization()
}
fn phase1_precomputation(&mut self) -> ConversionResult<()> {
self.set_document_metadata()?;
self.process_external_files()?;
self.process_blocks()?;
self.process_freedraw_elements()?;
self.setup_layers()?;
Ok(())
}
fn set_document_metadata(&mut self) -> ConversionResult<()> {
let mut info = Dictionary::new();
if let Some(title) = &self.context.options.metadata_title {
info.set("Title", Object::string_literal(title.as_str()));
}
if let Some(author) = &self.context.options.metadata_author {
info.set("Author", Object::string_literal(author.as_str()));
}
if let Some(subject) = &self.context.options.metadata_subject {
info.set("Subject", Object::string_literal(subject.as_str()));
}
info.set("Creator", Object::string_literal("DUC to PDF Converter"));
info.set("Producer", Object::string_literal("ducpdf"));
let scale_info = if self.context.scale < 1.0 {
format!("Scale: 1:{:.4}", 1.0 / self.context.scale)
} else if self.context.scale > 1.0 {
format!("Scale: {:.4}:1", self.context.scale)
} else {
"Scale: 1:1".to_string()
};
let keywords = format!(
"DUC version: {}, Source: {}, {}",
self.context.exported_data.version, self.context.exported_data.source, scale_info
);
info.set("Keywords", Object::string_literal(keywords.as_str()));
info.set("Scale", Object::string_literal(scale_info.as_str()));
let info_id = self.document.add_object(Object::Dictionary(info));
self.document
.trailer
.set("Info", Object::Reference(info_id));
Ok(())
}
fn process_external_files(&mut self) -> ConversionResult<()> {
if let Some(external_files) = &self.context.exported_data.external_files {
let external_files_clone = external_files.clone();
let files_data = self.context.exported_data.external_files_data.clone();
for (file_key, mut file) in external_files_clone {
if file.id != file_key {
file.id = file_key.clone();
}
let mime_type = match file.revisions.get(&file.active_revision_id) {
Some(rev) => rev.mime_type.clone(),
None => continue,
};
let rev_data = files_data
.as_ref()
.and_then(|d| d.get(&file.active_revision_id))
.map(|b| b.as_ref());
match mime_type.to_lowercase().as_str() {
"image/svg+xml" | "application/svg+xml" => {
self.process_svg_file(&file, rev_data)?;
}
"image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" => {
let _object_id = self.process_image_file(&file, rev_data)?;
}
"application/pdf" => {
let object_id = self.process_pdf_file(&file, rev_data)?;
self.context
.resource_cache
.embedded_pdfs
.insert(file.id.clone(), object_id);
}
"font/ttf" | "font/otf" | "font/woff" | "font/woff2" => {
let object_id = self.process_font_file(&file)?;
self.context
.resource_cache
.fonts
.insert(file.id.clone(), object_id);
}
_ => {
log_warn!("Unsupported file type: {}", mime_type);
}
}
}
}
Ok(())
}
fn process_svg_file(
&mut self,
file: &DucExternalFile,
rev_data: Option<&[u8]>,
) -> ConversionResult<u32> {
let revision = file
.revisions
.get(&file.active_revision_id)
.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No active revision for file {}",
file.id
))
})?;
let svg_data = rev_data.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No data blob for revision {}",
file.active_revision_id
))
})?;
let (pdf_bytes, svg_width, svg_height) =
svg_to_pdf_with_dimensions(svg_data).map_err(|e| {
ConversionError::ResourceLoadError(format!("SVG to PDF conversion failed: {}", e))
})?;
let embed_id = format!("svg_{}", file.id);
self.pdf_embedder
.load_pdf_from_bytes(&pdf_bytes, &embed_id)
.map_err(|e| {
ConversionError::ResourceLoadError(format!(
"Failed to load converted SVG PDF: {}",
e
))
})?;
if revision.mime_type == "image/svg+xml" {
let test_embed_id = "svg_test_svg";
self.pdf_embedder
.load_pdf_from_bytes(&pdf_bytes, test_embed_id)
.map_err(|e| {
ConversionError::ResourceLoadError(format!(
"Failed to load converted SVG PDF with test embed_id: {}",
e
))
})?;
}
self.context
.resource_cache
.svg_dimensions
.insert(file.id.clone(), (svg_width, svg_height));
self.context
.resource_cache
.embedded_pdfs
.insert(file.id.clone(), 0);
if revision.mime_type == "image/svg+xml" {
self.context
.resource_cache
.embedded_pdfs
.insert("test_svg".to_string(), 0);
self.context
.resource_cache
.svg_dimensions
.insert("test_svg".to_string(), (svg_width, svg_height));
}
Ok(0) }
fn process_image_file(
&mut self,
file: &DucExternalFile,
rev_data: Option<&[u8]>,
) -> ConversionResult<u32> {
let revision = file
.revisions
.get(&file.active_revision_id)
.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No active revision for file {}",
file.id
))
})?;
let image_data = rev_data.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No data blob for revision {}",
file.active_revision_id
))
})?;
let mime_type = &revision.mime_type;
let image = Image::from_bytes(image_data.to_vec(), Some(file.id.clone())).map_err(|e| {
ConversionError::ResourceLoadError(format!("Failed to load image: {}", e))
})?;
let image_id = self
.image_manager
.embed_image(&mut self.document, image)
.map_err(|e| {
ConversionError::ResourceLoadError(format!("Failed to embed image: {}", e))
})?;
self.context
.resource_cache
.images
.insert(file.id.clone(), image_id.0);
self.element_streamer.add_image(file.id.clone(), image_id.0);
let test_name = match mime_type.as_str() {
"image/svg+xml" => "test_svg",
"image/png" => "test_png",
"image/jpeg" => "test_jpeg",
_ => "",
};
if !test_name.is_empty() {
self.element_streamer
.add_image(test_name.to_string(), image_id.0);
self.context
.resource_cache
.images
.insert(test_name.to_string(), image_id.0);
}
Ok(image_id.0)
}
fn process_pdf_file(
&mut self,
file: &DucExternalFile,
rev_data: Option<&[u8]>,
) -> ConversionResult<u32> {
let revision = file
.revisions
.get(&file.active_revision_id)
.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No active revision for file {}",
file.id
))
})?;
let pdf_data = rev_data.ok_or_else(|| {
ConversionError::ResourceLoadError(format!(
"No data blob for revision {}",
file.active_revision_id
))
})?;
let mime_type = &revision.mime_type;
let embed_id = format!("pdf_{}", file.id);
self.pdf_embedder
.load_pdf_from_bytes(pdf_data, &embed_id)
.map_err(|e| {
ConversionError::ResourceLoadError(format!("Failed to load PDF: {}", e))
})?;
if mime_type == "application/pdf" {
let test_embed_id = "pdf_test_pdf";
self.pdf_embedder
.load_pdf_from_bytes(pdf_data, test_embed_id)
.map_err(|e| {
ConversionError::ResourceLoadError(format!(
"Failed to load PDF with test embed_id: {}",
e
))
})?;
}
self.context
.resource_cache
.embedded_pdfs
.insert(file.id.clone(), 0);
if mime_type == "application/pdf" {
self.context
.resource_cache
.embedded_pdfs
.insert("test_pdf".to_string(), 0);
}
Ok(0)
}
fn collect_plot_element_ids(&self, plot_id: &str) -> HashSet<String> {
let mut allowed = HashSet::new();
let mut stack = vec![plot_id.to_string()];
allowed.insert(plot_id.to_string());
while let Some(current_id) = stack.pop() {
for element_wrapper in &self.context.exported_data.elements {
let base = DucToPdfBuilder::get_element_base(&element_wrapper.element);
if let Some(parent_id) = &base.frame_id {
if parent_id == ¤t_id && allowed.insert(base.id.clone()) {
stack.push(base.id.clone());
}
}
}
}
allowed
}
fn embed_pdf_for_element(
&mut self,
file_id: &str,
_width: f64,
_height: f64,
) -> ConversionResult<()> {
let embed_id = format!("pdf_{}", file_id);
if self.pdf_embedder.get_pdf_info(&embed_id).is_none() {
return Err(ConversionError::ResourceLoadError(format!(
"PDF not loaded for file_id={}, embed_id={}",
file_id, embed_id
)));
}
Ok(())
}
fn process_font_file(&mut self, _file: &DucExternalFile) -> ConversionResult<u32> {
Ok(0)
}
fn process_blocks(&mut self) -> ConversionResult<()> {
let blocks = self.context.exported_data.blocks.clone();
for block in &blocks {
self.process_single_block(block)?;
}
Ok(())
}
fn process_single_block(&mut self, _block: &DucBlock) -> ConversionResult<()> {
Ok(())
}
fn process_freedraw_elements(&mut self) -> ConversionResult<()> {
for element_wrapper in &self.context.exported_data.elements {
if let DucElementEnum::DucFreeDrawElement(freedraw) = &element_wrapper.element {
if let Some(svg_path) = &freedraw.svg_path {
if !svg_path.trim().is_empty() {
let (stroke_color, stroke_opacity) = if let Some(stroke_obj) = freedraw
.base
.styles
.stroke
.iter()
.find(|s| s.content.visible)
{
(stroke_obj.content.src.clone(), stroke_obj.content.opacity)
} else {
("rgb(0, 0, 0)".to_string(), 1.0)
};
let svg_document = if let Some(bounds) =
self.select_freedraw_bounds(freedraw)
{
self.context
.resource_cache
.freedraw_bboxes
.insert(freedraw.base.id.clone(), bounds);
let mut width = bounds.width();
let mut height = bounds.height();
if !Self::has_usable_dimension(width) {
width = freedraw
.base
.width
.abs()
.max(freedraw.size.abs())
.max(FREEDRAW_EPSILON);
}
if !Self::has_usable_dimension(height) {
height = freedraw
.base
.height
.abs()
.max(freedraw.size.abs())
.max(FREEDRAW_EPSILON);
}
if !Self::has_usable_dimension(width)
|| !Self::has_usable_dimension(height)
{
log_warn!(
"Skipping freedraw {} because bounds remained unusable after fallback (base={}x{}, size={}, svg_len={})",
freedraw.base.id,
freedraw.base.width,
freedraw.base.height,
freedraw.size,
svg_path.len()
);
continue;
}
let width_str = format_number(width);
let height_str = format_number(height);
if width_str == "0" || height_str == "0" {
log_warn!(
"Skipping freedraw {} because formatted bounds collapsed to zero (numeric={}x{}, base={}x{}, size={})",
freedraw.base.id,
width,
height,
freedraw.base.width,
freedraw.base.height,
freedraw.size
);
continue;
}
let translate_x = format_number(-bounds.min_x);
let translate_y = format_number(-bounds.min_y);
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}"><g transform="translate({tx} {ty})"><path d="{path}" fill="{fill}" fill-opacity="{opacity}" /></g></svg>"#,
width = width_str,
height = height_str,
tx = translate_x,
ty = translate_y,
path = svg_path,
fill = stroke_color.as_str(),
opacity = stroke_opacity,
)
} else {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="{}" fill="{}" fill-opacity="{}" /></svg>"#,
svg_path,
stroke_color.as_str(),
stroke_opacity
)
};
match svg_to_pdf(svg_document.as_bytes()) {
Ok(pdf_bytes) => {
let embed_id = format!("freedraw_{}", freedraw.base.id);
match self.pdf_embedder.load_pdf_from_bytes(&pdf_bytes, &embed_id) {
Ok(_) => {
self.context
.resource_cache
.embedded_pdfs
.insert(freedraw.base.id.clone(), 0);
}
Err(e) => {
log_warn!("Warning: Failed to load converted Freedraw SVG PDF for {}: {}", freedraw.base.id, e);
}
}
}
Err(e) => {
let svg_header_preview =
svg_document.split('>').next().unwrap_or_default();
log_warn!(
"Warning: SVG to PDF conversion failed for Freedraw element {}: {} (base={}x{}, size={}, header='{}')",
freedraw.base.id,
e,
freedraw.base.width,
freedraw.base.height,
freedraw.size,
svg_header_preview
);
}
}
}
}
}
}
Ok(())
}
fn setup_layers(&mut self) -> ConversionResult<()> {
let mut counter: usize = 1;
for layer in &self.context.exported_data.layers {
let layer_name = if !layer.stack_base.label.is_empty() {
&layer.stack_base.label
} else {
&layer.id
};
let mut ocg_dict = Dictionary::new();
ocg_dict.set("Type", Object::Name("OCG".as_bytes().to_vec()));
ocg_dict.set("Name", Object::string_literal(layer_name.as_str()));
ocg_dict.set(
"Intent",
Object::Array(vec![Object::Name("View".as_bytes().to_vec())]),
);
let mut usage_dict = Dictionary::new();
let mut view_dict = Dictionary::new();
view_dict.set("ViewState", Object::Name("ON".as_bytes().to_vec()));
usage_dict.set("View", Object::Dictionary(view_dict));
ocg_dict.set("Usage", Object::Dictionary(usage_dict));
let ocg_id = self.document.add_object(Object::Dictionary(ocg_dict));
self.layer_refs
.insert(layer.id.clone(), Object::Reference(ocg_id));
let prop_name = format!("OCG_{}", counter);
self.layer_prop_names.insert(layer.id.clone(), prop_name);
counter += 1;
}
Ok(())
}
fn phase2_page_generation(&mut self) -> ConversionResult<()> {
match &self.context.options.mode {
ConversionMode::Plot => {
self.generate_plot_pages()?;
}
ConversionMode::Crop {
offset_x,
offset_y,
width,
height,
} => {
self.generate_crop_page(*offset_x, *offset_y, *width, *height)?;
}
}
Ok(())
}
fn generate_plot_pages(&mut self) -> ConversionResult<()> {
let plot_entries: Vec<(String, (f64, f64, f64, f64), Option<(String, f64)>)> = self
.context
.exported_data
.elements
.iter()
.filter_map(|elem| {
if let DucElementEnum::DucPlotElement(plot) = &elem.element {
let base = &plot.stack_element_base.base;
if base.is_deleted {
return None;
}
let background_data = if !base.styles.background.is_empty() {
base.styles.background.first().and_then(|bg| {
if bg.content.visible && !bg.content.src.is_empty() {
Some((bg.content.src.clone(), bg.content.opacity))
} else {
None
}
})
} else {
None
};
Some((
base.id.clone(),
(base.x, base.y, base.width, base.height),
background_data,
))
} else {
None
}
})
.collect();
if plot_entries.is_empty() {
self.create_single_page_with_all_elements()?;
} else {
for (plot_id, bounds, background_data) in plot_entries {
let background_with_opacity = background_data
.as_ref()
.map(|(color, opacity)| (color.as_str(), *opacity));
self.create_page_with_bounds(
bounds,
Some(plot_id.as_str()),
background_with_opacity,
)?;
}
}
Ok(())
}
fn generate_crop_page(
&mut self,
offset_x: f64,
offset_y: f64,
width: Option<f64>,
height: Option<f64>,
) -> ConversionResult<()> {
self.apply_crop_offset_to_local_state(offset_x, offset_y);
let overall_bounds = self.calculate_overall_bounds();
let crop_bounds = if let (Some(w_mm), Some(h_mm)) = (width, height) {
println!(
"🔧 Applied crop dimensions: {}x{} mm at offset ({}, {})",
w_mm, h_mm, offset_x, offset_y
);
(0.0, 0.0, w_mm, h_mm)
} else {
overall_bounds
};
self.create_page_with_crop_bounds(crop_bounds, width.is_some() && height.is_some())?;
Ok(())
}
fn apply_crop_offset_to_local_state(&mut self, offset_x_mm: f64, offset_y_mm: f64) {
if let Some(ref mut local_state) = self.context.exported_data.duc_local_state {
local_state.scroll_x = offset_x_mm;
local_state.scroll_y = offset_y_mm;
} else {
let new_local_state = duc::types::DucLocalState {
scope: "mm".to_string(), scroll_x: offset_x_mm,
scroll_y: offset_y_mm,
zoom: 1.0,
is_binding_enabled: false,
current_item_stroke: None,
current_item_background: None,
current_item_opacity: 1.0,
current_item_font_family: "Arial".to_string(),
current_item_font_size: 12.0,
current_item_text_align: TEXT_ALIGN::LEFT,
current_item_start_line_head: None,
current_item_end_line_head: None,
current_item_roundness: 0.0,
pen_mode: false,
view_mode_enabled: false,
objects_snap_mode_enabled: false,
grid_mode_enabled: false,
outline_mode_enabled: false,
manual_save_mode: false,
decimal_places: 2,
};
self.context.exported_data.duc_local_state = Some(new_local_state);
}
}
fn create_single_page_with_all_elements(&mut self) -> ConversionResult<()> {
let bounds = self.calculate_overall_bounds();
self.create_page_with_bounds(bounds, None, None)?;
Ok(())
}
fn create_page_with_bounds(
&mut self,
bounds: (f64, f64, f64, f64),
active_plot_id: Option<&str>,
page_background_color: Option<(&str, f64)>,
) -> ConversionResult<()> {
let (_x, _y, width, height) = bounds;
let (page_width, page_height) = self.sanitize_page_size(width, height);
self.page_height = page_height;
let content_stream =
self.create_content_stream(bounds, active_plot_id, page_background_color)?;
let content_id = self.document.add_object(Object::Stream(content_stream));
let resources = self.create_page_resources()?;
let mut page = Dictionary::new();
page.set("Type", Object::Name("Page".as_bytes().to_vec()));
page.set(
"CropBox",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(page_width as f32),
Object::Real(page_height as f32),
]),
);
page.set(
"MediaBox",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(page_width as f32),
Object::Real(page_height as f32),
]),
);
page.set("UserUnit", Object::Real(PDF_USER_UNIT));
page.set("Contents", Object::Reference(content_id));
page.set("Resources", Object::Dictionary(resources));
let page_id = self.document.add_object(Object::Dictionary(page));
self.page_ids.push(page_id.0);
Ok(())
}
fn create_page_with_crop_bounds(
&mut self,
bounds: (f64, f64, f64, f64),
preserve_exact_dimensions: bool,
) -> ConversionResult<()> {
let (page_width, page_height) = if preserve_exact_dimensions {
let (_x, _y, width, height) = bounds;
(width, height)
} else {
let (_x, _y, width, height) = bounds;
(width, height)
};
let (page_width, page_height) = self.sanitize_page_size(page_width, page_height);
self.page_height = page_height;
let content_stream = self.create_content_stream(bounds, None, None)?;
let content_id = self.document.add_object(Object::Stream(content_stream));
let resources = self.create_page_resources()?;
let mut page = Dictionary::new();
page.set("Type", Object::Name("Page".as_bytes().to_vec()));
page.set(
"CropBox",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(page_width as f32),
Object::Real(page_height as f32),
]),
);
page.set(
"MediaBox",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(page_width as f32),
Object::Real(page_height as f32),
]),
);
page.set("UserUnit", Object::Real(PDF_USER_UNIT));
page.set("Contents", Object::Reference(content_id));
page.set("Resources", Object::Dictionary(resources));
let page_id = self.document.add_object(Object::Dictionary(page));
self.page_ids.push(page_id.0);
Ok(())
}
fn create_page_resources(&mut self) -> ConversionResult<Dictionary> {
let mut resources = Dictionary::new();
for (_font, font_id, resource_name) in self.font_manager.fonts() {
self.font_manager
.add_to_resources(&mut resources, *font_id, resource_name);
}
let mut xobject_dict = if let Ok(Object::Dictionary(dict)) = resources.get(b"XObject") {
dict.clone()
} else {
Dictionary::new()
};
for (name, obj_ref) in self.element_streamer.drain_new_xobjects() {
xobject_dict.set(name, obj_ref);
}
if !xobject_dict.is_empty() {
resources.set("XObject", Object::Dictionary(xobject_dict));
}
let ext_gstates = self.element_streamer.take_page_ext_gstates();
if !ext_gstates.is_empty() {
let mut extgstate_dict = Dictionary::new();
for (name, gstate_dict) in ext_gstates {
let (gstate_id, _) = self.document.add_object(Object::Dictionary(gstate_dict));
extgstate_dict.set(name, Object::Reference((gstate_id, 0)));
}
resources.set("ExtGState", Object::Dictionary(extgstate_dict));
}
if !self.layer_refs.is_empty() {
let mut properties = Dictionary::new();
for layer in &self.context.exported_data.layers {
if let (Some(ocg_ref), Some(prop_name)) = (
self.layer_refs.get(&layer.id),
self.layer_prop_names.get(&layer.id),
) {
properties.set(prop_name.as_str(), ocg_ref.clone());
}
}
if !properties.is_empty() {
resources.set("Properties", Object::Dictionary(properties));
}
}
Ok(resources)
}
fn create_content_stream(
&mut self,
bounds: (f64, f64, f64, f64),
active_plot_id: Option<&str>,
page_background_color: Option<(&str, f64)>,
) -> ConversionResult<Stream> {
let (x, y, width, height) = bounds;
let mut operations = Vec::new();
operations.push(Operation::new("q", vec![]));
let background_to_apply = match &self.context.options.mode {
ConversionMode::Plot => page_background_color,
ConversionMode::Crop { .. } => self
.context
.options
.background_color
.as_deref()
.map(|color| (color, 1.0)),
};
if let Some((bg_color, bg_opacity)) = background_to_apply {
if let Some((r, g, b)) = self.parse_color(bg_color) {
let r_norm = r as f32 / 255.0;
let g_norm = g as f32 / 255.0;
let b_norm = b as f32 / 255.0;
if (bg_opacity - 1.0).abs() > f64::EPSILON {
let alpha = bg_opacity as f32;
let r_blended = r_norm * alpha + (1.0 - alpha);
let g_blended = g_norm * alpha + (1.0 - alpha);
let b_blended = b_norm * alpha + (1.0 - alpha);
operations.push(Operation::new(
"rg",
vec![
Object::Real(r_blended),
Object::Real(g_blended),
Object::Real(b_blended),
],
));
} else {
operations.push(Operation::new(
"rg",
vec![
Object::Real(r_norm),
Object::Real(g_norm),
Object::Real(b_norm),
],
));
}
const OVERSCAN: f32 = 3.0;
operations.push(Operation::new(
"re",
vec![
Object::Real(-OVERSCAN), Object::Real(-OVERSCAN), Object::Real(width as f32 + OVERSCAN * 2.0), Object::Real(height as f32 + OVERSCAN * 2.0), ],
));
operations.push(Operation::new("f", vec![]));
operations.push(Operation::new(
"rg",
vec![Object::Real(0.0), Object::Real(0.0), Object::Real(0.0)],
));
} else {
log_warn!(
"⚠️ Failed to parse background color '{}'; skipping background fill.",
bg_color
);
}
}
let (tx, ty) = match self.context.options.mode {
ConversionMode::Plot => (-x, -y),
ConversionMode::Crop { .. } => (-x, -y),
};
operations.push(Operation::new(
"cm",
vec![
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
Object::Real(1.0),
Object::Real(tx as f32),
Object::Real(ty as f32),
],
));
self.element_streamer.set_page_height(height);
self.element_streamer.set_page_origin(x, y);
let (vis_x, vis_y) = match self.context.options.mode {
ConversionMode::Crop { .. } => {
let (sx, sy) = if let Some(state) = &self.context.exported_data.duc_local_state {
(state.scroll_x, state.scroll_y)
} else {
(0.0, 0.0)
};
(-sx, -sy)
}
ConversionMode::Plot => (x, y),
};
self.element_streamer
.set_visible_scene_rect(vis_x, vis_y, width, height);
self.element_streamer.set_page_translation(tx, ty);
self.element_streamer
.set_resource_cache(self.context.resource_cache.xobject_names.clone());
self.element_streamer
.set_embedded_pdfs(self.context.resource_cache.embedded_pdfs.clone());
self.element_streamer
.set_images(self.context.resource_cache.images.clone());
self.element_streamer
.set_freedraw_bboxes(self.context.resource_cache.freedraw_bboxes.clone());
self.element_streamer
.set_svg_dimensions(self.context.resource_cache.svg_dimensions.clone());
self.element_streamer.begin_page();
let is_plot_mode = matches!(self.context.options.mode, ConversionMode::Plot);
let allowed_ids = if is_plot_mode {
active_plot_id.map(|plot_id| self.collect_plot_element_ids(plot_id))
} else {
None
};
self.element_streamer
.set_page_context(is_plot_mode, active_plot_id, allowed_ids);
let element_operations = if !self.context.exported_data.layers.is_empty() {
self.stream_elements_by_layer(bounds)?
} else {
self.element_streamer.stream_elements_within_bounds(
&self.context.exported_data.elements,
&self.context.exported_data.elements,
bounds,
self.context.exported_data.duc_local_state.as_ref(),
&mut self.resource_streamer,
&mut self.hatching_manager,
&mut self.pdf_embedder,
&mut self.image_manager,
&self.ocg_manager,
&mut self.document,
)?
};
operations.extend(element_operations);
operations.push(Operation::new("Q", vec![]));
self.element_streamer.clear_page_context();
#[cfg(debug_assertions)]
{
Self::debug_validate_operations(&operations);
}
let content_bytes = Content { operations }.encode().map_err(|e| {
ConversionError::PdfGenerationError(format!("Failed to encode content stream: {}", e))
})?;
let mut stream_dict = Dictionary::new();
stream_dict.set("Length", Object::Integer(content_bytes.len() as i64));
Ok(Stream::new(stream_dict, content_bytes))
}
fn stream_elements_by_layer(
&mut self,
bounds: (f64, f64, f64, f64),
) -> ConversionResult<Vec<hipdf::lopdf::content::Operation>> {
use hipdf::lopdf::content::Operation;
let mut all_operations = Vec::new();
let pdf_elements: Vec<_> = self
.context
.exported_data
.elements
.iter()
.filter_map(|element_wrapper| match &element_wrapper.element {
DucElementEnum::DucPdfElement(pdf_elem) => pdf_elem
.file_id
.as_ref()
.map(|file_id| (file_id.clone(), pdf_elem.base.width, pdf_elem.base.height)),
DucElementEnum::DucDocElement(doc_elem) => doc_elem
.file_id
.as_ref()
.map(|file_id| (file_id.clone(), doc_elem.base.width, doc_elem.base.height)),
_ => None,
})
.collect();
for (file_id, width, height) in pdf_elements {
if let Err(e) = self.embed_pdf_for_element(&file_id, width, height) {
log::warn!("Skipping PDF element file_id={}: {}", file_id, e);
}
}
self.element_streamer
.set_resource_cache(self.context.resource_cache.xobject_names.clone());
self.element_streamer
.set_embedded_pdfs(self.context.resource_cache.embedded_pdfs.clone());
self.element_streamer
.set_images(self.context.resource_cache.images.clone());
self.element_streamer
.set_freedraw_bboxes(self.context.resource_cache.freedraw_bboxes.clone());
self.element_streamer
.set_svg_dimensions(self.context.resource_cache.svg_dimensions.clone());
let is_plot_mode = matches!(self.context.options.mode, ConversionMode::Plot);
let (scroll_x, scroll_y) = if is_plot_mode {
(0.0, 0.0)
} else if let Some(state) = &self.context.exported_data.duc_local_state {
(state.scroll_x, state.scroll_y)
} else {
(0.0, 0.0)
};
let unlayered_elements: Vec<ElementWrapper> = self
.context
.exported_data
.elements
.iter()
.filter(|element_wrapper| {
let base =
crate::builder::DucToPdfBuilder::get_element_base(&element_wrapper.element);
base.layer_id.is_none()
})
.cloned()
.collect();
if !unlayered_elements.is_empty() {
let ops = self.element_streamer.stream_elements_within_bounds(
&unlayered_elements,
&self.context.exported_data.elements,
bounds,
self.context.exported_data.duc_local_state.as_ref(),
&mut self.resource_streamer,
&mut self.hatching_manager,
&mut self.pdf_embedder,
&mut self.image_manager,
&self.ocg_manager,
&mut self.document,
)?;
all_operations.extend(ops);
}
for layer in &self.context.exported_data.layers {
let layer_elements: Vec<ElementWrapper> = self
.context
.exported_data
.elements
.iter()
.filter(|element_wrapper| {
let base =
crate::builder::DucToPdfBuilder::get_element_base(&element_wrapper.element);
let belongs_to_layer = base
.layer_id
.as_ref()
.map_or(false, |layer_id| layer_id == &layer.id);
if !belongs_to_layer {
return false;
}
let (bounds_x, bounds_y, bounds_width, bounds_height) = bounds;
let bounds_max_x = bounds_x + bounds_width;
let bounds_max_y = bounds_y + bounds_height;
let offsets = self
.element_streamer
.get_element_duplication_offsets(&element_wrapper.element)
.unwrap_or_else(|| vec![(0.0, 0.0)]);
let mut intersects = false;
for (x_off, y_off) in offsets {
let elem_x = base.x + scroll_x + x_off;
let elem_y = base.y + scroll_y + y_off;
let elem_max_x = elem_x + base.width;
let elem_max_y = elem_y + base.height;
let this_intersects = !(elem_x > bounds_max_x
|| elem_max_x < bounds_x
|| elem_y > bounds_max_y
|| elem_max_y < bounds_y);
if this_intersects {
intersects = true;
break;
}
}
if !intersects {
let base_id = &base.id;
let instance_id = base.instance_id.as_deref().unwrap_or("<none>");
log_info!(
"Skipping layered element '{}' (instance {}) - no duplicated instance intersects bounds. bounds=({},{},{},{}) scroll=({}, {})",
base_id,
instance_id,
bounds_x,
bounds_y,
bounds_width,
bounds_height,
scroll_x,
scroll_y
);
}
intersects
})
.cloned()
.collect();
if layer_elements.is_empty() {
continue;
}
if let Some(prop_name) = self.layer_prop_names.get(&layer.id) {
all_operations.push(Operation::new(
"BDC",
vec![
Object::Name(b"OC".to_vec()),
Object::Name(prop_name.as_bytes().to_vec()),
],
));
}
let layer_ops = self.element_streamer.stream_elements_within_bounds(
&layer_elements,
&self.context.exported_data.elements,
bounds,
self.context.exported_data.duc_local_state.as_ref(),
&mut self.resource_streamer,
&mut self.hatching_manager,
&mut self.pdf_embedder,
&mut self.image_manager,
&self.ocg_manager,
&mut self.document,
)?;
all_operations.extend(layer_ops);
all_operations.push(Operation::new("EMC", vec![]));
}
Ok(all_operations)
}
fn calculate_overall_bounds(&self) -> (f64, f64, f64, f64) {
if self.context.exported_data.elements.is_empty() {
return (0.0, 0.0, 210.0, 297.0); }
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for element_wrapper in &self.context.exported_data.elements {
let base = Self::get_element_base(&element_wrapper.element);
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);
}
let width = max_x - min_x;
let height = max_y - min_y;
(min_x, min_y, width, height)
}
fn phase4_finalization(mut self) -> ConversionResult<Vec<u8>> {
self.create_pages_tree()?;
self.create_root_catalog()?;
let mut buffer = Vec::new();
self.document
.save_to(&mut buffer)
.map_err(|e| ConversionError::PdfGenerationError(e.to_string()))?;
Ok(buffer)
}
fn create_pages_tree(&mut self) -> ConversionResult<()> {
if self.page_ids.is_empty() {
return Err(ConversionError::PdfGenerationError(
"No pages created ".to_string(),
));
}
let page_refs: Vec<Object> = self
.page_ids
.iter()
.map(|&id| Object::Reference((id, 0)))
.collect();
let mut pages_dict = Dictionary::new();
pages_dict.set("Type", Object::Name("Pages".as_bytes().to_vec()));
pages_dict.set("Kids", Object::Array(page_refs));
pages_dict.set("Count", Object::Integer(self.page_ids.len() as i64));
let pages_id = self.document.add_object(Object::Dictionary(pages_dict));
for &page_id in &self.page_ids {
if let Ok(Object::Dictionary(ref mut page_dict)) =
self.document.get_object_mut((page_id, 0))
{
page_dict.set("Parent", Object::Reference(pages_id));
}
}
self.document
.trailer
.set("Pages", Object::Reference(pages_id));
Ok(())
}
fn create_root_catalog(&mut self) -> ConversionResult<()> {
let pages_ref = match self.document.trailer.get(b"Pages") {
Ok(obj) => obj.clone(),
Err(_) => {
return Err(ConversionError::PdfGenerationError(
"Pages not found in trailer ".to_string(),
))
}
};
let mut catalog = Dictionary::new();
catalog.set("Type", Object::Name("Catalog".as_bytes().to_vec()));
catalog.set("Pages", pages_ref);
if !self.layer_refs.is_empty() {
let mut ocgs_array: Vec<Object> = Vec::new();
let mut on_array: Vec<Object> = Vec::new();
let mut off_array: Vec<Object> = Vec::new();
for layer in &self.context.exported_data.layers {
if let Some(ocg_ref) = self.layer_refs.get(&layer.id) {
ocgs_array.push(ocg_ref.clone());
if layer.stack_base.is_visible {
on_array.push(ocg_ref.clone());
} else {
off_array.push(ocg_ref.clone());
}
}
}
let mut d_dict = Dictionary::new();
d_dict.set("BaseState", Object::Name("ON".as_bytes().to_vec()));
let mut order_array = Vec::new();
for layer in &self.context.exported_data.layers {
if let Some(ocg_ref) = self.layer_refs.get(&layer.id) {
order_array.push(ocg_ref.clone());
}
}
d_dict.set("Order", Object::Array(order_array));
let mut as_array = Vec::new();
let mut as_dict = Dictionary::new();
as_dict.set("Event", Object::Name("View".as_bytes().to_vec()));
as_dict.set("OCGs", Object::Array(ocgs_array.clone()));
as_dict.set(
"Category",
Object::Array(vec![Object::Name("View".as_bytes().to_vec())]),
);
as_array.push(Object::Dictionary(as_dict));
d_dict.set("AS", Object::Array(as_array));
if !on_array.is_empty() {
d_dict.set("ON", Object::Array(on_array));
}
if !off_array.is_empty() {
d_dict.set("OFF", Object::Array(off_array));
}
let mut ocprops = Dictionary::new();
ocprops.set("OCGs", Object::Array(ocgs_array));
ocprops.set("D", Object::Dictionary(d_dict));
catalog.set("OCProperties", Object::Dictionary(ocprops));
}
let catalog_id = self.document.add_object(Object::Dictionary(catalog));
self.document
.trailer
.set("Root", Object::Reference(catalog_id));
Ok(())
}
}
#[cfg(test)]
mod scaling_tests {
use super::*;
fn empty_state() -> ExportedDataState {
duc::api::DucDocument::open_memory()
.expect("open in-memory DUC document")
.read_document_state()
.expect("read empty DUC state")
}
#[test]
fn preserves_valid_explicit_scale_for_exported_state() {
let builder = DucToPdfBuilder::new(
empty_state(),
ConversionOptions {
scale: Some(0.02),
..Default::default()
},
HashMap::new(),
)
.expect("build PDF converter");
assert_eq!(builder.context.scale, 0.02);
}
#[test]
fn auto_scales_oversized_crop_dimensions() {
let builder = DucToPdfBuilder::new(
empty_state(),
ConversionOptions {
mode: ConversionMode::Crop {
offset_x: 0.0,
offset_y: 0.0,
width: Some(10_000.0),
height: Some(8_000.0),
},
..Default::default()
},
HashMap::new(),
)
.expect("build PDF converter");
assert!(builder.context.scale < 1.0);
assert!(builder.context.scale > 0.0);
}
}
impl DucToPdfBuilder {
#[cfg(debug_assertions)]
fn debug_validate_operations(operations: &[Operation]) {
let mut depth: i32 = 0;
let mut min_depth: i32 = 0;
for op in operations {
let name = op.operator.clone();
match name.as_str() {
"q" => {
depth += 1;
}
"Q" => {
depth -= 1;
min_depth = min_depth.min(depth);
}
_ => {}
}
}
if depth != 0 || min_depth < 0 {
log_warn!(
"Graphics state imbalance detected: final depth={}, min depth={}",
depth,
min_depth
);
}
}
}