Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 2e7babc53e Canvas: objects off the slide show and can be grabbed around it, as in PowerPoint (#270)
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, with
their effects, master and layout graphics included; the slide show,
exports and thumbnails clip to the slide. DeckCraft clipped everything to
the slide in the editor too.

- render: `slide_extent`/`layout_extent` estimate how far a slide's
  drawing reaches (boxes, outlines, shadow, glow, reflection);
  `render_slide_drawn` also reports what a render actually drew (text
  spilling out of its box, table rows that grew); `pasteboard_region`
  picks the pixels the editor renders: the slide plus what reaches off
  it and is in view, rounded to 256 px tiles so scrolling rarely
  re-renders. A slide with nothing off it renders exactly as before.
- ui-egui: the canvas texture covers that region (slide and master
  views); it re-renders only when the slide, zoom or the region changes,
  and a second time only if text drew beyond the estimate.
- Hit testing, selection, marquee and moves already worked in slide
  space; tests now cover them off the slide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:41:09 -04:00
Brandon ThomasandClaude Opus 5.5 75bfd1da3e Embedded objects draw their preview picture, including old VML previews (#328)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:40:45 -04:00
Brandon ThomasandClaude Opus 5.5 85366b1a62 Fonts: Office's downloaded fonts are used; opening a file says which fonts are missing (#326)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 20:34:54 -04:00
Brandon ThomasandClaude Opus 5.5 8159db8ba5 Render tests: the arch top bound allows Linux's fallback font (#323)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:06:46 -04:00
Brandon ThomasandClaude Opus 5.5 55a53f8ecb Fonts: missing fonts draw as PowerPoint draws them, and Windows family names resolve (#322)
* Text: a missing font is drawn in Calibri, as PowerPoint does

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: Windows family names find their faces; PowerPoint's twins for missing fonts

"Segoe UI Semibold", "Arial Black" and the like are the faces' Windows (GDI) family
names; a family's installed files now load together; Liberation Sans and Serif draw as
Arial and Times New Roman when missing, as PowerPoint draws them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: Office cloud fonts (Aptos…) keep their open substitute

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:05:48 -04:00
Brandon ThomasandClaude Opus 5.5 f8d54d7af4 Pictures: WMF and EMF draw, with the metafile reader ported from WordCraft (#317)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:27:19 -04:00
23 changed files with 1403 additions and 46 deletions
+2
View File
@@ -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
+1
View File
@@ -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) |
+7 -3
View File
@@ -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`).
+65
View File
@@ -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));
}
}
}
+5
View File
@@ -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 {
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
Binary file not shown.
Binary file not shown.
+55
View File
@@ -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)
+17 -1
View File
@@ -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")?;
+98
View File
@@ -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);
}
+329
View File
@@ -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);
}
}
+70 -7
View File
@@ -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),
+14 -1
View File
@@ -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
View File
@@ -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));
}
+1 -1
View File
@@ -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);
+39 -8
View File
@@ -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, &times)
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
}
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) {
+146
View File
@@ -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");
}
}
+73 -10
View File
@@ -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);
+94
View File
@@ -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
View File
@@ -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 |