mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
1d92efa707
commit
d6e3a6c438
6 files changed
+2503
-4
No files matched your search
+12
-2
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user