craft-pptx: the writer (from DeckCraft) (#1065)

* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps

The parts of DrawingML that need no document model, moved from DeckCraft
(60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU
conversions) and crates/pptx/src/custgeom.rs with CustomPath from
crates/model.

The code is unchanged except that custgeom reads craft-xml trees and writes
to a craft-xml Writer instead of DeckCraft's own XML helper, and matches
elements by local name. The 59 tests of the moved code come with it; six
more cover the custom geometry limits.

* drawingml: evaluate a custom geometry's guide lists once per coordinate space

read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every
path: up to 1,000 paths times up to 10,000 guides, each name copied and
hashed.

Guides are now cached per path coordinate space (w, h). At most 16 spaces
and 4 MiB of guide names are kept per geometry; paths in further spaces are
left out. The first space is always kept, and a geometry without guides is
not capped.

Ported from storytold/deckcraft#1789. A list counts as having guides only
when it has gd children, as that PR's review asked.

* drawingml: the model types for fills, lines, text, tables, charts and diagrams

Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs,
table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode
and the MediaId and SlideId newtypes from lib.rs.

The files are unchanged except that deckcraft_color:: is crate::color:: and
Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come
with them. Readers and writers follow.

* drawingml: the theme model (colour, font and format schemes)

Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme,
FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default
theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends
on a gallery.

* drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams

Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs,
read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto
craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink.
The chart frame budget and kept-part capture stay with the presentation reader.

* drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not

Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations;
a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads
anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them.

* craft-drawingml: extra named theme colours and explicit tint rows

Ported from the format part of storytold/deckcraft#1746 by @jagjordi: ColorScheme::{extra, tints,
computed_tints}, ExtraColor, GalleryVariant, variants_of, and the a:clrScheme extension that
keeps them in a file.

* drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches

From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78.

* Add craft-pptx: the PresentationML model (from DeckCraft)

Presentation, masters, layouts, slides, shapes, placeholders, media,
comments, sections, show settings, properties, embedded fonts, kept parts
and drawing guides from DeckCraft's crates/model/src/lib.rs, and
transitions and animations from anim.rs. The DrawingML types come from
craft-drawingml. The placeholder matching from DeckCraft's resolve.rs
moves as placeholder::parents, without the rest of resolve.

Presentation::default() is now an empty 16:9 deck with no masters; the
starting deck stays with the application.

* craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml

DeckCraft's crates/pptx reader (read.rs, read/shapes.rs and the chart
frame code of read/chart.rs) moved onto craft-opc's Package and
craft-xml trees, with the DrawingML readers of craft-drawingml behind a
part::Source over the import state. What DeckCraft took from its own
defaults (a master for a file without one, the theme of a master that
names none) comes from a caller-supplied Defaults; Plain is the neutral
one. Placeholder geometry inheritance uses placeholder::parents.

Transitions, animations, notes and comments follow in later changes.

* craft-pptx: drop DeckCraft issue numbers from reader comments

* craft-pptx: link the package budgets from the tag budget's doc

* craft-pptx: the writer (from DeckCraft)

DeckCraft's crates/pptx writer (write.rs, write/shapes.rs and the kept
chart parts of write/chart.rs) moved onto craft-opc's PackageOut and
craft-xml's Writer, with craft-drawingml's DrawingML writers behind a
part::Sink over the export state. What DeckCraft took from its own
defaults (a master for a deck without one, master and default text
styles, its name in docProps/app.xml and in table style names) comes
from Defaults. export and export_as take the defaults.

Transitions, animations, notes slides and comments follow in later
changes.

---------

Co-authored-by: Jordi Altayó <jordiag@kth.se>
This commit is contained in:
Dylan M. TaylorandJordi Altayó authored and GitHub committed 2026-11-11 21:53:02 -05:00
1 parent 1d92efa707
commit d6e3a6c438
6 files changed
+2503 -4

No files matched your search

