mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* 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>
205 lines
8.3 KiB
Rust
205 lines
8.3 KiB
Rust
//! 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);
|
|
}
|
|
}
|