From cacb5c98d2ff4404ed0eef33d02a974d90be3457 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 5 Aug 2026 00:49:25 +0800 Subject: [PATCH] feat(editor): export a deck as an editable powerpoint file Structured slides become real DrawingML: text lands as absolutely positioned text boxes with size, weight, colour and exact point line spacing (the percentage form multiplies each font's own line height and drifts per family), CJK families are written into the east-asian slot so PowerPoint does not substitute them away, and per-side strokes become thin filled bars so a divider does not turn into a box around editable text. Whatever DrawingML cannot express rasters alone at its exact rect instead of being dropped. The two shipped deck templates export with every node structured and open as 14-15 KB files. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x --- .../src/persistence_export_pptx.rs | 118 +++++ crates/op-host-services/src/export_pptx.rs | 497 ++++++++++++++++++ .../src/export_pptx/fallback.rs | 114 ++++ .../op-host-services/src/export_pptx/media.rs | 281 ++++++++++ .../src/export_pptx/package.rs | 428 +++++++++++++++ .../src/export_pptx/picture.rs | 194 +++++++ .../op-host-services/src/export_pptx/shape.rs | 330 ++++++++++++ .../op-host-services/src/export_pptx/tests.rs | 451 ++++++++++++++++ .../op-host-services/src/export_pptx/text.rs | 408 ++++++++++++++ .../op-host-services/src/export_pptx/units.rs | 201 +++++++ .../op-host-services/src/export_pptx/xml.rs | 364 +++++++++++++ crates/op-host-services/src/lib.rs | 1 + 12 files changed, 3387 insertions(+) create mode 100644 crates/op-host-desktop/src/persistence_export_pptx.rs create mode 100644 crates/op-host-services/src/export_pptx.rs create mode 100644 crates/op-host-services/src/export_pptx/fallback.rs create mode 100644 crates/op-host-services/src/export_pptx/media.rs create mode 100644 crates/op-host-services/src/export_pptx/package.rs create mode 100644 crates/op-host-services/src/export_pptx/picture.rs create mode 100644 crates/op-host-services/src/export_pptx/shape.rs create mode 100644 crates/op-host-services/src/export_pptx/tests.rs create mode 100644 crates/op-host-services/src/export_pptx/text.rs create mode 100644 crates/op-host-services/src/export_pptx/units.rs create mode 100644 crates/op-host-services/src/export_pptx/xml.rs diff --git a/crates/op-host-desktop/src/persistence_export_pptx.rs b/crates/op-host-desktop/src/persistence_export_pptx.rs new file mode 100644 index 000000000..8fa481f92 --- /dev/null +++ b/crates/op-host-desktop/src/persistence_export_pptx.rs @@ -0,0 +1,118 @@ +//! Desktop flow for File ▸ "Export PowerPoint". +//! +//! One save picker, then one `.pptx` holding the whole deck as editable +//! slides. The package and the DrawingML both live in +//! `op_host_services::export_pptx`; this file owns only the dialogs. + +use op_host_native::WidgetHostNative; +use op_host_services::export_pptx::export_deck_pptx; +use op_i18n::Locale; +use std::path::Path; + +/// Default file name offered by the save picker. +const DEFAULT_FILE_NAME: &str = "openpencil-deck.pptx"; + +/// Run the whole flow: save picker → export → report. +pub fn handle_export_pptx(host: &mut WidgetHostNative) { + let locale = host.editor_state().editor_ui.locale; + let Some(path) = rfd::FileDialog::new() + .set_title(op_i18n::translate(locale, "dialog.pptxTitle")) + .add_filter("PowerPoint", &["pptx"]) + .set_file_name(DEFAULT_FILE_NAME) + .save_file() + else { + return; + }; + + match export_deck_pptx(host.editor_state(), &path) { + // The dialog reports slides only. The structured / rastered node + // split is diagnostic detail; the user wants to know the deck + // landed and where. + Ok(export) => info_dialog( + op_i18n::translate(locale, "dialog.pptxTitle"), + summary_body(locale, &path, export.slides), + rfd::MessageLevel::Info, + ), + Err(e) => { + eprintln!("[export-pptx] {e}"); + info_dialog( + op_i18n::translate(locale, "dialog.pptxTitle"), + failure_body(locale, &e), + rfd::MessageLevel::Warning, + ); + } + } +} + +/// Body of the success dialog: how many slides landed and where. +fn summary_body(locale: Locale, path: &Path, slides: usize) -> String { + let mut body = + op_i18n::translate(locale, "dialog.pptxSummary").replace("{{count}}", &slides.to_string()); + body.push_str("\n\n"); + body.push_str(&path.display().to_string()); + body +} + +/// Body of the failure dialog. An empty deck gets the sentence written +/// for it; every other failure reports the exporter's own message, which +/// names the node that could not be rendered. +fn failure_body(locale: Locale, error: &op_host_services::export::ExportError) -> String { + if matches!( + error, + op_host_services::export::ExportError::NothingToExport + ) { + return op_i18n::translate(locale, "dialog.pptxEmpty").to_string(); + } + let mut body = op_i18n::translate(locale, "dialog.exportErrorLead").to_string(); + body.push_str("\n\n"); + body.push_str(&error.to_string()); + body +} + +/// Pop the same style of native dialog `persistence::show_error_dialog` +/// uses, at a caller-chosen level (the happy path is not an error). +fn info_dialog(title: &str, body: String, level: rfd::MessageLevel) { + rfd::MessageDialog::new() + .set_title(title) + .set_description(&body) + .set_level(level) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +#[cfg(test)] +mod tests { + use super::*; + use op_host_services::export::ExportError; + + #[test] + fn a_clean_run_reports_the_count_and_the_file() { + let body = summary_body(Locale::EnUs, Path::new("/tmp/deck.pptx"), 6); + assert!(body.starts_with("Exported 6 slides to:"), "{body}"); + assert!(body.contains("/tmp/deck.pptx"), "{body}"); + } + + #[test] + fn the_summary_is_localised() { + let body = summary_body(Locale::ZhCn, Path::new("/tmp/deck.pptx"), 3); + assert!(body.contains("已导出 3 张幻灯片到:"), "{body}"); + } + + #[test] + fn an_empty_deck_gets_its_own_sentence_rather_than_the_raw_error() { + let body = failure_body(Locale::EnUs, &ExportError::NothingToExport); + assert_eq!(body, "This deck has no visible slides to export."); + assert!(!body.contains("nothing to export"), "{body}"); + } + + #[test] + fn other_failures_name_the_node_that_could_not_render() { + let body = failure_body( + Locale::EnUs, + &ExportError::NodePaintsNothing { + node_id: "slide-4".to_string(), + }, + ); + assert!(body.contains("slide-4 paints nothing"), "{body}"); + } +} diff --git a/crates/op-host-services/src/export_pptx.rs b/crates/op-host-services/src/export_pptx.rs new file mode 100644 index 000000000..3fbe2567b --- /dev/null +++ b/crates/op-host-services/src/export_pptx.rs @@ -0,0 +1,497 @@ +//! Structured PPTX export — the render + IO behind File ▸ "Export +//! PowerPoint". +//! +//! # What this is +//! +//! One `.pptx` in which every board is a slide and every text node is a +//! real PowerPoint text box. Someone who opens the file in PowerPoint, +//! Keynote or WPS can fix a typo, restyle a heading or reflow a bullet — +//! the deck arrives as a document, not as a folder of screenshots. +//! +//! It shares its底料 with [`crate::export_html_structured`]: the +//! resolved [`LayoutScene`](op_editor_ui::layout_scene::LayoutScene), +//! whose rects jian's taffy pass has already computed. **No layout is +//! ever recomputed here.** Coordinates come from `SceneNode::bounds`, +//! translated by the board origin and converted to EMU, and nothing +//! else. If PowerPoint disagrees with the editor about how wide a word +//! is, the word still starts at the same point on the slide. +//! +//! # Self-containment +//! +//! The package must present on a laptop with no network. Nothing here +//! may emit a URL: an image reaches a slide only as bytes inside +//! `ppt/media/`, and a `http(s)` source therefore takes the raster path +//! rather than riding as a link that will not resolve on stage. Fonts +//! are the documented exception — they are NAMED, not embedded, because +//! bundling faces would add megabytes to a file that gets emailed, and +//! every text box is pinned by absolute position so a substituted face +//! drifts inside its own box rather than across the slide. +//! +//! # The fallback invariant +//! +//! Every emitter may say "I cannot express this". When it does, +//! [`fallback::render`] paints that node alone through the shared scene +//! painter and embeds it as a picture at its exact rect, and its subtree +//! is not walked. So a node is never silently dropped and never guessed +//! at, and a board with nothing expressible degrades on its own to a +//! single full-slide image. + +use op_editor_core::preview_slideshow::active_page_boards; +use op_editor_core::EditorState; +use op_editor_ui::layout_scene::{NodeKind, SceneFillType, SceneImageFit, SceneNode, ScenePage}; +use op_editor_ui::{ImageAdjustments, ImageBlendMode, Point2D, Rect}; +use std::path::Path; + +use crate::export::ExportError; +use crate::export_html::board_name; + +mod fallback; +mod media; +mod package; +mod picture; +mod shape; +mod text; +mod units; +mod xml; + +use media::MediaLibrary; +use package::{MediaFile, SlidePart}; + +#[cfg(test)] +#[path = "export_pptx/tests.rs"] +mod tests; + +/// What one export produced. +/// +/// The fallback count is not decoration: a deck that came out mostly +/// rastered opens fine but is no longer editable, and that is worth +/// being able to see without unzipping the file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DeckPptxExport { + /// Slides written — what the host reports back to the user. + pub slides: usize, + /// Nodes emitted as real shapes across the whole deck. + pub structured_nodes: usize, + /// Nodes that had to be embedded as a picture instead. + pub raster_fallbacks: usize, +} + +/// Export the active page's boards as a PowerPoint deck at `target`. +/// +/// A board that fails to render aborts the whole export rather than +/// being dropped: the artifact is a single file whose slide numbering +/// would silently close over the hole, and the presenter would only +/// discover the missing slide on stage. +pub fn export_deck_pptx(state: &EditorState, target: &Path) -> Result { + let (bytes, summary) = build_deck_pptx(state)?; + std::fs::write(target, bytes).map_err(|e| ExportError::Write(e.to_string()))?; + Ok(summary) +} + +/// The package bytes plus the summary, without touching the filesystem. +pub fn build_deck_pptx(state: &EditorState) -> Result<(Vec, DeckPptxExport), ExportError> { + let scene = op_pen_loader::editor_state_to_active_page_layout_scene(state); + let page = scene.active_page().ok_or(ExportError::NoActivePage)?; + + let mut library = MediaLibrary::default(); + let mut slides: Vec = Vec::new(); + let mut slide_px: Option<(f32, f32)> = None; + let mut summary = DeckPptxExport::default(); + + for board_id in active_page_boards(state) { + let Some(node) = page.find(&board_id) else { + return Err(ExportError::NodeNotFoundOnPage { + node_id: board_id, + page_id: page.id.clone(), + }); + }; + // Hidden boards are skipped, not failed: hiding a board is the + // author saying it is not part of the deck. + if node.hidden { + continue; + } + let board = board_slide(page, &board_id, &mut library)?; + summary.structured_nodes += board.structured_nodes; + summary.raster_fallbacks += board.fallback_reasons.len(); + slide_px.get_or_insert((board.width, board.height)); + slides.push(SlidePart { + name: board_name(state, &board_id), + shapes: board.shapes, + media: board.media, + }); + } + if slides.is_empty() { + return Err(ExportError::NothingToExport); + } + summary.slides = slides.len(); + + let media: Vec = library.into_files(); + let bytes = package::build(slide_px.unwrap_or((1920.0, 1080.0)), &slides, &media)?; + Ok((bytes, summary)) +} + +/// One board's shapes, plus what it cost to get them. +struct BoardShapes { + shapes: String, + width: f32, + height: f32, + media: Vec, + structured_nodes: usize, + fallback_reasons: Vec<&'static str>, +} + +/// Build the shape list for one board. +/// +/// Coordinates in the result are board-local: the slide's top-left is +/// `(0, 0)`, so a board can be placed without knowing where on the +/// infinite canvas it happened to live. +fn board_slide( + page: &ScenePage, + board_id: &str, + library: &mut MediaLibrary, +) -> Result { + let board = page + .find(board_id) + .ok_or_else(|| ExportError::NodeNotFoundOnPage { + node_id: board_id.to_string(), + page_id: page.id.clone(), + })?; + if board.hidden { + return Err(ExportError::NodeHidden { + node_id: board_id.to_string(), + }); + } + let bounds = op_editor_ui::scene_bounds::normalize_rect(board.aggregate_bounds()); + if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { + return Err(ExportError::NodePaintsNothing { + node_id: board_id.to_string(), + }); + } + + let mut emitter = Emitter { + out: String::with_capacity(8192), + library, + media: Vec::new(), + // Shape id 1 is reserved for the slide's own group shape. + next_id: 2, + origin: bounds.origin, + structured_nodes: 0, + fallback_reasons: Vec::new(), + }; + emitter.emit(board, 1.0, true)?; + + Ok(BoardShapes { + shapes: emitter.out, + width: bounds.size.x, + height: bounds.size.y, + media: emitter.media, + structured_nodes: emitter.structured_nodes, + fallback_reasons: emitter.fallback_reasons, + }) +} + +struct Emitter<'a> { + out: String, + /// Package-wide media table — shared across slides so a logo on + /// every slide is stored once. + library: &'a mut MediaLibrary, + /// Media indices this slide references, in relationship order. + media: Vec, + next_id: u32, + /// Doc-space origin of the board. Shapes are flattened, so this is + /// the ONLY offset applied — unlike the HTML exporter, which tracks + /// the current parent because CSS resolves against it. + origin: Point2D, + structured_nodes: usize, + fallback_reasons: Vec<&'static str>, +} + +impl Emitter<'_> { + /// `is_board` marks the slide's own root frame, whose clipping is + /// the slide edge PowerPoint already enforces. + fn emit(&mut self, node: &SceneNode, alpha: f32, is_board: bool) -> Result<(), ExportError> { + if node.hidden { + return Ok(()); + } + if let Some(reason) = unexpressible(node, is_board) { + return self.raster(node, reason); + } + // An image is resolved before anything is written: a source that + // cannot become bytes has to take the raster path as a whole + // node, not leave a half-emitted picture behind. + let picture = match node.image_src.as_deref() { + Some(src) => match self.intern_image(src) { + Some(resolved) => Some(resolved), + None => return self.raster(node, "image bytes could not be embedded"), + }, + None => None, + }; + + let alpha = alpha * clamp_unit(node.composite_opacity); + let rect = self.local(node.bounds); + self.structured_nodes += 1; + match &node.kind { + NodeKind::Text => { + let id = self.take_id(); + text::emit(&mut self.out, node, rect, alpha, id); + } + NodeKind::Line => { + shape::emit_line(&mut self.out, node, self.origin, alpha, &mut self.next_id) + } + kind => { + if let Some((rel_id, source_px)) = picture { + // A `Fit` image leaves the box's margins empty, and + // the loader parks a neutral grey under every image + // node so a failed decode reads as a placeholder. + // Painting that colour first reproduces the canvas; + // for every other mode the bitmap covers it. + if node.image_fit == SceneImageFit::Fit && node.fill.is_some() { + shape::emit_box( + &mut self.out, + node, + rect, + alpha, + xml::Geom::Rect, + &mut self.next_id, + ); + } + let id = self.take_id(); + picture::emit(&mut self.out, node, rect, alpha, &rel_id, source_px, id); + } else if shape::paints_anything(node) { + let geom = if matches!(kind, NodeKind::Ellipse) { + xml::Geom::Ellipse + } else { + xml::Geom::Rect + }; + shape::emit_box(&mut self.out, node, rect, alpha, geom, &mut self.next_id); + } + self.emit_children(node, alpha)?; + } + } + Ok(()) + } + + /// Walk a container's children. + /// + /// Scene children are stored topmost-first (layer-panel order) and + /// the canvas painter walks them in reverse. `` order IS + /// paint order — a later shape covers an earlier one — so the same + /// reversal is what keeps the z-order the author sees. + fn emit_children(&mut self, node: &SceneNode, alpha: f32) -> Result<(), ExportError> { + for child in node.children.iter().rev() { + self.emit(child, alpha, false)?; + } + Ok(()) + } + + /// Render `node` alone into the media table and place it as a + /// picture. Its subtree is not walked afterwards. + fn raster(&mut self, node: &SceneNode, reason: &'static str) -> Result<(), ExportError> { + let Some((png, doc_rect)) = fallback::render(node)? else { + // Nothing paints, so nothing is lost by writing nothing — + // and it is not a fidelity note either. + return Ok(()); + }; + let index = self.library.intern("png", png); + let rel_id = self.rel_for(index); + let id = self.take_id(); + let rect = self.local(doc_rect); + picture::emit_raster(&mut self.out, &node.id, rect, &rel_id, id); + self.fallback_reasons.push(reason); + Ok(()) + } + + /// Decode an image source into the media table, returning its + /// slide-local relationship id and the bitmap's real pixel size. + /// + /// `None` for a source that cannot be embedded OR cannot be + /// measured: the placement maths for a covering image is stated in + /// percentages of the source, so an unmeasurable bitmap would have + /// to be cropped by guess. + fn intern_image(&mut self, src: &str) -> Option<(String, (f32, f32))> { + let (ext, bytes) = media::decode_data_url(src)?; + let size = media::image_size(ext, &bytes)?; + let index = self.library.intern(ext, bytes); + Some((self.rel_for(index), size)) + } + + /// The slide-local relationship id for a package media index, + /// registering it on this slide the first time it is used. + fn rel_for(&mut self, media_index: usize) -> String { + let position = match self.media.iter().position(|i| *i == media_index) { + Some(existing) => existing, + None => { + self.media.push(media_index); + self.media.len() - 1 + } + }; + package::slide_media_rel_id(position) + } + + fn take_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn local(&self, rect: Rect) -> Rect { + let normalized = op_editor_ui::scene_bounds::normalize_rect(rect); + Rect { + origin: Point2D::new( + normalized.origin.x - self.origin.x, + normalized.origin.y - self.origin.y, + ), + size: normalized.size, + } + } +} + +fn clamp_unit(v: f32) -> f32 { + if v.is_finite() { + v.clamp(0.0, 1.0) + } else { + 1.0 + } +} + +/// Why this node cannot be expressed as DrawingML, or `None` when it +/// can. +/// +/// The list is deliberately conservative: a paint feature DrawingML can +/// only approximate is still allowed through when the approximation is +/// bounded and documented (radial gradient extent, stroke alignment, +/// uneven corner radii), but anything whose visual result would be a +/// GUESS is refused here so the raster path can be exact instead. +fn unexpressible(n: &SceneNode, is_board: bool) -> Option<&'static str> { + use op_editor_ui::layout_scene::Effect; + + if n.is_mask || n.mask_type.is_some() { + // A mask reshapes its front siblings' pixels. DrawingML has no + // equivalent relationship between shapes. + return Some("mask"); + } + if n.widget.is_some() { + // Switch knobs, slider tracks, select chevrons — the canvas + // draws a composite visual from the widget descriptor that has + // no scene geometry to read back. + return Some("composite widget"); + } + if n.fill_layers.len() > 1 { + return Some("layered fill stack"); + } + if n.blend_mode != ImageBlendMode::Normal || n.image_blend_mode != ImageBlendMode::Normal { + // DrawingML composites shapes source-over and offers no + // per-shape blend operation at all. + return Some("blend mode"); + } + match n.fill_type { + SceneFillType::Shader => return Some("sksl shader fill"), + SceneFillType::MeshGradient => return Some("mesh gradient fill"), + SceneFillType::LinearGradient | SceneFillType::RadialGradient => { + if n.gradient.is_none() { + return Some("gradient fill without a resolved body"); + } + } + SceneFillType::Solid | SceneFillType::Image => {} + } + for effect in &n.effects { + match effect { + // `a:blur` is a different operation from the painter's + // Gaussian, and a background blur has no DrawingML spelling + // whatsoever. + Effect::Blur(b) if b.radius > 0.0 => return Some("layer blur"), + Effect::BackgroundBlur { radius } if *radius > 0.0 => return Some("background blur"), + _ => {} + } + } + match &n.kind { + // Preset geometry cannot state an arbitrary polygon or path. + // `custGeom` could, but the path grammar is long-tail enough + // (bezier segments, winding rules, imported SVG `d` strings) + // that a wrong curve is likelier than a right one; v1 rasters + // and keeps the option open. + NodeKind::Polygon => return Some("polygon geometry"), + NodeKind::Path => return Some("vector path"), + NodeKind::Other(tag) if tag == "icon_font" => return Some("icon glyph"), + NodeKind::Other(_) => return Some("unknown node kind"), + NodeKind::Ellipse => { + if n.arc_start_angle.is_some() || n.arc_sweep_angle.is_some() { + // Pie / donut arcs. `prstGeom ellipse` is a full oval. + return Some("ellipse arc"); + } + if n.arc_inner_radius.is_some_and(|r| r > 0.0) { + return Some("ellipse inner radius"); + } + } + _ => {} + } + if let Some(src) = n.image_src.as_deref() { + if let Some(reason) = image_unexpressible(n, src) { + return Some(reason); + } + } + if !is_board && clipping_bites(n) { + // A group does not clip in PowerPoint, and neither does a shape + // with children (there is no such thing — the tree is flat), so + // a container whose content actually overflows can only keep its + // clip by being painted. + return Some("clipped overflow"); + } + None +} + +fn image_unexpressible(n: &SceneNode, src: &str) -> Option<&'static str> { + if !src.trim_start().starts_with("data:") { + // The package has to present offline. A remote or filesystem + // reference is not reachable there; the raster path resolves it + // now, at export time, instead. + return Some("image source is not embedded bytes"); + } + if n.image_transform.is_some() { + // Figma's normalized-UV affine crop. `srcRect` is an + // axis-aligned inset and cannot express a rotation or shear of + // the sampled region. + return Some("image crop transform"); + } + if n.image_adjustments != ImageAdjustments::default() { + // Exposure / contrast / temperature curves are applied by the + // painter's colour matrix; DrawingML's `duotone` / `lum` are not + // the same curves. + return Some("image colour adjustments"); + } + if n.image_fit == SceneImageFit::Tile { + // `a:tile` states its frequency as a percentage of the SHAPE, + // while the scene states it as a scale of the source; without + // both the repeat lands at a visibly wrong size. + return Some("tiled image fill"); + } + None +} + +/// Whether a clipping container actually clips anything. +/// +/// Most `clipContent` frames in a deck are cards whose content fits, and +/// rasterising every one of them would cost the deck its editable text +/// for nothing. So the question asked is not "does this node clip" but +/// "does any child stick out" — and only then is the node painted. +fn clipping_bites(n: &SceneNode) -> bool { + if !n.clip_content || n.children.is_empty() { + return false; + } + // Half a pixel of slop: a child sized to its parent can land a + // rounding step outside it, and that is not an overflow anybody sees. + const SLOP: f32 = 0.5; + let rect = op_editor_ui::scene_bounds::normalize_rect(n.bounds); + if rect.size.x <= 0.0 || rect.size.y <= 0.0 { + return false; + } + n.children.iter().filter(|c| !c.hidden).any(|child| { + let b = op_editor_ui::scene_bounds::normalize_rect(child.visual_bounds()); + b.size.x > 0.0 + && b.size.y > 0.0 + && (b.origin.x < rect.origin.x - SLOP + || b.origin.y < rect.origin.y - SLOP + || b.origin.x + b.size.x > rect.origin.x + rect.size.x + SLOP + || b.origin.y + b.size.y > rect.origin.y + rect.size.y + SLOP) + }) +} diff --git a/crates/op-host-services/src/export_pptx/fallback.rs b/crates/op-host-services/src/export_pptx/fallback.rs new file mode 100644 index 000000000..0ec66079a --- /dev/null +++ b/crates/op-host-services/src/export_pptx/fallback.rs @@ -0,0 +1,114 @@ +//! The raster escape hatch — the invariant that makes the rest of the +//! module safe to write. +//! +//! Any node the structured emitters judge they cannot express is +//! rendered on its own through the shared scene painter (the same one +//! the editor canvas and the PNG export use) and embedded as a picture +//! pinned at the node's exact rect. Its subtree is NOT walked +//! afterwards: the raster already contains every descendant, so +//! recursing would double-paint them. +//! +//! Because the fallback is per node rather than per slide, a board that +//! is entirely unexpressible degrades on its own to a single full-slide +//! image — the artifact this exporter replaced — while a board with one +//! awkward node keeps editable text everywhere else. +//! +//! The geometry below deliberately mirrors +//! `export_html_structured::fallback`. The two exporters landed in +//! parallel and share the rule but not the code; if a third backend +//! appears, this is the pair to hoist. + +use op_editor_ui::layout_scene::{Effect, SceneNode}; +use op_editor_ui::{Point2D, Rect}; + +use crate::export::{paint_node, render_raster_bytes, ExportError, RasterFormat}; + +/// Fallback rasters render at 2x. +/// +/// Slides are projected up from their authored size, and structured +/// shapes stay sharp at any scale because they are vectors and live +/// text. A 1x raster beside them would be visibly the soft one. 2x costs +/// four times the bytes, but only on the nodes that took this path. +const FALLBACK_SCALE: f32 = 2.0; + +/// Render `node` alone as PNG bytes, with the doc-space rect the image +/// must be placed at. +/// +/// The rect is returned rather than recomputed by the caller because the +/// placement has to be the SAME rect the surface covers; deriving it +/// twice would make their agreement a coincidence. `None` means the node +/// has no positive-area painted bounds — there is nothing to rasterise. +pub fn render(node: &SceneNode) -> Result, Rect)>, ExportError> { + let Some(doc_rect) = raster_rect(node) else { + return Ok(None); + }; + let png = render_raster_bytes(doc_rect, RasterFormat::Png, FALLBACK_SCALE, 0.0, |canvas| { + paint_node(canvas, node); + })?; + Ok(Some((png, doc_rect))) +} + +/// The doc-space rect the fallback surface must cover. +/// +/// Starts from the node's paint-visible bounds (which already stop at a +/// clipping container and include unclipped descendant overflow), grows +/// them by whatever paints outside the silhouette — stroke half-width, +/// shadow reach, blur radius — and finally takes the axis-aligned +/// bounding box of that rect under the node's own rotation, because +/// `paint_node` applies the rotation itself and the surface has to be +/// big enough to hold the turned result. +fn raster_rect(node: &SceneNode) -> Option { + let base = op_editor_ui::scene_bounds::normalize_rect(node.visual_bounds()); + if base.size.x <= 0.0 || base.size.y <= 0.0 { + return None; + } + let mut pad = node.stroke.map_or(0.0, |s| s.width.max(0.0)); + for effect in &node.effects { + pad = pad.max(match effect { + Effect::DropShadow(s) if !s.inner => { + s.blur.max(0.0) + s.offset_x.abs().max(s.offset_y.abs()) + } + Effect::DropShadow(_) => 0.0, + Effect::Blur(b) => b.radius.max(0.0), + Effect::BackgroundBlur { .. } => 0.0, + }); + } + let padded = Rect { + origin: Point2D::new(base.origin.x - pad, base.origin.y - pad), + size: Point2D::new(base.size.x + pad * 2.0, base.size.y + pad * 2.0), + }; + Some(rotated_aabb(padded, node.rotation, node.aggregate_bounds())) +} + +/// AABB of `rect` rotated by `radians` about the centre of `pivot_of`. +fn rotated_aabb(rect: Rect, radians: f32, pivot_of: Rect) -> Rect { + if radians == 0.0 || !radians.is_finite() { + return rect; + } + let (cx, cy) = ( + pivot_of.origin.x + pivot_of.size.x * 0.5, + pivot_of.origin.y + pivot_of.size.y * 0.5, + ); + let (sin, cos) = radians.sin_cos(); + let corners = [ + (rect.origin.x, rect.origin.y), + (rect.origin.x + rect.size.x, rect.origin.y), + (rect.origin.x, rect.origin.y + rect.size.y), + (rect.origin.x + rect.size.x, rect.origin.y + rect.size.y), + ]; + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for (x, y) in corners { + let (dx, dy) = (x - cx, y - cy); + let rx = cx + dx * cos - dy * sin; + let ry = cy + dx * sin + dy * cos; + min_x = min_x.min(rx); + min_y = min_y.min(ry); + max_x = max_x.max(rx); + max_y = max_y.max(ry); + } + Rect { + origin: Point2D::new(min_x, min_y), + size: Point2D::new(max_x - min_x, max_y - min_y), + } +} diff --git a/crates/op-host-services/src/export_pptx/media.rs b/crates/op-host-services/src/export_pptx/media.rs new file mode 100644 index 000000000..c8ac04b9c --- /dev/null +++ b/crates/op-host-services/src/export_pptx/media.rs @@ -0,0 +1,281 @@ +//! Embedded bitmaps: getting bytes out of a scene node, keeping one +//! copy of each, and knowing how big the source actually is. +//! +//! A `.pptx` carries its pictures as real files inside the zip, so an +//! image reaches a slide only if this module can produce BYTES for it. +//! That is the whole reason `http(s)` sources take the raster path: the +//! export must present on a machine with no network, and a `blipFill` +//! pointing at a URL is not a picture, it is a promise. +//! +//! Source dimensions matter more here than in the HTML exporter. +//! `background-size:cover` is a CSS keyword; DrawingML has no keyword — +//! covering a box means computing the crop rectangle yourself, and the +//! only honest way to compute it is from the real pixel size of the +//! bitmap. Hence [`image_size`], which reads the dimensions straight out +//! of the encoded bytes. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash as _, Hasher as _}; + +use super::package::{content_type_for, MediaFile}; + +/// The package's media table, de-duplicated by content. +/// +/// A deck that repeats one logo on twenty slides carries the bytes once: +/// the loader hands the same `data:` URL to twenty nodes, and twenty +/// copies of a 400 KB PNG is an 8 MB file the presenter has to mail. +#[derive(Default)] +pub struct MediaLibrary { + files: Vec, + /// Content hash of `files[i]`, parallel by index. + hashes: Vec, +} + +impl MediaLibrary { + /// Add `bytes` (or find the identical bytes already present) and + /// return its index in the package media table. + pub fn intern(&mut self, ext: &'static str, bytes: Vec) -> usize { + let mut hasher = DefaultHasher::new(); + ext.hash(&mut hasher); + bytes.hash(&mut hasher); + let hash = hasher.finish(); + if let Some(existing) = self.hashes.iter().position(|h| *h == hash) { + // A 64-bit content hash collision between two distinct + // images is not a correctness risk worth a byte compare + // here; the images would have to be adversarially chosen. + return existing; + } + self.files.push(MediaFile { ext, bytes }); + self.hashes.push(hash); + self.files.len() - 1 + } + + pub fn into_files(self) -> Vec { + self.files + } +} + +/// Decode a `data:` URL into an embeddable extension + bytes. +/// +/// `None` for anything that is not base64 `data:` with a media type the +/// package can declare — a `data:image/svg+xml` payload is real bytes +/// but `[Content_Types].xml` has no entry PowerPoint would accept for a +/// `blip`, so it goes down the raster path with everything else. +pub fn decode_data_url(src: &str) -> Option<(&'static str, Vec)> { + use base64::Engine as _; + + let trimmed = src.trim(); + let rest = trimmed.strip_prefix("data:")?; + let (meta, payload) = rest.split_once(',')?; + if !meta.trim_end().ends_with(";base64") { + // A percent-encoded data URL is legal but never produced by the + // host's importers, and guessing at its decoding would be a + // silent way to embed the wrong bytes. + return None; + } + let mime = meta.split(';').next()?.trim().to_ascii_lowercase(); + let ext = extension_for_mime(&mime)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .ok()?; + if bytes.is_empty() { + return None; + } + Some((ext, bytes)) +} + +/// Media type → the package extension, gated on the extension being one +/// `[Content_Types].xml` can declare. +fn extension_for_mime(mime: &str) -> Option<&'static str> { + let ext = match mime { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpeg", + "image/gif" => "gif", + "image/webp" => "webp", + _ => return None, + }; + content_type_for(ext).map(|_| ext) +} + +/// Pixel dimensions read out of encoded image bytes, or `None` when the +/// format's header is not one this reader understands. +/// +/// Only the container headers are parsed — nothing is decoded — so the +/// cost is a handful of byte reads regardless of image size. +pub fn image_size(ext: &str, bytes: &[u8]) -> Option<(f32, f32)> { + match ext { + "png" => png_size(bytes), + "jpeg" => jpeg_size(bytes), + "gif" => gif_size(bytes), + "webp" => webp_size(bytes), + _ => None, + } +} + +fn be_u32(b: &[u8], at: usize) -> Option { + let slice = b.get(at..at + 4)?; + Some(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]])) +} + +fn be_u16(b: &[u8], at: usize) -> Option { + let slice = b.get(at..at + 2)?; + Some(u16::from_be_bytes([slice[0], slice[1]])) +} + +fn size_of(w: u32, h: u32) -> Option<(f32, f32)> { + if w > 0 && h > 0 { + Some((w as f32, h as f32)) + } else { + None + } +} + +/// PNG: the IHDR chunk is mandated to be first, so width and height sit +/// at fixed offsets 16 and 20. +fn png_size(b: &[u8]) -> Option<(f32, f32)> { + if !b.starts_with(&[0x89, b'P', b'N', b'G']) { + return None; + } + size_of(be_u32(b, 16)?, be_u32(b, 20)?) +} + +/// JPEG: walk the marker segments to the start-of-frame, which is the +/// only one carrying the frame size. SOF0..SOF15 all qualify except the +/// three that are not frame headers (DHT `C4`, JPG `C8`, DAC `CC`). +fn jpeg_size(b: &[u8]) -> Option<(f32, f32)> { + if !b.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut i = 2usize; + while i + 3 < b.len() { + if b[i] != 0xFF { + i += 1; + continue; + } + let marker = b[i + 1]; + // Padding fill bytes and the standalone markers carry no length. + if marker == 0xFF || (0xD0..=0xD9).contains(&marker) || marker == 0x01 { + i += 1; + continue; + } + let length = be_u16(b, i + 2)? as usize; + if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + let height = be_u16(b, i + 5)? as u32; + let width = be_u16(b, i + 7)? as u32; + return size_of(width, height); + } + i += 2 + length.max(2); + } + None +} + +/// GIF: the logical screen descriptor is fixed at offset 6, little +/// endian. +fn gif_size(b: &[u8]) -> Option<(f32, f32)> { + if !b.starts_with(b"GIF8") { + return None; + } + let w = u16::from_le_bytes([*b.get(6)?, *b.get(7)?]) as u32; + let h = u16::from_le_bytes([*b.get(8)?, *b.get(9)?]) as u32; + size_of(w, h) +} + +/// WEBP: the extended (`VP8X`) and simple-lossy (`VP8 `) headers. +/// Lossless (`VP8L`) packs its size into a bit stream and is left to the +/// raster path rather than bit-twiddled here. +fn webp_size(b: &[u8]) -> Option<(f32, f32)> { + if !b.starts_with(b"RIFF") || b.get(8..12)? != b"WEBP" { + return None; + } + match b.get(12..16)? { + b"VP8X" => { + let w = 1 + + u32::from(*b.get(24)?) + + (u32::from(*b.get(25)?) << 8) + + (u32::from(*b.get(26)?) << 16); + let h = 1 + + u32::from(*b.get(27)?) + + (u32::from(*b.get(28)?) << 8) + + (u32::from(*b.get(29)?) << 16); + size_of(w, h) + } + b"VP8 " => { + // The keyframe header starts after the 3-byte frame tag and + // the 3-byte start code. + if b.get(23..26)? != [0x9D, 0x01, 0x2A] { + return None; + } + let w = u16::from_le_bytes([*b.get(26)?, *b.get(27)?]) as u32 & 0x3FFF; + let h = u16::from_le_bytes([*b.get(28)?, *b.get(29)?]) as u32 & 0x3FFF; + size_of(w, h) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The smallest legal PNG header the size reader needs — signature + /// plus an IHDR declaring 800×600. + fn png_header(w: u32, h: u32) -> Vec { + let mut b = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + b.extend_from_slice(&13u32.to_be_bytes()); + b.extend_from_slice(b"IHDR"); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b + } + + #[test] + fn png_dimensions_come_from_the_ihdr() { + assert_eq!( + image_size("png", &png_header(800, 600)), + Some((800.0, 600.0)) + ); + } + + #[test] + fn a_data_url_decodes_to_bytes_and_a_declarable_extension() { + use base64::Engine as _; + let bytes = png_header(4, 2); + let url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ); + let (ext, decoded) = decode_data_url(&url).expect("decodes"); + assert_eq!(ext, "png"); + assert_eq!(decoded, bytes); + } + + #[test] + fn an_undeclarable_media_type_is_refused_rather_than_embedded() { + // SVG bytes are real, but no `[Content_Types]` default this + // package writes would let PowerPoint read them as a blip. + assert!(decode_data_url("data:image/svg+xml;base64,PHN2Zy8+").is_none()); + assert!(decode_data_url("https://example.com/a.png").is_none()); + assert!(decode_data_url("data:image/png,%89PNG").is_none()); + } + + #[test] + fn identical_bytes_are_stored_once() { + let mut library = MediaLibrary::default(); + let first = library.intern("png", vec![1, 2, 3]); + let again = library.intern("png", vec![1, 2, 3]); + let other = library.intern("png", vec![9, 9, 9]); + assert_eq!(first, again); + assert_ne!(first, other); + assert_eq!(library.into_files().len(), 2); + } + + #[test] + fn jpeg_dimensions_come_from_the_start_of_frame() { + // SOI, a skipped APP0 segment, then an SOF0 declaring 120×60. + let mut b = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]; + b.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); + b.extend_from_slice(&60u16.to_be_bytes()); + b.extend_from_slice(&120u16.to_be_bytes()); + assert_eq!(image_size("jpeg", &b), Some((120.0, 60.0))); + } +} diff --git a/crates/op-host-services/src/export_pptx/package.rs b/crates/op-host-services/src/export_pptx/package.rs new file mode 100644 index 000000000..787c9db80 --- /dev/null +++ b/crates/op-host-services/src/export_pptx/package.rs @@ -0,0 +1,428 @@ +//! The OPC container: which parts a `.pptx` must contain, how they +//! point at each other, and the zip they are written into. +//! +//! A PowerPoint file is an Open Packaging Convention zip — a set of XML +//! parts plus a relationship graph that says which part is reached from +//! which. Nothing here is about how a slide LOOKS; the drawing lives in +//! the slide parts the caller hands in. This module owns the scaffolding +//! those slides need to be openable at all: +//! +//! ```text +//! [Content_Types].xml what every part in the zip is +//! _rels/.rels package root → the presentation +//! ppt/presentation.xml slide size + slide order +//! ppt/slideMasters/… one empty master +//! ppt/slideLayouts/… one blank layout +//! ppt/theme/theme1.xml the theme the master is required to have +//! ppt/slides/slideN.xml the caller's drawing +//! ppt/media/imageN.… embedded bitmaps +//! ``` +//! +//! **The master and the layout are deliberately empty.** A PowerPoint +//! deck normally inherits placeholders, backgrounds and text styles from +//! its layout, and anything inherited is something that could disagree +//! with the canvas. Every element this exporter emits is absolutely +//! positioned on the slide itself with its own explicit formatting, so +//! the layout's only job is to exist — the schema requires a slide to +//! have one, and requires that one to have a master, and requires that +//! master to name a theme. + +use std::io::{Cursor, Write as _}; + +use crate::export::ExportError; + +use super::units::emu; + +/// One embedded bitmap, already decoded to bytes. +pub struct MediaFile { + /// Lower-case file extension without the dot (`png` / `jpeg` / …). + /// It drives both the part name and the `[Content_Types]` default, + /// so an extension with no declared type would make the package + /// unreadable — [`content_type_for`] is the single gate. + pub ext: &'static str, + pub bytes: Vec, +} + +/// One finished slide: its `` body plus the media it refers +/// to, in relationship-id order. +pub struct SlidePart { + /// The board's authored name. PowerPoint shows it in the outline and + /// the selection pane, so a slide stays identifiable as the board it + /// came from after the deck leaves OpenPencil. + pub name: String, + /// The `` / `` elements, in paint order. + pub shapes: String, + /// Indices into the package media table. Position `i` here is the + /// slide's `rId{i + 2}` — `rId1` is always the layout. + pub media: Vec, +} + +/// The `r:embed` id a slide uses for the `n`-th media file it carries. +/// +/// Slide relationship ids are per-part, so two slides embedding the same +/// picture each get their own id pointing at the one shared media part. +pub fn slide_media_rel_id(index: usize) -> String { + format!("rId{}", index + 2) +} + +/// MIME type for an embedded media extension, or `None` when the +/// exporter has no declaration for it — the caller must then rasterise +/// instead of embedding an undeclarable part. +pub fn content_type_for(ext: &str) -> Option<&'static str> { + Some(match ext { + "png" => "image/png", + "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => return None, + }) +} + +/// Assemble the whole package. `slide_px` is the board size the deck +/// presents at; see the note in `export_pptx.rs` about why it comes from +/// the first slide rather than being normalised to 4:3 or 16:9. +pub fn build( + slide_px: (f32, f32), + slides: &[SlidePart], + media: &[MediaFile], +) -> Result, ExportError> { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let put = |zip: &mut zip::ZipWriter>>, name: &str, bytes: &[u8]| { + zip.start_file(name, options) + .and_then(|()| zip.write_all(bytes).map_err(Into::into)) + .map_err(|e| ExportError::Write(e.to_string())) + }; + + put( + &mut zip, + "[Content_Types].xml", + content_types(slides.len(), media).as_bytes(), + )?; + put(&mut zip, "_rels/.rels", ROOT_RELS.as_bytes())?; + put( + &mut zip, + "ppt/presentation.xml", + presentation(slide_px, slides.len()).as_bytes(), + )?; + put( + &mut zip, + "ppt/_rels/presentation.xml.rels", + presentation_rels(slides.len()).as_bytes(), + )?; + put( + &mut zip, + "ppt/slideMasters/slideMaster1.xml", + SLIDE_MASTER.as_bytes(), + )?; + put( + &mut zip, + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + MASTER_RELS.as_bytes(), + )?; + put( + &mut zip, + "ppt/slideLayouts/slideLayout1.xml", + SLIDE_LAYOUT.as_bytes(), + )?; + put( + &mut zip, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + LAYOUT_RELS.as_bytes(), + )?; + put(&mut zip, "ppt/theme/theme1.xml", THEME.as_bytes())?; + + for (i, slide) in slides.iter().enumerate() { + let n = i + 1; + put( + &mut zip, + &format!("ppt/slides/slide{n}.xml"), + slide_xml(slide).as_bytes(), + )?; + put( + &mut zip, + &format!("ppt/slides/_rels/slide{n}.xml.rels"), + slide_rels(slide, media).as_bytes(), + )?; + } + for (i, file) in media.iter().enumerate() { + put( + &mut zip, + &format!("ppt/media/image{}.{}", i + 1, file.ext), + &file.bytes, + )?; + } + + let cursor = zip + .finish() + .map_err(|e| ExportError::Write(e.to_string()))?; + Ok(cursor.into_inner()) +} + +const XML_DECL: &str = "\n"; + +/// The three namespaces every presentation part declares. +const NS: &str = "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \ +xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \ +xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\""; + +const REL_NS: &str = "xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\""; + +const REL_BASE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +fn content_types(slide_count: usize, media: &[MediaFile]) -> String { + let mut out = String::from(XML_DECL); + out.push_str( + "\ +\ +", + ); + // One `Default` per DISTINCT media extension present. A media part + // whose extension has no declaration here is a part PowerPoint + // cannot type, and it refuses the whole file rather than skipping + // the picture — so the emitter only ever embeds extensions + // `content_type_for` knows. + let mut seen: Vec<&str> = Vec::new(); + for file in media { + if seen.contains(&file.ext) { + continue; + } + seen.push(file.ext); + if let Some(mime) = content_type_for(file.ext) { + out.push_str(&format!( + "", + file.ext + )); + } + } + let pml = "application/vnd.openxmlformats-officedocument.presentationml"; + out.push_str(&format!( + "\ +\ +\ +" + )); + for n in 1..=slide_count { + out.push_str(&format!( + "" + )); + } + out.push_str(""); + out +} + +const ROOT_RELS: &str = concat!( + "\n", + "", + "", + "" +); + +/// `ppt/presentation.xml` — the slide size and the slide ORDER. +/// +/// The order lives in `sldIdLst`, not in the part names: a deck whose +/// slides were listed out of order would present out of order even +/// though `slide3.xml` is the third file in the zip. Both are emitted +/// in the same loop index so they cannot disagree. +fn presentation(slide_px: (f32, f32), slide_count: usize) -> String { + let (w, h) = (emu(slide_px.0).max(1), emu(slide_px.1).max(1)); + let mut out = String::from(XML_DECL); + out.push_str(&format!("")); + out.push_str( + "", + ); + out.push_str(""); + for i in 0..slide_count { + // Slide ids are an arbitrary but stable key space starting at + // 256 (PowerPoint's own first value); the r:id is what actually + // resolves the part. + out.push_str(&format!( + "", + 256 + i, + i + 2 + )); + } + out.push_str(""); + out.push_str(&format!("")); + // Notes pages keep the stock US-Letter portrait size; nothing this + // exporter writes lands on one, but the element is required. + out.push_str(""); + out.push_str(""); + out +} + +fn presentation_rels(slide_count: usize) -> String { + let mut out = String::from(XML_DECL); + out.push_str(&format!("")); + out.push_str(&format!( + "" + )); + for i in 0..slide_count { + out.push_str(&format!( + "", + i + 2, + i + 1 + )); + } + out.push_str(&format!( + "", + slide_count + 2 + )); + out.push_str(""); + out +} + +fn slide_xml(slide: &SlidePart) -> String { + let mut out = String::from(XML_DECL); + out.push_str(&format!( + "", + op_util::xml_escape::escape_xml(&slide.name) + )); + out.push_str(EMPTY_TREE_HEAD); + out.push_str(&slide.shapes); + out.push_str(""); + out +} + +fn slide_rels(slide: &SlidePart, media: &[MediaFile]) -> String { + let mut out = String::from(XML_DECL); + out.push_str(&format!("")); + out.push_str(&format!( + "" + )); + for (i, media_index) in slide.media.iter().enumerate() { + let ext = media.get(*media_index).map(|f| f.ext).unwrap_or("png"); + out.push_str(&format!( + "", + slide_media_rel_id(i), + media_index + 1 + )); + } + out.push_str(""); + out +} + +/// The group-shape header every `` opens with. `id="1"` is +/// reserved for it, which is why emitted shapes start numbering at 2. +const EMPTY_TREE_HEAD: &str = "\ +\ +"; + +const SLIDE_MASTER: &str = concat!( + "\n", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" +); + +const MASTER_RELS: &str = concat!( + "\n", + "", + "", + "", + "" +); + +const SLIDE_LAYOUT: &str = concat!( + "\n", + "", + "", + "", + "", + "", + "", + "", + "" +); + +const LAYOUT_RELS: &str = concat!( + "\n", + "", + "", + "" +); + +/// A complete, schema-valid theme. +/// +/// Nothing the exporter emits references a theme colour, font or effect +/// style — every shape states its own — but `ECMA-376` requires the +/// master to have a theme part, and the theme's `fmtScheme` must carry +/// exactly three entries in each of its four style lists. This is the +/// smallest thing that satisfies both. +const THEME: &str = concat!( + "\n", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" +); diff --git a/crates/op-host-services/src/export_pptx/picture.rs b/crates/op-host-services/src/export_pptx/picture.rs new file mode 100644 index 000000000..6038d0f16 --- /dev/null +++ b/crates/op-host-services/src/export_pptx/picture.rs @@ -0,0 +1,194 @@ +//! Image nodes as `` — an embedded bitmap placed in the box the +//! layout gave it. +//! +//! CSS gets `background-size: cover` as a keyword. DrawingML has no +//! keyword: filling a box while keeping the aspect ratio means stating +//! the crop rectangle yourself, in percentages of the SOURCE image. That +//! is why the emitter needs the bitmap's real pixel size and why a +//! source it cannot measure goes down the raster path instead — a +//! guessed crop is a visibly wrong photograph, and the fallback renders +//! the same pixels the canvas does. + +use op_editor_ui::layout_scene::{SceneImageFit, SceneNode}; +use op_editor_ui::Rect; +use op_util::xml_escape::escape_xml; +use std::fmt::Write as _; + +use super::units::{pct_1000, signed_pct_1000}; +use super::xml::{effect_list, line_element, prst_geom, sp_pr, xfrm, Geom}; + +/// Emit an image node. `rel_id` is the slide-local relationship id of +/// the already-interned media part, `source_px` its decoded dimensions. +pub fn emit( + out: &mut String, + node: &SceneNode, + rect: Rect, + alpha: f32, + rel_id: &str, + source_px: (f32, f32), + id: u32, +) { + let line = node.stroke.and_then(|s| line_element(s, alpha)); + let _ = write!( + out, + "\ +{}{}", + escape_xml(&node.id), + blip_fill(node, rect, alpha, rel_id, source_px), + sp_pr( + &xfrm(rect, node), + &prst_geom(node, Geom::Rect, rect), + "", + line.as_deref(), + &effect_list(node, alpha) + ) + ); +} + +/// Emit a raster image that fills its rect exactly — the shape used by +/// the fallback path, where the PNG was rendered AT the rect. +pub fn emit_raster(out: &mut String, node_id: &str, rect: Rect, rel_id: &str, id: u32) { + use super::xml::xfrm_plain; + let _ = write!( + out, + "\ +\ +{}", + escape_xml(node_id), + xfrm_plain(rect) + ); +} + +/// ``: the bitmap, an optional source crop, and how the +/// (possibly cropped) source maps onto the shape. +fn blip_fill( + node: &SceneNode, + rect: Rect, + alpha: f32, + rel_id: &str, + source_px: (f32, f32), +) -> String { + // DrawingML has no picture opacity property either; `alphaModFix` on + // the blip is the one place an inherited composite opacity can land. + let fade = if alpha < 0.999 { + format!("", pct_1000(alpha)) + } else { + String::new() + }; + let blip = if fade.is_empty() { + format!("") + } else { + format!("{fade}") + }; + let (src_rect, fill_rect) = placement(node.image_fit, rect, source_px); + format!("{blip}{src_rect}{fill_rect}") +} + +/// The `` / `` pair for a placement mode. +/// +/// - **Fill / Crop** cover the box: the SOURCE is cropped to the box's +/// aspect ratio and centred, then stretched edge to edge. +/// - **Fit** contains: the whole source is kept and the FILL AREA is +/// inset so the image sits centred inside the box with the leftover +/// space empty. +/// - **Stretch** ignores the aspect ratio, which is what it means. +fn placement(fit: SceneImageFit, rect: Rect, source_px: (f32, f32)) -> (String, String) { + let (sw, sh) = source_px; + let box_aspect = rect.size.x / rect.size.y.max(0.001); + let src_aspect = sw / sh.max(0.001); + if !box_aspect.is_finite() || !src_aspect.is_finite() || box_aspect <= 0.0 || src_aspect <= 0.0 + { + return (String::new(), "".to_string()); + } + match fit { + SceneImageFit::Stretch | SceneImageFit::Tile => { + (String::new(), "".to_string()) + } + SceneImageFit::Fill | SceneImageFit::Crop => { + let (mut side, mut top) = (0.0f32, 0.0f32); + if src_aspect > box_aspect { + side = (1.0 - box_aspect / src_aspect) / 2.0; + } else { + top = (1.0 - src_aspect / box_aspect) / 2.0; + } + if side < 0.0005 && top < 0.0005 { + return (String::new(), "".to_string()); + } + ( + format!( + "", + pct_1000(side), + pct_1000(top), + pct_1000(side), + pct_1000(top) + ), + "".to_string(), + ) + } + SceneImageFit::Fit => { + let (mut side, mut top) = (0.0f32, 0.0f32); + if src_aspect > box_aspect { + top = (1.0 - box_aspect / src_aspect) / 2.0; + } else { + side = (1.0 - src_aspect / box_aspect) / 2.0; + } + if side < 0.0005 && top < 0.0005 { + return (String::new(), "".to_string()); + } + ( + String::new(), + format!( + "", + signed_pct_1000(side), + signed_pct_1000(top), + signed_pct_1000(side), + signed_pct_1000(top) + ), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wide_box() -> Rect { + Rect::xywh(0.0, 0.0, 400.0, 100.0) + } + + #[test] + fn covering_a_wide_box_with_a_square_source_crops_top_and_bottom() { + let (src, fill) = placement(SceneImageFit::Fill, wide_box(), (500.0, 500.0)); + // Box is 4:1, source 1:1 — keep a quarter of the height, + // 37.5% off each end. + assert_eq!(src, ""); + assert_eq!(fill, ""); + } + + #[test] + fn fitting_a_square_source_into_a_wide_box_insets_the_fill_area() { + let (src, fill) = placement(SceneImageFit::Fit, wide_box(), (500.0, 500.0)); + assert_eq!(src, ""); + // The square keeps the box height and takes a quarter of its + // width, so 37.5% is left empty each side. + assert_eq!( + fill, + "" + ); + } + + #[test] + fn a_source_that_already_matches_needs_no_crop_at_all() { + let (src, fill) = placement(SceneImageFit::Fill, wide_box(), (800.0, 200.0)); + assert_eq!(src, ""); + assert_eq!(fill, ""); + } + + #[test] + fn stretch_ignores_the_aspect_ratio_by_definition() { + let (src, fill) = placement(SceneImageFit::Stretch, wide_box(), (500.0, 500.0)); + assert_eq!(src, ""); + assert_eq!(fill, ""); + } +} diff --git a/crates/op-host-services/src/export_pptx/shape.rs b/crates/op-host-services/src/export_pptx/shape.rs new file mode 100644 index 000000000..c24d03a84 --- /dev/null +++ b/crates/op-host-services/src/export_pptx/shape.rs @@ -0,0 +1,330 @@ +//! The box family — frames, rectangles, groups and ellipses — plus +//! lines. +//! +//! Unlike the HTML exporter, shapes here are NOT nested. DrawingML's +//! group shape does not clip its children and gives no advantage in +//! placement (a group needs a child coordinate system stated twice), so +//! the walk flattens the scene into one list of absolutely placed +//! shapes in paint order. Document order in `` IS paint order, +//! which is the same contract the DOM has, so the ordering logic +//! carries over unchanged. +//! +//! One consequence is worth stating: a container that paints nothing of +//! its own — a group, or a transparent layout frame — emits no shape at +//! all. Its children are already positioned absolutely, so the wrapper +//! would be an empty rectangle in the selection pane and nothing else. + +use op_editor_ui::layout_scene::{SceneNode, SceneStroke, SceneStrokeAlign}; +use op_editor_ui::{Color, Point2D, Rect}; +use std::fmt::Write as _; + +use super::units::solid_fill; +use super::xml::{ + effect_list, fill_element, filled_rect, line_element, nv_sp_pr, prst_geom, sp_pr, xfrm, Geom, + EMPTY_TX_BODY, +}; + +/// Default stroke for a Line node that authored none (canvas parity). +const DEFAULT_LINE_WIDTH: f32 = 1.5; + +/// Whether the node paints anything of its own. +/// +/// A `false` here is what keeps a deck from carrying a hundred invisible +/// rectangles: the walker skips the shape and goes straight to the +/// children. +pub fn paints_anything(node: &SceneNode) -> bool { + node.fill.is_some() + || node.image_src.is_some() + || node.gradient.is_some() + || node.stroke.is_some_and(stroke_paints) + || !node.effects.is_empty() +} + +fn stroke_paints(stroke: SceneStroke) -> bool { + match stroke.sides { + Some(sides) => sides.iter().any(|w| *w > 0.0), + None => stroke.width > 0.0, + } +} + +/// Emit the node's own box. `next_id` is advanced once per shape written +/// (a per-side stroke costs one extra shape per painted side). +pub fn emit_box( + out: &mut String, + node: &SceneNode, + rect: Rect, + alpha: f32, + geom: Geom, + next_id: &mut u32, +) { + let uniform = uniform_stroke(node.stroke); + let line = uniform.and_then(|s| line_element(s, alpha)); + let id = take(next_id); + let _ = write!( + out, + "{}{}{}", + nv_sp_pr(id, &node.id, false), + sp_pr( + &xfrm(rect, node), + &prst_geom(node, geom, rect), + &fill_element(node, alpha), + line.as_deref(), + &effect_list(node, alpha) + ), + EMPTY_TX_BODY + ); + if uniform.is_none() { + if let Some(stroke) = node.stroke { + emit_side_strokes(out, node, rect, stroke, alpha, next_id); + } + } +} + +/// The stroke as a single uniform band, or `None` when the sides differ. +fn uniform_stroke(stroke: Option) -> Option { + let stroke = stroke?; + match stroke.sides { + None => (stroke.width > 0.0).then_some(stroke), + Some([t, r, b, l]) => { + let even = (r - t).abs() < 0.01 && (b - t).abs() < 0.01 && (l - t).abs() < 0.01; + (even && t > 0.0).then_some(SceneStroke { width: t, ..stroke }) + } + } +} + +/// Draw a per-side stroke as up to four filled bands. +/// +/// `` has one width for the whole outline, so the two obvious +/// options for a bottom-only divider are both wrong: a full outline puts +/// a box where the design has a rule, and rasterising the node turns its +/// entire subtree — headings, labels, the lot — into pixels to render +/// one hairline. A band is a rectangle at the exact place the canvas +/// strokes, it costs one shape per painted side, and everything inside +/// the container stays live text. +fn emit_side_strokes( + out: &mut String, + node: &SceneNode, + rect: Rect, + stroke: SceneStroke, + alpha: f32, + next_id: &mut u32, +) { + let Some([top, right, bottom, left]) = stroke.sides else { + return; + }; + let outset = |width: f32| match stroke.align { + SceneStrokeAlign::Inside => 0.0, + SceneStrokeAlign::Center => width * 0.5, + SceneStrokeAlign::Outside => width, + }; + let (x, y, w, h) = ( + rect.origin.x, + rect.origin.y, + rect.size.x.max(0.0), + rect.size.y.max(0.0), + ); + let bands = [ + (top, Rect::xywh(x, y - outset(top), w, top)), + ( + right, + Rect::xywh(x + w - right + outset(right), y, right, h), + ), + ( + bottom, + Rect::xywh(x, y + h - bottom + outset(bottom), w, bottom), + ), + (left, Rect::xywh(x - outset(left), y, left, h)), + ]; + for (width, band) in bands { + if width <= 0.0 || band.size.x <= 0.0 || band.size.y <= 0.0 { + continue; + } + out.push_str(&filled_rect( + take(next_id), + &node.id, + band, + stroke.color, + alpha, + )); + } +} + +/// Emit a Line node as a straight connector. +/// +/// `origin` is the board origin: the endpoints come from the node's +/// SIGNED bounds (a line running up-and-left has a negative extent), and +/// direction is carried by `flipH` / `flipV` rather than by normalising +/// the rect, which would silently reverse the line. +pub fn emit_line( + out: &mut String, + node: &SceneNode, + origin: Point2D, + alpha: f32, + next_id: &mut u32, +) { + let (color, width) = match node.stroke { + Some(s) if s.width > 0.0 => (s.color, s.width), + _ => (node.fill.unwrap_or(Color::BLACK), DEFAULT_LINE_WIDTH), + }; + let start = Point2D::new( + node.bounds.origin.x - origin.x, + node.bounds.origin.y - origin.y, + ); + let (dx, dy) = (node.bounds.size.x, node.bounds.size.y); + let box_rect = Rect::xywh( + start.x.min(start.x + dx), + start.y.min(start.y + dy), + dx.abs(), + dy.abs(), + ); + let mut attrs = String::new(); + if (dx < 0.0) != node.flip_x { + attrs.push_str(" flipH=\"1\""); + } + if (dy < 0.0) != node.flip_y { + attrs.push_str(" flipV=\"1\""); + } + let rot = super::units::rot_60k(node.rotation); + if rot != 0 { + attrs = format!(" rot=\"{rot}\"{attrs}"); + } + let stroke = SceneStroke { + color, + width, + sides: None, + align: SceneStrokeAlign::Center, + }; + let _ = write!( + out, + "\ +{}{}\ +", + take(next_id), + op_util::xml_escape::escape_xml(&node.id), + line_xfrm(box_rect, &attrs), + line_element(stroke, alpha).unwrap_or_else(|| solid_fill(color, alpha)) + ); +} + +/// A connector's own ``. +/// +/// It does not go through `xml::xfrm_plain` because a line legitimately +/// has a zero extent on one axis — a horizontal rule is exactly +/// `cy="0"` — while an area shape's extent is floored to one EMU so it +/// cannot vanish. +fn line_xfrm(rect: Rect, attrs: &str) -> String { + use super::units::emu; + format!( + "", + emu(rect.origin.x), + emu(rect.origin.y), + emu(rect.size.x).max(0), + emu(rect.size.y).max(0) + ) +} + +fn take(next_id: &mut u32) -> u32 { + let id = *next_id; + *next_id += 1; + id +} + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_ui::layout_scene::NodeKind; + + fn rect_node() -> SceneNode { + let mut n = SceneNode::leaf("r1", NodeKind::Rect); + n.bounds = Rect::xywh(0.0, 0.0, 100.0, 40.0); + n.fill = Some(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }); + n + } + + #[test] + fn an_empty_group_paints_nothing_and_is_skipped() { + let group = SceneNode::leaf("g1", NodeKind::Group); + assert!(!paints_anything(&group)); + assert!(paints_anything(&rect_node())); + } + + #[test] + fn a_uniform_stroke_rides_on_the_shape_itself() { + let mut n = rect_node(); + n.stroke = Some(SceneStroke { + color: Color::BLACK, + width: 2.0, + sides: None, + align: SceneStrokeAlign::Inside, + }); + let mut out = String::new(); + let mut id = 2; + emit_box(&mut out, &n, n.bounds, 1.0, Geom::Rect, &mut id); + assert!(out.contains(""), "{out}"); + // The box starts at the LEFT end (40), not at the authored x. + assert!( + out.contains(&format!("x=\"{}\"", super::super::units::emu(40.0))), + "{out}" + ); + } +} diff --git a/crates/op-host-services/src/export_pptx/tests.rs b/crates/op-host-services/src/export_pptx/tests.rs new file mode 100644 index 000000000..48c5c4c86 --- /dev/null +++ b/crates/op-host-services/src/export_pptx/tests.rs @@ -0,0 +1,451 @@ +//! End-to-end tests over the produced package. +//! +//! Every assertion here reads the actual zip rather than the emitter's +//! intermediate strings: a `.pptx` that is right in memory and wrong in +//! the container (a part nobody declared, a relationship pointing at a +//! file that is not there) opens as "PowerPoint found a problem with +//! this content", and that failure is invisible from the inside. + +use std::io::Read as _; + +use super::*; +use op_editor_core::scene_template_catalog::TemplateScene; + +/// A 1×1 opaque PNG — the smallest thing the media path can embed and +/// the size reader can measure. +const ONE_PX_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +fn deck_state(source: &str) -> EditorState { + let doc = jian_ops_schema::load_str(source) + .expect("fixture JSON parses") + .value; + let mut state = EditorState::from_document(doc); + state.editor_ui.scenario = Some(TemplateScene::Slides); + state +} + +fn two_board_deck() -> EditorState { + deck_state( + r##"{"version":"1.0.0","children":[ + {"type":"frame","id":"f1","name":"封面","x":0,"y":0,"width":1920,"height":1080, + "fill":[{"type":"solid","color":"#ff0000"}]}, + {"type":"frame","id":"f2","name":"步骤 1","x":2000,"y":0,"width":1920,"height":1080, + "fill":[{"type":"solid","color":"#00ff00"}]} + ]}"##, + ) +} + +fn image_deck() -> EditorState { + deck_state(&format!( + r##"{{"version":"1.0.0","children":[ + {{"type":"frame","id":"f1","name":"cover","x":0,"y":0,"width":1920,"height":1080, + "fill":[{{"type":"solid","color":"#ffffff"}}],"children":[ + {{"type":"image","id":"i1","x":100,"y":100,"width":400,"height":300, + "src":"{ONE_PX_PNG}"}} + ]}} + ]}}"## + )) +} + +/// Every part name in the package, in zip order. +fn part_names(bytes: &[u8]) -> Vec { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("valid zip"); + (0..zip.len()) + .map(|i| zip.by_index(i).expect("entry").name().to_string()) + .collect() +} + +/// One part's contents as text. +fn part(bytes: &[u8], name: &str) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("valid zip"); + let mut entry = zip + .by_name(name) + .unwrap_or_else(|_| panic!("part {name} is missing")); + let mut out = String::new(); + entry.read_to_string(&mut out).expect("text part"); + out +} + +fn export(state: &EditorState) -> (Vec, DeckPptxExport) { + build_deck_pptx(state).expect("deck exports") +} + +#[test] +fn the_package_carries_every_part_a_reader_will_look_for() { + let (bytes, summary) = export(&two_board_deck()); + + assert_eq!(summary.slides, 2); + let names = part_names(&bytes); + for required in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + ] { + assert!(names.contains(&required.to_string()), "missing {required}"); + } +} + +#[test] +fn every_relationship_target_resolves_to_a_part_that_exists() { + let (bytes, _) = export(&image_deck()); + let names = part_names(&bytes); + + for rels_part in names.iter().filter(|n| n.ends_with(".rels")) { + let xml = part(&bytes, rels_part); + let base = rels_part + .rsplit_once("_rels/") + .map(|(dir, _)| dir.to_string()) + .unwrap_or_default(); + for target in xml.split("Target=\"").skip(1) { + let target = target.split('"').next().expect("closing quote"); + let resolved = normalize(&format!("{base}{target}")); + assert!( + names.contains(&resolved), + "{rels_part} points at {resolved}, which is not in the package" + ); + } + } +} + +/// Collapse the `../` segments a relationship target uses. +fn normalize(path: &str) -> String { + let mut stack: Vec<&str> = Vec::new(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + stack.pop(); + } + other => stack.push(other), + } + } + stack.join("/") +} + +#[test] +fn one_slide_per_visible_board_in_document_order() { + let (bytes, _) = export(&two_board_deck()); + + let names = part_names(&bytes); + assert_eq!( + names + .iter() + .filter(|n| n.starts_with("ppt/slides/slide")) + .count(), + 2, + "two slide parts (their rels live under ppt/slides/_rels/)" + ); + // Board names identify which board became which slide, so a swap + // could not pass. + assert!(part(&bytes, "ppt/slides/slide1.xml").contains("name=\"封面\"")); + assert!(part(&bytes, "ppt/slides/slide2.xml").contains("name=\"步骤 1\"")); + let presentation = part(&bytes, "ppt/presentation.xml"); + let first = presentation.find("r:id=\"rId2\"").expect("first slide id"); + let second = presentation.find("r:id=\"rId3\"").expect("second slide id"); + assert!(first < second, "{presentation}"); +} + +#[test] +fn the_slide_size_is_the_board_size_in_emu() { + let (bytes, _) = export(&two_board_deck()); + + let presentation = part(&bytes, "ppt/presentation.xml"); + // 1920 x 1080 px at 9525 EMU per px — a real 16:9 slide, not a + // rescaled one. + assert!( + presentation.contains(""), + "{presentation}" + ); +} + +#[test] +fn no_emitted_number_reaches_scientific_notation() { + let (bytes, _) = export(&two_board_deck()); + + for name in part_names(&bytes) { + if !name.ends_with(".xml") && !name.ends_with(".rels") { + continue; + } + let xml = part(&bytes, &name); + for attr in ["x=\"", "y=\"", "cx=\"", "cy=\"", "sz=\"", "w=\""] { + for value in xml.split(attr).skip(1) { + let value = value.split('"').next().unwrap_or(""); + assert!( + !value.contains('e') && !value.contains('.'), + "{name} has a non-integer {attr}{value}" + ); + } + } + } +} + +#[test] +fn slide_text_lands_as_a_real_text_box_not_as_pixels() { + let state = deck_state( + r##"{"version":"1.0.0","children":[ + {"type":"frame","id":"f1","name":"cover","x":0,"y":0,"width":1920,"height":1080, + "fill":[{"type":"solid","color":"#ffffff"}],"children":[ + {"type":"text","id":"t1","x":100,"y":100,"width":800,"height":60, + "content":"Quarterly Review","fontSize":32,"fontWeight":"700", + "fontFamily":"Noto Sans SC","fill":[{"type":"solid","color":"#101828"}]} + ]} + ]}"##, + ); + + let (bytes, summary) = export(&state); + + assert_eq!(summary.raster_fallbacks, 0); + let slide = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide.contains("Quarterly Review"), "{slide}"); + assert!(slide.contains("sz=\"2400\""), "32 px is 24 pt: {slide}"); + assert!(slide.contains("b=\"1\""), "{slide}"); + assert!(slide.contains(""), "{slide}"); + assert!( + slide.contains(""), + "{slide}" + ); + assert!(slide.contains("txBox=\"1\""), "{slide}"); + assert!(!slide.contains("data:image"), "{slide}"); +} + +#[test] +fn markup_in_authored_text_and_names_is_escaped() { + let state = deck_state( + r##"{"version":"1.0.0","children":[ + {"type":"frame","id":"f1","name":"