From 83a6e743add51f214cafe5f730222c6904d29026 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 12 Aug 2026 01:09:23 +0800 Subject: [PATCH] feat(export): support images across export backends --- Cargo.lock | 1 + crates/app/src/ui/canvas/image_painting.rs | 2 +- crates/app/src/ui/canvas/mod.rs | 6 +- crates/app/src/ui/canvas/navigation_tests.rs | 3 +- crates/app/src/ui/clipboard_figure.rs | 18 +- crates/app/src/ui/commands.rs | 11 +- crates/app/src/ui/export_dialog.rs | 122 ++++++++-- .../src/ui/file_dialogs/image_import/proxy.rs | 2 +- crates/core/src/automation/tool_executors.rs | 4 +- crates/core/src/export/asset_render.rs | 181 +++++++++++++++ crates/core/src/export/asset_render_tests.rs | 157 +++++++++++++ crates/core/src/export/mod.rs | 216 +++++++++--------- crates/core/src/export/pdf.rs | 87 +++++++ crates/core/src/export/precheck.rs | 205 ++++++++++++++++- crates/core/src/export/raster.rs | 15 +- crates/core/src/export/trim.rs | 29 ++- crates/core/src/project/asset_codec.rs | 86 ++++++- crates/core/src/project/convert_views.rs | 1 - crates/core/src/project/mod.rs | 43 ++-- crates/core/src/project/panel_schema_tests.rs | 147 +++++++++++- crates/core/src/state/app_impl.rs | 17 +- crates/core/src/state/app_impl_io.rs | 81 +++---- crates/core/src/state/plot_object.rs | 4 +- crates/core/src/state/stack.rs | 2 - crates/core/src/state/ui_state.rs | 4 +- crates/core/src/workflow.rs | 23 +- crates/render/Cargo.toml | 5 +- crates/render/src/emf.rs | 83 ++++++- crates/render/src/emf/dc.rs | 102 ++++++++- crates/render/src/emf/tests.rs | 38 +++ crates/render/src/lib.rs | 7 +- crates/render/src/screen_raster.rs | 14 +- crates/render/src/svg.rs | 4 +- crates/render/src/svg/document.rs | 178 ++++++++++++++- crates/render/src/svg_annotation_tests.rs | 37 +++ docs/src/content/docs/guides/exporting.md | 25 +- .../content/docs/guides/layout-and-export.md | 14 +- .../content/docs/reference/file-formats.md | 18 +- .../content/docs/reference/troubleshooting.md | 13 +- .../content/docs/zh-cn/guides/exporting.md | 19 +- .../docs/zh-cn/guides/layout-and-export.md | 9 +- .../docs/zh-cn/reference/file-formats.md | 14 +- .../docs/zh-cn/reference/troubleshooting.md | 10 +- 43 files changed, 1689 insertions(+), 368 deletions(-) create mode 100644 crates/core/src/export/asset_render.rs create mode 100644 crates/core/src/export/asset_render_tests.rs create mode 100644 crates/core/src/export/pdf.rs diff --git a/Cargo.lock b/Cargo.lock index 0d5f6d3..7689eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4201,6 +4201,7 @@ dependencies = [ name = "plotx-render" version = "0.1.0" dependencies = [ + "base64", "egui", "image", "plotx-figure", diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index b090bd9..c390384 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -33,7 +33,7 @@ pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte items[object_index] = plotx_render::DocumentItem::Raster(plotx_render::DocumentRaster { source_hash: raster_cache_hash(asset.sha256, image.page_index), frame, - pixels: preview.rgba8.as_slice(), + pixels: preview.rgba8.clone(), pixel_size: preview.pixel_size, source_pixel_size: if image.page_index == 0 { asset.pixel_size diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 644e242..c9c8d72 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -603,9 +603,7 @@ fn resize_cursor(handle: ResizeHandle) -> egui::CursorIcon { mod tests { use super::*; - use plotx_core::state::{ - CanvasObject, CanvasObjectKind, CanvasViewport, PanelMeta, PlotObject, TextBox, - }; + use plotx_core::state::{CanvasObject, CanvasObjectKind, CanvasViewport, PlotObject, TextBox}; use plotx_figure::{Axis, Figure}; #[test] @@ -648,7 +646,6 @@ mod tests { plotx_core::state::AxisOverrides::default(), figure, viewport, - PanelMeta::new("title".to_owned(), 50.0), ) })), }); @@ -682,7 +679,6 @@ mod tests { plotx_core::state::AxisOverrides::default(), figure, viewport, - PanelMeta::new("title".to_owned(), 50.0), ) })), }); diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs index b79cc02..7533575 100644 --- a/crates/app/src/ui/canvas/navigation_tests.rs +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -1,5 +1,5 @@ use super::*; -use plotx_core::state::{CanvasViewport, PanelMeta, PlotObject}; +use plotx_core::state::{CanvasViewport, PlotObject}; use plotx_figure::{Axis, Figure}; const PLOT_ID: ObjectId = ObjectId::new(1); @@ -32,7 +32,6 @@ fn zoomed_plot_fixture() -> (PlotxApp, ObjectId, PlotRect) { plotx_core::state::AxisOverrides::default(), figure, viewport, - PanelMeta::new("title".to_owned(), 50.0), ))), }); app.doc.canvases.push(canvas); diff --git a/crates/app/src/ui/clipboard_figure.rs b/crates/app/src/ui/clipboard_figure.rs index 0cf752b..47afd81 100644 --- a/crates/app/src/ui/clipboard_figure.rs +++ b/crates/app/src/ui/clipboard_figure.rs @@ -4,7 +4,10 @@ use std::fmt; use egui::Context; -use plotx_core::export::{RasterError, RasterImage, RasterOptions, rasterize_canvas}; +use plotx_core::export::{ + ExportError, MissingImagePolicy, RasterError, RasterImage, RasterOptions, + prepare_render_document, rasterize_svg, +}; use plotx_core::operation::{Diagnostic, DiagnosticCode, OperationKind, OperationReport, Severity}; use plotx_core::state::{BoardFrameId, PlotxApp}; @@ -92,15 +95,16 @@ fn build_payload( .get(canvas_index) .ok_or(ClipboardFigureError::NoTarget)?; let dpi = app.settings.export.dpi; - let raster = - rasterize_canvas(canvas, RasterOptions::new(dpi)).map_err(ClipboardFigureError::Raster)?; + let document = prepare_render_document(canvas, &app.doc.assets, MissingImagePolicy::Block) + .map_err(ClipboardFigureError::Export)?; + let svg = plotx_render::svg::export_document(&document); + let raster = rasterize_svg(&svg, canvas.size_pt(), RasterOptions::new(dpi)) + .map_err(ClipboardFigureError::Raster)?; #[cfg(windows)] { let png = raster .to_png() .map_err(|error| ClipboardFigureError::PngEncode(error.into()))?; - let document = plotx_core::state::build_render_document(canvas); - let svg = plotx_render::svg::export_document(&document); let emf = plotx_render::emf::export_document_emf(&document).map_err(|error| error.to_string()); Ok(FigurePayload { @@ -293,6 +297,7 @@ fn clipboard_error_code(error: &ClipboardFigureError) -> DiagnosticCode { fn clipboard_error_category(error: &ClipboardFigureError) -> &'static str { match error { ClipboardFigureError::NoTarget => "no_target", + ClipboardFigureError::Export(_) => "embedded_image", ClipboardFigureError::Raster(_) => "rasterization", #[cfg(windows)] ClipboardFigureError::PngEncode(_) => "png_encode", @@ -306,6 +311,7 @@ fn clipboard_error_category(error: &ClipboardFigureError) -> &'static str { #[derive(Debug)] enum ClipboardFigureError { NoTarget, + Export(ExportError), Raster(RasterError), #[cfg(windows)] PngEncode(Box), @@ -322,6 +328,7 @@ impl fmt::Display for ClipboardFigureError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NoTarget => formatter.write_str("no canvas is selected or active"), + Self::Export(error) => error.fmt(formatter), Self::Raster(error) => error.fmt(formatter), #[cfg(windows)] Self::PngEncode(error) => write!(formatter, "PNG encoding failed: {error}"), @@ -342,6 +349,7 @@ impl std::error::Error for ClipboardFigureError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Raster(error) => Some(error), + Self::Export(error) => Some(error), _ => None, } } diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 89e8260..e6d6ce9 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -419,15 +419,8 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { ), CommandId::Export(_) => requires(has_canvas, "Open a canvas before exporting a figure."), CommandId::CopyFigure => requires( - super::clipboard_figure::resolve_copy_target(app).is_some_and(|canvas| { - !app.doc.canvases[canvas].objects.iter().any(|item| { - matches!( - item.kind, - plotx_core::state::CanvasObjectKind::RasterImage(_) - ) - }) - }), - "Open a figure without external images; Copy Figure cannot include them yet.", + super::clipboard_figure::resolve_copy_target(app).is_some(), + "Open a canvas before copying a figure.", ), CommandId::Undo => requires(app.can_undo(), "Nothing to undo yet."), CommandId::Redo => requires(app.can_redo(), "Nothing to redo yet."), diff --git a/crates/app/src/ui/export_dialog.rs b/crates/app/src/ui/export_dialog.rs index 3c415c6..b03adc7 100644 --- a/crates/app/src/ui/export_dialog.rs +++ b/crates/app/src/ui/export_dialog.rs @@ -1,6 +1,7 @@ use super::*; use plotx_core::export::{ - ComplianceStatus, ExportPreset, PrecheckReport, page_metrics, precheck_report, + ComplianceStatus, ExportPreset, PrecheckReport, image_precheck_items, page_metrics, + precheck_report, }; use plotx_core::settings::{MAX_EXPORT_DPI, MIN_EXPORT_DPI}; @@ -99,11 +100,30 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { Empty pages keep their original size.", ); + let has_images = selected_pages_have_images( + &app.doc.canvases, + pending.scope, + active_page, + page_count, + ); + if has_images { + ui.add_space(8.0); + ui.checkbox( + &mut pending.allow_missing_images, + "Export with missing-image placeholders", + ) + .on_hover_text( + "If an embedded image cannot be read, export a labelled placeholder instead of stopping.", + ); + } else { + pending.allow_missing_images = false; + } + let preset = pending.preset; let scope = pending.scope; let dpi = pending.dpi; - if let Some(preset) = preset { - let report = build_report(app, preset, scope, dpi, active_page, page_count); + let report = build_report(app, preset, scope, dpi, active_page, page_count); + if !report.items.is_empty() { ui.add_space(10.0); ui.separator(); draw_precheck(ui, &report); @@ -138,6 +158,25 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { } } +fn selected_pages_have_images( + canvases: &[plotx_core::state::CanvasDocument], + scope: ExportPageScope, + active_page: usize, + page_count: usize, +) -> bool { + plotx_core::export::resolve_page_scope(scope, Some(active_page), page_count) + .unwrap_or_default() + .into_iter() + .filter_map(|page| canvases.get(page)) + .flat_map(|canvas| &canvas.objects) + .any(|item| { + matches!( + &item.kind, + plotx_core::state::CanvasObjectKind::RasterImage(_) + ) + }) +} + fn set_confirmed_trim_default(app: &mut PlotxApp, trim_to_visible_content: bool) { let target = app.app_target(); match app.plan_property_write( @@ -156,7 +195,7 @@ fn set_confirmed_trim_default(app: &mut PlotxApp, trim_to_visible_content: bool) fn build_report( app: &PlotxApp, - preset: ExportPreset, + preset: Option, scope: ExportPageScope, dpi: u16, active_page: usize, @@ -164,18 +203,32 @@ fn build_report( ) -> PrecheckReport { let pages = plotx_core::export::resolve_page_scope(scope, Some(active_page), page_count) .unwrap_or_else(|_| vec![active_page]); - let metrics: Vec<_> = pages + let mut report = if let Some(preset) = preset { + let metrics: Vec<_> = pages + .iter() + .filter_map(|&page| app.doc.canvases.get(page)) + .map(page_metrics) + .collect(); + precheck_report( + &metrics, + preset.target_width_mm(), + &preset.thresholds(), + preset.format(), + dpi, + ) + } else { + PrecheckReport { items: Vec::new() } + }; + let canvases: Vec<_> = pages .iter() .filter_map(|&page| app.doc.canvases.get(page)) - .map(page_metrics) .collect(); - precheck_report( - &metrics, - preset.target_width_mm(), - &preset.thresholds(), - preset.format(), - dpi, - ) + report.items.extend(image_precheck_items( + &canvases, + &app.doc.assets, + preset.and_then(ExportPreset::target_width_mm), + )); + report } fn draw_precheck(ui: &mut Ui, report: &PrecheckReport) { @@ -206,6 +259,49 @@ fn status_dot(ui: &mut Ui, status: ComplianceStatus) { ui.painter().circle_filled(rect.center(), 4.0, color); } +#[cfg(test)] +mod image_tests { + use super::*; + use plotx_core::state::{ + AssetId, CanvasObject, CanvasObjectKind, ObjectFrame, ObjectId, RasterImageContent, + }; + + #[test] + fn placeholder_option_is_available_for_every_scope_containing_an_image() { + let plain = plotx_core::state::CanvasDocument::new("plain".to_owned(), [100.0, 80.0]); + let mut image_page = + plotx_core::state::CanvasDocument::new("image".to_owned(), [100.0, 80.0]); + image_page.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "image".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 20.0, 20.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(RasterImageContent::new(AssetId::new())), + }); + let pages = [plain, image_page]; + + assert!(!selected_pages_have_images( + &pages, + ExportPageScope::Current, + 0, + pages.len(), + )); + assert!(selected_pages_have_images( + &pages, + ExportPageScope::Current, + 1, + pages.len(), + )); + assert!(selected_pages_have_images( + &pages, + ExportPageScope::All, + 0, + pages.len(), + )); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/ui/file_dialogs/image_import/proxy.rs b/crates/app/src/ui/file_dialogs/image_import/proxy.rs index 1353af4..3e8a0f3 100644 --- a/crates/app/src/ui/file_dialogs/image_import/proxy.rs +++ b/crates/app/src/ui/file_dialogs/image_import/proxy.rs @@ -120,7 +120,7 @@ pub(super) fn insert_proxy( hash, page_index, pixel_size: proxy.pixel_size, - rgba8: Arc::new(proxy.rgba8), + rgba8: Arc::from(proxy.rgba8), }); while app .session diff --git a/crates/core/src/automation/tool_executors.rs b/crates/core/src/automation/tool_executors.rs index 9a3db0e..56bf660 100644 --- a/crates/core/src/automation/tool_executors.rs +++ b/crates/core/src/automation/tool_executors.rs @@ -455,8 +455,9 @@ pub(super) fn execute_export( }); continue; } - let written = match crate::export::export_canvases( + let written = match crate::export::export_canvases_with_assets( std::slice::from_ref(canvas), + &app.doc.assets, Some(0), &ExportSettings { format, @@ -464,6 +465,7 @@ pub(super) fn execute_export( dpi: params.dpi, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }, &base, ) { diff --git a/crates/core/src/export/asset_render.rs b/crates/core/src/export/asset_render.rs new file mode 100644 index 0000000..18dc901 --- /dev/null +++ b/crates/core/src/export/asset_render.rs @@ -0,0 +1,181 @@ +use super::ExportError; +use crate::state::{ + AssetId, AssetRecord, CanvasDocument, CanvasObjectKind, ImageFit, ImageInterpolation, + QuarterTurn, document_items, +}; +use plotx_figure::Color; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::sync::Arc; + +const MAX_EXPORT_SAMPLE_EDGE: u32 = 4096; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MissingImagePolicy { + #[default] + Block, + Placeholder, +} + +pub fn prepare_render_document<'a>( + canvas: &'a CanvasDocument, + assets: &BTreeMap, + missing_policy: MissingImagePolicy, +) -> Result, ExportError> { + let [width, height] = canvas.size_pt(); + let mut items = document_items(canvas); + let mut decoded_assets: BTreeMap<(AssetId, u32), DecodedAsset> = BTreeMap::new(); + for (index, object) in canvas.objects.iter().enumerate() { + let CanvasObjectKind::RasterImage(image) = &object.kind else { + continue; + }; + let frame = canvas + .content_page_frame(object.id) + .unwrap_or(object.frame) + .rect(); + let parent = canvas + .parent_panel(object.id) + .and_then(|panel| canvas.panel(panel)); + let visible = object.visible && parent.is_none_or(|panel| panel.visible); + let clip = parent + .filter(|panel| panel.clip_children) + .map(|panel| panel.frame.rect()); + let key = (image.asset, image.page_index); + let decoded = if let Some(decoded) = decoded_assets.get(&key) { + Ok(decoded.clone()) + } else { + assets + .get(&image.asset) + .ok_or(ExportError::MissingImageAsset { asset: image.asset }) + .and_then(|asset| decode_asset(asset, image.page_index)) + .inspect(|decoded| { + decoded_assets.insert(key, (*decoded).clone()); + }) + }; + let decoded = match decoded { + Ok(decoded) => decoded, + Err(_error) if missing_policy == MissingImagePolicy::Placeholder => { + items[index] = missing_image_placeholder(frame, visible); + continue; + } + Err(error) => return Err(error), + }; + items[index] = plotx_render::DocumentItem::Raster(plotx_render::DocumentRaster { + source_hash: raster_cache_hash(decoded.sha256, image.page_index), + frame, + pixels: decoded.pixels, + pixel_size: decoded.pixel_size, + source_pixel_size: decoded.source_pixel_size, + crop: image.crop, + fit: match image.fit { + ImageFit::Contain => plotx_render::RasterFit::Contain, + ImageFit::Cover => plotx_render::RasterFit::Cover, + ImageFit::Stretch => plotx_render::RasterFit::Stretch, + }, + quarter_turns: match image.rotation { + QuarterTurn::Zero => 0, + QuarterTurn::Clockwise90 => 1, + QuarterTurn::Clockwise180 => 2, + QuarterTurn::Clockwise270 => 3, + }, + opacity: image.opacity, + nearest: image.interpolation == ImageInterpolation::Nearest, + clip, + visible, + }); + } + Ok(plotx_render::Document { + width, + height, + background: canvas.background, + items, + }) +} + +fn decode_asset(asset: &AssetRecord, page_index: u32) -> Result { + let actual: [u8; 32] = Sha256::digest(&asset.bytes).into(); + if actual != asset.sha256 { + return Err(ExportError::CorruptImageAsset { + asset: asset.id, + reason: "embedded bytes do not match the recorded SHA-256".to_owned(), + }); + } + let probe = + plotx_io::image::probe(&asset.bytes).map_err(|error| ExportError::CorruptImageAsset { + asset: asset.id, + reason: error.to_string(), + })?; + let source_pixel_size = if page_index == 0 { + [probe.width, probe.height] + } else { + plotx_io::image::tiff_page_dimensions(&asset.bytes, page_index).ok_or_else(|| { + ExportError::CorruptImageAsset { + asset: asset.id, + reason: format!("image page {page_index} is unavailable or unsupported"), + } + })? + }; + + let decoded = plotx_io::image::decode_rgba8_page(&asset.bytes, page_index, false); + match decoded { + Ok(decoded) => Ok(DecodedAsset { + sha256: decoded.sha256, + pixel_size: [decoded.probe.width, decoded.probe.height], + source_pixel_size, + pixels: Arc::from(decoded.rgba8), + }), + Err(plotx_io::image::ImageError::TooLarge { .. }) if page_index == 0 => { + let sample = plotx_io::image::decode_proxy_rgba8(&asset.bytes, MAX_EXPORT_SAMPLE_EDGE) + .map_err(|error| ExportError::CorruptImageAsset { + asset: asset.id, + reason: error.to_string(), + })?; + Ok(DecodedAsset { + sha256: actual, + pixel_size: sample.pixel_size, + source_pixel_size, + pixels: Arc::from(sample.rgba8), + }) + } + Err(error) => Err(ExportError::CorruptImageAsset { + asset: asset.id, + reason: error.to_string(), + }), + } +} + +#[derive(Clone)] +struct DecodedAsset { + sha256: [u8; 32], + pixel_size: [u32; 2], + source_pixel_size: [u32; 2], + pixels: Arc<[u8]>, +} + +fn missing_image_placeholder( + frame: plotx_render::Rect, + visible: bool, +) -> plotx_render::DocumentItem<'static> { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame, + visible, + kind: plotx_render::OverlayKind::Text(plotx_render::OverlayText { + text: "Missing image - replace it in the inspector", + font_size: 8.0, + color: Color::rgb(180, 32, 32), + align: plotx_render::OverlayAlign::Center, + bold: true, + }), + }) +} + +fn raster_cache_hash(mut hash: [u8; 32], page_index: u32) -> [u8; 32] { + for (target, byte) in hash.iter_mut().zip(page_index.to_le_bytes()) { + *target ^= byte; + } + hash +} + +#[cfg(test)] +#[path = "asset_render_tests.rs"] +mod tests; diff --git a/crates/core/src/export/asset_render_tests.rs b/crates/core/src/export/asset_render_tests.rs new file mode 100644 index 0000000..e0573fc --- /dev/null +++ b/crates/core/src/export/asset_render_tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::export::{ExportFormat, ExportPageScope, ExportSettings, export_canvases_with_assets}; +use crate::state::{CanvasObject, ObjectFrame, ObjectId, RasterImageContent, TextBox}; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; +use std::io::Cursor; + +fn image_asset() -> AssetRecord { + let image = RgbaImage::from_pixel(4, 3, Rgba([210, 20, 30, 255])); + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, ImageFormat::Png) + .unwrap(); + let bytes = bytes.into_inner(); + let id = AssetId::new(); + AssetRecord { + id, + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [4, 3], + bytes, + } +} + +fn image_canvas(asset: AssetId) -> CanvasDocument { + let mut canvas = CanvasDocument::new("image page".to_owned(), [25.4, 25.4]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "source image".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 72.0, 72.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(RasterImageContent::new(asset)), + }); + canvas.objects.push(CanvasObject { + id: ObjectId::new(2), + name: "front label".to_owned(), + frame: ObjectFrame::new(2.0, 2.0, 40.0, 12.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("FRONT_LAYER".to_owned())), + }); + canvas +} + +#[test] +fn repeated_image_items_share_one_original_decode() { + let asset = image_asset(); + let mut canvas = image_canvas(asset.id); + for id in 3..=258 { + let mut repeated = canvas.objects[0].clone(); + repeated.id = ObjectId::new(id); + canvas.objects.push(repeated); + } + let assets = BTreeMap::from([(asset.id, asset)]); + + let document = prepare_render_document(&canvas, &assets, MissingImagePolicy::Block).unwrap(); + let rasters: Vec<_> = document + .items + .iter() + .filter_map(|item| match item { + plotx_render::DocumentItem::Raster(raster) => Some(raster), + _ => None, + }) + .collect(); + assert_eq!(rasters.len(), 257); + assert!( + rasters + .iter() + .all(|raster| Arc::ptr_eq(&rasters[0].pixels, &raster.pixels)) + ); + assert_eq!(rasters[0].pixel_size, [4, 3]); +} + +#[test] +fn svg_pdf_and_bitmap_exports_include_original_image_and_z_order() { + let asset = image_asset(); + let canvas = image_canvas(asset.id); + let assets = BTreeMap::from([(asset.id, asset)]); + let dir = std::env::temp_dir().join(format!("plotx-image-export-{}", AssetId::new())); + std::fs::create_dir_all(&dir).unwrap(); + + for format in [ + ExportFormat::Svg, + ExportFormat::Pdf, + ExportFormat::Png, + ExportFormat::Jpeg, + ExportFormat::Tiff, + ] { + let base = dir.join(format!("figure.{}", format.extension())); + let paths = export_canvases_with_assets( + std::slice::from_ref(&canvas), + &assets, + Some(0), + &ExportSettings { + format, + scope: ExportPageScope::Current, + dpi: 72, + target_width_mm: None, + trim_to_visible_content: false, + allow_missing_images: false, + }, + &base, + ) + .unwrap(); + let bytes = std::fs::read(&paths[0]).unwrap(); + match format { + ExportFormat::Svg => { + let svg = String::from_utf8(bytes).unwrap(); + let image = svg.find(" { + assert!(bytes.starts_with(b"%PDF-")); + assert!(bytes.windows(15).any(|window| window == b"/Subtype /Image")); + } + ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Tiff => { + let decoded = image::load_from_memory(&bytes).unwrap(); + assert_eq!(decoded.width(), 72); + assert_eq!(decoded.height(), 72); + let center = decoded.to_rgb8().get_pixel(36, 36).0; + assert!(center[0] > center[1] + 100); + } + } + std::fs::remove_file(&paths[0]).unwrap(); + } + std::fs::remove_dir(&dir).unwrap(); +} + +#[test] +fn unavailable_images_block_by_default_and_can_export_as_placeholders() { + let asset = image_asset(); + let canvas = image_canvas(asset.id); + let missing = prepare_render_document(&canvas, &BTreeMap::new(), MissingImagePolicy::Block) + .err() + .expect("missing asset must block export"); + assert!(matches!(missing, ExportError::MissingImageAsset { .. })); + + let placeholder = + prepare_render_document(&canvas, &BTreeMap::new(), MissingImagePolicy::Placeholder) + .unwrap(); + assert!(matches!( + &placeholder.items[0], + plotx_render::DocumentItem::Overlay(overlay) + if matches!(&overlay.kind, plotx_render::OverlayKind::Text(text) if text.text.contains("Missing image")) + )); + + let mut damaged = asset; + damaged.bytes.push(0); + let assets = BTreeMap::from([(damaged.id, damaged)]); + let error = prepare_render_document(&canvas, &assets, MissingImagePolicy::Block) + .err() + .expect("damaged asset must block export"); + assert!(matches!(error, ExportError::CorruptImageAsset { .. })); + assert!(prepare_render_document(&canvas, &assets, MissingImagePolicy::Placeholder).is_ok()); +} diff --git a/crates/core/src/export/mod.rs b/crates/core/src/export/mod.rs index 0a5a43f..03fd7de 100644 --- a/crates/core/src/export/mod.rs +++ b/crates/core/src/export/mod.rs @@ -1,4 +1,6 @@ +mod asset_render; mod fonts; +mod pdf; mod precheck; mod preset; mod raster; @@ -6,21 +8,22 @@ mod raster; mod state_tests; mod trim; +pub use asset_render::{MissingImagePolicy, prepare_render_document}; pub use precheck::{ - ComplianceStatus, ComplianceThresholds, PrecheckReport, page_metrics, precheck_report, + ComplianceStatus, ComplianceThresholds, PrecheckReport, image_precheck_items, page_metrics, + precheck_report, }; pub use preset::ExportPreset; pub use raster::{ DEFAULT_MAX_RASTER_BYTES, DEFAULT_MAX_RASTER_HEIGHT, DEFAULT_MAX_RASTER_PIXELS, DEFAULT_MAX_RASTER_WIDTH, RasterError, RasterImage, RasterLimits, RasterOptions, - rasterize_canvas, rasterize_svg, + rasterize_canvas, rasterize_canvas_with_assets, rasterize_svg, }; -use crate::state::{CanvasDocument, render_document_svg}; +use crate::state::{AssetId, AssetRecord, CanvasDocument}; use image::codecs::jpeg::{JpegEncoder, PixelDensity}; use image::{ExtendedColorType, ImageFormat}; -use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, TextStr}; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -96,6 +99,7 @@ pub struct ExportDialogState { pub dpi: u16, pub preset: Option, pub trim_to_visible_content: bool, + pub allow_missing_images: bool, } impl ExportDialogState { @@ -106,6 +110,7 @@ impl ExportDialogState { dpi: DEFAULT_BITMAP_DPI, preset: None, trim_to_visible_content: false, + allow_missing_images: false, } } @@ -165,10 +170,9 @@ pub struct ExportSettings { pub format: ExportFormat, pub scope: ExportPageScope, pub dpi: u16, - /// When set, each page is scaled (uniformly, preserving aspect ratio) so its - /// output width equals this many millimetres. `None` keeps the page's size. pub target_width_mm: Option, pub trim_to_visible_content: bool, + pub allow_missing_images: bool, } impl From<&ExportDialogState> for ExportSettings { @@ -179,6 +183,7 @@ impl From<&ExportDialogState> for ExportSettings { dpi: value.dpi, target_width_mm: value.target_width_mm(), trim_to_visible_content: value.trim_to_visible_content, + allow_missing_images: value.allow_missing_images, } } } @@ -189,6 +194,13 @@ pub enum ExportError { EmptyDocument, #[error("current page is no longer available")] MissingCurrentPage, + #[error("image asset {asset} is missing; replace it or allow placeholder export")] + MissingImageAsset { asset: crate::state::AssetId }, + #[error("image asset {asset} is damaged: {reason}")] + CorruptImageAsset { + asset: crate::state::AssetId, + reason: String, + }, #[error("page range must be between 1 and {page_count}")] InvalidRange { page_count: usize }, #[error("SVG parse failed: {0}")] @@ -232,47 +244,70 @@ pub fn export_canvases( active_page: Option, settings: &ExportSettings, base_path: &Path, +) -> Result, ExportError> { + export_canvases_with_assets(canvases, &BTreeMap::new(), active_page, settings, base_path) +} + +pub fn export_canvases_with_assets( + canvases: &[CanvasDocument], + assets: &BTreeMap, + active_page: Option, + settings: &ExportSettings, + base_path: &Path, ) -> Result, ExportError> { let pages = resolve_page_scope(settings.scope, active_page, canvases.len())?; let target = settings.target_width_mm; + let missing_policy = if settings.allow_missing_images { + MissingImagePolicy::Placeholder + } else { + MissingImagePolicy::Block + }; match settings.format { ExportFormat::Svg => export_svg( canvases, + assets, &pages, target, settings.trim_to_visible_content, + missing_policy, base_path, ), ExportFormat::Pdf => export_pdf( canvases, + assets, &pages, target, settings.trim_to_visible_content, + missing_policy, base_path, ), ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Tiff => export_bitmap( canvases, + assets, &pages, - settings.format, - settings.dpi, - target, - settings.trim_to_visible_content, + settings, + missing_policy, base_path, ), } } -/// Scale the SVG's declared physical size; `None` preserves the authored size. -fn document_svg(canvas: &CanvasDocument, target_width_mm: Option) -> String { - let svg = render_document_svg(canvas); +fn document_svg_with_assets( + canvas: &CanvasDocument, + assets: &BTreeMap, + target_width_mm: Option, + missing_policy: MissingImagePolicy, +) -> Result { + let document = prepare_render_document(canvas, assets, missing_policy)?; + let svg = plotx_render::svg::export_document(&document); let Some(target) = target_width_mm else { - return svg; + return Ok(svg); }; let [w, h] = canvas.size_pt(); let scale = target / canvas.size_mm[0].max(f32::MIN_POSITIVE); let from = format!(r#"width="{w}pt" height="{h}pt""#); let to = format!(r#"width="{}pt" height="{}pt""#, w * scale, h * scale); - svg.replacen(&from, &to, 1) + Ok(svg.replacen(&from, &to, 1)) } pub fn export_output_paths( @@ -291,17 +326,24 @@ pub fn export_output_paths( fn export_svg( canvases: &[CanvasDocument], + assets: &BTreeMap, pages: &[usize], target_width_mm: Option, trim_to_visible_content: bool, + missing_policy: MissingImagePolicy, base_path: &Path, ) -> Result, ExportError> { let paths = export_output_paths(base_path, ExportFormat::Svg, pages.len()); for (&page, path) in pages.iter().zip(&paths) { let svg = if trim_to_visible_content { - trim::trim_document_svg(&canvases[page], target_width_mm)? + trim::trim_document_svg_with_assets( + &canvases[page], + assets, + target_width_mm, + missing_policy, + )? } else { - document_svg(&canvases[page], target_width_mm) + document_svg_with_assets(&canvases[page], assets, target_width_mm, missing_policy)? }; std::fs::write(path, svg)?; } @@ -310,9 +352,11 @@ fn export_svg( fn export_pdf( canvases: &[CanvasDocument], + assets: &BTreeMap, pages: &[usize], target_width_mm: Option, trim_to_visible_content: bool, + missing_policy: MissingImagePolicy, base_path: &Path, ) -> Result, ExportError> { let path = with_extension(base_path, ExportFormat::Pdf.extension()); @@ -320,57 +364,53 @@ fn export_pdf( .iter() .map(|&page| { if trim_to_visible_content { - trim::trim_document_svg(&canvases[page], target_width_mm) + trim::trim_document_svg_with_assets( + &canvases[page], + assets, + target_width_mm, + missing_policy, + ) } else { - Ok(document_svg(&canvases[page], target_width_mm)) + document_svg_with_assets(&canvases[page], assets, target_width_mm, missing_policy) } }) .collect::>()?; - let pdf = if svgs.len() == 1 { - let tree = parse_pdf_svg(&svgs[0])?; - svg2pdf::to_pdf( - &tree, - svg2pdf::ConversionOptions::default(), - svg2pdf::PageOptions::default(), - ) - .map_err(|e| ExportError::Pdf(e.to_string()))? - } else { - render_multi_page_pdf(&svgs)? - }; + let pdf = pdf::render(&svgs)?; std::fs::write(&path, pdf)?; Ok(vec![path]) } fn export_bitmap( canvases: &[CanvasDocument], + assets: &BTreeMap, pages: &[usize], - format: ExportFormat, - dpi: u16, - target_width_mm: Option, - trim_to_visible_content: bool, + settings: &ExportSettings, + missing_policy: MissingImagePolicy, base_path: &Path, ) -> Result, ExportError> { - let paths = export_output_paths(base_path, format, pages.len()); + let paths = export_output_paths(base_path, settings.format, pages.len()); for (&page, path) in pages.iter().zip(&paths) { - let raster = rasterize_canvas( + let raster = rasterize_canvas_with_assets( &canvases[page], + assets, RasterOptions { - dpi, - target_width_mm, + dpi: settings.dpi, + target_width_mm: settings.target_width_mm, limits: RasterLimits::default(), }, + missing_policy, )?; - let raster = if trim_to_visible_content { + let raster = if settings.trim_to_visible_content { let background = canvases[page].background; trim::crop_raster( raster, [background.r, background.g, background.b, 255], - trim::raster_trim_padding(dpi), + trim::raster_trim_padding(settings.dpi), )? } else { raster }; - match format { + match settings.format { ExportFormat::Png => { image::save_buffer_with_format( path, @@ -395,7 +435,7 @@ fn export_bitmap( let rgb = raster.to_rgb_over([255, 255, 255]); let file = std::fs::File::create(path)?; let mut encoder = JpegEncoder::new_with_quality(file, 90); - encoder.set_pixel_density(PixelDensity::dpi(dpi)); + encoder.set_pixel_density(PixelDensity::dpi(settings.dpi)); encoder.encode( &rgb, raster.width(), @@ -409,76 +449,6 @@ fn export_bitmap( Ok(paths) } -fn render_multi_page_pdf(svgs: &[String]) -> Result, ExportError> { - let mut alloc = Ref::new(1); - let catalog_id = alloc.bump(); - let page_tree_id = alloc.bump(); - let document_info_id = alloc.bump(); - let page_ids: Vec<_> = svgs.iter().map(|_| alloc.bump()).collect(); - let content_ids: Vec<_> = svgs.iter().map(|_| alloc.bump()).collect(); - - let mut embedded = Vec::with_capacity(svgs.len()); - for svg in svgs { - let tree = parse_pdf_svg(svg)?; - let size = tree.size(); - let (chunk, svg_id) = svg2pdf::to_chunk(&tree, svg2pdf::ConversionOptions::default()) - .map_err(|e| ExportError::Pdf(e.to_string()))?; - let mut ref_map = HashMap::new(); - let chunk = chunk.renumber(|old| *ref_map.entry(old).or_insert_with(|| alloc.bump())); - let svg_id = *ref_map - .get(&svg_id) - .ok_or_else(|| ExportError::Pdf("could not renumber SVG PDF object".into()))?; - // usvg reports CSS pixels (96/in), while a PDF MediaBox uses points - // (72/in). The SVG XObject is normalized and then placed at this size. - embedded.push(( - chunk, - svg_id, - size.width() * 72.0 / 96.0, - size.height() * 72.0 / 96.0, - )); - } - - let mut pdf = Pdf::new(); - pdf.catalog(catalog_id).pages(page_tree_id); - pdf.document_info(document_info_id) - .producer(TextStr("plotx svg2pdf")); - pdf.pages(page_tree_id) - .kids(page_ids.iter().copied()) - .count(page_ids.len() as i32); - - for (index, ((chunk, svg_id, width, height), (&page_id, &content_id))) in embedded - .iter() - .zip(page_ids.iter().zip(&content_ids)) - .enumerate() - { - let name = format!("S{}", index + 1); - let svg_name = Name(name.as_bytes()); - let mut page = pdf.page(page_id); - page.media_box(Rect::new(0.0, 0.0, *width, *height)); - page.parent(page_tree_id); - page.contents(content_id); - let mut resources = page.resources(); - resources.x_objects().pair(svg_name, *svg_id); - resources.finish(); - page.finish(); - - let mut content = Content::new(); - content - .transform([*width, 0.0, 0.0, *height, 0.0, 0.0]) - .x_object(svg_name); - pdf.stream(content_id, &content.finish()); - pdf.extend(chunk); - } - - Ok(pdf.finish()) -} - -fn parse_pdf_svg(svg: &str) -> Result { - let mut options = svg2pdf::usvg::Options::default(); - fonts::load_system_fonts(options.fontdb_mut()); - svg2pdf::usvg::Tree::from_str(svg, &options).map_err(|e| ExportError::SvgParse(e.to_string())) -} - fn with_extension(path: &Path, extension: &str) -> PathBuf { let mut path = path.to_path_buf(); path.set_extension(extension); @@ -512,6 +482,16 @@ mod tests { CanvasDocument::new(name.to_owned(), size_mm) } + fn document_svg(canvas: &CanvasDocument, target_width_mm: Option) -> String { + document_svg_with_assets( + canvas, + &BTreeMap::new(), + target_width_mm, + MissingImagePolicy::Block, + ) + .expect("a document without image assets renders") + } + fn test_dir() -> PathBuf { std::env::var_os("CARGO_TARGET_TMPDIR") .map(PathBuf::from) @@ -573,6 +553,7 @@ mod tests { dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }, &out, ) @@ -595,6 +576,7 @@ mod tests { dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }, &out, ) @@ -620,6 +602,7 @@ mod tests { dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }, &out, ) @@ -646,6 +629,7 @@ mod tests { dpi: 600, target_width_mm: Some(89.0), trim_to_visible_content: false, + allow_missing_images: false, }, &out, ) @@ -719,7 +703,9 @@ mod tests { .collect::>(); let authored_scale = 0.5; assert!((width_pt - view_box[2] * authored_scale).abs() < 0.01); - let bounds_svg = crate::state::render_document_svg_for_bounds(&doc); + let bounds_svg = plotx_render::svg::export_document_for_bounds( + &crate::state::build_render_document(&doc), + ); let mut options = resvg::usvg::Options::default(); fonts::load_system_fonts(options.fontdb_mut()); let tree = resvg::usvg::Tree::from_str(&bounds_svg, &options).unwrap(); @@ -755,6 +741,7 @@ mod tests { dpi: 72, target_width_mm: None, trim_to_visible_content: true, + allow_missing_images: false, }, &out, ) @@ -785,6 +772,7 @@ mod tests { dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: true, + allow_missing_images: false, }, &out, ) diff --git a/crates/core/src/export/pdf.rs b/crates/core/src/export/pdf.rs new file mode 100644 index 0000000..1417bc3 --- /dev/null +++ b/crates/core/src/export/pdf.rs @@ -0,0 +1,87 @@ +use super::{ExportError, fonts}; +use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, TextStr}; +use std::collections::HashMap; + +pub(super) fn render(svgs: &[String]) -> Result, ExportError> { + if svgs.len() == 1 { + let tree = parse_svg(&svgs[0])?; + return svg2pdf::to_pdf( + &tree, + svg2pdf::ConversionOptions::default(), + svg2pdf::PageOptions::default(), + ) + .map_err(|error| ExportError::Pdf(error.to_string())); + } + render_multi_page(svgs) +} + +fn render_multi_page(svgs: &[String]) -> Result, ExportError> { + let mut alloc = Ref::new(1); + let catalog_id = alloc.bump(); + let page_tree_id = alloc.bump(); + let document_info_id = alloc.bump(); + let page_ids: Vec<_> = svgs.iter().map(|_| alloc.bump()).collect(); + let content_ids: Vec<_> = svgs.iter().map(|_| alloc.bump()).collect(); + + let mut embedded = Vec::with_capacity(svgs.len()); + for svg in svgs { + let tree = parse_svg(svg)?; + let size = tree.size(); + let (chunk, svg_id) = svg2pdf::to_chunk(&tree, svg2pdf::ConversionOptions::default()) + .map_err(|error| ExportError::Pdf(error.to_string()))?; + let mut ref_map = HashMap::new(); + let chunk = chunk.renumber(|old| *ref_map.entry(old).or_insert_with(|| alloc.bump())); + let svg_id = *ref_map + .get(&svg_id) + .ok_or_else(|| ExportError::Pdf("could not renumber SVG PDF object".into()))?; + // usvg reports CSS pixels (96/in), while a PDF MediaBox uses points + // (72/in). The SVG XObject is normalized and then placed at this size. + embedded.push(( + chunk, + svg_id, + size.width() * 72.0 / 96.0, + size.height() * 72.0 / 96.0, + )); + } + + let mut pdf = Pdf::new(); + pdf.catalog(catalog_id).pages(page_tree_id); + pdf.document_info(document_info_id) + .producer(TextStr("plotx svg2pdf")); + pdf.pages(page_tree_id) + .kids(page_ids.iter().copied()) + .count(page_ids.len() as i32); + + for (index, ((chunk, svg_id, width, height), (&page_id, &content_id))) in embedded + .iter() + .zip(page_ids.iter().zip(&content_ids)) + .enumerate() + { + let name = format!("S{}", index + 1); + let svg_name = Name(name.as_bytes()); + let mut page = pdf.page(page_id); + page.media_box(Rect::new(0.0, 0.0, *width, *height)); + page.parent(page_tree_id); + page.contents(content_id); + let mut resources = page.resources(); + resources.x_objects().pair(svg_name, *svg_id); + resources.finish(); + page.finish(); + + let mut content = Content::new(); + content + .transform([*width, 0.0, 0.0, *height, 0.0, 0.0]) + .x_object(svg_name); + pdf.stream(content_id, &content.finish()); + pdf.extend(chunk); + } + + Ok(pdf.finish()) +} + +fn parse_svg(svg: &str) -> Result { + let mut options = svg2pdf::usvg::Options::default(); + fonts::load_system_fonts(options.fontdb_mut()); + svg2pdf::usvg::Tree::from_str(svg, &options) + .map_err(|error| ExportError::SvgParse(error.to_string())) +} diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index 1f05a70..118fe5b 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -1,6 +1,7 @@ use super::ExportFormat; -use crate::state::{CanvasDocument, CanvasObjectKind}; +use crate::state::{AssetId, AssetRecord, CanvasDocument, CanvasObjectKind, ImageFit, QuarterTurn}; use plotx_figure::AxisFrame; +use std::collections::BTreeMap; /// The minimum rendered sizes a figure must meet for a target. Values are in /// points, measured at the exported physical size (after any column downscale). @@ -149,6 +150,138 @@ pub fn precheck_report( PrecheckReport { items } } +pub fn image_precheck_items( + canvases: &[&CanvasDocument], + assets: &BTreeMap, + target_width_mm: Option, +) -> Vec { + let mut minimum_ppi: Option = None; + let mut missing = 0usize; + let mut converted = 0usize; + let mut images = 0usize; + for canvas in canvases { + let output_scale = target_width_mm + .map(|target| target / canvas.size_mm[0].max(f32::MIN_POSITIVE)) + .unwrap_or(1.0); + for item in &canvas.objects { + let CanvasObjectKind::RasterImage(image) = &item.kind else { + continue; + }; + if !item.visible + || canvas + .parent_panel(item.id) + .and_then(|panel| canvas.panel(panel)) + .is_some_and(|panel| !panel.visible) + { + continue; + } + images += 1; + let Some(asset) = assets.get(&image.asset) else { + missing += 1; + continue; + }; + let Ok(probe) = plotx_io::image::probe(&asset.bytes) else { + missing += 1; + continue; + }; + if probe.high_precision || probe.has_icc { + converted += 1; + } + let source = if image.page_index == 0 { + asset.pixel_size + } else { + plotx_io::image::tiff_page_dimensions(&asset.bytes, image.page_index) + .unwrap_or(asset.pixel_size) + }; + let Some(frame) = canvas.content_page_frame(item.id) else { + continue; + }; + let ppi = effective_ppi(source, image, frame, output_scale); + if ppi.is_finite() { + minimum_ppi = Some(minimum_ppi.map_or(ppi, |current| current.min(ppi))); + } + } + } + if images == 0 { + return Vec::new(); + } + let mut items = vec![if missing == 0 { + ComplianceItem { + status: ComplianceStatus::Pass, + label: "Embedded images".to_owned(), + detail: format!("{images} available"), + } + } else { + ComplianceItem { + status: ComplianceStatus::Fail, + label: "Embedded images".to_owned(), + detail: format!("{missing} missing or damaged; replace before publication export"), + } + }]; + if let Some(ppi) = minimum_ppi { + items.push(ComplianceItem { + status: if ppi >= 300.0 { + ComplianceStatus::Pass + } else if ppi >= 150.0 { + ComplianceStatus::Warn + } else { + ComplianceStatus::Fail + }, + label: "Lowest effective image PPI".to_owned(), + detail: format!("{ppi:.0} PPI at exported size (recommended 300 PPI)"), + }); + } + items.push(ComplianceItem { + status: if converted == 0 { + ComplianceStatus::Pass + } else { + ComplianceStatus::Warn + }, + label: "Image color".to_owned(), + detail: if converted == 0 { + "8-bit RGB sources".to_owned() + } else { + format!( + "{converted} image(s) with an ICC profile or high-precision samples export as 8-bit RGBA without an embedded profile" + ) + }, + }); + items +} + +fn effective_ppi( + source: [u32; 2], + image: &crate::state::RasterImageContent, + frame: crate::state::ObjectFrame, + output_scale: f32, +) -> f32 { + let mut pixels = [ + source[0] as f32 * (image.crop[2] - image.crop[0]), + source[1] as f32 * (image.crop[3] - image.crop[1]), + ]; + if matches!( + image.rotation, + QuarterTurn::Clockwise90 | QuarterTurn::Clockwise270 + ) { + pixels.swap(0, 1); + } + let frame = [frame.width * output_scale, frame.height * output_scale]; + match image.fit { + ImageFit::Stretch => (pixels[0] * 72.0 / frame[0].max(f32::MIN_POSITIVE)) + .min(pixels[1] * 72.0 / frame[1].max(f32::MIN_POSITIVE)), + ImageFit::Contain => { + let points_per_pixel = (frame[0] / pixels[0].max(f32::MIN_POSITIVE)) + .min(frame[1] / pixels[1].max(f32::MIN_POSITIVE)); + 72.0 / points_per_pixel.max(f32::MIN_POSITIVE) + } + ImageFit::Cover => { + let points_per_pixel = (frame[0] / pixels[0].max(f32::MIN_POSITIVE)) + .max(frame[1] / pixels[1].max(f32::MIN_POSITIVE)); + 72.0 / points_per_pixel.max(f32::MIN_POSITIVE) + } + } +} + fn worst_scaled( metrics: &[PageMetrics], target_width_mm: Option, @@ -210,10 +343,14 @@ fn threshold_status(value: f32, min: f32) -> ComplianceStatus { mod tests { use super::*; use crate::state::{ - AxisOverrides, AxisProjections, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, - DataBinding, ObjectFrame, ObjectId, PanelMeta, PlotObject, StackSpec, + AssetId, AssetRecord, AxisOverrides, AxisProjections, CanvasObject, CanvasObjectKind, + CanvasViewport, ChartSpec, DataBinding, ObjectFrame, ObjectId, PlotObject, + RasterImageContent, StackSpec, }; + use image::{DynamicImage, ImageBuffer, ImageFormat, Luma}; use plotx_figure::{Axis, Color, Figure, RangeAnnotation, Series}; + use sha2::{Digest, Sha256}; + use std::io::Cursor; fn thresholds() -> ComplianceThresholds { ComplianceThresholds { @@ -268,6 +405,59 @@ mod tests { assert_eq!(vector.items[2].status, ComplianceStatus::Pass); } + #[test] + fn effective_ppi_accounts_for_fit_crop_rotation_and_export_scale() { + let mut image = RasterImageContent::new(AssetId::new()); + let frame = ObjectFrame::new(0.0, 0.0, 144.0, 72.0); + image.fit = ImageFit::Stretch; + assert_eq!(effective_ppi([600, 300], &image, frame, 1.0), 300.0); + + image.crop = [0.0, 0.0, 0.5, 1.0]; + assert_eq!(effective_ppi([600, 300], &image, frame, 1.0), 150.0); + image.crop = [0.0, 0.0, 1.0, 1.0]; + image.rotation = QuarterTurn::Clockwise90; + assert_eq!(effective_ppi([600, 300], &image, frame, 0.5), 300.0); + } + + #[test] + fn image_precheck_reports_availability_resolution_and_color_conversion() { + let source = ImageBuffer::from_pixel(600, 300, Luma([1000_u16])); + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::ImageLuma16(source) + .write_to(&mut encoded, ImageFormat::Png) + .unwrap(); + let bytes = encoded.into_inner(); + let id = AssetId::new(); + let asset = AssetRecord { + id, + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [600, 300], + bytes, + }; + let mut canvas = CanvasDocument::new("precheck".to_owned(), [100.0, 100.0]); + let mut image = RasterImageContent::new(id); + image.fit = ImageFit::Stretch; + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "image".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 144.0, 72.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(image), + }); + + let items = image_precheck_items(&[&canvas], &BTreeMap::from([(id, asset)]), None); + assert_eq!(items[0].status, ComplianceStatus::Pass); + assert_eq!(items[1].status, ComplianceStatus::Pass); + assert!(items[1].detail.contains("300 PPI")); + assert_eq!(items[2].status, ComplianceStatus::Warn); + assert!(items[2].detail.contains("without an embedded profile")); + + let missing = image_precheck_items(&[&canvas], &BTreeMap::new(), None); + assert_eq!(missing[0].status, ComplianceStatus::Fail); + } + #[test] fn hidden_axes_do_not_contribute_unrendered_typography() { let mut figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); @@ -275,8 +465,6 @@ mod tests { figure.typography.tick_pt = 3.0; figure.typography.label_pt = 4.0; let viewport = CanvasViewport::from_figure(&figure); - let mut panel = PanelMeta::new(String::new(), 100.0); - panel.visible = false; let mut canvas = CanvasDocument::new("Hidden axes".to_owned(), [200.0, 100.0]); canvas.objects.push(CanvasObject { id: ObjectId::new(1), @@ -294,7 +482,6 @@ mod tests { AxisOverrides::default(), figure, viewport, - panel, ))), }); @@ -317,8 +504,6 @@ mod tests { Series::line("B", vec![[1.0, 1.0]]).colored(Color::rgb(200, 0, 0)), ]; let viewport = CanvasViewport::from_figure(&figure); - let mut panel = PanelMeta::new(String::new(), 100.0); - panel.visible = false; let mut canvas = CanvasDocument::new("Legend".to_owned(), [200.0, 100.0]); canvas.objects.push(CanvasObject { id: ObjectId::new(1), @@ -336,7 +521,6 @@ mod tests { AxisOverrides::default(), figure, viewport, - panel, ))), }); @@ -359,8 +543,6 @@ mod tests { width: 1.0, }); let viewport = CanvasViewport::from_figure(&figure); - let mut panel = PanelMeta::new(String::new(), 100.0); - panel.visible = false; let mut canvas = CanvasDocument::new("Ranges".to_owned(), [200.0, 100.0]); canvas.objects.push(CanvasObject { id: ObjectId::new(1), @@ -378,7 +560,6 @@ mod tests { AxisOverrides::default(), figure, viewport, - panel, ))), }); diff --git a/crates/core/src/export/raster.rs b/crates/core/src/export/raster.rs index 7e3fa8a..e08152d 100644 --- a/crates/core/src/export/raster.rs +++ b/crates/core/src/export/raster.rs @@ -1,5 +1,7 @@ -use crate::state::{CanvasDocument, render_document_svg}; +use super::{ExportError, MissingImagePolicy, prepare_render_document}; +use crate::state::{AssetId, AssetRecord, CanvasDocument, render_document_svg}; use resvg::tiny_skia::{Pixmap, Transform}; +use std::collections::BTreeMap; use thiserror::Error; /// Conservative defaults keep one RGBA result below 256 MiB and prevent @@ -194,6 +196,17 @@ pub fn rasterize_canvas( rasterize_svg(&svg, canvas.size_pt(), options) } +pub fn rasterize_canvas_with_assets( + canvas: &CanvasDocument, + assets: &BTreeMap, + options: RasterOptions, + missing_policy: MissingImagePolicy, +) -> Result { + let document = prepare_render_document(canvas, assets, missing_policy)?; + let svg = plotx_render::svg::export_document(&document); + Ok(rasterize_svg(&svg, canvas.size_pt(), options)?) +} + /// Render a physically sized SVG into a straight-alpha RGBA8 image. /// /// `source_size_pt` must describe the SVG view box in points. The function diff --git a/crates/core/src/export/trim.rs b/crates/core/src/export/trim.rs index d075db4..8ded4d5 100644 --- a/crates/core/src/export/trim.rs +++ b/crates/core/src/export/trim.rs @@ -1,7 +1,8 @@ use super::ExportError; use super::raster::{RasterError, RasterImage}; -use crate::state::{CanvasDocument, render_document_svg_for_bounds, render_document_svg_page}; +use crate::state::{AssetId, AssetRecord, CanvasDocument}; use plotx_render::Rect; +use std::collections::BTreeMap; const TRIM_SAFETY_EDGE_PT: f32 = 1.0; @@ -140,17 +141,20 @@ pub(crate) fn raster_trim_padding(dpi: u16) -> u32 { u32::from(dpi).div_ceil(72).max(1) } -pub(crate) fn trim_document_svg( +pub(crate) fn trim_document_svg_with_assets( canvas: &CanvasDocument, + assets: &BTreeMap, target_width_mm: Option, + missing_policy: super::MissingImagePolicy, ) -> Result { let [page_width, page_height] = canvas.size_pt(); let scale = target_width_mm .map(|target| target / canvas.size_mm[0].max(f32::MIN_POSITIVE)) .unwrap_or(1.0); - let bounds_svg = render_document_svg_for_bounds(canvas); + let document = super::prepare_render_document(canvas, assets, missing_policy)?; + let bounds_svg = plotx_render::svg::export_document_for_bounds(&document); let Some(bounds) = svg_content_bounds(&bounds_svg, [page_width, page_height])? else { - return Ok(super::document_svg(canvas, target_width_mm)); + return super::document_svg_with_assets(canvas, assets, target_width_mm, missing_policy); }; let left = bounds.left; let top = bounds.top; @@ -166,13 +170,26 @@ pub(crate) fn trim_document_svg( (right + padding).min(page_width) - (left - padding).max(0.0), (bottom + padding).min(page_height) - (top - padding).max(0.0), ); - Ok(render_document_svg_page( - canvas, + Ok(plotx_render::svg::export_document_page( + &document, view, [view.width * scale, view.height * scale], )) } +#[cfg(test)] +pub(crate) fn trim_document_svg( + canvas: &CanvasDocument, + target_width_mm: Option, +) -> Result { + trim_document_svg_with_assets( + canvas, + &BTreeMap::new(), + target_width_mm, + super::MissingImagePolicy::Block, + ) +} + fn svg_content_bounds(svg: &str, page: [f32; 2]) -> Result, ExportError> { let mut options = resvg::usvg::Options::default(); super::fonts::load_system_fonts(options.fontdb_mut()); diff --git a/crates/core/src/project/asset_codec.rs b/crates/core/src/project/asset_codec.rs index d3d6b6f..257253c 100644 --- a/crates/core/src/project/asset_codec.rs +++ b/crates/core/src/project/asset_codec.rs @@ -1,5 +1,33 @@ use super::*; +pub(super) fn append_undeclared_image_warnings( + doc: &Document, + manifest: &Manifest, + warnings: &mut Vec, +) { + let declared: std::collections::BTreeSet = manifest + .assets + .iter() + .filter_map(|entry| entry.id.parse().ok()) + .collect(); + let undeclared: std::collections::BTreeSet = doc + .canvases + .iter() + .flat_map(|canvas| canvas.objects.iter()) + .filter_map(|item| match &item.kind { + CanvasObjectKind::RasterImage(image) + if !doc.assets.contains_key(&image.asset) && !declared.contains(&image.asset) => + { + Some(image.asset) + } + _ => None, + }) + .collect(); + warnings.extend(undeclared.into_iter().map(|asset| { + format!("Embedded image {asset} is referenced but is not listed in the project manifest.") + })); +} + pub(super) fn write_reachable_assets( doc: &Document, zip: &mut zip::ZipWriter, @@ -81,8 +109,9 @@ pub(super) fn load_assets( zip: &mut ZipArchive, manifest: &Manifest, app: &mut PlotxApp, -) -> Result<()> { +) -> Result> { let mut ids = std::collections::BTreeSet::new(); + let mut warnings = Vec::new(); let mut path_metadata = std::collections::BTreeMap::new(); let mut path_bytes: std::collections::BTreeMap> = std::collections::BTreeMap::new(); @@ -98,9 +127,8 @@ pub(super) fn load_assets( || entry.pixel_size.contains(&0) || !valid_format(&entry.format) { - return Err(ProjectError::Invalid(format!( - "asset {id} has invalid metadata" - ))); + warnings.push(format!("Embedded image {id} has invalid metadata.")); + continue; } let metadata = ( entry.sha256.clone(), @@ -119,21 +147,44 @@ pub(super) fn load_assets( let bytes = if let Some(bytes) = path_bytes.get(&entry.path) { bytes.clone() } else { - let bytes = read_bytes(zip, &entry.path)?; + let bytes = match read_bytes(zip, &entry.path) { + Ok(bytes) => bytes, + Err(error) => { + warnings.push(format!("Embedded image {id} could not be read: {error}")); + continue; + } + }; path_bytes.insert(entry.path.clone(), bytes.clone()); bytes }; if bytes.len() as u64 != entry.byte_len { - return Err(ProjectError::Invalid(format!( - "asset {id} byte length mismatch" - ))); + warnings.push(format!("Embedded image {id} has a byte-length mismatch.")); + continue; } let digest = Sha256::digest(&bytes); let hash = format!("{digest:x}"); if hash != entry.sha256 || entry.path != format!("assets/{hash}.{}", entry.format) { - return Err(ProjectError::Invalid(format!( - "asset {id} hash or path mismatch" - ))); + warnings.push(format!("Embedded image {id} failed its integrity check.")); + continue; + } + if !format_matches_header(&entry.format, &bytes) { + warnings.push(format!( + "Embedded image {id} does not match its declared format." + )); + continue; + } + let probe = match plotx_io::image::probe(&bytes) { + Ok(probe) => probe, + Err(error) => { + warnings.push(format!("Embedded image {id} is damaged: {error}")); + continue; + } + }; + if [probe.width, probe.height] != entry.pixel_size { + warnings.push(format!( + "Embedded image {id} dimensions do not match its manifest." + )); + continue; } app.doc.assets.insert( id, @@ -146,9 +197,20 @@ pub(super) fn load_assets( }, ); } - Ok(()) + Ok(warnings) } fn valid_format(format: &str) -> bool { matches!(format, "png" | "jpeg" | "tiff" | "webp" | "bmp") } + +fn format_matches_header(format: &str, bytes: &[u8]) -> bool { + matches!( + (format, plotx_io::image::sniff(bytes)), + ("png", plotx_io::image::RasterFormat::Png) + | ("jpeg", plotx_io::image::RasterFormat::Jpeg) + | ("tiff", plotx_io::image::RasterFormat::Tiff) + | ("webp", plotx_io::image::RasterFormat::WebP) + | ("bmp", plotx_io::image::RasterFormat::Bmp) + ) +} diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index c9de537..326bd34 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -600,7 +600,6 @@ pub fn view_to_canvas( derived_axes, figure, viewport, - PanelMeta::new(app.default_plot_title(di), frame.width), ))) } other => { diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index c10b972..73bd951 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -2,9 +2,8 @@ use crate::layout::PageLayout; use crate::state::{ AnalysisSelection, AssetId, AssetRecord, AxisRange, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, DataBinding, Dataset, DatasetLineage, DerivationKind, - Document, Nmr2DDataset, NmrDataset, ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, - PrimaryView, SeriesBinding, ShapeKind, ShapeObject, StackMode, StackSpec, TextAlign, TextBox, - Tool, + Document, Nmr2DDataset, NmrDataset, ObjectFrame, ObjectId, PlotObject, PlotxApp, PrimaryView, + SeriesBinding, ShapeKind, ShapeObject, StackMode, StackSpec, TextAlign, TextBox, Tool, }; use num_complex::Complex64; use plotx_figure::Color; @@ -260,7 +259,7 @@ fn save_project_impl( recovery: Option, backup_count: usize, ) -> Result> { - validate_resource_ids(doc)?; + validate_resource_ids(doc, true)?; let tmp_path = temporary_path(path); let file = File::create(&tmp_path)?; let mut zip = zip::ZipWriter::new(file); @@ -468,7 +467,7 @@ pub fn load_project(path: &Path) -> Result { app.doc.datasets.clear(); app.doc.canvases.clear(); app.doc.assets.clear(); - asset_codec::load_assets(&mut zip, &manifest, &mut app)?; + app.session.project_load_warnings = asset_codec::load_assets(&mut zip, &manifest, &mut app)?; app.doc.project_path = Some(path.to_owned()); // Restore before the canvases below are built: figures stamp the document // typography at build time. @@ -534,20 +533,12 @@ pub fn load_project(path: &Path) -> Result { .map(|entry| read_json(&mut zip, &entry.path)) .collect::>>()?; app.doc.automation_revision = workspace.automation_revision; - validate_resource_ids(&app.doc)?; - for canvas in &app.doc.canvases { - for item in &canvas.objects { - if let CanvasObjectKind::RasterImage(image) = &item.kind - && !app.doc.assets.contains_key(&image.asset) - { - return Err(ProjectError::Invalid(format!( - "content {} references missing asset {}", - item.id, image.asset - ))); - } - } - } - + asset_codec::append_undeclared_image_warnings( + &app.doc, + &manifest, + &mut app.session.project_load_warnings, + ); + validate_resource_ids(&app.doc, false)?; let active_dataset = workspace .active_data .as_ref() @@ -588,7 +579,7 @@ pub fn load_project(path: &Path) -> Result { Ok(app) } -fn validate_resource_ids(doc: &crate::state::Document) -> Result<()> { +fn validate_resource_ids(doc: &crate::state::Document, require_image_assets: bool) -> Result<()> { let mut ids = std::collections::HashSet::new(); for (kind, id) in doc .datasets @@ -621,9 +612,15 @@ fn validate_resource_ids(doc: &crate::state::Document) -> Result<()> { let crate::state::CanvasObjectKind::RasterImage(image) = &item.kind else { continue; }; - let asset = doc.assets.get(&image.asset).ok_or_else(|| { - ProjectError::Invalid(format!("missing referenced asset {}", image.asset)) - })?; + let Some(asset) = doc.assets.get(&image.asset) else { + if require_image_assets { + return Err(ProjectError::Invalid(format!( + "missing referenced asset {}", + image.asset + ))); + } + continue; + }; if image.page_index > 0 && asset.format != "tiff" { return Err(ProjectError::Invalid(format!( "image {} references page {} of non-TIFF asset {}", diff --git a/crates/core/src/project/panel_schema_tests.rs b/crates/core/src/project/panel_schema_tests.rs index 7361b26..05b75e2 100644 --- a/crates/core/src/project/panel_schema_tests.rs +++ b/crates/core/src/project/panel_schema_tests.rs @@ -3,6 +3,16 @@ use crate::state::{ AssetId, AssetRecord, ContentItem, ContentKind, ObjectFrame, RasterImageContent, }; use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; + +fn png_bytes() -> Vec { + let image = image::RgbaImage::from_pixel(2, 2, image::Rgba([12, 34, 56, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + bytes.into_inner() +} #[test] fn final_v1_panel_and_shared_asset_round_trip_deterministically() { @@ -15,7 +25,7 @@ fn final_v1_panel_and_shared_asset_round_trip_deterministically() { page.panel_mut(panel).unwrap().item_order.push(plot); page.next_panel_label_slot = 9; - let bytes = b"not-decoded-in-foundation-stage".to_vec(); + let bytes = png_bytes(); let asset = AssetId::new(); app.doc.assets.insert( asset, @@ -23,7 +33,7 @@ fn final_v1_panel_and_shared_asset_round_trip_deterministically() { id: asset, sha256: Sha256::digest(&bytes).into(), format: "png".to_owned(), - pixel_size: [12, 8], + pixel_size: [2, 2], bytes: bytes.clone(), }, ); @@ -64,10 +74,139 @@ fn final_v1_panel_and_shared_asset_round_trip_deterministically() { assert_eq!(loaded.doc.assets[&asset].bytes, bytes); } +#[test] +fn missing_embedded_image_degrades_without_discarding_the_v1_content_item() { + let mut app = super::tests::sample_app(); + let bytes = png_bytes(); + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [2, 2], + bytes, + }, + ); + let image = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: image, + name: "missing after archive damage".to_owned(), + frame: ObjectFrame::new(4.0, 5.0, 20.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(asset)), + }); + + let valid = super::tests::temp_project("asset-degradation-source"); + let damaged = super::tests::temp_project("asset-degradation-missing"); + let _ = std::fs::remove_file(&valid); + let _ = std::fs::remove_file(&damaged); + save_project(&app, &valid, false).unwrap(); + + let mut source = zip::ZipArchive::new(std::fs::File::open(&valid).unwrap()).unwrap(); + let output = std::fs::File::create(&damaged).unwrap(); + let mut target = zip::ZipWriter::new(output); + for index in 0..source.len() { + let mut entry = source.by_index(index).unwrap(); + if entry.name().starts_with("assets/") { + continue; + } + let mut contents = Vec::new(); + entry.read_to_end(&mut contents).unwrap(); + target + .start_file(entry.name(), zip::write::SimpleFileOptions::default()) + .unwrap(); + target.write_all(&contents).unwrap(); + } + target.finish().unwrap(); + + let loaded = load_project(&damaged).expect("missing images must not block project open"); + assert!(!loaded.session.project_load_warnings.is_empty()); + assert!(!loaded.doc.assets.contains_key(&asset)); + assert!(matches!( + &loaded.doc.canvases[0].object(image).unwrap().kind, + ContentKind::RasterImage(content) if content.asset == asset + )); + let resave = super::tests::temp_project("asset-degradation-resave"); + let error = save_project(&loaded, &resave, false).unwrap_err(); + assert!(error.to_string().contains("missing referenced asset")); + assert!(!resave.exists()); + + std::fs::remove_file(valid).unwrap(); + std::fs::remove_file(damaged).unwrap(); +} + +#[test] +fn image_reference_absent_from_manifest_produces_a_load_warning() { + let mut app = super::tests::sample_app(); + let bytes = png_bytes(); + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [2, 2], + bytes, + }, + ); + let image = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: image, + name: "undeclared image".to_owned(), + frame: ObjectFrame::new(4.0, 5.0, 20.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(asset)), + }); + + let valid = super::tests::temp_project("undeclared-asset-source"); + let damaged = super::tests::temp_project("undeclared-asset-manifest"); + let _ = std::fs::remove_file(&valid); + let _ = std::fs::remove_file(&damaged); + save_project(&app, &valid, false).unwrap(); + + let mut source = zip::ZipArchive::new(std::fs::File::open(&valid).unwrap()).unwrap(); + let output = std::fs::File::create(&damaged).unwrap(); + let mut target = zip::ZipWriter::new(output); + for index in 0..source.len() { + let mut entry = source.by_index(index).unwrap(); + let mut contents = Vec::new(); + entry.read_to_end(&mut contents).unwrap(); + if entry.name() == "manifest.json" { + let mut manifest: serde_json::Value = serde_json::from_slice(&contents).unwrap(); + manifest["assets"] = serde_json::json!([]); + contents = serde_json::to_vec(&manifest).unwrap(); + } + target + .start_file(entry.name(), zip::write::SimpleFileOptions::default()) + .unwrap(); + target.write_all(&contents).unwrap(); + } + target.finish().unwrap(); + + let loaded = load_project(&damaged).expect("undeclared image must not block project open"); + assert!(loaded.session.project_load_warnings.iter().any(|warning| { + warning.contains(&asset.to_string()) + && warning.contains("not listed in the project manifest") + })); + assert!(!loaded.doc.assets.contains_key(&asset)); + assert!(matches!( + &loaded.doc.canvases[0].object(image).unwrap().kind, + ContentKind::RasterImage(content) if content.asset == asset + )); + + std::fs::remove_file(valid).unwrap(); + std::fs::remove_file(damaged).unwrap(); +} + #[test] fn distinct_asset_ids_can_share_one_content_addressed_blob() { let mut app = super::tests::sample_app(); - let bytes = b"identical-image-bytes".to_vec(); + let bytes = png_bytes(); let digest: [u8; 32] = Sha256::digest(&bytes).into(); let first = AssetId::new(); let second = AssetId::new(); @@ -78,7 +217,7 @@ fn distinct_asset_ids_can_share_one_content_addressed_blob() { id, sha256: digest, format: "png".to_owned(), - pixel_size: [4, 3], + pixel_size: [2, 2], bytes: bytes.clone(), }, ); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index ab7dc3b..a2a069a 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -64,6 +64,7 @@ impl PlotxApp { secondary_sidebar_visible: true, status: "Open data or a project to begin.".into(), operation_history: OperationHistory::default(), + project_load_warnings: Vec::new(), recent_files: { let mut files = settings.recent.files.clone(); files.truncate(crate::settings::MAX_RECENT_FILES); @@ -700,19 +701,3 @@ pub fn build_render_document(document: &CanvasDocument) -> plotx_render::Documen pub fn render_document_svg(document: &CanvasDocument) -> String { plotx_render::svg::export_document(&build_render_document(document)) } - -pub(crate) fn render_document_svg_for_bounds(document: &CanvasDocument) -> String { - plotx_render::svg::export_document_for_bounds(&build_render_document(document)) -} - -pub(crate) fn render_document_svg_page( - document: &CanvasDocument, - view_box: plotx_render::Rect, - physical_size: [f32; 2], -) -> String { - plotx_render::svg::export_document_page( - &build_render_document(document), - view_box, - physical_size, - ) -} diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 5cca0bc..ebcea04 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -36,6 +36,7 @@ impl PlotxApp { Some((dataset.name.clone(), warning)) }) .collect::>(); + let asset_warnings = std::mem::take(&mut self.session.project_load_warnings); let mut report = OperationReport::success( operation_id, OperationKind::ProjectLoad, @@ -63,7 +64,21 @@ impl PlotxApp { .with_context("dataset", name.clone().unwrap_or_default()), ); } - if let Some((_, first)) = restore_warnings.first() { + for warning in &asset_warnings { + report = report.with_diagnostic( + Diagnostic::new( + Severity::Warning, + DiagnosticCode::ProjectLoadWarning, + warning.clone(), + ) + .with_source("core.project.assets") + .with_context("path", path.display().to_string()), + ); + } + if let Some(first) = asset_warnings + .first() + .or_else(|| restore_warnings.first().map(|(_, warning)| warning)) + { self.session.status = first.clone(); } self.session.record_operation(report); @@ -372,27 +387,6 @@ impl PlotxApp { self.record_export_unavailable(format); return; } - if self.doc.canvases[ci] - .objects - .iter() - .any(|item| matches!(item.kind, crate::state::CanvasObjectKind::RasterImage(_))) - { - let operation_id = self.session.begin_operation(); - self.session.record_operation(OperationReport::<()>::failure( - operation_id, - OperationKind::Export, - "Export stopped because this figure contains external images.", - Diagnostic::new( - Severity::Error, - DiagnosticCode::ExportUnavailable, - "This export path does not yet support external images in the figure. Remove the images or keep the project as a .plotx file until image export is available.", - ) - .with_source("core.export.precheck") - .with_context("format", format.label()) - .with_context("reason", "external_images_unsupported"), - )); - return; - } let mut state = ExportDialogState::from_defaults(format, &self.settings.export); let canvas = &self.doc.canvases[ci]; if let Some(preset) = crate::export::ExportPreset::matching_canvas( @@ -412,37 +406,9 @@ impl PlotxApp { .record_operation(export_unavailable_report(operation_id, settings.format)); return; } - let export_pages = crate::export::resolve_page_scope( - settings.scope, - self.session.active_canvas, - self.doc.canvases.len(), - ); - if export_pages.as_ref().is_ok_and(|pages| { - pages.iter().any(|&page| { - self.doc.canvases[page] - .objects - .iter() - .any(|item| matches!(item.kind, crate::state::CanvasObjectKind::RasterImage(_))) - }) - }) { - self.session - .record_operation(OperationReport::<()>::failure( - operation_id, - OperationKind::Export, - "Export stopped because the selected pages contain external images.", - Diagnostic::new( - Severity::Error, - DiagnosticCode::ExportUnavailable, - "This export path does not yet support external images in the figure.", - ) - .with_source("core.export.precheck") - .with_context("format", settings.format.label()) - .with_context("reason", "external_images_unsupported"), - )); - return; - } - match crate::export::export_canvases( + match crate::export::export_canvases_with_assets( &self.doc.canvases, + &self.doc.assets, self.session.active_canvas, &settings, path, @@ -550,7 +516,9 @@ fn export_error_code(error: &crate::export::ExportError) -> DiagnosticCode { match error { crate::export::ExportError::EmptyDocument | crate::export::ExportError::MissingCurrentPage => DiagnosticCode::ExportUnavailable, - crate::export::ExportError::InvalidRange { .. } + crate::export::ExportError::MissingImageAsset { .. } + | crate::export::ExportError::CorruptImageAsset { .. } + | crate::export::ExportError::InvalidRange { .. } | crate::export::ExportError::SvgParse(_) | crate::export::ExportError::Pdf(_) | crate::export::ExportError::Image(_) @@ -563,6 +531,8 @@ fn export_error_kind(error: &crate::export::ExportError) -> &'static str { match error { crate::export::ExportError::EmptyDocument => "empty_document", crate::export::ExportError::MissingCurrentPage => "missing_current_page", + crate::export::ExportError::MissingImageAsset { .. } => "missing_image_asset", + crate::export::ExportError::CorruptImageAsset { .. } => "corrupt_image_asset", crate::export::ExportError::InvalidRange { .. } => "invalid_page_range", crate::export::ExportError::SvgParse(_) => "svg_parse", crate::export::ExportError::Pdf(_) => "pdf_conversion", @@ -628,6 +598,7 @@ mod export_operation_tests { dpi: crate::export::DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }, std::path::Path::new("unused.svg"), ); @@ -651,7 +622,7 @@ mod export_operation_tests { } #[test] - fn raster_precheck_only_scans_pages_in_the_requested_scope() { + fn image_pages_open_export_options_for_precheck_and_placeholder_choice() { let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); app.doc.canvases.push(CanvasDocument::new( "clean".to_owned(), @@ -677,7 +648,7 @@ mod export_operation_tests { app.session.ui.export_options = None; app.session.active_canvas = Some(1); app.request_export(ExportFormat::Svg); - assert!(app.session.ui.export_options.is_none()); + assert!(app.session.ui.export_options.is_some()); } } diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index ff1eb85..87860e6 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -1,6 +1,6 @@ use super::{ AxisOverrides, AxisProjections, CanvasViewport, ChartSpec, DataBinding, DatasetId, DerivedAxes, - PanelMeta, SeriesId, StackSpec, + SeriesId, StackSpec, }; use plotx_figure::{Figure, FigureTypography}; @@ -40,7 +40,6 @@ impl PlotObject { axis_overrides: AxisOverrides, figure: Figure, viewport: CanvasViewport, - _panel: PanelMeta, ) -> Self { let derived_axes = DerivedAxes::from_figure(&figure); Self { @@ -71,7 +70,6 @@ impl PlotObject { derived_axes: DerivedAxes, figure: Figure, viewport: CanvasViewport, - _panel: PanelMeta, ) -> Self { Self { display_owner, diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 95fafe0..dce619f 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -579,7 +579,6 @@ impl PlotxApp { let frame = ObjectFrame::new(0.0, 0.0, page[0], page[1]); let figure = self.build_binding_figure(&binding, &chart, &stack, canvas.size_mm); let viewport = CanvasViewport::from_figure(&figure); - let panel = PanelMeta::new(self.default_plot_title(primary), frame.width); let mut plot = PlotObject::new( None, SeriesId::new(0), @@ -590,7 +589,6 @@ impl PlotxApp { AxisOverrides::default(), figure, viewport, - panel, ); // One place decides how a freshly materialized binding is numbered, so // the ids and the allocator cannot drift apart. diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index a4eeea9..29490f8 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -247,7 +247,7 @@ pub struct RasterProxy { pub hash: [u8; 32], pub page_index: u32, pub pixel_size: [u32; 2], - pub rgba8: std::sync::Arc>, + pub rgba8: std::sync::Arc<[u8]>, } pub struct UiState { @@ -669,6 +669,8 @@ pub struct Session { pub secondary_sidebar_visible: bool, pub status: String, pub operation_history: OperationHistory, + /// Non-fatal resource failures collected while opening the current project. + pub project_load_warnings: Vec, /// Runtime cache of the persisted recent-files list (newest first), seeded /// from settings at construction and kept in sync by `note_recent_file` / /// `clear_recent_files` / `apply_settings`. Not serialized with projects. diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 8d3576e..0533a1a 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -1,13 +1,12 @@ //! UI-independent loading, export, and default-layout workflows shared by all frontends. use crate::actions::ProcessingStateError; use crate::export::{ - DEFAULT_BITMAP_DPI, ExportError, ExportFormat, ExportPageScope, ExportSettings, export_canvases, + DEFAULT_BITMAP_DPI, ExportError, ExportFormat, ExportPageScope, ExportSettings, }; use crate::state::{ AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, DEFAULT_CANVAS_SIZE_MM, DataBinding, Dataset, MM_TO_PT, Nmr2DDataset, NmrDataset, - ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, StackMode, StackSpec, - default_chart_type, + ObjectFrame, ObjectId, PlotObject, PlotxApp, StackMode, StackSpec, default_chart_type, }; use plotx_figure::{Axis, Figure}; use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Provenance}; @@ -186,9 +185,6 @@ pub fn process_file( ) -> Result { let mut loaded = load_dataset(input)?; loaded.apply_scheme_file(scheme)?; - // Headless exports use the same worker-only contour path as the desktop - // app. This waits for queued jobs rather than reintroducing a synchronous - // marching-squares shortcut in the export caller. let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); app.session .compute @@ -217,16 +213,21 @@ pub fn process_file( dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, trim_to_visible_content: false, + allow_missing_images: false, }; - let output_paths = export_canvases(&app.doc.canvases, Some(0), &settings, output)?; + let output_paths = crate::export::export_canvases_with_assets( + &app.doc.canvases, + &app.doc.assets, + Some(0), + &settings, + output, + )?; Ok(ProcessResult { inspection: loaded.inspection, output_paths, }) } -/// The only acquisition-to-dataset conversion path. Loading frontends retain -/// provenance separately and hand the neutral acquisition to this function. pub fn dataset_from_acquisition(acquisition: Acquisition) -> (Dataset, String) { dataset_from_acquisition_with_equal_scale_preference(acquisition, true) } @@ -384,7 +385,6 @@ pub fn build_plot_object( build_dataset_figure(dataset, &chart, size_mm), ); let viewport = CanvasViewport::from_figure(&figure); - let panel = PanelMeta::new(dataset_title(dataset), frame.width); let axis_overrides = AxisOverrides { lock_aspect: matches!(dataset, Dataset::Nmr2D(dataset) if dataset.is_true_2d()) .then_some(figure.lock_aspect), @@ -420,7 +420,6 @@ pub fn build_plot_object( axis_overrides, figure, viewport, - panel, ))), } } @@ -435,8 +434,6 @@ pub fn build_default_canvas(dataset: &Dataset, source: &str) -> CanvasDocument { ) } -/// Build the canonical initial layout used by GUI insertion, CLI, automation, -/// and export; callers supply only the document-local dataset and canvas identity. pub fn build_default_canvas_for_dataset( dataset: &Dataset, dataset_index: usize, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index a2d3096..f73a006 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -14,14 +14,15 @@ path = "src/lib.rs" default = ["screen"] # SVG export is always available. Gate egui screen rendering so headless and # export-only builds do not depend on egui; EMF export is available on Windows. -screen = ["dep:egui", "dep:image"] +screen = ["dep:egui"] emf = ["dep:windows-sys"] [dependencies] plotx-figure.workspace = true unicode-width.workspace = true +base64.workspace = true egui = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image.workspace = true [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, optional = true } diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index b8a14e0..fbd0c3c 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -82,6 +82,7 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { ); SetBkMode(hdc, TRANSPARENT as i32); + let mut paint_error = None; { let mut dc = Dc { hdc, @@ -98,7 +99,12 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { write_overlay(&mut dc, overlay); } } - DocumentItem::Raster(_) => {} + DocumentItem::Raster(raster) => { + if !write_raster(&mut dc, raster) { + paint_error = Some("could not record an embedded image".to_owned()); + break; + } + } DocumentItem::PanelLabel { frame, text, @@ -122,6 +128,10 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { if hemf.is_null() { return Err(EmfError("CloseEnhMetaFile failed".into())); } + if let Some(error) = paint_error { + DeleteEnhMetaFile(hemf); + return Err(EmfError(error)); + } let size = GetEnhMetaFileBits(hemf, 0, std::ptr::null_mut()); if size == 0 { DeleteEnhMetaFile(hemf); @@ -137,6 +147,77 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { } } +fn write_raster(dc: &mut Dc, raster: &crate::DocumentRaster) -> bool { + if !raster.visible || raster.opacity <= 0.0 { + return true; + } + let Some(image) = crate::svg::prepared_raster(raster) else { + return false; + }; + let image_aspect = image.width() as f32 / image.height().max(1) as f32; + let frame_aspect = raster.frame.width / raster.frame.height.max(f32::MIN_POSITIVE); + let destination = match raster.fit { + crate::RasterFit::Stretch => raster.frame, + crate::RasterFit::Contain => { + if image_aspect > frame_aspect { + let height = raster.frame.width / image_aspect; + Rect::new( + raster.frame.left, + raster.frame.top + (raster.frame.height - height) * 0.5, + raster.frame.width, + height, + ) + } else { + let width = raster.frame.height * image_aspect; + Rect::new( + raster.frame.left + (raster.frame.width - width) * 0.5, + raster.frame.top, + width, + raster.frame.height, + ) + } + } + crate::RasterFit::Cover => { + if image_aspect > frame_aspect { + let width = raster.frame.height * image_aspect; + Rect::new( + raster.frame.left + (raster.frame.width - width) * 0.5, + raster.frame.top, + width, + raster.frame.height, + ) + } else { + let height = raster.frame.width / image_aspect; + Rect::new( + raster.frame.left, + raster.frame.top + (raster.frame.height - height) * 0.5, + raster.frame.width, + height, + ) + } + } + }; + let clip = match (raster.fit, raster.clip) { + (crate::RasterFit::Cover, Some(panel)) => intersect(panel, raster.frame), + (crate::RasterFit::Cover, None) => Some(raster.frame), + (_, Some(panel)) => intersect(panel, destination), + (_, None) => Some(destination), + }; + clip.is_some_and(|clip| { + dc.clipped(clip, |dc| { + dc.raster(&image, destination, raster.opacity, raster.nearest) + }) + }) +} + +fn intersect(a: Rect, b: Rect) -> Option { + let left = a.left.max(b.left); + let top = a.top.max(b.top); + let right = a.right().min(b.right()); + let bottom = a.bottom().min(b.bottom()); + (right > left && bottom > top).then(|| Rect::new(left, top, right - left, bottom - top)) +} + fn write_document_object(dc: &mut Dc, object: &DocumentObject<'_>) { if !object.visible { return; diff --git a/crates/render/src/emf/dc.rs b/crates/render/src/emf/dc.rs index d382d87..2b809ad 100644 --- a/crates/render/src/emf/dc.rs +++ b/crates/render/src/emf/dc.rs @@ -6,12 +6,13 @@ use plotx_figure::Color; use std::collections::HashMap; use windows_sys::Win32::Foundation::POINT; use windows_sys::Win32::Graphics::Gdi::{ - ANTIALIASED_QUALITY, BS_SOLID, CreateFontW, CreateSolidBrush, DEFAULT_CHARSET, DeleteObject, - Ellipse, ExtCreatePen, ExtTextOutW, FW_BOLD, FW_NORMAL, GetTextMetricsW, HDC, HGDIOBJ, - IntersectClipRect, LOGBRUSH, LineTo, MoveToEx, NULL_BRUSH, NULL_PEN, PS_ENDCAP_ROUND, - PS_GEOMETRIC, PS_JOIN_ROUND, PS_SOLID, PolyPolyline, Polygon as GdiPolygon, Polyline, - Rectangle, RestoreDC, RoundRect, SaveDC, SelectObject, SetTextAlign, SetTextColor, TA_BASELINE, - TEXTMETRICW, + AC_SRC_ALPHA, AC_SRC_OVER, ANTIALIASED_QUALITY, BI_RGB, BITMAPINFO, BLENDFUNCTION, BS_SOLID, + COLORONCOLOR, CreateCompatibleDC, CreateDIBSection, CreateFontW, CreateSolidBrush, + DEFAULT_CHARSET, DIB_RGB_COLORS, DeleteDC, DeleteObject, Ellipse, ExtCreatePen, ExtTextOutW, + FW_BOLD, FW_NORMAL, GdiAlphaBlend, GetTextMetricsW, HALFTONE, HDC, HGDIOBJ, IntersectClipRect, + LOGBRUSH, LineTo, MoveToEx, NULL_BRUSH, NULL_PEN, PS_ENDCAP_ROUND, PS_GEOMETRIC, PS_JOIN_ROUND, + PS_SOLID, PolyPolyline, Polygon as GdiPolygon, Polyline, Rectangle, RestoreDC, RoundRect, + SaveDC, SelectObject, SetStretchBltMode, SetTextAlign, SetTextColor, TA_BASELINE, TEXTMETRICW, }; /// Logical units per point, so hairline geometry survives integer coordinates. @@ -259,6 +260,90 @@ impl Dc { } } + pub fn raster( + &mut self, + image: &image::RgbaImage, + destination: Rect, + opacity: f32, + nearest: bool, + ) -> bool { + let Ok(width) = i32::try_from(image.width()) else { + return false; + }; + let Ok(height) = i32::try_from(image.height()) else { + return false; + }; + let mut info = BITMAPINFO::default(); + info.bmiHeader.biSize = std::mem::size_of_val(&info.bmiHeader) as u32; + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + info.bmiHeader.biSizeImage = image.len() as u32; + let mut bits = std::ptr::null_mut(); + unsafe { + let bitmap = CreateDIBSection( + self.hdc, + &info, + DIB_RGB_COLORS, + &mut bits, + std::ptr::null_mut(), + 0, + ); + if bitmap.is_null() || bits.is_null() { + if !bitmap.is_null() { + DeleteObject(bitmap as HGDIOBJ); + } + return false; + } + let memory = CreateCompatibleDC(self.hdc); + if memory.is_null() { + DeleteObject(bitmap as HGDIOBJ); + return false; + } + let old = SelectObject(memory, bitmap as HGDIOBJ); + let output = std::slice::from_raw_parts_mut(bits.cast::(), image.len()); + for (source, target) in image + .as_raw() + .chunks_exact(4) + .zip(output.chunks_exact_mut(4)) + { + let alpha = u16::from(source[3]); + target.copy_from_slice(&[ + ((u16::from(source[2]) * alpha + 127) / 255) as u8, + ((u16::from(source[1]) * alpha + 127) / 255) as u8, + ((u16::from(source[0]) * alpha + 127) / 255) as u8, + source[3], + ]); + } + let blend = BLENDFUNCTION { + BlendOp: AC_SRC_OVER as u8, + BlendFlags: 0, + SourceConstantAlpha: (opacity.clamp(0.0, 1.0) * 255.0).round() as u8, + AlphaFormat: AC_SRC_ALPHA as u8, + }; + SetStretchBltMode(self.hdc, if nearest { COLORONCOLOR } else { HALFTONE }); + let success = GdiAlphaBlend( + self.hdc, + l(destination.left), + l(destination.top), + l(destination.width), + l(destination.height), + memory, + 0, + 0, + width, + height, + blend, + ) != 0; + SelectObject(memory, old); + DeleteDC(memory); + DeleteObject(bitmap as HGDIOBJ); + success + } + } + /// Baseline-anchored text matching SVG semantics: `align` maps text-anchor, /// `middle` emulates `dominant-baseline="middle"` via font metrics. pub fn text(&mut self, text: &str, pos: (f32, f32), style: TextStyle) { @@ -299,7 +384,7 @@ impl Dc { } } - pub fn clipped(&mut self, clip: Rect, draw: impl FnOnce(&mut Self)) { + pub fn clipped(&mut self, clip: Rect, draw: impl FnOnce(&mut Self) -> T) -> T { unsafe { SaveDC(self.hdc); IntersectClipRect( @@ -310,10 +395,11 @@ impl Dc { l(clip.bottom()), ); } - draw(self); + let result = draw(self); unsafe { RestoreDC(self.hdc, -1); } + result } } diff --git a/crates/render/src/emf/tests.rs b/crates/render/src/emf/tests.rs index ebcd6b3..71cf56e 100644 --- a/crates/render/src/emf/tests.rs +++ b/crates/render/src/emf/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::DocumentText; use plotx_figure::{Axis, ErrorBar, Figure, Series}; +use std::sync::Arc; fn demo_document(fig: &Figure) -> Document<'_> { Document { @@ -59,6 +60,43 @@ fn round_trips_through_set_enh_meta_file_bits() { } } +#[test] +fn embedded_raster_records_alpha_blend_in_document_order() { + let document = Document { + width: 40.0, + height: 30.0, + background: Color::rgb(255, 255, 255), + items: vec![DocumentItem::Raster(crate::DocumentRaster { + source_hash: [3; 32], + frame: Rect::new(2.0, 3.0, 20.0, 10.0), + pixels: Arc::from([255, 0, 0, 128, 0, 0, 255, 255]), + pixel_size: [2, 1], + source_pixel_size: [2, 1], + crop: [0.0, 0.0, 1.0, 1.0], + fit: crate::RasterFit::Stretch, + quarter_turns: 0, + opacity: 0.5, + nearest: false, + clip: None, + visible: true, + })], + }; + let bytes = export_document_emf(&document).expect("image EMF export"); + let mut offset = 0usize; + let mut records = Vec::new(); + while offset + 8 <= bytes.len() { + let kind = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let size = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize; + assert!(size >= 8 && offset + size <= bytes.len()); + records.push(kind); + offset += size; + } + assert!( + records.contains(&114), + "EMR_ALPHABLEND must carry image pixels" + ); +} + #[test] fn hidden_axis_text_is_absent_from_emf_while_drawing_records_remain() { let mut fig = Figure::new( diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f1af807..b7534f8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -22,6 +22,7 @@ mod screen_raster; pub mod emf; use plotx_figure::{AxisTrace, Color, ErrorBar, Figure, HeatmapGrid, Polygon}; +use std::sync::Arc; /// Fraction of the plot dimension reserved for a marginal axis-projection band. /// A fraction (not an absolute size) so bands scale with zoom like the margins. @@ -62,7 +63,7 @@ pub struct Document<'a> { pub enum DocumentItem<'a> { Plot(DocumentObject<'a>), Overlay(DocumentOverlay<'a>), - Raster(DocumentRaster<'a>), + Raster(DocumentRaster), PanelLabel { frame: Rect, text: DocumentText, @@ -70,10 +71,10 @@ pub enum DocumentItem<'a> { }, } -pub struct DocumentRaster<'a> { +pub struct DocumentRaster { pub source_hash: [u8; 32], pub frame: Rect, - pub pixels: &'a [u8], + pub pixels: Arc<[u8]>, /// Pixel dimensions of `pixels`, which may describe an editor proxy. pub pixel_size: [u32; 2], /// Original asset dimensions used for fit, crop, and effective resolution. diff --git a/crates/render/src/screen_raster.rs b/crates/render/src/screen_raster.rs index 6a9d936..601adb7 100644 --- a/crates/render/src/screen_raster.rs +++ b/crates/render/src/screen_raster.rs @@ -97,7 +97,7 @@ fn rotated_uv(x: f32, y: f32, crop: [f32; 4], turns: u8) -> [f32; 2] { pub(crate) fn paint_document_raster( painter: &egui::Painter, page: Rect, - raster: &DocumentRaster<'_>, + raster: &DocumentRaster, viewport: DocumentViewport, ) { if !raster.visible || raster.pixel_size.contains(&0) { @@ -145,7 +145,13 @@ pub(crate) fn paint_document_raster( } else { egui::TextureOptions::LINEAR }; - let texture = cached_texture(painter.ctx(), key, [width, height], raster.pixels, options); + let texture = cached_texture( + painter.ctx(), + key, + [width, height], + raster.pixels.as_ref(), + options, + ); let [left, top, right, bottom] = crop; let uv = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); let tint = @@ -298,11 +304,11 @@ fn shrink_crop_axis(crop: &mut [f32; 4], horizontal: bool, fraction: f32) { mod tests { use super::*; - fn raster(pixels: &[u8]) -> DocumentRaster<'_> { + fn raster(pixels: &[u8]) -> DocumentRaster { DocumentRaster { source_hash: [0; 32], frame: Rect::new(0.0, 0.0, 100.0, 100.0), - pixels, + pixels: std::sync::Arc::from(pixels), pixel_size: [1, 1], source_pixel_size: [1, 1], crop: [0.0, 0.0, 1.0, 1.0], diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 6920fee..867b401 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -9,6 +9,8 @@ use std::fmt::Write as _; mod color_scale; mod document; +#[cfg(all(windows, feature = "emf"))] +pub(crate) use document::prepared_raster; mod legend; mod sticks; pub use document::{export_document, export_document_for_bounds, export_document_page}; @@ -447,7 +449,7 @@ fn write_figure( let mut pts = String::new(); for p in &series.points { let (px, py) = proj.project(*p); - let _ = write!(pts, "{px:.2},{py:.2} "); + let _ = write!(pts, "{px},{py} "); } let _ = write!( s, diff --git a/crates/render/src/svg/document.rs b/crates/render/src/svg/document.rs index 0084c8c..feb114a 100644 --- a/crates/render/src/svg/document.rs +++ b/crates/render/src/svg/document.rs @@ -1,6 +1,9 @@ use super::{ Document, DocumentItem, Rect, write_document_object, write_overlay, write_panel_letter, }; +use crate::{DocumentRaster, RasterFit}; +use base64::Engine as _; +use image::ImageEncoder as _; use std::fmt::Write as _; /// Render a page document to SVG using page points as the geometry space. @@ -65,7 +68,7 @@ fn export_document_with_page( vh = page.height, ); } - for item in &document.items { + for (item_index, item) in document.items.iter().enumerate() { match item { DocumentItem::Plot(object) => write_document_object( &mut s, @@ -77,7 +80,7 @@ fn export_document_with_page( write_overlay(&mut s, overlay); } } - DocumentItem::Raster(_) => {} + DocumentItem::Raster(raster) => write_raster(&mut s, raster, item_index), DocumentItem::PanelLabel { frame, text, @@ -98,11 +101,182 @@ fn export_document_with_page( s } +fn write_raster(svg: &mut String, raster: &DocumentRaster, item_index: usize) { + if !raster.visible || raster.opacity <= 0.0 { + return; + } + let Some(image) = prepared_raster(raster) else { + return; + }; + let mut png = Vec::new(); + if image::codecs::png::PngEncoder::new(&mut png) + .write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + ) + .is_err() + { + return; + } + let href = base64::engine::general_purpose::STANDARD.encode(png); + let Some(clip) = intersect(raster.clip, raster.frame) else { + return; + }; + let clip_id = format!("raster-clip-{item_index}"); + let _ = write!( + svg, + r#""#, + x = clip.left, + y = clip.top, + w = clip.width, + h = clip.height, + ); + let preserve = match raster.fit { + RasterFit::Contain => "xMidYMid meet", + RasterFit::Cover => "xMidYMid slice", + RasterFit::Stretch => "none", + }; + let rendering = if raster.nearest { "pixelated" } else { "auto" }; + let _ = write!( + svg, + r#""#, + x = raster.frame.left, + y = raster.frame.top, + w = raster.frame.width, + h = raster.frame.height, + opacity = raster.opacity.clamp(0.0, 1.0), + ); +} + +pub(crate) fn prepared_raster(raster: &DocumentRaster) -> Option { + let width = usize::try_from(raster.pixel_size[0]).ok()?; + let height = usize::try_from(raster.pixel_size[1]).ok()?; + let expected = width.checked_mul(height)?.checked_mul(4)?; + if raster.pixel_size.contains(&0) || raster.pixels.len() != expected { + return None; + } + let source = image::RgbaImage::from_raw( + raster.pixel_size[0], + raster.pixel_size[1], + raster.pixels.as_ref().to_vec(), + )?; + let [left, top, right, bottom] = raster.crop; + if !raster.crop.into_iter().all(f32::is_finite) + || left < 0.0 + || top < 0.0 + || right > 1.0 + || bottom > 1.0 + || left >= right + || top >= bottom + { + return None; + } + let x0 = (left * source.width() as f32).floor() as u32; + let y0 = (top * source.height() as f32).floor() as u32; + let x1 = ((right * source.width() as f32).ceil() as u32).min(source.width()); + let y1 = ((bottom * source.height() as f32).ceil() as u32).min(source.height()); + let cropped = image::imageops::crop_imm( + &source, + x0, + y0, + x1.saturating_sub(x0).max(1), + y1.saturating_sub(y0).max(1), + ) + .to_image(); + Some(match raster.quarter_turns % 4 { + 0 => cropped, + 1 => image::imageops::rotate90(&cropped), + 2 => image::imageops::rotate180(&cropped), + _ => image::imageops::rotate270(&cropped), + }) +} + +fn intersect(clip: Option, frame: Rect) -> Option { + let Some(clip) = clip else { + return Some(frame); + }; + let left = clip.left.max(frame.left); + let top = clip.top.max(frame.top); + let right = clip.right().min(frame.right()); + let bottom = clip.bottom().min(frame.bottom()); + (right > left && bottom > top).then(|| Rect::new(left, top, right - left, bottom - top)) +} + #[cfg(test)] mod tests { use super::*; use crate::{DocumentObject, DocumentText}; use plotx_figure::{Axis, AxisFrame, Color, Figure}; + use std::sync::Arc; + + #[test] + fn raster_crop_and_rotation_use_source_pixels() { + let raster = DocumentRaster { + source_hash: [7; 32], + frame: Rect::new(0.0, 0.0, 20.0, 20.0), + pixels: Arc::from([1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255]), + pixel_size: [2, 2], + source_pixel_size: [2, 2], + crop: [0.0, 0.0, 0.5, 1.0], + fit: RasterFit::Cover, + quarter_turns: 1, + opacity: 0.5, + nearest: true, + clip: None, + visible: true, + }; + + let prepared = prepared_raster(&raster).unwrap(); + assert_eq!(prepared.dimensions(), (2, 1)); + assert_eq!(prepared.into_raw(), [3, 0, 0, 255, 1, 0, 0, 255]); + let svg = export_document(&Document { + width: 20.0, + height: 20.0, + background: Color::rgb(255, 255, 255), + items: vec![DocumentItem::Raster(raster)], + }); + assert!(svg.contains("preserveAspectRatio=\"xMidYMid slice\"")); + assert!(svg.contains("opacity=\"0.5000\"")); + assert!(svg.contains("image-rendering=\"pixelated\"")); + } + + #[test] + fn raster_clip_ids_are_unique_when_source_and_frame_match() { + let raster = |clip| DocumentRaster { + source_hash: [9; 32], + frame: Rect::new(0.0, 0.0, 20.0, 20.0), + pixels: Arc::from([1, 2, 3, 255]), + pixel_size: [1, 1], + source_pixel_size: [1, 1], + crop: [0.0, 0.0, 1.0, 1.0], + fit: RasterFit::Stretch, + quarter_turns: 0, + opacity: 1.0, + nearest: false, + clip: Some(clip), + visible: true, + }; + let svg = export_document(&Document { + width: 20.0, + height: 20.0, + background: Color::rgb(255, 255, 255), + items: vec![ + DocumentItem::Raster(raster(Rect::new(0.0, 0.0, 8.0, 20.0))), + DocumentItem::Raster(raster(Rect::new(12.0, 0.0, 8.0, 20.0))), + ], + }); + + assert!(svg.contains( + r#""# + )); + assert!(svg.contains( + r#""# + )); + assert_eq!(svg.matches(r#"clip-path="url(#raster-clip-0)""#).count(), 1); + assert_eq!(svg.matches(r#"clip-path="url(#raster-clip-1)""#).count(), 1); + } #[test] fn bounds_document_omits_only_visually_redundant_backgrounds() { diff --git a/crates/render/src/svg_annotation_tests.rs b/crates/render/src/svg_annotation_tests.rs index 133bbec..058ce12 100644 --- a/crates/render/src/svg_annotation_tests.rs +++ b/crates/render/src/svg_annotation_tests.rs @@ -19,6 +19,43 @@ fn exports_wellformed_ish_svg_with_polyline() { assert!(output.contains("Demo")); } +#[test] +fn dense_line_series_keeps_distinct_projected_x_coordinates() { + let count = 100_000usize; + let denominator = (count - 1) as f64; + let points = (0..count) + .map(|index| { + let noise = if index % 2 == 0 { -0.00001 } else { 0.00001 }; + [index as f64 / denominator, 0.5 + noise] + }) + .collect(); + let figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)) + .with_series(Series::line("", points)); + + let output = export(&figure); + let serialized = output + .split_once(" = serialized + .split_ascii_whitespace() + .map(|point| { + point + .split_once(',') + .expect("x,y pair") + .0 + .parse() + .expect("finite x coordinate") + }) + .collect(); + + assert_eq!(xs.len(), count); + assert!(xs.windows(2).all(|pair| pair[0] < pair[1])); +} + #[test] fn escapes_xml_special_chars() { let figure = Figure::new( diff --git a/docs/src/content/docs/guides/exporting.md b/docs/src/content/docs/guides/exporting.md index 77049a6..f4cec85 100644 --- a/docs/src/content/docs/guides/exporting.md +++ b/docs/src/content/docs/guides/exporting.md @@ -16,9 +16,20 @@ pages, or a page range. Presets cover common journal figure sizes — for example *Single column · 89 mm · 600 dpi · TIFF* — and an export precheck flags -font-size and line-width violations against the chosen preset before you -export, so problems are fixed on the board rather than discovered by the -journal. +font-size and line-width violations against the chosen preset. For pages with +images, it also reports the lowest effective PPI at the exported size, missing +or damaged resources, and sources with ICC profiles or samples above 8 bits. +Image resolution passes at 300 PPI, warns from 150 to 299 PPI, and fails below +150 PPI. Raster output is 8-bit RGB/RGBA and does not embed the source ICC +profile, so colour-critical work should be verified in its publication +workflow. + +Every figure format includes embedded images and applies their crop, rotation, +opacity, fit, interpolation, Panel clipping, and z-order. SVG embeds image +pixels rather than linking to a local path; PDF and bitmap output matches the +same page. If a source is missing or damaged, export stops by default. Enable +**Export with missing-image placeholders** only when a labelled review copy is +preferable to no output. ### Trim page whitespace @@ -37,11 +48,9 @@ Empty pages keep their original dimensions. *Copy figure* (`Ctrl` + `C`, also in the export menu, the command palette, and a frame's right-click menu) copies the selected frame — or the active -canvas — straight to the clipboard, no export needed. On Windows the figure -is published as a bitmap (PNG + DIB) and as a vector (SVG + EMF) at the same -time, and the app you paste into picks its best format automatically: chat -apps paste the bitmap, while Word, PowerPoint, and WPS paste an editable -vector. +canvas — straight to the clipboard, no export needed. Images are included. On +Windows the figure is published as PNG, DIB, SVG, and EMF at the same time, and +the receiving app chooses a format it supports. ## Export numerical data diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 7b74c2e..882dbb9 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -51,9 +51,17 @@ image and use **Image** in the Object inspector to crop or rotate it, adjust its opacity, or choose how it is interpolated. These changes do not alter the embedded source image. -Pages that contain external images cannot yet be exported or copied with -**Copy Figure**. PlotX stops the operation and reports the reason instead of -creating a figure with the image missing. +The board uses a bounded proxy for responsive editing, but SVG, PDF, EMF, +PNG, JPEG, TIFF, and **Copy Figure** sample the embedded source. Crop, +quarter-turn rotation, opacity, fit, interpolation, Panel clipping, and layer +order therefore match the authored page without depending on the source file's +original path. + +If an embedded image is missing or damaged, the project still opens and keeps +the image item in place as a labelled placeholder. Use **Replace Image…** in +the Object inspector to repair it. Normal save and publication export stop +until it is replaced; the Export dialog can explicitly allow labelled +placeholders when a review copy is still useful. ## Work with figure panels diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index da11aed..e6691e2 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -12,11 +12,6 @@ another machine or send it to a colleague and it opens exactly as you saved it, with no side files to remember. Keeping your original instrument data is still good practice, as with any analysis software. -Until PlotX 1.0, the project format may change between releases. When a newer -format meets an older PlotX (or the other way around), the file is rejected -with a clear "unsupported version" message — your file is never modified or -migrated silently. If that happens, update PlotX and reopen. - **Preferences → General → Project backup copies** keeps previous saves as hidden files beside the project, so an accidental overwrite can be recovered. Automatic crash-recovery checkpoints are separate internal files. They are @@ -38,6 +33,9 @@ saved map when the project opens. PlotX accepts PNG, JPEG, TIFF, WebP, and BMP images and stores them in the `.plotx` project. Duplicating an image does not duplicate its stored source, but each copy can have its own crop, rotation, opacity, fit, and interpolation. +Export and clipboard output sample the embedded pixels and preserve those +settings, Panel clipping, and z-order. SVG output embeds image data and never +depends on the original local path. SVG is not supported. For an animated PNG or WebP, **Add Images…** reports that the image is animated; use **Add Animated Image First Frame…** to display its @@ -49,13 +47,17 @@ It asks for confirmation above 100 megapixels or 512 MiB. For a multi-page TIFF, **Add Images…** imports the first page and **Add All TIFF Pages…** imports every page that PlotX can read. Each imported page can be edited independently. +PlotX checks every embedded image when a project opens. A missing, damaged, or +mismatched resource produces a diagnostic and a replaceable placeholder +without discarding the page or image item. PlotX does not silently save that +degraded state. Publication export blocks by default; an explicit Export-dialog +option can emit a labelled placeholder for review. + ## `.plotxproc` processing recipes A `.plotxproc` file stores one processing pipeline, without any data — save a recipe once and apply it to a whole series of similar experiments, on any -machine. See [Recipes and templates](/guides/templates/). Version handling -works the same way as for projects: a mismatched file is rejected with a clear -message, never misread. +machine. See [Recipes and templates](/guides/templates/). ## Workflow and run-record files diff --git a/docs/src/content/docs/reference/troubleshooting.md b/docs/src/content/docs/reference/troubleshooting.md index b95a0c0..bb4f534 100644 --- a/docs/src/content/docs/reference/troubleshooting.md +++ b/docs/src/content/docs/reference/troubleshooting.md @@ -45,9 +45,12 @@ window or confirm the exclusion it proposes. ## Exporting -**Export stops because the page contains an external image.** Export and -**Copy Figure** do not support external images. Remove the images from the page -before exporting. PlotX stops instead of creating a figure with images missing. +**A project opens with a missing-image placeholder.** The embedded resource is +missing, damaged, or fails its integrity check. Check **Operation +and Diagnostic History**, select the placeholder, and use **Replace Image…**. +PlotX keeps the page editable but blocks normal save and export so the damaged +state is not silently published. The Export dialog can explicitly create a +labelled-placeholder review copy. **The export precheck flags my figure.** Font sizes or line widths violate the selected journal preset. Fix them on the board — **Figure Typography…** @@ -56,10 +59,6 @@ intended size. ## Projects and updates -**"Unsupported version" when opening a project.** The file was saved by a -different PlotX with an incompatible project format (possible between -releases until 1.0). Your file is intact — update PlotX and try again. - **I overwrote a project by mistake.** If **Preferences → General → Project backup copies** is on, previous saves are kept as hidden files beside the project file. diff --git a/docs/src/content/docs/zh-cn/guides/exporting.md b/docs/src/content/docs/zh-cn/guides/exporting.md index 9b63f26..b9b6ba0 100644 --- a/docs/src/content/docs/zh-cn/guides/exporting.md +++ b/docs/src/content/docs/zh-cn/guides/exporting.md @@ -14,8 +14,16 @@ description: 导出出版级图像,以及图形背后的数值。 | PNG / TIFF / JPEG | 位图,DPI 可在 72–1200 间调节(默认 300) | 预设覆盖常见期刊图幅——例如 *Single column · 89 mm · 600 dpi · -TIFF*——并且导出前的合规检查会按所选预设标出字号与线宽不达标之处,让 -问题在画板上就被修正,而不是被期刊编辑发现。 +TIFF*——并且导出前的合规检查会按所选预设标出字号与线宽不达标之处。页面含图片 +时,还会报告导出尺寸下最低的有效 PPI、缺失或损坏的资源,以及带 ICC 配置文件或 +高于 8 位采样的来源。图片达到 300 PPI 为通过,150–299 PPI 为警告,低于 150 PPI +为失败。位图输出为 8 位 RGB/RGBA,且不嵌入来源 ICC 配置文件;对颜色要求严格的 +工作应在最终出版流程中另行核对。 + +所有图形格式都会包含嵌入图片,并应用裁剪、旋转、透明度、适配、插值、Panel +裁切和 z 顺序。SVG 会嵌入图片像素,不会链接本机路径;PDF 与位图导出使用同一份 +页面效果。来源缺失或损坏时,导出默认停止。只有在需要带标签的审阅稿时,才勾选 +**Export with missing-image placeholders**。 ### 裁去页面空白 @@ -31,10 +39,9 @@ TIFF*——并且导出前的合规检查会按所选预设标出字号与线宽 ## 复制图形 *Copy figure*(复制图形,`Ctrl` + `C`,也可在导出菜单、命令面板或图框 -右键菜单中触发)把选中的图框——未选中时为活动画布——直接复制到剪贴板, -无需先导出。在 Windows 上会同时发布位图(PNG + DIB)与矢量 -(SVG + EMF)格式,粘贴目标应用自动选择最合适的格式:聊天软件粘贴为 -位图,Word、PowerPoint、WPS 则粘贴为可编辑的矢量图。 +右键菜单中触发)把选中的图框——未选中时为活动画布——连同图片直接复制到 +剪贴板,无需先导出。在 Windows 上会同时发布 PNG、DIB、SVG 和 EMF,接收应用 +从中选择其支持的格式。 ## 导出数值数据 diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 005ae99..63d52da 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -41,8 +41,13 @@ PlotX 创建图片页面时,会优先采用图片的 DPI 信息确定实际尺 导入尺寸很大的图片前,PlotX 会要求确认。选中图片后,可在对象检查器的 **Image** 区域裁剪或旋转图片、调整透明度,或选择插值方式。这些调整不会改变嵌入的源图片。 -包含外部图片的页面暂时无法导出,也无法使用 **Copy Figure** 复制。PlotX 会停止 -操作并说明原因,不会生成缺少图片的图版。 +画板使用有内存上限的代理图以保持编辑流畅;SVG、PDF、EMF、PNG、JPEG、TIFF +以及 **Copy Figure** 则从项目中嵌入的原图采样。裁剪、90 度旋转、透明度、适配、 +插值、Panel 裁切和图层顺序都会与画板一致,也不依赖源文件原先所在的路径。 + +若嵌入图片缺失或损坏,项目仍会打开,并在原位置保留带说明的占位项。请在对象 +检查器中使用 **Replace Image…** 修复。替换前,常规保存和出版导出会停止;若仍需 +生成审阅稿,可在导出对话框中明确允许输出带标签的占位图。 ## 使用图版面板 diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index d152eeb..88257ab 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -10,10 +10,6 @@ description: PlotX 自有文件、导入格式及其兼容性边界。 的——复制到另一台机器或发给同事,打开时与你保存时完全一致,不需要记着 带任何附属文件。与使用任何分析软件一样,保留原始仪器数据仍是好习惯。 -在 PlotX 1.0 之前,项目格式可能随版本变化。当较新格式的文件遇到较旧的 -PlotX(或相反)时,文件会被拒绝并给出明确的"不支持的版本"提示——你的 -文件绝不会被悄悄修改或迁移。遇到这种情况,更新 PlotX 后重新打开即可。 - **Preferences → General → Project backup copies** 会在项目旁以隐藏文件 保留历史保存,误覆盖后可以恢复。 自动崩溃恢复检查点是单独的内部文件;它们只在出现新编辑后更新,并不是可供 @@ -31,7 +27,8 @@ PlotX(或相反)时,文件会被拒绝并给出明确的"不支持的版 PlotX 支持 PNG、JPEG、TIFF、WebP 与 BMP,并将图片保存在 `.plotx` 项目中。 复制图片不会重复保存源图片,但每个副本可以分别设置裁剪、旋转、透明度、适配与 -插值方式。 +插值方式。导出与剪贴板输出从嵌入像素采样,并保留这些设置、Panel 裁切和 z 顺序; +SVG 会嵌入图片数据,不依赖原始本机路径。 PlotX 不支持 SVG。用 **Add Images…** 添加动画 PNG 或 WebP 时,PlotX 会提示该 图片包含动画;使用 **Add Animated Image First Frame…** 可显示其第一帧。 @@ -41,12 +38,15 @@ PlotX 不支持 SVG。用 **Add Images…** 添加动画 PNG 或 WebP 时,Plot 512 MiB 时,会先要求确认。对于多页 TIFF,**Add Images…** 导入第一页,**Add All TIFF Pages…** 导入 PlotX 能够读取的所有页面。导入后的各页可以分别编辑。 +项目打开时,PlotX 会校验每张嵌入图片。资源缺失、损坏或与记录不符时,PlotX 会 +显示诊断和可替换的占位项,但不会丢弃页面或图片项,也不会静默保存这一降级状态。 +出版导出默认停止;若仅用于审阅,可在导出对话框中明确输出带标签的占位图。 + ## `.plotxproc` 处理配方 `.plotxproc` 文件保存一条处理管线,不含任何数据——配方保存一次即可在 任何机器上应用到一整个系列的同类实验。见 -[配方与模板](/zh-cn/guides/templates/)。版本处理与项目相同:不匹配的文 -件会被明确拒绝,绝不会被误读。 +[配方与模板](/zh-cn/guides/templates/)。 ## 工作流与运行记录文件 diff --git a/docs/src/content/docs/zh-cn/reference/troubleshooting.md b/docs/src/content/docs/zh-cn/reference/troubleshooting.md index 37e3996..5ebd656 100644 --- a/docs/src/content/docs/zh-cn/reference/troubleshooting.md +++ b/docs/src/content/docs/zh-cn/reference/troubleshooting.md @@ -38,8 +38,10 @@ ABF2。 ## 导出 -**页面含外部图片时导出停止。** 导出与 **Copy Figure** 不支持外部图片。请先从 -页面中移除图片再导出。PlotX 会停止操作,不会生成缺少图片的图版。 +**打开项目后出现缺失图片占位项。** 嵌入资源缺失、损坏,或未通过完整性校验。 +请查看 **Operation and Diagnostic History**,选中占位项并使用 **Replace Image…**。 +PlotX 会保留页面供继续编辑,但阻止常规保存和导出,避免损坏状态被静默发布;若只需 +审阅稿,可在导出对话框中明确生成带标签的占位图。 **导出预检标记了我的图。** 字号或线宽违反所选期刊预设。在画板上修正 ——**Figure Typography…** 可一次设定全部坐标轴文字尺寸——或改选符合 @@ -47,10 +49,6 @@ ABF2。 ## 项目与更新 -**打开项目时提示"不支持的版本"。** 该文件由项目格式不兼容的另一版本 -PlotX 保存(1.0 之前的版本间可能发生)。文件完好无损——更新 PlotX 后 -重试。 - **我不小心覆盖了项目。** 若 **Preferences → General → Project backup copies** 已开启,历史保存以隐藏文件保留在项目文件旁。