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":"