diff --git a/.github/workflows/release-ducpy.yml b/.github/workflows/release-ducpy.yml index a9700cf..fa1eff7 100644 --- a/.github/workflows/release-ducpy.yml +++ b/.github/workflows/release-ducpy.yml @@ -96,6 +96,7 @@ jobs: TAG=$(git describe --tags --abbrev=0 --match 'ducpy@*') VERSION="${TAG#ducpy@}" echo "Building Pyodide wheel for version ${VERSION}" + uv run python scripts/sync_schema.py RUSTUP_TOOLCHAIN=nightly SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION}" pyodide build --outdir dist - name: Upload wheels to GitHub release diff --git a/packages/ducjs/package.json b/packages/ducjs/package.json index ead5c65..bb4c1c7 100644 --- a/packages/ducjs/package.json +++ b/packages/ducjs/package.json @@ -8,14 +8,14 @@ "types": "dist/index.d.ts", "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/index.js", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" + "require": "./dist/index.js" }, "./*": { + "types": ["./dist/*.d.ts", "./dist/*/index.d.ts"], "import": ["./dist/*.js", "./dist/*/index.js"], - "require": ["./dist/*.js", "./dist/*/index.js"], - "types": ["./dist/*.d.ts", "./dist/*/index.d.ts"] + "require": ["./dist/*.js", "./dist/*/index.js"] } }, "scripts": { diff --git a/packages/ducjs/src/restore/restoreDataState.ts b/packages/ducjs/src/restore/restoreDataState.ts index 01d0f8c..432295b 100644 --- a/packages/ducjs/src/restore/restoreDataState.ts +++ b/packages/ducjs/src/restore/restoreDataState.ts @@ -1147,18 +1147,18 @@ export const isValidImageStatusValue = ( export const isValidDucHead = ( value: DucHead | null | undefined, blocks: RestoredDataState["blocks"], - elementScope: Scope, - currentScope: Scope ): DucHead | null => { if (value === undefined || value === null) return null; const type = isValidLineHeadValue(value.type); - // blockId can be null - only reject if type is invalid if (type === null) return null; const blockId = isValidBlockId(value.blockId, blocks); + const size = typeof value.size === "number" && Number.isFinite(value.size) && value.size > 0 + ? value.size + : 1; return { type, blockId, - size: restorePrecisionValue(value.size, elementScope, currentScope), + size, }; }; diff --git a/packages/ducjs/src/restore/restoreElements.ts b/packages/ducjs/src/restore/restoreElements.ts index dfcd95d..3a65de0 100644 --- a/packages/ducjs/src/restore/restoreElements.ts +++ b/packages/ducjs/src/restore/restoreElements.ts @@ -1415,9 +1415,7 @@ const repairBinding = ( ), head: isValidDucHead( binding.head, - restoredBlocks, - elementScope, - currentScope + restoredBlocks ), fixedPoint: element && isElbowArrow(element) ? normalizeFixedPoint(binding.fixedPoint ?? { x: 0.5, y: 0.5 }) @@ -1561,7 +1559,7 @@ const createHeadOnlyBinding = ( gap: getPrecisionValueFromRaw(0 as RawValue, currentScope, currentScope), fixedPoint: null, point: null, - head: isValidDucHead(head, restoredBlocks, currentScope, currentScope), + head: isValidDucHead(head, restoredBlocks), }; }; diff --git a/packages/ducjs/src/types/elements/index.ts b/packages/ducjs/src/types/elements/index.ts index 98e61a4..ba98c93 100644 --- a/packages/ducjs/src/types/elements/index.ts +++ b/packages/ducjs/src/types/elements/index.ts @@ -760,8 +760,8 @@ export type DucPointBinding = { export type DucHead = { type: LineHead; - blockId: string | null; // If the head is a block, this is the id of the block - size: PrecisionValue; + blockId: string | null; + size: number; } export interface DucPointPosition { diff --git a/packages/ducjs/src/wasm.ts b/packages/ducjs/src/wasm.ts index dbd86d3..896fd1b 100644 --- a/packages/ducjs/src/wasm.ts +++ b/packages/ducjs/src/wasm.ts @@ -17,13 +17,40 @@ import init, { let initialized = false; let initPromise: Promise | null = null; +const DEFAULT_WASM_URL = new URL("../dist/ducjs_wasm_bg.wasm", import.meta.url); + +const isNodeRuntime = () => + typeof process !== "undefined" && typeof process.versions?.node === "string"; + +type NodeFsPromisesModule = { + readFile(path: string | URL): Promise; +}; + +const loadNodeFsPromises = async (): Promise => { + const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string, + ) => Promise; + return dynamicImport(["node", "fs/promises"].join(":")); +}; + +const resolveDefaultWasmInput = async (): Promise => { + if (!isNodeRuntime()) { + return DEFAULT_WASM_URL; + } + + const { readFile } = await loadNodeFsPromises(); + const bytes = await readFile(DEFAULT_WASM_URL); + return new Uint8Array(bytes); +}; + export async function ensureWasm(wasmUrl?: string | URL | BufferSource): Promise { if (initialized) return; if (!initPromise) { - const arg = wasmUrl !== undefined ? { module_or_path: wasmUrl } : undefined; - initPromise = init(arg).then(() => { + initPromise = (async () => { + const moduleOrPath = wasmUrl ?? await resolveDefaultWasmInput(); + await init({ module_or_path: moduleOrPath }); initialized = true; - }); + })(); } return initPromise; } @@ -35,8 +62,13 @@ export async function ensureWasm(wasmUrl?: string | URL | BufferSource): Promise * without being able to resolve the file URL itself. */ export async function getWasmBinary(): Promise { - const url = new URL('../dist/ducjs_wasm_bg.wasm', import.meta.url); - const resp = await fetch(url); + if (isNodeRuntime()) { + const { readFile } = await loadNodeFsPromises(); + const bytes = await readFile(DEFAULT_WASM_URL); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } + + const resp = await fetch(DEFAULT_WASM_URL); if (!resp.ok) { throw new Error(`Failed to fetch WASM binary: ${resp.status} ${resp.statusText}`); } diff --git a/packages/ducpdf/src/duc2pdf/src/builder.rs b/packages/ducpdf/src/duc2pdf/src/builder.rs index 60a78b6..4eae945 100644 --- a/packages/ducpdf/src/duc2pdf/src/builder.rs +++ b/packages/ducpdf/src/duc2pdf/src/builder.rs @@ -8,8 +8,9 @@ use crate::utils::freedraw_bounds::{ 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, + 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::{ @@ -139,27 +140,22 @@ impl DucToPdfBuilder { 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)) + && 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)) + && 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)) + && elem.points.iter().all(|point| { + Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y) + }) } } } @@ -205,7 +201,9 @@ impl DucToPdfBuilder { 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()) { + if Self::has_usable_dimension(bounds.width()) + && Self::has_usable_dimension(bounds.height()) + { return Some(bounds); } @@ -299,20 +297,31 @@ impl DucToPdfBuilder { &exported_data, Some(user_scale), crop_offset, - ).is_ok(); + ) + .is_ok(); if fits { user_scale } else { // User scale doesn't keep coordinates in bounds; auto-calculate. 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) + 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) + calculate_required_scale_with_crop_dimensions( + &exported_data, + crop_offset, + crop_dimensions.0, + crop_dimensions.1, + ) } else { calculate_required_scale(&exported_data, crop_offset) } @@ -347,29 +356,39 @@ impl DucToPdfBuilder { 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())); + 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); - } + 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); + log_warn!( + "Failed to parse font '{}': {}. Will use fallback.", + family_name, + e + ); } } } // Create block instances map for duplication support - let block_instances: HashMap = context.exported_data + let block_instances: HashMap = context + .exported_data .block_instances .iter() .map(|bi| (bi.id.clone(), bi.clone())) @@ -690,18 +709,32 @@ impl DucToPdfBuilder { } /// Process SVG file and convert to PDF for later embedding - fn process_svg_file(&mut self, file: &DucExternalFile, rev_data: Option<&[u8]>) -> ConversionResult { - let revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + fn process_svg_file( + &mut self, + file: &DucExternalFile, + rev_data: Option<&[u8]>, + ) -> ConversionResult { + 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)) + ConversionError::ResourceLoadError(format!( + "No data blob for revision {}", + file.active_revision_id + )) })?; // Convert SVG to PDF using the utility and get dimensions - 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 (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)) + })?; // Load the PDF bytes for later embedding (don't embed now, save for when image elements reference it) let embed_id = format!("svg_{}", file.id); @@ -758,20 +791,32 @@ impl DucToPdfBuilder { } /// Process image file using hipdf::images for quality preservation - fn process_image_file(&mut self, file: &DucExternalFile, rev_data: Option<&[u8]>) -> ConversionResult { - let revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + fn process_image_file( + &mut self, + file: &DucExternalFile, + rev_data: Option<&[u8]>, + ) -> ConversionResult { + 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)) + ConversionError::ResourceLoadError(format!( + "No data blob for revision {}", + file.active_revision_id + )) })?; let mime_type = &revision.mime_type; // Create image directly from bytes (WASM-compatible) - 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 = Image::from_bytes(image_data.to_vec(), Some(file.id.clone())).map_err(|e| { + ConversionError::ResourceLoadError(format!("Failed to load image: {}", e)) + })?; // Embed the image with perfect quality preservation using hipdf::images let image_id = self @@ -788,8 +833,7 @@ impl DucToPdfBuilder { .insert(file.id.clone(), image_id.0); // Pass the image to the element streamer for streaming operations - self.element_streamer - .add_image(file.id.clone(), image_id.0); + self.element_streamer.add_image(file.id.clone(), image_id.0); // WORKAROUND: Also store by common test names based on MIME type // This handles the case where image elements reference by expected test names @@ -814,12 +858,25 @@ impl DucToPdfBuilder { } /// Process PDF file for embedding - fn process_pdf_file(&mut self, file: &DucExternalFile, rev_data: Option<&[u8]>) -> ConversionResult { - let revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + fn process_pdf_file( + &mut self, + file: &DucExternalFile, + rev_data: Option<&[u8]>, + ) -> ConversionResult { + 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)) + 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); @@ -952,7 +1009,9 @@ impl DucToPdfBuilder { }; // Calculate bounding box using the SVG path when available for accurate bounds - let svg_document = if let Some(bounds) = self.select_freedraw_bounds(freedraw) { + let svg_document = if let Some(bounds) = + self.select_freedraw_bounds(freedraw) + { // Cache the calculated bounding box for later use in stream_freedraw self.context .resource_cache @@ -963,13 +1022,25 @@ impl DucToPdfBuilder { let mut height = bounds.height(); if !Self::has_usable_dimension(width) { - width = freedraw.base.width.abs().max(freedraw.size.abs()).max(FREEDRAW_EPSILON); + 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); + height = freedraw + .base + .height + .abs() + .max(freedraw.size.abs()) + .max(FREEDRAW_EPSILON); } - if !Self::has_usable_dimension(width) || !Self::has_usable_dimension(height) { + 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, @@ -1043,10 +1114,8 @@ impl DucToPdfBuilder { } Err(e) => { // Log error but continue processing other elements - let svg_header_preview = svg_document - .split('>') - .next() - .unwrap_or_default(); + 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, @@ -1135,29 +1204,27 @@ impl DucToPdfBuilder { .filter_map(|elem| { if let DucElementEnum::DucPlotElement(plot) = &elem.element { let base = &plot.stack_element_base.base; - + // Skip deleted plot elements if base.is_deleted { return None; } - + // Extract background color and opacity from plot element // Only use background if it's visible and has a valid color let background_data = if !base.styles.background.is_empty() { - base.styles.background - .first() - .and_then(|bg| { - // Check if background is visible - if bg.content.visible && !bg.content.src.is_empty() { - Some((bg.content.src.clone(), bg.content.opacity)) - } else { - None - } - }) + base.styles.background.first().and_then(|bg| { + // Check if background is visible + 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), @@ -1173,8 +1240,14 @@ impl DucToPdfBuilder { 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)?; + 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, + )?; } } @@ -1197,7 +1270,6 @@ impl DucToPdfBuilder { // If width/height are specified, create a crop bounds that limits the visible area 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 @@ -1276,7 +1348,8 @@ impl DucToPdfBuilder { self.page_height = page_height; // Create content stream - let content_stream = self.create_content_stream(bounds, active_plot_id, page_background_color)?; + 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)); // Setup page resources including XObjects @@ -1431,13 +1504,13 @@ impl DucToPdfBuilder { Ok(resources) } -fn create_content_stream( + fn create_content_stream( &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; // Use bounds directly - data is already scaled by DucDataScaler + let (x, y, width, height) = bounds; // Use bounds directly - data is already scaled by DucDataScaler let mut operations = Vec::new(); @@ -1453,7 +1526,12 @@ fn create_content_stream( // - In CROP mode: use crop background option (with full opacity) 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)), + ConversionMode::Crop { .. } => self + .context + .options + .background_color + .as_deref() + .map(|color| (color, 1.0)), }; // Add background color if specified @@ -1464,7 +1542,7 @@ fn create_content_stream( 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 opacity is less than 1.0, we need to apply transparency // In PDF, we use the extended graphics state for this if (bg_opacity - 1.0).abs() > f64::EPSILON { @@ -1474,38 +1552,46 @@ fn create_content_stream( 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), - ])); + + operations.push(Operation::new( + "rg", + vec![ + Object::Real(r_blended), + Object::Real(g_blended), + Object::Real(b_blended), + ], + )); } else { // Full opacity - use color directly - operations.push(Operation::new("rg", vec![ - Object::Real(r_norm), - Object::Real(g_norm), - Object::Real(b_norm), - ])); + operations.push(Operation::new( + "rg", + vec![ + Object::Real(r_norm), + Object::Real(g_norm), + Object::Real(b_norm), + ], + )); } // Create a filled rectangle covering the entire page // Slightly overscan (1 unit on each side) to prevent aliasing artifacts at edges const OVERSCAN: f32 = 3.0; - operations.push(Operation::new("re", vec![ - Object::Real(-OVERSCAN), // x (start slightly left) - Object::Real(-OVERSCAN), // y (start slightly down) - Object::Real(width as f32 + OVERSCAN * 2.0), // width (extend right) - Object::Real(height as f32 + OVERSCAN * 2.0), // height (extend up) - ])); + operations.push(Operation::new( + "re", + vec![ + Object::Real(-OVERSCAN), // x (start slightly left) + Object::Real(-OVERSCAN), // y (start slightly down) + Object::Real(width as f32 + OVERSCAN * 2.0), // width (extend right) + Object::Real(height as f32 + OVERSCAN * 2.0), // height (extend up) + ], + )); operations.push(Operation::new("f", vec![])); // Fill the rectangle - + // Reset fill color to default (black) - operations.push(Operation::new("rg", vec![ - Object::Real(0.0), - Object::Real(0.0), - Object::Real(0.0), - ])); + 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.", @@ -1555,7 +1641,8 @@ fn create_content_stream( } ConversionMode::Plot => (x, y), }; - self.element_streamer.set_visible_scene_rect(vis_x, vis_y, width, height); + 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()); @@ -1635,27 +1722,23 @@ fn create_content_stream( let mut all_operations = Vec::new(); // Pre-process PDF elements to ensure they're embedded before streaming - 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(); + 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) { diff --git a/packages/ducpdf/src/duc2pdf/src/error_handling.rs b/packages/ducpdf/src/duc2pdf/src/error_handling.rs index 353cf07..ebed999 100644 --- a/packages/ducpdf/src/duc2pdf/src/error_handling.rs +++ b/packages/ducpdf/src/duc2pdf/src/error_handling.rs @@ -50,7 +50,11 @@ pub fn create_error_info( ) } ConversionError::PrecisionTooHigh(precision) => { - format!("Precision too high: {} (min allowed: {})", precision, crate::MIN_PRECISION_MM) + format!( + "Precision too high: {} (min allowed: {})", + precision, + crate::MIN_PRECISION_MM + ) } ConversionError::PdfGenerationError(msg) => { format!("PDF generation failed: {}", msg) @@ -66,7 +70,9 @@ pub fn create_error_info( crate::ConversionMode::Plot => false, }, has_dimensions: match &opts.mode { - crate::ConversionMode::Crop { width, height, .. } => width.is_some() && height.is_some(), + crate::ConversionMode::Crop { width, height, .. } => { + width.is_some() && height.is_some() + } crate::ConversionMode::Plot => false, }, has_zoom: false, // zoom is handled in JavaScript side @@ -97,18 +103,33 @@ pub fn log_error_details(error: &ConversionError, duc_data_length: usize, contex println!("Possible causes: corrupted DUC data, incomplete file transfer, or incompatible DUC version"); } ConversionError::CoordinateOutOfBounds(x, y) => { - println!("Coordinate out of bounds: x={}, y={} (max allowed: ±{})", x, y, crate::MAX_COORDINATE_MM); + println!( + "Coordinate out of bounds: x={}, y={} (max allowed: ±{})", + x, + y, + crate::MAX_COORDINATE_MM + ); println!("⚠️ This should be prevented by automatic scaling! Investigate the scaling logic in:"); println!(" - calculate_required_scale() function"); println!(" - validate_all_coordinates_with_scale() function"); println!(" - DucDataScaler scaling application"); } ConversionError::ScaleExceedsBounds(x, y, scale) => { - println!("Scale exceeds bounds: x={}, y={}, scale={} (max allowed: ±{})", x, y, scale, crate::MAX_COORDINATE_MM); + println!( + "Scale exceeds bounds: x={}, y={}, scale={} (max allowed: ±{})", + x, + y, + scale, + crate::MAX_COORDINATE_MM + ); println!("⚠️ User-provided scale still exceeds bounds! The scaling validation may need improvement."); } ConversionError::PrecisionTooHigh(precision) => { - println!("Precision too high: {} (min allowed: {})", precision, crate::MIN_PRECISION_MM); + println!( + "Precision too high: {} (min allowed: {})", + precision, + crate::MIN_PRECISION_MM + ); } ConversionError::PdfGenerationError(msg) => { println!("PDF generation failed: {}", msg); @@ -134,7 +155,13 @@ pub fn log_crop_details(offset_x: f64, offset_y: f64, width: Option, height } /// Validates basic input parameters for conversion operations -pub fn validate_basic_inputs(duc_data: &[u8], offset_x: Option, offset_y: Option, width: Option, height: Option) -> Result<(), String> { +pub fn validate_basic_inputs( + duc_data: &[u8], + offset_x: Option, + offset_y: Option, + width: Option, + height: Option, +) -> Result<(), String> { // Validate DUC data if duc_data.is_empty() { return Err("DUC data is empty".to_string()); @@ -171,10 +198,13 @@ pub fn validate_basic_inputs(duc_data: &[u8], offset_x: Option, offset_y: O /// Converts a ConversionError to a WASM-compatible byte vector pub fn error_to_wasm_bytes(error_info: &WasmErrorInfo) -> Vec { let error_json = serde_json::to_string(error_info).unwrap_or_else(|e| { - format!(r#"{{"error":"Failed to serialize error","details":"{}"}}"#, e) + format!( + r#"{{"error":"Failed to serialize error","details":"{}"}}"#, + e + ) }); let mut result = b"ERROR:".to_vec(); result.extend_from_slice(error_json.as_bytes()); result -} \ No newline at end of file +} diff --git a/packages/ducpdf/src/duc2pdf/src/lib.rs b/packages/ducpdf/src/duc2pdf/src/lib.rs index 0d3bc50..3316d02 100644 --- a/packages/ducpdf/src/duc2pdf/src/lib.rs +++ b/packages/ducpdf/src/duc2pdf/src/lib.rs @@ -517,9 +517,7 @@ fn deserialize_font_map(font_map_js: JsValue) -> HashMap> { return fonts; } - let entries = js_sys::try_iter(&font_map_js) - .ok() - .flatten(); + let entries = js_sys::try_iter(&font_map_js).ok().flatten(); if let Some(iter) = entries { for entry_result in iter { @@ -633,10 +631,18 @@ pub fn convert_exported_data_to_pdf_wasm( #[wasm_bindgen] pub fn convert_duc_to_pdf_with_fonts_rs(duc_data: &[u8], font_map_js: JsValue) -> Vec { let font_data = deserialize_font_map(font_map_js); - match convert_duc_to_pdf_with_fonts_and_options(duc_data, ConversionOptions::default(), font_data) { + match convert_duc_to_pdf_with_fonts_and_options( + duc_data, + ConversionOptions::default(), + font_data, + ) { Ok(pdf_bytes) => pdf_bytes, Err(e) => { - error_handling::log_error_details(&e, duc_data.len(), "Conversion with fonts (default options)"); + error_handling::log_error_details( + &e, + duc_data.len(), + "Conversion with fonts (default options)", + ); let error_info = error_handling::create_error_info(&e, duc_data.len(), None); error_handling::error_to_wasm_bytes(&error_info) } @@ -659,7 +665,11 @@ pub fn convert_duc_to_pdf_with_fonts_scaled_wasm( match convert_duc_to_pdf_with_fonts_and_options(duc_data, options, font_data) { Ok(pdf_bytes) => pdf_bytes, Err(e) => { - error_handling::log_error_details(&e, duc_data.len(), "Conversion with fonts and scale"); + error_handling::log_error_details( + &e, + duc_data.len(), + "Conversion with fonts and scale", + ); let options = ConversionOptions { scale: Some(scale), ..Default::default() @@ -781,7 +791,11 @@ pub fn convert_duc_to_pdf_crop_with_fonts_scaled_wasm( match convert_duc_to_pdf_with_fonts_and_options(duc_data, options, font_data) { Ok(pdf_bytes) => pdf_bytes, Err(e) => { - error_handling::log_error_details(&e, duc_data.len(), "Crop conversion with fonts and scale"); + error_handling::log_error_details( + &e, + duc_data.len(), + "Crop conversion with fonts and scale", + ); error_handling::log_crop_details(offset_x, offset_y, width, height); let crop_options = ConversionOptions { mode: ConversionMode::Crop { diff --git a/packages/ducpdf/src/duc2pdf/src/streaming/pdf_line_head.rs b/packages/ducpdf/src/duc2pdf/src/streaming/pdf_line_head.rs index 9d2165d..debcab9 100644 --- a/packages/ducpdf/src/duc2pdf/src/streaming/pdf_line_head.rs +++ b/packages/ducpdf/src/duc2pdf/src/streaming/pdf_line_head.rs @@ -1,450 +1,292 @@ -/// PDF Line Head Renderer -/// -/// This module renders line heads (arrows, triangles, etc.) at line endpoints. -/// It translates the Vello renderer's line head logic to PDF path operations. +//! PDF Line Head Renderer +//! +//! This module renders line heads (arrows, triangles, etc.) at line endpoints. +//! It translates the Pixi line head logic to PDF path operations. use crate::ConversionResult; +use bigcolor::BigColor; use duc::types::LINE_HEAD; use hipdf::lopdf::{content::Operation, Object}; use std::f64::consts::PI; pub struct PdfLineHeadRenderer; +#[derive(Clone, Copy)] +struct Point2D { + x: f64, + y: f64, +} + impl PdfLineHeadRenderer { - /// Render a line head at the specified position - /// - /// # Arguments - /// * `head_type` - Type of line head (arrow, triangle, etc.) - /// * `x, y` - Position of the line endpoint - /// * `dir_x, dir_y` - Direction vector of the line - /// * `line_width` - Width of the line - /// * `is_start` - Whether this is the start (true) or end (false) of the line + /// Render a line head using the same local DUC-space geometry as the Pixi renderer. pub fn render_line_head( head_type: LINE_HEAD, - x: f64, - y: f64, - dir_x: f64, - dir_y: f64, + tip: (f64, f64), + from: (f64, f64), line_width: f64, - is_start: bool, + color_src: &str, + size_scale: f64, ) -> ConversionResult> { let mut ops = Vec::new(); - // Calculate angle from direction - let angle = f64::atan2(dir_y, dir_x); - - // For start heads, rotate 180 degrees - let actual_angle = if is_start { angle + PI } else { angle }; - - // Calculate direction unit vector - let dir_length = (dir_x * dir_x + dir_y * dir_y).sqrt(); - let (dir_unit_x, dir_unit_y) = if dir_length > f64::EPSILON { - (dir_x / dir_length, dir_y / dir_length) - } else { - (1.0, 0.0) + let Some((r, g, b)) = Self::parse_color(color_src) else { + return Ok(ops); }; - // Calculate base offset for different head types - let base_offset = match head_type { - LINE_HEAD::ARROW | LINE_HEAD::REVERSED_ARROW => 0.0, - LINE_HEAD::TRIANGLE | LINE_HEAD::TRIANGLE_OUTLINED => -1.6 * line_width, - LINE_HEAD::CROSS | LINE_HEAD::OPEN_ARROW => 0.0, - LINE_HEAD::REVERSED_TRIANGLE | LINE_HEAD::REVERSED_TRIANGLE_OUTLINED => { - -2.8 * line_width - } - LINE_HEAD::CIRCLE | LINE_HEAD::CIRCLE_OUTLINED => -1.7 * line_width, - LINE_HEAD::DIAMOND | LINE_HEAD::DIAMOND_OUTLINED => -0.6 * line_width, - LINE_HEAD::BAR => 0.0, - LINE_HEAD::CONE | LINE_HEAD::HALF_CONE => -0.4 * line_width, + let tip = Point2D { x: tip.0, y: tip.1 }; + let from = Point2D { + x: from.0, + y: from.1, }; - - // Calculate offset direction - let (offset_dir_x, offset_dir_y) = if is_start { - (-dir_unit_x, -dir_unit_y) + let line_width = if line_width.is_finite() && line_width > 0.0 { + line_width + } else { + 1.0 + }; + let normalized_size = if size_scale.is_finite() && size_scale > 0.0 { + size_scale } else { - (dir_unit_x, dir_unit_y) + 1.0 }; - // Apply offset - let final_x = x + (base_offset * offset_dir_x); - let final_y = y + (base_offset * offset_dir_y); + let angle = Self::get_line_head_angle(head_type, tip, from); + let length = (line_width * 4.0).max(8.0) * normalized_size; + let width = (line_width * 3.0).max(6.0) * normalized_size; + let back = Self::point_from_angle(tip, angle + PI, length); + let middle = Self::point_from_angle(tip, angle + PI, length * 0.5); + let left = Self::offset_perpendicular(back, angle, width * 0.5); + let right = Self::offset_perpendicular(back, angle, -width * 0.5); - // Save graphics state ops.push(Operation::new("q", vec![])); - - // Transform: translate to position, then rotate - let cos_angle = actual_angle.cos(); - let sin_angle = actual_angle.sin(); - ops.push(Operation::new( - "cm", - vec![ - Object::Real(cos_angle as f32), - Object::Real(sin_angle as f32), - Object::Real(-sin_angle as f32), - Object::Real(cos_angle as f32), - Object::Real(final_x as f32), - Object::Real(final_y as f32), - ], + "RG", + vec![Object::Real(r), Object::Real(g), Object::Real(b)], + )); + ops.push(Operation::new( + "rg", + vec![Object::Real(r), Object::Real(g), Object::Real(b)], + )); + ops.push(Operation::new("w", vec![Object::Real(line_width as f32)])); + ops.push(Operation::new("J", vec![Object::Integer(0)])); + ops.push(Operation::new("j", vec![Object::Integer(0)])); + ops.push(Operation::new( + "d", + vec![Object::Array(Vec::new()), Object::Real(0.0)], )); - // Render specific head type - ops.extend(Self::render_head_shape(head_type, line_width)?); + match head_type { + LINE_HEAD::BAR => { + Self::draw_bar_head(&mut ops, tip, angle, width * 1.15); + } + LINE_HEAD::CIRCLE | LINE_HEAD::CIRCLE_OUTLINED => { + Self::draw_circle_head( + &mut ops, + tip, + length * 0.32, + head_type == LINE_HEAD::CIRCLE, + ); + } + LINE_HEAD::DIAMOND | LINE_HEAD::DIAMOND_OUTLINED => { + let far = Self::point_from_angle(tip, angle + PI, length * 1.15); + let diamond_left = Self::offset_perpendicular(middle, angle, width * 0.55); + let diamond_right = Self::offset_perpendicular(middle, angle, -width * 0.55); + Self::draw_polygon_head( + &mut ops, + &[tip, diamond_left, far, diamond_right], + head_type == LINE_HEAD::DIAMOND, + ); + } + LINE_HEAD::CROSS => { + Self::draw_cross_head(&mut ops, tip, angle, width * 0.7); + } + LINE_HEAD::TRIANGLE + | LINE_HEAD::TRIANGLE_OUTLINED + | LINE_HEAD::REVERSED_TRIANGLE + | LINE_HEAD::REVERSED_TRIANGLE_OUTLINED => { + Self::draw_polygon_head( + &mut ops, + &[tip, left, right], + head_type != LINE_HEAD::TRIANGLE_OUTLINED + && head_type != LINE_HEAD::REVERSED_TRIANGLE_OUTLINED, + ); + } + LINE_HEAD::CONE => { + Self::draw_cone_head(&mut ops, tip, angle, length, width, false); + } + LINE_HEAD::HALF_CONE => { + Self::draw_cone_head(&mut ops, tip, angle, length, width, true); + } + LINE_HEAD::ARROW | LINE_HEAD::REVERSED_ARROW => { + Self::draw_open_arrow_head(&mut ops, tip, angle, length, width); + } + LINE_HEAD::OPEN_ARROW => { + Self::draw_slash_head(&mut ops, tip, angle, length); + } + } - // Restore graphics state ops.push(Operation::new("Q", vec![])); Ok(ops) } - /// Render the specific head shape - fn render_head_shape( - head_type: LINE_HEAD, - line_width: f64, - ) -> ConversionResult> { - let mut ops = Vec::new(); + fn parse_color(color_src: &str) -> Option<(f32, f32, f32)> { + if color_src.trim().is_empty() || color_src == "transparent" { + return None; + } - match head_type { - LINE_HEAD::ARROW => { - // Simple arrow: two lines forming a V - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![ - Object::Real(-size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new("S", vec![])); - } + let color = BigColor::new(color_src); + let rgb = color.to_rgb(); + Some(( + rgb.r as f32 / 255.0, + rgb.g as f32 / 255.0, + rgb.b as f32 / 255.0, + )) + } - LINE_HEAD::BAR => { - // Perpendicular bar - let size = line_width * 2.0; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real((-size / 2.0) as f32)], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(0.0), Object::Real((size / 2.0) as f32)], - )); - ops.push(Operation::new("S", vec![])); - } + fn get_line_head_angle(head_type: LINE_HEAD, tip: Point2D, from: Point2D) -> f64 { + let base_angle = (tip.y - from.y).atan2(tip.x - from.x); + match head_type { + LINE_HEAD::REVERSED_ARROW + | LINE_HEAD::REVERSED_TRIANGLE + | LINE_HEAD::REVERSED_TRIANGLE_OUTLINED => base_angle + PI, + _ => base_angle, + } + } - LINE_HEAD::CIRCLE => { - // Filled circle - let radius = line_width * 1.5; - ops.extend(Self::create_circle(0.0, 0.0, radius, true)?); - } + fn point_from_angle(point: Point2D, angle: f64, distance: f64) -> Point2D { + Point2D { + x: point.x + angle.cos() * distance, + y: point.y + angle.sin() * distance, + } + } - LINE_HEAD::CIRCLE_OUTLINED => { - // Outlined circle - let radius = line_width * 1.5; - ops.extend(Self::create_circle(0.0, 0.0, radius, false)?); - } + fn offset_perpendicular(point: Point2D, angle: f64, distance: f64) -> Point2D { + Point2D { + x: point.x + (angle + PI / 2.0).cos() * distance, + y: point.y + (angle + PI / 2.0).sin() * distance, + } + } - LINE_HEAD::TRIANGLE => { - // Filled triangle - let size = line_width * 3.0; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("B", vec![])); // Fill and stroke - } + fn move_to(ops: &mut Vec, point: Point2D) { + ops.push(Operation::new( + "m", + vec![ + Object::Real(point.x as f32), + Object::Real((-point.y) as f32), + ], + )); + } - LINE_HEAD::TRIANGLE_OUTLINED => { - // Outlined triangle - let size = line_width * 3.0; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("S", vec![])); // Stroke only - } + fn line_to(ops: &mut Vec, point: Point2D) { + ops.push(Operation::new( + "l", + vec![ + Object::Real(point.x as f32), + Object::Real((-point.y) as f32), + ], + )); + } - LINE_HEAD::DIAMOND => { - // Filled diamond - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(-size as f32), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("B", vec![])); // Fill and stroke - } + fn draw_open_arrow_head( + ops: &mut Vec, + tip: Point2D, + angle: f64, + length: f64, + width: f64, + ) { + let back = Self::point_from_angle(tip, angle + PI, length); + let left = Self::offset_perpendicular(back, angle, width * 0.5); + let right = Self::offset_perpendicular(back, angle, -width * 0.5); + + Self::move_to(ops, tip); + Self::line_to(ops, left); + Self::move_to(ops, tip); + Self::line_to(ops, right); + ops.push(Operation::new("S", vec![])); + } - LINE_HEAD::DIAMOND_OUTLINED => { - // Outlined diamond - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(-size as f32), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("S", vec![])); // Stroke only - } + fn draw_slash_head(ops: &mut Vec, tip: Point2D, angle: f64, length: f64) { + let slash_angle = angle - PI / 3.0; + let half_length = length * 0.48; + let a = Self::point_from_angle(tip, slash_angle, half_length); + let b = Self::point_from_angle(tip, slash_angle + PI, half_length); - LINE_HEAD::CROSS => { - // X shape - let size = line_width * 2.0; - ops.push(Operation::new( - "m", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((size / 2.0) as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "m", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((size / 2.0) as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("S", vec![])); - } + Self::move_to(ops, a); + Self::line_to(ops, b); + ops.push(Operation::new("S", vec![])); + } - LINE_HEAD::OPEN_ARROW => { - // Open V arrow - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real((-size / 2.0) as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new("S", vec![])); - } + fn draw_polygon_head(ops: &mut Vec, points: &[Point2D], filled: bool) { + if points.is_empty() { + return; + } - LINE_HEAD::REVERSED_ARROW => { - // Reversed arrow (points backward) - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![ - Object::Real(size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(size as f32), Object::Real((size / 2.0) as f32)], - )); - ops.push(Operation::new("S", vec![])); - } + Self::move_to(ops, points[0]); + for point in &points[1..] { + Self::line_to(ops, *point); + } + ops.push(Operation::new("h", vec![])); + ops.push(Operation::new(if filled { "B" } else { "S" }, vec![])); + } - LINE_HEAD::REVERSED_TRIANGLE => { - // Reversed filled triangle - let size = line_width * 3.0; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(size as f32), Object::Real((size / 2.0) as f32)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("B", vec![])); - } + fn draw_cone_head( + ops: &mut Vec, + tip: Point2D, + angle: f64, + length: f64, + width: f64, + half_only: bool, + ) { + let inner = Self::point_from_angle(tip, angle + PI, length * 0.9); + let center_base = Self::point_from_angle(tip, angle + PI, length * 0.08); + let upper = Self::offset_perpendicular(tip, angle, width * 0.58); + let lower = Self::offset_perpendicular(tip, angle, -width * 0.58); + + Self::move_to(ops, center_base); + Self::line_to(ops, upper); + Self::line_to(ops, inner); + ops.push(Operation::new("h", vec![])); + + if !half_only { + Self::move_to(ops, center_base); + Self::line_to(ops, lower); + Self::line_to(ops, inner); + ops.push(Operation::new("h", vec![])); + } - LINE_HEAD::REVERSED_TRIANGLE_OUTLINED => { - // Reversed outlined triangle - let size = line_width * 3.0; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(size as f32), Object::Real((size / 2.0) as f32)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("S", vec![])); - } + ops.push(Operation::new("S", vec![])); + } - LINE_HEAD::CONE => { - // Filled cone - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((-size / 2.0) as f32), - ], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("B", vec![])); - } + fn draw_circle_head(ops: &mut Vec, center: Point2D, radius: f64, filled: bool) { + ops.extend(Self::create_circle(center, radius, filled)); + } - LINE_HEAD::HALF_CONE => { - // Half cone (one side only) - let size = line_width * 2.5; - ops.push(Operation::new( - "m", - vec![Object::Real(0.0), Object::Real(0.0)], - )); - ops.push(Operation::new( - "l", - vec![ - Object::Real(-size as f32), - Object::Real((size / 2.0) as f32), - ], - )); - ops.push(Operation::new( - "l", - vec![Object::Real(-size as f32), Object::Real(0.0)], - )); - ops.push(Operation::new("h", vec![])); - ops.push(Operation::new("B", vec![])); - } + fn draw_bar_head(ops: &mut Vec, tip: Point2D, angle: f64, size: f64) { + let left = Self::offset_perpendicular(tip, angle, size * 0.5); + let right = Self::offset_perpendicular(tip, angle, -size * 0.5); - } + Self::move_to(ops, left); + Self::line_to(ops, right); + ops.push(Operation::new("S", vec![])); + } - Ok(ops) + fn draw_cross_head(ops: &mut Vec, tip: Point2D, angle: f64, size: f64) { + let a = Self::point_from_angle(tip, angle + PI / 4.0, size); + let b = Self::point_from_angle(tip, angle + PI + PI / 4.0, size); + let c = Self::point_from_angle(tip, angle - PI / 4.0, size); + let d = Self::point_from_angle(tip, angle + PI - PI / 4.0, size); + + Self::move_to(ops, a); + Self::line_to(ops, b); + Self::move_to(ops, c); + Self::line_to(ops, d); + ops.push(Operation::new("S", vec![])); } /// Create a circle using Bezier curves - fn create_circle( - cx: f64, - cy: f64, - radius: f64, - filled: bool, - ) -> ConversionResult> { + fn create_circle(center: Point2D, radius: f64, filled: bool) -> Vec { let mut ops = Vec::new(); + let cx = center.x; + let cy = -center.y; // Magic number for Bezier circle approximation let kappa = 0.5522848 * radius; @@ -515,6 +357,6 @@ impl PdfLineHeadRenderer { ops.push(Operation::new("S", vec![])); // Stroke only } - Ok(ops) + ops } } diff --git a/packages/ducpdf/src/duc2pdf/src/streaming/pdf_linear.rs b/packages/ducpdf/src/duc2pdf/src/streaming/pdf_linear.rs index 5efa50b..14a2271 100644 --- a/packages/ducpdf/src/duc2pdf/src/streaming/pdf_linear.rs +++ b/packages/ducpdf/src/duc2pdf/src/streaming/pdf_linear.rs @@ -1,15 +1,16 @@ -/// PDF Linear Element Renderer -/// -/// This module handles the rendering of DucLinearElement to PDF operations. -/// It translates the Vello renderer's BezPath logic into PDF path construction -/// commands, supporting: -/// - Straight lines and Bezier curves (quadratic and cubic) -/// - Closed and open paths -/// - Fill paths with minimal cycles (faces) -/// - Stroke paths with all segments -/// - Path overrides for selective styling +//! PDF Linear Element Renderer +//! +//! This module handles the rendering of DucLinearElement to PDF operations. +//! It translates the Vello renderer's BezPath logic into PDF path construction +//! commands, supporting: +//! - Straight lines and Bezier curves (quadratic and cubic) +//! - Closed and open paths +//! - Fill paths with minimal cycles (faces) +//! - Stroke paths with all segments +//! - Path overrides for selective styling +use crate::streaming::pdf_line_head::PdfLineHeadRenderer; use crate::ConversionResult; -use duc::types::{DucLine, DucLinearElement, DucPoint, GeometricPoint}; +use duc::types::{DucLine, DucLinearElement, DucPoint, GeometricPoint, LINE_HEAD}; use hipdf::lopdf::{content::Operation, Object}; use std::collections::{BTreeMap, HashSet}; @@ -27,15 +28,23 @@ impl PdfLinearRenderer { return Ok(ops); } - // Create stroke path (all segments) - let stroke_path_ops = Self::create_stroke_path(points, lines)?; - // Create fill paths (minimal closed loops only) let fill_paths_ops = Self::create_fill_paths(points, lines)?; // Determine what to render based on styles let has_background = !linear.linear_base.base.styles.background.is_empty(); - let has_stroke = !linear.linear_base.base.styles.stroke.is_empty(); + let visible_stroke = linear + .linear_base + .base + .styles + .stroke + .iter() + .find(|stroke| stroke.content.visible); + let has_stroke = visible_stroke.is_some(); + let stroke_width = visible_stroke + .map(|stroke| stroke.width) + .filter(|width| width.is_finite() && *width > 0.0) + .unwrap_or(1.0); if has_background && !fill_paths_ops.is_empty() { // Render fills first @@ -43,16 +52,248 @@ impl PdfLinearRenderer { if has_stroke { // Then render strokes on top + let stroke_points = Self::get_trimmed_linear_points(linear, points, stroke_width); + let stroke_path_ops = Self::create_stroke_path(&stroke_points, lines)?; ops.extend(stroke_path_ops); } } else if has_stroke { // Stroke only + let stroke_points = Self::get_trimmed_linear_points(linear, points, stroke_width); + let stroke_path_ops = Self::create_stroke_path(&stroke_points, lines)?; ops.extend(stroke_path_ops); } + ops.extend(Self::render_line_heads(linear, points, stroke_width)?); + + Ok(ops) + } + + fn render_line_heads( + linear: &DucLinearElement, + points: &[DucPoint], + line_width: f64, + ) -> ConversionResult> { + let mut ops = Vec::new(); + + if points.len() < 2 { + return Ok(ops); + } + + let Some(stroke) = linear.linear_base.base.styles.stroke.first() else { + return Ok(ops); + }; + if !stroke.content.visible || stroke.content.src.trim().is_empty() { + return Ok(ops); + } + + if let Some(binding) = &linear.linear_base.start_binding { + if let Some(head) = &binding.head { + if let Some(head_type) = head.head_type { + let (tip, from) = Self::get_line_head_reference(linear, points, 0); + ops.extend(PdfLineHeadRenderer::render_line_head( + head_type, + tip, + from, + line_width, + &stroke.content.src, + head.size, + )?); + } + } + } + + if let Some(binding) = &linear.linear_base.end_binding { + if let Some(head) = &binding.head { + if let Some(head_type) = head.head_type { + let end_index = points.len() - 1; + let (tip, from) = Self::get_line_head_reference(linear, points, end_index); + ops.extend(PdfLineHeadRenderer::render_line_head( + head_type, + tip, + from, + line_width, + &stroke.content.src, + head.size, + )?); + } + } + } + Ok(ops) } + fn get_trimmed_linear_points( + linear: &DucLinearElement, + points: &[DucPoint], + line_width: f64, + ) -> Vec { + if points.len() < 2 { + return points.to_vec(); + } + + let start_clearance = linear + .linear_base + .start_binding + .as_ref() + .and_then(|binding| binding.head.as_ref()) + .and_then(|head| head.head_type.map(|head_type| (head_type, head.size))) + .map(|(head_type, size)| Self::get_line_head_clearance(head_type, line_width, size)) + .unwrap_or(0.0); + + let end_clearance = linear + .linear_base + .end_binding + .as_ref() + .and_then(|binding| binding.head.as_ref()) + .and_then(|head| head.head_type.map(|head_type| (head_type, head.size))) + .map(|(head_type, size)| Self::get_line_head_clearance(head_type, line_width, size)) + .unwrap_or(0.0); + + if start_clearance <= 0.0 && end_clearance <= 0.0 { + return points.to_vec(); + } + + let mut trimmed = points.to_vec(); + if start_clearance > 0.0 { + trimmed[0] = Self::trim_endpoint_point(linear, points, 0, start_clearance); + } + if end_clearance > 0.0 { + let last_index = points.len() - 1; + trimmed[last_index] = + Self::trim_endpoint_point(linear, points, last_index, end_clearance); + } + + trimmed + } + + fn get_line_head_clearance(head_type: LINE_HEAD, line_width: f64, size_scale: f64) -> f64 { + let normalized_size = if size_scale.is_finite() && size_scale > 0.0 { + size_scale + } else { + 1.0 + }; + let width = if line_width.is_finite() && line_width > 0.0 { + line_width + } else { + 1.0 + }; + let length = (width * 4.0).max(8.0) * normalized_size; + + match head_type { + LINE_HEAD::CIRCLE_OUTLINED => length * 0.28 + width * 0.2, + LINE_HEAD::TRIANGLE_OUTLINED | LINE_HEAD::DIAMOND_OUTLINED => { + length * 0.95 + width * 0.5 + } + _ => 0.0, + } + } + + fn trim_endpoint_point( + linear: &DucLinearElement, + points: &[DucPoint], + point_index: usize, + distance: f64, + ) -> DucPoint { + let point = &points[point_index]; + let interior = Self::get_endpoint_interior_vector(linear, points, point_index); + Self::trim_point_along_vector(point, interior, distance) + } + + fn trim_point_along_vector(point: &DucPoint, direction: (f64, f64), distance: f64) -> DucPoint { + let direction_length = (direction.0 * direction.0 + direction.1 * direction.1).sqrt(); + if direction_length <= 0.001 { + return point.clone(); + } + + let normalized = Self::normalize_vector(direction.0, direction.1); + let amount = distance.min((direction_length - 0.5).max(0.0)); + let mut trimmed = point.clone(); + trimmed.x += normalized.0 * amount; + trimmed.y += normalized.1 * amount; + trimmed + } + + fn get_line_head_reference( + linear: &DucLinearElement, + points: &[DucPoint], + point_index: usize, + ) -> ((f64, f64), (f64, f64)) { + let point = &points[point_index]; + let tip = (point.x, point.y); + let interior_vector = Self::get_endpoint_interior_vector(linear, points, point_index); + let interior = Self::normalize_vector(interior_vector.0, interior_vector.1); + + (tip, (tip.0 + interior.0, tip.1 + interior.1)) + } + + fn get_endpoint_line<'a>( + linear: &'a DucLinearElement, + points: &[DucPoint], + point_index: usize, + ) -> Option<&'a DucLine> { + let lines = &linear.linear_base.lines; + let preferred_neighbor = if point_index == 0 { + 1 + } else { + points.len() as i32 - 2 + }; + let point_index = point_index as i32; + + lines + .iter() + .find(|line| { + (line.start.index == point_index && line.end.index == preferred_neighbor) + || (line.end.index == point_index && line.start.index == preferred_neighbor) + }) + .or_else(|| { + lines + .iter() + .find(|line| line.start.index == point_index || line.end.index == point_index) + }) + } + + fn get_endpoint_interior_vector( + linear: &DucLinearElement, + points: &[DucPoint], + point_index: usize, + ) -> (f64, f64) { + let point = &points[point_index]; + let tip = (point.x, point.y); + let Some(line) = Self::get_endpoint_line(linear, points, point_index) else { + let fallback_index = if point_index == 0 { + 1 + } else { + points.len().saturating_sub(2) + }; + let fallback = points.get(fallback_index).unwrap_or(point); + return (fallback.x - tip.0, fallback.y - tip.1); + }; + + let is_start_ref = line.start.index == point_index as i32; + let endpoint_ref = if is_start_ref { &line.start } else { &line.end }; + let neighbor_ref = if is_start_ref { &line.end } else { &line.start }; + + if let Some(handle) = endpoint_ref + .handle + .as_ref() + .or(neighbor_ref.handle.as_ref()) + { + return (handle.x - tip.0, handle.y - tip.1); + } + + let neighbor = points.get(neighbor_ref.index as usize).unwrap_or(point); + (neighbor.x - tip.0, neighbor.y - tip.1) + } + + fn normalize_vector(x: f64, y: f64) -> (f64, f64) { + let length = (x * x + y * y).sqrt(); + if length < 0.001 { + (1.0, 0.0) + } else { + (x / length, y / length) + } + } + /// Transform a Duc point from the top-left DUC system to PDF local coordinates /// (origin at top-left after the element translation, positive Y upwards in PDF) fn transform_point_to_pdf(point: &DucPoint) -> (f64, f64) { @@ -325,7 +566,6 @@ impl PdfLinearRenderer { ) -> ConversionResult> { let mut ops = Vec::new(); - let (p3_x, p3_y) = Self::transform_point_to_pdf(end_point); match (start_handle, end_handle) { @@ -362,8 +602,6 @@ impl PdfLinearRenderer { // Quadratic curve (single handle) - convert to cubic _ if start_handle.is_some() || end_handle.is_some() => { if let Some(h) = start_handle.as_ref().or(end_handle.as_ref()) { - - // Convert quadratic to cubic using original coordinates, then transform the result. // cp1 = p0 + 2/3 * (c - p0) let cp1_x_orig = start_point.x + (2.0 / 3.0) * (h.x - start_point.x); diff --git a/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs b/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs index 767a389..5627357 100644 --- a/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs +++ b/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs @@ -31,12 +31,12 @@ use crate::utils::style_resolver::{ResolvedStyles, StyleResolver}; use crate::{ConversionError, ConversionResult}; use bigcolor::BigColor; use duc::types::{ - BEZIER_MIRRORING, ELEMENT_CONTENT_PREFERENCE, STROKE_CAP, STROKE_JOIN, DucElementEnum, - DucArrowElement, DucEllipseElement, DucFrameElement, - DucDocElement, DucFreeDrawElement, DucImageElement, DucLine, DucLineReference, DucLinearElement, - DucLinearElementBase, DucPath, DucPdfElement, DucPlotElement, DucPoint, - DucPolygonElement, DucRectangleElement, DucTableElement, DucTextElement, DucModelElement, ElementBackground, - ElementContentBase, ElementWrapper, GeometricPoint, DucBlockInstance, DucBlockDuplicationArray, + DucArrowElement, DucBlockDuplicationArray, DucBlockInstance, DucDocElement, DucElementEnum, + DucEllipseElement, DucFrameElement, DucFreeDrawElement, DucImageElement, DucLine, + DucLineReference, DucLinearElement, DucLinearElementBase, DucModelElement, DucPath, + DucPdfElement, DucPlotElement, DucPoint, DucPolygonElement, DucRectangleElement, + DucTableElement, DucTextElement, ElementBackground, ElementContentBase, ElementWrapper, + GeometricPoint, BEZIER_MIRRORING, ELEMENT_CONTENT_PREFERENCE, STROKE_CAP, STROKE_JOIN, }; use hipdf::embed_pdf::PdfEmbedder; @@ -169,8 +169,7 @@ impl ElementStreamer { cell_height: f64, ) -> Vec<(f64, f64)> { if duplication_array.row_spacing.is_nan() || duplication_array.col_spacing.is_nan() { - - log::warn!( + log::warn!( "Duplication array has NaN spacing! row_spacing: {}, col_spacing: {}", duplication_array.row_spacing, duplication_array.col_spacing @@ -210,11 +209,7 @@ impl ElementStreamer { (cell_width.max(0.0), cell_height.max(0.0)) } - fn rotate_point_around_center( - point: (f64, f64), - center: (f64, f64), - angle: f64, - ) -> (f64, f64) { + fn rotate_point_around_center(point: (f64, f64), center: (f64, f64), angle: f64) -> (f64, f64) { if angle == 0.0 { return point; } @@ -404,7 +399,8 @@ impl ElementStreamer { let row_spacing = dup_array.row_spacing; let footprint_width = pitch_w * cols as f64 + (cols as f64 - 1.0) * col_spacing; - let footprint_height = pitch_h * rows as f64 + (rows as f64 - 1.0) * row_spacing; + let footprint_height = + pitch_h * rows as f64 + (rows as f64 - 1.0) * row_spacing; let (bx1, by1, _bx2, _by2, _fcx, _fcy) = self.get_duplication_footprint_coords(element, Some(dup_array)); @@ -433,7 +429,10 @@ impl ElementStreamer { } } } else { - log::info!("Element refers to instance {} which is missing from block_instances!", instance_id); + log::info!( + "Element refers to instance {} which is missing from block_instances!", + instance_id + ); } None } @@ -441,10 +440,7 @@ impl ElementStreamer { /// Create a per-cell renderable element for duplication-array rendering. /// The exported element stores total grid dimensions, but each rendered copy /// needs the single-cell size. - pub fn get_renderable_duplication_element( - &self, - element: &DucElementEnum, - ) -> DucElementEnum { + pub fn get_renderable_duplication_element(&self, element: &DucElementEnum) -> DucElementEnum { let base = Self::get_element_base(element); let Some(instance_id) = base.instance_id.as_ref() else { return element.clone(); @@ -472,16 +468,26 @@ impl ElementStreamer { DucElementEnum::DucRectangleElement(r) => (r.base.width, r.base.height), DucElementEnum::DucEllipseElement(e) => (e.base.width, e.base.height), DucElementEnum::DucImageElement(i) => (i.base.width, i.base.height), - DucElementEnum::DucFrameElement(f) => (f.stack_element_base.base.width, f.stack_element_base.base.height), - DucElementEnum::DucPlotElement(p) => (p.stack_element_base.base.width, p.stack_element_base.base.height), + DucElementEnum::DucFrameElement(f) => ( + f.stack_element_base.base.width, + f.stack_element_base.base.height, + ), + DucElementEnum::DucPlotElement(p) => ( + p.stack_element_base.base.width, + p.stack_element_base.base.height, + ), DucElementEnum::DucTableElement(t) => (t.base.width, t.base.height), DucElementEnum::DucDocElement(d) => (d.base.width, d.base.height), DucElementEnum::DucEmbeddableElement(e) => (e.base.width, e.base.height), DucElementEnum::DucPolygonElement(p) => (p.base.width, p.base.height), DucElementEnum::DucTextElement(t) => (t.base.width, t.base.height), DucElementEnum::DucFreeDrawElement(f) => (f.base.width, f.base.height), - DucElementEnum::DucLinearElement(l) => (l.linear_base.base.width, l.linear_base.base.height), - DucElementEnum::DucArrowElement(a) => (a.linear_base.base.width, a.linear_base.base.height), + DucElementEnum::DucLinearElement(l) => { + (l.linear_base.base.width, l.linear_base.base.height) + } + DucElementEnum::DucArrowElement(a) => { + (a.linear_base.base.width, a.linear_base.base.height) + } DucElementEnum::DucPdfElement(p) => (p.base.width, p.base.height), DucElementEnum::DucModelElement(m) => (m.base.width, m.base.height), } @@ -558,11 +564,7 @@ impl ElementStreamer { element } - fn with_element_position( - mut element: DucElementEnum, - x: f64, - y: f64, - ) -> DucElementEnum { + fn with_element_position(mut element: DucElementEnum, x: f64, y: f64) -> DucElementEnum { match &mut element { DucElementEnum::DucRectangleElement(r) => { r.base.x = x; @@ -810,8 +812,8 @@ impl ElementStreamer { clip_applied = clip_active; } - let renderable_element = self - .get_renderable_duplication_element(&element_wrapper.element); + let renderable_element = + self.get_renderable_duplication_element(&element_wrapper.element); let offsets = self .get_element_duplication_offsets(&element_wrapper.element) @@ -939,7 +941,10 @@ impl ElementStreamer { // Special handling: PDF elements - do not apply styles to avoid affecting embedded content let styles = self.style_resolver.resolve_styles(element); - let is_pdf = matches!(element, DucElementEnum::DucPdfElement(_) | DucElementEnum::DucDocElement(_)); + let is_pdf = matches!( + element, + DucElementEnum::DucPdfElement(_) | DucElementEnum::DucDocElement(_) + ); if !is_pdf { let style_ops = self.apply_styles(element, &styles)?; operations.extend(style_ops); @@ -1786,11 +1791,17 @@ impl ElementStreamer { ops.push(Operation::new( "m", - vec![Object::Real((x + radius) as f32), Object::Real(top_y as f32)], + vec![ + Object::Real((x + radius) as f32), + Object::Real(top_y as f32), + ], )); ops.push(Operation::new( "l", - vec![Object::Real((right - radius) as f32), Object::Real(top_y as f32)], + vec![ + Object::Real((right - radius) as f32), + Object::Real(top_y as f32), + ], )); ops.push(Operation::new( "c", @@ -1805,7 +1816,10 @@ impl ElementStreamer { )); ops.push(Operation::new( "l", - vec![Object::Real(right as f32), Object::Real((bottom + radius) as f32)], + vec![ + Object::Real(right as f32), + Object::Real((bottom + radius) as f32), + ], )); ops.push(Operation::new( "c", @@ -1820,7 +1834,10 @@ impl ElementStreamer { )); ops.push(Operation::new( "l", - vec![Object::Real((x + radius) as f32), Object::Real(bottom as f32)], + vec![ + Object::Real((x + radius) as f32), + Object::Real(bottom as f32), + ], )); ops.push(Operation::new( "c", @@ -1835,7 +1852,10 @@ impl ElementStreamer { )); ops.push(Operation::new( "l", - vec![Object::Real(x as f32), Object::Real((top_y - radius) as f32)], + vec![ + Object::Real(x as f32), + Object::Real((top_y - radius) as f32), + ], )); ops.push(Operation::new( "c", @@ -1857,7 +1877,12 @@ impl ElementStreamer { height: f64, roundness: f64, ) -> bool { - if roundness <= 0.01 || width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() { + if roundness <= 0.01 + || width <= 0.0 + || height <= 0.0 + || !width.is_finite() + || !height.is_finite() + { return false; } @@ -1878,7 +1903,10 @@ impl ElementStreamer { // Handle filling and stroking with hatching support let styles = &rect.base.styles; - let has_background = styles.background.iter().any(|background| background.content.visible); + let has_background = styles + .background + .iter() + .any(|background| background.content.visible); let has_stroke = styles.stroke.iter().any(|stroke| stroke.content.visible); // Check for hatching patterns in backgrounds @@ -2023,14 +2051,24 @@ impl ElementStreamer { // Estimate total text height for vertical alignment let line_count = { - let max_w = if text.auto_resize { None } else { Some(text.base.width as f32) }; + let max_w = if text.auto_resize { + None + } else { + Some(text.base.width as f32) + }; let paragraphs: Vec<&str> = resolved_text.split('\n').collect(); let mut count = 0usize; for para in ¶graphs { if para.is_empty() { count += 1; } else if let Some(w) = max_w { - let wrapped = hipdf::fonts::utils::wrap_text(active_font, para, w, font_size, wrap_strategy); + let wrapped = hipdf::fonts::utils::wrap_text( + active_font, + para, + w, + font_size, + wrap_strategy, + ); count += wrapped.len().max(1); } else { count += 1; @@ -2042,12 +2080,8 @@ impl ElementStreamer { // Apply vertical alignment let text_start_y = match text.style.vertical_align { - VERTICAL_ALIGN::MIDDLE => { - -(font_size + (element_height - total_text_height) / 2.0) - } - VERTICAL_ALIGN::BOTTOM => { - -(element_height) - } + VERTICAL_ALIGN::MIDDLE => -(font_size + (element_height - total_text_height) / 2.0), + VERTICAL_ALIGN::BOTTOM => -(element_height), // TOP or default _ => -font_size, }; @@ -2116,7 +2150,8 @@ impl ElementStreamer { fn convert_polygon_to_linear_element(polygon: &DucPolygonElement) -> DucLinearElement { let sides = polygon.sides.max(3); - let base_points = Self::generate_polygon_points(sides, polygon.base.width, polygon.base.height); + let base_points = + Self::generate_polygon_points(sides, polygon.base.width, polygon.base.height); let roundness = polygon.base.styles.roundness.max(0.0); let (points, lines) = if roundness > 0.01 { Self::generate_rounded_polygon_path(&base_points, roundness) @@ -2762,7 +2797,10 @@ impl ElementStreamer { let info = match pdf_embedder.get_pdf_info(&embed_id) { Some(info) => info.clone(), None => { - log::info!("[duc2pdf] PDF not loaded for embed_id={}, skipping", embed_id); + log::info!( + "[duc2pdf] PDF not loaded for embed_id={}, skipping", + embed_id + ); return Ok(vec![Operation::new( &format!("% PDF not loaded: {}", embed_id), vec![], @@ -2937,7 +2975,8 @@ impl ElementStreamer { match pdf_embedder.embed_pdf(document, &embed_id, &page_opts) { Ok(result) => { for (name, obj_ref) in result.xobject_resources.iter() { - self.resource_cache.insert(file_id.to_string(), name.clone()); + self.resource_cache + .insert(file_id.to_string(), name.clone()); self.new_xobjects.push((name.clone(), obj_ref.clone())); } ops.extend(result.operations); @@ -3033,7 +3072,10 @@ impl ElementStreamer { error, ); ops.push(Operation::new( - &format!("% Failed to decode DucModelElement thumbnail: {}", model.base.id), + &format!( + "% Failed to decode DucModelElement thumbnail: {}", + model.base.id + ), vec![], )); return Ok(ops); @@ -3049,7 +3091,10 @@ impl ElementStreamer { error, ); ops.push(Operation::new( - &format!("% Failed to embed DucModelElement thumbnail: {}", model.base.id), + &format!( + "% Failed to embed DucModelElement thumbnail: {}", + model.base.id + ), vec![], )); return Ok(ops); @@ -3139,10 +3184,10 @@ impl ElementStreamer { ops.push(Operation::new( "cm", vec![ - Object::Real(scale_x as f32), // Scale X to fill width + Object::Real(scale_x as f32), // Scale X to fill width Object::Real(0.0), Object::Real(0.0), - Object::Real(scale_y as f32), // Scale Y to fill height + Object::Real(scale_y as f32), // Scale Y to fill height Object::Real(0.0), Object::Real(-(image.base.height as f32)), // Y-offset correction ], @@ -3321,7 +3366,10 @@ impl ElementStreamer { let mut ops = Vec::new(); let styles = &frame.stack_element_base.base.styles; - let has_background = styles.background.iter().any(|background| background.content.visible); + let has_background = styles + .background + .iter() + .any(|background| background.content.visible); let has_stroke = styles.stroke.iter().any(|stroke| stroke.content.visible); if has_background || has_stroke { diff --git a/packages/ducpdf/src/duc2pdf/src/streaming/stream_resources.rs b/packages/ducpdf/src/duc2pdf/src/streaming/stream_resources.rs index b95feed..3a689e4 100644 --- a/packages/ducpdf/src/duc2pdf/src/streaming/stream_resources.rs +++ b/packages/ducpdf/src/duc2pdf/src/streaming/stream_resources.rs @@ -1,6 +1,6 @@ use crate::utils::svg_to_pdf::SvgToPdfConverter; use crate::{ConversionError, ConversionResult}; -use duc::types::{DucExternalFile}; +use duc::types::DucExternalFile; use hipdf::blocks::BlockManager; use hipdf::embed_pdf::PdfEmbedder; use hipdf::hatching::HatchingManager; @@ -109,8 +109,7 @@ impl ResourceStreamer { .and_then(|d| d.get(&file.active_revision_id)) .map(|b| b.as_ref() as &[u8]); let resource_info = self.process_single_file(file, rev_data)?; - self.resource_cache - .insert(file.id.clone(), resource_info); + self.resource_cache.insert(file.id.clone(), resource_info); } Ok(()) } @@ -121,7 +120,9 @@ impl ResourceStreamer { file: &DucExternalFile, rev_data: Option<&[u8]>, ) -> ConversionResult { - let mime_type = file.revisions.get(&file.active_revision_id) + let mime_type = file + .revisions + .get(&file.active_revision_id) .map(|r| r.mime_type.clone()) .unwrap_or_default(); let resource_type = self.detect_resource_type(&mime_type); @@ -157,11 +158,20 @@ impl ResourceStreamer { file: &DucExternalFile, rev_data: Option<&[u8]>, ) -> ConversionResult { - let _revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + let _revision = file + .revisions + .get(&file.active_revision_id) + .ok_or_else(|| { + ConversionError::ResourceLoadError(format!( + "No active revision for file {}", + file.id + )) + })?; let data = rev_data.ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No data blob for revision {}", file.active_revision_id)) + ConversionError::ResourceLoadError(format!( + "No data blob for revision {}", + file.active_revision_id + )) })?; let document = self.document.as_mut().ok_or_else(|| { ConversionError::ResourceLoadError("PDF document not initialized".to_string()) @@ -185,11 +195,20 @@ impl ResourceStreamer { rev_data: Option<&[u8]>, resource_type: &ResourceType, ) -> ConversionResult { - let _revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + 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)) + ConversionError::ResourceLoadError(format!( + "No data blob for revision {}", + file.active_revision_id + )) })?; let xobject_id = { let mut document = self.document.take().ok_or_else(|| { @@ -218,11 +237,20 @@ impl ResourceStreamer { file: &DucExternalFile, rev_data: Option<&[u8]>, ) -> ConversionResult { - let _revision = file.revisions.get(&file.active_revision_id).ok_or_else(|| { - ConversionError::ResourceLoadError(format!("No active revision for file {}", file.id)) - })?; + 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)) + ConversionError::ResourceLoadError(format!( + "No data blob for revision {}", + file.active_revision_id + )) })?; let pdf_embedder = self.pdf_embedder.as_mut().ok_or_else(|| { @@ -307,12 +335,6 @@ impl ResourceStreamer { Ok(object_id) } - - - - - - /// Stream SVG resource as PDF operations pub fn stream_svg_resource( &self, diff --git a/packages/ducpdf/src/duc2pdf/src/utils/style_resolver.rs b/packages/ducpdf/src/duc2pdf/src/utils/style_resolver.rs index 75567dd..afdfa14 100644 --- a/packages/ducpdf/src/duc2pdf/src/utils/style_resolver.rs +++ b/packages/ducpdf/src/duc2pdf/src/utils/style_resolver.rs @@ -1,7 +1,7 @@ use crate::ConversionResult; use duc::types::{ - ELEMENT_CONTENT_PREFERENCE, HATCH_STYLE, STROKE_CAP, STROKE_JOIN, STROKE_PREFERENCE, DucElementEnum, DucElementStylesBase, DucHatchStyle, ElementBackground, ElementStroke, + ELEMENT_CONTENT_PREFERENCE, HATCH_STYLE, STROKE_CAP, STROKE_JOIN, STROKE_PREFERENCE, }; use hipdf::hatching::{HatchStyle, HatchingManager}; use hipdf::lopdf::{content::Operation, Object}; diff --git a/packages/ducpdf/src/duc2pdf/tests/test_integration.rs b/packages/ducpdf/src/duc2pdf/tests/test_integration.rs index c368714..b03cdff 100644 --- a/packages/ducpdf/src/duc2pdf/tests/test_integration.rs +++ b/packages/ducpdf/src/duc2pdf/tests/test_integration.rs @@ -550,9 +550,7 @@ mod integration_tests { } } - use duc::types::{ - ELEMENT_CONTENT_PREFERENCE, STROKE_CAP, STROKE_JOIN, STROKE_PREFERENCE, - }; + use duc::types::{ELEMENT_CONTENT_PREFERENCE, STROKE_CAP, STROKE_JOIN, STROKE_PREFERENCE}; use duc2pdf::streaming::stream_elements::ElementStreamer; use duc2pdf::utils::style_resolver::StyleResolver; use std::collections::HashMap; @@ -580,7 +578,7 @@ mod integration_tests { is_deleted: false, group_ids: vec![], region_ids: vec![], - + layer_id: None, frame_id: None, bound_elements: None, diff --git a/packages/ducpy/.gitignore b/packages/ducpy/.gitignore index d28db52..dac6a75 100644 --- a/packages/ducpy/.gitignore +++ b/packages/ducpy/.gitignore @@ -1,6 +1,8 @@ # Test files dist/ inputs/ +schema/* +!schema/.gitkeep # Auto-generated version file **/_version.py diff --git a/packages/ducpy/package.json b/packages/ducpy/package.json index 9ec6289..1c1e5e3 100644 --- a/packages/ducpy/package.json +++ b/packages/ducpy/package.json @@ -3,16 +3,17 @@ "version": "0.0.0-development", "private": true, "scripts": { - "build": "bash -c 'SETUPTOOLS_SCM_PRETEND_VERSION=${1} uv build' --", + "sync:schema": "uv run python scripts/sync_schema.py", + "build": "bash -c 'uv run python scripts/sync_schema.py && SETUPTOOLS_SCM_PRETEND_VERSION=${1} uv build' --", "gen:docs": "bun clean:docs && cd docs && uv run make html", "dev:docs": "uv run --with sphinx-autobuild sphinx-autobuild docs docs/_build/html --port 8080 --open-browser", "clean:docs": "cd docs && uv run make clean", "test": "uv run -m pytest src/tests/src/test_*.py", "test:verbose": "uv run -m pytest -v src/tests/src/test_*.py", - "prerelease": "uv sync && bun run test", + "prerelease": "uv sync && uv run python scripts/sync_schema.py && bun run test", "semantic-release": "semantic-release" }, "devDependencies": { "semantic-release": "^24.1.2" } -} \ No newline at end of file +} diff --git a/packages/ducpy/pyproject.toml b/packages/ducpy/pyproject.toml index 5c7692a..9556dad 100644 --- a/packages/ducpy/pyproject.toml +++ b/packages/ducpy/pyproject.toml @@ -34,7 +34,9 @@ manifest-path = "crate/Cargo.toml" python-source = "src" module-name = "ducpy_native" include = [ - { path = "LICENSE", format = "sdist" } + { path = "LICENSE", format = "sdist" }, + { path = "schema/**/*", format = "sdist" }, + { path = "schema/**/*", format = "wheel" } ] [tool.setuptools] diff --git a/packages/ducpy/release.config.cjs b/packages/ducpy/release.config.cjs index 6db63b4..dc766d5 100644 --- a/packages/ducpy/release.config.cjs +++ b/packages/ducpy/release.config.cjs @@ -16,7 +16,7 @@ module.exports = { "@semantic-release/exec", { prepareCmd: - "uv sync && sed -i 's/^version = \".*\"$/version = \"${nextRelease.version}\"/' crate/Cargo.toml && SETUPTOOLS_SCM_PRETEND_VERSION=${nextRelease.version} uv build --sdist", + "uv sync && uv run python scripts/sync_schema.py && sed -i 's/^version = \".*\"$/version = \"${nextRelease.version}\"/' crate/Cargo.toml && SETUPTOOLS_SCM_PRETEND_VERSION=${nextRelease.version} uv build --sdist", publishCmd: "uv publish --token ${process.env.PYPI_TOKEN} dist/*.tar.gz", }, ], diff --git a/packages/ducpy/scripts/sync_schema.py b/packages/ducpy/scripts/sync_schema.py new file mode 100644 index 0000000..cd65350 --- /dev/null +++ b/packages/ducpy/scripts/sync_schema.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + + +def main() -> None: + package_root = Path(__file__).resolve().parents[1] + repo_root = package_root.parents[1] + source = repo_root / "schema" + destination = package_root / "schema" + + if not source.joinpath("duc.sql").exists(): + raise FileNotFoundError(f"Missing source schema at {source}") + + if destination.exists(): + shutil.rmtree(destination) + + shutil.copytree(source, destination) + print(f"Synced schema: {source} -> {destination}") + + +if __name__ == "__main__": + main() diff --git a/packages/ducpy/setup.py b/packages/ducpy/setup.py index ed6e9eb..3ff7ca2 100644 --- a/packages/ducpy/setup.py +++ b/packages/ducpy/setup.py @@ -1,15 +1,28 @@ import os import re +from pathlib import Path from setuptools import setup -# Path to the schema file, relative to this setup.py file -# setup.py is in packages/ducpy/ -# schema/duc.sql is at the workspace root, so ../../schema/duc.sql -SCHEMA_FILE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'schema', 'duc.sql') # Path where _version.py will be written VERSION_PY_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src', 'ducpy', '_version.py') + +def find_schema_file_path(): + env_path = os.environ.get('DUC_SCHEMA_DIR') + if env_path: + candidate = Path(env_path) / 'duc.sql' + if candidate.exists(): + return str(candidate) + + current = Path(__file__).resolve() + for parent in current.parents: + candidate = parent / 'schema' / 'duc.sql' + if candidate.exists(): + return str(candidate) + + return None + def get_schema_version_from_sql(sql_file_path): """Extract schema version from PRAGMA user_version in duc.sql. @@ -34,7 +47,8 @@ def get_schema_version_from_sql(sql_file_path): def generate_version_py(): """Generates the _version.py file with DUC_SCHEMA_VERSION.""" - schema_version = get_schema_version_from_sql(SCHEMA_FILE_PATH) + schema_file_path = find_schema_file_path() + schema_version = get_schema_version_from_sql(schema_file_path) if schema_file_path else None if schema_version is not None: print(f"Generating DUC schema version file at: {VERSION_PY_PATH} with version: {schema_version}") @@ -62,4 +76,4 @@ def generate_version_py(): # package discovery are handled by pyproject.toml. # This setup.py is present for compatibility and can be used for tasks # like C-extension building or build-time file generation if needed. -setup() \ No newline at end of file +setup() diff --git a/packages/ducpy/src/ducpy/builders/sql_builder.py b/packages/ducpy/src/ducpy/builders/sql_builder.py index 8697adb..1064ff9 100644 --- a/packages/ducpy/src/ducpy/builders/sql_builder.py +++ b/packages/ducpy/src/ducpy/builders/sql_builder.py @@ -42,6 +42,12 @@ def _find_schema_dir() -> Optional[Path]: + env_path = os.environ.get("DUC_SCHEMA_DIR") + if env_path: + candidate = Path(env_path) + if (candidate / "duc.sql").exists(): + return candidate + current = Path(__file__).resolve() for parent in current.parents: candidate = parent / "schema" diff --git a/packages/ducpy/src/ducpy/serialize.py b/packages/ducpy/src/ducpy/serialize.py index 483a516..18f3a17 100644 --- a/packages/ducpy/src/ducpy/serialize.py +++ b/packages/ducpy/src/ducpy/serialize.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging +import os import re from dataclasses import asdict, is_dataclass from pathlib import Path @@ -17,6 +18,22 @@ logger = logging.getLogger(__name__) +def _find_schema_file() -> Path | None: + env_path = Path(os.environ["DUC_SCHEMA_DIR"]) if "DUC_SCHEMA_DIR" in os.environ else None + if env_path is not None: + candidate = env_path / "duc.sql" + if candidate.exists(): + return candidate + + current = Path(__file__).resolve() + for parent in current.parents: + candidate = parent / "schema" / "duc.sql" + if candidate.exists(): + return candidate + + return None + + def _decode_user_version_to_semver(user_version: int) -> str: """Decode sqlite-style schema user_version to semver. @@ -39,7 +56,9 @@ def _read_schema_version_fallback() -> str: CI environments before setup-time generation has run). """ try: - schema_path = Path(__file__).resolve().parents[4] / "schema" / "duc.sql" + schema_path = _find_schema_file() + if schema_path is None: + return "0.0.0" content = schema_path.read_text(encoding="utf-8") match = re.search(r"PRAGMA\s+user_version\s*=\s*(\d+)\s*;", content) if match: @@ -226,4 +245,3 @@ def serialize_duc( } return ducpy_native.serialize_duc(data) - diff --git a/packages/ducrs/build.rs b/packages/ducrs/build.rs index 27ee777..c187ba5 100644 --- a/packages/ducrs/build.rs +++ b/packages/ducrs/build.rs @@ -1,6 +1,6 @@ use std::env; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Extract raw `PRAGMA user_version = ;` from `duc.sql`. fn schema_user_version_from_sql(sql: &str) -> u32 { @@ -24,23 +24,45 @@ fn decode_user_version_to_semver(user_version: u32) -> String { format!("{major}.{minor}.{patch}") } +fn find_schema_dir(manifest_dir: &Path) -> Result> { + if let Ok(path) = env::var("DUC_SCHEMA_DIR") { + let candidate = PathBuf::from(path); + if candidate.join("duc.sql").is_file() { + return Ok(candidate); + } + } + + for ancestor in manifest_dir.ancestors() { + for candidate in [ + ancestor.join("schema"), + ancestor.join("packages").join("ducpy").join("schema"), + ] { + if candidate.join("duc.sql").is_file() { + return Ok(candidate); + } + } + } + + Err(format!( + "Could not locate schema/duc.sql from {}. Set DUC_SCHEMA_DIR to a directory containing duc.sql.", + manifest_dir.display() + ) + .into()) +} + fn main() -> Result<(), Box> { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); - let schema_dir = manifest_dir.join("..").join("..").join("schema"); + let schema_dir = find_schema_dir(&manifest_dir)?; let out_dir = PathBuf::from(env::var("OUT_DIR")?); + println!("cargo:rerun-if-env-changed=DUC_SCHEMA_DIR"); // Copy schema files into OUT_DIR so bootstrap.rs can include_str! them // even when the crate is built from an sdist in a temp directory. for name in ["duc.sql", "version_control.sql", "search.sql"] { let src = schema_dir.join(name); let dst = out_dir.join(name); - match fs::read_to_string(&src) { - Ok(contents) => fs::write(&dst, contents)?, - Err(e) => { - eprintln!("cargo:warning=Could not read {:?}: {e}. Writing empty stub.", src); - fs::write(&dst, "")?; - } - } + let contents = fs::read_to_string(&src)?; + fs::write(&dst, contents)?; println!("cargo:rerun-if-changed={}", src.display()); } // Scan schema/migrations/*.sql, parse (from, to) from filenames like @@ -127,4 +149,4 @@ fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=build.rs"); Ok(()) -} \ No newline at end of file +}