mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a9915761 | ||
|
|
f94f3c12d1 | ||
|
|
75bfd1da3e | ||
|
|
85366b1a62 | ||
|
|
8159db8ba5 | ||
|
|
55a53f8ecb | ||
|
|
f8d54d7af4 |
@@ -28,6 +28,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
|
||||
@@ -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 {
|
||||
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"))]
|
||||
@@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
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")]
|
||||
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 doc = open_presentation(name, &data)?;
|
||||
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`).
|
||||
|
||||
@@ -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 connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -141,6 +142,9 @@ pub struct DocState {
|
||||
pub interaction: Option<Interaction>,
|
||||
pub uid: u64,
|
||||
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);
|
||||
@@ -158,6 +162,7 @@ impl DocState {
|
||||
interaction: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
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.
|
||||
@@ -543,3 +567,42 @@ fn inserted_hyperlinks_look_like_links() {
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
|
||||
/// like any other; one can be dragged back onto the slide or out of it.
|
||||
#[test]
|
||||
fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
// Inserting off the slide keeps the box where it was asked for.
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// Hover and click wholly off the slide.
|
||||
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
|
||||
// Drag it further off, then back onto the slide.
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
|
||||
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
|
||||
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
|
||||
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
|
||||
// The off-slide half of a straddling shape is hit too.
|
||||
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
// A marquee started on the pasteboard selects what it encloses there.
|
||||
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
}
|
||||
|
||||
+135
-12
@@ -52,6 +52,53 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
)
|
||||
}
|
||||
|
||||
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
|
||||
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
|
||||
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
|
||||
/// substitutes (Arial CE, Times New Roman CYR…).
|
||||
fn office_twin(family: &str) -> Option<&'static str> {
|
||||
let f = family.to_ascii_lowercase();
|
||||
Some(match f.as_str() {
|
||||
"liberation sans" | "helvetica" | "helv" => "Arial",
|
||||
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
|
||||
_ => {
|
||||
let base = f
|
||||
.strip_suffix(" baltic")
|
||||
.or(f.strip_suffix(" ce"))
|
||||
.or(f.strip_suffix(" cyr"))
|
||||
.or(f.strip_suffix(" greek"))
|
||||
.or(f.strip_suffix(" tur"))?;
|
||||
match base {
|
||||
"arial" => "Arial",
|
||||
"courier new" => "Courier New",
|
||||
"times new roman" => "Times New Roman",
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
|
||||
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
|
||||
/// substitute ([`substitutes`]; Aptos as Inter).
|
||||
fn is_office_cloud_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
|
||||
}
|
||||
|
||||
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
|
||||
/// whatever the theme, `panose` or `pitchFamily` say.
|
||||
pub const MISSING_FONT_FAMILY: &str = "Calibri";
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
@@ -65,6 +112,10 @@ pub struct FontFace {
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
|
||||
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
|
||||
/// fonts this way.
|
||||
pub legacy: Option<(String, String)>,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
@@ -253,6 +304,11 @@ pub struct FontDb {
|
||||
/// scan, so what they find doesn't depend on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
|
||||
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
|
||||
/// Semibold" loads a face of the family "Segoe UI").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex<std::collections::HashSet<String>>,
|
||||
/// System fallback state: enabled, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
@@ -316,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
|
||||
/// typographic `family`.
|
||||
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
|
||||
let legacy = name(f, &[StringId::FAMILY_NAME])?;
|
||||
if legacy.eq_ignore_ascii_case(family) {
|
||||
return None;
|
||||
}
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
|
||||
/// variable font is cataloged under its default style; its named instances appear once it loads.
|
||||
@@ -362,8 +428,13 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
let f = skrifa::FontRef::new(&font).ok()?;
|
||||
let (family, style) = face_names(&f)?;
|
||||
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
|
||||
let legacy = legacy_names(&f, &family);
|
||||
Some(std::iter::once((family, style)).chain(legacy))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -379,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
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>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
@@ -393,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());
|
||||
dirs.push(root.join("Fonts"));
|
||||
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 {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
@@ -491,10 +572,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
legacy,
|
||||
weight,
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
@@ -571,6 +654,8 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex::new(std::collections::HashSet::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
@@ -676,6 +761,8 @@ impl FontDb {
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -756,17 +843,35 @@ impl FontDb {
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
// Every installed file of the family first, so the best style is among the candidates. The
|
||||
// lock is held while the files load, so another thread looking up the family waits for
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
loaded.insert(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
self.find(family, style)
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
@@ -782,7 +887,9 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -837,11 +944,27 @@ impl FontDb {
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let ns = norm(style);
|
||||
if cands.is_empty() {
|
||||
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
|
||||
// their Windows style ("Regular", "Bold"…).
|
||||
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
|
||||
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
|
||||
// by the caller, as Windows does, rather than a face of another weight.
|
||||
if let Some(f) = cands
|
||||
.iter()
|
||||
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
|
||||
{
|
||||
return Some((*f).clone());
|
||||
}
|
||||
}
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ns = norm(style);
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
|
||||
+62
-1
@@ -14,7 +14,9 @@ pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
@@ -182,6 +184,65 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_family_names_find_their_faces() {
|
||||
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
|
||||
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
|
||||
let check = |db: &FontDb| {
|
||||
assert!(db.has_family("DeckCraft Legacy Semibold"));
|
||||
for style in ["Regular", "Bold"] {
|
||||
let f = db.face("DeckCraft Legacy Semibold", style);
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
|
||||
}
|
||||
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
|
||||
// The typographic name still works.
|
||||
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
|
||||
};
|
||||
// Loaded directly…
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
|
||||
check(&db);
|
||||
// …and found by the system font scan under its Windows name, then loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.set_system_fallback(false);
|
||||
check(&db);
|
||||
// Looking up the Windows name loads the Semibold file only; a later lookup of the
|
||||
// family's Regular must still load and pick the Regular file, not settle for Semibold.
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
|
||||
let regular = db.face("DeckCraft Legacy", "Regular");
|
||||
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
|
||||
let db = FontDb::global();
|
||||
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
|
||||
let f = db.document_face(missing, "Bold");
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
}
|
||||
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
|
||||
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
|
||||
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
|
||||
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Calibri").contains(&"Carlito"));
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -0,0 +1,55 @@
|
||||
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
|
||||
|
||||
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
|
||||
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
|
||||
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
|
||||
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
|
||||
a square for "A".
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def square():
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((100, 0))
|
||||
pen.lineTo((100, 700))
|
||||
pen.lineTo((600, 700))
|
||||
pen.lineTo((600, 0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def build(path, names, weight):
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder([".notdef", "A"])
|
||||
fb.setupCharacterMap({ord("A"): "A"})
|
||||
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
|
||||
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
|
||||
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
||||
fb.setupNameTable(names)
|
||||
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
|
||||
fb.setupPost()
|
||||
fb.save(path)
|
||||
|
||||
|
||||
build(sys.argv[1], {
|
||||
"familyName": "DeckCraft Legacy Semibold",
|
||||
"styleName": "Regular",
|
||||
"typographicFamily": "DeckCraft Legacy",
|
||||
"typographicSubfamily": "Semibold",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
|
||||
"fullName": "DeckCraft Legacy Semibold",
|
||||
"psName": "DeckCraftLegacy-Semibold",
|
||||
}, 600)
|
||||
build(sys.argv[2], {
|
||||
"familyName": "DeckCraft Legacy",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
|
||||
"fullName": "DeckCraft Legacy",
|
||||
"psName": "DeckCraftLegacy-Regular",
|
||||
}, 400)
|
||||
@@ -134,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
|
||||
/// Effective fill and the style colour (`phClr`) to resolve it with.
|
||||
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(st) = &s.style {
|
||||
let style_fill = |s: &Shape| {
|
||||
s.style.as_ref().map(|st| {
|
||||
let (idx, col) = &st.fill_ref;
|
||||
let ph = ctx.color(col, None);
|
||||
let f = match *idx {
|
||||
@@ -146,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(),
|
||||
i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
|
||||
};
|
||||
return (f, Some(ph));
|
||||
(f, Some(ph))
|
||||
})
|
||||
};
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
// A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
|
||||
// gradient its style refers to, keeping its own direction, as PowerPoint draws it.
|
||||
if let Fill::Gradient(g) = f
|
||||
&& g.stops.is_empty()
|
||||
&& let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
|
||||
{
|
||||
let mut g = g.clone();
|
||||
g.stops = sg.stops;
|
||||
return (Some(Fill::Gradient(g)), ph);
|
||||
}
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(r) = style_fill(s) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if shape.text_box {
|
||||
|
||||
@@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() {
|
||||
assert!(ph.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
|
||||
// the style's fillRef names, in the style's colour, along the shape's own direction.
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
let (f, ph) = resolve::fill(&ctx, &sh);
|
||||
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
|
||||
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
|
||||
assert_eq!(g.stops, theme.stops);
|
||||
assert_eq!(g.shape, own.shape, "the shape's own direction");
|
||||
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
|
||||
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
|
||||
st.fill_ref.0 = 1;
|
||||
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = Presentation::default();
|
||||
|
||||
@@ -356,7 +356,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
let mut pics = vec![];
|
||||
e.find_all("blip", &mut pics);
|
||||
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")) {
|
||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||
} 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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
|
||||
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open0("a:gsLst");
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.is_empty() {
|
||||
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
|
||||
}
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
|
||||
@@ -79,3 +79,26 @@ fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
// `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving
|
||||
// must not turn it into a gradient of its own (it used to become solid white).
|
||||
use deckcraft_model::style::{Gradient, GradientShape};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(own.clone())),
|
||||
style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The pasteboard: what the editor draws around the slide.
|
||||
//!
|
||||
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
|
||||
//! on the area around it, at full strength and with their effects (master and layout graphics
|
||||
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
|
||||
//!
|
||||
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
|
||||
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Line};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use kurbo::Rect;
|
||||
|
||||
use crate::MAX_SIDE;
|
||||
|
||||
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
|
||||
/// hostile files can't blow the pasteboard up.
|
||||
const MAX_SPILL: f64 = 2000.0;
|
||||
|
||||
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
|
||||
/// that is wider than the window re-renders only when a new tile comes into view.
|
||||
pub const TILE: i64 = 256;
|
||||
|
||||
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
|
||||
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
|
||||
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
|
||||
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
|
||||
let mut s: f64 = 0.0;
|
||||
if let Some(l) = line
|
||||
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
// Arrowheads reach up to a few line widths past the end point.
|
||||
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
|
||||
}
|
||||
if let Some(e) = effects {
|
||||
let big = fin(w).max(fin(h));
|
||||
if let Some(sh) = &e.outer_shadow {
|
||||
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
|
||||
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
s = s.max(fin(g.radius));
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
s = s.max(fin(h) + fin(r.dist));
|
||||
}
|
||||
}
|
||||
s.min(MAX_SPILL)
|
||||
}
|
||||
|
||||
/// Slide-space box a top-level shape draws into, its spill included.
|
||||
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
|
||||
if s.hidden {
|
||||
return None;
|
||||
}
|
||||
let x: Xfrm = resolve::xfrm(rctx, s);
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = match &s.kind {
|
||||
ShapeKind::Ink { strokes } => {
|
||||
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
|
||||
let (px, py, _) = pts.next()?;
|
||||
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
|
||||
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
|
||||
return Some(r.inflate(w, w));
|
||||
}
|
||||
_ => x.bounds(),
|
||||
};
|
||||
let (line, _) = resolve::line(rctx, s);
|
||||
let (fx, _) = resolve::effects(rctx, s);
|
||||
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
|
||||
Some(b.inflate(d, d))
|
||||
}
|
||||
|
||||
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
|
||||
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
|
||||
}
|
||||
|
||||
fn slide_rect(pres: &Presentation) -> Rect {
|
||||
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
|
||||
}
|
||||
|
||||
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
|
||||
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
|
||||
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
|
||||
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(slide) = pres.slides.get(index) else { return page };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
|
||||
let mut r = union_boxes(&rctx, &slide.shapes, page);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
|
||||
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(m) = pres.masters.get(master) else { return page };
|
||||
match layout.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
|
||||
if l.show_master_shapes {
|
||||
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The slide itself, `w` × `h` pixels.
|
||||
pub fn slide(w: u32, h: u32) -> Self {
|
||||
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
|
||||
}
|
||||
|
||||
fn edges(&self) -> (i64, i64, i64, i64) {
|
||||
let (x, y) = (self.x as i64, self.y as i64);
|
||||
(x, y, x + self.w as i64, y + self.h as i64)
|
||||
}
|
||||
|
||||
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
|
||||
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
|
||||
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
|
||||
}
|
||||
|
||||
/// Does this region cover all of `other`?
|
||||
pub fn contains(&self, other: &Region) -> bool {
|
||||
let (a, b) = (self.edges(), other.edges());
|
||||
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
|
||||
}
|
||||
|
||||
/// Is the region more than the slide (some of the pasteboard is drawn)?
|
||||
pub fn beyond(&self, slide: (u32, u32)) -> bool {
|
||||
*self != Region::slide(slide.0, slide.1)
|
||||
}
|
||||
|
||||
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
|
||||
pub fn offset(&self) -> (f64, f64) {
|
||||
(-(self.x as f64), -(self.y as f64))
|
||||
}
|
||||
|
||||
/// The region's size, for render options' `size`.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.w, self.h)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
|
||||
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
|
||||
let lim = i32::MAX as f64 / 2.0;
|
||||
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
|
||||
Some((
|
||||
px((r.x0 * scale).floor())? as i64,
|
||||
px((r.y0 * scale).floor())? as i64,
|
||||
px((r.x1 * scale).ceil())? as i64,
|
||||
px((r.y1 * scale).ceil())? as i64,
|
||||
))
|
||||
}
|
||||
|
||||
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
|
||||
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
|
||||
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
|
||||
/// area is rounded out to whole [`TILE`]s.
|
||||
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
|
||||
let page = Region::slide(slide_px.0, slide_px.1);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return page;
|
||||
}
|
||||
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
|
||||
let (vx0, vy0, vx1, vy1) = visible.edges();
|
||||
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
|
||||
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
|
||||
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
return page;
|
||||
}
|
||||
let (px0, py0, px1, py1) = page.edges();
|
||||
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use deckcraft_model::Shape;
|
||||
|
||||
fn black() -> deckcraft_model::ColorRef {
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
|
||||
}
|
||||
|
||||
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
|
||||
let p = deck_with(100.0, 100.0, 50.0, 50.0);
|
||||
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_off_the_slide_grow_the_extent() {
|
||||
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
|
||||
let e = slide_extent(&p, 0);
|
||||
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
|
||||
assert_eq!(e.y0, 0.0);
|
||||
assert_eq!(e.x1, p.slide_size.width);
|
||||
// Missing slides are just the page.
|
||||
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
|
||||
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
|
||||
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_count_towards_the_extent() {
|
||||
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
|
||||
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
|
||||
sh.effects = Some(Effects {
|
||||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||
color: black(),
|
||||
blur: 10.0,
|
||||
dist: 20.0,
|
||||
dir: 270.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(slide_extent(&p, 0).y0 <= -130.0);
|
||||
assert_eq!(
|
||||
spill(
|
||||
None,
|
||||
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
|
||||
1.0,
|
||||
1.0
|
||||
),
|
||||
MAX_SPILL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
|
||||
assert!(slide_extent(&p, 0).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
|
||||
let slide = (960, 540);
|
||||
let page = Region::slide(960, 540);
|
||||
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
|
||||
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
|
||||
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
|
||||
// Off to the left, in view: the region reaches it.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
|
||||
assert!(r.beyond(slide) && r.contains(&page));
|
||||
assert_eq!(r.offset(), (80.0, 0.0));
|
||||
// Far off and out of view (the view rounds out to tiles): only what's visible.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -256);
|
||||
// Wholly out of view: the slide.
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
|
||||
// Scale applies; nonsense falls back to the slide.
|
||||
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -40);
|
||||
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
|
||||
// Huge extents are capped.
|
||||
let r =
|
||||
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
|
||||
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_within_a_tile_keeps_the_region() {
|
||||
let slide = (2000, 1125);
|
||||
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
|
||||
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
|
||||
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod extent;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
@@ -40,6 +41,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::{RenderContext, Resources, peniko};
|
||||
|
||||
pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
@@ -233,11 +235,14 @@ pub struct Renderer {
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||
drawn: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +290,38 @@ fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
|
||||
Ok(img) => img,
|
||||
render_slide_drawn(pres, index, opts).0
|
||||
}
|
||||
|
||||
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
|
||||
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
|
||||
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
|
||||
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
|
||||
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
|
||||
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
r.drawn = None;
|
||||
let img = r.slide(pres, slide, index, opts);
|
||||
(img, r.drawn.take())
|
||||
})
|
||||
})) {
|
||||
Ok((img, drawn)) => {
|
||||
let k = opts.scale;
|
||||
let to_slide =
|
||||
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
|
||||
let extent = match drawn {
|
||||
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
|
||||
_ => page,
|
||||
};
|
||||
(img, extent)
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("rendering slide {index} panicked; showing a blank slide");
|
||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||
let (w, h) = output_size(pres, opts);
|
||||
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
|
||||
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +501,14 @@ impl Renderer {
|
||||
(ctx, w, h, view)
|
||||
}
|
||||
|
||||
/// Note that `r` (local space, placed by `m`) was drawn.
|
||||
fn track(&mut self, m: Affine, r: Rect) {
|
||||
let b = m.transform_rect_bbox(r);
|
||||
if b.is_finite() {
|
||||
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
@@ -658,10 +695,21 @@ impl Renderer {
|
||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
|
||||
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
|
||||
ShapeKind::Table(t) => {
|
||||
// Rows grow with their text: what the cells cover, borders included.
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
|
||||
chart::draw(ctx, rctx, c, m, x.w, x.h)
|
||||
}
|
||||
ShapeKind::Ink { strokes } => {
|
||||
for sk in strokes {
|
||||
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
|
||||
for (px, py, _) in &sk.points {
|
||||
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
for (i, (px, py, _)) in sk.points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -692,6 +740,8 @@ impl Renderer {
|
||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||
let (line, line_ph) = resolve::line(rctx, s);
|
||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
|
||||
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
|
||||
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
||||
let outline = geo.outline();
|
||||
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
||||
@@ -717,6 +767,13 @@ impl Renderer {
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
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) => {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
@@ -818,6 +875,12 @@ impl Renderer {
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
let pad = b.height() * 0.3;
|
||||
self.track(tm, b.inflate(pad, pad));
|
||||
}
|
||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||
|
||||
@@ -297,9 +297,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
ctx: &mut RenderContext,
|
||||
pres: &Presentation,
|
||||
rctx: &Ctx,
|
||||
t: &Table,
|
||||
m: Affine,
|
||||
_w: f64,
|
||||
_h: f64,
|
||||
fields: &dyn deckcraft_text::Fields,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
@@ -379,4 +391,5 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
+124
-1
@@ -1037,7 +1037,8 @@ fn wordart_arch_up_lifts_the_middle_of_the_line() {
|
||||
// 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());
|
||||
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]
|
||||
@@ -1145,3 +1146,125 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
|
||||
fn off_slide_deck() -> Presentation {
|
||||
let half = Shape {
|
||||
id: ShapeId(2700),
|
||||
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let below = Shape {
|
||||
id: ShapeId(2701),
|
||||
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||||
..Default::default()
|
||||
};
|
||||
deck_with(vec![half, below])
|
||||
}
|
||||
|
||||
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
|
||||
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_exports_and_thumbnails_clip_to_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (960, 540));
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
// The on-slide half is there, nothing else is drawn outside the slide.
|
||||
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
|
||||
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let extent = slide_extent(&p, 0);
|
||||
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
|
||||
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
|
||||
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
|
||||
assert_eq!((img.width, img.height), region.size());
|
||||
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
|
||||
// Both halves of the straddling shape, at full strength, and the shape below the slide.
|
||||
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
|
||||
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
|
||||
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
|
||||
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
|
||||
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
|
||||
assert_eq!(at(200, 300), [255, 255, 255, 255]);
|
||||
// The drawing's reach, in slide space.
|
||||
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape {
|
||||
id: ShapeId(2702),
|
||||
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
|
||||
..Default::default()
|
||||
});
|
||||
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
|
||||
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
|
||||
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
|
||||
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
|
||||
for para in &mut t.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(40.0);
|
||||
}
|
||||
}
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
// The box estimate doesn't see the text below the slide; the render does.
|
||||
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
|
||||
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
assert!(drawn.y1 > 600.0, "{drawn:?}");
|
||||
// A slide with nothing off it reaches just the slide.
|
||||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
let family = resolve::font_family(ctx, r);
|
||||
let bold = r.bold.unwrap_or(false);
|
||||
let italic = r.italic.unwrap_or(false);
|
||||
let face = FontDb::global().face(&family, style_name(bold, italic));
|
||||
let face = FontDb::global().document_face(&family, style_name(bold, italic));
|
||||
let fake_bold = bold && face.weight < 550.0;
|
||||
let fake_italic = italic && !face.italic;
|
||||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
||||
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
||||
//! previews, the text caret and text selection).
|
||||
//!
|
||||
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
|
||||
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
|
||||
//! the slide show, exports and thumbnails clip to the slide.
|
||||
|
||||
use deckcraft_engine::tools::{Hit, box_handles};
|
||||
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
||||
@@ -108,18 +112,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let st = app.session.active();
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
let visible = visible_region(avail, slide_rect, pixel);
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
None => None,
|
||||
};
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if let Some((tex, region)) = tex {
|
||||
let at = Rect::from_min_size(
|
||||
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
|
||||
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
|
||||
);
|
||||
app.canvas_region = Some(at);
|
||||
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
app.perf.render_ms = app.textures.last_render_ms;
|
||||
media_frames(app, &painter, xf);
|
||||
@@ -157,6 +169,22 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
||||
/// top-left corner.
|
||||
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
|
||||
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
|
||||
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
|
||||
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
|
||||
let lim = i32::MAX as f64 / 4.0;
|
||||
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
|
||||
deckcraft_render::Region {
|
||||
x: x0 as i32,
|
||||
y: y0 as i32,
|
||||
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -240,14 +268,17 @@ fn master_tex(
|
||||
master: usize,
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
) -> Option<egui::TextureHandle> {
|
||||
visible: deckcraft_render::Region,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
||||
let img = deckcraft_render::render_layout(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
|
||||
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod status;
|
||||
pub mod textures;
|
||||
pub mod theme;
|
||||
pub mod thumbs;
|
||||
pub mod toast;
|
||||
pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -181,6 +182,10 @@ pub struct SlideApp {
|
||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||
pub media: media::MediaHost,
|
||||
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,
|
||||
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
@@ -188,6 +193,8 @@ pub struct SlideApp {
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
pub canvas_scale: f32,
|
||||
pub slide_rect: Option<egui::Rect>,
|
||||
/// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen.
|
||||
pub canvas_region: Option<egui::Rect>,
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
@@ -224,11 +231,14 @@ impl SlideApp {
|
||||
show: None,
|
||||
media: media::MediaHost::new(audio_out),
|
||||
status: None,
|
||||
toast: None,
|
||||
fonts_noted: Default::default(),
|
||||
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
|
||||
synthetic: vec![],
|
||||
canvas_rect: None,
|
||||
canvas_scale: 1.0,
|
||||
slide_rect: None,
|
||||
canvas_region: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
@@ -730,6 +740,8 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
});
|
||||
toast::note_missing_fonts(self);
|
||||
toast::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
menus::palette(self, &ctx);
|
||||
for url in std::mem::take(&mut self.pending_urls) {
|
||||
@@ -889,6 +901,37 @@ mod tests {
|
||||
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.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
@@ -938,4 +981,107 @@ mod tests {
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
}
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
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);
|
||||
let app = harness.state_mut();
|
||||
// Zoomed out, the canvas shows plenty of pasteboard around the slide.
|
||||
app.ui.zoom = Some(0.4);
|
||||
app.session.prefs.snap_to_grid = false;
|
||||
app.session.prefs.smart_guides = false;
|
||||
harness
|
||||
}
|
||||
|
||||
fn selected(app: &SlideApp) -> Vec<u64> {
|
||||
app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect()
|
||||
}
|
||||
|
||||
fn shape_x(app: &SlideApp, id: u64) -> f64 {
|
||||
let st = app.session.active().unwrap();
|
||||
st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x
|
||||
}
|
||||
|
||||
/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where
|
||||
/// they can be selected and moved.
|
||||
#[test]
|
||||
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
|
||||
let mut harness = canvas_harness();
|
||||
let off =
|
||||
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
harness.run_steps(3);
|
||||
|
||||
let app = harness.state();
|
||||
let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale);
|
||||
// The texture reaches past the slide's right edge to the shape's, and no further left/up.
|
||||
assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}");
|
||||
assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}");
|
||||
let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k);
|
||||
assert!(app.canvas_rect.unwrap().contains(centre));
|
||||
|
||||
// Drawn at full strength on the pasteboard (when a GPU renderer is available here).
|
||||
if let Ok(img) = harness.render() {
|
||||
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
|
||||
let shape = px(centre);
|
||||
let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k));
|
||||
let doc = &harness.state().session.active().unwrap().doc;
|
||||
let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4;
|
||||
assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}");
|
||||
assert_ne!(shape, board);
|
||||
}
|
||||
|
||||
// Click selects it.
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drop_at(centre);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(selected(harness.state()), vec![off]);
|
||||
|
||||
// Drag moves it, further off the slide.
|
||||
let to = centre + egui::vec2(40.0, 0.0);
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
harness.drop_at(to);
|
||||
harness.run_steps(2);
|
||||
let x = shape_x(harness.state(), off);
|
||||
assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}");
|
||||
// The pasteboard followed it.
|
||||
let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap());
|
||||
assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}");
|
||||
|
||||
// Idle frames don't render the canvas again.
|
||||
let n = harness.state().textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n);
|
||||
}
|
||||
|
||||
/// A slide with nothing off it renders just the slide, as before.
|
||||
#[test]
|
||||
fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() {
|
||||
let mut harness = canvas_harness();
|
||||
harness.run_steps(3);
|
||||
let app = harness.state();
|
||||
let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap());
|
||||
assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}");
|
||||
let n = app.textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
|
||||
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
|
||||
//!
|
||||
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
|
||||
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Rect;
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use deckcraft_render::{Image, Region, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -15,11 +19,15 @@ struct Key {
|
||||
size: (u32, u32),
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
key: Key,
|
||||
tex: TextureHandle,
|
||||
/// The region the texture covers (at least `key.want`).
|
||||
region: Region,
|
||||
// Keep the Arcs alive so their addresses can't be reused while cached.
|
||||
_slide: Arc<Slide>,
|
||||
_master: Option<Arc<Master>>,
|
||||
@@ -29,6 +37,14 @@ struct Entry {
|
||||
#[derive(Default)]
|
||||
pub struct Textures {
|
||||
canvas: Option<Entry>,
|
||||
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
|
||||
/// held (kept alive so their addresses can't be reused).
|
||||
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
|
||||
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
|
||||
/// its box shows there.
|
||||
drawn: Option<(usize, Rect)>,
|
||||
/// Canvas renders so far (tests, performance checks).
|
||||
pub canvas_renders: u64,
|
||||
thumbs: HashMap<usize, Entry>,
|
||||
clock: u64,
|
||||
pub last_render_ms: f64,
|
||||
@@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
|
||||
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn threads() -> u16 {
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
||||
}
|
||||
|
||||
impl Textures {
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
p: &Presentation,
|
||||
index: usize,
|
||||
size: (u32, u32),
|
||||
visible: Region,
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
) -> Option<TextureHandle> {
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let estimate = match &self.estimate {
|
||||
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
|
||||
_ => {
|
||||
let r = deckcraft_render::slide_extent(p, index);
|
||||
self.estimate = Some((slide.clone(), master.clone(), r));
|
||||
r
|
||||
}
|
||||
};
|
||||
let reach = match self.drawn {
|
||||
Some((i, d)) if i == index => estimate.union(d),
|
||||
_ => estimate,
|
||||
};
|
||||
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||
let key = Key {
|
||||
slide: Arc::as_ptr(&slide) as usize,
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size,
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64,
|
||||
want,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
{
|
||||
return Some(e.tex.clone());
|
||||
return Some((e.tex.clone(), e.region));
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
||||
let render = |region: Region| {
|
||||
let opts =
|
||||
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
};
|
||||
let (mut img, drawn) = render(want);
|
||||
self.canvas_renders += 1;
|
||||
let mut region = want;
|
||||
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||
// more than this render covered.
|
||||
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||
if !region.contains(&key.want) {
|
||||
region = key.want;
|
||||
img = render(region).0;
|
||||
self.canvas_renders += 1;
|
||||
}
|
||||
self.drawn = Some((index, drawn));
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
@@ -99,13 +158,15 @@ impl Textures {
|
||||
}
|
||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
|
||||
Some(tex)
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
}
|
||||
|
||||
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
|
||||
pub fn invalidate(&mut self) {
|
||||
self.canvas = None;
|
||||
self.estimate = None;
|
||||
self.drawn = None;
|
||||
self.thumbs.clear();
|
||||
}
|
||||
|
||||
@@ -121,6 +182,7 @@ impl Textures {
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
@@ -139,7 +201,8 @@ impl Textures {
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
||||
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
if self.thumbs.len() > 600 {
|
||||
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
|
||||
v.sort_by_key(|x| x.1);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||
@@ -41,6 +41,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
|---|---|---|
|
||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
||||
| Smart guides (edges, centres, equal spacing) | yes | yes |
|
||||
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
|
||||
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
||||
@@ -87,4 +88,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||
|
||||
Reference in New Issue
Block a user