+12 -2
View File
@@ -32,5 +32,15 @@ readable one, and the theme of a master that names none. `Plain` has no applicat
`import_theme` reads the theme of a `.thmx`, or of a presentation's first master. `sniff` tells a
package from other bytes.
Not read yet (later changes): transitions and animations, speaker notes, comments. The writer
comes later too.
## Writing
`export(&presentation, &defaults)` writes a package PowerPoint opens without repair; `export_as`
writes a `.potx` or `.ppsx` instead. Masters, layouts and slides with their shapes, pictures,
media, tables and table styles, charts (an unchanged chart's workbook and other parts written
back), SmartArt, 3-D models, ink (as freeform lines), objects kept as XML (with their parts),
notes and handout masters, sections, custom shows, show settings, drawing guides, embedded fonts,
tags, kept parts and document properties. A slide whose layout is missing goes on the first one;
a presentation with no master gets the caller's. `Defaults` also gives the text styles written
where a master or the presentation has none, and the program name for `docProps/app.xml`.
Not read or written yet (later changes): transitions and animations, speaker notes, comments.
+35 -2
View File
@@ -15,6 +15,7 @@ pub mod anim;
mod package;
pub mod placeholder;
mod read;
mod write;
pub use package::{LIMITS, MAX_ENTRIES, MAX_INFLATE_WORK, MAX_PART, MAX_TOTAL, MAX_XML_PART};
pub use read::MAX_TAG_BYTES;
@@ -50,8 +51,10 @@ pub enum Error {
Xml(String),
}
/// What the caller supplies where a file leaves something out: the reader asks for a master when a
/// file has none it can read, and for the theme of a master that names none.
/// What the caller supplies where a file or a presentation leaves something out. The reader asks
/// for a master when a file has none it can read, and for the theme of a master that names none;
/// the writer for a master when the presentation has none, and for the text styles a master or
/// the presentation leaves empty.
pub trait Defaults {
/// A slide master for slides of `size`, with at least one layout. Its ids (master, layouts,
/// shapes) are above `after`; returns the master and the largest id it used.
@@ -60,6 +63,19 @@ pub trait Defaults {
fn theme(&self) -> Theme {
Theme::default()
}
/// The title, body and other text styles written for a master whose own are empty.
fn master_styles(&self) -> (text::ListStyle, text::ListStyle, text::ListStyle) {
Default::default()
}
/// The presentation's default text style, written when its own is empty.
fn default_text_style(&self) -> text::ListStyle {
text::ListStyle::default()
}
/// The program writing the file: `Application` in `docProps/app.xml`, and the first word of
/// the names of the table styles the writer defines.
fn application(&self) -> &str {
"craft-pptx"
}
}
/// [`Defaults`] with nothing of an application's: a master with one blank layout and the plain
@@ -107,6 +123,23 @@ pub fn import_theme(bytes: &[u8]) -> Result<(Theme, Vec<MediaItem>), Error> {
read::import_theme(bytes)
}
/// Writes a `.pptx` package that PowerPoint opens without repair. `defaults` fills in what the
/// presentation leaves out (see [`Defaults`]).
pub fn export(p: &Presentation, defaults: &dyn Defaults) -> Result<Vec<u8>, Error> {
write::export(p, defaults, write::CT_PRES)
}
/// Writes a package for `ext`: `potx` (template) and `ppsx` (slide show) differ from `pptx` only in
/// the main part's content type.
pub fn export_as(p: &Presentation, defaults: &dyn Defaults, ext: &str) -> Result<Vec<u8>, Error> {
let ct = match ext {
"potx" => write::CT_TEMPLATE,
"ppsx" => write::CT_SHOW,
_ => write::CT_PRES,
};
write::export(p, defaults, ct)
}
/// Content type for a file name's extension.
pub(crate) fn mime_for(name: &str) -> &'static str {
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
//! Charts: the parts an unchanged chart carried (workbook, styles, colours), written back under
//! fresh names.
use craft_drawingml::chart::write::sidecar_part;
pub(crate) use craft_drawingml::chart::write::{chart_xml, chartex_part};
use crate::chart::Chart;
/// A fresh name for a kept part: `ppt/<folder>/keptN.<ext>`. The writer generates no `kept…`
/// names and `n` is unique within the package, so a kept part never lands on (or duplicates) a
/// part the writer makes, whatever the source file called it.
fn kept_name(original: &str, n: u32) -> String {
let norm = craft_opc::normalize(original);
let area = norm.split('/').nth(1).unwrap_or("embeddings");
let area = if matches!(area, "embeddings" | "charts" | "drawings" | "media") { area } else { "embeddings" };
let file = craft_opc::file_of(&norm);
let ext = file
.rsplit_once('.')
.map(|(_, e)| e)
.filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or("bin");
format!("ppt/{area}/kept{n}.{ext}")
}
/// Write an unchanged chart's own relationships and the parts they reach. Each kept part is
/// written under a fresh name ([`kept_name`]) and the relationships between kept parts are
/// rewritten to those names; a relationship to anything that was not kept is dropped.
/// `n_kept` numbers kept parts across the package.
pub fn write_kept(pkg: &mut craft_opc::PackageOut, chart_name: &str, c: &Chart, n_kept: &mut u32) {
use std::collections::HashMap;
use craft_opc::{RelOut, RelsOut, normalize as norm, relative, rels_path};
if c.kept.rels.is_empty() {
return;
}
// Original part name (normalized) → the name written.
let mut names: HashMap<String, String> = HashMap::new();
for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) {
let key = norm(&p.name);
if names.contains_key(&key) {
continue;
}
*n_kept += 1;
let new = kept_name(&p.name, *n_kept);
let ct = if !p.content_type.is_empty() { p.content_type.clone() } else { crate::mime_for(&p.name).to_string() };
// An override per part: a kept part never sets the package-wide default for its extension.
pkg.xml_part(&new, &ct, p.bytes.clone());
names.insert(key, new);
}
let mut rels = RelsOut::default();
for r in &c.kept.rels {
if r.external {
rels.items.push(RelOut { id: r.id.clone(), ty: r.rel_type.clone(), target: r.target.clone(), external: true });
} else if let Some(new) = names.get(&norm(&r.part)) {
rels.items.push(RelOut { id: r.id.clone(), ty: r.rel_type.clone(), target: relative(chart_name, new), external: false });
} else {
log::warn!("pptx: chart relationship {} has no kept part; dropped", r.id);
}
}
pkg.rels(chart_name, &rels);
let mut parts_by_norm = std::collections::HashMap::new();
for p in &c.kept.parts {
parts_by_norm.entry(norm(&p.name)).or_insert(p);
}
let mut seen_payloads = std::collections::HashSet::new();
// The kept parts' own relationships, pointed at the new names.
for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) {
let norm_name = norm(&p.name);
if !seen_payloads.insert(norm_name.clone()) {
continue;
}
let Some(new_source) = names.get(&norm_name) else { continue };
let rp = norm(&rels_path(&p.name));
let Some(rels_part) = parts_by_norm.get(&rp) else { continue };
let mut out = RelsOut::default();
for r in craft_opc::Rels::parse(&rels_part.bytes, &p.name).list {
if r.external {
out.items.push(RelOut { id: r.id, ty: r.ty, target: r.target_raw, external: true });
} else if let Some(new) = names.get(&norm(&r.target)) {
out.items.push(RelOut { id: r.id, ty: r.ty, target: relative(new_source, new), external: false });
} else {
log::warn!("pptx: relationship {} of kept chart part {} has no kept target; dropped", r.id, p.name);
}
}
pkg.rels(new_source, &out);
}
}
/// A kept part to write itself (relationships parts are rewritten from the relationships).
fn kept_payload(name: &str) -> bool {
!name.to_ascii_lowercase().ends_with(".rels") && sidecar_part(name)
}
File diff suppressed because it is too large. Load diff
+204
View File
@@ -0,0 +1,204 @@
//! The writer, read back with the reader.
use std::sync::Arc;
use craft_pptx::table::{Cell, Table};
use craft_pptx::{
ColorRef, Defaults, Geom, MediaId, Plain, Presentation, Rgba, SchemeSlot, Shape, ShapeId, ShapeKind, Size, Slide, SlideId, TextBody, Xfrm,
};
/// A deck with the plain master and one slide on its layout.
fn deck() -> Presentation {
let mut p = Presentation::default();
let (m, last) = Plain.master(p.slide_size, 0);
let layout = m.layouts[0].id;
p.masters.push(Arc::new(m));
p.next_id = last;
let id = SlideId(p.alloc_id().unwrap());
p.slides.push(Arc::new(Slide { id, layout, ..Default::default() }));
p
}
fn add(p: &mut Presentation, mut s: Shape) -> ShapeId {
s.id = ShapeId(p.alloc_id().unwrap());
let id = s.id;
Arc::make_mut(&mut p.slides[0]).shapes.push(s);
id
}
fn bytes_of(pptx: &[u8], name: &str) -> Vec<u8> {
craft_opc::Package::open(pptx, &craft_pptx::LIMITS).unwrap().get(name).unwrap().to_vec()
}
fn part(pptx: &[u8], name: &str) -> String {
String::from_utf8(bytes_of(pptx, name)).unwrap()
}
fn round_trip(p: &Presentation) -> Presentation {
let bytes = craft_pptx::export(p, &Plain).unwrap();
craft_pptx::import(&bytes, &Plain).unwrap()
}
#[test]
fn shapes_survive_writing_and_reading() {
let mut p = deck();
let png = p.add_media("dot.png", "image/png", b"\x89PNG not really".to_vec()).unwrap();
let mut text = TextBody::from_text("Hello\nWorld");
text.body.anchor = Some(craft_pptx::text::Anchor::Bottom);
let a = add(
&mut p,
Shape {
name: "Box".into(),
xfrm: Some(Xfrm::new(10.0, 20.0, 200.0, 100.0)),
geom: Geom::Preset { name: "roundRect".into(), adj: vec![25000.0] },
fill: Some(craft_pptx::Fill::solid(ColorRef::rgb(Rgba::rgb(0x12, 0x34, 0x56)))),
text: Some(text),
descr: "alt".into(),
..Default::default()
},
);
let b = add(
&mut p,
Shape {
name: "Picture".into(),
xfrm: Some(Xfrm::new(300.0, 20.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: craft_pptx::style::PictureFill { media: png, ..Default::default() } },
..Default::default()
},
);
add(&mut p, Shape { name: "Line".into(), kind: ShapeKind::Connector { start: Some((a, 1)), end: Some((b, 3)) }, ..Default::default() });
let mut t = Table::new(2, 2, 200.0, 30.0);
t.rows[1].cells[1] = Cell::new("B2");
add(&mut p, Shape { name: "Table".into(), xfrm: Some(Xfrm::new(0.0, 200.0, 200.0, 60.0)), kind: ShapeKind::Table(t), ..Default::default() });
let q = round_trip(&p);
assert!(q.validate().is_empty(), "{:?}", q.validate());
let s = &q.slides[0].shapes;
assert_eq!(s.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Box", "Picture", "Line", "Table"]);
assert_eq!(s[0].xfrm, p.slides[0].shapes[0].xfrm);
assert_eq!(s[0].geom, p.slides[0].shapes[0].geom);
assert_eq!(s[0].fill, p.slides[0].shapes[0].fill);
assert_eq!(s[0].descr, "alt");
assert_eq!(s[0].text.as_ref().map(|t| (t.text(), t.body.anchor)), Some(("Hello\nWorld".into(), Some(craft_pptx::text::Anchor::Bottom))));
let ShapeKind::Picture { fill } = &s[1].kind else { panic!("not a picture") };
assert_eq!(q.media(fill.media).map(|m| m.data.as_slice()), Some(&b"\x89PNG not really"[..]));
assert_eq!(s[2].kind, ShapeKind::Connector { start: Some((s[0].id, 1)), end: Some((s[1].id, 3)) });
let ShapeKind::Table(t) = &s[3].kind else { panic!("not a table") };
assert_eq!(t.rows[1].cells[1].text.text(), "B2");
}
#[test]
fn a_deck_without_masters_gets_the_callers() {
let mut p = Presentation::default();
p.slides.push(Arc::new(Slide { id: SlideId(1), ..Default::default() }));
let bytes = craft_pptx::export(&p, &Plain).unwrap();
let q = craft_pptx::import(&bytes, &Plain).unwrap();
assert_eq!(q.masters.len(), 1);
assert_eq!(q.slides.len(), 1);
assert_eq!(q.slides[0].layout, q.masters[0].layouts[0].id);
}
#[test]
fn a_missing_picture_is_written_as_a_transparent_stand_in() {
let mut p = deck();
add(
&mut p,
Shape {
kind: ShapeKind::Picture { fill: craft_pptx::style::PictureFill { media: MediaId(999), ..Default::default() } },
..Default::default()
},
);
let bytes = craft_pptx::export(&p, &Plain).unwrap();
let png = bytes_of(&bytes, "ppt/media/image1.png");
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n") && png.ends_with(b"IEND\xAE\x42\x60\x82"), "{png:?}");
}
/// The caller's name and text styles go into the file.
#[test]
fn the_callers_defaults_are_written() {
struct Acme;
impl Defaults for Acme {
fn master(&self, size: Size, after: u32) -> (craft_pptx::Master, u32) {
Plain.master(size, after)
}
fn default_text_style(&self) -> craft_pptx::text::ListStyle {
let mut ls = craft_pptx::text::ListStyle::default();
ls.set(
0,
craft_pptx::text::LevelStyle { run: craft_pptx::text::RunProps { size: Some(26.0), ..Default::default() }, ..Default::default() },
);
ls
}
fn application(&self) -> &str {
"Acme"
}
}
let mut p = deck();
let t = Table { style: "light1-accent2".into(), ..Table::new(1, 1, 100.0, 20.0) };
add(&mut p, Shape { kind: ShapeKind::Table(t), ..Default::default() });
let bytes = craft_pptx::export(&p, &Acme).unwrap();
assert!(part(&bytes, "docProps/app.xml").contains("<Application>Acme</Application>"));
assert!(part(&bytes, "ppt/tableStyles.xml").contains("styleName=\"Acme "), "{}", part(&bytes, "ppt/tableStyles.xml"));
let q = craft_pptx::import(&bytes, &Plain).unwrap();
assert_eq!(q.default_text_style.level(0).and_then(|l| l.run.size), Some(26.0));
assert!(part(&craft_pptx::export(&p, &Plain).unwrap(), "docProps/app.xml").contains("<Application>craft-pptx</Application>"));
}
/// From DeckCraft's `crates/pptx/src/lib.rs`.
#[test]
fn table_direction_and_column_order_survive_two_saves() {
for rtl in [Some(true), Some(false), None] {
let mut p = deck();
let mut t = Table::new(1, 2, 200.0, 30.0);
t.rtl = rtl;
t.rows[0].cells[0] = Cell::new("First column");
t.rows[0].cells[1] = Cell::new("Second column");
add(&mut p, Shape { kind: ShapeKind::Table(t), ..Default::default() });
let mut q = round_trip(&p);
for _ in 0..2 {
let Some(ShapeKind::Table(t)) = q.slides[0].shapes.first().map(|s| &s.kind) else { panic!("no table") };
assert_eq!(t.rtl, rtl);
assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column");
assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column");
q = round_trip(&q);
}
}
}
/// From DeckCraft's `crates/pptx/src/lib.rs`: the theme of a written deck, and of a `.thmx`
/// holding that theme part.
#[test]
fn reads_the_theme_of_a_pptx_and_of_a_thmx() {
let mut p = deck();
let m = Arc::make_mut(&mut p.masters[0]);
m.theme.name = "Harbour".into();
m.theme.fonts.major.latin = "Lato".into();
m.theme.colors.set(SchemeSlot::Accent1, Rgba::rgb(0x12, 0x34, 0x56));
let pptx = craft_pptx::export(&p, &Plain).unwrap();
let mut out = craft_opc::PackageOut::default();
out.xml_part(
"theme/theme/theme1.xml",
"application/vnd.openxmlformats-officedocument.theme+xml",
part(&pptx, "ppt/theme/theme1.xml").into_bytes(),
);
let mut rels = craft_opc::RelsOut::default();
rels.add("officeDocument", "theme/theme/theme1.xml", false);
out.rels("", &rels);
let thmx = out.finish().unwrap();
for bytes in [pptx, thmx] {
let (t, _) = craft_pptx::import_theme(&bytes).unwrap();
assert_eq!(t.name, "Harbour");
assert_eq!(t.fonts.major.latin, "Lato");
assert_eq!(t.colors.get(SchemeSlot::Accent1), Rgba::rgb(0x12, 0x34, 0x56));
}
}
#[test]
fn templates_and_shows_differ_in_the_main_content_type() {
let p = deck();
for (ext, ct) in [("potx", "template.main+xml"), ("ppsx", "slideshow.main+xml"), ("pptx", "presentation.main+xml")] {
let bytes = craft_pptx::export_as(&p, &Plain, ext).unwrap();
assert!(part(&bytes, "[Content_Types].xml").contains(ct), "{ext}");
assert_eq!(craft_pptx::import(&bytes, &Plain).unwrap().slides.len(), 1);
}
}