A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
36 kB · 953 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954//! A slide deck, rendered by this app.//!//! The third of the three, and the one that fits DOM worst. A document flows and//! a sheet is a grid, but a slide is a fixed canvas: every shape carries an//! absolute position and size, and moving one relative to another changes what//! the slide means. There is no reflowing that.//!//! So this does not reflow it. Each slide is a box of the deck's own aspect//! ratio, and every shape sits inside at its own fraction of that box —//! positions in per cent, type in `cqw` (a hundredth of the box's width). The//! whole slide then scales with whatever width it is given, on a phone or a//! projector, and the layout the author made survives intact.//!//! What that buys over the embedded viewers is the same as elsewhere: nothing is//! sent to anybody, and the text is real text — selectable, searchable, and//! readable by a screen reader in the order the shapes were authored.//!//! What it does not do: images, charts, tables inside slides, transitions,//! speaker notes, or any shape geometry beyond a rectangle. A deck that leans on//! those will look wrong here, and the other two viewers are one tap away.//!//! Parsing is `pptx-parser` (MIT, Yuki Yokotani).
use dioxus::prelude::*;use serde::Deserialize;
use crate::i18n::t;
/// English Metric Units per point. OOXML measures everything in EMU: 914400 to/// the inch, and 72 points to the inch.const EMU_PER_POINT: f64 = 12700.0;
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct Deck { #[serde(default)] pub slides: Vec<Slide>, #[serde(default)] pub slide_width: f64, #[serde(default)] pub slide_height: f64,}
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct Slide { #[serde(default)] pub elements: Vec<Shape>, #[serde(default)] pub slide_number: Option<u32>, /// What the slide is drawn ON, resolved by the parser through the layout and /// master to a literal colour. Dropped before this existed, so every deck /// was drawn on the APP's surface instead of its own, which in dark mode /// meant a pale deck on a dark page with the author's dark text on top. #[serde(default)] pub background: Option<Background>,}
/// A slide's own fill.#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct Background { /// `RRGGBB`, already resolved from whatever theme reference stated it. #[serde(default)] pub color: Option<String>, /// `solid` is the only kind with a colour to use; a gradient or a picture /// background says nothing this can paint, and is left to the app's surface. #[serde(default)] pub fill_type: Option<String>,}
impl Background { /// The colour to paint, when it is one this can actually paint. pub fn solid_colour(&self) -> Option<&str> { if self.fill_type.as_deref() != Some("solid") { return None; } self.color .as_deref() .map(str::trim) .filter(|c| c.len() == 6 && c.chars().all(|ch| ch.is_ascii_hexdigit())) }}
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct Shape { #[serde(default)] pub x: f64, #[serde(default)] pub y: f64, #[serde(default)] pub width: f64, #[serde(default)] pub height: f64, #[serde(default)] pub text_body: Option<TextBody>, #[serde(default)] pub name: Option<String>,
// --- pictures --- /// Zip path of the picture this shape draws, `ppt/media/image1.png`. #[serde(default)] pub image_path: Option<String>, /// The vector original, when PowerPoint kept one beside the raster it /// rasterised for compatibility. Preferred: it scales to the projector. #[serde(default)] pub svg_image_path: Option<String>, #[serde(default)] pub mime_type: Option<String>, #[serde(default)] pub rotation: Option<f64>, #[serde(default)] pub flip_h: bool, #[serde(default)] pub flip_v: bool, /// The picture itself, filled in by [`attach_images`] after parsing, exactly /// as the Word renderer does it. #[serde(skip)] pub src: Option<std::rc::Rc<str>>,}
/// Which picture a shape draws, and in what format.pub fn picture_of(shape: &Shape) -> Option<(&str, &str)> { if let Some(svg) = shape.svg_image_path.as_deref().filter(|p| !p.is_empty()) { return Some((svg, "image/svg+xml")); } let path = shape.image_path.as_deref().filter(|p| !p.is_empty())?; Some((path, shape.mime_type.as_deref().unwrap_or("")))}
/// Read every picture the deck draws out of the package, once each.pub fn collect_images(deck: &Deck, package: &[u8]) -> super::docx::Images { let mut wanted: Vec<(String, String)> = Vec::new(); for slide in &deck.slides { for shape in &slide.elements { if let Some((path, mime)) = picture_of(shape) { if super::docx::is_drawable(mime) && !wanted.iter().any(|(p, _)| p == path) { wanted.push((path.to_string(), mime.to_string())); } } } } super::docx::read_images(wanted, package)}
/// The metafile pictures the deck draws, for the backend to render.pub fn collect_metafiles(deck: &Deck) -> Vec<(String, String)> { let mut wanted: Vec<(String, String)> = Vec::new(); for slide in &deck.slides { for shape in &slide.elements { if let Some((path, mime)) = picture_of(shape) { if super::docx::is_metafile(mime) && !wanted.iter().any(|(p, _)| p == path) { wanted.push((path.to_string(), mime.to_string())); } } } } wanted}
/// Give every picture shape the bytes it draws.pub fn attach_images(deck: &mut Deck, images: &super::docx::Images) { for slide in &mut deck.slides { for shape in &mut slide.elements { if let Some((path, _)) = picture_of(shape) { shape.src = images.get(path).cloned(); } } }}
/// How a picture sits in its shape box.////// NOT its width and height: the `<img>` IS the box, placed by [`shape_box`] as/// a fraction of the slide, and setting a size here would override that and/// blow the picture up to the whole slide.////// `contain` rather than `cover` because the box is the size the author drew,/// and cropping to fill it would hide part of what they placed.pub fn image_style(shape: &Shape) -> String { let mut css = String::from("object-fit:contain;"); let mut transform = String::new(); if let Some(deg) = shape.rotation.filter(|d| d.abs() > 0.01) { transform.push_str(&format!("rotate({deg:.2}deg) ")); } if shape.flip_h || shape.flip_v { let x = if shape.flip_h { -1 } else { 1 }; let y = if shape.flip_v { -1 } else { 1 }; transform.push_str(&format!("scale({x},{y})")); } if !transform.trim().is_empty() { css.push_str(&format!("transform:{};", transform.trim())); } css}
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct TextBody { #[serde(default)] pub paragraphs: Vec<TextParagraph>, #[serde(default)] pub default_font_size: Option<f64>, /// `t`, `ctr` or `b`: where the text sits in a box taller than itself. #[serde(default)] pub vertical_anchor: Option<String>, /// `sp` (grow the box to the text), `norm` (shrink the text to the box) or /// `none`. Under `sp` the recorded height is not a constraint the author /// chose: PowerPoint COMPUTED it from its own text metrics, and a browser /// laying the same words out slightly taller then had them cut off. #[serde(default)] pub auto_fit: Option<String>, /// What PowerPoint's "shrink text on overflow" settled on, as a fraction. /// /// It does NOT rewrite the sizes when it shrinks text: it leaves the /// author's size alone and records the scale beside it. So a box saying /// 80pt with a scale of 0.25 is 20pt on the slide, and reading the size /// without the scale drew it four times too big. #[serde(default)] pub font_scale: Option<f64>,}
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct TextParagraph { #[serde(default)] pub runs: Vec<TextRun>, /// Outline depth, for the indent a bulleted list carries. #[serde(default)] pub lvl: Option<u32>, #[serde(default)] pub alignment: Option<String>, #[serde(default)] pub def_font_size: Option<f64>, #[serde(default)] pub def_color: Option<String>,}
#[derive(Deserialize, Clone, Debug, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct TextRun { #[serde(default)] pub text: String, #[serde(default)] pub bold: Option<bool>, #[serde(default)] pub italic: Option<bool>, #[serde(default)] pub underline: Option<bool>, #[serde(default)] pub strikethrough: Option<bool>, #[serde(default)] pub color: Option<String>, #[serde(default)] pub font_size: Option<f64>,}
/// A shape's box, as percentages of the slide.////// Per cent rather than pixels so the slide scales: the same deck fills a/// projector and a phone, and the shapes keep their relationship to each other,/// which on a slide IS the content.pub fn shape_box(shape: &Shape, deck: &Deck) -> String { let (w, h) = (deck.slide_width.max(1.0), deck.slide_height.max(1.0)); // A box PowerPoint sized to its own text is a FLOOR, not a ceiling: it // measured those words in its own metrics, and a browser measuring them in // the browser's needs whatever room that takes. Pinning it to the recorded // height sliced the second line off a caption that PowerPoint had fitted on // one. Everything else keeps the height the author actually chose. let grows = shape.text_body.as_ref().and_then(|b| b.auto_fit.as_deref()) == Some("sp"); let height = if grows { "min-height" } else { "height" }; format!( "left:{:.3}%;top:{:.3}%;width:{:.3}%;{height}:{:.3}%;", shape.x / w * 100.0, shape.y / h * 100.0, shape.width / w * 100.0, shape.height / h * 100.0, )}
/// Whether a shape may spill past the box recorded for it.////// Only one that PowerPoint sized to its text. Clipping is right for a box the/// author drew, and wrong for one that is really a measurement.pub fn shape_overflow(shape: &Shape) -> &'static str { match shape.text_body.as_ref().and_then(|b| b.auto_fit.as_deref()) { Some("sp") => "overflow:visible;", _ => "", }}
/// A point size as `cqw` — hundredths of the slide box's width.////// The unit is what makes the deck scale. A 44pt title on a 10-inch slide is/// 44/720 of its width, and stays that fraction whatever the box is; `pt` here/// would be 44pt on a phone, which is a title four words wide.pub fn font_cqw(points: f64, deck: &Deck) -> f64 { let slide_points = (deck.slide_width / EMU_PER_POINT).max(1.0); points / slide_points * 100.0}
/// The size a run is actually set in.////// Three places carry it and only one is usually filled: the run itself, else/// the paragraph's default, else the text body's. A missing size at every level/// is 18pt, PowerPoint's own default for body text — a slide with no size/// anywhere still has to render at *something*.pub fn effective_font_size( run: &TextRun, paragraph: &TextParagraph, body: Option<&TextBody>,) -> f64 { let stated = run .font_size .or(paragraph.def_font_size) .or_else(|| body.and_then(|b| b.default_font_size)) .unwrap_or(18.0); // Autofit's scale applies to whichever of the three stated it. Bounded // because it comes out of the file: a zero or a negative would erase the // text, and a scale above 1 is not a shrink. let scale = body .and_then(|b| b.font_scale) .filter(|s| *s > 0.0 && *s <= 1.0) .unwrap_or(1.0); stated * scale}
/// Where text sits in a box taller than the text itself.pub fn vertical_anchor(body: Option<&TextBody>) -> &'static str { match body.and_then(|b| b.vertical_anchor.as_deref()) { Some("ctr") => "center", Some("b") => "flex-end", _ => "flex-start", }}
/// The CSS for one run: colour and the styles that have no element.pub fn run_css( run: &TextRun, paragraph: &TextParagraph, body: Option<&TextBody>, deck: &Deck,) -> String { let mut css = format!( "font-size:{:.3}cqw;", font_cqw(effective_font_size(run, paragraph, body), deck) ); let colour = run.color.as_deref().or(paragraph.def_color.as_deref()); if let Some(c) = colour { if c.len() == 6 && c.chars().all(|ch| ch.is_ascii_hexdigit()) { css.push_str(&format!("color:#{c};")); } } if run.underline == Some(true) && run.strikethrough == Some(true) { css.push_str("text-decoration:underline line-through;"); } else if run.underline == Some(true) { css.push_str("text-decoration:underline;"); } else if run.strikethrough == Some(true) { css.push_str("text-decoration:line-through;"); } css}
/// The CSS for a paragraph: how it is aligned, and how far its level indents it.pub fn paragraph_css(paragraph: &TextParagraph, deck: &Deck) -> String { let mut css = String::new(); // `l` is emitted rather than skipped: the file viewer around a slide // centres its contents, so a left-aligned run that says nothing inherits // centre. See the same note in components::docx. match paragraph.alignment.as_deref() { Some("ctr") => css.push_str("text-align:center;"), Some("r") => css.push_str("text-align:right;"), Some("just") => css.push_str("text-align:justify;"), Some("l") => css.push_str("text-align:left;"), _ => {} } // Each outline level steps in by a third of an inch, in the same scaling // unit as the type so the indent shrinks with the slide. if let Some(level) = paragraph.lvl.filter(|l| *l > 0) { let indent_pt = level as f64 * 24.0; css.push_str(&format!("margin-left:{:.3}cqw;", font_cqw(indent_pt, deck))); } css}
/// One deck, as a stack of slides.#[component]pub fn DeckView(deck: Deck) -> Element { // A deck with no dimensions cannot be laid out; 4:3 is the safest guess and // beats dividing by zero. let (w, h) = if deck.slide_width > 0.0 && deck.slide_height > 0.0 { (deck.slide_width, deck.slide_height) } else { (9_144_000.0, 6_858_000.0) }; // The deck's own paper, and ink to match it. A run that states no colour // inherits from the slide, and inheriting the APP's text colour put // near-white text on a near-white slide for a reader in dark mode. // `readable_ink` picks by luminance, the same way a shaded spreadsheet cell // does. Computed here because rsx has no room for it inline. let slides: Vec<(Slide, String)> = deck .slides .iter() .map(|slide| { let paper = slide .background .as_ref() .and_then(|b| b.solid_colour()) .map(|c| format!("background:#{c};color:{};", super::docx::readable_ink(c))) .unwrap_or_default(); (slide.clone(), paper) }) .collect(); let slide_count = slides.len(); rsx! { div { class: "pptx-deck", for (i , (slide , paper)) in slides.into_iter().enumerate() { section { key: "s{i}", class: "pptx-slide", style: "aspect-ratio:{w} / {h};{paper}", aria_label: "{i + 1}", // A slide IS a page, so the deck's pages need no working // out: slide one is page one. No `data-page-ends` -- this // is the page itself, not a hairline between two, so a jump // lands ON it rather than after it. "data-page": "{i + 1}", for (j , shape) in slide.elements.into_iter().enumerate() { if let Some(src) = shape.src.clone() { // A picture sits in its own box, at the place and // size the author put it, like every other shape. img { key: "e{j}", class: "pptx-img", src: "{src}", style: "{shape_box(&shape, &deck)}{image_style(&shape)}", alt: "", loading: "lazy", decoding: "async", } } else if shape.image_path.as_deref().is_some_and(|p| !p.is_empty()) { // A picture the browser cannot decode. Office keeps // pasted figures as EMF, which nothing renders and // which carries no fallback of its own, so there are // no pixels to draw here and never will be. // // Its BOX is drawn regardless, where and how big the // author put it. Skipping the shape entirely left a // silent hole under a caption that promises a figure // ("Figur 7 ..."), which reads as the app losing the // picture rather than as a viewer that cannot show // this one. The banner above the document counts them // too; this says WHICH. div { key: "e{j}", class: "pptx-img-missing", style: "{shape_box(&shape, &deck)}", title: "{t(\"file.imageNotDrawable\")}", role: "img", aria_label: "{t(\"file.imageNotDrawable\")}", span { class: "material-icons", "image_not_supported" } } } else if shape.text_body.is_some() { div { key: "e{j}", class: "pptx-shape", style: "{shape_box(&shape, &deck)}{shape_overflow(&shape)}justify-content:{vertical_anchor(shape.text_body.as_ref())};", {shape_text(&shape, &deck)} } } } } } // Which slide the reader is on, and a way to a numbered one. // A deck is where this control is least of a reconstruction: the // numbers are the slides' own. if slide_count > 1 { super::pager::PageControl { first: "1".to_string(), last: slide_count.to_string() } } } }}
/// A shape's paragraphs.fn shape_text(shape: &Shape, deck: &Deck) -> Element { let body = shape.text_body.clone(); let paragraphs = body .as_ref() .map(|b| b.paragraphs.clone()) .unwrap_or_default(); let deck = deck.clone(); rsx! { for (i , paragraph) in paragraphs.into_iter().enumerate() { p { key: "p{i}", class: "pptx-para", style: "{paragraph_css(¶graph, &deck)}", for (j , run) in paragraph.runs.iter().enumerate() { { let css = run_css(run, ¶graph, body.as_ref(), &deck); let text = run.text.clone(); let bold = run.bold == Some(true); let italic = run.italic == Some(true); rsx! { if bold && italic { strong { key: "r{j}", em { style: "{css}", "{text}" } } } else if bold { strong { key: "r{j}", style: "{css}", "{text}" } } else if italic { em { key: "r{j}", style: "{css}", "{text}" } } else { span { key: "r{j}", style: "{css}", "{text}" } } } } } } } }}
#[cfg(test)]mod tests { use super::*;
fn deck_10in() -> Deck { Deck { slide_width: 9_144_000.0, slide_height: 6_858_000.0, ..Deck::default() } }
fn caption(auto_fit: Option<&str>) -> Shape { Shape { x: 0.0, y: 0.0, width: 4_572_000.0, height: 685_800.0, text_body: Some(TextBody { auto_fit: auto_fit.map(str::to_string), ..TextBody::default() }), ..Shape::default() } }
/// A box PowerPoint sized to its own text is a floor: it measured those /// words in its metrics, and a browser needs whatever room its own take. #[test] fn a_grow_to_fit_box_may_get_taller() { let style = shape_box(&caption(Some("sp")), &deck_10in()); assert!(style.contains("min-height:10.000%"), "{style}"); assert!(!style.contains(";height:"), "{style}"); assert_eq!(shape_overflow(&caption(Some("sp"))), "overflow:visible;"); }
/// A box the author drew keeps the size they drew, and still clips. #[test] fn an_authored_box_keeps_its_height() { for kind in [Some("norm"), Some("none"), None] { let style = shape_box(&caption(kind), &deck_10in()); assert!(style.contains("height:10.000%"), "{kind:?}: {style}"); assert!(!style.contains("min-height"), "{kind:?}: {style}"); assert_eq!(shape_overflow(&caption(kind)), "", "{kind:?}"); } }
use super::*;
/// PowerPoint's "shrink text on overflow" leaves the author's size alone and /// records the scale beside it, so the size alone is not the size drawn. #[test] fn autofit_shrinks_the_size_the_box_states() { let body = TextBody { font_scale: Some(0.25), ..TextBody::default() }; let para = TextParagraph::default(); let run = TextRun { font_size: Some(80.0), ..TextRun::default() }; // 80pt at a quarter is 20pt, not 80. assert_eq!(effective_font_size(&run, ¶, Some(&body)), 20.0);
// And it applies to a size inherited from the paragraph or the body. let inherited = TextParagraph { def_font_size: Some(40.0), ..TextParagraph::default() }; assert_eq!( effective_font_size(&TextRun::default(), &inherited, Some(&body)), 10.0 ); }
/// A scale out of range comes from the file and must not erase the text or /// magnify it. #[test] fn an_impossible_scale_is_ignored() { let para = TextParagraph::default(); let run = TextRun { font_size: Some(20.0), ..TextRun::default() }; for bad in [0.0, -1.0, 4.0] { let body = TextBody { font_scale: Some(bad), ..TextBody::default() }; assert_eq!( effective_font_size(&run, ¶, Some(&body)), 20.0, "scale {bad} should have been ignored" ); } }
use super::*;
/// The parser resolves a slide's background through the layout and master to /// a literal colour. This used to be dropped, so a deck was drawn on the /// app's surface rather than its own. #[test] fn a_solid_background_is_read_off_the_slide() { let slide: Slide = serde_json::from_value(serde_json::json!({ "elements": [], "background": { "color": "FAF6F3", "fillType": "solid" } })) .expect("slide"); assert_eq!( slide.background.as_ref().and_then(|b| b.solid_colour()), Some("FAF6F3") ); }
/// Only a solid fill states a colour this can paint. A gradient or picture /// background names one that would be wrong on its own. #[test] fn only_a_solid_fill_offers_a_colour() { let gradient = Background { color: Some("FF0000".into()), fill_type: Some("gradient".into()), }; assert_eq!(gradient.solid_colour(), None);
// And a malformed colour is not painted either. let broken = Background { color: Some("nothex".into()), fill_type: Some("solid".into()), }; assert_eq!(broken.solid_colour(), None); }
use super::*;
/// The numbers are from a real deck in the wiki: a full-bleed background /// picture and a small logo, on a 9144000 x 6858000 EMU slide. #[test] fn a_picture_is_placed_like_any_other_shape() { let deck = Deck { slide_width: 9_144_000.0, slide_height: 6_858_000.0, ..Default::default() }; let full_bleed = Shape { x: 0.0, y: 0.0, width: 9_144_000.0, height: 5_143_500.0, image_path: Some("ppt/media/image1.png".into()), mime_type: Some("image/png".into()), ..Default::default() }; assert_eq!( shape_box(&full_bleed, &deck), "left:0.000%;top:0.000%;width:100.000%;height:75.000%;" ); // The box carries the size. The picture style must NOT, or it would // override those percentages and blow the picture up to the slide. let css = image_style(&full_bleed); assert!(!css.contains("width"), "{css}"); assert!(!css.contains("height"), "{css}"); assert!(css.contains("object-fit:contain;"), "{css}");
let logo = Shape { x: 540_000.0, y: 530_467.0, width: 1_235_676.0, height: 190_500.0, image_path: Some("ppt/media/image2.png".into()), mime_type: Some("image/png".into()), ..Default::default() }; let box_css = shape_box(&logo, &deck); assert!(box_css.contains("left:5.906%"), "{box_css}"); assert!(box_css.contains("width:13.514%"), "{box_css}"); }
#[test] fn a_slide_picture_prefers_its_vector_original_and_carries_its_turn() { let mut shape = Shape { image_path: Some("ppt/media/image1.png".into()), mime_type: Some("image/png".into()), ..Default::default() }; assert_eq!( picture_of(&shape), Some(("ppt/media/image1.png", "image/png")) ); shape.svg_image_path = Some("ppt/media/image1.svg".into()); assert_eq!( picture_of(&shape), Some(("ppt/media/image1.svg", "image/svg+xml")) );
shape.rotation = Some(90.0); shape.flip_v = true; let css = image_style(&shape); assert!( css.contains("transform:rotate(90.00deg) scale(1,-1);"), "{css}" );
// A shape that draws no picture is not one. assert_eq!(picture_of(&Shape::default()), None); }
/// The shape as the parser writes it for a picture, pasted from a real deck. #[test] fn a_real_picture_shape_deserialises() { let json = r#"{ "x": 540000, "y": 530467, "width": 1235676, "height": 190500, "rotation": 0.0, "flipH": false, "flipV": false, "imagePath": "ppt/media/image2.png", "mimeType": "image/png", "type": "picture" }"#; let shape: Shape = serde_json::from_str(json).expect("the parser's own output"); assert_eq!(shape.image_path.as_deref(), Some("ppt/media/image2.png")); assert_eq!(shape.mime_type.as_deref(), Some("image/png")); assert_eq!(shape.width, 1_235_676.0); assert!(shape.src.is_none(), "the bytes are attached separately"); assert!(shape.text_body.is_none()); }
/// A figure pasted into PowerPoint from Excel is stored as EMF, which no /// browser decodes and which carries no fallback of its own. It is not /// collected, so the shape keeps a picture path and never gets bytes — the /// exact state the slide draws a placeholder box for, rather than skipping /// the shape and leaving a hole under the caption that announces it. #[test] fn an_emf_picture_is_never_collected_but_stays_a_picture() { let emf = Shape { image_path: Some("ppt/media/image23.emf".into()), mime_type: Some("image/x-emf".into()), ..Default::default() }; assert!(!super::super::docx::is_drawable("image/x-emf")); assert_eq!( picture_of(&emf), Some(("ppt/media/image23.emf", "image/x-emf")) );
let mut deck = Deck { slides: vec![Slide { elements: vec![emf], ..Default::default() }], ..Default::default() }; // The package is never opened for it: EMF is filtered out before any // reading, so an empty one is enough to show nothing was wanted. assert!( collect_images(&deck, &[]).is_empty(), "nothing to fetch: the browser could not draw it anyway" ); attach_images(&mut deck, &Default::default()); let shape = &deck.slides[0].elements[0]; assert!(shape.src.is_none(), "no bytes, so no <img>"); assert!( shape.image_path.as_deref().is_some_and(|p| !p.is_empty()), "still a picture, so its box is still drawn" ); assert!(shape.text_body.is_none(), "and it is not a text shape"); }
/// A 10 x 7.5 inch slide, which is what PowerPoint calls 4:3. fn deck() -> Deck { Deck { slides: vec![], slide_width: 9_144_000.0, slide_height: 6_858_000.0, } }
#[test] fn a_shape_is_placed_as_a_fraction_of_the_slide() { let d = deck(); // Half an inch in from the left edge of a ten-inch slide is 5%. let s = Shape { x: 457_200.0, y: 274_638.0, width: 8_229_600.0, height: 1_143_000.0, ..Default::default() }; let css = shape_box(&s, &d); assert!(css.contains("left:5.000%;"), "{css}"); assert!(css.contains("width:90.000%;"), "{css}"); // 1143000 EMU of a 6858000-high slide is a sixth. assert!(css.contains("height:16.667%;"), "{css}"); }
/// A deck with no dimensions must not divide by zero. #[test] fn a_dimensionless_deck_does_not_divide_by_zero() { let d = Deck::default(); let css = shape_box( &Shape { x: 100.0, width: 100.0, ..Default::default() }, &d, ); assert!(!css.contains("NaN") && !css.contains("inf"), "{css}"); assert!(font_cqw(44.0, &d).is_finite()); }
/// The unit that makes a deck scale: 44pt on a 720pt-wide slide is 6.11% of /// its width, whatever the box is actually drawn at. #[test] fn type_is_sized_as_a_fraction_of_the_slide_width() { let d = deck(); assert!( (font_cqw(44.0, &d) - 6.111).abs() < 0.01, "{}", font_cqw(44.0, &d) ); assert!( (font_cqw(720.0, &d) - 100.0).abs() < 0.01, "a full-width glyph" ); }
/// Three places carry the size and usually only one is filled. #[test] fn a_runs_size_falls_back_through_paragraph_then_body() { let body = TextBody { default_font_size: Some(32.0), ..Default::default() }; let para_with = TextParagraph { def_font_size: Some(28.0), ..Default::default() }; let para_without = TextParagraph::default(); let run_with = TextRun { font_size: Some(18.0), ..Default::default() }; let run_without = TextRun::default();
assert_eq!( effective_font_size(&run_with, ¶_with, Some(&body)), 18.0 ); assert_eq!( effective_font_size(&run_without, ¶_with, Some(&body)), 28.0 ); assert_eq!( effective_font_size(&run_without, ¶_without, Some(&body)), 32.0 ); // Nothing anywhere still has to render at something. assert_eq!( effective_font_size(&run_without, ¶_without, None), 18.0, "PowerPoint's own body default" ); }
/// The same reported bug, in the deck renderer: `l` used to emit nothing /// and inherit the file viewer's centring. #[test] fn left_alignment_is_stated_in_a_slide_too() { let d = deck(); let left = TextParagraph { alignment: Some("l".into()), ..Default::default() }; assert!(paragraph_css(&left, &d).contains("text-align:left;")); let centred = TextParagraph { alignment: Some("ctr".into()), ..Default::default() }; assert!(paragraph_css(¢red, &d).contains("text-align:center;")); }
#[test] fn text_sits_where_the_box_says() { assert_eq!(vertical_anchor(None), "flex-start"); let ctr = TextBody { vertical_anchor: Some("ctr".into()), ..Default::default() }; assert_eq!(vertical_anchor(Some(&ctr)), "center"); let bottom = TextBody { vertical_anchor: Some("b".into()), ..Default::default() }; assert_eq!(vertical_anchor(Some(&bottom)), "flex-end"); }
/// The parser's real wire shape, from a deck made with python-pptx. #[test] fn the_parsers_json_deserialises() { let json = r#"{ "slideWidth": 9144000, "slideHeight": 6858000, "slides": [{ "index": 0, "slideNumber": 1, "background": null, "partName": "slide1.xml", "elements": [{ "id": "2", "name": "Title 1", "type": "shape", "geometry": "rect", "x": 457200, "y": 274638, "width": 8229600, "height": 1143000, "rotation": 0.0, "flipH": false, "flipV": false, "fill": null, "placeholderType": "ctrTitle", "textBody": { "autoFit": "none", "defaultFontSize": 44.0, "verticalAnchor": "ctr", "paragraphs": [{ "alignment": null, "lvl": null, "defFontSize": null, "defColor": null, "runs": [{"text":"Landsmøde 2026","bold":null,"italic":null, "underline":false,"strikethrough":false,"color":null, "fontSize":null,"type":"text","fieldType":null}] }] } }] }] }"#; let deck: Deck = serde_json::from_str(json).expect("the deck model must parse"); assert_eq!(deck.slides.len(), 1); let shape = &deck.slides[0].elements[0]; assert_eq!(shape.name.as_deref(), Some("Title 1")); let body = shape.text_body.as_ref().expect("a text body"); assert_eq!(vertical_anchor(Some(body)), "center"); let para = &body.paragraphs[0]; assert_eq!(para.runs[0].text, "Landsmøde 2026"); // The size comes from the body, since neither run nor paragraph set one. assert_eq!(effective_font_size(¶.runs[0], para, Some(body)), 44.0); }}