mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 16:30:36 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f9cbdf49 | ||
|
|
85366b1a62 | ||
|
|
8159db8ba5 |
@@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
|||||||
|
|
||||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||||
s.add_document(DocState::new(p, path, title))
|
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||||
|
let mut st = DocState::new(p, path, title);
|
||||||
|
st.missing_fonts = missing_fonts;
|
||||||
|
s.add_document(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let doc = open_presentation(path, &bytes)?;
|
let doc = open_presentation(path, &bytes)?;
|
||||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
let st = s.doc()?;
|
||||||
|
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
@@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||||
let doc = open_presentation(name, &data)?;
|
let doc = open_presentation(name, &data)?;
|
||||||
let i = add_opened(s, name, None, doc);
|
let i = add_opened(s, name, None, doc);
|
||||||
Ok(json!({"document": i}))
|
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||||
|
//! draws in a stand-in; see `FontDb::document_face`).
|
||||||
|
|
||||||
|
use deckcraft_fonts::FontDb;
|
||||||
|
use deckcraft_model::resolve::{self, Ctx};
|
||||||
|
use deckcraft_model::text::{Bullet, TextBody};
|
||||||
|
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||||
|
|
||||||
|
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||||
|
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||||
|
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||||
|
let db = FontDb::global();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for family in used_fonts(p) {
|
||||||
|
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||||
|
out.push(family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|f| f.to_lowercase());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The families the slides' text runs and bullets are drawn in.
|
||||||
|
fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut add = |f: String| {
|
||||||
|
let f = f.trim().to_string();
|
||||||
|
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for slide in &p.slides {
|
||||||
|
let Some(ctx) = Ctx::for_slide(p, slide) else { continue };
|
||||||
|
deckcraft_model::walk(&slide.shapes, &mut |s, _| {
|
||||||
|
if let Some(body) = &s.text {
|
||||||
|
body_fonts(&ctx, s, body, &mut add);
|
||||||
|
}
|
||||||
|
if let ShapeKind::Table(t) = &s.kind {
|
||||||
|
for cell in t.rows.iter().flat_map(|r| &r.cells) {
|
||||||
|
body_fonts(&ctx, s, &cell.text, &mut add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) {
|
||||||
|
for para in &body.paragraphs {
|
||||||
|
let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||||
|
if !has_text {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) {
|
||||||
|
let r = resolve::run(ctx, s, para, &run.props);
|
||||||
|
add(resolve::font_family(ctx, &r));
|
||||||
|
}
|
||||||
|
let pp = resolve::para(ctx, s, para);
|
||||||
|
if matches!(pp.bullet, Some(Bullet::Char { .. }))
|
||||||
|
&& let Some(f) = pp.bullet_font.as_deref()
|
||||||
|
{
|
||||||
|
add(ctx.master.theme.font(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod connect;
|
pub mod connect;
|
||||||
|
pub mod fonts;
|
||||||
pub mod guard;
|
pub mod guard;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod links;
|
pub mod links;
|
||||||
@@ -141,6 +142,9 @@ pub struct DocState {
|
|||||||
pub interaction: Option<Interaction>,
|
pub interaction: Option<Interaction>,
|
||||||
pub uid: u64,
|
pub uid: u64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||||
|
/// documents); the app mentions them once.
|
||||||
|
pub missing_fonts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -158,6 +162,7 @@ impl DocState {
|
|||||||
interaction: None,
|
interaction: None,
|
||||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
title,
|
title,
|
||||||
|
missing_fonts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_dirty(&self) -> bool {
|
pub fn is_dirty(&self) -> bool {
|
||||||
|
|||||||
@@ -504,6 +504,30 @@ fn hyperlink_on_a_word_or_shape_links_it() {
|
|||||||
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A .pptx (base64) whose first slide's title is set in `font`.
|
||||||
|
fn deck_in_font(font: &str) -> String {
|
||||||
|
let mut p = deckcraft_model::Presentation::default();
|
||||||
|
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||||
|
body.paragraphs[0].runs[0].props.font = Some(font.into());
|
||||||
|
slide.shapes[0].text = Some(body);
|
||||||
|
crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap();
|
||||||
|
assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"]));
|
||||||
|
assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]);
|
||||||
|
// A font that is there (bundled or installed) isn't listed; new documents list nothing.
|
||||||
|
let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone();
|
||||||
|
let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap();
|
||||||
|
assert_eq!(r["missingFonts"], json!([]));
|
||||||
|
s.execute("file.new", &json!({})).unwrap();
|
||||||
|
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
|||||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||||
if let Some(h) = &home {
|
if let Some(h) = &home {
|
||||||
dirs.push(h.join("Library/Fonts"));
|
dirs.push(h.join("Library/Fonts"));
|
||||||
|
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
|
||||||
|
dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
|
||||||
|
}
|
||||||
|
// Fonts Office installs inside its apps.
|
||||||
|
for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
|
||||||
|
dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
|
||||||
}
|
}
|
||||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||||
@@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
|||||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||||
dirs.push(root.join("Fonts"));
|
dirs.push(root.join("Fonts"));
|
||||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
let l = std::path::PathBuf::from(l);
|
||||||
|
dirs.push(l.join("Microsoft\\Windows\\Fonts"));
|
||||||
|
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them;
|
||||||
|
// DeckCraft never ships or needs them.
|
||||||
|
dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
|||||||
let mut pics = vec![];
|
let mut pics = vec![];
|
||||||
e.find_all("blip", &mut pics);
|
e.find_all("blip", &mut pics);
|
||||||
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
||||||
};
|
}
|
||||||
|
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
|
||||||
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
||||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||||
} else if uri.ends_with("/diagram") {
|
} else if uri.ends_with("/diagram") {
|
||||||
@@ -370,6 +371,21 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
|
||||||
|
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
|
||||||
|
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
|
||||||
|
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
|
||||||
|
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
|
||||||
|
let mut shapes = vec![];
|
||||||
|
doc.root.find_all("shape", &mut shapes);
|
||||||
|
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
|
||||||
|
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
|
||||||
|
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
|
||||||
|
return imp.media_for(&target);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||||
let rel_ids = gd.child("relIds")?;
|
let rel_ids = gd.child("relIds")?;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them.
|
||||||
|
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
|
||||||
|
use deckcraft_model::{ShapeKind, defaults};
|
||||||
|
|
||||||
|
fn png() -> Vec<u8> {
|
||||||
|
let mut out = Cursor::new(vec![]);
|
||||||
|
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||||
|
.write_to(&mut out, image::ImageFormat::Png)
|
||||||
|
.expect("png");
|
||||||
|
out.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text.
|
||||||
|
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
|
||||||
|
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||||
|
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
|
||||||
|
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default();
|
||||||
|
for i in 0..z.len() {
|
||||||
|
let mut f = z.by_index(i).expect("entry");
|
||||||
|
let name = f.name().to_string();
|
||||||
|
if parts.iter().any(|(n, _)| *n == name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut d = vec![];
|
||||||
|
f.read_to_end(&mut d).expect("read");
|
||||||
|
if name.ends_with(".xml") || name.ends_with(".rels") {
|
||||||
|
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
|
||||||
|
}
|
||||||
|
out.start_file(name, o).expect("start");
|
||||||
|
out.write_all(&d).expect("write");
|
||||||
|
}
|
||||||
|
for (name, data) in parts {
|
||||||
|
out.start_file(*name, o).expect("start");
|
||||||
|
out.write_all(data).expect("write");
|
||||||
|
}
|
||||||
|
out.finish().expect("finish").into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the
|
||||||
|
/// VML shape with that id in the slide's legacy drawing part.
|
||||||
|
fn ole_deck(spid_in_vml: &str) -> Vec<u8> {
|
||||||
|
let vml = format!(
|
||||||
|
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<v:shape id="_x0000_s9999" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rId7" o:title=""/></v:shape>
|
||||||
|
<v:shape id="{spid_in_vml}" style='position:absolute;left:0;top:0;width:2in;height:1in'><v:imagedata o:relid="rId1" o:title=""/></v:shape>
|
||||||
|
</xml>"#
|
||||||
|
);
|
||||||
|
let vml_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/></Relationships>"#;
|
||||||
|
let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s681987" name="Slide" r:id="rIdOle" imgW="4664160" imgH="3497400" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||||
|
with_parts(
|
||||||
|
&[
|
||||||
|
("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()),
|
||||||
|
("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()),
|
||||||
|
("ppt/media/preview.png", png()),
|
||||||
|
("ppt/media/other.png", vec![1, 2, 3]),
|
||||||
|
("ppt/embeddings/oleObject1.bin", vec![0; 16]),
|
||||||
|
],
|
||||||
|
|name, s| {
|
||||||
|
if name == "ppt/slides/slide1.xml" {
|
||||||
|
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||||
|
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||||
|
s.replacen(
|
||||||
|
"</Relationships>",
|
||||||
|
r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let p = deckcraft_pptx::import(bytes).expect("import");
|
||||||
|
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
|
||||||
|
match &s.kind {
|
||||||
|
ShapeKind::Opaque { preview, label, .. } => {
|
||||||
|
assert_eq!(label, "PowerPoint.Slide.8");
|
||||||
|
preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec())
|
||||||
|
}
|
||||||
|
k => panic!("{k:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_ole_object_shows_its_vml_preview() {
|
||||||
|
assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
|
||||||
|
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
|
||||||
|
}
|
||||||
@@ -717,6 +717,13 @@ impl Renderer {
|
|||||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
|
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||||
|
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||||||
|
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||||||
|
ctx.set_transform(m);
|
||||||
|
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||||
|
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||||||
|
}
|
||||||
ShapeKind::Media(mc) => {
|
ShapeKind::Media(mc) => {
|
||||||
ctx.set_transform(m);
|
ctx.set_transform(m);
|
||||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||||
|
|||||||
@@ -1037,7 +1037,8 @@ fn wordart_arch_up_lifts_the_middle_of_the_line() {
|
|||||||
// at the box top and the ends sit clearly lower.
|
// at the box top and the ends sit clearly lower.
|
||||||
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
|
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
|
||||||
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
|
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
|
||||||
assert!((80..=110).contains(&ay0), "arch top {ay0}");
|
// At the box top (y = 100), give or take the font: Linux's fallback face reaches 79.
|
||||||
|
assert!((70..=110).contains(&ay0), "arch top {ay0}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1145,3 +1146,34 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
|
|||||||
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
|
||||||
|
// An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame,
|
||||||
|
// as PowerPoint does; with no preview it draws nothing.
|
||||||
|
let png = {
|
||||||
|
let mut out = std::io::Cursor::new(vec![]);
|
||||||
|
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([200, 20, 20])))
|
||||||
|
.write_to(&mut out, image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
out.into_inner()
|
||||||
|
};
|
||||||
|
for with_preview in [true, false] {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let media = p.add_media("image1.png", "image/png", png.clone());
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(600),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||||
|
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
|
||||||
|
let px = img.pixel(x, y);
|
||||||
|
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
|
||||||
|
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
|
||||||
|
}
|
||||||
|
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod status;
|
|||||||
pub mod textures;
|
pub mod textures;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod thumbs;
|
pub mod thumbs;
|
||||||
|
pub mod toast;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
@@ -181,6 +182,10 @@ pub struct SlideApp {
|
|||||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||||
pub media: media::MediaHost,
|
pub media: media::MediaHost,
|
||||||
pub status: Option<(String, f64)>,
|
pub status: Option<(String, f64)>,
|
||||||
|
/// A brief notice over the bottom of the window (missing fonts on opening a file).
|
||||||
|
pub toast: Option<toast::Toast>,
|
||||||
|
/// Documents (by uid) whose missing fonts have been mentioned.
|
||||||
|
fonts_noted: std::collections::HashSet<u64>,
|
||||||
pub perf: Perf,
|
pub perf: Perf,
|
||||||
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
||||||
pub synthetic: Vec<egui::Event>,
|
pub synthetic: Vec<egui::Event>,
|
||||||
@@ -224,6 +229,8 @@ impl SlideApp {
|
|||||||
show: None,
|
show: None,
|
||||||
media: media::MediaHost::new(audio_out),
|
media: media::MediaHost::new(audio_out),
|
||||||
status: None,
|
status: None,
|
||||||
|
toast: None,
|
||||||
|
fonts_noted: Default::default(),
|
||||||
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
|
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
|
||||||
synthetic: vec![],
|
synthetic: vec![],
|
||||||
canvas_rect: None,
|
canvas_rect: None,
|
||||||
@@ -730,6 +737,8 @@ impl SlideApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
toast::note_missing_fonts(self);
|
||||||
|
toast::show(self, &ctx);
|
||||||
dialogs::show(self, &ctx);
|
dialogs::show(self, &ctx);
|
||||||
menus::palette(self, &ctx);
|
menus::palette(self, &ctx);
|
||||||
for url in std::mem::take(&mut self.pending_urls) {
|
for url in std::mem::take(&mut self.pending_urls) {
|
||||||
@@ -889,6 +898,37 @@ mod tests {
|
|||||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_a_file_with_missing_fonts_shows_a_toast_once() {
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
let mut p = deckcraft_model::Presentation::default();
|
||||||
|
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||||
|
body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into());
|
||||||
|
slide.shapes[0].text = Some(body);
|
||||||
|
let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap());
|
||||||
|
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SlideApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SlideApp::new(Session::with_new(), Services::default()),
|
||||||
|
);
|
||||||
|
harness.run_steps(2);
|
||||||
|
assert!(harness.state().toast.is_none(), "a new presentation has nothing to say");
|
||||||
|
harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap();
|
||||||
|
harness.run_steps(2);
|
||||||
|
let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default();
|
||||||
|
assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}");
|
||||||
|
assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some());
|
||||||
|
// Closed, it stays closed for that document.
|
||||||
|
harness.get_by_label("Close").click();
|
||||||
|
harness.run_steps(2);
|
||||||
|
assert!(harness.state().toast.is_none());
|
||||||
|
harness.run_steps(2);
|
||||||
|
assert!(harness.state().toast.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||||
#[test]
|
#[test]
|
||||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Brief notices over the window's bottom edge: shown once, gone after a few seconds or when
|
||||||
|
//! closed. Used to say a presentation's fonts aren't installed when it is first opened.
|
||||||
|
|
||||||
|
use egui::{Align2, CornerRadius, Margin, Stroke, vec2};
|
||||||
|
|
||||||
|
use crate::SlideApp;
|
||||||
|
use crate::icons::{self, Icon};
|
||||||
|
use crate::theme::{self, Tokens};
|
||||||
|
|
||||||
|
/// How long a toast stays, in milliseconds.
|
||||||
|
const SHOW_MS: f64 = 8000.0;
|
||||||
|
|
||||||
|
pub struct Toast {
|
||||||
|
pub text: String,
|
||||||
|
pub at: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notice for fonts a presentation uses that aren't installed, or `None` when all are.
|
||||||
|
pub fn missing_fonts_message(fonts: &[String]) -> Option<String> {
|
||||||
|
let names = match fonts {
|
||||||
|
[] => return None,
|
||||||
|
[a] => a.clone(),
|
||||||
|
[a, b] => format!("{a} and {b}"),
|
||||||
|
[a, b, c] => format!("{a}, {b} and {c}"),
|
||||||
|
[a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()),
|
||||||
|
};
|
||||||
|
let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") };
|
||||||
|
Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast the active presentation's missing fonts, once per opened document.
|
||||||
|
pub fn note_missing_fonts(app: &mut SlideApp) {
|
||||||
|
let Some(d) = app.session.active() else { return };
|
||||||
|
if !app.fonts_noted.insert(d.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(text) = missing_fonts_message(&d.missing_fonts) {
|
||||||
|
app.toast = Some(Toast { text, at: crate::now_ms() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||||
|
let Some(toast) = &app.toast else { return };
|
||||||
|
if crate::now_ms() - toast.at > SHOW_MS {
|
||||||
|
app.toast = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t = Tokens::get(ctx);
|
||||||
|
let text = toast.text.clone();
|
||||||
|
let mut close = false;
|
||||||
|
egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||||
|
egui::Frame::NONE
|
||||||
|
.fill(t.card)
|
||||||
|
.stroke(Stroke::new(1.0, t.card_border))
|
||||||
|
.corner_radius(CornerRadius::same(6))
|
||||||
|
.inner_margin(Margin::symmetric(14, 10))
|
||||||
|
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow })
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_max_width(520.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap());
|
||||||
|
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());
|
||||||
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close"));
|
||||||
|
if resp.hovered() {
|
||||||
|
ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
|
||||||
|
}
|
||||||
|
icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false);
|
||||||
|
if resp.on_hover_text("Close").clicked() {
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if close {
|
||||||
|
app.toast = None;
|
||||||
|
} else {
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_message_names_the_fonts() {
|
||||||
|
let f = |v: &[&str]| missing_fonts_message(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||||
|
assert_eq!(f(&[]), None);
|
||||||
|
assert_eq!(f(&["Aptos"]).unwrap(), "Aptos isn't installed on this computer. Text that uses it is shown in a similar font.");
|
||||||
|
assert!(f(&["Aptos", "Sora"]).unwrap().starts_with("Aptos and Sora aren't installed"));
|
||||||
|
assert!(f(&["A", "B", "C"]).unwrap().starts_with("A, B and C aren't"));
|
||||||
|
assert!(f(&["A", "B", "C", "D", "E"]).unwrap().starts_with("A, B, C and 2 more aren't"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user