mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca4b8ef49 |
@@ -28,8 +28,6 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
@@ -128,9 +128,6 @@ 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) |
|
||||
|
||||
@@ -79,10 +79,7 @@ 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());
|
||||
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)
|
||||
s.add_document(DocState::new(p, path, title))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -92,8 +89,7 @@ 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);
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -105,7 +101,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, "missingFonts": s.doc()?.missing_fonts}))
|
||||
Ok(json!({"document": i}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
|
||||
@@ -17,27 +17,6 @@ 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),
|
||||
@@ -165,33 +144,6 @@ 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))
|
||||
@@ -388,112 +340,6 @@ 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)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ 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();
|
||||
@@ -152,45 +151,6 @@ 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();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -131,24 +130,6 @@ 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>,
|
||||
@@ -160,11 +141,6 @@ 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);
|
||||
@@ -182,8 +158,6 @@ 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 {
|
||||
@@ -424,10 +398,6 @@ 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 {
|
||||
@@ -478,16 +448,6 @@ 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()?;
|
||||
|
||||
@@ -504,30 +504,6 @@ 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.
|
||||
@@ -567,210 +543,3 @@ 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());
|
||||
}
|
||||
|
||||
@@ -265,10 +265,6 @@ 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 {
|
||||
|
||||
+14
-137
@@ -52,53 +52,6 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
)
|
||||
}
|
||||
|
||||
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
|
||||
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
|
||||
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
|
||||
/// substitutes (Arial CE, Times New Roman CYR…).
|
||||
fn office_twin(family: &str) -> Option<&'static str> {
|
||||
let f = family.to_ascii_lowercase();
|
||||
Some(match f.as_str() {
|
||||
"liberation sans" | "helvetica" | "helv" => "Arial",
|
||||
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
|
||||
_ => {
|
||||
let base = f
|
||||
.strip_suffix(" baltic")
|
||||
.or(f.strip_suffix(" ce"))
|
||||
.or(f.strip_suffix(" cyr"))
|
||||
.or(f.strip_suffix(" greek"))
|
||||
.or(f.strip_suffix(" tur"))?;
|
||||
match base {
|
||||
"arial" => "Arial",
|
||||
"courier new" => "Courier New",
|
||||
"times new roman" => "Times New Roman",
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
|
||||
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
|
||||
/// substitute ([`substitutes`]; Aptos as Inter).
|
||||
fn is_office_cloud_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
|
||||
}
|
||||
|
||||
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
|
||||
/// whatever the theme, `panose` or `pitchFamily` say.
|
||||
pub const MISSING_FONT_FAMILY: &str = "Calibri";
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
@@ -112,10 +65,6 @@ pub struct FontFace {
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
|
||||
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
|
||||
/// fonts this way.
|
||||
pub legacy: Option<(String, String)>,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
@@ -304,11 +253,6 @@ pub struct FontDb {
|
||||
/// scan, so what they find doesn't depend on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
|
||||
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
|
||||
/// Semibold" loads a face of the family "Segoe UI").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex<std::collections::HashSet<String>>,
|
||||
/// System fallback state: enabled, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
@@ -372,16 +316,6 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
|
||||
/// typographic `family`.
|
||||
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
|
||||
let legacy = name(f, &[StringId::FAMILY_NAME])?;
|
||||
if legacy.eq_ignore_ascii_case(family) {
|
||||
return None;
|
||||
}
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
|
||||
/// variable font is cataloged under its default style; its named instances appear once it loads.
|
||||
@@ -428,13 +362,8 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
let f = skrifa::FontRef::new(&font).ok()?;
|
||||
let (family, style) = face_names(&f)?;
|
||||
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
|
||||
let legacy = legacy_names(&f, &family);
|
||||
Some(std::iter::once((family, style)).chain(legacy))
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -450,12 +379,6 @@ 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"] {
|
||||
@@ -470,11 +393,7 @@ 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") {
|
||||
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"));
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
@@ -572,12 +491,10 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
legacy,
|
||||
weight,
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
@@ -654,8 +571,6 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex::new(std::collections::HashSet::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
@@ -761,8 +676,6 @@ impl FontDb {
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -843,35 +756,17 @@ impl FontDb {
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
// Every installed file of the family first, so the best style is among the candidates. The
|
||||
// lock is held while the files load, so another thread looking up the family waits for
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
loaded.insert(key);
|
||||
}
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
self.find(family, style)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
@@ -887,9 +782,7 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -944,27 +837,11 @@ impl FontDb {
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let ns = norm(style);
|
||||
if cands.is_empty() {
|
||||
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
|
||||
// their Windows style ("Regular", "Bold"…).
|
||||
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
|
||||
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
|
||||
// by the caller, as Windows does, rather than a face of another weight.
|
||||
if let Some(f) = cands
|
||||
.iter()
|
||||
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
|
||||
{
|
||||
return Some((*f).clone());
|
||||
}
|
||||
}
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ns = norm(style);
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
|
||||
+1
-62
@@ -14,9 +14,7 @@ pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
@@ -184,65 +182,6 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_family_names_find_their_faces() {
|
||||
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
|
||||
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
|
||||
let check = |db: &FontDb| {
|
||||
assert!(db.has_family("DeckCraft Legacy Semibold"));
|
||||
for style in ["Regular", "Bold"] {
|
||||
let f = db.face("DeckCraft Legacy Semibold", style);
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
|
||||
}
|
||||
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
|
||||
// The typographic name still works.
|
||||
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
|
||||
};
|
||||
// Loaded directly…
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
|
||||
check(&db);
|
||||
// …and found by the system font scan under its Windows name, then loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.set_system_fallback(false);
|
||||
check(&db);
|
||||
// Looking up the Windows name loads the Semibold file only; a later lookup of the
|
||||
// family's Regular must still load and pick the Regular file, not settle for Semibold.
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
|
||||
let regular = db.face("DeckCraft Legacy", "Regular");
|
||||
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
|
||||
let db = FontDb::global();
|
||||
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
|
||||
let f = db.document_face(missing, "Bold");
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
}
|
||||
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
|
||||
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
|
||||
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
|
||||
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Calibri").contains(&"Carlito"));
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
-55
@@ -1,55 +0,0 @@
|
||||
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
|
||||
|
||||
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
|
||||
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
|
||||
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
|
||||
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
|
||||
a square for "A".
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def square():
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((100, 0))
|
||||
pen.lineTo((100, 700))
|
||||
pen.lineTo((600, 700))
|
||||
pen.lineTo((600, 0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def build(path, names, weight):
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder([".notdef", "A"])
|
||||
fb.setupCharacterMap({ord("A"): "A"})
|
||||
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
|
||||
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
|
||||
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
||||
fb.setupNameTable(names)
|
||||
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
|
||||
fb.setupPost()
|
||||
fb.save(path)
|
||||
|
||||
|
||||
build(sys.argv[1], {
|
||||
"familyName": "DeckCraft Legacy Semibold",
|
||||
"styleName": "Regular",
|
||||
"typographicFamily": "DeckCraft Legacy",
|
||||
"typographicSubfamily": "Semibold",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
|
||||
"fullName": "DeckCraft Legacy Semibold",
|
||||
"psName": "DeckCraftLegacy-Semibold",
|
||||
}, 600)
|
||||
build(sys.argv[2], {
|
||||
"familyName": "DeckCraft Legacy",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
|
||||
"fullName": "DeckCraft Legacy",
|
||||
"psName": "DeckCraftLegacy-Regular",
|
||||
}, 400)
|
||||
@@ -134,8 +134,11 @@ 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);
|
||||
let style_fill = |s: &Shape| {
|
||||
s.style.as_ref().map(|st| {
|
||||
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 (idx, col) = &st.fill_ref;
|
||||
let ph = ctx.color(col, None);
|
||||
let f = match *idx {
|
||||
@@ -143,25 +146,7 @@ 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(),
|
||||
};
|
||||
(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;
|
||||
return (f, Some(ph));
|
||||
}
|
||||
}
|
||||
if shape.text_box {
|
||||
|
||||
@@ -62,29 +62,6 @@ fn shape_style_fill_resolves_through_theme() {
|
||||
assert!(ph.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
|
||||
// the style's fillRef names, in the style's colour, along the shape's own direction.
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
let (f, ph) = resolve::fill(&ctx, &sh);
|
||||
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
|
||||
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
|
||||
assert_eq!(g.stops, theme.stops);
|
||||
assert_eq!(g.shape, own.shape, "the shape's own direction");
|
||||
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
|
||||
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
|
||||
st.fill_ref.0 = 1;
|
||||
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = Presentation::default();
|
||||
|
||||
@@ -356,8 +356,7 @@ 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") {
|
||||
@@ -371,21 +370,6 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
s
|
||||
}
|
||||
|
||||
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
|
||||
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
|
||||
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
|
||||
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
|
||||
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
|
||||
let mut shapes = vec![];
|
||||
doc.root.find_all("shape", &mut shapes);
|
||||
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
|
||||
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
|
||||
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
|
||||
return imp.media_for(&target);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
|
||||
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
w.open0("a:gsLst");
|
||||
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);
|
||||
}
|
||||
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");
|
||||
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");
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
|
||||
@@ -79,26 +79,3 @@ 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)));
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
//! 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,329 +0,0 @@
|
||||
//! The pasteboard: what the editor draws around the slide.
|
||||
//!
|
||||
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
|
||||
//! on the area around it, at full strength and with their effects (master and layout graphics
|
||||
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
|
||||
//!
|
||||
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
|
||||
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Line};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use kurbo::Rect;
|
||||
|
||||
use crate::MAX_SIDE;
|
||||
|
||||
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
|
||||
/// hostile files can't blow the pasteboard up.
|
||||
const MAX_SPILL: f64 = 2000.0;
|
||||
|
||||
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
|
||||
/// that is wider than the window re-renders only when a new tile comes into view.
|
||||
pub const TILE: i64 = 256;
|
||||
|
||||
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
|
||||
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
|
||||
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
|
||||
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
|
||||
let mut s: f64 = 0.0;
|
||||
if let Some(l) = line
|
||||
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
// Arrowheads reach up to a few line widths past the end point.
|
||||
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
|
||||
}
|
||||
if let Some(e) = effects {
|
||||
let big = fin(w).max(fin(h));
|
||||
if let Some(sh) = &e.outer_shadow {
|
||||
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
|
||||
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
s = s.max(fin(g.radius));
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
s = s.max(fin(h) + fin(r.dist));
|
||||
}
|
||||
}
|
||||
s.min(MAX_SPILL)
|
||||
}
|
||||
|
||||
/// Slide-space box a top-level shape draws into, its spill included.
|
||||
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
|
||||
if s.hidden {
|
||||
return None;
|
||||
}
|
||||
let x: Xfrm = resolve::xfrm(rctx, s);
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = match &s.kind {
|
||||
ShapeKind::Ink { strokes } => {
|
||||
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
|
||||
let (px, py, _) = pts.next()?;
|
||||
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
|
||||
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
|
||||
return Some(r.inflate(w, w));
|
||||
}
|
||||
_ => x.bounds(),
|
||||
};
|
||||
let (line, _) = resolve::line(rctx, s);
|
||||
let (fx, _) = resolve::effects(rctx, s);
|
||||
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
|
||||
Some(b.inflate(d, d))
|
||||
}
|
||||
|
||||
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
|
||||
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
|
||||
}
|
||||
|
||||
fn slide_rect(pres: &Presentation) -> Rect {
|
||||
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
|
||||
}
|
||||
|
||||
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
|
||||
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
|
||||
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
|
||||
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(slide) = pres.slides.get(index) else { return page };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
|
||||
let mut r = union_boxes(&rctx, &slide.shapes, page);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
|
||||
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(m) = pres.masters.get(master) else { return page };
|
||||
match layout.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
|
||||
if l.show_master_shapes {
|
||||
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The slide itself, `w` × `h` pixels.
|
||||
pub fn slide(w: u32, h: u32) -> Self {
|
||||
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
|
||||
}
|
||||
|
||||
fn edges(&self) -> (i64, i64, i64, i64) {
|
||||
let (x, y) = (self.x as i64, self.y as i64);
|
||||
(x, y, x + self.w as i64, y + self.h as i64)
|
||||
}
|
||||
|
||||
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
|
||||
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
|
||||
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
|
||||
}
|
||||
|
||||
/// Does this region cover all of `other`?
|
||||
pub fn contains(&self, other: &Region) -> bool {
|
||||
let (a, b) = (self.edges(), other.edges());
|
||||
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
|
||||
}
|
||||
|
||||
/// Is the region more than the slide (some of the pasteboard is drawn)?
|
||||
pub fn beyond(&self, slide: (u32, u32)) -> bool {
|
||||
*self != Region::slide(slide.0, slide.1)
|
||||
}
|
||||
|
||||
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
|
||||
pub fn offset(&self) -> (f64, f64) {
|
||||
(-(self.x as f64), -(self.y as f64))
|
||||
}
|
||||
|
||||
/// The region's size, for render options' `size`.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.w, self.h)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
|
||||
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
|
||||
let lim = i32::MAX as f64 / 2.0;
|
||||
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
|
||||
Some((
|
||||
px((r.x0 * scale).floor())? as i64,
|
||||
px((r.y0 * scale).floor())? as i64,
|
||||
px((r.x1 * scale).ceil())? as i64,
|
||||
px((r.y1 * scale).ceil())? as i64,
|
||||
))
|
||||
}
|
||||
|
||||
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
|
||||
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
|
||||
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
|
||||
/// area is rounded out to whole [`TILE`]s.
|
||||
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
|
||||
let page = Region::slide(slide_px.0, slide_px.1);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return page;
|
||||
}
|
||||
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
|
||||
let (vx0, vy0, vx1, vy1) = visible.edges();
|
||||
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
|
||||
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
|
||||
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
return page;
|
||||
}
|
||||
let (px0, py0, px1, py1) = page.edges();
|
||||
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use deckcraft_model::Shape;
|
||||
|
||||
fn black() -> deckcraft_model::ColorRef {
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
|
||||
}
|
||||
|
||||
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
|
||||
let p = deck_with(100.0, 100.0, 50.0, 50.0);
|
||||
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_off_the_slide_grow_the_extent() {
|
||||
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
|
||||
let e = slide_extent(&p, 0);
|
||||
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
|
||||
assert_eq!(e.y0, 0.0);
|
||||
assert_eq!(e.x1, p.slide_size.width);
|
||||
// Missing slides are just the page.
|
||||
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
|
||||
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
|
||||
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_count_towards_the_extent() {
|
||||
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
|
||||
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
|
||||
sh.effects = Some(Effects {
|
||||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||
color: black(),
|
||||
blur: 10.0,
|
||||
dist: 20.0,
|
||||
dir: 270.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(slide_extent(&p, 0).y0 <= -130.0);
|
||||
assert_eq!(
|
||||
spill(
|
||||
None,
|
||||
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
|
||||
1.0,
|
||||
1.0
|
||||
),
|
||||
MAX_SPILL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
|
||||
assert!(slide_extent(&p, 0).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
|
||||
let slide = (960, 540);
|
||||
let page = Region::slide(960, 540);
|
||||
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
|
||||
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
|
||||
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
|
||||
// Off to the left, in view: the region reaches it.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
|
||||
assert!(r.beyond(slide) && r.contains(&page));
|
||||
assert_eq!(r.offset(), (80.0, 0.0));
|
||||
// Far off and out of view (the view rounds out to tiles): only what's visible.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -256);
|
||||
// Wholly out of view: the slide.
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
|
||||
// Scale applies; nonsense falls back to the slide.
|
||||
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -40);
|
||||
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
|
||||
// Huge extents are capped.
|
||||
let r =
|
||||
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
|
||||
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_within_a_tile_keeps_the_region() {
|
||||
let slide = (2000, 1125);
|
||||
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
|
||||
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
|
||||
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod extent;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
@@ -41,7 +40,6 @@ 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;
|
||||
@@ -235,14 +233,11 @@ 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, drawn: None }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,38 +285,14 @@ 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 {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
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] }, page)
|
||||
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,14 +472,6 @@ 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)
|
||||
}
|
||||
@@ -695,21 +658,10 @@ impl Renderer {
|
||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
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::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::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 {
|
||||
@@ -740,8 +692,6 @@ 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.
|
||||
@@ -767,13 +717,6 @@ 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);
|
||||
@@ -875,12 +818,6 @@ impl Renderer {
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
let pad = b.height() * 0.3;
|
||||
self.track(tm, b.inflate(pad, pad));
|
||||
}
|
||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||
|
||||
@@ -297,21 +297,9 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
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
|
||||
@@ -391,5 +379,4 @@ pub fn draw(
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
+1
-124
@@ -1037,8 +1037,7 @@ 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}");
|
||||
// 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}");
|
||||
assert!((80..=110).contains(&ay0), "arch top {ay0}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1146,125 +1145,3 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
|
||||
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
|
||||
// An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame,
|
||||
// as PowerPoint does; with no preview it draws nothing.
|
||||
let png = {
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([200, 20, 20])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
for with_preview in [true, false] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("image1.png", "image/png", png.clone());
|
||||
let sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
|
||||
let px = img.pixel(x, y);
|
||||
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
|
||||
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
|
||||
}
|
||||
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
|
||||
fn off_slide_deck() -> Presentation {
|
||||
let half = Shape {
|
||||
id: ShapeId(2700),
|
||||
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let below = Shape {
|
||||
id: ShapeId(2701),
|
||||
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||||
..Default::default()
|
||||
};
|
||||
deck_with(vec![half, below])
|
||||
}
|
||||
|
||||
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
|
||||
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_exports_and_thumbnails_clip_to_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (960, 540));
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
// The on-slide half is there, nothing else is drawn outside the slide.
|
||||
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
|
||||
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let extent = slide_extent(&p, 0);
|
||||
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
|
||||
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
|
||||
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
|
||||
assert_eq!((img.width, img.height), region.size());
|
||||
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
|
||||
// Both halves of the straddling shape, at full strength, and the shape below the slide.
|
||||
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
|
||||
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
|
||||
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
|
||||
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
|
||||
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
|
||||
assert_eq!(at(200, 300), [255, 255, 255, 255]);
|
||||
// The drawing's reach, in slide space.
|
||||
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape {
|
||||
id: ShapeId(2702),
|
||||
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
|
||||
..Default::default()
|
||||
});
|
||||
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
|
||||
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
|
||||
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
|
||||
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
|
||||
for para in &mut t.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(40.0);
|
||||
}
|
||||
}
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
// The box estimate doesn't see the text below the slide; the render does.
|
||||
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
|
||||
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
assert!(drawn.y1 > 600.0, "{drawn:?}");
|
||||
// A slide with nothing off it reaches just the slide.
|
||||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||
}
|
||||
|
||||
+1
-28
@@ -261,7 +261,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
let family = resolve::font_family(ctx, r);
|
||||
let bold = r.bold.unwrap_or(false);
|
||||
let italic = r.italic.unwrap_or(false);
|
||||
let face = FontDb::global().document_face(&family, style_name(bold, italic));
|
||||
let face = FontDb::global().face(&family, style_name(bold, italic));
|
||||
let fake_bold = bold && face.weight < 550.0;
|
||||
let fake_italic = italic && !face.italic;
|
||||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||||
@@ -1831,33 +1831,6 @@ 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 };
|
||||
|
||||
@@ -151,36 +151,6 @@ 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);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! 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};
|
||||
@@ -112,26 +108,18 @@ 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, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
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),
|
||||
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),
|
||||
None => None,
|
||||
};
|
||||
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);
|
||||
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);
|
||||
}
|
||||
app.perf.render_ms = app.textures.last_render_ms;
|
||||
media_frames(app, &painter, xf);
|
||||
@@ -164,69 +152,11 @@ 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 };
|
||||
@@ -310,17 +240,14 @@ fn master_tex(
|
||||
master: usize,
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
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);
|
||||
) -> Option<egui::TextureHandle> {
|
||||
let img = deckcraft_render::render_layout(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
|
||||
);
|
||||
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -495,16 +422,12 @@ 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));
|
||||
// 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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,23 +147,6 @@ 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")) {
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
//! #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)));
|
||||
}
|
||||
}
|
||||
+1
-204
@@ -22,8 +22,6 @@ 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};
|
||||
@@ -183,10 +181,6 @@ 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>,
|
||||
@@ -194,8 +188,6 @@ 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,
|
||||
@@ -215,8 +207,6 @@ 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)>,
|
||||
}
|
||||
@@ -234,14 +224,11 @@ 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,
|
||||
@@ -256,7 +243,6 @@ impl SlideApp {
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
}
|
||||
}
|
||||
@@ -549,7 +535,7 @@ impl SlideApp {
|
||||
ctx.set_zoom_factor(scale);
|
||||
}
|
||||
if !self.styled {
|
||||
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||
theme::install_fonts(ctx);
|
||||
self.styled = true;
|
||||
self.restyle = true;
|
||||
} else {
|
||||
@@ -572,9 +558,6 @@ 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.
|
||||
@@ -606,52 +589,6 @@ 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() {
|
||||
@@ -699,7 +636,6 @@ 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)
|
||||
@@ -794,8 +730,6 @@ 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) {
|
||||
@@ -943,9 +877,6 @@ fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
@@ -958,37 +889,6 @@ 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() {
|
||||
@@ -1038,107 +938,4 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,19 +129,12 @@ 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));
|
||||
// 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 {
|
||||
if !focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
//! 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, Region, RenderOpts};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -19,15 +15,11 @@ 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>>,
|
||||
@@ -37,14 +29,6 @@ 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,
|
||||
@@ -71,84 +55,41 @@ 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`, 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)]
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
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, Region)> {
|
||||
) -> Option<TextureHandle> {
|
||||
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(), e.region));
|
||||
return Some(e.tex.clone());
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
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));
|
||||
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);
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
@@ -158,15 +99,13 @@ impl Textures {
|
||||
}
|
||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
@@ -182,7 +121,6 @@ impl Textures {
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
@@ -201,8 +139,7 @@ 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.
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _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);
|
||||
|
||||
@@ -155,18 +155,6 @@ 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() {
|
||||
@@ -190,24 +178,7 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
ctx.set_fonts(defs);
|
||||
}
|
||||
|
||||
pub fn bold(size: f32) -> egui::FontId {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
//! 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()));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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
-7
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **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)
|
||||
> **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)
|
||||
|
||||
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
|
||||
@@ -225,11 +225,7 @@ 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).
|
||||
- **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.
|
||||
- **No IME composition in text editing** (CJK input). 3–6 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.
|
||||
|
||||
@@ -255,7 +251,6 @@ 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity with PowerPoint
|
||||
|
||||
> **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
|
||||
> **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
|
||||
|
||||
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 | yes: composed in place, underlined, candidate window at the caret (#101) |
|
||||
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
|
||||
| Dictation | yes (macOS) | no |
|
||||
|
||||
## Performance
|
||||
@@ -71,5 +71,4 @@ 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with PowerPoint
|
||||
|
||||
> **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)
|
||||
> **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)
|
||||
|
||||
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; IME composition (#101); 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; **no IME composition**; 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; IME composition (#101); 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; no IME composition; 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, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | 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,8 +44,7 @@ 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 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.
|
||||
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
|
||||
|
||||
## Proofing languages
|
||||
|
||||
@@ -57,5 +56,4 @@ 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
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **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)
|
||||
> **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)
|
||||
|
||||
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,7 +41,6 @@ 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 |
|
||||
@@ -88,5 +87,4 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||
|
||||
Reference in New Issue
Block a user