Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d91be2e6d7 UI: Korean, Japanese and Chinese input and interface text (#101)
The slide turns the input method on while it takes typing, with the candidate window at the
caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes
syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul,
Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun
Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text.
Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before
focus leaves in the same frame (Escape, another slide). Control channel: ui.ime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:01:49 -04:00
Brandon ThomasandClaude Opus 5.5 128b93738e Text: input method composition as commands, one undo step per composition (#101)
text.compose shows an input method's preedit in place at the insertion point and replaces it on
each update; an empty preedit cancels and restores the text and selection. text.commit replaces
the composition with the result as one undo step. Any other edit, key or click settles an open
composition first. The composition is underlined under the baseline (thicker under the part being
converted), and the caret line is measured for empty placeholders too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:01:48 -04:00
Brandon ThomasandClaude Opus 5.5 23a3b6eba2 Fills: a gradient without stops takes them from its style, and keeps none when saved (#330)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:01:29 -04:00
Brandon ThomasandClaude Opus 5.5 f94f3c12d1 Canvas: objects off the slide show and can be grabbed around it, as in PowerPoint (#270) (#329)
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:47:05 -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
53 changed files with 5470 additions and 80 deletions
Generated
+9
View File
@@ -1046,6 +1046,14 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-metafile"
version = "0.6.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-model"
version = "0.6.1"
@@ -1103,6 +1111,7 @@ dependencies = [
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-metafile",
"deckcraft-model",
"deckcraft-text",
"image",
+1
View File
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft"
# internal
deckcraft-geom = { path = "crates/geom" }
deckcraft-color = { path = "crates/color" }
deckcraft-metafile = { path = "crates/metafile" }
deckcraft-model = { path = "crates/model" }
deckcraft-fonts = { path = "crates/fonts" }
deckcraft-text = { path = "crates/text" }
+3
View File
@@ -128,6 +128,9 @@ 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 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 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`).
+154
View File
@@ -17,6 +17,27 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
// The input method's composition: shown in place, an undo step only once committed.
CommandSpec {
id: "text.compose",
label: "Compose Text",
menu: &[],
shortcut: None,
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
enabled: has_text,
run: compose,
journal: false,
undoable: false,
},
cmd!(
noundo "text.commit",
"Commit Composition",
[],
None,
"{text} — the input method's result replaces its composition, as one undo step",
has_text,
commit
),
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
@@ -144,6 +165,33 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
))
}
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
/// table cells, which aren't laid out on the slide here.
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let x = xfrm_of(&st.doc, &st.selection, sh);
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let mut body = sh.text.clone().unwrap_or_default();
if body.paragraphs.is_empty() {
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
}
let l = deckcraft_text::layout(
&ctx,
sh,
&body,
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?;
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
}
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
@@ -340,6 +388,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
ok()
}
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
fn ime_text(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
/// Is the composition still where it was put (the paragraph holds all of it)?
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
let Some(t) = &st.selection.text else { return false };
let Some(body) = body_of(st, t) else { return false };
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
}
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
/// goes back to how it was before the composition began.
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
let n = text.chars().count();
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
(x < y).then_some((x.min(n), y.min(n)))
});
let current = s.doc()?.composition.clone();
// A composition whose text was changed under it is kept as it is; a new one starts.
let current = match current {
Some(c) if !composition_fits(s.doc()?, &c) => {
s.end_composition();
None
}
c => c,
};
match current {
None if text.is_empty() => Ok(json!({"composing": false})),
None => {
let st = s.doc()?;
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
let (start, props) = edit_text(s, |body, t| {
let (a, b) = t.ordered();
let at = ed::delete(body, a, b);
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
let c = ed::insert(body, at, &text, props.clone());
t.anchor = c;
t.caret = c;
(at, props)
})?;
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
}
Some(c) if text.is_empty() => {
let st = s.doc_mut()?;
st.doc = c.before;
st.selection = c.before_selection;
st.composition = None;
st.revision += 1;
Ok(json!({"composing": false}))
}
Some(c) => {
let (start, len, props) = (c.start, c.len, c.props.clone());
edit_text(s, |body, t| {
ed::delete(body, start, (start.0, start.1 + len));
let e = ed::insert(body, start, &text, props);
t.anchor = e;
t.caret = e;
})?;
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
}
}
}
/// `text.commit`: the input method's result replaces the composition (or is typed at the
/// insertion point when there is none) and the whole composition becomes one undo step.
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
if s.doc()?.composition.is_some() || !text.is_empty() {
compose(s, &json!({"text": text}))?;
}
s.end_composition();
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
Ok(json!({"caret": [caret.0, caret.1]}))
}
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
/// thick)` per line, thick under the part being converted. Empty without a composition or when
/// the edited text isn't laid out on the slide (notes, table cells).
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
let Some(sh) = st.shape(t.shape) else { return vec![] };
let Some(l) = layout_for(st, t) else { return vec![] };
let x = xfrm_of(&st.doc, &st.selection, sh);
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
let mut out = vec![];
let mut add = |a: usize, b: usize, thick: bool| {
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
}
};
add(0, c.len, false);
if let Some((a, b)) = c.active {
add(a, b.min(c.len), true);
}
out
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
insert_text(s, &text, None)
+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));
}
}
}
+40
View File
@@ -25,6 +25,7 @@ impl Session {
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
/// Returns `{handled: bool, …}`.
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
self.end_composition();
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
let k = key.to_ascii_lowercase();
@@ -151,6 +152,45 @@ impl Session {
self.execute("text.insert", &json!({"text": text}))
}
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
/// `active` is the part being converted, in characters of `text`.
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
if !self.start_typing(text)? {
return Ok(json!({"handled": false}));
}
let mut p = json!({"text": text});
if let Some((a, b)) = active {
p["active"] = json!([a, b]);
}
self.execute("text.compose", &p)
}
/// An input method's result (#101): replaces the composition as one undo step.
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
if !self.start_typing(text)? {
return Ok(json!({"handled": false}));
}
self.execute("text.commit", &json!({"text": text}))
}
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
/// starts with everything selected. False when there is nowhere to type (or nothing to type
/// and nothing being edited).
fn start_typing(&mut self, text: &str) -> Result<bool> {
let st = self.doc()?;
if st.selection.text.is_some() {
return Ok(true);
}
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
if st.selection.shapes.len() != 1 || text.is_empty() {
return Ok(false);
}
self.execute("text.edit", &json!({"id": id}))?;
self.execute("text.selectAll", &json!({}))?;
Ok(true)
}
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
let st = self.doc()?;
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
+40
View File
@@ -9,6 +9,7 @@
pub mod cmd;
pub mod connect;
pub mod fonts;
pub mod guard;
pub mod keys;
pub mod links;
@@ -130,6 +131,24 @@ pub struct Interaction {
pub selection: Selection,
}
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
/// before it began makes the whole composition one undo step.
#[derive(Clone, Debug)]
pub struct Composition {
/// Where the composed text starts: (paragraph, char).
pub start: (usize, usize),
/// Its length in characters (it never spans paragraphs).
pub len: usize,
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
pub active: Option<(usize, usize)>,
/// The formatting the composed text is typed with (as `text.insert` would choose).
pub props: Option<deckcraft_model::text::RunProps>,
/// The presentation and selection before the composition began.
pub before: Arc<Presentation>,
pub before_selection: Selection,
}
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Presentation>,
@@ -141,6 +160,11 @@ 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>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -158,6 +182,8 @@ impl DocState {
interaction: None,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
title,
missing_fonts: Vec::new(),
composition: None,
}
}
pub fn is_dirty(&self) -> bool {
@@ -398,6 +424,10 @@ impl Session {
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// Any other edit (or a click, a key, undo) settles an input method's composition first.
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
self.end_composition();
}
if let Err(e) = (spec.enabled)(self) {
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
if !named {
@@ -448,6 +478,16 @@ impl Session {
Ok(r)
}
/// Keep an input method's composition as it stands (#101): its text stays and the whole
/// composition becomes one undo step. Nothing happens without one.
pub fn end_composition(&mut self) {
let Some(st) = self.active_mut() else { return };
let Some(c) = st.composition.take() else { return };
if !Arc::ptr_eq(&c.before, &st.doc) {
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
}
}
/// Change only the selection (no undo step).
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
+231
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,210 @@ 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]);
}
fn title_text(s: &Session) -> String {
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
/// committed text is one undo step, and nothing on the way is an undo step of its own.
#[test]
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "A "})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
s.compose_text(pre, None).unwrap();
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
let c = s.doc().unwrap().composition.clone().unwrap();
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
}
s.commit_text("한글").unwrap();
assert_eq!(title_text(&s), "A 한글");
assert!(s.doc().unwrap().composition.is_none());
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A ");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A 한글");
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
// then a new preedit.
s.compose_text("ㄴ", None).unwrap();
s.compose_text("", None).unwrap();
s.commit_text("ㄴ").unwrap();
s.compose_text("ㅁ", None).unwrap();
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
s.commit_text("마").unwrap();
assert_eq!(title_text(&s), "A 한글ㄴ마");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A 한글ㄴ");
}
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
/// the text and the selection it replaced are as they were, with no undo step.
#[test]
fn ime_cancel_restores_the_text_and_selection() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
let rev = s.doc().unwrap().revision;
s.compose_text("せかい", Some((0, 3))).unwrap();
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
s.compose_text("世界", Some((0, 9))).unwrap();
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
s.compose_text("", None).unwrap();
assert_eq!(title_text(&s), "Hello world");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
assert!(s.doc().unwrap().revision > rev, "redrawn");
// A commit of nothing without a composition is nothing.
s.commit_text("").unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
// A commit without a preedit (some input methods send only the result) is one step.
s.commit_text("世界").unwrap();
assert_eq!(title_text(&s), "Hello 世界");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
}
/// #101: anything else settles the composition first: its text stays and is its own undo step.
#[test]
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
s.compose_text("안", None).unwrap();
s.key("Right", Mods::default()).unwrap();
assert!(s.doc().unwrap().composition.is_none());
assert_eq!(title_text(&s), "안");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
s.compose_text("녕", None).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
assert_eq!(title_text(&s), "안녕");
s.execute("text.edit", &json!({"id": title})).unwrap();
s.compose_text("하", None).unwrap();
s.execute("text.get", &json!({"id": title})).unwrap();
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
s.execute("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(title_text(&s), "안녕하!");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "안녕하");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "안녕");
// Text changed under the composition (here behind its back): a new one starts, no panic.
s.compose_text("ㄱ", None).unwrap();
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
s.compose_text("가", None).unwrap();
assert!(title_text(&s).ends_with('가'));
// Control characters (an Enter sent as text) never split the paragraph.
s.commit_text("가\n\r\u{b}").unwrap();
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
}
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
/// does; with nothing selected there is nowhere to compose.
#[test]
fn ime_on_a_selected_shape_replaces_its_text() {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.select(|_, sel| {
sel.text = None;
sel.shapes = vec![ShapeId(id as u32)];
})
.unwrap();
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
s.compose_text("新", None).unwrap();
s.commit_text("新しい").unwrap();
let v = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(v["text"], "新しい");
s.select(|_, sel| {
sel.text = None;
sel.shapes.clear();
})
.unwrap();
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
}
/// #101: the composition is underlined in place, under the composed characters, with a thicker
/// line under the part being converted.
#[test]
fn ime_composition_underlines_follow_the_text() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
s.compose_text("にほんご", Some((2, 4))).unwrap();
let u = cmd::text::composition_underlines(s.doc().unwrap());
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
for (a, b, _) in &u {
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
}
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
+4
View File
@@ -265,6 +265,10 @@ impl Session {
if !(p.x.is_finite() && p.y.is_finite()) {
return Ok(Value::Null);
}
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
if !matches!(ev.kind, PointerKind::Move) {
self.end_composition();
}
match ev.kind {
PointerKind::Move => {
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
+11 -1
View File
@@ -450,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"] {
@@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
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));
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "deckcraft-metafile"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
thiserror = { workspace = true }
+59
View File
@@ -0,0 +1,59 @@
//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so
//! a truncated or lying size field stops the parse instead of panicking.
#[derive(Clone, Copy)]
pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]);
impl<'a> Bytes<'a> {
pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> {
self.0.get(off..off.checked_add(len)?)
}
pub fn tail(&self, off: usize) -> Option<Bytes<'a>> {
self.0.get(off..).map(Bytes)
}
fn arr<const N: usize>(&self, off: usize) -> Option<[u8; N]> {
self.slice(off, N)?.try_into().ok()
}
pub fn u16(&self, off: usize) -> Option<u16> {
Some(u16::from_le_bytes(self.arr(off)?))
}
pub fn i16(&self, off: usize) -> Option<i16> {
Some(i16::from_le_bytes(self.arr(off)?))
}
pub fn u32(&self, off: usize) -> Option<u32> {
Some(u32::from_le_bytes(self.arr(off)?))
}
pub fn i32(&self, off: usize) -> Option<i32> {
Some(i32::from_le_bytes(self.arr(off)?))
}
pub fn f32(&self, off: usize) -> Option<f32> {
Some(f32::from_le_bytes(self.arr(off)?))
}
/// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`.
pub fn points(&self, off: usize, n: usize, wide: bool) -> Option<Vec<(f64, f64)>> {
let sz = if wide { 8 } else { 4 };
let raw = self.slice(off, n.checked_mul(sz)?)?;
let mut out = Vec::with_capacity(n);
for c in raw.chunks_exact(sz) {
let p = if wide {
let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?);
let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?);
(f64::from(x), f64::from(y))
} else {
let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?);
let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?);
(f64::from(x), f64::from(y))
};
out.push(p);
}
Some(out)
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
//! Output is device-space items; `lib.rs` maps them into the picture frame.
use crate::dib::Rgba;
use crate::geom::{Op, P};
use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
pub(crate) const MM_TEXT: u32 = 1;
const MM_ISOTROPIC: u32 = 7;
const MM_ANISOTROPIC: u32 = 8;
const STOCK: u32 = 0x8000_0000;
pub(crate) const PS_NULL: u32 = 5;
/// Pen style bits; the rest of the style word holds end-cap, join and type flags.
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
const BS_SOLID: u32 = 0;
pub(crate) const BS_NULL: u32 = 1;
const WINDING: u32 = 2;
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
const R2_NOP: u32 = 11;
const PATCOPY: u32 = 0x00F0_0021;
const BLACKNESS: u32 = 0x0000_0042;
const WHITENESS: u32 = 0x00FF_0062;
/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size.
const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4;
/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`.
pub(crate) type Xform = [f64; 6];
const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`.
pub(crate) fn mul(a: Xform, b: Xform) -> Xform {
[
a[0] * b[0] + a[1] * b[2],
a[0] * b[1] + a[1] * b[3],
a[2] * b[0] + a[3] * b[2],
a[2] * b[1] + a[3] * b[3],
a[4] * b[0] + a[5] * b[2] + b[4],
a[4] * b[1] + a[5] * b[3] + b[5],
]
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Pen {
pub style: u32,
/// Logical width; 0 means a cosmetic one-pixel hairline.
pub width: f64,
pub color: [u8; 4],
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Brush {
pub style: u32,
pub color: [u8; 4],
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum Obj {
Pen(Pen),
Brush(Brush),
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
Other,
}
/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA.
pub(crate) fn colorref(c: u32) -> [u8; 4] {
[(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255]
}
/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS).
fn metric_mm(mode: u32) -> Option<f64> {
match mode {
2 => Some(0.1),
3 => Some(0.01),
4 => Some(25.4 / 100.0),
5 => Some(25.4 / 1000.0),
6 => Some(25.4 / 1440.0),
_ => None,
}
}
#[derive(Clone, Copy)]
pub(crate) struct Mapping {
wo: P,
we: P,
vo: P,
ve: P,
mode: u32,
world: Xform,
/// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters).
px_mm: P,
}
impl Mapping {
fn identity() -> Self {
Mapping {
wo: (0.0, 0.0),
we: (1.0, 1.0),
vo: (0.0, 0.0),
ve: (1.0, 1.0),
mode: MM_TEXT,
world: IDENTITY,
px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM),
}
}
/// Logical point to device point: world transform, then window to viewport.
pub fn device(&self, p: P) -> P {
let w = self.world;
let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]);
let (sx, sy) = self.ratios();
((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1)
}
/// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed
/// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's
/// sign kept); anisotropic and unknown modes use the extents as given.
fn ratios(&self) -> (f64, f64) {
if self.mode == MM_TEXT {
return (1.0, 1.0);
}
if let Some(mm) = metric_mm(self.mode) {
return (mm * self.px_mm.0, -mm * self.px_mm.1);
}
let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1));
if self.mode == MM_ISOTROPIC {
let s = rx.abs().min(ry.abs());
return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s });
}
(rx, ry)
}
/// Approximate device length per logical length, for pen widths.
fn scale(&self) -> f64 {
let w = self.world;
let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt();
let (sx, sy) = self.ratios();
det * (sx.abs() + sy.abs()) / 2.0
}
/// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes).
pub fn uses_extents(&self) -> bool {
matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC)
}
pub fn window_ext(&self) -> P {
self.we
}
pub fn viewport_ext(&self) -> P {
self.ve
}
/// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame
/// of a plain WMF.
pub fn window_rect(&self) -> [f64; 4] {
let a = self.device(self.wo);
let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1));
[a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]
}
}
fn ratio(num: f64, den: f64) -> f64 {
if den == 0.0 { 1.0 } else { num / den }
}
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
pub(crate) enum Raw {
Path { ops: Vec<Op>, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
Bitmap { rect: [f64; 4], img: Rgba },
}
#[derive(Clone)]
struct Saved {
map: Mapping,
pen: Pen,
brush: Brush,
even_odd: bool,
nop: bool,
}
pub(crate) struct Canvas {
pub items: Vec<Raw>,
pub map: Mapping,
/// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use.
pub window_set: bool,
/// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]).
pub viewport_set: bool,
saved: Vec<Saved>,
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
used: usize,
objects: Vec<Option<Obj>>,
pen: Pen,
brush: Brush,
even_odd: bool,
/// The mix mode is R2_NOP: shapes draw nothing.
nop: bool,
pos: P,
in_path: bool,
path: Vec<Op>,
full: bool,
}
impl Canvas {
pub fn new() -> Self {
Canvas {
items: Vec::new(),
map: Mapping::identity(),
window_set: false,
viewport_set: false,
saved: Vec::new(),
used: 0,
objects: Vec::new(),
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
even_odd: true,
nop: false,
pos: (0.0, 0.0),
in_path: false,
path: Vec::new(),
full: false,
}
}
/// True once the item cap is hit; the readers stop there and keep what they have.
pub fn full(&self) -> bool {
self.full
}
fn push(&mut self, raw: Raw) {
let cost = match &raw {
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
Raw::Bitmap { img, .. } => img.px.len(),
};
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
} else {
self.used += cost;
self.items.push(raw);
}
}
pub fn set_window_org(&mut self, p: P) {
self.map.wo = p;
}
pub fn set_window_ext(&mut self, e: P) {
self.map.we = e;
self.window_set = true;
}
pub fn set_viewport_org(&mut self, p: P) {
self.map.vo = p;
}
pub fn set_viewport_ext(&mut self, e: P) {
self.map.ve = e;
self.viewport_set = true;
}
/// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic,
/// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's
/// own window records then replace the window; the viewport stays unless the file sets one.
/// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport
/// origin stay the file's.
pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) {
if let Some([x, y, w, h]) = window {
self.map.mode = MM_ANISOTROPIC;
self.map.wo = (x, y);
self.map.we = (w, h);
self.map.vo = (0.0, 0.0);
}
self.map.ve = size;
}
pub fn set_map_mode(&mut self, mode: u32) {
self.map.mode = mode;
}
/// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive.
pub fn set_device_mm(&mut self, px_mm: P) {
if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 {
self.map.px_mm = px_mm;
}
}
pub fn set_polyfill(&mut self, mode: u32) {
self.even_odd = mode != WINDING;
}
/// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
pub fn set_rop2(&mut self, mode: u32) {
self.nop = mode == R2_NOP;
}
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
/// An unknown mode leaves the world transform as it was.
pub fn modify_world(&mut self, x: Xform, mode: u32) {
match mode {
1 => self.map.world = IDENTITY,
2 => self.map.world = mul(x, self.map.world),
3 => self.map.world = mul(self.map.world, x),
4 => self.map.world = x,
_ => {}
}
}
/// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores
/// could no longer match the file.
pub fn save_dc(&mut self) {
if self.saved.len() < MAX_STACK {
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
} else {
self.full = true;
}
}
/// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`.
pub fn restore_dc(&mut self, n: i32) {
let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) };
let Some(idx) = idx else { return };
let Some(s) = self.saved.get(idx).cloned() else { return };
self.saved.truncate(idx);
self.map = s.map;
self.pen = s.pen;
self.brush = s.brush;
self.even_odd = s.even_odd;
self.nop = s.nop;
}
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
pub fn add_object(&mut self, slot: Option<usize>, obj: Obj) {
match slot {
Some(i) if i < MAX_OBJECTS => {
if self.objects.len() <= i {
self.objects.resize(i + 1, None);
}
if let Some(s) = self.objects.get_mut(i) {
*s = Some(obj);
}
}
Some(_) => {}
None => match self.objects.iter().position(Option::is_none) {
Some(i) => {
if let Some(s) = self.objects.get_mut(i) {
*s = Some(obj);
}
}
None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)),
None => {}
},
}
}
pub fn delete_object(&mut self, i: u32) {
if let Some(s) = self.objects.get_mut(i as usize) {
*s = None;
}
}
/// Selects an object by index; indices with the high bit set are stock objects.
pub fn select(&mut self, i: u32) {
if i & STOCK != 0 {
return self.select_stock(i & !STOCK);
}
match self.objects.get(i as usize).copied().flatten() {
Some(Obj::Pen(p)) => self.pen = p,
Some(Obj::Brush(b)) => self.brush = b,
Some(Obj::Other) | None => {}
}
}
fn select_stock(&mut self, i: u32) {
let white = [255, 255, 255, 255];
let black = [0, 0, 0, 255];
let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] };
match i {
0 | 18 => self.brush = Brush { style: BS_SOLID, color: white },
1 => self.brush = gray(192),
2 => self.brush = gray(128),
3 => self.brush = gray(64),
4 => self.brush = Brush { style: BS_SOLID, color: black },
5 => self.brush = Brush { style: BS_NULL, color: black },
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
_ => {}
}
}
fn map_op(&self, op: Op) -> Op {
match op {
Op::M(p) => Op::M(self.map.device(p)),
Op::L(p) => Op::L(self.map.device(p)),
Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)),
Op::Z => Op::Z,
}
}
fn fill_color(&self) -> Option<[u8; 4]> {
(self.brush.style == BS_SOLID).then_some(self.brush.color)
}
fn stroke_pen(&self) -> Option<([u8; 4], f64)> {
if (self.pen.style & PS_STYLE_MASK) == PS_NULL {
return None;
}
// A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline.
let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() };
Some((self.pen.color, w))
}
fn push_path_ops(&mut self, ops: impl IntoIterator<Item = Op>) {
for op in ops {
if self.path.len() >= MAX_PATH_OPS {
return;
}
self.path.push(op);
}
}
/// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to
/// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH.
pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) {
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
if self.in_path {
return self.push_path_ops(dev);
}
if self.nop {
return;
}
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops: dev, fill, stroke, even_odd });
}
pub fn move_to(&mut self, p: P) {
if self.in_path {
self.push_path_ops([Op::M(self.map.device(p))]);
}
self.pos = p;
}
/// LINETO from the current point, then moves the current point to `p`.
pub fn line_to(&mut self, p: P) {
self.line_chain(&[p], false, true);
}
/// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point.
pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) {
// Inside an open bracket the path already ends at the current point, so no MoveTo is needed.
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
ops.extend(pts.iter().map(|p| Op::L(*p)));
if let Some(last) = pts.last() {
self.pos = *last;
}
self.shape(&ops, fill, stroke);
}
/// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged.
pub fn bezier(&mut self, from: P, pts: &[P]) {
let mut ops = vec![Op::M(from)];
ops.extend(cubics(pts));
self.shape(&ops, false, true);
}
/// POLYBEZIERTO: cubic segments from the current point through groups of three control or end
/// points; the current point moves to the last point. Inside an open bracket the path already
/// ends at the current point, so no MoveTo is needed.
pub fn bezier_to(&mut self, pts: &[P]) {
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
ops.extend(cubics(pts));
if let Some(last) = pts.last() {
self.pos = *last;
}
self.shape(&ops, false, true);
}
pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) {
let Some(first) = pts.first() else { return };
let mut ops = vec![Op::M(*first)];
ops.extend(pts.iter().skip(1).map(|p| Op::L(*p)));
if close {
ops.push(Op::Z);
}
self.shape(&ops, fill, stroke);
}
pub fn begin_path(&mut self) {
self.in_path = true;
self.path.clear();
}
pub fn end_path(&mut self) {
self.in_path = false;
}
pub fn close_figure(&mut self) {
if self.in_path {
self.push_path_ops([Op::Z]);
}
}
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
let ops = std::mem::take(&mut self.path);
if self.nop {
return;
}
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops, fill, stroke, even_odd });
}
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid).
pub fn fill_brush_rect(&mut self, rect: [f64; 4]) {
if let Some(color) = self.fill_color() {
self.fill_rect_with(rect, color);
}
}
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) {
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true });
}
/// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS).
pub fn sourceless(rop: u32) -> bool {
matches!(rop, PATCOPY | BLACKNESS | WHITENESS)
}
/// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the
/// brush (when solid); the others fill black or white. Other operations are not handled here.
pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) {
let rect = [x, y, x + w, y + h];
match rop {
PATCOPY => self.fill_brush_rect(rect),
BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]),
WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]),
_ => {}
}
}
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
/// bitmap, as the mapping does.
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
let [x, y, w, h] = rect;
let a = self.map.device((x, y));
let b = self.map.device((x + w, y + h));
crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1);
let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
self.push(Raw::Bitmap { rect: dev, img });
}
}
/// Cubic segments from groups of three points (control, control, end). A trailing partial group is
/// dropped.
fn cubics(pts: &[P]) -> Vec<Op> {
let mut ops = Vec::with_capacity(pts.len() / 3);
let mut it = pts.iter().copied();
while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) {
ops.push(Op::C(a, b, e));
}
ops
}
+259
View File
@@ -0,0 +1,259 @@
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
use crate::{MAX_PIXELS, bytes::Bytes};
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
/// Most palette entries a DIB can carry (the colour table never exceeds 2^8).
const MAX_PALETTE: usize = 256;
const ROP_SRCAND: u32 = 0x0088_00C6;
const ROP_SRCPAINT: u32 = 0x00EE_0086;
const ROP_SRCINVERT: u32 = 0x0066_0046;
/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use.
pub(crate) struct Rgba {
pub w: usize,
pub h: usize,
pub bottom_up: bool,
pub px: Vec<u8>,
}
/// How a decoded bitmap combines with the destination.
#[derive(Clone, Copy)]
pub(crate) enum Blend {
/// A raster operation (BITBLT, STRETCHDIB and friends).
Rop(u32),
/// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set
/// (AC_SRC_ALPHA: the source colours are premultiplied by it).
Alpha { constant: u8, per_pixel: bool },
}
impl Blend {
/// Whether the decoder must keep the 32-bit alpha byte of each pixel.
pub fn per_pixel(self) -> bool {
matches!(self, Blend::Alpha { per_pixel: true, .. })
}
}
struct Header {
w: usize,
h: usize,
top_down: bool,
bpp: usize,
masks: [u32; 3],
pal_off: usize,
pal_n: usize,
}
impl Header {
fn read(info: Bytes) -> Option<Header> {
let size = info.u32(0)? as usize;
let w = info.i32(4)?;
let h = info.i32(8)?;
let bpp = info.u16(14)? as usize;
let comp = info.u32(16)?;
let clr_used = info.u32(32)? as usize;
if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) {
return None;
}
let masks = if comp == BI_BITFIELDS {
[info.u32(40)?, info.u32(44)?, info.u32(48)?]
} else if bpp == 16 {
[0x7C00, 0x03E0, 0x001F]
} else {
[0xFF_0000, 0xFF00, 0xFF]
};
let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 };
let pal_off = size.checked_add(masks_len)?;
// Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the
// entries clrUsed names (WMF DIBs can have them), still capped.
let pal_n = match (bpp <= 8, clr_used) {
(true, 0) => 1usize << bpp,
(true, n) => n.min(1usize << bpp),
(false, n) => n.min(MAX_PALETTE),
};
Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n })
}
}
/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it.
pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
let h = Header::read(dib)?;
h.pal_off.checked_add(h.pal_n.checked_mul(4)?)
}
/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
/// are opaque.
pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option<Rgba> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
if pixels == 0 || pixels > MAX_PIXELS {
return None;
}
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let mut px = vec![0u8; pixels * 4];
for y in 0..hd.h {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for x in 0..hd.w {
let p = pixel(data, row, x, &hd, pal, keep_alpha)?;
let o = (y * hd.w + x) * 4;
px.get_mut(o..o + 4)?.copy_from_slice(&p);
}
}
Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px })
}
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
};
Some([r, g, b, 255])
}
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
fn palette(pal: &[u8], i: usize) -> [u8; 3] {
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down)
/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted
/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the
/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part,
/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors).
pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let (iw, ih) = (img.w as i64, img.h as i64);
let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] };
// The request's top row in top-down order.
let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy };
let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw));
let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih));
if x1 <= x0 || r1 <= r0 {
return None;
}
let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64);
let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky];
let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize);
let (ow, oh) = (x1 - x0, r1 - r0);
let mut px = Vec::with_capacity(ow * oh * 4);
for row in r0..r1 {
let s = (row * img.w + x0) * 4;
px.extend_from_slice(img.px.get(s..s + ow * 4)?);
}
Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out))
}
/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so
/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves
/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other
/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a
/// per-pixel (premultiplied) source is made straight.
pub(crate) fn prepare(img: &mut Rgba, blend: Blend) {
match blend {
Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]),
Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]),
Blend::Rop(_) => {}
Blend::Alpha { constant, per_pixel } => {
for p in img.px.as_chunks_mut::<4>().0 {
if per_pixel {
unpremultiply(p);
}
p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8;
}
}
}
}
/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination.
fn clear_where(img: &mut Rgba, rgb: [u8; 3]) {
for p in img.px.as_chunks_mut::<4>().0 {
if p[..3] == rgb {
p[3] = 0;
}
}
}
/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the
/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes.
fn unpremultiply(p: &mut [u8; 4]) {
let a = u32::from(p[3]);
if a == 0 || a == 255 {
return;
}
for c in &mut p[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
}
}
/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a
/// short buffer flips only the rows it has.
pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) {
let (w, h) = (img.w, img.h);
if w == 0 || h == 0 {
return;
}
let row = w * 4;
if horizontal {
for line in img.px.chunks_exact_mut(row) {
for x in 0..w / 2 {
for c in 0..4 {
line.swap(x * 4 + c, (w - 1 - x) * 4 + c);
}
}
}
}
if vertical {
// Rows the buffer actually holds: a short buffer flips only those.
let n = h.min(img.px.len() / row);
for y in 0..n / 2 {
swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row);
}
}
}
/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range.
fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) {
let Some((head, tail)) = px.split_at_mut_checked(b) else { return };
if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) {
top.swap_with_slice(bottom);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
use crate::bytes::Bytes;
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::dib::{self, Blend};
use crate::geom::{self, ArcKind, P};
use crate::{Error, Frame, MAX_PATH_OPS};
const EMF_SIGNATURE: u32 = 0x464D_4520;
/// Hundredths of a millimetre to points.
const HMM_TO_PT: f64 = 72.0 / 2540.0;
/// SRCCOPY, the plain copy of a bitmap.
const SRCCOPY: u32 = 0x00CC_0020;
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
const PS_TYPE_MASK: u32 = 0x000F_0000;
const PS_COSMETIC: u32 = 0;
pub(crate) fn is_emf(b: Bytes) -> bool {
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
}
/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and
/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point).
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
if !is_emf(input) {
return Err(Error::BadHeader);
}
let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize;
let bounds = rect32(input, 8).ok_or(Error::BadHeader)?;
let [l, t, r, b] = bounds;
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
let mut cv = Canvas::new();
if let Some(px_mm) = device_px_per_mm(input) {
cv.set_device_mm(px_mm);
}
let mut parsed = 0usize;
let mut pos = hsize.max(88);
while !cv.full() {
let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break };
let size = size as usize;
if size < 8 || !size.is_multiple_of(4) {
break;
}
let Some(rec) = input.slice(pos, size) else { break };
if typ == 14 {
break;
}
record(&mut cv, typ, Bytes(rec));
parsed += 1;
let Some(next) = pos.checked_add(size) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
Ok((cv, frame))
}
/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80).
fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
let (dw, dh) = (input.i32(72)?, input.i32(76)?);
let (mw, mh) = (input.i32(80)?, input.i32(84)?);
(dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh)))
}
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
fn frame_from_header(input: Bytes) -> Option<Frame> {
let [fl, ft, fr, fb] = rect32(input, 24)?;
if fr <= fl || fb <= ft {
return None;
}
// Device units per hundredth of a millimetre.
let (px_mm_x, px_mm_y) = device_px_per_mm(input)?;
let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0);
let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy];
Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) })
}
fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
if cv.full() {
return;
}
let _ = match typ {
9 => xy32(r, 8).map(|p| cv.set_window_ext(p)),
10 => xy32(r, 8).map(|p| cv.set_window_org(p)),
11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)),
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
20 => r.u32(8).map(|m| cv.set_rop2(m)),
33 => {
cv.save_dc();
Some(())
}
34 => r.i32(8).map(|n| cv.restore_dc(n)),
35 => xform(r, 8).map(|x| cv.modify_world(x, 4)),
36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)),
37 => r.u32(8).map(|i| cv.select(i)),
38 => create_pen(cv, r),
95 => create_ext_pen(cv, r),
39 => create_brush(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
44 => {
let (cw, ch) = (r.i32(24), r.i32(28));
let (Some(cw), Some(ch)) = (cw, ch) else { return };
rect32(r, 8).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true);
})
}
45..=47 => arc32(r).map(|(bounds, s, e)| {
let kind = match typ {
45 => ArcKind::Arc,
46 => ArcKind::Chord,
_ => ArcKind::Pie,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)),
3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)),
6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)),
// POLYBEZIER starts its own figure at the first point and leaves the current point alone;
// POLYBEZIERTO continues from the current point.
2 | 85 => counted(r, typ == 2).and_then(|pts| {
let from = *pts.first()?;
cv.bezier(from, pts.get(1..)?);
Some(())
}),
5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)),
7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)),
8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)),
59 => {
cv.begin_path();
Some(())
}
60 => {
cv.end_path();
Some(())
}
61 => {
cv.close_figure();
Some(())
}
62 => {
cv.paint_path(true, false);
Some(())
}
63 => {
cv.paint_path(true, true);
Some(())
}
64 => {
cv.paint_path(false, true);
Some(())
}
76 | 77 => bitblt(cv, r, typ == 77),
80 => set_dibits(cv, r),
81 => stretch_dibits(cv, r),
114 => alphablend(cv, r),
_ => Some(()),
};
}
/// Two little-endian i32 values at `off`, as a point.
fn xy32(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
}
/// RECTL (left, top, right, bottom) as i32 values widened to f64.
fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> {
Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)])
}
/// An i32 field as f64; 0 past the end.
fn i32s(r: Bytes, off: usize) -> f64 {
r.i32(off).map_or(0.0, f64::from)
}
/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order.
fn xform(r: Bytes, off: usize) -> Option<Xform> {
let mut x = [0.0f64; 6];
for (i, v) in x.iter_mut().enumerate() {
*v = f64::from(r.f32(off + i * 4)?);
}
Some(x)
}
/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32.
fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> {
Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?))
}
/// A point count from a record, rejected when it exceeds the path budget (before any allocation).
fn budget(n: u32) -> Option<usize> {
let n = n as usize;
(n <= MAX_PATH_OPS).then_some(n)
}
/// Point count at 24, then the points (32-bit or 16-bit) at 28.
fn counted(r: Bytes, wide: bool) -> Option<Vec<P>> {
let n = budget(r.u32(24)?)?;
r.tail(28)?.points(0, n, wide)
}
/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points.
fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec<usize>, Vec<P>)> {
let n = budget(r.u32(24)?)?;
// The record must hold the n counts before anything is allocated for them.
let raw = r.slice(32, n.checked_mul(4)?)?;
let counts: Vec<usize> = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect();
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
let total = budget(u32::try_from(total).ok()?)?;
let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?;
Some((counts, pts))
}
/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour).
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let width = r.i32(16)?;
let color = r.u32(24)?;
cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) }));
Some(())
}
/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour).
/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws
/// no line at all.
fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?);
let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) };
let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK };
cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) }));
Some(())
}
/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let color = r.u32(16)?;
cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) }));
Some(())
}
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let rop = r.u32(40)?;
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) };
let src = [i32s(r, 44), i32s(r, 48), sw, sh];
blit(cv, r, dst, src, (84, 92), Blend::Rop(rop))
}
/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to
/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha.
fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let constant = *r.0.get(42)?;
let per_pixel = (*r.0.get(43)? & 0x01) != 0;
let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)];
blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel })
}
/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68,
/// dest w/h at 72.
fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)];
let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)];
let rop = r.u32(68)?;
blit(cv, r, dst, src, (48, 56), Blend::Rop(rop))
}
/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi
/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line
/// `iStartScan`, so the source y counts from there.
fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let (cx, cy) = (i32s(r, 40), i32s(r, 44));
if cx <= 0.0 || cy <= 0.0 {
return None;
}
let dst = [i32s(r, 24), i32s(r, 28), cx, cy];
let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy];
blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY))
}
/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both
/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a
/// non-positive source size means the whole bitmap. `blend` decides how it combines.
fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> {
let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize);
let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize);
if pl == 0 {
return None;
}
let info = Bytes(r.slice(bo, bl)?);
let pixels = Bytes(r.slice(po, pl)?);
let mut img = dib::decode(info, pixels, blend.per_pixel())?;
dib::prepare(&mut img, blend);
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
+118
View File
@@ -0,0 +1,118 @@
//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs
//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece).
use std::f64::consts::{FRAC_PI_2, PI, TAU};
pub(crate) type P = (f64, f64);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Op {
M(P),
L(P),
C(P, P, P),
Z,
}
pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z]
}
/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap.
pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec<Op> {
// Reversed corners (right < left, bottom < top) describe the same rectangle.
let (l, r) = (l.min(r), l.max(r));
let (t, b) = (t.min(b), t.max(b));
let rx = (ew / 2.0).abs().min((r - l) / 2.0);
let ry = (eh / 2.0).abs().min((b - t) / 2.0);
let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))];
ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((r, b - ry)));
ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2));
ops.push(Op::L((l + rx, b)));
ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((l, t + ry)));
ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2));
ops.push(Op::Z);
ops
}
pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let mut ops = vec![Op::M((cx + rx, cy))];
ops.extend(arc(cx, cy, rx, ry, 0.0, TAU));
ops.push(Op::Z);
ops
}
/// Which closed or open shape an ARC/CHORD/PIE record draws.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ArcKind {
Arc,
Chord,
Pie,
}
/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point.
pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE));
let (a0, a1) = (ang(s), ang(e));
let mut d = (a0 - a1).rem_euclid(TAU);
if d < 1e-12 {
d = TAU;
}
let sweep = -d;
let start = (cx + rx * a0.cos(), cy + ry * a0.sin());
let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] };
ops.extend(arc(cx, cy, rx, ry, a0, sweep));
match kind {
ArcKind::Arc => {}
ArcKind::Chord => ops.push(Op::Z),
ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]),
}
ops
}
fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) {
((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0)
}
/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point
/// must already be at `(cx + rx cos a0, cy + ry sin a0)`.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec<Op> {
let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8);
let step = sweep / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin());
let mut ops = Vec::with_capacity(n);
for i in 0..n {
let t0 = a0 + step * i as f64;
let t1 = t0 + step;
let (p0, p3) = (pt(t0), pt(t1));
let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos());
let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos());
ops.push(Op::C(c1, c2, p3));
}
ops
}
/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the
/// point list are cut short rather than failing the whole record.
pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
let mut ops = Vec::with_capacity(pts.len() + counts.len());
let mut at = 0usize;
for &c in counts {
let end = at.saturating_add(c).min(pts.len());
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
at = end;
continue;
};
ops.push(Op::M(*first));
ops.extend(rest.iter().map(|p| Op::L(*p)));
if close {
ops.push(Op::Z);
}
at = end;
}
ops
}
+208
View File
@@ -0,0 +1,208 @@
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA
//! bitmaps, in a picture frame with y down.
//!
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
//! never a panic.
//!
//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bytes;
mod canvas;
mod dib;
mod emf;
mod geom;
mod place;
mod wmf;
#[cfg(test)]
mod tests;
use bytes::Bytes;
use canvas::Raw;
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
/// Object table slots (WMF handles, EMF object indices).
pub(crate) const MAX_OBJECTS: usize = 4096;
/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA).
pub(crate) const MAX_PIXELS: usize = 40_000_000;
/// DC save-stack depth.
pub(crate) const MAX_STACK: usize = 1024;
/// Points in one open path bracket.
pub(crate) const MAX_PATH_OPS: usize = 2_000_000;
/// Bytes of path ops and pixels kept from one file (about 256 MB).
pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024;
/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
/// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device
/// or logical units otherwise.
pub width: f32,
pub height: f32,
pub items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Item {
/// A filled and/or stroked path, in picture coordinates.
Path {
segs: Vec<Seg>,
fill: Option<[u8; 4]>,
/// Colour and width in picture units.
stroke: Option<([u8; 4], f32)>,
even_odd: bool,
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Seg {
Move(f32, f32),
Line(f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum Error {
#[error("not a WMF or EMF file")]
NotMetafile,
#[error("metafile header is truncated or invalid")]
BadHeader,
#[error("metafile has no readable records")]
NoRecords,
}
/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its
/// size in picture units.
pub(crate) struct Frame {
pub rect: [f64; 4],
pub size: Option<(f64, f64)>,
}
/// Parses a WMF (placeable or plain) or EMF file.
pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
let b = Bytes(bytes);
let (cv, frame) = if emf::is_emf(b) {
emf::parse(b)?
} else if wmf::is_wmf(b) {
wmf::parse(b)?
} else {
return Err(Error::NotMetafile);
};
Ok(build(cv.items, frame))
}
/// True when the bytes start like a WMF (placeable or plain header) or an EMF.
pub fn is_metafile(bytes: &[u8]) -> bool {
let b = Bytes(bytes);
emf::is_emf(b) || wmf::is_wmf(b)
}
/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding
/// box of the drawing, so the origin is still the top-left of what was drawn.
fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0);
let (rect, size) = match frame {
Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))),
None => match bbox(&raw) {
Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)),
None => return Picture { width: 0.0, height: 0.0, items: Vec::new() },
},
};
let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 };
let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 };
let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy);
let stroke_scale = (sx + sy) / 2.0;
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
let diag = size.0.hypot(size.1);
let items = raw.into_iter().filter_map(|r| match r {
Raw::Path { ops, fill, stroke, even_odd } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
Some(Item::Path { segs, fill, stroke, even_odd })
}
Raw::Bitmap { rect: r, img } => {
let (x, y) = tf((r[0], r[1]));
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
}
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
}
fn f(v: f64) -> Option<f32> {
let v = v as f32;
v.is_finite().then_some(v)
}
fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option<Seg> {
let pt = |p: P| {
let (x, y) = tf(p);
Some((f(x)?, f(y)?))
};
Some(match *op {
Op::M(p) => {
let (x, y) = pt(p)?;
Seg::Move(x, y)
}
Op::L(p) => {
let (x, y) = pt(p)?;
Seg::Line(x, y)
}
Op::C(a, b, c) => {
let (x1, y1) = pt(a)?;
let (x2, y2) = pt(b)?;
let (x3, y3) = pt(c)?;
Seg::Cubic(x1, y1, x2, y2, x3, y3)
}
Op::Z => Seg::Close,
})
}
/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output.
fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
let mut b: Option<[f64; 4]> = None;
let mut grow = |p: P| {
if p.0.is_finite() && p.1.is_finite() {
b = Some(match b {
None => [p.0, p.1, p.0, p.1],
Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)],
});
}
};
for r in raw {
match r {
Raw::Path { ops, .. } => {
for op in ops {
match *op {
Op::M(p) | Op::L(p) => grow(p),
Op::C(a, c, d) => {
grow(a);
grow(c);
grow(d);
}
Op::Z => {}
}
}
}
Raw::Bitmap { rect, .. } => {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
}
}
b
}
+124
View File
@@ -0,0 +1,124 @@
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
use kurbo::{Affine, BezPath, Rect, Shape};
use crate::{Item, Picture, Seg};
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
pub const MAX_BITMAP_SIDE: u32 = 30_000;
/// Placed coordinates past this (target units) are far off any page; such items are left out.
const MAX_COORD: f64 = 1e6;
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
const MIN_BITMAP_SIDE: f64 = 1e-6;
/// One item of a placed picture, in target units.
pub enum PlacedItem {
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
/// the renderer draws at its minimum width.
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect },
}
impl Picture {
/// Whether the picture has a finite, positive size, so it can be fitted into a frame.
pub fn has_size(&self) -> bool {
self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0
}
/// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right,
/// bottom) removed first: the visible part fills `r`. None for a degenerate size.
pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option<Affine> {
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 });
let (w, h) = (f64::from(self.width), f64::from(self.height));
let sx = r.width() / (w * (1.0 - cl - cr).max(0.05));
let sy = r.height() / (h * (1.0 - ct - cb).max(0.05));
(sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0)
.then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy))
}
/// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6
/// target units are left out (they are far off any page), as are bitmaps with no area. Stroke
/// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps
/// strokes even).
pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec<PlacedItem> {
let Some(tf) = self.fit(r, crop) else { return Vec::new() };
let c = tf.as_coeffs();
let k = (c[0].abs() + c[3].abs()) / 2.0;
let mut out = Vec::with_capacity(self.items.len());
for (index, item) in self.items.iter().enumerate() {
match item {
Item::Path { segs, fill, stroke, even_odd } => {
let path = tf * bez(segs);
if in_range(path.bounding_box()) {
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
}
}
Item::Bitmap { rect: [x, y, w, h], .. } => {
let (x0, y0) = (f64::from(*x), f64::from(*y));
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
out.push(PlacedItem::Bitmap { index, rect });
}
}
}
}
out
}
}
impl Item {
/// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height,
/// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many
/// pixels. None for paths and for bitmaps that cannot be drawn.
pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let Item::Bitmap { width, height, rgba, .. } = self else { return None };
let (w, h, px) = (*width, *height, std::mem::take(rgba));
let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h);
(sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px))
}
}
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
fn bez(segs: &[Seg]) -> BezPath {
let mut p = BezPath::new();
let mut start = (0.0, 0.0);
let mut open = false;
for s in segs {
match *s {
Seg::Move(x, y) => {
start = (f64::from(x), f64::from(y));
p.move_to(start);
open = true;
}
Seg::Line(x, y) => {
if !open {
p.move_to(start);
}
p.line_to((f64::from(x), f64::from(y)));
open = true;
}
Seg::Cubic(a, b, c, d, e, f) => {
if !open {
p.move_to(start);
}
p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f)));
open = true;
}
Seg::Close => {
if open {
p.close_path();
}
open = false;
}
}
}
p
}
/// Whether a placed box is finite and within ±[`MAX_COORD`].
fn in_range(b: Rect) -> bool {
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD)
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
use crate::bytes::Bytes;
use crate::canvas::{Brush, Canvas, Obj, Pen, colorref};
use crate::dib;
use crate::geom::{self, ArcKind, P};
use crate::{Error, Frame, MAX_PATH_OPS};
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const DEFAULT_INCH: f64 = 1440.0;
pub(crate) fn is_wmf(b: Bytes) -> bool {
if b.u32(0) == Some(PLACEABLE_KEY) {
return true;
}
matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300))
}
/// Reads all records. Returns the drawing state and the picture frame, or an error when no record
/// could be read at all.
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let placeable = input.u32(0) == Some(PLACEABLE_KEY);
// Placeable bounds: logical corners (left, top, right, bottom) and their size in points.
let (hdr, bounds) = if placeable {
let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from));
let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) };
let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
let (w, h) = (r - l, b - t);
let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch)));
(22, bounds)
} else {
(0, None)
};
let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize;
if hsize < 9 {
return Err(Error::BadHeader);
}
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
// Played into a target like Word does: the placeable bounds are the first window, the target (the
// picture size) the viewport. A plain WMF's target is its first window.
let mut cv = Canvas::new();
if let Some((c, size)) = bounds {
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
}
let mut player_viewport = bounds.is_some();
let mut parsed = 0usize;
while !cv.full() {
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break };
let Some(rec) = input.slice(pos, len) else { break };
if func == 0 {
break;
}
record(&mut cv, func, Bytes(rec));
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
let we = cv.map.window_ext();
cv.play_into(None, (we.0.abs(), we.1.abs()));
player_viewport = true;
}
parsed += 1;
let Some(next) = pos.checked_add(len) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
let frame = match bounds {
// Played into its target: the frame is the target.
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
// The file set its own viewport, so the drawing is in its device units and the logical bounds
// go through the final mapping.
Some((c, size)) => {
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
}
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
let ve = cv.map.viewport_ext();
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
}
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
None => None,
};
Ok((cv, frame))
}
fn record(cv: &mut Canvas, func: u16, r: Bytes) {
if cv.full() {
return;
}
let _ = match func {
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
// Y is stored before X in these records.
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)),
0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)),
0x001E => {
cv.save_dc();
Some(())
}
0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))),
0x0214 => xy16(r, 6).map(|p| cv.move_to(p)),
0x0213 => xy16(r, 6).map(|p| cv.line_to(p)),
0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)),
0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)),
0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)),
0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
0x061C => {
let [ew, eh] = [r.i16(8), r.i16(6)];
let (Some(ew), Some(eh)) = (ew, eh) else { return };
rect16(r, 10).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
})
}
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
let kind = match func {
0x0817 => ArcKind::Arc,
0x081A => ArcKind::Pie,
_ => ArcKind::Chord,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
0x02FA => create_pen(cv, r),
0x02FC => create_brush(cv, r),
// Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT
// handles count them; selecting one draws nothing.
0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => {
cv.add_object(None, Obj::Other);
Some(())
}
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
0x0F43 => {
let rop = r.u32(6).unwrap_or(0);
dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)])
}
0x0B41 => dib_stretchblt(cv, r),
0x0940 => dib_bitblt(cv, r),
_ => Some(()),
};
}
/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the
/// canvas reads with the 32-bit stock flag.
fn wmf_handle(h: u16) -> u32 {
let h = u32::from(h);
if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h }
}
/// An i16 field as f64; 0 past the end.
fn i16s(r: Bytes, off: usize) -> f64 {
r.i16(off).map_or(0.0, f64::from)
}
/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used.
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = r.u16(6)?;
let width = r.i16(8)?;
let color = r.u32(12)?;
cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) }));
Some(())
}
/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = r.u16(6)?;
let color = r.u32(8)?;
cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) }));
Some(())
}
/// (x, y) from a Y-then-X pair stored at `off`.
fn xy16(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?)))
}
/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE).
fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> {
let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?);
Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)])
}
/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left).
fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> {
let end = xy16(r, 6)?;
let start = xy16(r, 10)?;
let bounds = rect16(r, 14)?;
Some((bounds, start, end))
}
/// A count of 16-bit points, then the points (POLYLINE, POLYGON).
fn counted_points(r: Bytes) -> Option<Vec<P>> {
let n = r.u16(6)? as usize;
r.tail(8)?.points(0, n, false)
}
/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points.
fn polypolygon(r: Bytes) -> Option<Vec<geom::Op>> {
let n = r.u16(6)? as usize;
let mut counts = Vec::with_capacity(n);
for i in 0..n {
counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize);
}
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
if total > MAX_PATH_OPS {
return None;
}
let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?;
Some(geom::subpaths(&pts, &counts, true))
}
/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly
/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3).
fn has_bitmap(func: u16, r: Bytes) -> bool {
r.0.len() / 2 > usize::from(func >> 8) + 3
}
/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The
/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or
/// a source-less raster operation, fills the destination.
fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0940, r) {
cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop);
return Some(());
}
let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]];
dib_blit(cv, r, 22, rop, dst, src)
}
/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at
/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later;
/// it, or a source-less raster operation, fills the destination.
fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0B41, r) {
cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop);
return Some(());
}
let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)];
dib_blit(cv, r, 26, rop, dst, src)
}
/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`).
/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]).
fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> {
let dib = r.tail(at)?;
let n = dib::info_len(dib)?;
let info = dib.slice(0, n)?;
let bits = dib.tail(n)?;
let mut img = dib::decode(Bytes(info), bits, false)?;
dib::prepare(&mut img, dib::Blend::Rop(rop));
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
+21 -6
View File
@@ -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 {
+23
View File
@@ -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();
+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")?;
+9 -9
View File
@@ -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" }))
+23
View File
@@ -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)));
}
+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);
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
deckcraft-geom = { workspace = true }
deckcraft-color = { workspace = true }
deckcraft-metafile = { workspace = true }
deckcraft-model = { workspace = true }
deckcraft-fonts = { workspace = true }
deckcraft-text = { workspace = true }
+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);
}
}
+12 -3
View File
@@ -99,11 +99,20 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
}
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at
/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA.
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
decode_at(bytes, crate::metafile::DEFAULT_SIDE)
}
/// [`decode`], with a metafile drawn with its longer side `side` px.
fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
if bytes.len() > 512 << 20 {
return None;
}
if crate::metafile::is_metafile(bytes) {
return crate::metafile::rasterize(bytes, side);
}
let img = image::load_from_memory(bytes).ok()?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 {
@@ -131,13 +140,13 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
}) {
return Some(pm);
}
let Some(mut img) = decode(bytes) else {
let target = 1u32 << bucket;
let Some(mut img) = decode_at(bytes, target) else {
with(|c| {
c.bad.insert(addr, bytes.clone());
});
return None;
};
let target = 1u32 << bucket;
let (w, h) = img.dimensions();
if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) {
let k = target as f64 / w.max(h) as f64;
+71 -7
View File
@@ -11,7 +11,9 @@ mod chart;
mod colr;
mod compound;
mod extent;
mod images;
mod metafile;
mod morph_path;
mod morph_text;
mod paint;
@@ -39,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;
@@ -232,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 }
}
}
@@ -284,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)
}
}
}
@@ -471,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)
}
@@ -657,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 {
@@ -691,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.
@@ -716,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);
@@ -817,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),
+105
View File
@@ -0,0 +1,105 @@
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
//! through the same crop, tile and recolour path as any other picture.
use std::sync::Arc;
use deckcraft_metafile::PlacedItem;
use kurbo::{Affine, Rect, Shape as _};
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
pub const DEFAULT_SIDE: u32 = 1024;
/// Largest side drawn.
const MAX_SIDE: u32 = 8192;
/// Is `bytes` a WMF or EMF file?
pub fn is_metafile(bytes: &[u8]) -> bool {
deckcraft_metafile::is_metafile(bytes)
}
/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where
/// it draws nothing). `None` when it doesn't parse or has no size.
pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
let mut pic = deckcraft_metafile::parse(bytes).ok()?;
if !pic.has_size() {
return None;
}
let side = side.clamp(16, MAX_SIDE) as f64;
let (pw, ph) = (pic.width as f64, pic.height as f64);
let k = side / pw.max(ph);
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
ctx.push_clip_layer(&r.to_path(0.1));
for it in pic.place(r, [0.0; 4]) {
match it {
PlacedItem::Path { path, fill, stroke, even_odd } => {
if let Some(f) = fill {
ctx.set_paint(rgba(f));
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&path);
}
// GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel.
if let Some((s, sw)) = stroke {
ctx.set_paint(rgba(s));
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
PlacedItem::Bitmap { index, rect } => {
let Some(Some(pm)) = pixmaps.get(index) else { continue };
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.pop_layer();
ctx.flush();
let mut px = vec![0u8; w as usize * h as usize * 4];
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) {
ctx.render(pm, &mut Resources::new());
}
// Premultiplied to straight.
for p in px.as_chunks_mut::<4>().0 {
let a = p[3] as u32;
if a != 0 && a != 255 {
for c in p.iter_mut().take(3) {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
image::RgbaImage::from_raw(w as u32, h as u32, px)
}
fn rgba(c: [u8; 4]) -> peniko::Color {
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
}
/// Straight RGBA rows to a premultiplied pixmap.
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
return None;
}
let data = px
.as_chunks::<4>()
.0
.iter()
.take(w as usize * h as usize)
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
}
+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
}
+194 -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]
@@ -1075,3 +1076,195 @@ fn every_warp_preset_renders_text_inside_the_slide() {
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
}
}
/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the
/// left half, nothing on the right.
fn half_red_wmf() -> Vec<u8> {
fn rec(func: u16, params: &[i16]) -> Vec<u8> {
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
v.extend(func.to_le_bytes());
for p in params {
v.extend(p.to_le_bytes());
}
v
}
// CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom,
// right, top, left).
let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat();
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
f.extend(0u16.to_le_bytes());
for v in [0i16, 0, 2880, 1440] {
f.extend(v.to_le_bytes());
}
f.extend(1440u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let total = (f.len() + 18 + body.len()) / 2;
for v in [1u16, 9, 0x300] {
f.extend(v.to_le_bytes());
}
f.extend((total as u32).to_le_bytes());
f.extend(16u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(body);
f
}
#[test]
fn metafile_pictures_draw_their_vectors() {
let mut p = Presentation::default();
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf());
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
for scale in [0.5, 1.0, 3.0] {
let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() });
let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32);
let left = at(200.0, 200.0);
assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}");
let right = at(400.0, 200.0);
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}");
}
}
#[test]
fn broken_metafiles_draw_nothing_and_never_panic() {
let wmf = half_red_wmf();
let mut cases: Vec<Vec<u8>> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect();
cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
let mut huge = wmf.clone();
huge[10] = 0xFF;
huge[11] = 0x7F;
cases.push(huge);
for bytes in cases {
let mut p = Presentation::default();
let media = p.add_media("image1.emf", "image/x-emf", bytes);
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
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));
}
+27
View File
@@ -1831,6 +1831,33 @@ impl TextLayout {
}
out
}
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
/// in place). A composition that wraps gets one underline per line.
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
let (s, e) = if a <= b { (a, b) } else { (b, a) };
let mut out = vec![];
if s == e {
return out;
}
for l in &self.lines {
let lp = Pos { para: l.para, ch: l.start };
let le = Pos { para: l.para, ch: l.end };
if le <= s || lp >= e {
continue;
}
let from = if s > lp { s.ch } else { l.start };
let to = if e < le { e.ch } else { l.end };
// Under the baseline, in the descent, never below the line.
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
for (x0, x1) in l.spans(from, to) {
if x1 > x0 {
out.push((x0, x1, y));
}
}
}
out
}
/// Vertical caret movement: the position on the line above/below closest to `x`.
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
let Some(li) = self.line_of(pos) else { return pos };
+30
View File
@@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() {
assert_eq!(r.len(), 1);
}
/// #101: the text an input method is composing is underlined in place, under the baseline of each
/// line it occupies, and only over the composed characters.
#[test]
fn composition_is_underlined_per_line() {
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
let l = lay(&p, &sh, 600.0, 100.0);
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
let u = l.composition_underlines(a, b);
assert_eq!(u.len(), 1, "{u:?}");
let (x0, x1, y) = u[0];
let (cx0, ..) = l.caret(a).unwrap();
let (cx1, ..) = l.caret(b).unwrap();
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
let line = &l.lines[0];
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
// Reversed ends give the same; an empty composition, nothing.
assert_eq!(l.composition_underlines(b, a), u);
assert!(l.composition_underlines(a, a).is_empty());
// Wrapped: one underline per line.
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
let l = lay(&p, &sh, 120.0, 400.0);
assert!(l.lines.len() >= 2);
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
assert!(u.len() >= 2, "{u:?}");
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
// Positions past the text never panic.
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
}
#[test]
fn alignment_center_and_right() {
let (p, mut sh) = setup("Hi", 400.0, 100.0);
+88 -11
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);
@@ -152,11 +164,69 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
ime_output(app, ui, xf);
media_bar(app, ui, xf);
gif_buttons(app, ui, xf, &gifs);
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,
}
}
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
/// nothing to type into.
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
let st = app.session.active()?;
if let Some(ts) = &st.selection.text {
if ts.notes {
return None;
}
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
}
let sh = st.shape(ts.shape)?;
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
}
if st.selection.shapes.len() != 1 {
return None;
}
let sh = st.shape(*st.selection.shapes.first()?)?;
if sh.is_line() {
return None;
}
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
}
/// Turn the input method on for the slide while it takes typing, with its candidate window at
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
let r = to_global * r;
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
}
/// 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 +310,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) {
@@ -422,12 +495,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
}
// An input method's composition: underlined in place, thicker under the part being converted.
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
}
if !ts.is_range() {
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
// An empty placeholder shows the caret where typing begins too.
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
}
}
}
+17
View File
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
let Some(t) = s("text") else { return err("missing `text`") };
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
}
"ui.ime" => {
// An input method's event, through egui like a real one: it reaches the slide's text
// or a focused text field (#101).
let ev = if let Some(t) = s("commit") {
egui::ImeEvent::Commit(t.into())
} else if let Some(t) = s("preedit") {
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
(x <= y).then_some(x..y)
});
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
} else {
return err("missing `preedit` or `commit`");
};
app.synthetic.push(egui::Event::Ime(ev));
ok(json!({"queued": true}))
}
"ui.tool.select" => {
let Some(name) = s("tool") else { return err("missing `tool`") };
match tool_kind(name, s("preset")) {
+217
View File
@@ -0,0 +1,217 @@
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
//! real glyphs rather than boxes.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use egui::{Event, FontId, ImeEvent};
use serde_json::json;
use crate::{Services, SlideApp};
use deckcraft_engine::Session;
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, Services::default()),
);
// The app keeps requesting repaints, so step frames instead of run().
h.run_steps(3);
h
}
fn preedit(text: &str) -> Event {
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
}
fn title_text(app: &SlideApp) -> String {
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
fn undo_len(app: &SlideApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
/// is on while the slide's text is edited, with its window at the insertion point.
#[test]
fn korean_composes_in_a_slide_text_box() {
let mut h = harness(Session::with_new());
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.run_steps(2);
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
let slide = h.state().slide_rect.unwrap();
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
let caret0 = ime.rect;
let undo0 = undo_len(h.state());
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
let st = h.state().session.active().unwrap();
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
}
h.run_steps(1);
let ime = h.output().platform_output.ime.unwrap();
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert!(h.state().session.active().unwrap().composition.is_none());
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
// Escape cancels a composition: an empty preedit, and the text is as it was.
h.event(preedit("ㅁ"));
h.event(preedit(""));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert_eq!(undo_len(h.state()), undo0 + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(1);
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
}
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
/// composition the input method left open.
#[test]
fn japanese_conversion_shows_the_active_part() {
let mut h = harness(Session::with_new());
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
h.run_steps(2);
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
let undo0 = undo_len(h.state());
h.key_press(egui::Key::End);
h.run_steps(1);
assert_eq!(title_text(h.state()), "日本ご");
assert!(h.state().session.active().unwrap().composition.is_none());
assert_eq!(undo_len(h.state()), undo0 + 1);
}
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
/// the input method is on for it.
#[test]
fn composing_on_a_selected_shape_starts_editing_it() {
let mut h = harness(Session::with_new());
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
let id = r["id"].as_u64().unwrap();
h.state_mut().run("text.exit", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().session.active().unwrap().selection.text.is_none());
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
h.event(preedit("新"));
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
h.run_steps(2);
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(v["text"], "新しい");
}
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
#[test]
fn korean_composes_in_the_notes_pane() {
let mut h = harness(Session::with_new());
let id = egui::Id::new("notes_edit");
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
assert!(h.ctx.memory(|m| m.has_focus(id)));
let undo0 = undo_len(h.state());
for pre in ["ㄴ", "노", "놑", "노트"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
}
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
let slide = h.state().slide_rect.unwrap();
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, "노트");
// Focus leaves the field (Escape): the notes are committed as one step.
h.key_press(egui::Key::Escape);
h.run_steps(2);
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
assert_eq!(notes, "노트");
assert_eq!(undo_len(h.state()), undo0 + 1);
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
// Composing, then going to another slide: the notes stay with the slide they were typed for.
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
h.run_steps(2);
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.event(preedit("ㅇ"));
h.event(Event::Ime(ImeEvent::Commit("요".into())));
h.run_steps(1);
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
h.run_steps(2);
let d = h.state().session.active().unwrap();
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
assert_eq!(d.doc.slides[1].notes_text(), "");
}
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
fn system_has(s: &str) -> bool {
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
}
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
#[test]
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
let h = harness(Session::with_new());
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
let mut s = Session::with_new();
let korean = "한국어 발표 자료";
let japanese = "日本語のスライド";
let chinese = "简体中文演示文稿";
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
s.execute("section.add", &json!({"name": chinese})).unwrap();
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
let mut h = harness(s);
h.state_mut().run("view.outline", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().session.active().unwrap().title().contains(korean));
let mut checked = 0;
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
if !system_has(text) {
eprintln!("skipped {text:?}: no font on this machine draws it");
continue;
}
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
}
checked += 1;
}
if cfg!(windows) || cfg!(target_os = "macos") {
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
}
// Text typed later (a dialog field, a new comment) is looked at too.
let typed = "ไทย";
h.event(Event::Text(typed.into()));
h.run_steps(2);
if system_has(typed) {
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
}
}
+204 -1
View File
@@ -22,6 +22,8 @@ pub mod status;
pub mod textures;
pub mod theme;
pub mod thumbs;
pub mod toast;
pub mod uifonts;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
@@ -181,6 +183,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 +194,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,
@@ -207,6 +215,8 @@ pub struct SlideApp {
/// Quit was confirmed (changes saved or discarded): let the window close.
pub quit_confirmed: bool,
pub(crate) notes_buf: (u64, usize, String),
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
pub ui_fonts: uifonts::UiFonts,
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
}
@@ -224,11 +234,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,
@@ -243,6 +256,7 @@ impl SlideApp {
last_recovery: 0.0,
quit_confirmed: false,
notes_buf: (0, usize::MAX, String::new()),
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
}
}
@@ -535,7 +549,7 @@ impl SlideApp {
ctx.set_zoom_factor(scale);
}
if !self.styled {
theme::install_fonts(ctx);
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
self.styled = true;
self.restyle = true;
} else {
@@ -558,6 +572,9 @@ impl SlideApp {
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
if self.fonts_ready {
self.note_ui_text(ctx);
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
// Closing the window with unsaved changes asks first.
@@ -589,6 +606,52 @@ impl SlideApp {
}
}
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
/// presentations' names and text, recent files, and what is typed or composed this frame.
fn note_ui_text(&mut self, ctx: &egui::Context) {
let typed: String = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
_ => None,
})
.collect()
});
self.ui_fonts.note(ctx, &typed);
for d in self.session.documents() {
self.ui_fonts.note(ctx, &d.title());
}
for r in &self.ui.recent {
self.ui_fonts.note(ctx, r);
}
if let Some(st) = self.session.active() {
self.ui_fonts.note_doc(ctx, st);
}
self.ui_fonts.apply(ctx);
}
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
/// replaced on each update; the commit replaces it as one undo step.
fn ime_event(&mut self, e: &egui::ImeEvent) {
let r = match e {
egui::ImeEvent::Preedit { text, active_range_chars } => {
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
}
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
#[allow(deprecated)]
egui::ImeEvent::Disabled => {
self.session.end_composition();
Ok(Value::Null)
}
_ => Ok(Value::Null),
};
if let Err(e) = r {
self.set_status(e.to_string());
}
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@@ -636,6 +699,7 @@ impl SlideApp {
self.set_status(e.to_string());
}
}
egui::Event::Ime(ime) => self.ime_event(&ime),
egui::Event::Copy => {
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
@@ -730,6 +794,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) {
@@ -877,6 +943,9 @@ fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
@@ -889,6 +958,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 +1038,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");
}
}
+11 -4
View File
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
ui.painter()
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
// Keep a local buffer per slide; commit when it changes and the field loses focus.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
app.notes_buf = (uid, idx, current.clone());
}
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if !focused && app.notes_buf.2 != current {
// Focus can leave in the frame before the field is drawn (Escape, a click on a
// thumbnail): what was typed is still committed, to the slide it was typed for.
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
}
}
if !focused && !was_focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
+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);
+30 -1
View File
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
pub fn install_fonts(ctx: &egui::Context) {
install_fonts_with(ctx, &[]);
}
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
ctx.set_fonts(font_definitions(extra));
}
/// The interface font definitions; see [`install_fonts_with`].
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
let mut defs = FontDefinitions::default();
let mut names = vec![];
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
@@ -178,7 +190,24 @@ pub fn install_fonts(ctx: &egui::Context) {
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
ctx.set_fonts(defs);
add_fallback_faces(&mut defs, extra);
defs
}
/// Add `faces` after every other font of each interface family (the last fallbacks).
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
if defs.font_data.contains_key(&name) {
continue;
}
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
for list in defs.families.values_mut() {
list.push(name.clone());
}
}
}
pub fn bold(size: f32) -> egui::FontId {
+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"));
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
//! character on screen that they lack, a face that has it is borrowed from the font database (the
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
//! there are no installed fonts, only the embedded faces are used.
//!
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
//! the composition and caption to every text the interface draws from the presentation.
use std::collections::HashSet;
use std::sync::Arc;
use deckcraft_fonts::{FontDb, FontFace};
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
const MAX_FACE_BYTES: usize = 48 << 20;
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
/// Faces added after the interface fonts, in the order they were found.
pub faces: Vec<Arc<FontFace>>,
/// Characters already looked at (drawn, given a face, or found in no face).
seen: HashSet<char>,
/// The presentation last looked through: (uid, revision).
doc: Option<(u64, u64)>,
/// Faces were added since the fonts were last installed.
changed: bool,
}
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
fn wanted(c: char) -> bool {
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
}
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
/// very large faces are skipped (egui draws neither well).
pub fn faces_for_text(
text: &str,
have: &[Arc<FontFace>],
seen: &mut HashSet<char>,
mut covered: impl FnMut(char) -> bool,
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
) -> Vec<Arc<FontFace>> {
let mut out: Vec<Arc<FontFace>> = Vec::new();
for c in text.chars().filter(|c| wanted(*c)) {
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
continue;
}
let Some(f) = lookup(c) else { continue };
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
continue;
}
out.push(f);
}
out
}
impl UiFonts {
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
return;
}
let font = egui::FontId::proportional(13.0);
let new = ctx.fonts_mut(|f| {
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
self.faces.extend(new);
self.changed = true;
}
}
/// Look through the text of a presentation the interface shows (once per revision): its
/// title, slide text and names, notes, comments, sections and layout names.
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
if self.doc == Some((st.uid, st.revision)) {
return;
}
self.doc = Some((st.uid, st.revision));
let text = ui_text(st);
self.note(ctx, &text);
}
/// Install the interface fonts again when faces were added. True if they were.
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
if !std::mem::take(&mut self.changed) {
return false;
}
crate::theme::install_fonts_with(ctx, &self.faces);
ctx.request_repaint();
true
}
}
/// The text of a presentation that the interface can show (not the slides' pictures of it).
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
let mut out = st.title();
let mut add = |s: &str| {
if s.chars().any(wanted) {
out.push('\n');
out.push_str(s);
}
};
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
for p in &b.paragraphs {
for r in &p.runs {
add(&r.text);
}
}
};
let doc = &st.doc;
add(&doc.props.title);
for sec in &doc.sections {
add(&sec.name);
}
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
deckcraft_model::walk(list, &mut |sh, _| {
add(&sh.name);
add(&sh.title);
add(&sh.descr);
if let Some(t) = &sh.text {
body(t, add);
}
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
for row in &tb.rows {
for cell in &row.cells {
body(&cell.text, add);
}
}
}
});
};
for s in &doc.slides {
add(&s.name);
shapes(&s.shapes, &mut add);
body(&s.notes, &mut add);
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
while let Some(c) = stack.pop() {
add(&c.author);
add(&c.text);
stack.extend(c.replies.iter());
}
}
for m in &doc.masters {
add(&m.name);
for l in &m.layouts {
add(&l.name);
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn face(family: &str) -> Arc<FontFace> {
FontDb::global().face(family, "Regular")
}
/// One face for a whole string, looked up once per character, never for characters the
/// interface already draws, and never twice.
#[test]
fn one_face_per_script_and_each_character_once() {
let inter = face("Inter");
let looked = std::cell::Cell::new(0);
let lookup = |_: char| {
looked.set(looked.get() + 1);
Some(inter.clone())
};
let mut seen = HashSet::new();
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
assert_eq!(got.len(), 1, "one face, not one per character");
// Inter doesn't cover Hangul, so every character was looked up once.
assert_eq!(looked.get(), 5);
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
assert_eq!(looked.get(), 5);
let mut seen = HashSet::new();
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
}
#[test]
fn interface_text_of_a_presentation() {
let mut s = deckcraft_engine::Session::with_new();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
let t = ui_text(s.doc().unwrap());
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
assert!(t.starts_with(&s.doc().unwrap().title()));
}
}
+1
View File
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
+2 -2
View File
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|---|---|---|---|
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped |
| HEIC | yes (macOS) | no | |
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
| WMA | yes (Windows) | embedded, not playable | |
@@ -93,7 +93,7 @@ the round trip even though DeckCraft doesn't model it.
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
2. The round-trip losses above (#70, #74, #77).
3. EMF/WMF and SVG rendering.
3. SVG rendering, and text inside EMF/WMF.
## Revision history
+15 -10
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -20,7 +20,7 @@ revision history.
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
| 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
@@ -75,14 +75,14 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Impact:** a deck opened and saved silently loses review state and show behaviour.
- **Estimate:** 3–6 h. **Beta blocker.**
### EMF/WMF and SVG pictures don't render
### SVG pictures don't render
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
TIFF, BMP); nothing reads EMF, WMF or SVG. Decks from Windows often hold EMF (pasted charts,
Visio, logos); PowerPoint's Insert Icons stores SVG with a PNG fallback.
- **Impact:** blank pictures in real decks; SVG icons show only their raster fallback, and SVG files
can't be inserted.
- **Estimate:** 8–14 h (an SVG renderer through `resvg`-style crates, an EMF/WMF record player for
the common records). **Beta blocker** for EMF.
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens
and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG
with a PNG fallback.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text
drawn inside an EMF/WMF is missing.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h.
### Template, slide-show and macro variants are saved as plain `.pptx`
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
@@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md).
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
- **No IME composition in text editing** (CJK input). 3–6 h.
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
@@ -251,6 +255,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
| Pinch to zoom, two-finger gestures | yes | no |
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
| Touch screens (Windows) | yes | basic pointer only |
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
| Dictation | yes (macOS) | no |
## Performance
@@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
+7 -5
View File
@@ -1,6 +1,6 @@
# Localization parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|---|---|---|---|---|---|---|---|---|
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
@@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
the system (#15), plural and number formatting, and an `xtask` check that every key has a
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
per composition); the hours that included it are kept until the next re-measure.
## Proofing languages
@@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
+1 -1
View File
@@ -16,7 +16,7 @@ points and lead the beta plan.
| Core workflow | Works end to end? | Evidence | Hours to full pass |
|---|---|---|---|
| Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 |
| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; EMF/WMF pictures render blank; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 |
| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 |
| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 |
| Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 |
| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 |
+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 |
+2
View File
@@ -47,6 +47,8 @@ pub const TABLE: &[(&str, Class)] = &[
("isobmff", Class::Layer(1)),
("media", Class::Layer(2)),
("color", Class::Layer(0)),
// WMF/EMF reader (ported from WordCraft).
("metafile", Class::Layer(0)),
("model", Class::Layer(1)),
("fonts", Class::Layer(1)),
("text", Class::Layer(2)),