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
This commit is contained in:
parent
12bb05243f
commit
cacb5c98d2
118
crates/op-host-desktop/src/persistence_export_pptx.rs
Normal file
118
crates/op-host-desktop/src/persistence_export_pptx.rs
Normal file
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
497
crates/op-host-services/src/export_pptx.rs
Normal file
497
crates/op-host-services/src/export_pptx.rs
Normal file
|
|
@ -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<DeckPptxExport, ExportError> {
|
||||
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<u8>, 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<SlidePart> = 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<MediaFile> = 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<usize>,
|
||||
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<BoardShapes, ExportError> {
|
||||
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<usize>,
|
||||
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. `<p:spTree>` 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)
|
||||
})
|
||||
}
|
||||
114
crates/op-host-services/src/export_pptx/fallback.rs
Normal file
114
crates/op-host-services/src/export_pptx/fallback.rs
Normal file
|
|
@ -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<Option<(Vec<u8>, 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<Rect> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
281
crates/op-host-services/src/export_pptx/media.rs
Normal file
281
crates/op-host-services/src/export_pptx/media.rs
Normal file
|
|
@ -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<MediaFile>,
|
||||
/// Content hash of `files[i]`, parallel by index.
|
||||
hashes: Vec<u64>,
|
||||
}
|
||||
|
||||
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<u8>) -> 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<MediaFile> {
|
||||
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<u8>)> {
|
||||
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<u32> {
|
||||
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<u16> {
|
||||
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<u8> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
428
crates/op-host-services/src/export_pptx/package.rs
Normal file
428
crates/op-host-services/src/export_pptx/package.rs
Normal file
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
/// One finished slide: its `<p:spTree>` 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 `<p:sp>` / `<p:pic>` 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, 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<Cursor<Vec<u8>>>, 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 = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\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(
|
||||
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
|
||||
<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\
|
||||
<Default Extension=\"xml\" ContentType=\"application/xml\"/>",
|
||||
);
|
||||
// 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!(
|
||||
"<Default Extension=\"{}\" ContentType=\"{mime}\"/>",
|
||||
file.ext
|
||||
));
|
||||
}
|
||||
}
|
||||
let pml = "application/vnd.openxmlformats-officedocument.presentationml";
|
||||
out.push_str(&format!(
|
||||
"<Override PartName=\"/ppt/presentation.xml\" ContentType=\"{pml}.presentation.main+xml\"/>\
|
||||
<Override PartName=\"/ppt/slideMasters/slideMaster1.xml\" ContentType=\"{pml}.slideMaster+xml\"/>\
|
||||
<Override PartName=\"/ppt/slideLayouts/slideLayout1.xml\" ContentType=\"{pml}.slideLayout+xml\"/>\
|
||||
<Override PartName=\"/ppt/theme/theme1.xml\" \
|
||||
ContentType=\"application/vnd.openxmlformats-officedocument.theme+xml\"/>"
|
||||
));
|
||||
for n in 1..=slide_count {
|
||||
out.push_str(&format!(
|
||||
"<Override PartName=\"/ppt/slides/slide{n}.xml\" ContentType=\"{pml}.slide+xml\"/>"
|
||||
));
|
||||
}
|
||||
out.push_str("</Types>");
|
||||
out
|
||||
}
|
||||
|
||||
const ROOT_RELS: &str = concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
||||
"<Relationship Id=\"rId1\" ",
|
||||
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" ",
|
||||
"Target=\"ppt/presentation.xml\"/>",
|
||||
"</Relationships>"
|
||||
);
|
||||
|
||||
/// `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!("<p:presentation {NS} saveSubsetFonts=\"1\">"));
|
||||
out.push_str(
|
||||
"<p:sldMasterIdLst><p:sldMasterId id=\"2147483648\" r:id=\"rId1\"/></p:sldMasterIdLst>",
|
||||
);
|
||||
out.push_str("<p:sldIdLst>");
|
||||
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!(
|
||||
"<p:sldId id=\"{}\" r:id=\"rId{}\"/>",
|
||||
256 + i,
|
||||
i + 2
|
||||
));
|
||||
}
|
||||
out.push_str("</p:sldIdLst>");
|
||||
out.push_str(&format!("<p:sldSz cx=\"{w}\" cy=\"{h}\"/>"));
|
||||
// Notes pages keep the stock US-Letter portrait size; nothing this
|
||||
// exporter writes lands on one, but the element is required.
|
||||
out.push_str("<p:notesSz cx=\"6858000\" cy=\"9144000\"/>");
|
||||
out.push_str("</p:presentation>");
|
||||
out
|
||||
}
|
||||
|
||||
fn presentation_rels(slide_count: usize) -> String {
|
||||
let mut out = String::from(XML_DECL);
|
||||
out.push_str(&format!("<Relationships {REL_NS}>"));
|
||||
out.push_str(&format!(
|
||||
"<Relationship Id=\"rId1\" Type=\"{REL_BASE}/slideMaster\" \
|
||||
Target=\"slideMasters/slideMaster1.xml\"/>"
|
||||
));
|
||||
for i in 0..slide_count {
|
||||
out.push_str(&format!(
|
||||
"<Relationship Id=\"rId{}\" Type=\"{REL_BASE}/slide\" Target=\"slides/slide{}.xml\"/>",
|
||||
i + 2,
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"<Relationship Id=\"rId{}\" Type=\"{REL_BASE}/theme\" Target=\"theme/theme1.xml\"/>",
|
||||
slide_count + 2
|
||||
));
|
||||
out.push_str("</Relationships>");
|
||||
out
|
||||
}
|
||||
|
||||
fn slide_xml(slide: &SlidePart) -> String {
|
||||
let mut out = String::from(XML_DECL);
|
||||
out.push_str(&format!(
|
||||
"<p:sld {NS}><p:cSld name=\"{}\"><p:spTree>",
|
||||
op_util::xml_escape::escape_xml(&slide.name)
|
||||
));
|
||||
out.push_str(EMPTY_TREE_HEAD);
|
||||
out.push_str(&slide.shapes);
|
||||
out.push_str("</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>");
|
||||
out
|
||||
}
|
||||
|
||||
fn slide_rels(slide: &SlidePart, media: &[MediaFile]) -> String {
|
||||
let mut out = String::from(XML_DECL);
|
||||
out.push_str(&format!("<Relationships {REL_NS}>"));
|
||||
out.push_str(&format!(
|
||||
"<Relationship Id=\"rId1\" Type=\"{REL_BASE}/slideLayout\" \
|
||||
Target=\"../slideLayouts/slideLayout1.xml\"/>"
|
||||
));
|
||||
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!(
|
||||
"<Relationship Id=\"{}\" Type=\"{REL_BASE}/image\" Target=\"../media/image{}.{ext}\"/>",
|
||||
slide_media_rel_id(i),
|
||||
media_index + 1
|
||||
));
|
||||
}
|
||||
out.push_str("</Relationships>");
|
||||
out
|
||||
}
|
||||
|
||||
/// The group-shape header every `<p:spTree>` opens with. `id="1"` is
|
||||
/// reserved for it, which is why emitted shapes start numbering at 2.
|
||||
const EMPTY_TREE_HEAD: &str = "<p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/>\
|
||||
</p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>\
|
||||
<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>";
|
||||
|
||||
const SLIDE_MASTER: &str = concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<p:sldMaster 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\">",
|
||||
"<p:cSld><p:bg><p:bgPr><a:solidFill><a:schemeClr val=\"bg1\"/></a:solidFill>",
|
||||
"<a:effectLst/></p:bgPr></p:bg><p:spTree>",
|
||||
"<p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>",
|
||||
"<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>",
|
||||
"<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>",
|
||||
"</p:spTree></p:cSld>",
|
||||
"<p:clrMap bg1=\"lt1\" tx1=\"dk1\" bg2=\"lt2\" tx2=\"dk2\" accent1=\"accent1\" ",
|
||||
"accent2=\"accent2\" accent3=\"accent3\" accent4=\"accent4\" accent5=\"accent5\" ",
|
||||
"accent6=\"accent6\" hlink=\"hlink\" folHlink=\"folHlink\"/>",
|
||||
"<p:sldLayoutIdLst><p:sldLayoutId id=\"2147483649\" r:id=\"rId1\"/></p:sldLayoutIdLst>",
|
||||
"</p:sldMaster>"
|
||||
);
|
||||
|
||||
const MASTER_RELS: &str = concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
||||
"<Relationship Id=\"rId1\" ",
|
||||
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout\" ",
|
||||
"Target=\"../slideLayouts/slideLayout1.xml\"/>",
|
||||
"<Relationship Id=\"rId2\" ",
|
||||
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme\" ",
|
||||
"Target=\"../theme/theme1.xml\"/>",
|
||||
"</Relationships>"
|
||||
);
|
||||
|
||||
const SLIDE_LAYOUT: &str = concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<p:sldLayout 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\" ",
|
||||
"type=\"blank\" preserve=\"1\">",
|
||||
"<p:cSld name=\"Blank\"><p:spTree>",
|
||||
"<p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>",
|
||||
"<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>",
|
||||
"<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>",
|
||||
"</p:spTree></p:cSld>",
|
||||
"<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>",
|
||||
"</p:sldLayout>"
|
||||
);
|
||||
|
||||
const LAYOUT_RELS: &str = concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
||||
"<Relationship Id=\"rId1\" ",
|
||||
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster\" ",
|
||||
"Target=\"../slideMasters/slideMaster1.xml\"/>",
|
||||
"</Relationships>"
|
||||
);
|
||||
|
||||
/// 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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
|
||||
"<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" ",
|
||||
"name=\"OpenPencil\"><a:themeElements>",
|
||||
"<a:clrScheme name=\"OpenPencil\">",
|
||||
"<a:dk1><a:sysClr val=\"windowText\" lastClr=\"000000\"/></a:dk1>",
|
||||
"<a:lt1><a:sysClr val=\"window\" lastClr=\"FFFFFF\"/></a:lt1>",
|
||||
"<a:dk2><a:srgbClr val=\"44546A\"/></a:dk2>",
|
||||
"<a:lt2><a:srgbClr val=\"E7E6E6\"/></a:lt2>",
|
||||
"<a:accent1><a:srgbClr val=\"4472C4\"/></a:accent1>",
|
||||
"<a:accent2><a:srgbClr val=\"ED7D31\"/></a:accent2>",
|
||||
"<a:accent3><a:srgbClr val=\"A5A5A5\"/></a:accent3>",
|
||||
"<a:accent4><a:srgbClr val=\"FFC000\"/></a:accent4>",
|
||||
"<a:accent5><a:srgbClr val=\"5B9BD5\"/></a:accent5>",
|
||||
"<a:accent6><a:srgbClr val=\"70AD47\"/></a:accent6>",
|
||||
"<a:hlink><a:srgbClr val=\"0563C1\"/></a:hlink>",
|
||||
"<a:folHlink><a:srgbClr val=\"954F72\"/></a:folHlink>",
|
||||
"</a:clrScheme>",
|
||||
"<a:fontScheme name=\"OpenPencil\">",
|
||||
"<a:majorFont><a:latin typeface=\"Calibri Light\"/><a:ea typeface=\"\"/>",
|
||||
"<a:cs typeface=\"\"/></a:majorFont>",
|
||||
"<a:minorFont><a:latin typeface=\"Calibri\"/><a:ea typeface=\"\"/>",
|
||||
"<a:cs typeface=\"\"/></a:minorFont>",
|
||||
"</a:fontScheme>",
|
||||
"<a:fmtScheme name=\"OpenPencil\">",
|
||||
"<a:fillStyleLst>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"</a:fillStyleLst>",
|
||||
"<a:lnStyleLst>",
|
||||
"<a:ln w=\"6350\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\">",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
|
||||
"<a:ln w=\"12700\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\">",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
|
||||
"<a:ln w=\"19050\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\">",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/></a:ln>",
|
||||
"</a:lnStyleLst>",
|
||||
"<a:effectStyleLst>",
|
||||
"<a:effectStyle><a:effectLst/></a:effectStyle>",
|
||||
"<a:effectStyle><a:effectLst/></a:effectStyle>",
|
||||
"<a:effectStyle><a:effectLst/></a:effectStyle>",
|
||||
"</a:effectStyleLst>",
|
||||
"<a:bgFillStyleLst>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>",
|
||||
"</a:bgFillStyleLst>",
|
||||
"</a:fmtScheme></a:themeElements></a:theme>"
|
||||
);
|
||||
194
crates/op-host-services/src/export_pptx/picture.rs
Normal file
194
crates/op-host-services/src/export_pptx/picture.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
//! Image nodes as `<p:pic>` — 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,
|
||||
"<p:pic><p:nvPicPr><p:cNvPr id=\"{id}\" name=\"{}\"/><p:cNvPicPr>\
|
||||
<a:picLocks noChangeAspect=\"0\"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>{}{}</p:pic>",
|
||||
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,
|
||||
"<p:pic><p:nvPicPr><p:cNvPr id=\"{id}\" name=\"{}\"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>\
|
||||
<p:blipFill><a:blip r:embed=\"{rel_id}\"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>\
|
||||
<p:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></p:spPr></p:pic>",
|
||||
escape_xml(node_id),
|
||||
xfrm_plain(rect)
|
||||
);
|
||||
}
|
||||
|
||||
/// `<p:blipFill>`: 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!("<a:alphaModFix amt=\"{}\"/>", pct_1000(alpha))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let blip = if fade.is_empty() {
|
||||
format!("<a:blip r:embed=\"{rel_id}\"/>")
|
||||
} else {
|
||||
format!("<a:blip r:embed=\"{rel_id}\">{fade}</a:blip>")
|
||||
};
|
||||
let (src_rect, fill_rect) = placement(node.image_fit, rect, source_px);
|
||||
format!("<p:blipFill rotWithShape=\"1\">{blip}{src_rect}<a:stretch>{fill_rect}</a:stretch></p:blipFill>")
|
||||
}
|
||||
|
||||
/// The `<a:srcRect>` / `<a:fillRect>` 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(), "<a:fillRect/>".to_string());
|
||||
}
|
||||
match fit {
|
||||
SceneImageFit::Stretch | SceneImageFit::Tile => {
|
||||
(String::new(), "<a:fillRect/>".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(), "<a:fillRect/>".to_string());
|
||||
}
|
||||
(
|
||||
format!(
|
||||
"<a:srcRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>",
|
||||
pct_1000(side),
|
||||
pct_1000(top),
|
||||
pct_1000(side),
|
||||
pct_1000(top)
|
||||
),
|
||||
"<a:fillRect/>".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(), "<a:fillRect/>".to_string());
|
||||
}
|
||||
(
|
||||
String::new(),
|
||||
format!(
|
||||
"<a:fillRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>",
|
||||
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, "<a:srcRect l=\"0\" t=\"37500\" r=\"0\" b=\"37500\"/>");
|
||||
assert_eq!(fill, "<a:fillRect/>");
|
||||
}
|
||||
|
||||
#[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,
|
||||
"<a:fillRect l=\"37500\" t=\"0\" r=\"37500\" b=\"0\"/>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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, "<a:fillRect/>");
|
||||
}
|
||||
|
||||
#[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, "<a:fillRect/>");
|
||||
}
|
||||
}
|
||||
330
crates/op-host-services/src/export_pptx/shape.rs
Normal file
330
crates/op-host-services/src/export_pptx/shape.rs
Normal file
|
|
@ -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 `<p:spTree>` 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,
|
||||
"<p:sp>{}{}{}</p:sp>",
|
||||
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<SceneStroke>) -> Option<SceneStroke> {
|
||||
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.
|
||||
///
|
||||
/// `<a:ln>` 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,
|
||||
"<p:cxnSp><p:nvCxnSpPr><p:cNvPr id=\"{}\" name=\"{}\"/><p:cNvCxnSpPr/><p:nvPr/>\
|
||||
</p:nvCxnSpPr><p:spPr>{}<a:prstGeom prst=\"line\"><a:avLst/></a:prstGeom>{}</p:spPr>\
|
||||
</p:cxnSp>",
|
||||
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 `<a:xfrm>`.
|
||||
///
|
||||
/// 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!(
|
||||
"<a:xfrm{attrs}><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></a:xfrm>",
|
||||
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("<a:ln w=\"19050\""), "{out}");
|
||||
assert_eq!(id, 3, "one shape only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_only_border_becomes_a_band_not_a_full_outline() {
|
||||
let mut n = rect_node();
|
||||
n.stroke = Some(SceneStroke {
|
||||
color: Color::BLACK,
|
||||
width: 1.0,
|
||||
sides: Some([0.0, 0.0, 1.0, 0.0]),
|
||||
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("<a:ln "), "no full outline: {out}");
|
||||
assert_eq!(id, 4, "the box plus one band");
|
||||
// The band sits on the bottom edge: y = 40 - 1 px.
|
||||
assert!(
|
||||
out.contains(&format!("y=\"{}\"", super::super::units::emu(39.0))),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_evenly_specified_per_side_stroke_is_still_one_outline() {
|
||||
let mut n = rect_node();
|
||||
n.stroke = Some(SceneStroke {
|
||||
color: Color::BLACK,
|
||||
width: 0.0,
|
||||
sides: Some([2.0, 2.0, 2.0, 2.0]),
|
||||
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("<a:ln w=\"19050\""), "{out}");
|
||||
assert_eq!(id, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_running_backwards_keeps_its_direction_through_a_flip() {
|
||||
let mut n = SceneNode::leaf("l1", NodeKind::Line);
|
||||
n.bounds = Rect::xywh(100.0, 50.0, -60.0, 0.0);
|
||||
n.stroke = Some(SceneStroke {
|
||||
color: Color::BLACK,
|
||||
width: 2.0,
|
||||
sides: None,
|
||||
align: SceneStrokeAlign::Center,
|
||||
});
|
||||
let mut out = String::new();
|
||||
let mut id = 2;
|
||||
emit_line(&mut out, &n, Point2D::new(0.0, 0.0), 1.0, &mut id);
|
||||
assert!(out.contains("flipH=\"1\""), "{out}");
|
||||
assert!(out.contains("<p:cxnSp>"), "{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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
451
crates/op-host-services/src/export_pptx/tests.rs
Normal file
451
crates/op-host-services/src/export_pptx/tests.rs
Normal file
|
|
@ -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<String> {
|
||||
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<u8>, 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("<p:sldSz cx=\"18288000\" cy=\"10287000\"/>"),
|
||||
"{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("<a:t>Quarterly Review</a:t>"), "{slide}");
|
||||
assert!(slide.contains("sz=\"2400\""), "32 px is 24 pt: {slide}");
|
||||
assert!(slide.contains("b=\"1\""), "{slide}");
|
||||
assert!(slide.contains("<a:srgbClr val=\"101828\"/>"), "{slide}");
|
||||
assert!(
|
||||
slide.contains("<a:latin typeface=\"Noto Sans SC\"/>"),
|
||||
"{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":"<script> & \"quotes\"","x":0,"y":0,
|
||||
"width":1920,"height":1080,"fill":[{"type":"solid","color":"#ffffff"}],
|
||||
"children":[
|
||||
{"type":"text","id":"t1","x":10,"y":10,"width":900,"height":60,
|
||||
"content":"a < b & c","fontSize":24,
|
||||
"fill":[{"type":"solid","color":"#000000"}]}
|
||||
]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, _) = export(&state);
|
||||
|
||||
let slide = part(&bytes, "ppt/slides/slide1.xml");
|
||||
assert!(slide.contains("<a:t>a < b & c</a:t>"), "{slide}");
|
||||
assert!(
|
||||
slide.contains("name=\"<script> & "quotes"\""),
|
||||
"{slide}"
|
||||
);
|
||||
assert!(
|
||||
!slide.contains("<script>"),
|
||||
"raw board name leaked: {slide}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_board_is_skipped_rather_than_failing_the_export() {
|
||||
let state = deck_state(
|
||||
r##"{"version":"1.0.0","children":[
|
||||
{"type":"frame","id":"f1","name":"one","x":0,"y":0,"width":1920,"height":1080,
|
||||
"fill":[{"type":"solid","color":"#ff0000"}]},
|
||||
{"type":"frame","id":"f2","name":"skipped","x":2000,"y":0,"width":1920,"height":1080,
|
||||
"visible":false,"fill":[{"type":"solid","color":"#00ff00"}]},
|
||||
{"type":"frame","id":"f3","name":"two","x":4000,"y":0,"width":1920,"height":1080,
|
||||
"fill":[{"type":"solid","color":"#0000ff"}]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, summary) = export(&state);
|
||||
|
||||
assert_eq!(
|
||||
summary.slides, 2,
|
||||
"the hidden board must not become a slide"
|
||||
);
|
||||
let names = part_names(&bytes);
|
||||
assert!(!names.contains(&"ppt/slides/slide3.xml".to_string()));
|
||||
assert!(!part(&bytes, "ppt/slides/slide2.xml").contains("name=\"skipped\""));
|
||||
assert!(part(&bytes, "ppt/slides/slide2.xml").contains("name=\"two\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deck_with_no_visible_board_refuses_to_write_a_file() {
|
||||
let state = deck_state(
|
||||
r##"{"version":"1.0.0","children":[
|
||||
{"type":"frame","id":"f1","name":"gone","x":0,"y":0,"width":1920,"height":1080,
|
||||
"visible":false,"fill":[{"type":"solid","color":"#ff0000"}]}
|
||||
]}"##,
|
||||
);
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("openpencil-pptx-empty-{}.pptx", std::process::id()));
|
||||
|
||||
let result = export_deck_pptx(&state, &path);
|
||||
|
||||
assert_eq!(result, Err(ExportError::NothingToExport));
|
||||
assert!(!path.exists(), "a refused export must leave no file behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_image_becomes_a_media_part_the_content_types_declares() {
|
||||
let (bytes, summary) = export(&image_deck());
|
||||
|
||||
assert_eq!(summary.raster_fallbacks, 0, "a data URL needs no raster");
|
||||
let names = part_names(&bytes);
|
||||
assert!(
|
||||
names.contains(&"ppt/media/image1.png".to_string()),
|
||||
"{names:?}"
|
||||
);
|
||||
let types = part(&bytes, "[Content_Types].xml");
|
||||
assert!(
|
||||
types.contains("<Default Extension=\"png\" ContentType=\"image/png\"/>"),
|
||||
"{types}"
|
||||
);
|
||||
let slide = part(&bytes, "ppt/slides/slide1.xml");
|
||||
assert!(slide.contains("<p:pic>"), "{slide}");
|
||||
assert!(slide.contains("r:embed=\"rId2\""), "{slide}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_media_extension_present_is_declared() {
|
||||
let (bytes, _) = export(&image_deck());
|
||||
|
||||
let types = part(&bytes, "[Content_Types].xml");
|
||||
for name in part_names(&bytes) {
|
||||
let Some(ext) = name
|
||||
.strip_prefix("ppt/media/")
|
||||
.and_then(|file| file.rsplit('.').next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
types.contains(&format!("<Default Extension=\"{ext}\"")),
|
||||
"{ext} is in the package but not in [Content_Types]: {types}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_image_is_rasterised_so_the_package_stays_self_contained() {
|
||||
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":"image","id":"i1","x":100,"y":100,"width":400,"height":300,
|
||||
"src":"https://example.com/hero.png"}
|
||||
]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, summary) = export(&state);
|
||||
|
||||
assert_eq!(summary.raster_fallbacks, 1, "the remote image must raster");
|
||||
assert!(
|
||||
part_names(&bytes).contains(&"ppt/media/image1.png".to_string()),
|
||||
"the raster must land in the package"
|
||||
);
|
||||
// Nothing anywhere in the package may still point at the network.
|
||||
for name in part_names(&bytes) {
|
||||
if !name.ends_with(".xml") && !name.ends_with(".rels") {
|
||||
continue;
|
||||
}
|
||||
let xml = part(&bytes, &name);
|
||||
assert!(!xml.contains("http://example"), "{name} leaks a URL");
|
||||
assert!(!xml.contains("https://example"), "{name} leaks a URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vector_path_rasters_while_its_siblings_stay_editable() {
|
||||
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":10,"y":10,"width":900,"height":60,
|
||||
"content":"Still text","fontSize":24,
|
||||
"fill":[{"type":"solid","color":"#000000"}]},
|
||||
{"type":"polygon","id":"p1","x":600,"y":300,"width":200,"height":200,
|
||||
"polygonCount":5,"fill":[{"type":"solid","color":"#3366ff"}]}
|
||||
]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, summary) = export(&state);
|
||||
|
||||
assert_eq!(summary.raster_fallbacks, 1);
|
||||
let slide = part(&bytes, "ppt/slides/slide1.xml");
|
||||
assert!(slide.contains("<a:t>Still text</a:t>"), "{slide}");
|
||||
assert!(slide.contains("<p:pic>"), "the polygon rastered: {slide}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_board_states_its_angle_in_drawingml_units() {
|
||||
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":"linear_gradient","angle":90.0,
|
||||
"stops":[{"offset":0.0,"color":"#000000"},
|
||||
{"offset":1.0,"color":"#ffffff"}]}]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, summary) = export(&state);
|
||||
|
||||
assert_eq!(
|
||||
summary.raster_fallbacks, 0,
|
||||
"a linear gradient is expressible"
|
||||
);
|
||||
let slide = part(&bytes, "ppt/slides/slide1.xml");
|
||||
assert!(slide.contains("<a:gradFill"), "{slide}");
|
||||
// CSS 90deg runs left→right, which is DrawingML 0.
|
||||
assert!(slide.contains("<a:lin ang=\"0\" scaled=\"0\"/>"), "{slide}");
|
||||
assert!(slide.contains("pos=\"0\""), "{slide}");
|
||||
assert!(slide.contains("pos=\"100000\""), "{slide}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_card_keeps_its_radius_as_a_preset_adjustment() {
|
||||
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":"rectangle","id":"r1","x":100,"y":100,"width":400,"height":200,
|
||||
"cornerRadius":20,"fill":[{"type":"solid","color":"#3366ff"}]}
|
||||
]}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let (bytes, summary) = export(&state);
|
||||
|
||||
assert_eq!(summary.raster_fallbacks, 0);
|
||||
let slide = part(&bytes, "ppt/slides/slide1.xml");
|
||||
assert!(slide.contains("prst=\"roundRect\""), "{slide}");
|
||||
// 20 / min(400, 200) = 10% of the shorter side.
|
||||
assert!(slide.contains("val 10000"), "{slide}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_file_is_a_zip_holding_the_same_parts() {
|
||||
let state = two_board_deck();
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"openpencil-pptx-write-{}-{}.pptx",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
|
||||
let summary = export_deck_pptx(&state, &path).expect("deck exports");
|
||||
|
||||
let written = std::fs::read(&path).expect("file exists");
|
||||
assert_eq!(summary.slides, 2);
|
||||
assert_eq!(part_names(&written), part_names(&export(&state).0));
|
||||
// The zip local-file-header magic — how every reader identifies the
|
||||
// format before it looks at a single part.
|
||||
assert_eq!(&written[..2], b"PK");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
408
crates/op-host-services/src/export_pptx/text.rs
Normal file
408
crates/op-host-services/src/export_pptx/text.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
//! Text emission — the reason a structured `.pptx` beats a deck of
|
||||
//! screenshots.
|
||||
//!
|
||||
//! A text node becomes a real PowerPoint text box holding real
|
||||
//! characters, so the presenter can fix a typo on stage, the reviewer
|
||||
//! can leave a comment on a sentence, and the whole deck stays
|
||||
//! searchable. Everything else in this module tree exists so that this
|
||||
//! one keeps working.
|
||||
//!
|
||||
//! **Fonts are named, not embedded.** The authored family is written
|
||||
//! into each run and PowerPoint substitutes when the machine does not
|
||||
//! have it. A substituted face changes glyph advances, but every box is
|
||||
//! pinned by absolute position and given explicit line spacing, so the
|
||||
//! drift stays inside the box — a line wrapping a word early, never a
|
||||
//! caption sliding across the slide.
|
||||
//!
|
||||
//! **Line spacing is stated in points, not percent.** PowerPoint's
|
||||
//! percentage spacing multiplies the FONT's natural line height (about
|
||||
//! 1.2 em), so a design authored at `lineHeight: 1.2` would come out at
|
||||
//! roughly 1.44 em — visibly looser, and looser by a different amount
|
||||
//! per font. `spcPts` states the exact measure the canvas laid out with.
|
||||
|
||||
use op_editor_ui::layout_scene::{SceneNode, SceneTextAlign, SceneTextRun};
|
||||
use op_editor_ui::{Color, Rect};
|
||||
use op_util::xml_escape::escape_xml;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::units::{font_hundredths_pt, hundredths_pt, solid_fill};
|
||||
use super::xml::{nv_sp_pr, sp_pr, xfrm};
|
||||
|
||||
/// Painter default when a text node authored no size (mirrors
|
||||
/// `canvas_viewport_text.rs`).
|
||||
const DEFAULT_FONT_SIZE: f32 = 13.0;
|
||||
|
||||
/// Painter default text colour for a fill-less text node.
|
||||
const DEFAULT_FILL: Color = Color {
|
||||
r: 0.08,
|
||||
g: 0.08,
|
||||
b: 0.08,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
/// Emit one text node as a text box.
|
||||
///
|
||||
/// Text is pinned to the TOP of its box because the canvas painter is:
|
||||
/// `canvas_viewport_text.rs` draws from the node's top-left and
|
||||
/// deliberately ignores `textAlignVertical` (Figma exports bake vertical
|
||||
/// placement into the authored y). Honouring the field here would move
|
||||
/// every imported label relative to what the editor shows.
|
||||
pub fn emit(out: &mut String, node: &SceneNode, rect: Rect, alpha: f32, id: u32) {
|
||||
let Some(text) = node.text.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let font_size = if node.font_size > 0.0 {
|
||||
node.font_size
|
||||
} else {
|
||||
DEFAULT_FONT_SIZE
|
||||
};
|
||||
|
||||
let _ = write!(
|
||||
out,
|
||||
"<p:sp>{}{}<p:txBody>{}<a:lstStyle/>{}</p:txBody></p:sp>",
|
||||
nv_sp_pr(id, &node.id, true),
|
||||
sp_pr(
|
||||
&xfrm(rect, node),
|
||||
"<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>",
|
||||
"<a:noFill/>",
|
||||
None,
|
||||
""
|
||||
),
|
||||
body_pr(node),
|
||||
paragraphs(node, text, font_size, alpha)
|
||||
);
|
||||
}
|
||||
|
||||
/// `<a:bodyPr>`: zero insets (the scene rect IS the text box, with no
|
||||
/// padding of its own), no autofit (PowerPoint must not resize the type
|
||||
/// the canvas already measured), and wrapping only where the document
|
||||
/// asked for it.
|
||||
fn body_pr(node: &SceneNode) -> String {
|
||||
let wrap = if node.text_wrap { "square" } else { "none" };
|
||||
format!(
|
||||
"<a:bodyPr wrap=\"{wrap}\" lIns=\"0\" tIns=\"0\" rIns=\"0\" bIns=\"0\" rtlCol=\"0\" \
|
||||
anchor=\"t\"><a:noAutofit/></a:bodyPr>"
|
||||
)
|
||||
}
|
||||
|
||||
/// One `<a:p>` per authored line.
|
||||
///
|
||||
/// Splitting on `\n` rather than emitting `<a:br/>` keeps each line a
|
||||
/// paragraph, which is what carries the line-spacing and alignment
|
||||
/// properties — a break inside one paragraph would inherit them, but an
|
||||
/// empty line would then collapse to nothing.
|
||||
fn paragraphs(node: &SceneNode, text: &str, font_size: f32, alpha: f32) -> String {
|
||||
let props = paragraph_props(node, font_size);
|
||||
let mut out = String::new();
|
||||
let mut offset = 0usize;
|
||||
for line in text.split('\n') {
|
||||
let start = offset;
|
||||
let end = start + line.len();
|
||||
// `+ 1` steps over the '\n' that `split` consumed.
|
||||
offset = end + 1;
|
||||
out.push_str("<a:p>");
|
||||
out.push_str(&props);
|
||||
if line.is_empty() {
|
||||
// An empty paragraph with no run has no height at all;
|
||||
// `endParaRPr` gives the blank line the node's type size so
|
||||
// the following line lands where the canvas puts it.
|
||||
let _ = write!(
|
||||
out,
|
||||
"<a:endParaRPr lang=\"en-US\" sz=\"{}\"/>",
|
||||
font_hundredths_pt(font_size)
|
||||
);
|
||||
} else {
|
||||
out.push_str(&runs(node, text, start, end, font_size, alpha));
|
||||
}
|
||||
out.push_str("</a:p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn paragraph_props(node: &SceneNode, font_size: f32) -> String {
|
||||
let mut inner = String::new();
|
||||
if node.line_height > 0.0 {
|
||||
let _ = write!(
|
||||
inner,
|
||||
"<a:lnSpc><a:spcPts val=\"{}\"/></a:lnSpc>",
|
||||
hundredths_pt(node.line_height * font_size)
|
||||
);
|
||||
}
|
||||
// Without `buNone` a bullet inherited from the layout's list styles
|
||||
// can appear in front of a line that was never a list item.
|
||||
inner.push_str("<a:buNone/>");
|
||||
format!(
|
||||
"<a:pPr marL=\"0\" indent=\"0\" algn=\"{}\">{inner}</a:pPr>",
|
||||
align(node.text_align)
|
||||
)
|
||||
}
|
||||
|
||||
/// The runs covering `text[start..end]`.
|
||||
///
|
||||
/// Styled runs are byte ranges over the WHOLE string, so each line takes
|
||||
/// the slice of them that overlaps it. A run that is reversed, that
|
||||
/// overlaps its predecessor, or that lands mid-codepoint is skipped: the
|
||||
/// characters still reach the slide with the node's own style, which is
|
||||
/// a far smaller loss than dropping the text or panicking on a bad
|
||||
/// slice.
|
||||
fn runs(
|
||||
node: &SceneNode,
|
||||
text: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
font_size: f32,
|
||||
alpha: f32,
|
||||
) -> String {
|
||||
let node_style = RunStyle::from_node(node, font_size);
|
||||
if node.text_runs.is_empty() {
|
||||
return run(&text[start..end], &node_style, alpha);
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut cursor = start;
|
||||
for styled in &node.text_runs {
|
||||
let (run_start, run_end) = (styled.start.max(start), styled.end.min(end));
|
||||
if run_end <= run_start || run_start < cursor {
|
||||
continue;
|
||||
}
|
||||
if !text.is_char_boundary(run_start) || !text.is_char_boundary(run_end) {
|
||||
continue;
|
||||
}
|
||||
if run_start > cursor {
|
||||
out.push_str(&run(&text[cursor..run_start], &node_style, alpha));
|
||||
}
|
||||
out.push_str(&run(
|
||||
&text[run_start..run_end],
|
||||
&node_style.overlaid(styled),
|
||||
alpha,
|
||||
));
|
||||
cursor = run_end;
|
||||
}
|
||||
if cursor < end {
|
||||
out.push_str(&run(&text[cursor..end], &node_style, alpha));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Everything a `<a:rPr>` needs, resolved — node level first, then the
|
||||
/// per-run overrides laid over it.
|
||||
struct RunStyle {
|
||||
size: f32,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
letter_spacing: f32,
|
||||
color: Color,
|
||||
family: String,
|
||||
}
|
||||
|
||||
impl RunStyle {
|
||||
fn from_node(node: &SceneNode, font_size: f32) -> Self {
|
||||
Self {
|
||||
size: font_size,
|
||||
weight: node.font_weight,
|
||||
italic: node.italic,
|
||||
underline: node.underline,
|
||||
strikethrough: node.strikethrough,
|
||||
letter_spacing: node.letter_spacing,
|
||||
color: node.fill.unwrap_or(DEFAULT_FILL),
|
||||
family: primary_family(&node.font_family),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentinels (`0.0` size, `0` weight, `None` fill) mean "inherit
|
||||
/// from the node", so only a set field overrides.
|
||||
fn overlaid(&self, run: &SceneTextRun) -> Self {
|
||||
Self {
|
||||
size: if run.font_size > 0.0 {
|
||||
run.font_size
|
||||
} else {
|
||||
self.size
|
||||
},
|
||||
weight: if run.font_weight > 0 {
|
||||
run.font_weight
|
||||
} else {
|
||||
self.weight
|
||||
},
|
||||
italic: self.italic || run.italic,
|
||||
underline: self.underline || run.underline,
|
||||
strikethrough: self.strikethrough || run.strikethrough,
|
||||
letter_spacing: self.letter_spacing,
|
||||
color: run.fill.unwrap_or(self.color),
|
||||
family: self.family.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(text: &str, style: &RunStyle, alpha: f32) -> String {
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut attrs = format!(" sz=\"{}\"", font_hundredths_pt(style.size));
|
||||
// PowerPoint has one bold bit, not a 100-900 axis. 600 is the
|
||||
// threshold the canvas backends already use for synthetic bold, so
|
||||
// a semibold heading reads heavy in both.
|
||||
if style.weight >= 600 {
|
||||
attrs.push_str(" b=\"1\"");
|
||||
}
|
||||
if style.italic {
|
||||
attrs.push_str(" i=\"1\"");
|
||||
}
|
||||
if style.underline {
|
||||
attrs.push_str(" u=\"sng\"");
|
||||
}
|
||||
if style.strikethrough {
|
||||
attrs.push_str(" strike=\"sngStrike\"");
|
||||
}
|
||||
if style.letter_spacing != 0.0 && style.letter_spacing.is_finite() {
|
||||
attrs.push_str(&format!(" spc=\"{}\"", hundredths_pt(style.letter_spacing)));
|
||||
}
|
||||
let family = escape_xml(&style.family);
|
||||
let typefaces = if style.family.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
// `latin` covers Latin script, `ea` East Asian, `cs` complex
|
||||
// script. A CJK deck whose family is named only in `latin` gets
|
||||
// PowerPoint's own East Asian default instead of the authored
|
||||
// face, so all three are set to the same family.
|
||||
format!(
|
||||
"<a:latin typeface=\"{family}\"/><a:ea typeface=\"{family}\"/>\
|
||||
<a:cs typeface=\"{family}\"/>"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"<a:r><a:rPr lang=\"en-US\"{attrs} dirty=\"0\">{}{typefaces}</a:rPr><a:t>{}</a:t></a:r>",
|
||||
solid_fill(style.color, alpha),
|
||||
escape_xml(&sanitize(text))
|
||||
)
|
||||
}
|
||||
|
||||
/// The first family of the authored CSS stack.
|
||||
///
|
||||
/// The scene carries a stack (`"Noto Sans SC", sans-serif`); DrawingML
|
||||
/// names exactly one face and resolves the rest itself, so the fallback
|
||||
/// tail is dropped rather than jammed into the attribute.
|
||||
fn primary_family(stack: &str) -> String {
|
||||
stack
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_matches(['"', '\''])
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Drop the control characters XML 1.0 forbids.
|
||||
///
|
||||
/// Text arriving from an import can carry a stray `\r` or `\u{0}`; a
|
||||
/// single one of those makes the whole part unparseable, which costs the
|
||||
/// deck rather than the character.
|
||||
fn sanitize(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|c| *c == '\t' || !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn align(align: SceneTextAlign) -> &'static str {
|
||||
match align {
|
||||
SceneTextAlign::Left => "l",
|
||||
SceneTextAlign::Center => "ctr",
|
||||
SceneTextAlign::Right => "r",
|
||||
SceneTextAlign::Justify => "just",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_ui::layout_scene::NodeKind;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
fn text_node(content: &str) -> SceneNode {
|
||||
let mut n = SceneNode::leaf("t1", NodeKind::Text);
|
||||
n.text = Some(content.to_string());
|
||||
n.font_size = 32.0;
|
||||
n.font_family = "\"Noto Sans SC\", sans-serif".to_string();
|
||||
n.bounds = Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(300.0, 48.0),
|
||||
};
|
||||
n
|
||||
}
|
||||
|
||||
fn emitted(node: &SceneNode) -> String {
|
||||
let mut out = String::new();
|
||||
emit(&mut out, node, node.bounds, 1.0, 2);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_characters_land_as_text_not_as_a_picture() {
|
||||
let xml = emitted(&text_node("Quarterly Review"));
|
||||
assert!(xml.contains("<a:t>Quarterly Review</a:t>"), "{xml}");
|
||||
assert!(xml.contains("sz=\"2400\""), "32 px is 24 pt: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_family_of_the_css_stack_is_named() {
|
||||
let xml = emitted(&text_node("hi"));
|
||||
assert!(
|
||||
xml.contains("<a:latin typeface=\"Noto Sans SC\"/>"),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(!xml.contains("sans-serif"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_authored_line_becomes_its_own_paragraph() {
|
||||
let xml = emitted(&text_node("one\n\nthree"));
|
||||
assert_eq!(xml.matches("<a:p>").count(), 3, "{xml}");
|
||||
// The blank middle line keeps its height.
|
||||
assert!(xml.contains("<a:endParaRPr"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_is_stated_as_an_exact_measure() {
|
||||
let mut n = text_node("hi");
|
||||
n.line_height = 1.5;
|
||||
// 1.5 x 32 px = 48 px = 36 pt.
|
||||
assert!(
|
||||
emitted(&n).contains("<a:spcPts val=\"3600\"/>"),
|
||||
"{}",
|
||||
emitted(&n)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_in_the_authored_text_is_escaped() {
|
||||
let xml = emitted(&text_node("a < b & \"c\""));
|
||||
assert!(xml.contains("a < b & "c""), "{xml}");
|
||||
assert!(!xml.contains("a < b"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_styled_run_overrides_only_what_it_sets() {
|
||||
let mut n = text_node("plain bold");
|
||||
n.font_weight = 400;
|
||||
n.text_runs = vec![SceneTextRun {
|
||||
start: 6,
|
||||
end: 10,
|
||||
font_size: 0.0,
|
||||
font_weight: 700,
|
||||
fill: None,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
}];
|
||||
let xml = emitted(&n);
|
||||
assert!(xml.contains("<a:t>plain </a:t>"), "{xml}");
|
||||
assert!(xml.contains("b=\"1\""), "{xml}");
|
||||
assert!(xml.contains("<a:t>bold</a:t>"), "{xml}");
|
||||
// Both runs keep the node's size — the run overrode weight only.
|
||||
assert_eq!(xml.matches("sz=\"2400\"").count(), 2, "{xml}");
|
||||
}
|
||||
}
|
||||
201
crates/op-host-services/src/export_pptx/units.rs
Normal file
201
crates/op-host-services/src/export_pptx/units.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! Scene scalars → OOXML units.
|
||||
//!
|
||||
//! DrawingML measures nothing in pixels. Every function here is a pure
|
||||
//! map from the doc-px / radian / 0..1 world the scene speaks into the
|
||||
//! integer unit the XML expects, and every one of them returns an
|
||||
//! INTEGER: OOXML attributes are xsd integer types, and a value that
|
||||
//! formatted as `1.8288e7` would be rejected by the schema before
|
||||
//! PowerPoint ever saw the slide.
|
||||
|
||||
use op_editor_ui::Color;
|
||||
|
||||
/// English Metric Units per doc pixel.
|
||||
///
|
||||
/// EMU is defined as 914400 per inch, and the canonical `.op` document
|
||||
/// is authored at the CSS reference resolution of 96 px per inch, so
|
||||
/// one pixel is exactly 9525 EMU with no rounding in the constant
|
||||
/// itself. A 1920×1080 board therefore lands as 18288000×10287000 EMU
|
||||
/// — a real 20×11.25 inch slide, 16:9 to the last unit.
|
||||
pub const EMU_PER_PX: f64 = 9525.0;
|
||||
|
||||
/// The largest coordinate OOXML accepts (`ST_Coordinate` tops out at
|
||||
/// 2^31-1 EMU). Clamping instead of wrapping keeps a corrupt bound from
|
||||
/// producing a negative offset that PowerPoint reads as garbage.
|
||||
const MAX_EMU: i64 = 2_147_483_647;
|
||||
|
||||
/// Doc pixels → EMU, rounded to the nearest unit.
|
||||
pub fn emu(px: f32) -> i64 {
|
||||
if !px.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
let v = (px as f64 * EMU_PER_PX).round();
|
||||
(v as i64).clamp(-MAX_EMU, MAX_EMU)
|
||||
}
|
||||
|
||||
/// Doc pixels → EMU for a LENGTH, which may not be negative or zero:
|
||||
/// a shape with `cx="0"` is legal XML that PowerPoint draws as nothing,
|
||||
/// so a degenerate rect is floored to one EMU rather than vanishing.
|
||||
pub fn emu_extent(px: f32) -> i64 {
|
||||
emu(px).max(1)
|
||||
}
|
||||
|
||||
/// Doc pixels → hundredths of a point, the unit of `sz` on a run and of
|
||||
/// `spcPts` on a paragraph.
|
||||
///
|
||||
/// CSS pixels are 1/96 inch and points are 1/72, so a pixel is exactly
|
||||
/// 0.75 pt: a 32 px heading is 24 pt, the size PowerPoint's own font
|
||||
/// box will show. Clamped to the `ST_TextFontSize` range (1 pt..4000
|
||||
/// pt) that the schema enforces.
|
||||
pub fn font_hundredths_pt(px: f32) -> i64 {
|
||||
if !px.is_finite() {
|
||||
return 1200;
|
||||
}
|
||||
((px as f64 * 75.0).round() as i64).clamp(100, 400_000)
|
||||
}
|
||||
|
||||
/// Doc pixels → hundredths of a point WITHOUT the font-size clamp, for
|
||||
/// letter spacing (which is legally negative) and line spacing.
|
||||
pub fn hundredths_pt(px: f32) -> i64 {
|
||||
if !px.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
((px as f64 * 75.0).round() as i64).clamp(-400_000, 400_000)
|
||||
}
|
||||
|
||||
/// Radians clockwise → the 1/60000-degree clockwise integer that every
|
||||
/// DrawingML rotation attribute uses, normalized into `[0, 360)`.
|
||||
///
|
||||
/// Both models turn clockwise about the shape's centre, so this is a
|
||||
/// unit change and nothing else.
|
||||
pub fn rot_60k(radians: f32) -> i64 {
|
||||
if !radians.is_finite() || radians == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
degrees_60k(radians.to_degrees())
|
||||
}
|
||||
|
||||
/// Degrees clockwise → 1/60000-degree, normalized into `[0, 360)`.
|
||||
pub fn degrees_60k(degrees: f32) -> i64 {
|
||||
if !degrees.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
let normalized = degrees.rem_euclid(360.0);
|
||||
((normalized as f64 * 60_000.0).round() as i64).rem_euclid(21_600_000)
|
||||
}
|
||||
|
||||
/// A `0.0..=1.0` fraction → the 1/1000-percent integer DrawingML uses
|
||||
/// for alpha, gradient stop positions and crop insets.
|
||||
pub fn pct_1000(fraction: f32) -> i64 {
|
||||
if !fraction.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
((fraction.clamp(0.0, 1.0) as f64) * 100_000.0).round() as i64
|
||||
}
|
||||
|
||||
/// Same scale as [`pct_1000`] but for a value that is legally negative
|
||||
/// (a `fillRect` inset expands the fill area when negative).
|
||||
pub fn signed_pct_1000(fraction: f32) -> i64 {
|
||||
if !fraction.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
((fraction.clamp(-10.0, 10.0) as f64) * 100_000.0).round() as i64
|
||||
}
|
||||
|
||||
/// A resolved scene colour as the six upper-case hex digits `srgbClr`
|
||||
/// wants. The alpha channel is NOT part of it — see [`alpha_child`].
|
||||
pub fn srgb(c: Color) -> String {
|
||||
let channel = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!(
|
||||
"{:02X}{:02X}{:02X}",
|
||||
channel(c.r),
|
||||
channel(c.g),
|
||||
channel(c.b)
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<a:alpha/>` child of a colour element, or an empty string when
|
||||
/// the colour is opaque.
|
||||
///
|
||||
/// `scale` folds in the inherited composite opacity of every ancestor.
|
||||
/// DrawingML has no per-shape opacity property at all: a translucent
|
||||
/// group in the scene can only reach the slide by multiplying into the
|
||||
/// alpha of each colour its subtree paints, which is what the walk does
|
||||
/// by threading a scale factor down and passing it here.
|
||||
pub fn alpha_child(c: Color, scale: f32) -> String {
|
||||
let a = (c.a * scale.clamp(0.0, 1.0)).clamp(0.0, 1.0);
|
||||
if a >= 0.999 {
|
||||
return String::new();
|
||||
}
|
||||
format!("<a:alpha val=\"{}\"/>", pct_1000(a))
|
||||
}
|
||||
|
||||
/// A complete `<a:srgbClr>` element for `c` at the inherited `scale`.
|
||||
pub fn color_element(c: Color, scale: f32) -> String {
|
||||
let alpha = alpha_child(c, scale);
|
||||
if alpha.is_empty() {
|
||||
return format!("<a:srgbClr val=\"{}\"/>", srgb(c));
|
||||
}
|
||||
format!("<a:srgbClr val=\"{}\">{alpha}</a:srgbClr>", srgb(c))
|
||||
}
|
||||
|
||||
/// A complete `<a:solidFill>` element for `c` at the inherited `scale`.
|
||||
pub fn solid_fill(c: Color, scale: f32) -> String {
|
||||
format!("<a:solidFill>{}</a:solidFill>", color_element(c, scale))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_full_hd_board_is_exactly_sixteen_by_nine_in_emu() {
|
||||
assert_eq!(emu(1920.0), 18_288_000);
|
||||
assert_eq!(emu(1080.0), 10_287_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unit_formats_as_a_plain_integer() {
|
||||
// The bug this guards: an f32 formatted with `{}` reaches
|
||||
// scientific notation around 1e7, which is inside the EMU range
|
||||
// a normal slide uses.
|
||||
for px in [0.0, 1.5, 1920.0, 12_345.678] {
|
||||
let s = emu(px).to_string();
|
||||
assert!(!s.contains('e'), "{s}");
|
||||
assert!(!s.contains('.'), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixels_convert_to_points_at_three_quarters() {
|
||||
assert_eq!(font_hundredths_pt(32.0), 2400);
|
||||
assert_eq!(font_hundredths_pt(16.0), 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_degenerate_extent_is_floored_rather_than_dropped() {
|
||||
assert_eq!(emu_extent(0.0), 1);
|
||||
assert_eq!(emu_extent(-3.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_normalizes_into_one_turn() {
|
||||
assert_eq!(rot_60k(0.0), 0);
|
||||
assert_eq!(rot_60k(std::f32::consts::FRAC_PI_2), 5_400_000);
|
||||
// -90 degrees is 270, not a negative attribute value.
|
||||
assert_eq!(rot_60k(-std::f32::consts::FRAC_PI_2), 16_200_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_colour_writes_no_alpha_child() {
|
||||
let opaque = Color {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
};
|
||||
assert_eq!(alpha_child(opaque, 1.0), "");
|
||||
assert_eq!(srgb(opaque), "FF0000");
|
||||
// An opaque colour under a half-transparent ancestor is not.
|
||||
assert_eq!(alpha_child(opaque, 0.5), "<a:alpha val=\"50000\"/>");
|
||||
}
|
||||
}
|
||||
364
crates/op-host-services/src/export_pptx/xml.rs
Normal file
364
crates/op-host-services/src/export_pptx/xml.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
//! Shared DrawingML fragments — the parts of a shape that read the same
|
||||
//! whether the shape came out of a frame, an ellipse or a picture.
|
||||
//!
|
||||
//! Element ORDER is part of the schema here, not a style choice: a
|
||||
//! `<p:spPr>` must list `xfrm`, then geometry, then fill, then line,
|
||||
//! then effects, and PowerPoint rejects the file outright if they are
|
||||
//! written in any other order. Every builder in this module emits its
|
||||
//! own fragment only; [`sp_pr`] is the one place that concatenates them,
|
||||
//! so the order is stated once.
|
||||
|
||||
use op_editor_ui::layout_scene::{
|
||||
SceneFillType, SceneGradient, SceneGradientStop, SceneNode, SceneStroke,
|
||||
};
|
||||
use op_editor_ui::{Color, Rect};
|
||||
use op_util::xml_escape::escape_xml;
|
||||
|
||||
use super::units::{color_element, degrees_60k, emu, emu_extent, pct_1000, rot_60k, solid_fill};
|
||||
|
||||
/// The preset shape a node maps onto.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Geom {
|
||||
Rect,
|
||||
Ellipse,
|
||||
}
|
||||
|
||||
/// `<p:nvSpPr>` — the identity block every shape opens with.
|
||||
///
|
||||
/// `name` is diagnostic only (it is what the PowerPoint selection pane
|
||||
/// shows), so it carries the scene node id: a slide that looks wrong is
|
||||
/// then traceable back to a node without re-running the export.
|
||||
pub fn nv_sp_pr(id: u32, node_id: &str, text_box: bool) -> String {
|
||||
let cnv = if text_box {
|
||||
"<p:cNvSpPr txBox=\"1\"/>"
|
||||
} else {
|
||||
"<p:cNvSpPr/>"
|
||||
};
|
||||
format!(
|
||||
"<p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{}\"/>{cnv}<p:nvPr/></p:nvSpPr>",
|
||||
escape_xml(node_id)
|
||||
)
|
||||
}
|
||||
|
||||
/// `<a:xfrm>` — placement, rotation and mirroring in one element.
|
||||
pub fn xfrm(rect: Rect, node: &SceneNode) -> String {
|
||||
let mut attrs = String::new();
|
||||
let rot = rot_60k(node.rotation);
|
||||
if rot != 0 {
|
||||
attrs.push_str(&format!(" rot=\"{rot}\""));
|
||||
}
|
||||
if node.flip_x {
|
||||
attrs.push_str(" flipH=\"1\"");
|
||||
}
|
||||
if node.flip_y {
|
||||
attrs.push_str(" flipV=\"1\"");
|
||||
}
|
||||
plain_xfrm(rect, &attrs)
|
||||
}
|
||||
|
||||
/// `<a:xfrm>` for a rect with no rotation or mirroring of its own.
|
||||
pub fn xfrm_plain(rect: Rect) -> String {
|
||||
plain_xfrm(rect, "")
|
||||
}
|
||||
|
||||
fn plain_xfrm(rect: Rect, attrs: &str) -> String {
|
||||
format!(
|
||||
"<a:xfrm{attrs}><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></a:xfrm>",
|
||||
emu(rect.origin.x),
|
||||
emu(rect.origin.y),
|
||||
emu_extent(rect.size.x),
|
||||
emu_extent(rect.size.y)
|
||||
)
|
||||
}
|
||||
|
||||
/// `<a:prstGeom>` for a node.
|
||||
///
|
||||
/// A corner radius becomes `roundRect`, whose single `adj` value is a
|
||||
/// fraction of HALF the shorter side — DrawingML has no per-corner
|
||||
/// preset at all. When the four scene radii disagree, the top-left one
|
||||
/// is used for all four and the difference is lost; the alternative
|
||||
/// (rasterising every card with one squared corner) would cost the whole
|
||||
/// subtree's live text to save one corner.
|
||||
pub fn prst_geom(node: &SceneNode, geom: Geom, rect: Rect) -> String {
|
||||
match geom {
|
||||
Geom::Ellipse => "<a:prstGeom prst=\"ellipse\"><a:avLst/></a:prstGeom>".to_string(),
|
||||
Geom::Rect => match corner_radius(node) {
|
||||
Some(radius) => {
|
||||
let shorter = rect.size.x.min(rect.size.y).max(0.01);
|
||||
let adj = pct_1000((radius / shorter).clamp(0.0, 0.5));
|
||||
format!(
|
||||
"<a:prstGeom prst=\"roundRect\"><a:avLst>\
|
||||
<a:gd name=\"adj\" fmla=\"val {adj}\"/></a:avLst></a:prstGeom>"
|
||||
)
|
||||
}
|
||||
None => "<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's effective corner radius in doc px, or `None` when it is
|
||||
/// square-cornered. Per-corner radii collapse to the top-left value.
|
||||
pub fn corner_radius(node: &SceneNode) -> Option<f32> {
|
||||
if let Some([tl, tr, br, bl]) = node.corner_radii {
|
||||
let first = [tl, tr, br, bl].into_iter().find(|r| *r > 0.0)?;
|
||||
return Some(first);
|
||||
}
|
||||
(node.corner_radius > 0.0).then_some(node.corner_radius)
|
||||
}
|
||||
|
||||
/// The node's fill as a DrawingML fill element.
|
||||
///
|
||||
/// Returns `<a:noFill/>` for an unfilled node rather than nothing at
|
||||
/// all: an omitted fill element means "inherit from the theme's style
|
||||
/// matrix", which would paint a blue box under a shape the canvas draws
|
||||
/// as empty.
|
||||
pub fn fill_element(node: &SceneNode, alpha: f32) -> String {
|
||||
if let (SceneFillType::LinearGradient | SceneFillType::RadialGradient, Some(gradient)) =
|
||||
(node.fill_type, node.gradient.as_ref())
|
||||
{
|
||||
if let Some(value) = grad_fill(gradient, alpha) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
match node.fill {
|
||||
Some(color) => solid_fill(color, alpha),
|
||||
None => "<a:noFill/>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved gradient as `<a:gradFill>`, or `None` when it has too few
|
||||
/// stops to be one (DrawingML requires at least two).
|
||||
///
|
||||
/// **Linear angles are exact.** The canonical `.op` convention is CSS's
|
||||
/// (0° = bottom→top, growing clockwise) while DrawingML measures
|
||||
/// clockwise from due east, so the conversion is a fixed −90° turn.
|
||||
///
|
||||
/// **Radial extent is not.** The scene states a radius as a fraction of
|
||||
/// the box; `<a:path path="circle">` always runs the ramp from the focus
|
||||
/// point out to the shape's edge, so a gradient authored to finish at
|
||||
/// 50% is stretched to finish at 100%. The centre — the thing the eye
|
||||
/// actually locates — is exact, via `fillToRect`.
|
||||
pub fn grad_fill(gradient: &SceneGradient, alpha: f32) -> Option<String> {
|
||||
let (stops, opacity, tail) = match gradient {
|
||||
SceneGradient::Linear {
|
||||
angle_deg,
|
||||
opacity,
|
||||
stops,
|
||||
} => (
|
||||
stops,
|
||||
*opacity,
|
||||
format!(
|
||||
"<a:lin ang=\"{}\" scaled=\"0\"/>",
|
||||
degrees_60k(angle_deg - 90.0)
|
||||
),
|
||||
),
|
||||
SceneGradient::Radial {
|
||||
cx,
|
||||
cy,
|
||||
opacity,
|
||||
stops,
|
||||
..
|
||||
} => {
|
||||
let (cx, cy) = (cx.clamp(0.0, 1.0), cy.clamp(0.0, 1.0));
|
||||
(
|
||||
stops,
|
||||
*opacity,
|
||||
format!(
|
||||
"<a:path path=\"circle\"><a:fillToRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>\
|
||||
</a:path>",
|
||||
pct_1000(cx),
|
||||
pct_1000(cy),
|
||||
pct_1000(1.0 - cx),
|
||||
pct_1000(1.0 - cy)
|
||||
),
|
||||
)
|
||||
}
|
||||
// A Gouraud lattice has no DrawingML spelling; the caller sends
|
||||
// the node to the raster path instead of flattening it.
|
||||
SceneGradient::Mesh { .. } => return None,
|
||||
};
|
||||
if stops.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"<a:gradFill flip=\"none\" rotWithShape=\"1\"><a:gsLst>{}</a:gsLst>{tail}</a:gradFill>",
|
||||
stop_list(stops, opacity * alpha)
|
||||
))
|
||||
}
|
||||
|
||||
fn stop_list(stops: &[SceneGradientStop], alpha: f32) -> String {
|
||||
let mut out = String::new();
|
||||
// Positions must not decrease; the scene can carry an authored stop
|
||||
// list that does, and PowerPoint rejects the part rather than
|
||||
// sorting it for us.
|
||||
let mut floor = 0.0f32;
|
||||
for stop in stops {
|
||||
let offset = stop.offset.clamp(0.0, 1.0).max(floor);
|
||||
floor = offset;
|
||||
out.push_str(&format!(
|
||||
"<a:gs pos=\"{}\">{}</a:gs>",
|
||||
pct_1000(offset),
|
||||
color_element(stop.color, alpha)
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `<a:ln>` for a uniform stroke, or `None` when the node has none.
|
||||
///
|
||||
/// Per-side strokes do NOT come through here — see
|
||||
/// `shape::emit_side_strokes` for why they are drawn as their own
|
||||
/// rectangles instead.
|
||||
///
|
||||
/// DrawingML centres a line on the shape outline and has no alignment
|
||||
/// property, so an `Inside` or `Outside` stroke lands half its width off
|
||||
/// from where the canvas puts it. At the 1–2 px strokes decks use, that
|
||||
/// is a sub-pixel difference on a projected slide, and the alternative —
|
||||
/// insetting the shape to compensate — would move the FILL edge too.
|
||||
pub fn line_element(stroke: SceneStroke, alpha: f32) -> Option<String> {
|
||||
if stroke.width <= 0.0 || !stroke.width.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"<a:ln w=\"{}\" cap=\"flat\">{}<a:prstDash val=\"solid\"/></a:ln>",
|
||||
emu_extent(stroke.width),
|
||||
solid_fill(stroke.color, alpha)
|
||||
))
|
||||
}
|
||||
|
||||
/// `<a:effectLst>` for the node's shadows, or an empty string.
|
||||
///
|
||||
/// Blur effects are absent by construction: a node carrying one never
|
||||
/// reaches this function, because `filter: blur()` and DrawingML's
|
||||
/// `a:blur` are different operations and the raster path reproduces the
|
||||
/// canvas exactly.
|
||||
pub fn effect_list(node: &SceneNode, alpha: f32) -> String {
|
||||
use op_editor_ui::layout_scene::Effect;
|
||||
|
||||
let mut body = String::new();
|
||||
for effect in &node.effects {
|
||||
let Effect::DropShadow(shadow) = effect else {
|
||||
continue;
|
||||
};
|
||||
let blur = emu(shadow.blur.max(0.0));
|
||||
let dist = emu((shadow.offset_x.powi(2) + shadow.offset_y.powi(2)).sqrt());
|
||||
let dir = degrees_60k(shadow.offset_y.atan2(shadow.offset_x).to_degrees());
|
||||
let color = color_element(shadow.color, alpha);
|
||||
if shadow.inner {
|
||||
body.push_str(&format!(
|
||||
"<a:innerShdw blurRad=\"{blur}\" dist=\"{dist}\" dir=\"{dir}\">{color}\
|
||||
</a:innerShdw>"
|
||||
));
|
||||
} else {
|
||||
body.push_str(&format!(
|
||||
"<a:outerShdw blurRad=\"{blur}\" dist=\"{dist}\" dir=\"{dir}\" \
|
||||
rotWithShape=\"0\">{color}</a:outerShdw>"
|
||||
));
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<a:effectLst>{body}</a:effectLst>")
|
||||
}
|
||||
}
|
||||
|
||||
/// `<p:spPr>` with its children in the order the schema demands.
|
||||
pub fn sp_pr(xfrm: &str, geom: &str, fill: &str, line: Option<&str>, effects: &str) -> String {
|
||||
format!(
|
||||
"<p:spPr>{xfrm}{geom}{fill}{}{effects}</p:spPr>",
|
||||
line.unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
/// A shape carrying no text still needs a `<p:txBody>`: PowerPoint
|
||||
/// treats a `<p:sp>` without one as malformed even though the schema
|
||||
/// marks it optional.
|
||||
pub const EMPTY_TX_BODY: &str = "<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody>";
|
||||
|
||||
/// A flat filled rectangle with no stroke, geometry adjustment or
|
||||
/// effects — the primitive behind per-side stroke bands.
|
||||
pub fn filled_rect(id: u32, node_id: &str, rect: Rect, color: Color, alpha: f32) -> String {
|
||||
format!(
|
||||
"<p:sp>{}{}{}</p:sp>",
|
||||
nv_sp_pr(id, node_id, false),
|
||||
sp_pr(
|
||||
&xfrm_plain(rect),
|
||||
"<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>",
|
||||
&solid_fill(color, alpha),
|
||||
None,
|
||||
""
|
||||
),
|
||||
EMPTY_TX_BODY
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_ui::layout_scene::NodeKind;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
fn node() -> SceneNode {
|
||||
SceneNode::leaf("n1", NodeKind::Rect)
|
||||
}
|
||||
|
||||
fn rect() -> Rect {
|
||||
Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(200.0, 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_radius_becomes_a_fraction_of_the_shorter_side() {
|
||||
let mut n = node();
|
||||
n.corner_radius = 25.0;
|
||||
let xml = prst_geom(&n, Geom::Rect, rect());
|
||||
// 25 / min(200, 100) = 0.25 of the shorter side.
|
||||
assert!(xml.contains("val 25000"), "{xml}");
|
||||
assert!(xml.contains("roundRect"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unfilled_node_says_so_instead_of_inheriting_a_theme_fill() {
|
||||
assert_eq!(fill_element(&node(), 1.0), "<a:noFill/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_css_gradient_angle_becomes_a_drawingml_one() {
|
||||
// CSS 0deg points up; DrawingML 0 points right, so straight up
|
||||
// is a three-quarter turn clockwise.
|
||||
let up = SceneGradient::Linear {
|
||||
angle_deg: 0.0,
|
||||
opacity: 1.0,
|
||||
stops: vec![
|
||||
SceneGradientStop {
|
||||
offset: 0.0,
|
||||
color: Color::BLACK,
|
||||
},
|
||||
SceneGradientStop {
|
||||
offset: 1.0,
|
||||
color: Color::WHITE,
|
||||
},
|
||||
],
|
||||
};
|
||||
let xml = grad_fill(&up, 1.0).expect("two stops");
|
||||
assert!(xml.contains("<a:lin ang=\"16200000\""), "{xml}");
|
||||
assert!(xml.contains("pos=\"0\""), "{xml}");
|
||||
assert!(xml.contains("pos=\"100000\""), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_stop_gradient_is_refused_so_the_caller_can_fall_back() {
|
||||
let single = SceneGradient::Linear {
|
||||
angle_deg: 90.0,
|
||||
opacity: 1.0,
|
||||
stops: vec![SceneGradientStop {
|
||||
offset: 0.0,
|
||||
color: Color::BLACK,
|
||||
}],
|
||||
};
|
||||
assert!(grad_fill(&single, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ mod export_html_structured;
|
|||
mod export_html_template;
|
||||
pub mod export_hyperframes;
|
||||
pub mod export_pdf;
|
||||
pub mod export_pptx;
|
||||
mod figma_convert;
|
||||
mod figma_convert_error;
|
||||
mod import_html_url;
|
||||
|
|
|
|||
Loading…
Reference in a new issue