mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
feat(pptx): export documents as editable PowerPoint presentations
New designcraft-pptx crate (L3) and file.exportPptx command (File › Export PowerPoint…, CLI --export x.pptx). Each page becomes a slide of native objects: shapes with fills, gradients, strokes and effects, pictures cropped like their frames, native tables, and text boxes. Text keeps DesignCraft's composition by default: composed lines end in line breaks, justified spacing becomes character spacing, and leading becomes percentage line spacing (PowerPoint rounds exact spacing to whole points; its 100% line is 1.2 em for every font). Threaded stories split at their frames and wrapped lines get their own boxes. lineBreaks: reflow writes one box per frame in native columns instead. TrueType fonts are embedded as Embedded OpenType when their licences allow. Objects PowerPoint can't represent (placed PDF/EPS, type on a path, some effects) are rasterised through the existing rasterize_where path. Checked in PowerPoint for Mac and LibreOffice: on the sample magazine, word positions match DesignCraft's PDF to within 0.8 pt horizontally and 1.6 pt vertically.
This commit is contained in:
Generated
+18
@@ -939,6 +939,7 @@ dependencies = [
|
||||
"designcraft-idml",
|
||||
"designcraft-images",
|
||||
"designcraft-pdf",
|
||||
"designcraft-pptx",
|
||||
"designcraft-render",
|
||||
"designcraft-textimport",
|
||||
"designcraft-tools",
|
||||
@@ -1058,6 +1059,23 @@ dependencies = [
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-images",
|
||||
"image",
|
||||
"quick-xml 0.38.4",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -22,6 +22,7 @@ designcraft-render = { path = "crates/render" }
|
||||
designcraft-pdf = { path = "crates/pdf" }
|
||||
designcraft-idml = { path = "crates/idml" }
|
||||
designcraft-epub = { path = "crates/epub" }
|
||||
designcraft-pptx = { path = "crates/pptx" }
|
||||
designcraft-textimport = { path = "crates/textimport" }
|
||||
designcraft-format = { path = "crates/format" }
|
||||
designcraft-tools = { path = "crates/tools" }
|
||||
|
||||
@@ -87,7 +87,7 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
|
||||
Line breaks are identical on screen and in PDF.
|
||||
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
|
||||
snapshots, and cached composition.
|
||||
- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap.
|
||||
- **Open.** A documented native format, IDML import and export, PNG and editable PowerPoint export, and PDF on the roadmap.
|
||||
No subscription, no licence server, no telemetry.
|
||||
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
|
||||
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
|
||||
|
||||
@@ -49,6 +49,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
|
||||
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
|
||||
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
|
||||
- File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice.
|
||||
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
|
||||
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
|
||||
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Headless DesignCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
|
||||
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub|.pptx] [--pdf-options JSON] [--all-pages DIR]
|
||||
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
|
||||
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
|
||||
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
@@ -209,6 +209,14 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
|
||||
if out.to_ascii_lowercase().ends_with(".pptx") {
|
||||
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if out.to_ascii_lowercase().ends_with(".idml") {
|
||||
let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
|
||||
@@ -332,6 +340,8 @@ fn script(args: &[String]) -> Result<(), String> {
|
||||
exec(b, "file.exportIdml", p)?;
|
||||
} else if lower.ends_with(".epub") {
|
||||
exec(b, "file.exportEpub", p)?;
|
||||
} else if lower.ends_with(".pptx") {
|
||||
exec(b, "file.exportPptx", p)?;
|
||||
} else {
|
||||
// PNG / JPEG of the first page.
|
||||
b.call("app.export", p)?;
|
||||
|
||||
@@ -18,6 +18,7 @@ designcraft-compose = { workspace = true }
|
||||
designcraft-render = { workspace = true }
|
||||
designcraft-format = { workspace = true }
|
||||
designcraft-epub = { workspace = true }
|
||||
designcraft-pptx = { workspace = true }
|
||||
designcraft-textimport = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-tools = { workspace = true }
|
||||
|
||||
@@ -25,6 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None,
|
||||
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
|
||||
has_doc, export_html),
|
||||
cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",
|
||||
has_doc, export_pptx),
|
||||
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
|
||||
"{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
|
||||
has_story_target, export_text),
|
||||
@@ -66,6 +69,43 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "file.exportPptx";
|
||||
let st = s.doc()?;
|
||||
let line_breaks = match str_param(p, "lineBreaks") {
|
||||
None | Some("keep") => designcraft_pptx::LineBreaks::Keep,
|
||||
Some("reflow") => designcraft_pptx::LineBreaks::Reflow,
|
||||
Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))),
|
||||
};
|
||||
let opts = designcraft_pptx::PptxOptions {
|
||||
pages: pages_param(ID, p, st.doc.page_count())?,
|
||||
line_breaks,
|
||||
title: str_param(p, "title").map(str::to_string),
|
||||
author: str_param(p, "author").map(str::to_string),
|
||||
created: None,
|
||||
embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true),
|
||||
};
|
||||
// Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame
|
||||
// stays text: a picture of it would drop the rest of its story).
|
||||
let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true);
|
||||
let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, |it| {
|
||||
!threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it)))
|
||||
});
|
||||
let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
|
||||
let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if rasterized > 0 {
|
||||
r.warnings.push(format!("{rasterized} object(s) went in as images"));
|
||||
}
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_story_target(s: &Session) -> std::result::Result<(), String> {
|
||||
super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into())
|
||||
}
|
||||
@@ -167,26 +207,31 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
const ID: &str = "file.exportPdf";
|
||||
|
||||
let pages = match p.get("pages") {
|
||||
/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions.
|
||||
fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usize>>> {
|
||||
Ok(match p.get("pages") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?),
|
||||
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?),
|
||||
Some(Value::Array(a)) => {
|
||||
let mut v = Vec::new();
|
||||
for x in a {
|
||||
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?;
|
||||
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?;
|
||||
v.push(n as usize - 1);
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?;
|
||||
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?;
|
||||
Some(vec![n as usize - 1])
|
||||
}
|
||||
Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))),
|
||||
};
|
||||
Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
const ID: &str = "file.exportPdf";
|
||||
|
||||
let pages = pages_param(ID, p, page_count)?;
|
||||
let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false);
|
||||
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
|
||||
let marks = match p.get("marks") {
|
||||
@@ -899,3 +944,57 @@ mod print_group_tests {
|
||||
assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pptx_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn unzip(b64: &str) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
|
||||
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
|
||||
}
|
||||
|
||||
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, name: &str) -> String {
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_exports_editable_slides() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let r = s.execute("file.exportPptx", &json!({})).unwrap();
|
||||
assert_eq!(r["slides"], 4);
|
||||
let mut z = unzip(r["base64"].as_str().unwrap());
|
||||
// The swatch table is a PowerPoint table; the cover picture is embedded; text is text.
|
||||
assert!(read(&mut z, "ppt/slides/slide4.xml").contains("<a:tbl>"));
|
||||
assert!(read(&mut z, "ppt/slides/slide1.xml").contains("<p:pic>"));
|
||||
let text: String = read(&mut z, "ppt/slides/slide2.xml").split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect();
|
||||
assert!(text.contains("Notes on the Grid"), "{text}");
|
||||
assert!(read(&mut z, "ppt/presentation.xml").contains("<p:embeddedFontLst>"));
|
||||
|
||||
let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap();
|
||||
assert_eq!(r["slides"], 2);
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded")));
|
||||
assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err());
|
||||
assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_option_places_a_picture() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap();
|
||||
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap();
|
||||
let shapes = |s: &mut Session| {
|
||||
let r = s.execute("file.exportPptx", &json!({})).unwrap();
|
||||
read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml")
|
||||
};
|
||||
assert!(shapes(&mut s).contains("prst=\"ellipse\""));
|
||||
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap();
|
||||
let x = shapes(&mut s);
|
||||
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "designcraft-pptx"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft PowerPoint (PPTX) export: pages as slides of editable shapes, pictures and text"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
designcraft-fonts = { workspace = true }
|
||||
designcraft-compose = { workspace = true }
|
||||
designcraft-images = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
image = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Font names as PowerPoint looks them up, and nominal advances to measure its line widths.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use designcraft_fonts::FontFace;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::string::StringId;
|
||||
|
||||
/// How a run names its face: the legacy (style-linked) family plus bold and italic flags, which
|
||||
/// is how Office finds a face (`Source Serif 4 Semibold` is its own family; `Bold` is a flag).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RunFont {
|
||||
pub family: String,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fonts {
|
||||
names: HashMap<u32, RunFont>,
|
||||
cmaps: HashMap<u32, HashMap<char, u32>>,
|
||||
/// Glyph → the lowest character mapping to it.
|
||||
reverse: HashMap<u32, HashMap<u32, char>>,
|
||||
/// Faces the slides use, by (family, bold, italic), in first-use order.
|
||||
pub used: Vec<(RunFont, designcraft_fonts::FaceRef)>,
|
||||
}
|
||||
|
||||
fn english(face: &FontFace, id: StringId) -> Option<String> {
|
||||
let f = face.skrifa()?;
|
||||
let mut first = None;
|
||||
for s in f.localized_strings(id) {
|
||||
let text: String = s.chars().collect();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.language().is_none_or(|l| l.starts_with("en")) {
|
||||
return Some(text);
|
||||
}
|
||||
first.get_or_insert(text);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
impl Fonts {
|
||||
/// The run font for a face a slide uses (recorded for embedding).
|
||||
pub fn use_face(&mut self, face: designcraft_fonts::FaceRef) -> RunFont {
|
||||
let rf = self.run_font(face.get());
|
||||
if !self.used.iter().any(|(u, _)| *u == rf) {
|
||||
self.used.push((rf.clone(), face));
|
||||
}
|
||||
rf
|
||||
}
|
||||
|
||||
/// Embedded OpenType for every family used, and the faces that couldn't be embedded.
|
||||
pub fn embedded(&self) -> (Vec<EmbeddedFont>, Vec<String>) {
|
||||
let mut out: Vec<EmbeddedFont> = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for (rf, face) in &self.used {
|
||||
let face = face.get();
|
||||
match eot(face) {
|
||||
Ok(bytes) => {
|
||||
let slot = usize::from(rf.bold) + 2 * usize::from(rf.italic);
|
||||
let at = match out.iter().position(|e| e.family == rf.family) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.push(EmbeddedFont { family: rf.family.clone(), ..Default::default() });
|
||||
out.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some(e) = out.get_mut(at)
|
||||
&& let Some(s) = e.faces.get_mut(slot)
|
||||
{
|
||||
*s = Some(bytes);
|
||||
}
|
||||
}
|
||||
Err(why) => skipped.push(format!(
|
||||
"{} {} ({})",
|
||||
face.family,
|
||||
face.style,
|
||||
if why == NoEmbed::Restricted { "its licence forbids embedding" } else { "not a TrueType font" }
|
||||
)),
|
||||
}
|
||||
}
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
pub fn run_font(&mut self, face: &FontFace) -> RunFont {
|
||||
self.names
|
||||
.entry(face.id())
|
||||
.or_insert_with(|| {
|
||||
let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone());
|
||||
let sub = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()).to_ascii_lowercase();
|
||||
// A named instance of a variable font has no legacy names of its own.
|
||||
if face.is_variable() {
|
||||
let style = face.style.to_ascii_lowercase();
|
||||
let bold = face.weight >= 650.0;
|
||||
let regular = matches!(style.as_str(), "regular" | "italic" | "bold" | "bold italic");
|
||||
let family = if regular { face.family.clone() } else { format!("{} {}", face.family, face.style.replace(" Italic", "")) };
|
||||
return RunFont { family, bold: bold && regular, italic: face.italic };
|
||||
}
|
||||
RunFont { family, bold: sub.contains("bold"), italic: sub.contains("italic") || sub.contains("oblique") }
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Nominal advance of `c` in font units (no kerning or features), as an application without
|
||||
/// the composer's spacing would set it.
|
||||
pub fn advance(&mut self, face: &FontFace, c: char) -> Option<f64> {
|
||||
self.cmap(face).get(&c).map(|g| face.advance(*g))
|
||||
}
|
||||
|
||||
pub fn glyph(&mut self, face: &FontFace, c: char) -> Option<u32> {
|
||||
self.cmap(face).get(&c).copied()
|
||||
}
|
||||
|
||||
/// The character a glyph shows (the lowest code point mapping to it).
|
||||
pub fn char_of(&mut self, face: &FontFace, gid: u32) -> Option<char> {
|
||||
let map = self.reverse.entry(face.id()).or_insert_with(|| {
|
||||
let mut m = HashMap::new();
|
||||
for (c, g) in face.chars() {
|
||||
m.entry(g).or_insert(c);
|
||||
}
|
||||
m
|
||||
});
|
||||
map.get(&gid).copied()
|
||||
}
|
||||
|
||||
fn cmap(&mut self, face: &FontFace) -> &HashMap<char, u32> {
|
||||
self.cmaps.entry(face.id()).or_insert_with(|| face.chars().into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A family embedded in the presentation: its regular, bold, italic and bold italic faces.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EmbeddedFont {
|
||||
pub family: String,
|
||||
pub faces: [Option<Vec<u8>>; 4],
|
||||
}
|
||||
|
||||
fn be16(d: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*d.get(at)?, *d.get(at + 1)?]))
|
||||
}
|
||||
|
||||
fn be32(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([*d.get(at)?, *d.get(at + 1)?, *d.get(at + 2)?, *d.get(at + 3)?]))
|
||||
}
|
||||
|
||||
fn utf16_name(out: &mut Vec<u8>, s: &str) {
|
||||
let units: Vec<u16> = s.encode_utf16().collect();
|
||||
let bytes = (units.len() * 2).min(u16::MAX as usize & !1);
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&(bytes as u16).to_le_bytes());
|
||||
for u in units.iter().take(bytes / 2) {
|
||||
out.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a face can't be embedded.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum NoEmbed {
|
||||
/// The font's licence (OS/2 `fsType`) forbids embedding.
|
||||
Restricted,
|
||||
/// PowerPoint embeds TrueType outlines only (not CFF, collections or variable instances).
|
||||
Format,
|
||||
}
|
||||
|
||||
/// The face as Embedded OpenType (uncompressed, version 2.1), the form PowerPoint stores
|
||||
/// embedded fonts in.
|
||||
pub fn eot(face: &FontFace) -> Result<Vec<u8>, NoEmbed> {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
use skrifa::raw::types::Tag;
|
||||
let data = face.data();
|
||||
if face.index() != 0 || face.is_variable() || data.get(..4) == Some(b"OTTO") || data.get(..4) == Some(b"ttcf") {
|
||||
return Err(NoEmbed::Format);
|
||||
}
|
||||
let f = face.skrifa().ok_or(NoEmbed::Format)?;
|
||||
if f.glyf().is_err() {
|
||||
return Err(NoEmbed::Format);
|
||||
}
|
||||
let os2 = f.table_data(Tag::new(b"OS/2")).map(|t| t.as_bytes().to_vec()).unwrap_or_default();
|
||||
let head = f.table_data(Tag::new(b"head")).map(|t| t.as_bytes().to_vec()).unwrap_or_default();
|
||||
let fs_type = be16(&os2, 8).unwrap_or(0);
|
||||
// Restricted License embedding (bit 1 alone): may not be embedded.
|
||||
if fs_type & 0x000F == 0x0002 {
|
||||
return Err(NoEmbed::Restricted);
|
||||
}
|
||||
let mut h: Vec<u8> = Vec::with_capacity(256);
|
||||
h.extend_from_slice(&[0; 8]); // EOTSize, FontDataSize (filled in below)
|
||||
h.extend_from_slice(&0x0002_0001u32.to_le_bytes());
|
||||
h.extend_from_slice(&0u32.to_le_bytes()); // Flags: no compression, no obfuscation
|
||||
h.extend_from_slice(os2.get(32..42).unwrap_or(&[0; 10]));
|
||||
h.push(1); // DEFAULT_CHARSET
|
||||
h.push(u8::from(face.italic));
|
||||
h.extend_from_slice(&u32::from(be16(&os2, 4).unwrap_or(400)).to_le_bytes());
|
||||
h.extend_from_slice(&fs_type.to_le_bytes());
|
||||
h.extend_from_slice(&0x504Cu16.to_le_bytes());
|
||||
for i in 0..4 {
|
||||
h.extend_from_slice(&be32(&os2, 42 + 4 * i).unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
let has_codepages = be16(&os2, 0).unwrap_or(0) >= 1;
|
||||
for i in 0..2 {
|
||||
h.extend_from_slice(&(if has_codepages { be32(&os2, 78 + 4 * i).unwrap_or(0) } else { 0 }).to_le_bytes());
|
||||
}
|
||||
h.extend_from_slice(&be32(&head, 8).unwrap_or(0).to_le_bytes());
|
||||
h.extend_from_slice(&[0; 16]); // Reserved1–4
|
||||
let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone());
|
||||
let style = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone());
|
||||
let version = english(face, StringId::VERSION_STRING).unwrap_or_default();
|
||||
let full = english(face, StringId::FULL_NAME).unwrap_or_else(|| format!("{family} {style}"));
|
||||
utf16_name(&mut h, &family);
|
||||
utf16_name(&mut h, &style);
|
||||
utf16_name(&mut h, &version);
|
||||
utf16_name(&mut h, &full);
|
||||
utf16_name(&mut h, ""); // RootString
|
||||
let total = h.len().checked_add(data.len()).and_then(|n| u32::try_from(n).ok()).ok_or(NoEmbed::Format)?;
|
||||
h[0..4].copy_from_slice(&total.to_le_bytes());
|
||||
h[4..8].copy_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
h.extend_from_slice(data);
|
||||
Ok(h)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! PowerPoint export (File › Export › PowerPoint).
|
||||
//!
|
||||
//! Every exported page becomes a slide of native, editable objects: frames become shapes (preset
|
||||
//! rectangles and ellipses, custom geometry otherwise) with their fills, strokes and effects,
|
||||
//! placed images become pictures cropped like their frames, and text frames become text boxes.
|
||||
//!
|
||||
//! Text keeps DesignCraft's composition by default ([`LineBreaks::Keep`]): each composed line ends
|
||||
//! in a line break, justified word spacing and tracking become character spacing, and leading
|
||||
//! becomes percentage line spacing (PowerPoint rounds exact point spacing to whole points; its
|
||||
//! 100% line is 1.2 em for every font, so a percentage is exact). A threaded story is split at its
|
||||
//! frames, and the lines a text wrap pushes aside become boxes of their own, since PowerPoint has
|
||||
//! neither threads nor wraps. [`LineBreaks::Reflow`] instead writes one box per frame with native
|
||||
//! columns and lets PowerPoint break the lines.
|
||||
//!
|
||||
//! Parent-page items are copied onto each slide; layers are flattened in stacking order. What has
|
||||
//! no PowerPoint equivalent (blend modes, type on a path, some effects) is approximated or
|
||||
//! dropped and reported in [`ExportReport::warnings`]; [`needs_raster`] names the objects a caller
|
||||
//! should rasterise first.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fonts;
|
||||
mod package;
|
||||
mod slide;
|
||||
mod table;
|
||||
mod text;
|
||||
mod xml;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::Document;
|
||||
|
||||
pub use slide::needs_raster;
|
||||
|
||||
/// How text frames become text boxes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LineBreaks {
|
||||
/// Keep DesignCraft's line breaks, word spacing and leading (looks the same; edits don't
|
||||
/// re-break lines).
|
||||
#[default]
|
||||
Keep,
|
||||
/// One box per frame with native columns; PowerPoint breaks the lines (edits reflow; lines,
|
||||
/// hyphenation and wraps differ).
|
||||
Reflow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PptxOptions {
|
||||
/// Document pages to export (0-based positions); all when `None`.
|
||||
pub pages: Option<Vec<usize>>,
|
||||
pub line_breaks: LineBreaks,
|
||||
pub title: Option<String>,
|
||||
pub author: Option<String>,
|
||||
/// Creation time (Unix seconds) for the document properties; none when `None`.
|
||||
pub created: Option<i64>,
|
||||
/// Embed the TrueType fonts the text uses (when their licences allow it), so the slides
|
||||
/// look the same where they aren't installed.
|
||||
pub embed_fonts: bool,
|
||||
}
|
||||
|
||||
/// The exported presentation and what could not be represented exactly.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ExportReport {
|
||||
pub bytes: Vec<u8>,
|
||||
pub slides: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PptxError {
|
||||
#[error("the document has no pages to export")]
|
||||
NoPages,
|
||||
#[error("page {0} does not exist")]
|
||||
BadPage(usize),
|
||||
#[error("could not write the presentation: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PptxError>;
|
||||
|
||||
/// Export `doc` as a PowerPoint presentation.
|
||||
pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result<ExportReport> {
|
||||
let count = doc.page_count();
|
||||
let pages: Vec<usize> = match &opts.pages {
|
||||
Some(v) => v.clone(),
|
||||
None => (0..count).collect(),
|
||||
};
|
||||
if pages.is_empty() {
|
||||
return Err(PptxError::NoPages);
|
||||
}
|
||||
if let Some(bad) = pages.iter().find(|p| **p >= count) {
|
||||
return Err(PptxError::BadPage(bad + 1));
|
||||
}
|
||||
let mut ex = slide::Exporter::new(doc, cache, opts);
|
||||
let mut slides = Vec::with_capacity(pages.len());
|
||||
for abs in &pages {
|
||||
slides.push(ex.slide(*abs)?);
|
||||
}
|
||||
let size = slides.first().map(|s| s.size).ok_or(PptxError::NoPages)?;
|
||||
if slides.iter().any(|s| (s.size.0 - size.0).abs() > 0.5 || (s.size.1 - size.1).abs() > 0.5) {
|
||||
ex.warn("pages of different sizes: PowerPoint has one slide size, so every slide uses the first page's");
|
||||
}
|
||||
let fonts = if opts.embed_fonts {
|
||||
let (fonts, skipped) = ex.fonts.embedded();
|
||||
if !skipped.is_empty() {
|
||||
ex.warn(format!("fonts not embedded: {}", skipped.join(", ")));
|
||||
}
|
||||
fonts
|
||||
} else {
|
||||
let mut names: Vec<String> = ex.fonts.used.iter().map(|(f, _)| f.family.clone()).collect();
|
||||
names.dedup();
|
||||
if !names.is_empty() {
|
||||
ex.warn(format!("fonts are not embedded; the slides need {} installed to look the same", names.join(", ")));
|
||||
}
|
||||
Vec::new()
|
||||
};
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let bytes = package::write(&package::Package {
|
||||
size,
|
||||
slides: &slides,
|
||||
media: &ex.media,
|
||||
fonts: &fonts,
|
||||
title: &title,
|
||||
author: opts.author.as_deref().unwrap_or(""),
|
||||
created: opts.created,
|
||||
})?;
|
||||
Ok(ExportReport { bytes, slides: slides.len(), warnings: ex.warnings })
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! The PresentationML package: one blank master and layout, a theme, the slides and their media.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
use crate::fonts::EmbeddedFont;
|
||||
use crate::xml::{emu_len, esc};
|
||||
use crate::{PptxError, Result};
|
||||
|
||||
pub const NS: &str = r#"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 DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
|
||||
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
pub const REL_IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||
|
||||
/// A relationship from a slide to another part.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rel {
|
||||
pub id: String,
|
||||
pub kind: &'static str,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// One slide: its shape tree content (everything inside `<p:spTree>` after the group
|
||||
/// properties) and relationships beyond the layout (`rId1`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Slide {
|
||||
/// Page size in points.
|
||||
pub size: (f64, f64),
|
||||
pub shapes: String,
|
||||
pub rels: Vec<Rel>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A media part (`ppt/media/<name>`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Media {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Package<'a> {
|
||||
pub size: (f64, f64),
|
||||
pub slides: &'a [Slide],
|
||||
pub media: &'a [Media],
|
||||
pub fonts: &'a [EmbeddedFont],
|
||||
pub title: &'a str,
|
||||
pub author: &'a str,
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
fn rels(list: &[(String, &str, String)]) -> String {
|
||||
let mut s = format!("{DECL}<Relationships xmlns=\"{REL_NS}\">");
|
||||
for (id, kind, target) in list {
|
||||
let _ = write!(s, "<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{}\"/>", esc(target));
|
||||
}
|
||||
s.push_str("</Relationships>");
|
||||
s
|
||||
}
|
||||
|
||||
/// PowerPoint accepts slide sizes from 1 inch to 56 inches.
|
||||
fn slide_extent(pt: f64) -> i64 {
|
||||
emu_len(pt).clamp(914_400, 51_206_400)
|
||||
}
|
||||
|
||||
pub fn write(p: &Package) -> Result<Vec<u8>> {
|
||||
let mut parts: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let mut put = |name: &str, s: String| parts.push((name.to_string(), s.into_bytes()));
|
||||
let n = p.slides.len();
|
||||
|
||||
// Content types.
|
||||
let mut ct = format!(
|
||||
"{DECL}<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\"/>\
|
||||
<Default Extension=\"png\" ContentType=\"image/png\"/>\
|
||||
<Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\
|
||||
<Default Extension=\"gif\" ContentType=\"image/gif\"/>\
|
||||
<Default Extension=\"svg\" ContentType=\"image/svg+xml\"/>\
|
||||
<Default Extension=\"fntdata\" ContentType=\"application/x-fontdata\"/>\
|
||||
<Override PartName=\"/ppt/presentation.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\"/>\
|
||||
<Override PartName=\"/ppt/slideMasters/slideMaster1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml\"/>\
|
||||
<Override PartName=\"/ppt/slideLayouts/slideLayout1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml\"/>\
|
||||
<Override PartName=\"/ppt/theme/theme1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.theme+xml\"/>\
|
||||
<Override PartName=\"/ppt/presProps.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presProps+xml\"/>\
|
||||
<Override PartName=\"/ppt/viewProps.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml\"/>\
|
||||
<Override PartName=\"/ppt/tableStyles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml\"/>\
|
||||
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>\
|
||||
<Override PartName=\"/docProps/app.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.extended-properties+xml\"/>"
|
||||
);
|
||||
for i in 1..=n {
|
||||
let _ = write!(
|
||||
ct,
|
||||
"<Override PartName=\"/ppt/slides/slide{i}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>"
|
||||
);
|
||||
}
|
||||
ct.push_str("</Types>");
|
||||
put("[Content_Types].xml", ct);
|
||||
|
||||
put(
|
||||
"_rels/.rels",
|
||||
rels(&[
|
||||
("rId1".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "ppt/presentation.xml".into()),
|
||||
("rId2".into(), "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml".into()),
|
||||
("rId3".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml".into()),
|
||||
]),
|
||||
);
|
||||
|
||||
// Presentation: master rId1, slides rId2.., then the property parts.
|
||||
let (cx, cy) = (slide_extent(p.size.0), slide_extent(p.size.1));
|
||||
let mut ids = String::new();
|
||||
let mut prels: Vec<(String, &str, String)> = vec![(
|
||||
"rId1".into(),
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
|
||||
"slideMasters/slideMaster1.xml".into(),
|
||||
)];
|
||||
for i in 1..=n {
|
||||
let _ = write!(ids, "<p:sldId id=\"{}\" r:id=\"rId{}\"/>", 255 + i, i + 1);
|
||||
prels.push((
|
||||
format!("rId{}", i + 1),
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
|
||||
format!("slides/slide{i}.xml"),
|
||||
));
|
||||
}
|
||||
for (k, (kind, target)) in
|
||||
[("presProps", "presProps.xml"), ("viewProps", "viewProps.xml"), ("theme", "theme/theme1.xml"), ("tableStyles", "tableStyles.xml")]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
prels.push((format!("rId{}", n + 2 + k), rel_kind(kind), target.to_string()));
|
||||
}
|
||||
// Embedded fonts: one part per face, listed per family in regular, bold, italic, bold italic.
|
||||
let mut font_list = String::new();
|
||||
let mut font_parts: Vec<(String, &[u8])> = Vec::new();
|
||||
for f in p.fonts {
|
||||
let _ = write!(font_list, "<p:embeddedFont><p:font typeface=\"{}\"/>", esc(&f.family));
|
||||
for (slot, tag) in ["regular", "bold", "italic", "boldItalic"].iter().enumerate() {
|
||||
if let Some(Some(bytes)) = f.faces.get(slot) {
|
||||
let id = format!("rId{}", prels.len() + 1);
|
||||
let target = format!("fonts/font{}.fntdata", font_parts.len() + 1);
|
||||
let _ = write!(font_list, "<p:{tag} r:id=\"{id}\"/>");
|
||||
prels.push((id, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", target.clone()));
|
||||
font_parts.push((format!("ppt/{target}"), bytes.as_slice()));
|
||||
}
|
||||
}
|
||||
font_list.push_str("</p:embeddedFont>");
|
||||
}
|
||||
let (embed_attr, font_list) = if font_parts.is_empty() {
|
||||
("", String::new())
|
||||
} else {
|
||||
(" embedTrueTypeFonts=\"1\"", format!("<p:embeddedFontLst>{font_list}</p:embeddedFontLst>"))
|
||||
};
|
||||
put(
|
||||
"ppt/presentation.xml",
|
||||
format!(
|
||||
"{DECL}<p:presentation {NS} saveSubsetFonts=\"1\"{embed_attr}><p:sldMasterIdLst><p:sldMasterId id=\"2147483648\" r:id=\"rId1\"/></p:sldMasterIdLst>\
|
||||
<p:sldIdLst>{ids}</p:sldIdLst><p:sldSz cx=\"{cx}\" cy=\"{cy}\"/><p:notesSz cx=\"6858000\" cy=\"9144000\"/>{font_list}\
|
||||
<p:defaultTextStyle><a:defPPr><a:defRPr lang=\"en-US\"/></a:defPPr></p:defaultTextStyle></p:presentation>"
|
||||
),
|
||||
);
|
||||
put("ppt/_rels/presentation.xml.rels", rels(&prels));
|
||||
|
||||
put("ppt/presProps.xml", format!("{DECL}<p:presentationPr {NS}/>"));
|
||||
put(
|
||||
"ppt/viewProps.xml",
|
||||
format!(
|
||||
"{DECL}<p:viewPr {NS}><p:normalViewPr><p:restoredLeft sz=\"15620\"/><p:restoredTop sz=\"94660\"/></p:normalViewPr>\
|
||||
<p:slideViewPr><p:cSldViewPr><p:cViewPr varScale=\"1\"><p:scale><a:sx n=\"100\" d=\"100\"/><a:sy n=\"100\" d=\"100\"/></p:scale><p:origin x=\"0\" y=\"0\"/></p:cViewPr></p:cSldViewPr></p:slideViewPr>\
|
||||
<p:gridSpacing cx=\"76200\" cy=\"76200\"/></p:viewPr>"
|
||||
),
|
||||
);
|
||||
put(
|
||||
"ppt/tableStyles.xml",
|
||||
format!(
|
||||
"{DECL}<a:tblStyleLst xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" def=\"{{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}}\"/>"
|
||||
),
|
||||
);
|
||||
|
||||
// One blank master and layout.
|
||||
let tree = "<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>";
|
||||
put(
|
||||
"ppt/slideMasters/slideMaster1.xml",
|
||||
format!(
|
||||
"{DECL}<p:sldMaster {NS}><p:cSld><p:bg><p:bgRef idx=\"1001\"><a:schemeClr val=\"bg1\"/></p:bgRef></p:bg>{tree}</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:txStyles><p:titleStyle><a:lvl1pPr><a:defRPr sz=\"4400\"/></a:lvl1pPr></p:titleStyle><p:bodyStyle><a:lvl1pPr><a:defRPr sz=\"1800\"/></a:lvl1pPr></p:bodyStyle><p:otherStyle><a:lvl1pPr><a:defRPr sz=\"1800\"/></a:lvl1pPr></p:otherStyle></p:txStyles></p:sldMaster>"
|
||||
),
|
||||
);
|
||||
put(
|
||||
"ppt/slideMasters/_rels/slideMaster1.xml.rels",
|
||||
rels(&[
|
||||
(
|
||||
"rId1".into(),
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
|
||||
"../slideLayouts/slideLayout1.xml".into(),
|
||||
),
|
||||
("rId2".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "../theme/theme1.xml".into()),
|
||||
]),
|
||||
);
|
||||
put(
|
||||
"ppt/slideLayouts/slideLayout1.xml",
|
||||
format!(
|
||||
"{DECL}<p:sldLayout {NS} type=\"blank\" preserve=\"1\"><p:cSld name=\"Blank\">{tree}</p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>"
|
||||
),
|
||||
);
|
||||
put(
|
||||
"ppt/slideLayouts/_rels/slideLayout1.xml.rels",
|
||||
rels(&[(
|
||||
"rId1".into(),
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
|
||||
"../slideMasters/slideMaster1.xml".into(),
|
||||
)]),
|
||||
);
|
||||
put("ppt/theme/theme1.xml", theme());
|
||||
|
||||
for (i, s) in p.slides.iter().enumerate() {
|
||||
let name = if s.name.is_empty() { String::new() } else { format!(" name=\"{}\"", esc(&s.name)) };
|
||||
put(
|
||||
&format!("ppt/slides/slide{}.xml", i + 1),
|
||||
format!(
|
||||
"{DECL}<p:sld {NS}><p:cSld{name}><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:sld>",
|
||||
s.shapes
|
||||
),
|
||||
);
|
||||
let mut list: Vec<(String, &str, String)> = vec![(
|
||||
"rId1".into(),
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
|
||||
"../slideLayouts/slideLayout1.xml".into(),
|
||||
)];
|
||||
list.extend(s.rels.iter().map(|r| (r.id.clone(), r.kind, r.target.clone())));
|
||||
put(&format!("ppt/slides/_rels/slide{}.xml.rels", i + 1), rels(&list));
|
||||
}
|
||||
|
||||
put("docProps/core.xml", core(p));
|
||||
put(
|
||||
"docProps/app.xml",
|
||||
format!(
|
||||
"{DECL}<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\"><Application>DesignCraft</Application><Slides>{n}</Slides><PresentationFormat>Custom</PresentationFormat></Properties>"
|
||||
),
|
||||
);
|
||||
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let zerr = |e: zip::result::ZipError| PptxError::Write(e.to_string());
|
||||
let ioerr = |e: std::io::Error| PptxError::Write(e.to_string());
|
||||
for (name, bytes) in &parts {
|
||||
zw.start_file(name.as_str(), deflate).map_err(zerr)?;
|
||||
zw.write_all(bytes).map_err(ioerr)?;
|
||||
}
|
||||
for (name, bytes) in &font_parts {
|
||||
zw.start_file(name.as_str(), deflate).map_err(zerr)?;
|
||||
zw.write_all(bytes).map_err(ioerr)?;
|
||||
}
|
||||
for m in p.media {
|
||||
// Compressed formats gain nothing from deflate.
|
||||
let opts = if m.name.ends_with(".svg") { deflate } else { stored };
|
||||
zw.start_file(format!("ppt/media/{}", m.name), opts).map_err(zerr)?;
|
||||
zw.write_all(&m.bytes).map_err(ioerr)?;
|
||||
}
|
||||
Ok(zw.finish().map_err(zerr)?.into_inner())
|
||||
}
|
||||
|
||||
fn rel_kind(k: &str) -> &'static str {
|
||||
match k {
|
||||
"presProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps",
|
||||
"viewProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps",
|
||||
"theme" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
|
||||
_ => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
|
||||
}
|
||||
}
|
||||
|
||||
fn core(p: &Package) -> String {
|
||||
let mut s = format!(
|
||||
"{DECL}<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
|
||||
);
|
||||
if !p.title.is_empty() {
|
||||
let _ = write!(s, "<dc:title>{}</dc:title>", esc(p.title));
|
||||
}
|
||||
if !p.author.is_empty() {
|
||||
let _ = write!(s, "<dc:creator>{}</dc:creator>", esc(p.author));
|
||||
}
|
||||
if let Some(t) = p.created {
|
||||
let (y, mo, d, h, mi, se) = civil(t);
|
||||
let _ = write!(s, "<dcterms:created xsi:type=\"dcterms:W3CDTF\">{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z</dcterms:created>");
|
||||
}
|
||||
s.push_str("</cp:coreProperties>");
|
||||
s
|
||||
}
|
||||
|
||||
/// Unix seconds → UTC civil date and time (proleptic Gregorian).
|
||||
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
(y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32)
|
||||
}
|
||||
|
||||
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
|
||||
fn theme() -> String {
|
||||
let fill3 = "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>".repeat(3);
|
||||
let ln = "<a:ln w=\"6350\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:ln>".repeat(3);
|
||||
let fx = "<a:effectStyle><a:effectLst/></a:effectStyle>".repeat(3);
|
||||
format!(
|
||||
"{DECL}<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" name=\"DesignCraft\"><a:themeElements>\
|
||||
<a:clrScheme name=\"DesignCraft\"><a:dk1><a:srgbClr val=\"000000\"/></a:dk1><a:lt1><a:srgbClr val=\"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=\"DesignCraft\"><a:majorFont><a:latin typeface=\"Arial\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:majorFont><a:minorFont><a:latin typeface=\"Arial\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:minorFont></a:fontScheme>\
|
||||
<a:fmtScheme name=\"DesignCraft\"><a:fillStyleLst>{fill3}</a:fillStyleLst><a:lnStyleLst>{ln}</a:lnStyleLst><a:effectStyleLst>{fx}</a:effectStyleLst><a:bgFillStyleLst>{fill3}</a:bgFillStyleLst></a:fmtScheme>\
|
||||
</a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::civil;
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
|
||||
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
|
||||
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
//! A page as a slide: items in stacking order as shapes, pictures, groups and text boxes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use designcraft_color::swatch::resolve_gradient;
|
||||
use designcraft_color::{BlendMode, GradientKind};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Arrowhead, AssetId, Cap, Content, Document, Graphic, Item, Join, Shape, Stroke, StrokeAlign, StrokeType};
|
||||
use designcraft_geom::kurbo::{PathEl, Shape as _};
|
||||
use designcraft_geom::{Affine, BezPath, Point, Rect, Vec2, corners};
|
||||
|
||||
use crate::fonts::Fonts;
|
||||
use crate::package::{Media, REL_IMAGE, Rel, Slide};
|
||||
use crate::xml::{angle, emu, emu_len, esc, hex, pct};
|
||||
use crate::{PptxError, PptxOptions, Result};
|
||||
|
||||
/// Objects PowerPoint can't show as editable objects, which a caller should rasterise before
|
||||
/// exporting: placed PDF and EPS graphics, type on a path, and effects without a DrawingML
|
||||
/// counterpart.
|
||||
pub fn needs_raster(doc: &Document, it: &Item) -> bool {
|
||||
let fx = &it.effects;
|
||||
let effects = fx.inner_glow.on || fx.bevel.on || fx.satin.on || fx.directional_feather.on || fx.gradient_feather.on;
|
||||
let placed = |g: &Graphic| {
|
||||
doc.assets.get(&g.asset).is_some_and(|a| {
|
||||
let m = a.mime.as_str();
|
||||
m == "application/pdf" || m.contains("postscript") || m.contains("illustrator")
|
||||
})
|
||||
};
|
||||
let on_path = it.text_frame().is_some_and(|t| t.options.path.is_some());
|
||||
effects || on_path || it.graphic().is_some_and(placed) || it.children().iter().any(|c| needs_raster(doc, c))
|
||||
}
|
||||
|
||||
/// A DrawingML transform box (slide space, points).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct XfrmBox {
|
||||
pub rect: Rect,
|
||||
pub rot: f64,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
impl XfrmBox {
|
||||
pub fn plain(rect: Rect) -> Self {
|
||||
XfrmBox { rect, rot: 0.0, flip_h: false, flip_v: false }
|
||||
}
|
||||
|
||||
pub fn xml(&self) -> String {
|
||||
let mut a = String::new();
|
||||
let r = angle(self.rot);
|
||||
if r != 0 {
|
||||
let _ = write!(a, " rot=\"{r}\"");
|
||||
}
|
||||
if self.flip_h {
|
||||
a.push_str(" flipH=\"1\"");
|
||||
}
|
||||
if self.flip_v {
|
||||
a.push_str(" flipV=\"1\"");
|
||||
}
|
||||
format!(
|
||||
"<a:xfrm{a}><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></a:xfrm>",
|
||||
emu(self.rect.x0),
|
||||
emu(self.rect.y0),
|
||||
emu_len(self.rect.width()),
|
||||
emu_len(self.rect.height())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) ·
|
||||
/// [sx, shear·sy; 0, sy]`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Decomposed {
|
||||
pub rot: f64,
|
||||
pub sx: f64,
|
||||
pub sy: f64,
|
||||
pub shear: f64,
|
||||
}
|
||||
|
||||
pub fn decompose(m: Affine) -> Decomposed {
|
||||
let [a, b, c, d, _, _] = m.as_coeffs();
|
||||
let sx = a.hypot(b);
|
||||
let rot = b.atan2(a);
|
||||
let (cos, sin) = (rot.cos(), rot.sin());
|
||||
let c2 = cos * c + sin * d;
|
||||
let d2 = -sin * c + cos * d;
|
||||
let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 };
|
||||
Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear }
|
||||
}
|
||||
|
||||
/// The box `inner` occupies under `m` when `m` has no shear: rotated about its centre, flipped
|
||||
/// vertically for a negative determinant. `None` for sheared transforms.
|
||||
pub fn box_of(inner: Rect, m: Affine) -> Option<(XfrmBox, Decomposed)> {
|
||||
let dm = decompose(m);
|
||||
if dm.shear.abs() > 1e-6 || !dm.sx.is_finite() || !dm.sy.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c = m * inner.center();
|
||||
let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs());
|
||||
let rect = Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
|
||||
Some((XfrmBox { rect, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 }, dm))
|
||||
}
|
||||
|
||||
/// Shape geometry: a preset, or a custom path in the box's own (unrotated) space.
|
||||
pub struct Geometry {
|
||||
pub xfrm: XfrmBox,
|
||||
pub xml: String,
|
||||
}
|
||||
|
||||
fn custom_geometry(bp: &BezPath, w: f64, h: f64, filled: bool) -> String {
|
||||
let (we, he) = (emu_len(w).max(1), emu_len(h).max(1));
|
||||
let mut p = format!(
|
||||
"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l=\"0\" t=\"0\" r=\"r\" b=\"b\"/><a:pathLst><a:path w=\"{we}\" h=\"{he}\"{}>",
|
||||
if filled { "" } else { " fill=\"none\"" }
|
||||
);
|
||||
let pt = |q: Point| format!("<a:pt x=\"{}\" y=\"{}\"/>", emu(q.x), emu(q.y));
|
||||
let mut last = Point::ZERO;
|
||||
for el in bp.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(a) => {
|
||||
let _ = write!(p, "<a:moveTo>{}</a:moveTo>", pt(a));
|
||||
last = a;
|
||||
}
|
||||
PathEl::LineTo(a) => {
|
||||
let _ = write!(p, "<a:lnTo>{}</a:lnTo>", pt(a));
|
||||
last = a;
|
||||
}
|
||||
PathEl::QuadTo(a, b) => {
|
||||
// As a cubic: PowerPoint's quadratic support is patchy.
|
||||
let c1 = last + (a - last) * (2.0 / 3.0);
|
||||
let c2 = b + (a - b) * (2.0 / 3.0);
|
||||
let _ = write!(p, "<a:cubicBezTo>{}{}{}</a:cubicBezTo>", pt(c1), pt(c2), pt(b));
|
||||
last = b;
|
||||
}
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
let _ = write!(p, "<a:cubicBezTo>{}{}{}</a:cubicBezTo>", pt(a), pt(b), pt(c));
|
||||
last = c;
|
||||
}
|
||||
PathEl::ClosePath => p.push_str("<a:close/>"),
|
||||
}
|
||||
}
|
||||
p.push_str("</a:path></a:pathLst></a:custGeom>");
|
||||
p
|
||||
}
|
||||
|
||||
fn preset(name: &str) -> String {
|
||||
format!("<a:prstGeom prst=\"{name}\"><a:avLst/></a:prstGeom>")
|
||||
}
|
||||
|
||||
/// Is `bp` exactly the rectangle `r` (any start corner and direction)?
|
||||
fn is_rect(bp: &BezPath, r: Rect) -> bool {
|
||||
let mut pts = Vec::new();
|
||||
for el in bp.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
|
||||
PathEl::ClosePath => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).hypot() < 1e-6) {
|
||||
pts.pop();
|
||||
}
|
||||
pts.len() == 4
|
||||
&& pts.iter().all(|p| ((p.x - r.x0).abs() < 1e-6 || (p.x - r.x1).abs() < 1e-6) && ((p.y - r.y0).abs() < 1e-6 || (p.y - r.y1).abs() < 1e-6))
|
||||
}
|
||||
|
||||
/// The geometry of `it` (its path, corner options applied) under `m` (inner → slide).
|
||||
pub fn geometry(it: &Item, m: Affine) -> Geometry {
|
||||
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
|
||||
let closed = it.path.is_closed();
|
||||
let inner = bp.bounding_box();
|
||||
if let Some((xfrm, dm)) = box_of(inner, m) {
|
||||
if it.corners.is_none() && is_rect(&bp, inner) {
|
||||
return Geometry { xfrm, xml: preset("rect") };
|
||||
}
|
||||
if it.shape == Shape::Oval && it.corners.is_none() && closed {
|
||||
return Geometry { xfrm, xml: preset("ellipse") };
|
||||
}
|
||||
// The path in the box's own space: scaled (not flipped: the box flips), from its corner.
|
||||
let (kx, ky) = (dm.sx.abs(), dm.sy.abs());
|
||||
let mut local = bp.clone();
|
||||
local.apply_affine(Affine::scale_non_uniform(kx, ky) * Affine::translate(-inner.origin().to_vec2()));
|
||||
return Geometry { xfrm, xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed) };
|
||||
}
|
||||
// Sheared: the transformed path in slide space.
|
||||
let mut pb = bp;
|
||||
pb.apply_affine(m);
|
||||
let b = pb.bounding_box();
|
||||
pb.apply_affine(Affine::translate(-b.origin().to_vec2()));
|
||||
Geometry { xfrm: XfrmBox::plain(b), xml: custom_geometry(&pb, b.width(), b.height(), closed) }
|
||||
}
|
||||
|
||||
pub struct Exporter<'a> {
|
||||
pub doc: &'a Document,
|
||||
pub cache: &'a Cache,
|
||||
pub opts: &'a PptxOptions,
|
||||
pub warnings: Vec<String>,
|
||||
pub media: Vec<Media>,
|
||||
/// Asset → (picture media name, SVG media name).
|
||||
media_of: HashMap<AssetId, Option<(String, Option<String>)>>,
|
||||
pub fonts: Fonts,
|
||||
// Per slide.
|
||||
pub out: String,
|
||||
next_id: u32,
|
||||
rels: Vec<Rel>,
|
||||
rel_of: HashMap<String, String>,
|
||||
/// Spread space → slide space.
|
||||
pub to_slide: Affine,
|
||||
/// The page's bleed area in spread space (items outside it are left out).
|
||||
clip: Rect,
|
||||
pub page_name: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
pub fn new(doc: &'a Document, cache: &'a Cache, opts: &'a PptxOptions) -> Self {
|
||||
Exporter {
|
||||
doc,
|
||||
cache,
|
||||
opts,
|
||||
warnings: Vec::new(),
|
||||
media: Vec::new(),
|
||||
media_of: HashMap::new(),
|
||||
fonts: Fonts::default(),
|
||||
out: String::new(),
|
||||
next_id: 2,
|
||||
rels: Vec::new(),
|
||||
rel_of: HashMap::new(),
|
||||
to_slide: Affine::IDENTITY,
|
||||
clip: Rect::ZERO,
|
||||
page_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warn(&mut self, w: impl Into<String>) {
|
||||
let w = w.into();
|
||||
if !self.warnings.contains(&w) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&mut self) -> u32 {
|
||||
let i = self.next_id;
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
i
|
||||
}
|
||||
|
||||
pub fn slide(&mut self, abs: usize) -> Result<Slide> {
|
||||
let doc = self.doc;
|
||||
let (si, pi) = doc.page_loc(abs).ok_or(PptxError::BadPage(abs + 1))?;
|
||||
let sp = doc.spreads.get(si).ok_or(PptxError::BadPage(abs + 1))?;
|
||||
let page = sp.pages.get(pi).ok_or(PptxError::BadPage(abs + 1))?;
|
||||
let bounds = page.bounds();
|
||||
self.out.clear();
|
||||
self.next_id = 2;
|
||||
self.rels.clear();
|
||||
self.rel_of.clear();
|
||||
self.to_slide = Affine::translate(-bounds.origin().to_vec2());
|
||||
let b = doc.settings.bleed;
|
||||
self.clip = Rect::new(bounds.x0 - b[2].max(b[3]), bounds.y0 - b[0], bounds.x1 + b[2].max(b[3]), bounds.y1 + b[1]);
|
||||
let page_name = doc.page_name(abs);
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || !layer.printable {
|
||||
continue;
|
||||
}
|
||||
if page.show_parent_items
|
||||
&& let Some((ppi, ppage)) = doc.parent_page_for(abs)
|
||||
&& let Some(parent) = doc.parents.get(ppi)
|
||||
&& let Some(pp) = parent.pages.get(ppage)
|
||||
{
|
||||
let dx = page.x - pp.x;
|
||||
for it in &parent.items {
|
||||
if it.layer != layer.id || page.overridden.contains(&it.id) {
|
||||
continue;
|
||||
}
|
||||
if parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppage) {
|
||||
continue;
|
||||
}
|
||||
// Parent items compose per page (page numbers, running heads).
|
||||
self.page_name = Some(page_name.clone());
|
||||
self.item(it, Affine::translate((dx, 0.0)), 1.0);
|
||||
self.page_name = None;
|
||||
}
|
||||
}
|
||||
for it in &sp.items {
|
||||
if it.layer == layer.id {
|
||||
self.item(it, Affine::IDENTITY, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Slide {
|
||||
size: (bounds.width(), bounds.height()),
|
||||
shapes: std::mem::take(&mut self.out),
|
||||
rels: std::mem::take(&mut self.rels),
|
||||
name: page_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn rel(&mut self, target: String) -> String {
|
||||
if let Some(r) = self.rel_of.get(&target) {
|
||||
return r.clone();
|
||||
}
|
||||
let id = format!("rId{}", self.rels.len() + 2);
|
||||
self.rels.push(Rel { id: id.clone(), kind: REL_IMAGE, target: target.clone() });
|
||||
self.rel_of.insert(target, id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
/// One item under `parent` (inner-of-parent → spread), with an inherited opacity.
|
||||
pub fn item(&mut self, it: &Item, parent: Affine, opacity: f32) {
|
||||
if it.hidden || it.nonprinting {
|
||||
return;
|
||||
}
|
||||
let reach = it.stroke.extent()
|
||||
+ 2.0
|
||||
+ if it.effects.drop_shadow.on { it.effects.drop_shadow.distance.abs() + it.effects.drop_shadow.size.abs() } else { 0.0 };
|
||||
let vb = parent.transform_rect_bbox(it.bounds()).inflate(reach, reach);
|
||||
if vb.x1 < self.clip.x0 || vb.x0 > self.clip.x1 || vb.y1 < self.clip.y0 || vb.y0 > self.clip.y1 {
|
||||
return;
|
||||
}
|
||||
if it.blend != BlendMode::Normal {
|
||||
self.warn("blend modes have no PowerPoint equivalent; those objects are drawn normally");
|
||||
}
|
||||
if needs_raster(self.doc, it) && !it.is_group() {
|
||||
self.warn("some effects (inner glow, bevel and emboss, satin, gradient and directional feather), type on a path and placed PDF/EPS graphics can't be exported as PowerPoint objects; rasterise them (Object Export Options) or they are approximated");
|
||||
}
|
||||
let xf = parent * it.xf;
|
||||
let opacity = opacity * it.opacity.clamp(0.0, 1.0);
|
||||
if it.has_nested_items() {
|
||||
// Paste Into: the frame, then its contents (PowerPoint can't clip a group to a shape).
|
||||
self.shape(it, xf, opacity, None);
|
||||
let fb = xf.transform_rect_bbox(it.inner_bounds());
|
||||
for c in it.children() {
|
||||
let cb = xf.transform_rect_bbox(c.bounds());
|
||||
if cb.x0 < fb.x0 - 0.5 || cb.y0 < fb.y0 - 0.5 || cb.x1 > fb.x1 + 0.5 || cb.y1 > fb.y1 + 0.5 {
|
||||
self.warn("contents pasted into a frame are not clipped to it");
|
||||
}
|
||||
self.item(c, xf, opacity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !it.children().is_empty() {
|
||||
self.group(it, xf, opacity);
|
||||
return;
|
||||
}
|
||||
match &it.content {
|
||||
Content::Graphic(g) => self.picture(it, g, xf, opacity),
|
||||
Content::Text(t) => {
|
||||
let t = t.clone();
|
||||
crate::text::frame(self, it, &t, xf, opacity);
|
||||
}
|
||||
_ => self.shape(it, xf, opacity, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn group(&mut self, it: &Item, xf: Affine, opacity: f32) {
|
||||
let mut body = std::mem::take(&mut self.out);
|
||||
let id = self.id();
|
||||
for c in it.shown_children() {
|
||||
self.item(c, xf, opacity);
|
||||
}
|
||||
let inner = std::mem::take(&mut self.out);
|
||||
if inner.is_empty() {
|
||||
self.out = body;
|
||||
return;
|
||||
}
|
||||
// Children are in slide space: the group's child space is the slide's.
|
||||
let inner_bounds = it.shown_children().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
|
||||
let b = (self.to_slide * xf).transform_rect_bbox(inner_bounds);
|
||||
let (x, y, w, h) = (emu(b.x0), emu(b.y0), emu_len(b.width()), emu_len(b.height()));
|
||||
let _ = write!(
|
||||
body,
|
||||
"<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x=\"{x}\" y=\"{y}\"/><a:ext cx=\"{w}\" cy=\"{h}\"/><a:chOff x=\"{x}\" y=\"{y}\"/><a:chExt cx=\"{w}\" cy=\"{h}\"/></a:xfrm></p:grpSpPr>{inner}</p:grpSp>",
|
||||
esc(&name_of(it, id)),
|
||||
descr(it)
|
||||
);
|
||||
self.out = body;
|
||||
}
|
||||
|
||||
/// A shape with the item's geometry, fill, stroke and effects; `text` is a `<p:txBody>`.
|
||||
pub fn shape(&mut self, it: &Item, xf: Affine, opacity: f32, text: Option<&str>) {
|
||||
let m = self.to_slide * xf;
|
||||
let g = geometry(it, m);
|
||||
let fill = self.fill(&it.fill, it.path.is_closed(), opacity);
|
||||
let line = self.line(&it.stroke, opacity);
|
||||
let fx = self.effects(it, opacity);
|
||||
if text.is_none() && fill == "<a:noFill/>" && line.contains("<a:noFill/>") && fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = self.id();
|
||||
let txbox = if text.is_some() && it.fill.is_none() && it.stroke.is_none() { " txBox=\"1\"" } else { "" };
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvSpPr{txbox}/><p:nvPr/></p:nvSpPr><p:spPr>{}{}{fill}{line}{fx}</p:spPr>{}</p:sp>",
|
||||
esc(&name_of(it, id)),
|
||||
descr(it),
|
||||
g.xfrm.xml(),
|
||||
g.xml,
|
||||
text.unwrap_or("")
|
||||
);
|
||||
}
|
||||
|
||||
/// An sRGB colour element for a swatch and tint, `None` for [None] and unknown swatches.
|
||||
pub fn color(&self, swatch: &str, tint: f32, alpha: f32) -> Option<String> {
|
||||
if let Some(g) = resolve_gradient(&self.doc.swatches, swatch) {
|
||||
// A gradient used where only a colour fits (text): its first stop.
|
||||
let (_, c, a) = g.expanded_stops().into_iter().next()?;
|
||||
return Some(srgb(c.to_rgb(), a * alpha));
|
||||
}
|
||||
let c = self.doc.resolve_color(swatch, tint)?;
|
||||
Some(srgb(c.to_rgb(), alpha))
|
||||
}
|
||||
|
||||
pub fn fill(&mut self, f: &designcraft_doc::Fill, closed: bool, opacity: f32) -> String {
|
||||
if f.is_none() || !closed {
|
||||
return "<a:noFill/>".into();
|
||||
}
|
||||
if let Some(g) = resolve_gradient(&self.doc.swatches, &f.swatch) {
|
||||
let mut gs = String::new();
|
||||
for (o, c, a) in g.expanded_stops() {
|
||||
let _ = write!(gs, "<a:gs pos=\"{}\">{}</a:gs>", pct(o.clamp(0.0, 1.0) as f64), srgb(c.to_rgb(), a * opacity));
|
||||
}
|
||||
if gs.is_empty() {
|
||||
return "<a:noFill/>".into();
|
||||
}
|
||||
if f.gradient_vector.is_some() {
|
||||
self.warn("gradients set with the Gradient tool use their angle only; PowerPoint stretches them over the whole shape");
|
||||
}
|
||||
let shade = match g.kind {
|
||||
GradientKind::Radial => {
|
||||
"<a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"50000\" r=\"50000\" b=\"50000\"/></a:path>".to_string()
|
||||
}
|
||||
_ => {
|
||||
let deg = match f.gradient_vector {
|
||||
Some([x0, y0, x1, y1]) => (y1 - y0).atan2(x1 - x0).to_degrees(),
|
||||
None => -f.gradient_angle.unwrap_or(0.0),
|
||||
};
|
||||
format!("<a:lin ang=\"{}\" scaled=\"0\"/>", angle(deg))
|
||||
}
|
||||
};
|
||||
return format!("<a:gradFill rotWithShape=\"1\"><a:gsLst>{gs}</a:gsLst>{shade}</a:gradFill>");
|
||||
}
|
||||
match self.color(&f.swatch, f.tint, opacity) {
|
||||
Some(c) => format!("<a:solidFill>{c}</a:solidFill>"),
|
||||
None => "<a:noFill/>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line(&mut self, s: &Stroke, opacity: f32) -> String {
|
||||
if s.is_none() || s.weight <= 0.0 {
|
||||
return "<a:ln><a:noFill/></a:ln>".into();
|
||||
}
|
||||
let Some(color) = self.color(&s.swatch, s.tint, opacity) else { return "<a:ln><a:noFill/></a:ln>".into() };
|
||||
let cap = match s.cap {
|
||||
Cap::Butt => "flat",
|
||||
Cap::Round => "rnd",
|
||||
Cap::Projecting => "sq",
|
||||
};
|
||||
let cmpd = match s.kind {
|
||||
StrokeType::ThickThin => "thickThin",
|
||||
StrokeType::ThinThick => "thinThick",
|
||||
StrokeType::ThinThin | StrokeType::ThickThick => "dbl",
|
||||
StrokeType::ThinThickThin | StrokeType::ThickThinThick => "tri",
|
||||
_ => "sng",
|
||||
};
|
||||
if matches!(s.kind, StrokeType::Wavy | StrokeType::Hashed | StrokeType::Stripes { .. } | StrokeType::Style { .. }) {
|
||||
self.warn("wavy, hashed, striped and custom stroke styles are exported as solid lines");
|
||||
}
|
||||
let algn = match s.align {
|
||||
StrokeAlign::Inside => " algn=\"in\"",
|
||||
StrokeAlign::Outside => {
|
||||
self.warn("strokes aligned outside are centred on the path (PowerPoint has centre and inside only)");
|
||||
""
|
||||
}
|
||||
StrokeAlign::Center => "",
|
||||
};
|
||||
let w = s.weight;
|
||||
let dash = match &s.kind {
|
||||
StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
|
||||
let mut v: Vec<f64> = pattern.iter().map(|x| x.max(0.0)).collect();
|
||||
if v.len() % 2 == 1 {
|
||||
v.extend(v.clone());
|
||||
}
|
||||
let mut d = String::from("<a:custDash>");
|
||||
for pair in v.chunks(2) {
|
||||
if let [a, b] = pair {
|
||||
let _ = write!(d, "<a:ds d=\"{}\" sp=\"{}\"/>", pct(a / w), pct(b / w));
|
||||
}
|
||||
}
|
||||
d.push_str("</a:custDash>");
|
||||
d
|
||||
}
|
||||
StrokeType::Dotted => "<a:prstDash val=\"sysDot\"/>".into(),
|
||||
_ => "<a:prstDash val=\"solid\"/>".into(),
|
||||
};
|
||||
let join = match s.join {
|
||||
Join::Miter => format!("<a:miter lim=\"{}\"/>", pct(s.miter_limit.max(1.0))),
|
||||
Join::Round => "<a:round/>".into(),
|
||||
Join::Bevel => "<a:bevel/>".into(),
|
||||
};
|
||||
let ends = |tag: &str, a: Arrowhead, me: &mut Self| -> String {
|
||||
let kind = match a {
|
||||
Arrowhead::None => return String::new(),
|
||||
Arrowhead::Simple | Arrowhead::SimpleWide => "arrow",
|
||||
Arrowhead::Triangle | Arrowhead::TriangleWide | Arrowhead::Curved => "triangle",
|
||||
Arrowhead::Barbed => "stealth",
|
||||
Arrowhead::Circle | Arrowhead::CircleSolid => "oval",
|
||||
Arrowhead::Square | Arrowhead::SquareSolid => "diamond",
|
||||
Arrowhead::Bar => {
|
||||
me.warn("bar arrowheads have no PowerPoint equivalent and are left out");
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
// PowerPoint's large heads come closest to DesignCraft's at the same weight.
|
||||
format!("<a:{tag} type=\"{kind}\" w=\"lg\" len=\"lg\"/>")
|
||||
};
|
||||
let head = ends("headEnd", s.start, self);
|
||||
let tail = ends("tailEnd", s.end, self);
|
||||
format!("<a:ln w=\"{}\" cap=\"{cap}\" cmpd=\"{cmpd}\"{algn}><a:solidFill>{color}</a:solidFill>{dash}{join}{head}{tail}</a:ln>", emu_len(w))
|
||||
}
|
||||
|
||||
/// The item's effects that DrawingML has (in schema order).
|
||||
pub fn effects(&mut self, it: &Item, opacity: f32) -> String {
|
||||
let fx = &it.effects;
|
||||
let mut s = String::new();
|
||||
if fx.outer_glow.on
|
||||
&& let Some(c) = self.color(&fx.outer_glow.color, 1.0, fx.outer_glow.opacity * opacity)
|
||||
{
|
||||
let _ = write!(s, "<a:glow rad=\"{}\">{c}</a:glow>", emu_len(fx.outer_glow.size));
|
||||
}
|
||||
if fx.inner_shadow.on {
|
||||
let sh = &fx.inner_shadow;
|
||||
if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) {
|
||||
let a = self.doc.light_angle(sh.angle, sh.global_light);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<a:innerShdw blurRad=\"{}\" dist=\"{}\" dir=\"{}\">{c}</a:innerShdw>",
|
||||
emu_len(sh.size),
|
||||
emu_len(sh.distance.abs()),
|
||||
shadow_dir(a)
|
||||
);
|
||||
}
|
||||
}
|
||||
if fx.drop_shadow.on {
|
||||
let sh = &fx.drop_shadow;
|
||||
if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) {
|
||||
let a = self.doc.light_angle(sh.angle, sh.global_light);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<a:outerShdw blurRad=\"{}\" dist=\"{}\" dir=\"{}\" algn=\"ctr\" rotWithShape=\"0\">{c}</a:outerShdw>",
|
||||
emu_len(sh.size),
|
||||
emu_len(sh.distance.abs()),
|
||||
shadow_dir(a)
|
||||
);
|
||||
}
|
||||
}
|
||||
if fx.feather > 0.0 {
|
||||
let _ = write!(s, "<a:softEdge rad=\"{}\"/>", emu_len(fx.feather));
|
||||
}
|
||||
if s.is_empty() { s } else { format!("<a:effectLst>{s}</a:effectLst>") }
|
||||
}
|
||||
|
||||
/// The media for a placed graphic: PNG, JPEG and GIF as they are, SVG with a PNG fallback,
|
||||
/// other rasters converted to PNG.
|
||||
fn media_for(&mut self, asset: AssetId) -> Option<(String, Option<String>)> {
|
||||
if let Some(m) = self.media_of.get(&asset) {
|
||||
return m.clone();
|
||||
}
|
||||
let r = self.convert(asset);
|
||||
self.media_of.insert(asset, r.clone());
|
||||
r
|
||||
}
|
||||
|
||||
fn convert(&mut self, asset: AssetId) -> Option<(String, Option<String>)> {
|
||||
let a = self.doc.assets.get(&asset)?.clone();
|
||||
let data: &[u8] = &a.data;
|
||||
if data.is_empty() {
|
||||
self.warn(format!("`{}` is missing (linked file not found); left out", a.name));
|
||||
return None;
|
||||
}
|
||||
let n = self.media.len() + 1;
|
||||
let mime = designcraft_images::mime(data);
|
||||
let ext = match mime {
|
||||
"image/png" => Some("png"),
|
||||
"image/jpeg" => Some("jpeg"),
|
||||
"image/gif" => Some("gif"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ext) = ext {
|
||||
let name = format!("image{n}.{ext}");
|
||||
self.media.push(Media { name: name.clone(), bytes: data.to_vec() });
|
||||
return Some((name, None));
|
||||
}
|
||||
if mime == "image/svg+xml" {
|
||||
let png = designcraft_images::render_svg(data, 2048).and_then(|(px, w, h)| encode_png(unpremultiply(px), w, h));
|
||||
let Some(png) = png else {
|
||||
self.warn(format!("`{}` could not be read; left out", a.name));
|
||||
return None;
|
||||
};
|
||||
let name = format!("image{n}.png");
|
||||
let svg = format!("image{}.svg", n + 1);
|
||||
self.media.push(Media { name: name.clone(), bytes: png });
|
||||
self.media.push(Media { name: svg.clone(), bytes: data.to_vec() });
|
||||
return Some((name, Some(svg)));
|
||||
}
|
||||
if mime == "application/pdf" || mime == "application/octet-stream" {
|
||||
self.warn(format!(
|
||||
"`{}` (PDF, EPS or an unknown format) can't be placed in PowerPoint; rasterise it (Object Export Options) to include it",
|
||||
a.name
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let Some(img) = designcraft_images::decode_rgba(data) else {
|
||||
self.warn(format!("`{}` could not be read; left out", a.name));
|
||||
return None;
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
let png = encode_png(img.into_raw(), w, h)?;
|
||||
let name = format!("image{n}.png");
|
||||
self.media.push(Media { name: name.clone(), bytes: png });
|
||||
Some((name, None))
|
||||
}
|
||||
|
||||
fn picture(&mut self, it: &Item, g: &Graphic, xf: Affine, opacity: f32) {
|
||||
let g = if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
|
||||
let Some((name, svg)) = self.media_for(g.asset) else {
|
||||
self.shape(it, xf, opacity, None);
|
||||
return;
|
||||
};
|
||||
let m = self.to_slide * xf;
|
||||
let [a, b, c, d, _, _] = g.xf.as_coeffs();
|
||||
let frame = it.inner_bounds();
|
||||
let img = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1));
|
||||
let axis = b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && d > 0.0;
|
||||
let (geo, crop) = if axis && img.width() > 0.0 && img.height() > 0.0 {
|
||||
// The frame's geometry with the image cropped (or padded) to the frame's box.
|
||||
let crop = format!(
|
||||
"<a:srcRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>",
|
||||
pct((frame.x0 - img.x0) / img.width()),
|
||||
pct((frame.y0 - img.y0) / img.height()),
|
||||
pct((img.x1 - frame.x1) / img.width()),
|
||||
pct((img.y1 - frame.y1) / img.height())
|
||||
);
|
||||
(geometry(it, m), crop)
|
||||
} else {
|
||||
// Rotated, skewed or flipped inside its frame: the image's own box, uncropped, and
|
||||
// the frame drawn on its own.
|
||||
self.warn("images rotated, skewed or flipped inside their frames are not cropped to the frame");
|
||||
self.shape(it, xf, opacity, None);
|
||||
let gi = Rect::new(0.0, 0.0, g.size.0, g.size.1);
|
||||
let gm = m * g.xf;
|
||||
let xfrm = box_of(gi, gm).map(|(x, _)| x).unwrap_or_else(|| XfrmBox::plain(gm.transform_rect_bbox(gi)));
|
||||
(Geometry { xfrm, xml: preset("rect") }, String::new())
|
||||
};
|
||||
let rid = self.rel(format!("../media/{name}"));
|
||||
let svg_ext = match svg {
|
||||
Some(s) => {
|
||||
let sid = self.rel(format!("../media/{s}"));
|
||||
format!(
|
||||
"<a:extLst><a:ext uri=\"{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}\"><asvg:svgBlip xmlns:asvg=\"http://schemas.microsoft.com/office/drawing/2016/SVG/main\" r:embed=\"{sid}\"/></a:ext></a:extLst>"
|
||||
)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let alpha = if opacity < 0.999 { format!("<a:alphaModFix amt=\"{}\"/>", pct(opacity as f64)) } else { String::new() };
|
||||
let fill = self.fill(&it.fill, it.path.is_closed(), opacity);
|
||||
let line = if axis { self.line(&it.stroke, opacity) } else { "<a:ln><a:noFill/></a:ln>".into() };
|
||||
let fx = self.effects(it, opacity);
|
||||
let id = self.id();
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<p:pic><p:nvPicPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>\
|
||||
<p:blipFill><a:blip r:embed=\"{rid}\">{alpha}{svg_ext}</a:blip>{crop}<a:stretch><a:fillRect/></a:stretch></p:blipFill>\
|
||||
<p:spPr>{}{}{fill}{line}{fx}</p:spPr></p:pic>",
|
||||
esc(&name_of(it, id)),
|
||||
descr(it),
|
||||
geo.xfrm.xml(),
|
||||
geo.xml
|
||||
);
|
||||
}
|
||||
|
||||
/// Paragraph shading and rules, table cell fills: a plain filled rectangle in slide space.
|
||||
pub fn rect_shape(&mut self, xfrm: XfrmBox, fill: &str, name: &str) {
|
||||
let id = self.id();
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{}\"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{}{}{fill}<a:ln><a:noFill/></a:ln></p:spPr></p:sp>",
|
||||
esc(name),
|
||||
xfrm.xml(),
|
||||
preset("rect")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DrawingML shadow direction (clockwise from +x, y down) for a light angle (degrees,
|
||||
/// counter-clockwise; the shadow falls away from the light).
|
||||
fn shadow_dir(light: f64) -> i64 {
|
||||
let a = light.to_radians();
|
||||
let off = Vec2::new(-a.cos(), a.sin());
|
||||
angle(off.y.atan2(off.x).to_degrees())
|
||||
}
|
||||
|
||||
pub fn srgb(rgb: [f32; 3], alpha: f32) -> String {
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
if a >= 0.999 {
|
||||
format!("<a:srgbClr val=\"{}\"/>", hex(rgb))
|
||||
} else {
|
||||
format!("<a:srgbClr val=\"{}\"><a:alpha val=\"{}\"/></a:srgbClr>", hex(rgb), pct(a as f64))
|
||||
}
|
||||
}
|
||||
|
||||
fn name_of(it: &Item, id: u32) -> String {
|
||||
if !it.name.is_empty() {
|
||||
return it.name.clone();
|
||||
}
|
||||
let kind = match (&it.content, it.shape) {
|
||||
(Content::Group { .. }, _) => "Group",
|
||||
(Content::Text(_), _) => "Text Frame",
|
||||
(Content::Graphic(_), _) => "Picture",
|
||||
(_, Shape::Oval) => "Ellipse",
|
||||
(_, Shape::Polygon) => "Polygon",
|
||||
(_, Shape::GraphicLine) => "Line",
|
||||
(_, Shape::Path) => "Path",
|
||||
_ => "Rectangle",
|
||||
};
|
||||
format!("{kind} {id}")
|
||||
}
|
||||
|
||||
fn descr(it: &Item) -> String {
|
||||
if it.alt_text.is_empty() { String::new() } else { format!(" descr=\"{}\"", esc(&it.alt_text)) }
|
||||
}
|
||||
|
||||
fn unpremultiply(mut px: Vec<u8>) -> Vec<u8> {
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let a = p[3] as u32;
|
||||
if a > 0 && a < 255 {
|
||||
for c in &mut p[..3] {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let img = image::RgbaImage::from_raw(w, h, rgba)?;
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).ok()?;
|
||||
Some(out.into_inner())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Table fragments as native PowerPoint tables: the grid from the cells' edges, merged cells as
|
||||
//! spans, fills, edges as cell borders, and each cell's composed text.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use designcraft_compose::{FrameText, PlacedCell, StrokeSeg, TableFrag};
|
||||
use designcraft_doc::StrokeType;
|
||||
use designcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::slide::{Exporter, box_of};
|
||||
use crate::text::{paragraphs, seg_rect, segments};
|
||||
use crate::xml::{emu, emu_len};
|
||||
|
||||
/// Sorted distinct values (within 0.25 pt).
|
||||
fn edges(mut v: Vec<f64>) -> Vec<f64> {
|
||||
v.retain(|x| x.is_finite());
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup_by(|a, b| (*a - *b).abs() < 0.25);
|
||||
v
|
||||
}
|
||||
|
||||
fn index(edges: &[f64], x: f64) -> usize {
|
||||
edges.iter().enumerate().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
pub fn tables(ex: &mut Exporter, ft: &FrameText, m: Affine, k: f64, opacity: f32) {
|
||||
for t in &ft.tables {
|
||||
table(ex, t, m, k, opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke along one side of a cell (`a`–`b`), if the segments on that line cover most of it
|
||||
/// (a merged cell's side is drawn in pieces, one per grid cell).
|
||||
fn edge_stroke(strokes: &[StrokeSeg], a: (f64, f64), b: (f64, f64)) -> Option<&StrokeSeg> {
|
||||
let vertical = (a.0 - b.0).abs() < 0.25;
|
||||
let (lo, hi) = if vertical { (a.1, b.1) } else { (a.0, b.0) };
|
||||
let mut covered = 0.0;
|
||||
let mut first = None;
|
||||
for s in strokes {
|
||||
let (on, s0, s1) = if vertical {
|
||||
((s.a.x - a.0).abs() < 0.75 && (s.b.x - a.0).abs() < 0.75, s.a.y.min(s.b.y), s.a.y.max(s.b.y))
|
||||
} else {
|
||||
((s.a.y - a.1).abs() < 0.75 && (s.b.y - a.1).abs() < 0.75, s.a.x.min(s.b.x), s.a.x.max(s.b.x))
|
||||
};
|
||||
let overlap = s1.min(hi) - s0.max(lo);
|
||||
if on && overlap > 0.0 {
|
||||
covered += overlap;
|
||||
first.get_or_insert(s);
|
||||
}
|
||||
}
|
||||
first.filter(|_| covered > 0.5 * (hi - lo))
|
||||
}
|
||||
|
||||
fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity: f32) -> String {
|
||||
let Some(s) = s.filter(|s| s.stroke.weight > 0.0) else { return format!("<a:{tag} w=\"0\"><a:noFill/></a:{tag}>") };
|
||||
let st = &s.stroke;
|
||||
let Some(c) = ex.color(&st.color, st.tint, opacity) else { return format!("<a:{tag} w=\"0\"><a:noFill/></a:{tag}>") };
|
||||
let dash = match st.kind {
|
||||
StrokeType::Dashed { .. } => "dash",
|
||||
StrokeType::Dotted => "sysDot",
|
||||
_ => "solid",
|
||||
};
|
||||
format!(
|
||||
"<a:{tag} w=\"{}\" cap=\"flat\" cmpd=\"sng\"><a:solidFill>{c}</a:solidFill><a:prstDash val=\"{dash}\"/></a:{tag}>",
|
||||
emu_len(st.weight * k)
|
||||
)
|
||||
}
|
||||
|
||||
fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) {
|
||||
if t.cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some((xfrm, dm)) = box_of(t.rect, m) else { return };
|
||||
if dm.rot.abs() > 0.01 || xfrm.flip_v {
|
||||
ex.warn("tables in rotated or flipped frames are exported upright");
|
||||
}
|
||||
let xs = edges(t.cells.iter().flat_map(|c| [c.rect.x0, c.rect.x1]).collect());
|
||||
let ys = edges(t.cells.iter().flat_map(|c| [c.rect.y0, c.rect.y1]).collect());
|
||||
if xs.len() < 2 || ys.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let (nc, nr) = (xs.len() - 1, ys.len() - 1);
|
||||
// Which placed cell starts at each grid position, and which positions merged cells cover.
|
||||
let mut origin: Vec<Option<&PlacedCell>> = vec![None; nc * nr];
|
||||
let mut covered: Vec<(bool, bool)> = vec![(false, false); nc * nr];
|
||||
let mut span: Vec<(usize, usize)> = vec![(1, 1); nc * nr];
|
||||
for c in &t.cells {
|
||||
let (c0, c1) = (index(&xs, c.rect.x0), index(&xs, c.rect.x1));
|
||||
let (r0, r1) = (index(&ys, c.rect.y0), index(&ys, c.rect.y1));
|
||||
if c1 <= c0 || r1 <= r0 {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = origin.get_mut(r0 * nc + c0) else { continue };
|
||||
*slot = Some(c);
|
||||
if let Some(sp) = span.get_mut(r0 * nc + c0) {
|
||||
*sp = (c1 - c0, r1 - r0);
|
||||
}
|
||||
for r in r0..r1 {
|
||||
for col in c0..c1 {
|
||||
if (r, col) != (r0, c0)
|
||||
&& let Some(cv) = covered.get_mut(r * nc + col)
|
||||
{
|
||||
*cv = (col > c0, r > r0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.cells.iter().any(|c| c.graphic.is_some()) {
|
||||
ex.warn("graphic cells are exported empty");
|
||||
}
|
||||
let id = ex.id();
|
||||
let mut s = format!(
|
||||
"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id=\"{id}\" name=\"Table {id}\"/><p:cNvGraphicFramePr><a:graphicFrameLocks noGrp=\"1\"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>\
|
||||
<p:xfrm><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></p:xfrm><a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/table\"><a:tbl><a:tblPr/><a:tblGrid>",
|
||||
emu(xfrm.rect.x0),
|
||||
emu(xfrm.rect.y0),
|
||||
emu_len(xfrm.rect.width()),
|
||||
emu_len(xfrm.rect.height())
|
||||
);
|
||||
for w in xs.windows(2) {
|
||||
let _ = write!(s, "<a:gridCol w=\"{}\"/>", emu_len((w[1] - w[0]) * k));
|
||||
}
|
||||
s.push_str("</a:tblGrid>");
|
||||
for r in 0..nr {
|
||||
let h = ys.get(r + 1).zip(ys.get(r)).map_or(0.0, |(b, a)| b - a);
|
||||
let _ = write!(s, "<a:tr h=\"{}\">", emu_len(h * k));
|
||||
for col in 0..nc {
|
||||
let at = r * nc + col;
|
||||
let (hm, vm) = covered.get(at).copied().unwrap_or((false, false));
|
||||
if hm || vm {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<a:tc{}{}><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p></a:txBody><a:tcPr/></a:tc>",
|
||||
if hm { " hMerge=\"1\"" } else { "" },
|
||||
if vm { " vMerge=\"1\"" } else { "" }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let (gs, rs) = span.get(at).copied().unwrap_or((1, 1));
|
||||
let mut attrs = String::new();
|
||||
if gs > 1 {
|
||||
let _ = write!(attrs, " gridSpan=\"{gs}\"");
|
||||
}
|
||||
if rs > 1 {
|
||||
let _ = write!(attrs, " rowSpan=\"{rs}\"");
|
||||
}
|
||||
let Some(c) = origin.get(at).copied().flatten() else {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<a:tc{attrs}><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p></a:txBody><a:tcPr marL=\"0\" marR=\"0\" marT=\"0\" marB=\"0\"><a:noFill/></a:tcPr></a:tc>"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
s.push_str(&cell(ex, c, &attrs, t, k, opacity));
|
||||
}
|
||||
s.push_str("</a:tr>");
|
||||
}
|
||||
s.push_str("</a:tbl></a:graphicData></a:graphic></p:graphicFrame>");
|
||||
ex.out.push_str(&s);
|
||||
}
|
||||
|
||||
fn cell(ex: &mut Exporter, c: &PlacedCell, attrs: &str, t: &TableFrag, k: f64, opacity: f32) -> String {
|
||||
let rc = c.rect;
|
||||
let mut body = String::new();
|
||||
let mut margins = [0.0f64; 4];
|
||||
if let Some(cft) = c.text.frames.first() {
|
||||
let segs = segments(ex, &c.text, cft, &c.source, &[]);
|
||||
// A cell holds one box: its lines, from the first segment's slot.
|
||||
let mut all = segs.first().cloned().unwrap_or_default();
|
||||
for sg in segs.iter().skip(1) {
|
||||
all.lines.extend(sg.lines.iter().cloned());
|
||||
all.x1 = all.x1.max(sg.x1);
|
||||
}
|
||||
if !all.lines.is_empty() {
|
||||
let r = seg_rect(&all);
|
||||
let r = Rect::new(r.x0 + c.origin.x, r.y0 + c.origin.y, r.x1 + c.origin.x, r.y1 + c.origin.y);
|
||||
margins = [(r.x0 - rc.x0).max(0.0), (r.y0 - rc.y0).max(0.0), (rc.x1 - r.x1).max(0.0), (rc.y1 - r.y1).max(0.0)];
|
||||
let local = Rect::new(r.x0 - c.origin.x, r.y0 - c.origin.y, r.x1 - c.origin.x, r.y1 - c.origin.y);
|
||||
body = paragraphs(ex, &all, local, k, opacity);
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
body = "<a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p>".into();
|
||||
}
|
||||
let [l, tp, r, b] = margins.map(|v| emu_len(v * k));
|
||||
let ln = |side: char| -> ((f64, f64), (f64, f64)) {
|
||||
match side {
|
||||
'L' => ((rc.x0, rc.y0), (rc.x0, rc.y1)),
|
||||
'R' => ((rc.x1, rc.y0), (rc.x1, rc.y1)),
|
||||
'T' => ((rc.x0, rc.y0), (rc.x1, rc.y0)),
|
||||
_ => ((rc.x0, rc.y1), (rc.x1, rc.y1)),
|
||||
}
|
||||
};
|
||||
let mut pr = format!("<a:tcPr marL=\"{l}\" marR=\"{r}\" marT=\"{tp}\" marB=\"{b}\">");
|
||||
for (tag, side) in [("lnL", 'L'), ("lnR", 'R'), ("lnT", 'T'), ("lnB", 'B')] {
|
||||
let (a, bb) = ln(side);
|
||||
let seg = edge_stroke(&t.strokes, a, bb);
|
||||
pr.push_str(&border(ex, tag, seg, k, opacity));
|
||||
}
|
||||
match c.fill.as_ref().and_then(|(sw, tint)| ex.color(sw, *tint, opacity)) {
|
||||
Some(col) => {
|
||||
let _ = write!(pr, "<a:solidFill>{col}</a:solidFill>");
|
||||
}
|
||||
None => pr.push_str("<a:noFill/>"),
|
||||
}
|
||||
pr.push_str("</a:tcPr>");
|
||||
format!("<a:tc{attrs}><a:txBody><a:bodyPr/><a:lstStyle/>{body}</a:txBody>{pr}</a:tc>")
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Package structure and object mapping tests.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{
|
||||
Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Stroke, StrokeType, TextFrameOptions,
|
||||
};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::{LineBreaks, PptxError, PptxOptions, export_pptx};
|
||||
|
||||
fn unzip(bytes: &[u8]) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
|
||||
zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap()
|
||||
}
|
||||
|
||||
fn part(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, name: &str) -> String {
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap_or_else(|_| panic!("missing {name}")).read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn export(d: &Document, opts: &PptxOptions) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
|
||||
unzip(&export_pptx(d, &Cache::new(), opts).unwrap().bytes)
|
||||
}
|
||||
|
||||
fn rect_item(d: &mut Document, r: Rect, shape: Shape) -> ItemId {
|
||||
let id = ItemId(d.alloc());
|
||||
let path = if shape == Shape::Oval { shapes::ellipse(r) } else { shapes::rectangle(r) };
|
||||
let mut it = Item::new(id, d.default_layer(), shape, path);
|
||||
it.fill = Fill::swatch("[Black]");
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn edit(d: &mut Document, id: ItemId, f: impl FnOnce(&mut Item)) {
|
||||
let it = d.spreads.iter_mut().flat_map(|sp| Arc::make_mut(sp).items.iter_mut()).find(|it| it.id == id).unwrap();
|
||||
f(Arc::make_mut(it));
|
||||
}
|
||||
|
||||
/// The text of each shape on a slide (runs joined).
|
||||
fn shape_texts(slide: &str) -> Vec<String> {
|
||||
slide
|
||||
.split("<p:sp>")
|
||||
.skip(1)
|
||||
.map(|sp| sp.split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect::<String>())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every XML part parses.
|
||||
fn well_formed(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>) {
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
for n in names.iter().filter(|n| n.ends_with(".xml") || n.ends_with(".rels")) {
|
||||
let x = part(z, n);
|
||||
let mut r = quick_xml::Reader::from_str(&x);
|
||||
loop {
|
||||
match r.read_event() {
|
||||
Ok(quick_xml::events::Event::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("{n}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_has_the_parts_powerpoint_needs() {
|
||||
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello & <world>", ParaFormat::default()).unwrap();
|
||||
let r = export_pptx(&d, &Cache::new(), &PptxOptions::default()).unwrap();
|
||||
assert_eq!(r.slides, 2);
|
||||
let mut z = unzip(&r.bytes);
|
||||
for p in [
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"ppt/presentation.xml",
|
||||
"ppt/_rels/presentation.xml.rels",
|
||||
"ppt/slideMasters/slideMaster1.xml",
|
||||
"ppt/slideLayouts/slideLayout1.xml",
|
||||
"ppt/theme/theme1.xml",
|
||||
"ppt/slides/slide1.xml",
|
||||
"ppt/slides/slide2.xml",
|
||||
"ppt/slides/_rels/slide2.xml.rels",
|
||||
"docProps/core.xml",
|
||||
] {
|
||||
assert!(z.by_name(p).is_ok(), "{p}");
|
||||
}
|
||||
well_formed(&mut z);
|
||||
let pres = part(&mut z, "ppt/presentation.xml");
|
||||
let (w, h) = (d.settings.page_width, d.settings.page_height);
|
||||
assert!(pres.contains(&format!("<p:sldSz cx=\"{}\" cy=\"{}\"/>", (w * 12_700.0).round() as i64, (h * 12_700.0).round() as i64)), "{pres}");
|
||||
let s1 = part(&mut z, "ppt/slides/slide1.xml");
|
||||
assert_eq!(shape_texts(&s1), ["Hello & <world>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_keep_geometry_fill_stroke_and_effects() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = rect_item(&mut d, Rect::new(72.0, 72.0, 172.0, 122.0), Shape::Rectangle);
|
||||
let b = rect_item(&mut d, Rect::new(200.0, 72.0, 300.0, 122.0), Shape::Oval);
|
||||
let c = rect_item(&mut d, Rect::new(72.0, 200.0, 172.0, 300.0), Shape::Rectangle);
|
||||
edit(&mut d, a, |it| {
|
||||
it.stroke = Stroke { swatch: "[Black]".into(), weight: 2.0, kind: StrokeType::Dashed { pattern: vec![4.0, 2.0] }, ..Stroke::default() };
|
||||
it.opacity = 0.5;
|
||||
});
|
||||
edit(&mut d, b, |it| it.effects.drop_shadow.on = true);
|
||||
edit(&mut d, c, |it| {
|
||||
let ctr = it.bounds().center();
|
||||
it.xf = Affine::rotate_about(30f64.to_radians(), ctr);
|
||||
});
|
||||
let mut z = export(&d, &PptxOptions::default());
|
||||
let s = part(&mut z, "ppt/slides/slide1.xml");
|
||||
// 72 pt from the page corner, 100 × 50 pt.
|
||||
assert!(s.contains("<a:off x=\"914400\" y=\"914400\"/><a:ext cx=\"1270000\" cy=\"635000\"/></a:xfrm><a:prstGeom prst=\"rect\">"), "{s}");
|
||||
assert!(s.contains("<a:prstGeom prst=\"ellipse\">"));
|
||||
assert!(s.contains("<a:custDash><a:ds d=\"200000\" sp=\"100000\"/></a:custDash>"), "{s}");
|
||||
assert!(s.contains("<a:alpha val=\"50000\"/>"));
|
||||
assert!(s.contains("<a:outerShdw"));
|
||||
assert!(s.contains("rot=\"1800000\""), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_lines_become_line_breaks_with_the_story_text() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let text = "Every page begins as an empty field of possibility. The grid arrives first: margins that frame the reading area, columns that set the rhythm.";
|
||||
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, text, ParaFormat::default()).unwrap();
|
||||
let cache = Cache::new();
|
||||
let lines = cache.get(&d, sid, None).frame(fid).map_or(0, |f| f.lines.len());
|
||||
assert!(lines > 3, "the frame breaks the text into lines");
|
||||
let r = export_pptx(&d, &cache, &PptxOptions::default()).unwrap();
|
||||
let mut z = unzip(&r.bytes);
|
||||
let s = part(&mut z, "ppt/slides/slide1.xml");
|
||||
assert_eq!(s.matches("<a:br>").count(), lines - 1, "{s}");
|
||||
// The text survives intact (line-end spaces kept, the composer's hyphens shown).
|
||||
let joined = shape_texts(&s).concat();
|
||||
assert_eq!(joined.replace('-', ""), text);
|
||||
assert!(s.contains("<a:spcPct"), "leading as percentage line spacing");
|
||||
assert!(s.contains("typeface=\"Source Serif 4\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflow_writes_one_box_with_native_columns() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 500.0, 300.0), lid, "One\nTwo", ParaFormat::default()).unwrap();
|
||||
edit(&mut d, fid, |it| {
|
||||
if let Content::Text(t) = &mut it.content {
|
||||
t.options = TextFrameOptions { columns: 2, gutter: 12.0, ..t.options.clone() };
|
||||
}
|
||||
});
|
||||
let mut z = export(&d, &PptxOptions { line_breaks: LineBreaks::Reflow, ..Default::default() });
|
||||
let s = part(&mut z, "ppt/slides/slide1.xml");
|
||||
assert!(s.contains("numCol=\"2\" spcCol=\"152400\""), "{s}");
|
||||
assert!(!s.contains("<a:br>"));
|
||||
assert_eq!(s.matches("<a:p>").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_story_splits_at_its_frames() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega";
|
||||
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 160.0, 110.0), lid, text, ParaFormat::default()).unwrap();
|
||||
let f2 = rect_item(&mut d, Rect::new(300.0, 72.0, 388.0, 400.0), Shape::Rectangle);
|
||||
edit(&mut d, f2, |it| {
|
||||
it.fill = Fill::none();
|
||||
it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: TextFrameOptions::default() });
|
||||
});
|
||||
if let Some(st) = d.stories.get_mut(&sid) {
|
||||
Arc::make_mut(st).frames = vec![f1, f2];
|
||||
}
|
||||
let mut z = export(&d, &PptxOptions::default());
|
||||
let s = part(&mut z, "ppt/slides/slide1.xml");
|
||||
// Two text shapes, the story starting in the first and ending in the second.
|
||||
let texts: Vec<String> = shape_texts(&s).iter().map(|t| t.replace('-', "")).collect();
|
||||
assert_eq!(texts.len(), 2, "{s}");
|
||||
assert!(texts[0].starts_with("alpha") && !texts[0].contains("omega"), "{texts:?}");
|
||||
assert!(texts[1].trim_end().ends_with("omega"), "{texts:?}");
|
||||
assert_eq!(texts.concat().trim_end(), text);
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_are_cropped_to_their_frames() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let asset = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
asset,
|
||||
Arc::new(Asset {
|
||||
id: asset,
|
||||
name: "red.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(png(200, 100)),
|
||||
pixels: Some((200, 100)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let id = rect_item(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Shape::Rectangle);
|
||||
edit(&mut d, id, |it| {
|
||||
it.fill = Fill::none();
|
||||
// 200 × 100 image at 1:1, its left quarter cut off by the frame.
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset,
|
||||
size: (200.0, 100.0),
|
||||
xf: Affine::translate((50.0, 100.0)),
|
||||
auto_fit: designcraft_doc::Fitting::None,
|
||||
fit_align: 0,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
});
|
||||
let mut z = export(&d, &PptxOptions::default());
|
||||
assert!(z.by_name("ppt/media/image1.png").is_ok());
|
||||
let s = part(&mut z, "ppt/slides/slide1.xml");
|
||||
assert!(s.contains("<a:srcRect l=\"25000\" t=\"0\" r=\"25000\" b=\"0\"/>"), "{s}");
|
||||
let rels = part(&mut z, "ppt/slides/_rels/slide1.xml.rels");
|
||||
assert!(rels.contains("../media/image1.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_pages_and_hostile_geometry_are_errors_not_crashes() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let r = export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![5]), ..Default::default() });
|
||||
assert!(matches!(r, Err(PptxError::BadPage(6))));
|
||||
assert!(matches!(export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![]), ..Default::default() }), Err(PptxError::NoPages)));
|
||||
let a = rect_item(&mut d, Rect::new(10.0, 10.0, 10.0, 10.0), Shape::Rectangle);
|
||||
let b = rect_item(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Shape::Rectangle);
|
||||
edit(&mut d, a, |it| it.xf = Affine::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
|
||||
edit(&mut d, b, |it| it.xf = Affine::new([f64::NAN, 0.0, 0.0, 1e300, f64::INFINITY, 0.0]));
|
||||
let lid = d.default_layer();
|
||||
let (t, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 60.0, 60.0), lid, "x", ParaFormat::default()).unwrap();
|
||||
edit(&mut d, t, |it| it.xf = Affine::new([1.0, 0.0, 3.0, 1.0, 0.0, 0.0]));
|
||||
let mut z = export(&d, &PptxOptions::default());
|
||||
well_formed(&mut z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_embed_as_embedded_opentype() {
|
||||
let face = designcraft_fonts::FontDb::global().face("Source Serif 4", "Regular");
|
||||
let eot = crate::fonts::eot(&face).unwrap();
|
||||
let le32 = |at: usize| u32::from_le_bytes([eot[at], eot[at + 1], eot[at + 2], eot[at + 3]]);
|
||||
assert_eq!(le32(0) as usize, eot.len());
|
||||
assert_eq!(le32(4) as usize, face.data().len());
|
||||
assert_eq!(le32(8), 0x0002_0001);
|
||||
assert_eq!(u16::from_le_bytes([eot[34], eot[35]]), 0x504C, "magic");
|
||||
assert!(eot.ends_with(face.data()));
|
||||
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Embedded", ParaFormat::default()).unwrap();
|
||||
let mut z = export(&d, &PptxOptions { embed_fonts: true, ..Default::default() });
|
||||
let pres = part(&mut z, "ppt/presentation.xml");
|
||||
assert!(
|
||||
pres.contains("embedTrueTypeFonts=\"1\"") && pres.contains("<p:embeddedFont><p:font typeface=\"Source Serif 4\"/><p:regular r:id="),
|
||||
"{pres}"
|
||||
);
|
||||
assert!(z.by_name("ppt/fonts/font1.fntdata").is_ok());
|
||||
let ct = part(&mut z, "[Content_Types].xml");
|
||||
assert!(ct.contains("application/x-fontdata"));
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! Text frames as text boxes.
|
||||
//!
|
||||
//! [`LineBreaks::Keep`]: a frame's composed lines are cut into boxes where the column, the line
|
||||
//! slot (a wrap) or a gap changes; each box holds its lines as paragraphs with line breaks. Line
|
||||
//! starts come from indents, widths from character spacing on the runs (PowerPoint has no word
|
||||
//! spacing, so a justified space is a run of its own), baselines from percentage line spacing and
|
||||
//! space before.
|
||||
//!
|
||||
//! [`LineBreaks::Reflow`]: one box per frame, native columns, paragraph attributes from the
|
||||
//! styles.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use designcraft_compose::{ComposedStory, FrameText, Line, PlacedGlyph, RunStyle};
|
||||
use designcraft_doc::{Align, Item, TextFrame};
|
||||
use designcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::LineBreaks;
|
||||
use crate::slide::{Exporter, XfrmBox, box_of};
|
||||
use crate::xml::{emu, emu_len, esc, pct};
|
||||
|
||||
/// PowerPoint's single line is 1.2 em for every font; percentage spacing scales it.
|
||||
const SINGLE: f64 = 1.2;
|
||||
/// Where PowerPoint puts a line's baseline in its line box (fraction from the top).
|
||||
const BASELINE_IN_LINE: f64 = 0.75;
|
||||
/// Largest drift (points) a run's character spacing may leave before a new run starts.
|
||||
const TOLERANCE: f64 = 0.3;
|
||||
|
||||
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
|
||||
fn is_marker(c: char) -> bool {
|
||||
('\u{E000}'..='\u{E1FF}').contains(&c)
|
||||
}
|
||||
|
||||
/// One piece of a line: a cluster's text and where the composer put it.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Unit {
|
||||
text: String,
|
||||
style: u32,
|
||||
face: designcraft_fonts::FaceRef,
|
||||
size: f64,
|
||||
/// Baseline shift (points, down positive).
|
||||
y: f64,
|
||||
x: f64,
|
||||
end: f64,
|
||||
/// Width PowerPoint gives the text without extra spacing.
|
||||
natural: f64,
|
||||
tab: bool,
|
||||
rs: Option<RunStyle>,
|
||||
}
|
||||
|
||||
/// A run of units sharing style and character spacing.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Run {
|
||||
text: String,
|
||||
style: u32,
|
||||
face: designcraft_fonts::FaceRef,
|
||||
size: f64,
|
||||
y: f64,
|
||||
/// Character spacing, points.
|
||||
spc: f64,
|
||||
rs: Option<RunStyle>,
|
||||
}
|
||||
|
||||
/// A laid-out line ready to write.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OutLine {
|
||||
runs: Vec<Run>,
|
||||
/// Text-space x where the line's first unit starts.
|
||||
start: f64,
|
||||
/// Text-space x where it ends.
|
||||
end: f64,
|
||||
baseline: f64,
|
||||
descent: f64,
|
||||
/// Largest font size on the line (PowerPoint's line height follows it).
|
||||
size: f64,
|
||||
/// The composer's leading for the line.
|
||||
leading: f64,
|
||||
tabs: Vec<f64>,
|
||||
para: usize,
|
||||
last_in_para: bool,
|
||||
/// The line ends at a space the composer doesn't draw.
|
||||
trailing_space: bool,
|
||||
}
|
||||
|
||||
/// Lines that share a text box.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Segment {
|
||||
pub lines: Vec<OutLine>,
|
||||
pub x0: f64,
|
||||
pub x1: f64,
|
||||
}
|
||||
|
||||
pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) {
|
||||
let doc = ex.doc;
|
||||
let cs = ex.cache.get(doc, tfr.story, ex.page_name.as_deref());
|
||||
let Some(ft) = cs.frame(it.id) else {
|
||||
ex.shape(it, xf, opacity, None);
|
||||
return;
|
||||
};
|
||||
let story_text = doc.story(tfr.story).map(|s| s.text.clone()).unwrap_or_default();
|
||||
let vertical = doc.frame_vertical(it);
|
||||
if vertical {
|
||||
ex.warn("vertical text frames are exported with PowerPoint's vertical text (line breaks may differ)");
|
||||
}
|
||||
let m = ex.to_slide * xf;
|
||||
if ex.opts.line_breaks == LineBreaks::Reflow || vertical {
|
||||
reflow(ex, it, tfr, ft, &cs, &story_text, xf, opacity, vertical);
|
||||
return;
|
||||
}
|
||||
let Some((_, dm)) = box_of(it.inner_bounds(), m) else {
|
||||
ex.warn("skewed text frames are exported unskewed");
|
||||
// Upright where the skewed frame's box is (its parents' transforms included).
|
||||
let inner = it.inner_bounds();
|
||||
let mut plain = it.clone();
|
||||
plain.xf = Affine::translate(xf.transform_rect_bbox(inner).origin().to_vec2() - inner.origin().to_vec2());
|
||||
frame(ex, &plain, tfr, plain.xf, opacity);
|
||||
return;
|
||||
};
|
||||
let k = (dm.sx * dm.sy).abs().sqrt();
|
||||
let table_lines: Vec<usize> = ft.tables.iter().map(|t| t.line).collect();
|
||||
let segs = segments(ex, &cs, ft, &story_text, &table_lines);
|
||||
let simple = segs.len() == 1 && ft.notes.is_empty() && ft.tables.is_empty() && ft.decos.is_empty();
|
||||
if simple && let Some(seg) = segs.first() {
|
||||
// One box: the frame itself carries the text, inset to where the lines sit.
|
||||
let inner = it.inner_bounds();
|
||||
let r = seg_rect(seg);
|
||||
let insets = [r.x0 - inner.x0, r.y0 - inner.y0, inner.x1 - r.x1, inner.y1 - r.y1];
|
||||
if insets.iter().all(|v| *v >= -0.01) {
|
||||
let body = body(ex, seg, r, k, opacity, insets);
|
||||
ex.shape(it, xf, opacity, Some(&body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
ex.shape(it, xf, opacity, None);
|
||||
decos(ex, ft, m, opacity);
|
||||
for seg in &segs {
|
||||
text_box(ex, seg, m, k, opacity, "Text");
|
||||
}
|
||||
for n in &ft.notes {
|
||||
if let Some(nft) = n.text.frames.first() {
|
||||
let nm = m * Affine::translate(n.origin.to_vec2());
|
||||
let note = segments(ex, &n.text, nft, &n.source, &[]);
|
||||
decos(ex, nft, nm, opacity);
|
||||
for seg in ¬e {
|
||||
text_box(ex, seg, nm, k, opacity, "Footnote");
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::table::tables(ex, ft, m, k, opacity);
|
||||
anchored(ex, tfr, ft, xf, opacity);
|
||||
}
|
||||
|
||||
/// Anchored objects in the frame's text.
|
||||
fn anchored(ex: &mut Exporter, tfr: &TextFrame, ft: &FrameText, xf: Affine, opacity: f32) {
|
||||
let Some(st) = ex.doc.story(tfr.story) else { return };
|
||||
let objects: Vec<_> = ft.objects.iter().filter_map(|o| st.objects.get(o.index).map(|obj| (obj.item.clone(), o.origin))).collect();
|
||||
for (item, origin) in objects {
|
||||
ex.item(&item, xf * Affine::translate(origin.to_vec2()), opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph shading and rules.
|
||||
fn decos(ex: &mut Exporter, ft: &FrameText, m: Affine, opacity: f32) {
|
||||
for d in &ft.decos {
|
||||
let Some(c) = ex.color(&d.color, d.tint, opacity) else { continue };
|
||||
if let Some((xfrm, _)) = box_of(d.rect, m) {
|
||||
ex.rect_shape(xfrm, &format!("<a:solidFill>{c}</a:solidFill>"), "Paragraph Shading");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seg_rect(seg: &Segment) -> Rect {
|
||||
let first = seg.lines.first();
|
||||
let top = first.map_or(0.0, |l| l.baseline - BASELINE_IN_LINE * line_height(seg, 0));
|
||||
let bottom = seg.lines.last().map_or(top + 1.0, |l| l.baseline + l.descent);
|
||||
Rect::new(seg.x0, top, seg.x1.max(seg.x0 + 1.0), bottom.max(top + 1.0))
|
||||
}
|
||||
|
||||
/// The PowerPoint line height used for line `i` of a segment: the baseline distance inside its
|
||||
/// paragraph, else the composer's leading for the line.
|
||||
fn line_height(seg: &Segment, i: usize) -> f64 {
|
||||
let Some(l) = seg.lines.get(i) else { return 12.0 };
|
||||
para_step(seg, i).unwrap_or(if l.leading > 0.0 { l.leading } else { l.size * SINGLE })
|
||||
}
|
||||
|
||||
/// The baseline-to-baseline distance inside line `i`'s paragraph (the median, in this segment).
|
||||
fn para_step(seg: &Segment, i: usize) -> Option<f64> {
|
||||
let para = seg.lines.get(i)?.para;
|
||||
let ls: Vec<&OutLine> = seg.lines.iter().filter(|l| l.para == para).collect();
|
||||
let mut d: Vec<f64> = ls.windows(2).map(|w| w[1].baseline - w[0].baseline).filter(|v| *v > 0.0).collect();
|
||||
if d.is_empty() {
|
||||
return None;
|
||||
}
|
||||
d.sort_by(f64::total_cmp);
|
||||
d.get(d.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn text_box(ex: &mut Exporter, seg: &Segment, m: Affine, k: f64, opacity: f32, name: &str) {
|
||||
let r = seg_rect(seg);
|
||||
let Some((xfrm, _)) = box_of(r, m) else { return };
|
||||
let body = body(ex, seg, r, k, opacity, [0.0; 4]);
|
||||
write_box(ex, xfrm, &body, name);
|
||||
}
|
||||
|
||||
fn write_box(ex: &mut Exporter, xfrm: XfrmBox, body: &str, name: &str) {
|
||||
let id = ex.id();
|
||||
let _ = write!(
|
||||
ex.out,
|
||||
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{name} {id}\"/><p:cNvSpPr txBox=\"1\"/><p:nvPr/></p:nvSpPr><p:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>{body}</p:sp>",
|
||||
xfrm.xml()
|
||||
);
|
||||
}
|
||||
|
||||
/// Cut a frame's lines into segments and lay out their runs.
|
||||
pub(crate) fn segments(ex: &mut Exporter, cs: &ComposedStory, ft: &FrameText, story: &str, skip: &[usize]) -> Vec<Segment> {
|
||||
let mut out: Vec<Segment> = Vec::new();
|
||||
let mut prev: Option<&Line> = None;
|
||||
for (i, l) in ft.lines.iter().enumerate() {
|
||||
if skip.contains(&i) {
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
let ol = out_line(ex, cs, l, story);
|
||||
let joins = prev.is_some_and(|p| {
|
||||
p.column == l.column
|
||||
&& (p.x0 - l.x0).abs() < 0.5
|
||||
&& (p.x1 - l.x1).abs() < 0.5
|
||||
&& l.baseline > p.baseline
|
||||
&& l.baseline - p.baseline < 2.5 * p.leading.max(l.leading).max(ol.size)
|
||||
});
|
||||
match out.last_mut() {
|
||||
Some(seg) if joins => seg.lines.push(ol),
|
||||
_ => out.push(Segment { lines: vec![ol], x0: l.x0, x1: l.x1 }),
|
||||
}
|
||||
prev = Some(l);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text for glyph `g`: the story's characters, or what the glyph shows when the composer
|
||||
/// inserted or changed it (page numbers, list labels, hyphens, case changes).
|
||||
fn glyph_text(ex: &mut Exporter, g: &PlacedGlyph, story: &str, prev_src: &mut Option<usize>) -> Option<String> {
|
||||
let src = story.get(g.byte..g.byte.saturating_add(g.len)).filter(|t| !t.is_empty() && !t.chars().any(is_marker));
|
||||
let face = g.face.get();
|
||||
if let Some(t) = src {
|
||||
*prev_src = Some(g.byte);
|
||||
// A single character drawn as another glyph (All Caps): what the glyph shows.
|
||||
let mut cs = t.chars();
|
||||
if let (Some(c), None) = (cs.next(), cs.next())
|
||||
&& let Some(shown) = ex.fonts.char_of(face, g.gid)
|
||||
&& shown != c
|
||||
&& ex.fonts.glyph(face, c) != Some(g.gid)
|
||||
&& shown.to_lowercase().eq(c.to_lowercase())
|
||||
{
|
||||
return Some(shown.to_string());
|
||||
}
|
||||
return Some(t.to_string());
|
||||
}
|
||||
if g.len == 0 && *prev_src == Some(g.byte) {
|
||||
// Further glyphs of a cluster already written.
|
||||
return None;
|
||||
}
|
||||
*prev_src = None;
|
||||
ex.fonts.char_of(face, g.gid).map(|c| c.to_string())
|
||||
}
|
||||
|
||||
fn out_line(ex: &mut Exporter, cs: &ComposedStory, l: &Line, story: &str) -> OutLine {
|
||||
let mut units: Vec<Unit> = Vec::new();
|
||||
let mut prev_src = None;
|
||||
let is_tab = |g: &PlacedGlyph| story.get(g.byte..g.byte.saturating_add(g.len)) == Some("\t");
|
||||
let last_shown = l.glyphs.iter().rposition(|g| g.visible || is_tab(g));
|
||||
let mut trailing_space = false;
|
||||
for (i, g) in l.glyphs.iter().enumerate() {
|
||||
let next_x = l.glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x);
|
||||
let is_tab = is_tab(g);
|
||||
if !g.visible && !is_tab {
|
||||
if last_shown.is_some_and(|k| i > k) {
|
||||
trailing_space |= story.get(g.byte..g.byte.saturating_add(g.len)).is_some_and(|t| t.chars().any(char::is_whitespace));
|
||||
} else if let Some(u) = units.last_mut() {
|
||||
// Invisible glyphs inside the line still take up their room.
|
||||
u.end = next_x.max(u.end);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let face = g.face.get();
|
||||
let size = g.sy * face.upem;
|
||||
let text = if is_tab { Some("\t".to_string()) } else { glyph_text(ex, g, story, &mut prev_src) };
|
||||
let Some(text) = text else {
|
||||
if let Some(u) = units.last_mut() {
|
||||
u.end = next_x.max(u.end);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let natural: f64 = if is_tab { 0.0 } else { text.chars().map(|c| ex.fonts.advance(face, c).unwrap_or(face.upem * 0.5) * g.sy).sum() };
|
||||
let rs = cs.styles.get(g.style as usize).cloned();
|
||||
units.push(Unit { text, style: g.style, face: g.face, size, y: g.y, x: g.x, end: next_x, natural, tab: is_tab, rs });
|
||||
}
|
||||
// A space the composer left out of the line (it ends there): in the story at the line's end.
|
||||
let end_byte = l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.byte.saturating_add(g.len)).max().unwrap_or(l.range.end);
|
||||
let after = story.get(end_byte.min(story.len())..).and_then(|t| t.chars().next());
|
||||
trailing_space |= !l.last_in_para && after.is_some_and(|c| c.is_whitespace() && c != '\n' && c != '\r');
|
||||
let start = units.first().map_or(l.x0, |u| u.x);
|
||||
let end = units.last().map_or(l.x0, |u| u.end);
|
||||
let mut runs: Vec<Run> = Vec::new();
|
||||
let mut tabs = Vec::new();
|
||||
// PowerPoint's pen, and the spacing of the run it is in.
|
||||
let mut pen = start;
|
||||
for u in &units {
|
||||
if u.tab {
|
||||
tabs.push(u.end);
|
||||
runs.push(Run { text: "\t".into(), style: u.style, face: u.face, size: u.size, y: u.y, spc: 0.0, rs: u.rs.clone() });
|
||||
pen = u.end;
|
||||
continue;
|
||||
}
|
||||
let n = u.text.chars().count().max(1) as f64;
|
||||
if let Some(r) = runs.last_mut().filter(|r| !r.text.ends_with('\t'))
|
||||
&& r.style == u.style
|
||||
&& r.face.get().id() == u.face.get().id()
|
||||
&& (r.size - u.size).abs() < 0.005
|
||||
&& (r.y - u.y).abs() < 0.01
|
||||
&& (pen + u.natural + r.spc * n - u.end).abs() <= TOLERANCE
|
||||
{
|
||||
r.text.push_str(&u.text);
|
||||
pen += u.natural + r.spc * n;
|
||||
continue;
|
||||
}
|
||||
// A new run whose spacing lands this unit where the composer put it (0.01 pt steps).
|
||||
let spc = ((u.end - pen - u.natural) / n * 100.0).round() / 100.0;
|
||||
let spc = if spc.is_finite() { spc.clamp(-100.0, 100.0) } else { 0.0 };
|
||||
runs.push(Run { text: u.text.clone(), style: u.style, face: u.face, size: u.size, y: u.y, spc, rs: u.rs.clone() });
|
||||
pen += u.natural + spc * n;
|
||||
}
|
||||
let size = units.iter().map(|u| u.size).fold(0.0, f64::max);
|
||||
OutLine {
|
||||
runs,
|
||||
start,
|
||||
end,
|
||||
baseline: l.baseline,
|
||||
descent: l.descent,
|
||||
size: if size > 0.0 { size } else { (l.ascent + l.descent).max(1.0) / SINGLE },
|
||||
leading: l.leading,
|
||||
tabs,
|
||||
para: l.para,
|
||||
last_in_para: l.last_in_para,
|
||||
trailing_space,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph alignment read off the lines: centred and right-aligned lines leave room on both or
|
||||
/// the left side; everything else starts at its left indent.
|
||||
fn align_of(seg: &Segment, lines: &[OutLine]) -> &'static str {
|
||||
let gaps: Vec<(f64, f64)> = lines.iter().map(|l| (l.start - seg.x0, seg.x1 - l.end)).collect();
|
||||
let roomy = |g: &(f64, f64)| g.0 > 1.0 || g.1 > 1.0;
|
||||
if !gaps.iter().any(roomy) {
|
||||
return "l";
|
||||
}
|
||||
if gaps.iter().all(|(a, b)| (a - b).abs() < 0.75) && gaps.iter().any(|(a, _)| *a > 1.0) {
|
||||
return "ctr";
|
||||
}
|
||||
// Flush right with room on the left: more than an indented first line of justified text.
|
||||
let indented = gaps.iter().filter(|(a, _)| *a > 1.0).count();
|
||||
if gaps.iter().all(|(_, b)| *b < 0.75) && (indented > 1 || (gaps.len() == 1 && indented == 1)) {
|
||||
return "r";
|
||||
}
|
||||
"l"
|
||||
}
|
||||
|
||||
/// `<p:txBody>` for a segment whose box is `r` (text space); `insets` place the text inside a
|
||||
/// larger shape (left, top, right, bottom).
|
||||
fn body(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32, insets: [f64; 4]) -> String {
|
||||
let [li, ti, ri, bi] = insets.map(|v| emu_len(v * k));
|
||||
format!(
|
||||
"<p:txBody><a:bodyPr wrap=\"none\" lIns=\"{li}\" tIns=\"{ti}\" rIns=\"{ri}\" bIns=\"{bi}\" rtlCol=\"0\" anchor=\"t\"><a:noAutofit/></a:bodyPr><a:lstStyle/>{}</p:txBody>",
|
||||
paragraphs(ex, seg, r, k, opacity)
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<a:p>` elements of a segment whose box is `r` (text space).
|
||||
pub(crate) fn paragraphs(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32) -> String {
|
||||
let mut s = String::new();
|
||||
let mut prev_end: Option<(f64, f64)> = None;
|
||||
// The segment's lines, a paragraph at a time; `at` is the index of each one's first line.
|
||||
let mut at = 0;
|
||||
for lines in seg.lines.chunk_by(|a, b| a.para == b.para && !a.last_in_para) {
|
||||
let i = at;
|
||||
at += lines.len();
|
||||
let (Some(first), Some(last)) = (lines.first(), lines.last()) else { continue };
|
||||
let mut h = line_height(seg, i);
|
||||
let r_in = BASELINE_IN_LINE;
|
||||
if let Some((prev_base, prev_h)) = prev_end
|
||||
&& lines.len() == 1
|
||||
{
|
||||
// A one-line paragraph closer to the line before than its own line box allows (tight
|
||||
// display type): a smaller box puts it there.
|
||||
let natural = (1.0 - r_in) * prev_h + r_in * h;
|
||||
let gap = first.baseline - prev_base;
|
||||
if gap < natural {
|
||||
h = ((gap - (1.0 - r_in) * prev_h) / r_in).max(first.size * 0.2);
|
||||
}
|
||||
}
|
||||
let algn = align_of(seg, lines);
|
||||
let mut ppr = format!("<a:pPr algn=\"{algn}\"");
|
||||
if algn == "l" {
|
||||
let rest = lines.get(1).map_or(first.start, |l| l.start) - r.x0;
|
||||
let ind = first.start - r.x0 - rest;
|
||||
if rest.abs() > 0.005 {
|
||||
let _ = write!(ppr, " marL=\"{}\"", emu(rest * k));
|
||||
}
|
||||
if ind.abs() > 0.005 {
|
||||
let _ = write!(ppr, " indent=\"{}\"", emu(ind * k));
|
||||
}
|
||||
}
|
||||
ppr.push('>');
|
||||
let _ = write!(ppr, "<a:lnSpc><a:spcPct val=\"{}\"/></a:lnSpc>", pct(h / (first.size * SINGLE)));
|
||||
if let Some((prev_base, prev_h)) = prev_end {
|
||||
// PowerPoint puts the next line one line box down: the rest of the previous line's box
|
||||
// and the top part of this one. Space Before makes up the difference.
|
||||
let natural = (1.0 - r_in) * prev_h + r_in * h;
|
||||
let before = first.baseline - prev_base - natural;
|
||||
if before > 0.005 {
|
||||
let _ = write!(ppr, "<a:spcBef><a:spcPts val=\"{}\"/></a:spcBef>", (before * k * 100.0).round() as i64);
|
||||
}
|
||||
}
|
||||
let tabs: Vec<f64> = lines.iter().flat_map(|l| l.tabs.iter().copied()).collect();
|
||||
if !tabs.is_empty() {
|
||||
ppr.push_str("<a:tabLst>");
|
||||
let mut sorted = tabs.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
sorted.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||||
for t in sorted {
|
||||
let _ = write!(ppr, "<a:tab pos=\"{}\" algn=\"l\"/>", emu((t - r.x0) * k));
|
||||
}
|
||||
ppr.push_str("</a:tabLst>");
|
||||
}
|
||||
ppr.push_str("</a:pPr>");
|
||||
s.push_str("<a:p>");
|
||||
s.push_str(&ppr);
|
||||
for (n, l) in lines.iter().enumerate() {
|
||||
if n > 0 {
|
||||
let _ = write!(s, "<a:br>{}</a:br>", rpr(ex, l.runs.first(), l.size, k, opacity, "a:rPr"));
|
||||
}
|
||||
let n_runs = l.runs.len();
|
||||
for (ri, run) in l.runs.iter().enumerate() {
|
||||
// The undrawn space at a left-aligned line's end goes back in (invisible there),
|
||||
// so removing the break doesn't join words.
|
||||
let tail = if ri + 1 == n_runs && l.trailing_space && algn == "l" && !run.text.ends_with(char::is_whitespace) { " " } else { "" };
|
||||
let _ = write!(s, "<a:r>{}<a:t>{}{tail}</a:t></a:r>", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text));
|
||||
}
|
||||
}
|
||||
s.push_str(&rpr(ex, last.runs.last(), last.size, k, opacity, "a:endParaRPr"));
|
||||
s.push_str("</a:p>");
|
||||
prev_end = Some((last.baseline, h));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Run properties (`a:rPr`, or `a:endParaRPr` for the paragraph mark).
|
||||
fn rpr(ex: &mut Exporter, run: Option<&Run>, size: f64, k: f64, opacity: f32, tag: &str) -> String {
|
||||
// PowerPoint draws raised and lowered text at 2/3 of its size: state it larger.
|
||||
let shifted = run.is_some_and(|r| r.y.abs() > 0.01 && r.size > 0.0);
|
||||
let size = if shifted { size * 1.5 } else { size };
|
||||
let sz = ((size * k * 100.0).round() as i64).clamp(100, 400_000);
|
||||
let Some(run) = run else { return format!("<{tag} lang=\"en-US\" sz=\"{sz}\" dirty=\"0\"/>") };
|
||||
let cs = run.rs.as_ref();
|
||||
let font = ex.fonts.use_face(run.face);
|
||||
let mut a = format!("<{tag} lang=\"en-US\" sz=\"{sz}\"");
|
||||
let italic = font.italic || cs.is_some_and(|c| c.skew.abs() > 0.01);
|
||||
if font.bold {
|
||||
a.push_str(" b=\"1\"");
|
||||
}
|
||||
if italic {
|
||||
a.push_str(" i=\"1\"");
|
||||
}
|
||||
if let Some(st) = cs {
|
||||
if st.underline {
|
||||
a.push_str(" u=\"sng\"");
|
||||
}
|
||||
if st.strikethrough {
|
||||
a.push_str(" strike=\"sngStrike\"");
|
||||
}
|
||||
}
|
||||
// Kerning off: the composer's kerning is in the character spacing already.
|
||||
a.push_str(" kern=\"400000\"");
|
||||
let spc = (run.spc * k * 100.0).round() as i64;
|
||||
if spc != 0 && tag == "a:rPr" {
|
||||
let _ = write!(a, " spc=\"{spc}\"");
|
||||
}
|
||||
if shifted {
|
||||
let _ = write!(a, " baseline=\"{}\"", pct(-run.y / size));
|
||||
}
|
||||
a.push_str(" dirty=\"0\">");
|
||||
if let Some(st) = cs {
|
||||
if st.stroke != designcraft_color::swatch::NONE
|
||||
&& st.stroke_weight > 0.0
|
||||
&& let Some(c) = ex.color(&st.stroke, st.stroke_tint, opacity)
|
||||
{
|
||||
let _ = write!(a, "<a:ln w=\"{}\"><a:solidFill>{c}</a:solidFill></a:ln>", emu_len(st.stroke_weight * k));
|
||||
}
|
||||
match ex.color(&st.fill, st.fill_tint, opacity) {
|
||||
Some(c) => {
|
||||
let _ = write!(a, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
None => a.push_str("<a:noFill/>"),
|
||||
}
|
||||
}
|
||||
let fam = esc(&font.family);
|
||||
let _ = write!(a, "<a:latin typeface=\"{fam}\"/><a:ea typeface=\"{fam}\"/><a:cs typeface=\"{fam}\"/></{tag}>");
|
||||
a
|
||||
}
|
||||
|
||||
/// Reflow: the frame as one shape whose text PowerPoint sets, in the frame's columns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reflow(ex: &mut Exporter, it: &Item, tfr: &TextFrame, ft: &FrameText, cs: &ComposedStory, story: &str, xf: Affine, opacity: f32, vertical: bool) {
|
||||
let doc = ex.doc;
|
||||
let Some(st) = doc.story(tfr.story) else {
|
||||
ex.shape(it, xf, opacity, None);
|
||||
return;
|
||||
};
|
||||
let o = &tfr.options;
|
||||
let m = ex.to_slide * xf;
|
||||
let k = box_of(it.inner_bounds(), m).map_or(1.0, |(_, d)| (d.sx * d.sy).abs().sqrt());
|
||||
let [t, l, b, r] = o.inset.map(|v| emu_len(v * k));
|
||||
let cols = ft.columns.len().max(1);
|
||||
let mut s = format!("<p:txBody><a:bodyPr wrap=\"square\" lIns=\"{l}\" tIns=\"{t}\" rIns=\"{r}\" bIns=\"{b}\" rtlCol=\"0\"");
|
||||
if cols > 1 {
|
||||
let _ = write!(s, " numCol=\"{cols}\" spcCol=\"{}\"", emu_len(o.gutter * k));
|
||||
}
|
||||
if vertical {
|
||||
s.push_str(" vert=\"eaVert\"");
|
||||
}
|
||||
let anchor = match o.vertical_justification {
|
||||
designcraft_doc::VerticalJustification::Center => "ctr",
|
||||
designcraft_doc::VerticalJustification::Bottom => "b",
|
||||
designcraft_doc::VerticalJustification::Justify => "just",
|
||||
designcraft_doc::VerticalJustification::Top => "t",
|
||||
};
|
||||
let _ = write!(s, " anchor=\"{anchor}\"><a:noAutofit/></a:bodyPr><a:lstStyle/>");
|
||||
// The paragraphs this frame shows, with the runs of their composed glyphs.
|
||||
let ranges = st.para_ranges();
|
||||
let mut first = true;
|
||||
for (pi, range) in ranges.iter().enumerate() {
|
||||
if range.end < ft.range.start || range.start >= ft.range.end.max(ft.range.start + 1) {
|
||||
continue;
|
||||
}
|
||||
let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect();
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (pp, chars) = doc.styles.resolve_para(pf);
|
||||
let algn = match pp.align {
|
||||
Align::Center => "ctr",
|
||||
Align::Right => "r",
|
||||
Align::LeftJustified | Align::CenterJustified | Align::RightJustified => "just",
|
||||
Align::FullyJustified => "dist",
|
||||
_ => "l",
|
||||
};
|
||||
let mut ppr = format!("<a:pPr algn=\"{algn}\"");
|
||||
if pp.left_indent.abs() > 0.005 {
|
||||
let _ = write!(ppr, " marL=\"{}\"", emu(pp.left_indent * k));
|
||||
}
|
||||
if pp.right_indent.abs() > 0.005 {
|
||||
let _ = write!(ppr, " marR=\"{}\"", emu(pp.right_indent * k));
|
||||
}
|
||||
if pp.first_line_indent.abs() > 0.005 {
|
||||
let _ = write!(ppr, " indent=\"{}\"", emu(pp.first_line_indent * k));
|
||||
}
|
||||
ppr.push('>');
|
||||
let size = chars.size.max(1.0);
|
||||
let lead = match lines.windows(2).map(|w| w[1].baseline - w[0].baseline).find(|d| *d > 0.0) {
|
||||
Some(d) => d,
|
||||
None => lines.first().map_or(size * pp.auto_leading, |l| l.leading.max(1.0)),
|
||||
};
|
||||
let _ = write!(ppr, "<a:lnSpc><a:spcPct val=\"{}\"/></a:lnSpc>", pct(lead / (size * SINGLE)));
|
||||
if !first && pp.space_before > 0.0 {
|
||||
let _ = write!(ppr, "<a:spcBef><a:spcPts val=\"{}\"/></a:spcBef>", (pp.space_before * k * 100.0).round() as i64);
|
||||
}
|
||||
if pp.space_after > 0.0 {
|
||||
let _ = write!(ppr, "<a:spcAft><a:spcPts val=\"{}\"/></a:spcAft>", (pp.space_after * k * 100.0).round() as i64);
|
||||
}
|
||||
ppr.push_str("</a:pPr>");
|
||||
s.push_str("<a:p>");
|
||||
s.push_str(&ppr);
|
||||
let mut last_size = size;
|
||||
for l in &lines {
|
||||
let ol = out_line(ex, cs, l, story);
|
||||
let mut runs: Vec<Run> = Vec::new();
|
||||
for mut r in ol.runs {
|
||||
// PowerPoint sets the spacing; only tracking-sized adjustments would survive, and
|
||||
// they vary line to line, so none is kept.
|
||||
r.spc = 0.0;
|
||||
match runs.last_mut() {
|
||||
Some(p)
|
||||
if p.style == r.style
|
||||
&& p.face.get().id() == r.face.get().id()
|
||||
&& (p.size - r.size).abs() < 0.005
|
||||
&& (p.y - r.y).abs() < 0.01 =>
|
||||
{
|
||||
p.text.push_str(&r.text)
|
||||
}
|
||||
_ => runs.push(r),
|
||||
}
|
||||
}
|
||||
// Composer hyphens at line ends go: PowerPoint breaks the lines itself.
|
||||
if l.hyphenated
|
||||
&& let Some(r) = runs.last_mut()
|
||||
&& r.text.ends_with(['-', '\u{2010}'])
|
||||
{
|
||||
r.text.pop();
|
||||
}
|
||||
if ol.trailing_space
|
||||
&& let Some(r) = runs.last_mut()
|
||||
&& !r.text.ends_with(char::is_whitespace)
|
||||
{
|
||||
r.text.push(' ');
|
||||
}
|
||||
for run in &runs {
|
||||
let _ = write!(s, "<a:r>{}<a:t>{}</a:t></a:r>", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text));
|
||||
last_size = run.size;
|
||||
}
|
||||
}
|
||||
s.push_str(&rpr(ex, None, last_size, k, opacity, "a:endParaRPr"));
|
||||
s.push_str("</a:p>");
|
||||
first = false;
|
||||
}
|
||||
s.push_str("</p:txBody>");
|
||||
ex.shape(it, xf, opacity, Some(&s));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! XML text helpers and DrawingML units.
|
||||
|
||||
/// English Metric Units per point.
|
||||
pub const EMU_PER_PT: f64 = 12_700.0;
|
||||
|
||||
/// Points → EMU, saturating (input-derived geometry may be huge or not finite).
|
||||
pub fn emu(pt: f64) -> i64 {
|
||||
if !pt.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
(pt * EMU_PER_PT).round().clamp(-27_273_042_316_900.0, 27_273_042_316_900.0) as i64
|
||||
}
|
||||
|
||||
/// A non-negative extent in EMU (DrawingML rejects negative sizes).
|
||||
pub fn emu_len(pt: f64) -> i64 {
|
||||
emu(pt).max(0)
|
||||
}
|
||||
|
||||
/// Degrees → DrawingML angle units (60 000ths of a degree), normalised to 0..360°.
|
||||
pub fn angle(deg: f64) -> i64 {
|
||||
if !deg.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
(deg.rem_euclid(360.0) * 60_000.0).round() as i64 % 21_600_000
|
||||
}
|
||||
|
||||
/// A 0..1 fraction → DrawingML percentage units (1000ths of a percent).
|
||||
pub fn pct(v: f64) -> i64 {
|
||||
if !v.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
(v * 100_000.0).round().clamp(-100_000_000.0, 100_000_000.0) as i64
|
||||
}
|
||||
|
||||
/// Escape text for element content and attribute values. Characters XML 1.0 can't hold are
|
||||
/// dropped.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\'' => o.push_str("'"),
|
||||
'\t' | '\n' | '\r' => o.push(c),
|
||||
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// sRGB 0..1 → `RRGGBB`.
|
||||
pub fn hex(rgb: [f32; 3]) -> String {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn units() {
|
||||
assert_eq!(emu(1.0), 12_700);
|
||||
assert_eq!(emu(f64::NAN), 0);
|
||||
assert_eq!(emu_len(-3.0), 0);
|
||||
assert_eq!(angle(-90.0), 16_200_000);
|
||||
assert_eq!(angle(360.0), 0);
|
||||
assert_eq!(pct(0.5), 50_000);
|
||||
assert_eq!(esc("a<b & \"c\"\u{1}"), "a<b & "c"");
|
||||
assert_eq!(hex([1.0, 0.0, 0.5]), "FF0080");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
),
|
||||
("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"),
|
||||
("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"),
|
||||
("app.exportPptx", "Export PowerPoint…", None, "{path?} — every page as a slide of editable shapes, pictures and text"),
|
||||
("app.exportXml", "Export XML…", None, "{path?} — the tagged content"),
|
||||
("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"),
|
||||
("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"),
|
||||
@@ -180,6 +181,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:app.exportFixedEpub",
|
||||
"ui:app.exportInteractivePdf",
|
||||
"ui:app.exportHtml",
|
||||
"ui:app.exportPptx",
|
||||
"ui:app.exportXml",
|
||||
"ui:app.exportText",
|
||||
">Export",
|
||||
@@ -753,6 +755,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
}
|
||||
"app.exportFixedEpub" => export_bytes(app, p, "epub", "file.exportFixedEpub"),
|
||||
"app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"),
|
||||
"app.exportPptx" => export_bytes(app, p, "pptx", "file.exportPptx"),
|
||||
"app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"),
|
||||
"app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"),
|
||||
"app.menus" => {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("pdf", Class::Layer(3)),
|
||||
("idml", Class::Layer(3)),
|
||||
("epub", Class::Layer(3)),
|
||||
("pptx", Class::Layer(3)),
|
||||
("textimport", Class::Layer(3)),
|
||||
("format", Class::Layer(3)),
|
||||
("tools", Class::Layer(4)),
|
||||
|
||||
Reference in New Issue
Block a user