From 1bb08647f142674722496d99778831906c61dd1c Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 22 Jul 2026 23:01:21 +0800 Subject: [PATCH 1/2] feat(export): trim pages to visible content Add sticky, opt-in trimming for bitmap, SVG, and PDF exports. Compute clip-aware vector bounds, preserve format-specific safety padding, and propagate raster crop failures. Include follow-up correctness fixes for axis simplification, visual spacing invariants, and tile-preview cache identity. --- crates/app/src/ui/canvas/mod.rs | 4 +- crates/app/src/ui/canvas/tiling.rs | 186 ++++++-- crates/app/src/ui/export_dialog.rs | 49 +- crates/app/src/ui/file_dialogs.rs | 8 +- crates/core/src/actions/arrange.rs | 12 +- crates/core/src/actions/tests/tiling.rs | 119 ++++- crates/core/src/automation/tools.rs | 1 + crates/core/src/export/mod.rs | 246 ++++++++-- crates/core/src/export/raster.rs | 43 ++ crates/core/src/export/state_tests.rs | 38 ++ crates/core/src/export/trim.rs | 438 ++++++++++++++++++ crates/core/src/layout.rs | 3 +- crates/core/src/layout/visual_spacing.rs | 150 +++++- crates/core/src/settings/model.rs | 3 + crates/core/src/settings/tests.rs | 3 + crates/core/src/state/app_impl.rs | 16 + crates/core/src/state/app_impl_io.rs | 5 +- crates/core/src/state/mod.rs | 2 + crates/core/src/state/tile_drop.rs | 23 + crates/core/src/state/ui_state.rs | 11 - crates/core/src/workflow.rs | 1 + crates/render/src/svg.rs | 64 ++- crates/render/src/svg/document.rs | 125 +++++ docs/src/content/docs/guides/exporting.md | 13 + .../content/docs/zh-cn/guides/exporting.md | 11 + 25 files changed, 1421 insertions(+), 153 deletions(-) create mode 100644 crates/core/src/export/state_tests.rs create mode 100644 crates/core/src/export/trim.rs create mode 100644 crates/core/src/state/tile_drop.rs create mode 100644 crates/render/src/svg/document.rs diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index ced33fc..190a6f5 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -9,8 +9,8 @@ use plotx_core::state::{ ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, PanelNoteEditState, PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, RegionDragKind, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, - TextEditState, TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frames, frame_board_pos, - frame_board_rect, set_frame_board_pos, toggle_frame_selection_synced, + TextEditState, TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frames, + frame_board_pos, frame_board_rect, set_frame_board_pos, toggle_frame_selection_synced, }; use plotx_core::{Integral2D, IntegralResult}; use plotx_render::Rect as PlotRect; diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index 9ed79c4..b09e88c 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -42,15 +42,19 @@ pub(crate) fn update_tile_drop( let page_pt = app.doc.canvases[target].size_pt(); let layout = app.doc.canvases[target].layout; let existing_ids = app.doc.canvases[target].plot_object_ids(); - if app.session.ui.tile_drop.as_ref().is_some_and(|preview| { - preview.target == target - && preview - .existing - .iter() - .map(|(id, _)| *id) - .eq(existing_ids.iter().copied()) - && preview_cell_matches(preview, page_pt, existing_ids.len(), pointer_page) - }) { + let region = plotx_core::layout::tiling_drop_region( + page_pt, + existing_ids.len(), + [pointer_page.x, pointer_page.y], + ); + let cache_key = tile_cache_key(drag, target, page_pt, layout, &existing_ids, region); + if app + .session + .ui + .tile_drop + .as_ref() + .is_some_and(|preview| preview.cache_key == cache_key) + { return true; } let existing_items: Vec<_> = existing_ids @@ -69,6 +73,7 @@ pub(crate) fn update_tile_drop( [pointer_page.x, pointer_page.y], ); app.session.ui.tile_drop = Some(TileDropPreview { + cache_key, target, newcomer: plan.newcomer, existing: plan.existing, @@ -76,6 +81,25 @@ pub(crate) fn update_tile_drop( true } +fn tile_cache_key( + drag: &ObjectDrag, + target_canvas: usize, + target_page_pt: [f32; 2], + target_layout: plotx_core::layout::PageLayout, + target_existing_ids: &[ObjectId], + region: plotx_core::layout::TilingDropRegion, +) -> TileDropCacheKey { + TileDropCacheKey { + source_canvas: drag.canvas, + source_object: drag.object, + target_canvas, + target_page_pt, + target_layout, + target_existing_ids: target_existing_ids.to_vec(), + region, + } +} + fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option { let object = canvas.object(id)?; let plot = object.plot()?; @@ -86,41 +110,6 @@ fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option bool { - if existing_count != 1 { - return true; - } - let Some((_, existing)) = preview.existing.first() else { - return false; - }; - let nx = if page[0] > 0.0 { - pointer.x / page[0] - } else { - 0.5 - }; - let ny = if page[1] > 0.0 { - pointer.y / page[1] - } else { - 0.5 - }; - let horizontal = (nx - 0.5).abs() >= (ny - 0.5).abs(); - let newcomer_last = if horizontal { nx >= 0.5 } else { ny >= 0.5 }; - let dx = preview.newcomer.x - existing.x; - let dy = preview.newcomer.y - existing.y; - let preview_horizontal = dx.abs() >= dy.abs(); - let preview_last = if preview_horizontal { - dx >= 0.0 - } else { - dy >= 0.0 - }; - horizontal == preview_horizontal && newcomer_last == preview_last -} - /// Falls back to a plain move if the atomic action cannot be built. pub(crate) fn commit_tile_drop( app: &mut PlotxApp, @@ -185,3 +174,112 @@ pub(crate) fn paint_tile_preview( painter.add(segment); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn drag(canvas: usize, object: ObjectId) -> ObjectDrag { + ObjectDrag { + canvas, + object, + kind: ObjectDragKind::Move, + before: ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + start_pointer: [0.0; 2], + start_pointer_screen: [0.0; 2], + others: Vec::new(), + active: true, + } + } + + #[test] + fn tile_cache_identity_tracks_source_region_target_and_existing_order() { + let layout = plotx_core::layout::PageLayout::default(); + let page = [400.0, 300.0]; + let base = tile_cache_key( + &drag(0, 10), + 2, + page, + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ); + assert_eq!( + base, + tile_cache_key( + &drag(0, 10), + 2, + page, + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(1, 11), + 2, + page, + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(0, 10), + 3, + page, + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(0, 10), + 2, + [401.0, 300.0], + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(0, 10), + 2, + page, + plotx_core::layout::PageLayout { cols: 2, ..layout }, + &[20, 21], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(0, 10), + 2, + page, + layout, + &[20, 21], + plotx_core::layout::TilingDropRegion::Right, + ) + ); + assert_ne!( + base, + tile_cache_key( + &drag(0, 10), + 2, + page, + layout, + &[21, 20], + plotx_core::layout::TilingDropRegion::Left, + ) + ); + } +} diff --git a/crates/app/src/ui/export_dialog.rs b/crates/app/src/ui/export_dialog.rs index 38824b9..b064c0a 100644 --- a/crates/app/src/ui/export_dialog.rs +++ b/crates/app/src/ui/export_dialog.rs @@ -84,6 +84,17 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { }); } + ui.add_space(8.0); + ui.checkbox( + &mut pending.trim_to_visible_content, + "Trim page to visible content", + ) + .on_hover_text( + "Removes page whitespace around visible content without enlarging the content.\n\ + With journal/column presets, the final physical page width may be smaller than the preset.\n\ + Empty pages keep their original size.", + ); + let preset = pending.preset; let scope = pending.scope; let dpi = pending.dpi; @@ -112,13 +123,29 @@ pub(super) fn export_options_window(app: &mut PlotxApp, ctx: &egui::Context) { if export { app.session.ui.export_options = None; if let Some(settings) = settings { - crate::ui::file_dialogs::export_with_options(app, settings); + let trim = settings.trim_to_visible_content; + if let Some(path) = crate::ui::file_dialogs::choose_export_path(&settings) { + plotx_core::settings::update(move |settings| { + apply_confirmed_export_default(&mut settings.export, trim, true); + }); + app.export_to(settings, &path); + } } } else if cancel || modal.should_close() { app.session.ui.export_options = None; } } +fn apply_confirmed_export_default( + defaults: &mut plotx_core::settings::ExportDefaults, + trim_to_visible_content: bool, + path_confirmed: bool, +) { + if path_confirmed { + defaults.trim_to_visible_content = trim_to_visible_content; + } +} + fn build_report( app: &PlotxApp, preset: ExportPreset, @@ -170,3 +197,23 @@ fn status_dot(ui: &mut Ui, status: ComplianceStatus) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); ui.painter().circle_filled(rect.center(), 4.0, color); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_confirmed_path_updates_trim_and_never_dpi() { + let mut defaults = plotx_core::settings::ExportDefaults { + dpi: 600, + ..Default::default() + }; + apply_confirmed_export_default(&mut defaults, true, false); + assert!(!defaults.trim_to_visible_content); + assert_eq!(defaults.dpi, 600); + + apply_confirmed_export_default(&mut defaults, true, true); + assert!(defaults.trim_to_visible_content); + assert_eq!(defaults.dpi, 600); + } +} diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index c6540a3..5be30a8 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -390,16 +390,12 @@ fn open_folder_path(app: &mut PlotxApp, path: &std::path::Path) { } } -pub(crate) fn export_with_options(app: &mut PlotxApp, settings: ExportSettings) { - let Some(path) = rfd::FileDialog::new() +pub(crate) fn choose_export_path(settings: &ExportSettings) -> Option { + rfd::FileDialog::new() .add_filter(settings.format.label(), &[settings.format.extension()]) .set_file_name(settings.format.default_file_name()) .set_title(settings.format.dialog_title()) .save_file() - else { - return; - }; - app.export_to(settings, &path); } pub(crate) fn load_processing_scheme(app: &mut PlotxApp, di: usize) { diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index 266f8e6..11e9ebd 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -324,14 +324,10 @@ fn simplified_axis_changes( .filter_map(|(&id, (keep_x, keep_y))| { let before = canvas.object(id)?.plot()?.axis_overrides.clone(); let mut after = before.clone(); - if !keep_x { - after.x_show_tick_labels = Some(false); - after.x_show_label = Some(false); - } - if !keep_y { - after.y_show_tick_labels = Some(false); - after.y_show_label = Some(false); - } + after.x_show_tick_labels = (!keep_x).then_some(false); + after.x_show_label = (!keep_x).then_some(false); + after.y_show_tick_labels = (!keep_y).then_some(false); + after.y_show_label = (!keep_y).then_some(false); (after != before).then_some(AxisOverrideChange { id, before, after }) }) .collect() diff --git a/crates/core/src/actions/tests/tiling.rs b/crates/core/src/actions/tests/tiling.rs index c6dd83b..93dc95f 100644 --- a/crates/core/src/actions/tests/tiling.rs +++ b/crates/core/src/actions/tests/tiling.rs @@ -1,7 +1,7 @@ use crate::actions::Action; use crate::actions::tests::{push_canvas, sample_app}; use crate::layout::compute_tiling_plan; -use crate::state::{AxisOverrides, AxisRange, ObjectFrame}; +use crate::state::{AxisOverrides, AxisRange, ObjectFrame, TileDropCacheKey, TileDropPreview}; /// A drop of canvas 0's plot onto canvas 1 (which already has one plot) transfers /// ownership and reframes both into a two-way split, undoably. @@ -52,6 +52,27 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[1].object(existing).unwrap().frame, ex); } +#[test] +fn cancelling_interaction_clears_tile_preview_cache() { + let mut app = sample_app(); + app.session.ui.tile_drop = Some(TileDropPreview { + cache_key: TileDropCacheKey { + source_canvas: 0, + source_object: 1, + target_canvas: 1, + target_page_pt: [100.0, 80.0], + target_layout: crate::layout::PageLayout::default(), + target_existing_ids: vec![2], + region: crate::layout::TilingDropRegion::Left, + }, + target: 1, + newcomer: ObjectFrame::new(0.0, 0.0, 50.0, 80.0), + existing: Vec::new(), + }); + app.cancel_interaction(); + assert!(app.session.ui.tile_drop.is_none()); +} + #[test] fn simplify_grid_is_one_undo_step_and_preserves_other_axis_overrides() { let mut app = sample_app(); @@ -80,7 +101,7 @@ fn simplify_grid_is_one_undo_step_and_preserves_other_axis_overrides() { .axis_overrides; assert_eq!(simplified.x_label, original.x_label); assert_eq!(simplified.y_range, original.y_range); - assert_eq!(simplified.y_show_label, Some(false)); + assert_eq!(simplified.y_show_label, None); assert_eq!(simplified.x_show_tick_labels, Some(false)); assert_eq!(simplified.x_show_label, Some(false)); @@ -107,6 +128,100 @@ fn simplify_grid_is_one_undo_step_and_preserves_other_axis_overrides() { ); } +fn add_plots(app: &mut crate::state::PlotxApp, count: usize) { + let template = app.doc.canvases[0].objects[0].clone(); + while app.doc.canvases[0].objects.len() < count { + let mut object = template.clone(); + object.id = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(object); + } +} + +fn visibility(app: &crate::state::PlotxApp) -> Vec<[Option; 4]> { + app.doc.canvases[0] + .objects + .iter() + .map(|object| { + let axes = &object.plot().unwrap().axis_overrides; + [ + axes.x_show_tick_labels, + axes.x_show_label, + axes.y_show_tick_labels, + axes.y_show_label, + ] + }) + .collect() +} + +#[test] +fn repeated_simplify_rebuilds_complete_visibility_for_each_grid() { + let mut app = sample_app(); + add_plots(&mut app, 4); + let first = app.doc.canvases[0].objects[0].id; + let non_visibility = AxisOverrides { + x_label: Some("chemical shift".into()), + y_range: Some(AxisRange::new(-3.0, 8.0)), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, first, &non_visibility); + + app.arrange_active_canvas_grid_with_simplify(2, 2, true); + app.arrange_active_canvas_grid_with_simplify(1, 4, true); + assert_eq!( + visibility(&app), + vec![ + [None, None, None, None], + [None, None, Some(false), Some(false)], + [None, None, Some(false), Some(false)], + [None, None, Some(false), Some(false)], + ] + ); + let before_column = visibility(&app); + + app.arrange_active_canvas_grid_with_simplify(4, 1, true); + assert_eq!( + visibility(&app), + vec![ + [Some(false), Some(false), None, None], + [Some(false), Some(false), None, None], + [Some(false), Some(false), None, None], + [None, None, None, None], + ] + ); + let axes = &app.doc.canvases[0] + .object(first) + .unwrap() + .plot() + .unwrap() + .axis_overrides; + assert_eq!(axes.x_label, non_visibility.x_label); + assert_eq!(axes.y_range, non_visibility.y_range); + + app.undo(); + assert_eq!(visibility(&app), before_column); +} + +#[test] +fn apply_grid_and_standalone_simplify_share_complete_visibility_semantics() { + let mut app = sample_app(); + add_plots(&mut app, 4); + app.arrange_active_canvas_grid_with_simplify(2, 2, true); + let expected = visibility(&app); + + for object in &mut app.doc.canvases[0].objects { + let axes = &mut object.plot_mut().unwrap().axis_overrides; + axes.x_show_tick_labels = Some(false); + axes.x_show_label = Some(false); + axes.y_show_tick_labels = Some(false); + axes.y_show_label = Some(false); + } + app.simplify_inner_axes(); + assert_eq!(visibility(&app), expected); + let history = app.session.undo_stack.len(); + app.simplify_inner_axes(); + assert_eq!(app.session.undo_stack.len(), history); +} + #[test] fn standalone_simplify_infers_drag_tiled_frames_instead_of_layout_divisions() { let mut app = sample_app(); diff --git a/crates/core/src/automation/tools.rs b/crates/core/src/automation/tools.rs index 51f631a..63e761d 100644 --- a/crates/core/src/automation/tools.rs +++ b/crates/core/src/automation/tools.rs @@ -532,6 +532,7 @@ fn execute_export(app: &PlotxApp, plan: &ToolPlan) -> Result, + pub trim_to_visible_content: bool, } impl ExportDialogState { @@ -100,9 +104,17 @@ impl ExportDialogState { scope: ExportPageScope::Current, dpi: DEFAULT_BITMAP_DPI, preset: None, + trim_to_visible_content: false, } } + pub fn from_defaults(format: ExportFormat, defaults: &crate::settings::ExportDefaults) -> Self { + let mut state = Self::new(format); + state.dpi = defaults.dpi; + state.trim_to_visible_content = defaults.trim_to_visible_content; + state + } + pub fn apply_preset(&mut self, preset: Option) { self.preset = preset; if let Some(preset) = preset { @@ -155,6 +167,7 @@ pub struct ExportSettings { /// 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, } impl From<&ExportDialogState> for ExportSettings { @@ -164,6 +177,7 @@ impl From<&ExportDialogState> for ExportSettings { scope: value.scope, dpi: value.dpi, target_width_mm: value.target_width_mm(), + trim_to_visible_content: value.trim_to_visible_content, } } } @@ -221,23 +235,34 @@ pub fn export_canvases( let pages = resolve_page_scope(settings.scope, active_page, canvases.len())?; let target = settings.target_width_mm; match settings.format { - ExportFormat::Svg => export_svg(canvases, &pages, target, base_path), - ExportFormat::Pdf => export_pdf(canvases, &pages, target, base_path), + ExportFormat::Svg => export_svg( + canvases, + &pages, + target, + settings.trim_to_visible_content, + base_path, + ), + ExportFormat::Pdf => export_pdf( + canvases, + &pages, + target, + settings.trim_to_visible_content, + base_path, + ), ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Tiff => export_bitmap( canvases, &pages, settings.format, settings.dpi, target, + settings.trim_to_visible_content, base_path, ), } } -/// The page's SVG with its declared physical size scaled to `target_width_mm` -/// (leaving the `viewBox` — and thus all geometry — untouched, a uniform scale). -/// `None` keeps the page's own size. Reproduces the exact `width/height` header -/// `render_document_svg` emits so the rewrite is a single deterministic replace. +/// Scale the SVG's declared physical size while leaving its geometry untouched. +/// `None` preserves the authored page size. fn document_svg(canvas: &CanvasDocument, target_width_mm: Option) -> String { let svg = render_document_svg(canvas); let Some(target) = target_width_mm else { @@ -268,11 +293,17 @@ fn export_svg( canvases: &[CanvasDocument], pages: &[usize], target_width_mm: Option, + trim_to_visible_content: bool, base_path: &Path, ) -> Result, ExportError> { let paths = export_output_paths(base_path, ExportFormat::Svg, pages.len()); for (&page, path) in pages.iter().zip(&paths) { - std::fs::write(path, document_svg(&canvases[page], target_width_mm))?; + let svg = if trim_to_visible_content { + trim::trim_document_svg(&canvases[page], target_width_mm)? + } else { + document_svg(&canvases[page], target_width_mm) + }; + std::fs::write(path, svg)?; } Ok(paths) } @@ -281,13 +312,20 @@ fn export_pdf( canvases: &[CanvasDocument], pages: &[usize], target_width_mm: Option, + trim_to_visible_content: bool, base_path: &Path, ) -> Result, ExportError> { let path = with_extension(base_path, ExportFormat::Pdf.extension()); let svgs: Vec = pages .iter() - .map(|&page| document_svg(&canvases[page], target_width_mm)) - .collect(); + .map(|&page| { + if trim_to_visible_content { + trim::trim_document_svg(&canvases[page], target_width_mm) + } else { + Ok(document_svg(&canvases[page], target_width_mm)) + } + }) + .collect::>()?; let pdf = if svgs.len() == 1 { let tree = parse_pdf_svg(&svgs[0])?; svg2pdf::to_pdf( @@ -309,6 +347,7 @@ fn export_bitmap( format: ExportFormat, dpi: u16, target_width_mm: Option, + trim_to_visible_content: bool, base_path: &Path, ) -> Result, ExportError> { let paths = export_output_paths(base_path, format, pages.len()); @@ -321,6 +360,16 @@ fn export_bitmap( limits: RasterLimits::default(), }, )?; + let raster = if 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), + )? + } else { + raster + }; match format { ExportFormat::Png => { image::save_buffer_with_format( @@ -379,7 +428,14 @@ fn render_multi_page_pdf(svgs: &[String]) -> Result, ExportError> { let svg_id = *ref_map .get(&svg_id) .ok_or_else(|| ExportError::Pdf("could not renumber SVG PDF object".into()))?; - embedded.push((chunk, svg_id, size.width(), size.height())); + // 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(); @@ -447,7 +503,9 @@ fn numbered_output_path(base_path: &Path, ordinal: usize, extension: &str) -> Pa #[cfg(test)] mod tests { use super::*; - use crate::state::CanvasDocument; + use crate::state::{ + CanvasDocument, CanvasObject, CanvasObjectKind, ObjectFrame, ShapeKind, ShapeObject, + }; fn canvas(name: &str, size_mm: [f32; 2]) -> CanvasDocument { CanvasDocument::new(name.to_owned(), size_mm) @@ -459,6 +517,20 @@ mod tests { .unwrap_or_else(|| std::env::temp_dir().join("plotx-export-tests")) } + fn canvas_with_shape(frame: ObjectFrame) -> CanvasDocument { + let mut canvas = canvas("page", [100.0, 80.0]); + canvas.objects.push(CanvasObject { + id: 1, + name: "shape".into(), + frame, + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Shape(ShapeObject::new(ShapeKind::Rect)), + }); + canvas + } + #[test] fn svg_export_is_invariant_to_board_pos() { let mut c = canvas("page", [80.0, 60.0]); @@ -467,25 +539,6 @@ mod tests { assert_eq!(document_svg(&c, None), baseline); } - #[test] - fn resolves_page_scopes() { - assert_eq!( - resolve_page_scope(ExportPageScope::Current, Some(1), 3).unwrap(), - vec![1] - ); - assert_eq!( - resolve_page_scope(ExportPageScope::All, Some(1), 3).unwrap(), - vec![0, 1, 2] - ); - assert_eq!( - resolve_page_scope(ExportPageScope::Range { start: 2, end: 3 }, Some(0), 4).unwrap(), - vec![1, 2] - ); - assert!( - resolve_page_scope(ExportPageScope::Range { start: 3, end: 2 }, Some(0), 4).is_err() - ); - } - #[test] fn bitmap_multi_page_paths_are_deterministic() { let paths = export_output_paths(Path::new("figure.png"), ExportFormat::Png, 2); @@ -519,6 +572,7 @@ mod tests { scope: ExportPageScope::Current, dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, + trim_to_visible_content: false, }, &out, ) @@ -540,6 +594,7 @@ mod tests { scope: ExportPageScope::All, dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, + trim_to_visible_content: false, }, &out, ) @@ -564,6 +619,7 @@ mod tests { scope: ExportPageScope::Current, dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, + trim_to_visible_content: false, }, &out, ) @@ -589,6 +645,7 @@ mod tests { scope: ExportPageScope::Current, dpi: 600, target_width_mm: Some(89.0), + trim_to_visible_content: false, }, &out, ) @@ -610,4 +667,133 @@ mod tests { assert!(scaled.contains(&format!(r#"width="{}pt" height="{}pt""#, w * 0.5, h * 0.5))); assert!(scaled.contains(&format!(r#"viewBox="0 0 {w} {h}""#))); } + + #[test] + fn trimmed_svg_uses_painted_bounds_and_keeps_page_background() { + let doc = canvas_with_shape(ObjectFrame::new(100.0, 80.0, 40.0, 30.0)); + let svg = trim::trim_document_svg(&doc, None).unwrap(); + let [page_width, page_height] = doc.size_pt(); + assert!(svg.contains(" f32 { + let start = svg.find(attribute).unwrap() + attribute.len(); + let end = svg[start..].find('"').unwrap() + start; + svg[start..end].trim_end_matches("pt").parse().unwrap() + } + + fn pdf_media_boxes(bytes: &[u8]) -> Vec<[f32; 2]> { + let text = String::from_utf8_lossy(bytes); + text.match_indices("/MediaBox [") + .filter_map(|(start, _)| { + let values = text[start + 11..].split_once(']')?.0; + let numbers = values + .split_whitespace() + .map(str::parse::) + .collect::, _>>() + .ok()?; + (numbers.len() == 4).then_some([numbers[2], numbers[3]]) + }) + .collect() + } + + #[test] + fn preset_scale_precedes_trim_without_refitting_and_padding_is_physical_point() { + let doc = canvas_with_shape(ObjectFrame::new(100.0, 80.0, 40.0, 30.0)); + let svg = trim::trim_document_svg(&doc, Some(50.0)).unwrap(); + let width_pt = svg_number(&svg, "width=\""); + let preset_width_pt = 50.0 * 72.0 / 25.4; + assert!(width_pt < preset_width_pt); + + let view_box = svg + .split_once("viewBox=\"") + .unwrap() + .1 + .split_once('"') + .unwrap() + .0 + .split_whitespace() + .map(|value| value.parse::().unwrap()) + .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 mut options = resvg::usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + let tree = resvg::usvg::Tree::from_str(&bounds_svg, &options).unwrap(); + let painted = tree.root().abs_stroke_bounding_box(); + let painted_x = painted.x() * doc.size_pt()[0] / tree.size().width(); + // Two authored points at 0.5 scale are one physical point. + assert!(((painted_x - view_box[0]) * authored_scale - 1.0).abs() < 0.01); + } + + #[test] + fn empty_trimmed_svg_keeps_original_page() { + let doc = canvas("empty", [100.0, 80.0]); + assert_eq!( + trim::trim_document_svg(&doc, None).unwrap(), + document_svg(&doc, None) + ); + } + + #[test] + fn bitmap_formats_encode_the_shared_trimmed_dimensions() { + let canvases = vec![canvas_with_shape(ObjectFrame::new(100.0, 80.0, 40.0, 30.0))]; + let dir = test_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let mut dimensions = Vec::new(); + for format in [ExportFormat::Png, ExportFormat::Jpeg, ExportFormat::Tiff] { + let out = dir.join(format!("trimmed.{}", format.extension())); + let paths = export_canvases( + &canvases, + Some(0), + &ExportSettings { + format, + scope: ExportPageScope::Current, + dpi: 72, + target_width_mm: None, + trim_to_visible_content: true, + }, + &out, + ) + .unwrap(); + let image = image::open(&paths[0]).unwrap(); + dimensions.push((image.width(), image.height())); + assert!(image.width() < 284 && image.height() < 227); + } + assert!(dimensions.windows(2).all(|pair| pair[0] == pair[1])); + } + + #[test] + fn pdf_media_boxes_follow_each_trimmed_svg_page_and_empty_page_stays_full_size() { + let shaped = canvas_with_shape(ObjectFrame::new(100.0, 80.0, 40.0, 30.0)); + let mut wider = canvas_with_shape(ObjectFrame::new(60.0, 50.0, 100.0, 35.0)); + wider.name = "wider".into(); + let empty = canvas("empty", [100.0, 80.0]); + let canvases = vec![shaped, wider, empty]; + let dir = test_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("trimmed-pages.pdf"); + let paths = export_canvases( + &canvases, + Some(0), + &ExportSettings { + format: ExportFormat::Pdf, + scope: ExportPageScope::All, + dpi: DEFAULT_BITMAP_DPI, + target_width_mm: None, + trim_to_visible_content: true, + }, + &out, + ) + .unwrap(); + let boxes = pdf_media_boxes(&std::fs::read(&paths[0]).unwrap()); + assert_eq!(boxes.len(), 3); + assert_ne!(boxes[0], boxes[1]); + let [page_width, page_height] = canvases[2].size_pt(); + assert!((boxes[2][0] - page_width).abs() < 0.01); + assert!((boxes[2][1] - page_height).abs() < 0.01); + } } diff --git a/crates/core/src/export/raster.rs b/crates/core/src/export/raster.rs index 6c39961..96f5c4c 100644 --- a/crates/core/src/export/raster.rs +++ b/crates/core/src/export/raster.rs @@ -66,10 +66,44 @@ pub struct RasterImage { } impl RasterImage { + pub(crate) fn from_rgba(width: u32, height: u32, rgba: Vec) -> Result { + let expected = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(RasterError::PixelDimensionsOverflow)?; + if rgba.len() != expected { + return Err(RasterError::InvalidBufferLength { + width, + height, + expected, + actual: rgba.len(), + }); + } + Ok(Self { + width, + height, + rgba, + }) + } + pub fn width(&self) -> u32 { self.width } + #[cfg(test)] + pub(crate) fn from_invalid_buffer(width: u32, height: u32, rgba: Vec) -> Self { + Self { + width, + height, + rgba, + } + } + pub fn height(&self) -> u32 { self.height } @@ -140,6 +174,15 @@ pub enum RasterError { PixmapAllocation { width: u32, height: u32 }, #[error("could not allocate {bytes} bytes for the RGBA result")] OutputAllocation { bytes: u64 }, + #[error( + "RGBA buffer length {actual} does not match {width}x{height} image (expected {expected})" + )] + InvalidBufferLength { + width: u32, + height: u32, + expected: usize, + actual: usize, + }, } /// Render a canvas into memory without performing filesystem I/O. diff --git a/crates/core/src/export/state_tests.rs b/crates/core/src/export/state_tests.rs new file mode 100644 index 0000000..363a5ac --- /dev/null +++ b/crates/core/src/export/state_tests.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn dialog_state_passes_trim_to_settings() { + let mut dialog = ExportDialogState::new(ExportFormat::Svg); + assert!(!dialog.trim_to_visible_content); + dialog.trim_to_visible_content = true; + assert!(ExportSettings::from(&dialog).trim_to_visible_content); +} + +#[test] +fn dialog_initializes_sticky_trim_and_existing_dpi_from_defaults() { + let defaults = crate::settings::ExportDefaults { + dpi: 450, + trim_to_visible_content: true, + ..Default::default() + }; + let dialog = ExportDialogState::from_defaults(ExportFormat::Png, &defaults); + assert_eq!(dialog.dpi, 450); + assert!(dialog.trim_to_visible_content); +} + +#[test] +fn resolves_page_scopes() { + assert_eq!( + resolve_page_scope(ExportPageScope::Current, Some(1), 3).unwrap(), + vec![1] + ); + assert_eq!( + resolve_page_scope(ExportPageScope::All, Some(1), 3).unwrap(), + vec![0, 1, 2] + ); + assert_eq!( + resolve_page_scope(ExportPageScope::Range { start: 2, end: 3 }, Some(0), 4).unwrap(), + vec![1, 2] + ); + assert!(resolve_page_scope(ExportPageScope::Range { start: 3, end: 2 }, Some(0), 4).is_err()); +} diff --git a/crates/core/src/export/trim.rs b/crates/core/src/export/trim.rs new file mode 100644 index 0000000..8fa4346 --- /dev/null +++ b/crates/core/src/export/trim.rs @@ -0,0 +1,438 @@ +use super::ExportError; +use super::raster::{RasterError, RasterImage}; +use crate::state::{CanvasDocument, render_document_svg_for_bounds, render_document_svg_page}; +use plotx_render::Rect; + +const TRIM_SAFETY_EDGE_PT: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PixelBounds { + pub left: u32, + pub top: u32, + pub right: u32, + pub bottom: u32, +} + +pub(crate) fn raster_visible_bounds( + image: &RasterImage, + background: [u8; 4], +) -> Result, RasterError> { + let width = usize::try_from(image.width()).map_err(|_| RasterError::PixelDimensionsOverflow)?; + let height = + usize::try_from(image.height()).map_err(|_| RasterError::PixelDimensionsOverflow)?; + let expected = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(RasterError::PixelDimensionsOverflow)?; + if image.rgba().len() != expected { + return Err(RasterError::InvalidBufferLength { + width: image.width(), + height: image.height(), + expected, + actual: image.rgba().len(), + }); + } + let mut bounds: Option = None; + for (index, pixel) in image.rgba().chunks_exact(4).enumerate() { + if pixel == background { + continue; + } + let x = u32::try_from(index % width).map_err(|_| RasterError::PixelDimensionsOverflow)?; + let y = u32::try_from(index / width).map_err(|_| RasterError::PixelDimensionsOverflow)?; + bounds = Some(match bounds { + Some(old) => PixelBounds { + left: old.left.min(x), + top: old.top.min(y), + right: old.right.max(x), + bottom: old.bottom.max(y), + }, + None => PixelBounds { + left: x, + top: y, + right: x, + bottom: y, + }, + }); + } + Ok(bounds) +} + +pub(crate) fn crop_raster( + image: RasterImage, + background: [u8; 4], + padding_px: u32, +) -> Result { + let Some(mut bounds) = raster_visible_bounds(&image, background)? else { + return Ok(image); + }; + bounds.left = bounds.left.saturating_sub(padding_px); + bounds.top = bounds.top.saturating_sub(padding_px); + bounds.right = bounds + .right + .saturating_add(padding_px) + .min(image.width().saturating_sub(1)); + bounds.bottom = bounds + .bottom + .saturating_add(padding_px) + .min(image.height().saturating_sub(1)); + + let Some(width) = bounds + .right + .checked_sub(bounds.left) + .and_then(|v| v.checked_add(1)) + else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Some(height) = bounds + .bottom + .checked_sub(bounds.top) + .and_then(|v| v.checked_add(1)) + else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Some(row_bytes) = usize::try_from(width).ok().and_then(|v| v.checked_mul(4)) else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Some(capacity) = row_bytes.checked_mul(usize::try_from(height).unwrap_or(usize::MAX)) + else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Ok(source_width) = usize::try_from(image.width()) else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let mut rgba = Vec::new(); + if rgba.try_reserve_exact(capacity).is_err() { + return Err(RasterError::OutputAllocation { + bytes: u64::try_from(capacity).unwrap_or(u64::MAX), + }); + } + for y in bounds.top..=bounds.bottom { + let Some(start) = usize::try_from(y) + .ok() + .and_then(|y| y.checked_mul(source_width)) + .and_then(|v| v.checked_add(bounds.left as usize)) + .and_then(|v| v.checked_mul(4)) + else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Some(end) = start.checked_add(row_bytes) else { + return Err(RasterError::PixelDimensionsOverflow); + }; + let Some(row) = image.rgba().get(start..end) else { + return Err(RasterError::InvalidBufferLength { + width: image.width(), + height: image.height(), + expected: source_width + .checked_mul(image.height() as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .unwrap_or(usize::MAX), + actual: image.rgba().len(), + }); + }; + rgba.extend_from_slice(row); + } + RasterImage::from_rgba(width, height, rgba) +} + +pub(crate) fn raster_trim_padding(dpi: u16) -> u32 { + // Match the vector export's one-point physical safety edge. Rounding up + // keeps the edge from becoming smaller than one point at any output DPI. + u32::from(dpi).div_ceil(72).max(1) +} + +pub(crate) fn trim_document_svg( + canvas: &CanvasDocument, + target_width_mm: Option, +) -> 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 Some(bounds) = svg_content_bounds(&bounds_svg, [page_width, page_height])? else { + return Ok(super::document_svg(canvas, target_width_mm)); + }; + let left = bounds.left; + let top = bounds.top; + let right = bounds.left + bounds.width; + let bottom = bounds.top + bounds.height; + + // Padding is expressed in authored coordinates so it becomes exactly 1 pt + // after the preset's physical scale has been applied. + let padding = TRIM_SAFETY_EDGE_PT / scale.max(f32::MIN_POSITIVE); + let view = Rect::new( + (left - padding).max(0.0), + (top - padding).max(0.0), + (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, + view, + [view.width * scale, view.height * scale], + )) +} + +fn svg_content_bounds(svg: &str, page: [f32; 2]) -> Result, ExportError> { + let mut options = resvg::usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + let tree = resvg::usvg::Tree::from_str(svg, &options) + .map_err(|error| ExportError::SvgParse(error.to_string()))?; + if tree.root().children().is_empty() { + return Ok(None); + } + let Some(bounds) = visible_group_bounds(tree.root()) else { + return Ok(None); + }; + // usvg reports absolute bounds in its CSS-pixel viewport. PlotX document + // SVGs declare their physical size in points, so the viewport is 96/72 + // larger than the authored page coordinate system. Normalize through the + // parsed tree size rather than baking in that ratio, which also keeps this + // helper correct for unitless SVG fixtures and future physical units. + let tree_size = tree.size(); + let scale_x = page[0] / tree_size.width(); + let scale_y = page[1] / tree_size.height(); + if !scale_x.is_finite() || !scale_y.is_finite() || scale_x <= 0.0 || scale_y <= 0.0 { + return Ok(None); + } + let bounds = bounds.scaled(scale_x, scale_y); + let left = bounds.left.max(0.0); + let top = bounds.top.max(0.0); + let right = bounds.right.min(page[0]); + let bottom = bounds.bottom.min(page[1]); + if !left.is_finite() || !top.is_finite() || right <= left || bottom <= top { + return Ok(None); + } + Ok(Some(Rect::new(left, top, right - left, bottom - top))) +} + +#[derive(Clone, Copy)] +struct SvgBounds { + left: f32, + top: f32, + right: f32, + bottom: f32, +} + +impl SvgBounds { + fn from_rect(rect: resvg::tiny_skia::Rect) -> Self { + Self { + left: rect.left(), + top: rect.top(), + right: rect.right(), + bottom: rect.bottom(), + } + } + + fn union(self, other: Self) -> Self { + Self { + left: self.left.min(other.left), + top: self.top.min(other.top), + right: self.right.max(other.right), + bottom: self.bottom.max(other.bottom), + } + } + + fn scaled(self, x: f32, y: f32) -> Self { + Self { + left: self.left * x, + top: self.top * y, + right: self.right * x, + bottom: self.bottom * y, + } + } + + fn intersect(self, other: Self) -> Option { + let bounds = Self { + left: self.left.max(other.left), + top: self.top.max(other.top), + right: self.right.min(other.right), + bottom: self.bottom.min(other.bottom), + }; + (bounds.right > bounds.left && bounds.bottom > bounds.top).then_some(bounds) + } +} + +fn visible_group_bounds(group: &resvg::usvg::Group) -> Option { + let clip_bounds = match group.clip_path() { + Some(clip) => Some(clip_path_bounds(clip)?), + None => None, + }; + let mask_bounds = match group.mask() { + Some(mask) => Some(mask_bounds(mask, group.abs_transform())?), + None => None, + }; + let mut bounds = group + .children() + .iter() + .filter_map(visible_node_bounds) + .filter_map(|bounds| match clip_bounds { + Some(clip) => bounds.intersect(clip), + None => Some(bounds), + }) + .filter_map(|bounds| match mask_bounds { + Some(mask) => bounds.intersect(mask), + None => Some(bounds), + }) + .reduce(SvgBounds::union)?; + + // usvg's layer bounds include filter regions but deliberately do not apply + // clip paths. Preserve filter expansion, then explicitly constrain the + // stroke-aware aggregate to the painted clip geometry. + if !group.filters().is_empty() { + bounds = SvgBounds::from_rect(group.abs_layer_bounding_box().to_rect()); + if let Some(clip) = clip_bounds { + bounds = bounds.intersect(clip)?; + } + if let Some(mask) = mask_bounds { + bounds = bounds.intersect(mask)?; + } + } + Some(bounds) +} + +fn visible_node_bounds(node: &resvg::usvg::Node) -> Option { + match node { + resvg::usvg::Node::Group(group) => visible_group_bounds(group), + _ => Some(SvgBounds::from_rect(node.abs_stroke_bounding_box())), + } +} + +fn clip_path_bounds(clip: &resvg::usvg::ClipPath) -> Option { + if clip.root().children().is_empty() { + return None; + } + let mut bounds = SvgBounds::from_rect(clip.root().abs_bounding_box()); + if let Some(parent) = clip.clip_path() { + bounds = bounds.intersect(clip_path_bounds(parent)?)?; + } + Some(bounds) +} + +fn mask_bounds( + mask: &resvg::usvg::Mask, + transform: resvg::tiny_skia::Transform, +) -> Option { + let region = mask.rect().to_rect().transform(transform)?; + let content = visible_group_bounds(mask.root())?; + let mut bounds = SvgBounds::from_rect(region).intersect(content)?; + if let Some(parent) = mask.mask() { + bounds = bounds.intersect(mask_bounds(parent, transform)?)?; + } + Some(bounds) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(width: u32, height: u32, pixels: Vec) -> RasterImage { + RasterImage::from_rgba(width, height, pixels).unwrap() + } + + #[test] + fn raster_crop_adds_one_pixel_and_clamps_at_edges() { + let mut pixels = vec![255; 10 * 8 * 4]; + pixels[(3 * 10 + 4) * 4..(3 * 10 + 4) * 4 + 4].copy_from_slice(&[0, 0, 0, 255]); + let cropped = crop_raster(image(10, 8, pixels), [255; 4], 1).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (3, 3)); + + let mut pixels = vec![255; 4 * 4 * 4]; + pixels[..4].copy_from_slice(&[0, 0, 0, 255]); + let cropped = crop_raster(image(4, 4, pixels), [255; 4], 1).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (2, 2)); + } + + #[test] + fn empty_raster_keeps_its_page() { + let original = image(7, 5, vec![255; 7 * 5 * 4]); + assert_eq!( + crop_raster(original.clone(), [255; 4], 1).unwrap(), + original + ); + } + + #[test] + fn raster_visibility_is_exact_final_pixel_color_and_padding_is_one_pixel() { + let mut pixels = vec![255; 9 * 9 * 4]; + // A white-on-white authored mark is visually absent in the final raster. + pixels[(2 * 9 + 2) * 4..(2 * 9 + 2) * 4 + 4].copy_from_slice(&[255; 4]); + pixels[(4 * 9 + 4) * 4..(4 * 9 + 4) * 4 + 4].copy_from_slice(&[254, 255, 255, 255]); + let cropped = crop_raster(image(9, 9, pixels), [255; 4], 1).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (3, 3)); + } + + #[test] + fn invalid_raster_buffer_is_reported() { + let invalid = RasterImage::from_invalid_buffer(2, 2, vec![255; 15]); + assert!(matches!( + crop_raster(invalid, [255; 4], 1), + Err(RasterError::InvalidBufferLength { .. }) + )); + } + + #[test] + fn raster_safety_edge_is_one_physical_point_rounded_up() { + assert_eq!(raster_trim_padding(72), 1); + assert_eq!(raster_trim_padding(300), 5); + assert_eq!(raster_trim_padding(600), 9); + assert_eq!(raster_trim_padding(1_200), 17); + } + + #[test] + fn raster_crop_applies_requested_padding() { + let mut pixels = vec![255; 20 * 20 * 4]; + pixels[(10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4].copy_from_slice(&[0, 0, 0, 255]); + let cropped = crop_raster(image(20, 20, pixels), [255; 4], 5).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (11, 11)); + } + + #[test] + fn svg_layer_bounds_intersect_clipped_geometry_and_keep_visible_stroke() { + let svg = r##" + + + + + + "##; + let bounds = svg_content_bounds(svg, [200.0, 120.0]).unwrap().unwrap(); + assert!((bounds.left - 50.0).abs() < 0.01, "{bounds:?}"); + assert!((bounds.width - 80.0).abs() < 0.01); + assert!((bounds.top - 45.0).abs() < 0.01); + assert!((bounds.height - 10.0).abs() < 0.01, "{bounds:?}"); + } + + #[test] + fn fully_clipped_svg_element_does_not_expand_visible_bounds() { + let svg = r##" + + + + "##; + let bounds = svg_content_bounds(svg, [200.0, 120.0]).unwrap().unwrap(); + assert!((bounds.left - 16.0).abs() < 0.01); + assert!((bounds.top - 16.0).abs() < 0.01); + assert!((bounds.width - 8.0).abs() < 0.01, "{bounds:?}"); + assert!((bounds.height - 8.0).abs() < 0.01); + } + + #[test] + fn text_bounds_stay_inside_the_authored_page_coordinate_system() { + let svg = r##" + Axis label + "##; + let bounds = svg_content_bounds(svg, [200.0, 120.0]) + .unwrap() + .expect("text should have painted bounds"); + assert!( + bounds.left >= 35.0 + && bounds.left <= 45.0 + && bounds.top >= 30.0 + && bounds.top <= 50.0 + && bounds.left + bounds.width < 150.0 + && bounds.top + bounds.height < 70.0, + "unexpected text bounds: {bounds:?}" + ); + } +} diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs index a054e20..d319b45 100644 --- a/crates/core/src/layout.rs +++ b/crates/core/src/layout.rs @@ -5,8 +5,9 @@ use crate::state::{MM_TO_PT, ObjectFrame, ObjectId}; mod visual_spacing; pub use visual_spacing::{ - GutterPreset, LayoutItem, OccupiedGrid, SpacingMode, arrange_grid, + GutterPreset, LayoutItem, OccupiedGrid, SpacingMode, TilingDropRegion, arrange_grid, compute_tiling_plan_for_items, infer_occupied_grid, layout_item, outer_axis_cells, + tiling_drop_region, }; /// Grid presets offered in the Arrange menu, as `(label, rows, cols)`. diff --git a/crates/core/src/layout/visual_spacing.rs b/crates/core/src/layout/visual_spacing.rs index 7335b4f..d9a723d 100644 --- a/crates/core/src/layout/visual_spacing.rs +++ b/crates/core/src/layout/visual_spacing.rs @@ -42,6 +42,47 @@ pub struct LayoutItem { pub insets: [f32; 4], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TilingDropRegion { + Left, + Right, + Top, + Bottom, + /// Retiling three or more objects is independent of pointer direction. + Retile, +} + +pub fn tiling_drop_region( + page_pt: [f32; 2], + existing_count: usize, + pointer_page: [f32; 2], +) -> TilingDropRegion { + if existing_count != 1 { + return TilingDropRegion::Retile; + } + let nx = if page_pt[0] > 0.0 { + pointer_page[0] / page_pt[0] + } else { + 0.5 + }; + let ny = if page_pt[1] > 0.0 { + pointer_page[1] / page_pt[1] + } else { + 0.5 + }; + if (nx - 0.5).abs() >= (ny - 0.5).abs() { + if nx >= 0.5 { + TilingDropRegion::Right + } else { + TilingDropRegion::Left + } + } else if ny >= 0.5 { + TilingDropRegion::Bottom + } else { + TilingDropRegion::Top + } +} + pub fn layout_item(id: ObjectId, figure: &plotx_figure::Figure, frame: ObjectFrame) -> LayoutItem { let margins = plotx_render::axis_layout(figure, frame.width, frame.height).margins; LayoutItem { @@ -54,6 +95,11 @@ pub fn layout_item(id: ObjectId, figure: &plotx_figure::Figure, frame: ObjectFra /// drag-tiling floating-point calculations. const GRID_ALIGNMENT_TOLERANCE_PT: f32 = 1.0; +/// `ObjectFrame::new` clamps each extent to at least 1 pt. Gap fitting reserves +/// that minimum for every row and column so the clamp cannot create overlaps or +/// push frames outside the page. This is a geometry invariant, not visual padding. +const MIN_CELL_EXTENT_PT: f32 = 1.0; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct OccupiedGrid { pub rows: u32, @@ -147,8 +193,14 @@ pub fn arrange_grid( let top = mt.clamp(0.0, page_pt[1].max(0.0)); let available_w = (page_pt[0] - left - mr.max(0.0)).max(0.0); let available_h = (page_pt[1] - top - mb.max(0.0)).max(0.0); - fit_gaps(&mut col_gaps, (available_w - cols as f32).max(0.0)); - fit_gaps(&mut row_gaps, (available_h - rows as f32).max(0.0)); + fit_gaps( + &mut col_gaps, + (available_w - cols as f32 * MIN_CELL_EXTENT_PT).max(0.0), + ); + fit_gaps( + &mut row_gaps, + (available_h - rows as f32 * MIN_CELL_EXTENT_PT).max(0.0), + ); let width = available_w - col_gaps.iter().sum::(); let height = available_h - row_gaps.iter().sum::(); let cell_w = width / cols as f32; @@ -245,11 +297,9 @@ fn split_plan( newcomer: LayoutItem, pointer: [f32; 2], ) -> TilingPlan { - let [w, h] = page_pt; - let nx = if w > 0.0 { pointer[0] / w } else { 0.5 }; - let ny = if h > 0.0 { pointer[1] / h } else { 0.5 }; - let horizontal = (nx - 0.5).abs() >= (ny - 0.5).abs(); - let newcomer_last = if horizontal { nx >= 0.5 } else { ny >= 0.5 }; + let region = tiling_drop_region(page_pt, 1, pointer); + let horizontal = matches!(region, TilingDropRegion::Left | TilingDropRegion::Right); + let newcomer_last = matches!(region, TilingDropRegion::Right | TilingDropRegion::Bottom); let split_layout = PageLayout { rows: if horizontal { 1 } else { 2 }, cols: if horizontal { 2 } else { 1 }, @@ -340,8 +390,92 @@ mod tests { assert!( frames .iter() - .all(|(_, frame)| frame.x >= 0.0 && frame.x + frame.width <= 100.001) + .all(|(_, frame)| frame.width >= MIN_CELL_EXTENT_PT + && frame.height >= MIN_CELL_EXTENT_PT + && frame.x >= 0.0 + && frame.y >= 0.0 + && frame.x + frame.width <= 100.001 + && frame.y + frame.height <= 50.001) + ); + } + + #[test] + fn near_minimum_page_reserves_cell_extents_before_frame_construction() { + let layout = PageLayout { + rows: 2, + cols: 3, + gutter_mm: 100.0, + ..PageLayout::default() + }; + let items: Vec<_> = (1..=6).map(|id| item(id, 0.0)).collect(); + let frames = arrange_grid([3.0, 2.0], &layout, &items); + assert!(frames.iter().all(|(_, frame)| { + frame.width == MIN_CELL_EXTENT_PT && frame.height == MIN_CELL_EXTENT_PT + })); + assert!(frames.windows(2).all(|pair| { + pair[0].1.y < pair[1].1.y || pair[0].1.x + pair[0].1.width <= pair[1].1.x + f32::EPSILON + })); + } + + #[test] + fn ordinary_visual_spacing_geometry_is_unchanged() { + let layout = PageLayout { + rows: 1, + cols: 2, + gutter_mm: 5.0, + ..PageLayout::default() + }; + let frames = arrange_grid([400.0, 300.0], &layout, &[item(1, 0.0), item(2, 0.0)]); + let expected_gap = 5.0 * crate::state::MM_TO_PT; + let expected_width = (400.0 - expected_gap) * 0.5; + assert!((frames[0].1.width - expected_width).abs() < 0.001); + assert!((frames[1].1.x - (expected_width + expected_gap)).abs() < 0.001); + } + + #[test] + fn split_region_is_explicit_and_newcomer_insets_affect_its_own_preview() { + let page = [400.0, 300.0]; + assert_eq!( + tiling_drop_region(page, 1, [10.0, 150.0]), + TilingDropRegion::Left + ); + assert_eq!( + tiling_drop_region(page, 1, [390.0, 150.0]), + TilingDropRegion::Right + ); + assert_eq!( + tiling_drop_region(page, 1, [200.0, 10.0]), + TilingDropRegion::Top + ); + assert_eq!( + tiling_drop_region(page, 1, [200.0, 290.0]), + TilingDropRegion::Bottom + ); + assert_eq!( + tiling_drop_region(page, 2, [10.0, 10.0]), + TilingDropRegion::Retile + ); + + let layout = PageLayout { + rows: 1, + cols: 2, + ..PageLayout::default() + }; + let narrow = compute_tiling_plan_for_items( + page, + &layout, + &[item(1, 2.0)], + item(2, 2.0), + [390.0, 150.0], + ); + let wide = compute_tiling_plan_for_items( + page, + &layout, + &[item(1, 2.0)], + item(3, 40.0), + [390.0, 150.0], ); + assert_ne!(narrow.newcomer, wide.newcomer); } fn frame(id: ObjectId, col: u32, row: u32) -> (ObjectId, ObjectFrame) { diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index 18d53df..ba8169b 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -155,6 +155,8 @@ pub struct ExportDefaults { pub include_view_snapshots: bool, #[serde(default = "default_export_dpi")] pub dpi: u16, + #[serde(default)] + pub trim_to_visible_content: bool, } /// Sticky choices of the canvas-size popover. @@ -258,6 +260,7 @@ impl Default for ExportDefaults { Self { include_view_snapshots: false, dpi: default_export_dpi(), + trim_to_visible_content: false, } } } diff --git a/crates/core/src/settings/tests.rs b/crates/core/src/settings/tests.rs index 2688f7d..514a697 100644 --- a/crates/core/src/settings/tests.rs +++ b/crates/core/src/settings/tests.rs @@ -14,6 +14,7 @@ fn missing_fields_take_defaults() { assert!(settings.general.snap_enabled); assert_eq!(settings.general.project_backup_generations, 1); assert_eq!(settings.export.dpi, crate::export::DEFAULT_BITMAP_DPI); + assert!(!settings.export.trim_to_visible_content); assert_eq!( settings.appearance.graphics_power, GraphicsPowerPreference::LowPower @@ -96,6 +97,7 @@ fn save_and_load_roundtrip() { settings.general.snap_enabled = false; settings.general.project_backup_generations = 3; settings.export.include_view_snapshots = true; + settings.export.trim_to_visible_content = true; io::save_to_path(&path, &settings).unwrap(); let loaded = io::load_from_paths(&path, None); @@ -104,6 +106,7 @@ fn save_and_load_roundtrip() { assert!(!loaded.general.snap_enabled); assert_eq!(loaded.general.project_backup_generations, 3); assert!(loaded.export.include_view_snapshots); + assert!(loaded.export.trim_to_visible_content); } #[test] diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 4aada95..4dcba43 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -731,3 +731,19 @@ 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 fcf525e..5c96502 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -380,8 +380,8 @@ impl PlotxApp { self.record_export_unavailable(format); return; } - let mut state = ExportDialogState::new(format); - state.dpi = crate::settings::load().export.dpi; + let defaults = crate::settings::load().export; + let mut state = ExportDialogState::from_defaults(format, &defaults); let canvas = &self.doc.canvases[ci]; if let Some(preset) = crate::export::ExportPreset::matching_canvas( format, @@ -586,6 +586,7 @@ mod export_operation_tests { scope: crate::export::ExportPageScope::Range { start: 2, end: 1 }, dpi: crate::export::DEFAULT_BITMAP_DPI, target_width_mm: None, + trim_to_visible_content: false, }, std::path::Path::new("unused.svg"), ); diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 8a05ab6..2ff4035 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -72,6 +72,7 @@ mod table_execution_job; mod table_fit; mod table_native; mod table_numeric; +mod tile_drop; mod ui_state; mod units; mod workflow_tab; @@ -106,6 +107,7 @@ pub use table_edit::*; pub use table_execution::*; pub use table_execution_job::*; pub use table_native::*; +pub use tile_drop::*; pub use ui_state::*; pub use units::*; pub use workflow_tab::WorkflowTab; diff --git a/crates/core/src/state/tile_drop.rs b/crates/core/src/state/tile_drop.rs new file mode 100644 index 0000000..1633942 --- /dev/null +++ b/crates/core/src/state/tile_drop.rs @@ -0,0 +1,23 @@ +use super::{ObjectFrame, ObjectId}; + +/// A previewed auto-tiling drop, computed while a single plot hovers another page. +#[derive(Clone, Debug)] +pub struct TileDropPreview { + pub cache_key: TileDropCacheKey, + pub target: usize, + pub newcomer: ObjectFrame, + pub existing: Vec<(ObjectId, ObjectFrame)>, +} + +/// Every input that can change an auto-tiling preview without moving the pointer +/// inside its current drop region. +#[derive(Clone, Debug, PartialEq)] +pub struct TileDropCacheKey { + pub source_canvas: usize, + pub source_object: ObjectId, + pub target_canvas: usize, + pub target_page_pt: [f32; 2], + pub target_layout: crate::layout::PageLayout, + pub target_existing_ids: Vec, + pub region: crate::layout::TilingDropRegion, +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 6e86abe..2e1da96 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -751,17 +751,6 @@ pub struct ObjectDrag { pub active: bool, } -/// A previewed auto-tiling drop, computed each frame a qualifying single-plot -/// move drag hovers a different canvas. `target` is that canvas; `newcomer` is the -/// dragged plot's landing frame and `existing` the pushed-aside plots' new frames, -/// all in the target's page space (pt). Committed as one undoable step on release. -#[derive(Clone, Debug)] -pub struct TileDropPreview { - pub target: usize, - pub newcomer: ObjectFrame, - pub existing: Vec<(ObjectId, ObjectFrame)>, -} - /// In-progress drag of a whole frame (page or sheet) across the board by its /// header strip. `before` is the frame's `board_pos` (pt) at grab time and /// `start_world` the board-world (pt) pointer position then, so the live position diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index e9c5b59..5bc1b98 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -145,6 +145,7 @@ pub fn process_file( scope: ExportPageScope::Current, dpi: DEFAULT_BITMAP_DPI, target_width_mm: None, + trim_to_visible_content: false, }; let output_paths = export_canvases(&[canvas], Some(0), &settings, output)?; Ok(ProcessResult { diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 14a84c7..0b9fe1e 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -7,6 +7,9 @@ use crate::{ use plotx_figure::{AxisFrame, AxisTrace, Figure, SeriesKind}; use std::fmt::Write as _; +mod document; +pub use document::{export_document, export_document_for_bounds, export_document_page}; + /// Render a [`Figure`] to a standalone SVG document string. pub fn export(fig: &Figure) -> String { let w = fig.width; @@ -17,40 +20,16 @@ pub fn export(fig: &Figure) -> String { s, r#""# ); - write_figure(&mut s, fig, outer, "plot"); + write_figure(&mut s, fig, outer, "plot", None); let _ = write!(s, ""); s } -/// Render a page document to SVG using page points as the geometry space. -pub fn export_document(document: &Document<'_>) -> String { - let w = document.width; - let h = document.height; - let mut s = String::new(); - let _ = write!( - s, - r#""# - ); - let _ = write!( - s, - r#""#, - document.background.to_hex() - ); - for item in &document.items { - match item { - DocumentItem::Plot(object) => write_document_object(&mut s, object), - DocumentItem::Overlay(overlay) => { - if overlay.visible { - write_overlay(&mut s, overlay); - } - } - } - } - let _ = write!(s, ""); - s -} - -fn write_document_object(s: &mut String, object: &DocumentObject<'_>) { +fn write_document_object( + s: &mut String, + object: &DocumentObject<'_>, + omit_figure_background_matching: Option, +) { if !object.visible { return; } @@ -66,6 +45,7 @@ fn write_document_object(s: &mut String, object: &DocumentObject<'_>) { object.figure, Rect::new(0.0, 0.0, object.frame.width, object.frame.height), &format!("{id}_clip"), + omit_figure_background_matching, ); if let Some(title) = &object.title { write_panel_letter(s, &title.text, title.position, title.font_size); @@ -163,7 +143,13 @@ fn write_overlay(s: &mut String, overlay: &DocumentOverlay<'_>) { } } -fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { +fn write_figure( + s: &mut String, + fig: &Figure, + outer: Rect, + clip_id: &str, + omit_background_matching: Option, +) { let ty = fig.typography; let w = outer.width; let h = outer.height; @@ -172,13 +158,15 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { let proj = Projector::new(fig, outer, &margins); let plot = proj.plot; - let _ = write!( - s, - r#""#, - fig.background.to_hex(), - x = outer.left, - y = outer.top - ); + if omit_background_matching != Some(fig.background) { + let _ = write!( + s, + r#""#, + fig.background.to_hex(), + x = outer.left, + y = outer.top + ); + } if !fig.title.trim().is_empty() { let _ = write!( diff --git a/crates/render/src/svg/document.rs b/crates/render/src/svg/document.rs new file mode 100644 index 0000000..2a027d5 --- /dev/null +++ b/crates/render/src/svg/document.rs @@ -0,0 +1,125 @@ +use super::{Document, DocumentItem, Rect, write_document_object, write_overlay}; +use std::fmt::Write as _; + +/// Render a page document to SVG using page points as the geometry space. +pub fn export_document(document: &Document<'_>) -> String { + export_document_with_page( + document, + None, + [document.width, document.height], + true, + false, + ) +} + +/// Render the document without visually redundant backgrounds for painted-bounds analysis. +pub fn export_document_for_bounds(document: &Document<'_>) -> String { + export_document_with_page( + document, + None, + [document.width, document.height], + false, + true, + ) +} + +/// Render the complete document against a cropped page without moving its geometry. +pub fn export_document_page( + document: &Document<'_>, + view_box: Rect, + physical_size: [f32; 2], +) -> String { + export_document_with_page(document, Some(view_box), physical_size, true, false) +} + +fn export_document_with_page( + document: &Document<'_>, + view_box: Option, + physical_size: [f32; 2], + include_page_background: bool, + omit_redundant_figure_background: bool, +) -> String { + let w = document.width; + let h = document.height; + let page = view_box.unwrap_or_else(|| Rect::new(0.0, 0.0, w, h)); + let [physical_width, physical_height] = physical_size; + let mut s = String::new(); + let _ = write!( + s, + r#""#, + x = page.left, + y = page.top, + vw = page.width, + vh = page.height, + ); + if include_page_background { + let _ = write!( + s, + r#""#, + document.background.to_hex(), + x = page.left, + y = page.top, + vw = page.width, + vh = page.height, + ); + } + for item in &document.items { + match item { + DocumentItem::Plot(object) => write_document_object( + &mut s, + object, + omit_redundant_figure_background.then_some(document.background), + ), + DocumentItem::Overlay(overlay) => { + if overlay.visible { + write_overlay(&mut s, overlay); + } + } + } + } + let _ = write!(s, ""); + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DocumentObject; + use plotx_figure::{Axis, AxisFrame, Color, Figure}; + + #[test] + fn bounds_document_omits_only_visually_redundant_backgrounds() { + let page_color = Color::rgb(255, 255, 255); + let mut matching = Figure::new("", Axis::new("", 0.0, 1.0), Axis::new("", 0.0, 1.0)); + matching.background = page_color; + matching.axis_frame = AxisFrame::Hidden; + let matching_doc = Document { + width: 100.0, + height: 80.0, + background: page_color, + items: vec![DocumentItem::Plot(DocumentObject { + id: "matching".into(), + frame: Rect::new(10.0, 10.0, 50.0, 40.0), + figure: &matching, + visible: true, + title: None, + })], + }; + assert!(!export_document_for_bounds(&matching_doc).contains("fill=\"#ffffff\"")); + assert!(export_document(&matching_doc).contains("fill=\"#ffffff\"")); + + let mut contrasting = matching.clone(); + contrasting.background = Color::rgb(1, 2, 3); + let contrasting_doc = Document { + items: vec![DocumentItem::Plot(DocumentObject { + id: "contrasting".into(), + frame: Rect::new(10.0, 10.0, 50.0, 40.0), + figure: &contrasting, + visible: true, + title: None, + })], + ..matching_doc + }; + assert!(export_document_for_bounds(&contrasting_doc).contains("fill=\"#010203\"")); + } +} diff --git a/docs/src/content/docs/guides/exporting.md b/docs/src/content/docs/guides/exporting.md index 5b05318..891a97b 100644 --- a/docs/src/content/docs/guides/exporting.md +++ b/docs/src/content/docs/guides/exporting.md @@ -20,6 +20,19 @@ 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. +### Trim page whitespace + +Enable **Trim page to visible content** in the Export dialog to remove page +whitespace around the final rendered content. PNG, JPEG, TIFF, SVG, and PDF +support this option, and PlotX remembers the choice for later exports. + +Trimming happens after the target-width preset establishes the page's physical +scale. It changes only the page boundary and does not enlarge or fit the content +again. A journal or column preset can therefore produce a final physical page +width smaller than the preset width. Every supported format retains a 1-point +physical safety edge; bitmap exports round that edge up to a whole output pixel. +Empty pages keep their original dimensions. + ## Copy figure *Copy figure* (`Ctrl` + `C`, also in the export menu, the command palette, diff --git a/docs/src/content/docs/zh-cn/guides/exporting.md b/docs/src/content/docs/zh-cn/guides/exporting.md index e2657c3..72b6a4e 100644 --- a/docs/src/content/docs/zh-cn/guides/exporting.md +++ b/docs/src/content/docs/zh-cn/guides/exporting.md @@ -17,6 +17,17 @@ description: 导出出版级图像,以及图形背后的数值。 TIFF*——并且导出前的合规检查会按所选预设标出字号与线宽不达标之处,让 问题在画板上就被修正,而不是被期刊编辑发现。 +### 裁去页面空白 + +在“导出”对话框中启用 **Trim page to visible content(将页面裁至可见内容)**, +可移除最终渲染内容四周的页面空白。PNG、JPEG、TIFF、SVG 和 PDF 均支持此 +选项,PlotX 会记住该选择并用于后续导出。 + +裁边在目标宽度预设确定页面物理缩放后执行,只改变页面边界,不会再次放大或 +适配内容。因此使用期刊或分栏预设时,最终页面的物理宽度可能小于预设宽度。 +所有受支持的格式均保留 1 pt 物理安全边;位图会按输出 DPI 向上取整至完整像素。 +空页面保持原尺寸。 + ## 复制图形 *Copy figure*(复制图形,`Ctrl` + `C`,也可在导出菜单、命令面板或图框 From 8bdd5df405abdc4799a0fa627a6039486d31228c Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 22 Jul 2026 23:09:28 +0800 Subject: [PATCH 2/2] fix(export): resolve generic fonts on Linux Map SVG's generic sans-serif family to an installed platform font so text contributes bounds during trim, raster, and PDF parsing on Ubuntu. --- crates/core/src/export/fonts.rs | 29 +++++++++++++++++++++++++++++ crates/core/src/export/mod.rs | 8 ++++---- crates/core/src/export/raster.rs | 2 +- crates/core/src/export/trim.rs | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 crates/core/src/export/fonts.rs diff --git a/crates/core/src/export/fonts.rs b/crates/core/src/export/fonts.rs new file mode 100644 index 0000000..bcdc57b --- /dev/null +++ b/crates/core/src/export/fonts.rs @@ -0,0 +1,29 @@ +use resvg::usvg::fontdb::Database; + +/// Load platform fonts and make SVG's generic `sans-serif` family resolve on +/// systems that do not install fontdb's default Arial family (notably Linux). +pub(super) fn load_system_fonts(database: &mut Database) { + database.load_system_fonts(); + + const PREFERRED_SANS: &[&str] = &["Arial", "Liberation Sans", "DejaVu Sans", "Noto Sans"]; + let family = PREFERRED_SANS + .iter() + .find_map(|candidate| { + database + .faces() + .flat_map(|face| &face.families) + .find(|(family, _)| family.eq_ignore_ascii_case(candidate)) + .map(|(family, _)| family.clone()) + }) + .or_else(|| { + database + .faces() + .find(|face| !face.monospaced) + .or_else(|| database.faces().next()) + .and_then(|face| face.families.first()) + .map(|(family, _)| family.clone()) + }); + if let Some(family) = family { + database.set_sans_serif_family(family); + } +} diff --git a/crates/core/src/export/mod.rs b/crates/core/src/export/mod.rs index 22c2ff7..ddea7c3 100644 --- a/crates/core/src/export/mod.rs +++ b/crates/core/src/export/mod.rs @@ -1,3 +1,4 @@ +mod fonts; mod precheck; mod preset; mod raster; @@ -261,8 +262,7 @@ pub fn export_canvases( } } -/// Scale the SVG's declared physical size while leaving its geometry untouched. -/// `None` preserves the authored page size. +/// 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); let Some(target) = target_width_mm else { @@ -475,7 +475,7 @@ fn render_multi_page_pdf(svgs: &[String]) -> Result, ExportError> { fn parse_pdf_svg(svg: &str) -> Result { let mut options = svg2pdf::usvg::Options::default(); - options.fontdb_mut().load_system_fonts(); + fonts::load_system_fonts(options.fontdb_mut()); svg2pdf::usvg::Tree::from_str(svg, &options).map_err(|e| ExportError::SvgParse(e.to_string())) } @@ -721,7 +721,7 @@ mod tests { assert!((width_pt - view_box[2] * authored_scale).abs() < 0.01); let bounds_svg = crate::state::render_document_svg_for_bounds(&doc); let mut options = resvg::usvg::Options::default(); - options.fontdb_mut().load_system_fonts(); + fonts::load_system_fonts(options.fontdb_mut()); let tree = resvg::usvg::Tree::from_str(&bounds_svg, &options).unwrap(); let painted = tree.root().abs_stroke_bounding_box(); let painted_x = painted.x() * doc.size_pt()[0] / tree.size().width(); diff --git a/crates/core/src/export/raster.rs b/crates/core/src/export/raster.rs index 96f5c4c..7e3fa8a 100644 --- a/crates/core/src/export/raster.rs +++ b/crates/core/src/export/raster.rs @@ -226,7 +226,7 @@ pub fn rasterize_svg( enforce_limits(width, height, options.limits)?; let mut usvg_options = resvg::usvg::Options::default(); - usvg_options.fontdb_mut().load_system_fonts(); + super::fonts::load_system_fonts(usvg_options.fontdb_mut()); let tree = resvg::usvg::Tree::from_str(svg, &usvg_options) .map_err(|error| RasterError::SvgParse(error.to_string()))?; let mut pixmap = diff --git a/crates/core/src/export/trim.rs b/crates/core/src/export/trim.rs index 8fa4346..d075db4 100644 --- a/crates/core/src/export/trim.rs +++ b/crates/core/src/export/trim.rs @@ -175,7 +175,7 @@ pub(crate) fn trim_document_svg( fn svg_content_bounds(svg: &str, page: [f32; 2]) -> Result, ExportError> { let mut options = resvg::usvg::Options::default(); - options.fontdb_mut().load_system_fonts(); + super::fonts::load_system_fonts(options.fontdb_mut()); let tree = resvg::usvg::Tree::from_str(svg, &options) .map_err(|error| ExportError::SvgParse(error.to_string()))?; if tree.root().children().is_empty() {