mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
Compare commits
42
Commits
Generated
+2
@@ -1063,6 +1063,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1108,6 +1109,7 @@ dependencies = [
|
||||
name = "deckcraft-render"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
|
||||
+10
-1
@@ -128,10 +128,16 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 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 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 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) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -143,6 +149,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
@@ -34,6 +35,7 @@ macro_rules! outln {
|
||||
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
@@ -52,6 +54,7 @@ fn main() -> ExitCode {
|
||||
}
|
||||
Some("info") => report(info(rest)),
|
||||
Some("render") => report(render(rest)),
|
||||
Some("render-transition") => report(render_transition(rest)),
|
||||
Some("convert") => report(convert(rest)),
|
||||
Some("run") => report(run(rest)),
|
||||
Some("commands") => {
|
||||
@@ -161,6 +164,80 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
|
||||
/// the given progress values. With several values, OUT gets `-t<value>` before its extension.
|
||||
fn render_transition(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_engine::render;
|
||||
let mut input = None;
|
||||
let mut slide: Option<usize> = None;
|
||||
let mut ts: Vec<f64> = Vec::new();
|
||||
let mut width: Option<f64> = None;
|
||||
let mut out = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||||
"--t" => {
|
||||
let v = it.next().ok_or("--t needs a value")?;
|
||||
for x in v.split(',') {
|
||||
let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?;
|
||||
if !t.is_finite() {
|
||||
return Err(format!("bad --t value `{x}`"));
|
||||
}
|
||||
ts.push(t);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?)
|
||||
}
|
||||
other if input.is_none() => input = Some(other.to_string()),
|
||||
other => out = Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
let input = input.ok_or("render-transition needs a FILE")?;
|
||||
let out = out.ok_or("render-transition needs an output path")?;
|
||||
let i = slide.ok_or("render-transition needs --slide N")?;
|
||||
if ts.is_empty() {
|
||||
return Err("render-transition needs --t".into());
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, Some(&input), false)?;
|
||||
let st = s.doc().map_err(|e| e.to_string())?;
|
||||
let doc = &st.doc;
|
||||
let n = doc.slides.len();
|
||||
let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) };
|
||||
let sw = doc.slide_size.width.max(1.0);
|
||||
let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64);
|
||||
let opts = render::RenderOpts { scale: w / sw, ..Default::default() };
|
||||
let tr = sl.transition.clone().unwrap_or_default();
|
||||
// Render both slides once and composite every frame from them (morph re-renders per frame).
|
||||
let prev = i.checked_sub(1);
|
||||
let cached = (tr.kind != "morph").then(|| {
|
||||
let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts));
|
||||
(prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts)
|
||||
});
|
||||
let aspect = sw / doc.slide_size.height.max(1.0);
|
||||
for t in &ts {
|
||||
let img = match &cached {
|
||||
Some((old, new, parts)) => {
|
||||
render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect))
|
||||
}
|
||||
None => render::render_transition(doc, i, *t, &opts),
|
||||
};
|
||||
let path = if ts.len() == 1 {
|
||||
out.clone()
|
||||
} else {
|
||||
let p = std::path::Path::new(&out);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
let name = format!("{stem}-t{t:.3}.png");
|
||||
p.with_file_name(name).to_string_lossy().into_owned()
|
||||
};
|
||||
std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?;
|
||||
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -37,3 +37,7 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -14,24 +14,30 @@
|
||||
|
||||
mod easing;
|
||||
mod effects;
|
||||
mod mesh;
|
||||
mod morph;
|
||||
mod path;
|
||||
mod show;
|
||||
mod timeline;
|
||||
mod transition;
|
||||
mod transition_fx;
|
||||
mod transition_geom;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
|
||||
pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use mesh::{MeshVertex, layer_mesh};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use path::{MotionPath, default_path};
|
||||
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
|
||||
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
|
||||
pub use transition::{Layer, Source, transition_layers};
|
||||
pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
|
||||
|
||||
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Triangle meshes for transition layers.
|
||||
//!
|
||||
//! A [`Layer`] is a textured quad. Drawn as two triangles, a trapezoid (a slide seen in
|
||||
//! perspective) bends its texture along the diagonal, because GPUs and simple rasterisers
|
||||
//! interpolate texture coordinates linearly in screen space. [`layer_mesh`] splits such quads into a
|
||||
//! grid whose vertices follow the projective map from the source rectangle to the quad, so the
|
||||
//! picture looks like a flat card turned in 3-D. Rectangles and parallelograms stay two triangles,
|
||||
//! wedges one: the per-frame cost stays a handful of vertices per layer.
|
||||
|
||||
use crate::transition::Layer;
|
||||
|
||||
/// One mesh vertex: position in slide-unit space (0..1, y down), texture coordinate (0..1) and
|
||||
/// opacity (the layer's alpha times its corner fade, interpolated).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MeshVertex {
|
||||
pub pos: [f64; 2],
|
||||
pub uv: [f64; 2],
|
||||
pub a: f64,
|
||||
}
|
||||
|
||||
/// Grid cells per side for quads that need a projective subdivision.
|
||||
const GRID: usize = 8;
|
||||
|
||||
/// The triangles of a layer: vertices and index triples into them.
|
||||
pub fn layer_mesh(l: &Layer) -> (Vec<MeshVertex>, Vec<[u32; 3]>) {
|
||||
let q = l.quad;
|
||||
let uv = l.uv;
|
||||
let fa = l.fade.map(|f| f * l.alpha);
|
||||
// A wedge (triangle): the 4th corner repeats the 3rd.
|
||||
if q[3] == q[2] && uv[3] == uv[2] {
|
||||
let v = (0..3).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
|
||||
return (v, vec![[0, 1, 2]]);
|
||||
}
|
||||
let affine = {
|
||||
// Parallelogram: opposite edges equal.
|
||||
let e1 = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
|
||||
let e2 = [q[2][0] - q[3][0], q[2][1] - q[3][1]];
|
||||
(e1[0] - e2[0]).abs() < 1e-9 && (e1[1] - e2[1]).abs() < 1e-9
|
||||
};
|
||||
let h = if affine { None } else { square_to_quad(q) };
|
||||
let Some(h) = h else {
|
||||
let v = (0..4).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
|
||||
return (v, vec![[0, 1, 2], [0, 2, 3]]);
|
||||
};
|
||||
// Small quads (tiles, shards) bend their texture too little to matter: fewer cells.
|
||||
let span = q.iter().flat_map(|p| q.iter().map(move |r| (p[0] - r[0]).abs().max((p[1] - r[1]).abs()))).fold(0.0, f64::max);
|
||||
let n = ((span * 2.0 * GRID as f64).ceil() as usize).clamp(1, GRID);
|
||||
let mut v = Vec::with_capacity((n + 1) * (n + 1));
|
||||
for j in 0..=n {
|
||||
let b = j as f64 / n as f64;
|
||||
for i in 0..=n {
|
||||
let a = i as f64 / n as f64;
|
||||
// Texture: bilinear in (a, b) over the four uv corners (exact for rectangles).
|
||||
let top = lerp2(uv[0], uv[1], a);
|
||||
let bot = lerp2(uv[3], uv[2], a);
|
||||
let t = lerp2(top, bot, b);
|
||||
let at = fa[0] + (fa[1] - fa[0]) * a;
|
||||
let ab = fa[3] + (fa[2] - fa[3]) * a;
|
||||
v.push(MeshVertex { pos: apply(&h, a, b), uv: t, a: at + (ab - at) * b });
|
||||
}
|
||||
}
|
||||
let mut tris = Vec::with_capacity(n * n * 2);
|
||||
let w = (n + 1) as u32;
|
||||
for j in 0..n as u32 {
|
||||
for i in 0..n as u32 {
|
||||
let p = j * w + i;
|
||||
tris.push([p, p + 1, p + w + 1]);
|
||||
tris.push([p, p + w + 1, p + w]);
|
||||
}
|
||||
}
|
||||
(v, tris)
|
||||
}
|
||||
|
||||
fn lerp2(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
/// Projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`'s tl, tr, br,
|
||||
/// bl (Heckbert's square-to-quad). `None` when the quad is degenerate.
|
||||
fn square_to_quad(q: [[f64; 2]; 4]) -> Option<[f64; 8]> {
|
||||
let [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q;
|
||||
let sx = x0 - x1 + x2 - x3;
|
||||
let sy = y0 - y1 + y2 - y3;
|
||||
let (dx1, dx2, dy1, dy2) = (x1 - x2, x3 - x2, y1 - y2, y3 - y2);
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
if !den.is_finite() || den.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let g = (sx * dy2 - dx2 * sy) / den;
|
||||
let h = (dx1 * sy - sx * dy1) / den;
|
||||
let m = [x1 - x0 + g * x1, x3 - x0 + h * x3, x0, y1 - y0 + g * y1, y3 - y0 + h * y3, y0, g, h];
|
||||
m.iter().all(|v| v.is_finite()).then_some(m)
|
||||
}
|
||||
|
||||
fn apply(m: &[f64; 8], u: f64, v: f64) -> [f64; 2] {
|
||||
let w = m[6] * u + m[7] * v + 1.0;
|
||||
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
|
||||
[(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::transition::{Layer, Source};
|
||||
|
||||
#[test]
|
||||
fn rect_is_two_triangles() {
|
||||
let (v, t) = layer_mesh(&Layer::full(Source::New, 1.0));
|
||||
assert_eq!((v.len(), t.len()), (4, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trapezoid_is_subdivided_projectively() {
|
||||
// A card turned about its left edge: the right edge is shorter.
|
||||
let q = [[0.0, 0.0], [0.6, 0.2], [0.6, 0.8], [0.0, 1.0]];
|
||||
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
|
||||
let (v, t) = layer_mesh(&l);
|
||||
assert_eq!(v.len(), (GRID + 1) * (GRID + 1));
|
||||
assert_eq!(t.len(), GRID * GRID * 2);
|
||||
// A small tile in perspective needs no more than a couple of triangles.
|
||||
let small = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], [[0.5, 0.5], [0.52, 0.505], [0.52, 0.515], [0.5, 0.52]], 1.0);
|
||||
assert!(layer_mesh(&small).1.len() <= 2);
|
||||
// Corners land on the quad's corners.
|
||||
assert_eq!(v[0].pos, q[0]);
|
||||
let last = v[v.len() - 1].pos;
|
||||
assert!((last[0] - 0.6).abs() < 1e-9 && (last[1] - 0.8).abs() < 1e-9);
|
||||
// The texture's middle column sits nearer the far (short) edge than halfway: foreshortening.
|
||||
let mid = v[GRID / 2].pos[0];
|
||||
assert!(mid > 0.3, "{mid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_quads_do_not_blow_up() {
|
||||
let q = [[0.5, 0.5]; 4];
|
||||
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
|
||||
let (v, _) = layer_mesh(&l);
|
||||
assert!(v.iter().all(|p| p.pos[0].is_finite() && p.pos[1].is_finite()));
|
||||
}
|
||||
}
|
||||
+96
-821
@@ -1,15 +1,18 @@
|
||||
//! Slide transitions as layers of textured quads.
|
||||
//!
|
||||
//! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide,
|
||||
//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from
|
||||
//! trapezoids; wedges are triangles (4th corner repeats the 3rd).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
//! their backgrounds or contents alone, or a solid colour) onto a quad in slide-unit space (0..1, y
|
||||
//! down), with an opacity per corner. Perspective comes from projecting 3-D cards
|
||||
//! ([`crate::layer_mesh`] keeps their textures straight); soft edges come from per-corner opacity
|
||||
//! ramps; wedges are triangles (4th corner repeats the 3rd).
|
||||
//!
|
||||
//! The motions, curves, directions and option meanings were measured frame by frame on
|
||||
//! PowerPoint's video export of synthetic decks (`plan/powerpoint/tools/synth_transitions.ps1`).
|
||||
//! The effects themselves live in [`crate::transition_fx`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::clampf;
|
||||
use crate::easing::smooth;
|
||||
|
||||
/// What a layer shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -20,6 +23,35 @@ pub enum Source {
|
||||
New,
|
||||
Black,
|
||||
White,
|
||||
/// The outgoing slide's background (background fill plus master and layout graphics) alone.
|
||||
OldBack,
|
||||
/// The incoming slide's background alone.
|
||||
NewBack,
|
||||
/// The outgoing slide's own shapes alone, over transparency.
|
||||
OldContent,
|
||||
/// The incoming slide's own shapes alone, over transparency.
|
||||
NewContent,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The whole-slide source to draw instead when the parts aren't available: a background
|
||||
/// becomes its slide, content becomes nothing.
|
||||
pub fn whole(self) -> Option<Source> {
|
||||
match self {
|
||||
Source::OldBack => Some(Source::Old),
|
||||
Source::NewBack => Some(Source::New),
|
||||
Source::OldContent | Source::NewContent => None,
|
||||
s => Some(s),
|
||||
}
|
||||
}
|
||||
/// Does this source need the slide rendered in parts (background / content)?
|
||||
pub fn is_part(self) -> bool {
|
||||
matches!(self, Source::OldBack | Source::NewBack | Source::OldContent | Source::NewContent)
|
||||
}
|
||||
}
|
||||
|
||||
fn ones() -> [f64; 4] {
|
||||
[1.0; 4]
|
||||
}
|
||||
|
||||
/// One textured quad of a transition frame.
|
||||
@@ -34,12 +66,18 @@ pub struct Layer {
|
||||
/// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these.
|
||||
pub uv: [[f64; 2]; 4],
|
||||
pub alpha: f64,
|
||||
/// Opacity of each corner (tl, tr, br, bl), multiplied by `alpha` and interpolated across the
|
||||
/// quad: soft edges and feathered masks.
|
||||
#[serde(default = "ones")]
|
||||
pub fade: [f64; 4],
|
||||
}
|
||||
|
||||
type R = [f64; 4];
|
||||
const FULL: R = [0.0, 0.0, 1.0, 1.0];
|
||||
pub(crate) type R = [f64; 4];
|
||||
pub(crate) type P = [f64; 2];
|
||||
pub(crate) type Q = [[f64; 2]; 4];
|
||||
pub(crate) const FULL: R = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
fn corners(r: R) -> [[f64; 2]; 4] {
|
||||
pub(crate) fn corners(r: R) -> Q {
|
||||
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
|
||||
}
|
||||
|
||||
@@ -50,23 +88,39 @@ impl Layer {
|
||||
}
|
||||
/// `src` rectangle of the source drawn into the `dst` rectangle.
|
||||
pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer {
|
||||
Layer { source, src, quad: corners(dst), uv: corners(src), alpha }
|
||||
Layer { source, src, quad: corners(dst), uv: corners(src), alpha, fade: ones() }
|
||||
}
|
||||
/// `src` rectangle of the source drawn onto a quad.
|
||||
pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer {
|
||||
Layer { source, src, quad, uv: corners(src), alpha }
|
||||
pub fn quad(source: Source, src: R, quad: Q, alpha: f64) -> Layer {
|
||||
Layer { source, src, quad, uv: corners(src), alpha, fade: ones() }
|
||||
}
|
||||
/// The part of the source under `dst` drawn in place.
|
||||
fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
|
||||
pub(crate) fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
|
||||
Layer::rect(source, dst, dst, alpha)
|
||||
}
|
||||
/// A triangle of the source drawn in place.
|
||||
fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer {
|
||||
let xs = [a[0], b[0], c[0]];
|
||||
let ys = [a[1], b[1], c[1]];
|
||||
/// A triangle with its own texture coordinates and corner opacities.
|
||||
pub(crate) fn tri_uv(source: Source, p: [P; 3], uv: [P; 3], fade: [f64; 3], alpha: f64) -> Layer {
|
||||
let xs = [uv[0][0], uv[1][0], uv[2][0]];
|
||||
let ys = [uv[0][1], uv[1][1], uv[2][1]];
|
||||
let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha }
|
||||
Layer {
|
||||
source,
|
||||
src: [mn(xs), mn(ys), mx(xs), mx(ys)],
|
||||
quad: [p[0], p[1], p[2], p[2]],
|
||||
uv: [uv[0], uv[1], uv[2], uv[2]],
|
||||
alpha,
|
||||
fade: [fade[0], fade[1], fade[2], fade[2]],
|
||||
}
|
||||
}
|
||||
/// With corner opacities.
|
||||
pub(crate) fn faded(mut self, fade: [f64; 4]) -> Layer {
|
||||
self.fade = fade;
|
||||
self
|
||||
}
|
||||
/// A black copy of this layer's shape at `k` of its opacity: shading a card.
|
||||
pub(crate) fn shade(&self, k: f64) -> Layer {
|
||||
Layer { source: Source::Black, alpha: self.alpha * k.clamp(0.0, 1.0), ..*self }
|
||||
}
|
||||
fn sanitized(mut self) -> Layer {
|
||||
let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 };
|
||||
@@ -81,827 +135,48 @@ impl Layer {
|
||||
c[0] = c[0].clamp(0.0, 1.0);
|
||||
c[1] = c[1].clamp(0.0, 1.0);
|
||||
}
|
||||
for a in self.fade.iter_mut() {
|
||||
*a = clampf(*a, 0.0, 1.0, 0.0);
|
||||
}
|
||||
self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- geometry helpers ----------
|
||||
|
||||
fn offset(r: R, dx: f64, dy: f64) -> R {
|
||||
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
|
||||
}
|
||||
|
||||
fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// The whole source scaled about (cx, cy) and moved.
|
||||
fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy];
|
||||
Layer::rect(source, FULL, r, alpha)
|
||||
}
|
||||
|
||||
/// Rotate a point about a pivot (degrees).
|
||||
fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let (x, y) = (p[0] - c[0], p[1] - c[1]);
|
||||
[c[0] + x * co - y * s, c[1] + x * s + y * co]
|
||||
}
|
||||
|
||||
fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] {
|
||||
[rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)]
|
||||
}
|
||||
|
||||
/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end).
|
||||
fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] {
|
||||
let h = r[3] - r[1];
|
||||
[[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]]
|
||||
}
|
||||
|
||||
/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end).
|
||||
fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] {
|
||||
let w = r[2] - r[0];
|
||||
[[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]]
|
||||
}
|
||||
|
||||
/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction).
|
||||
fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R {
|
||||
let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r };
|
||||
if mx {
|
||||
r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]];
|
||||
}
|
||||
if my {
|
||||
r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]];
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Motion direction of an option: l, r, u, d and diagonals.
|
||||
fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
|
||||
let mut d = (0.0, 0.0);
|
||||
if opt.contains('l') {
|
||||
d.0 = -1.0;
|
||||
}
|
||||
if opt.contains('r') {
|
||||
d.0 = 1.0;
|
||||
}
|
||||
if opt.contains('u') {
|
||||
d.1 = -1.0;
|
||||
}
|
||||
if opt.contains('d') {
|
||||
d.1 = 1.0;
|
||||
}
|
||||
if d == (0.0, 0.0) { default } else { d }
|
||||
}
|
||||
|
||||
fn hash64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random value in [0, 1).
|
||||
fn rnd(i: u64) -> f64 {
|
||||
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn ramp(t: f64, order: f64, window: f64) -> f64 {
|
||||
let w = window.clamp(0.01, 1.0);
|
||||
((t - order * (1.0 - w)) / w).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> {
|
||||
let mut v = Vec::with_capacity(cols * rows);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64];
|
||||
v.push((c, r, cell));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn center(r: R) -> [f64; 2] {
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
}
|
||||
|
||||
fn scale_rect(r: R, s: f64) -> R {
|
||||
let c = center(r);
|
||||
let hw = (r[2] - r[0]) / 2.0 * s;
|
||||
let hh = (r[3] - r[1]) / 2.0 * s;
|
||||
[c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh]
|
||||
}
|
||||
|
||||
// ---------- effects ----------
|
||||
/// Slide aspect assumed by [`transition_layers`] (16:9).
|
||||
pub const DEFAULT_ASPECT: f64 = 16.0 / 9.0;
|
||||
|
||||
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
|
||||
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
|
||||
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
|
||||
/// frames with [`crate::morph_frame`]).
|
||||
/// progress `t` (0..1) on a 16:9 slide. See [`transition_frame`].
|
||||
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
|
||||
transition_frame(kind, option, t, DEFAULT_ASPECT)
|
||||
}
|
||||
|
||||
/// Layers of the transition `kind` with `option` at linear progress `t` (0..1; the effect applies
|
||||
/// its own easing) on a slide `aspect` wide per unit of height. Draw in order over black. At
|
||||
/// `t >= 1` the result is the new slide alone; at `t <= 0` (or NaN) the old slide alone. Morph
|
||||
/// returns a plain crossfade (the UI renders morph frames with [`crate::morph_frame`]). Layers
|
||||
/// with [`Source::is_part`] sources need the slides rendered as background and content
|
||||
/// ([`uses_slide_parts`]).
|
||||
pub fn transition_frame(kind: &str, option: &str, t: f64, aspect: f64) -> Vec<Layer> {
|
||||
let t = clampf(t, 0.0, 1.0, 0.0);
|
||||
let aspect = if aspect.is_finite() && aspect > 0.01 { aspect.min(100.0) } else { DEFAULT_ASPECT };
|
||||
let v = if t >= 1.0 {
|
||||
vec![Layer::full(Source::New, 1.0)]
|
||||
} else if t <= 0.0 {
|
||||
vec![Layer::full(Source::Old, 1.0)]
|
||||
} else {
|
||||
layers(kind, option, t, 0)
|
||||
crate::transition_fx::layers(kind, option, t, aspect, 0)
|
||||
};
|
||||
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect()
|
||||
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0 && l.fade.iter().any(|a| *a > 0.0)).collect()
|
||||
}
|
||||
|
||||
fn crossfade(t: f64) -> Vec<Layer> {
|
||||
vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)]
|
||||
}
|
||||
|
||||
const RANDOM_KINDS: &[&str] = &[
|
||||
"fade",
|
||||
"push",
|
||||
"wipe",
|
||||
"split",
|
||||
"reveal",
|
||||
"randomBar",
|
||||
"shape",
|
||||
"uncover",
|
||||
"cover",
|
||||
"fallOver",
|
||||
"drape",
|
||||
"curtains",
|
||||
"wind",
|
||||
"prestige",
|
||||
"fracture",
|
||||
"crush",
|
||||
"peelOff",
|
||||
"pageCurlDouble",
|
||||
"airplane",
|
||||
"origami",
|
||||
"dissolve",
|
||||
"checker",
|
||||
"blinds",
|
||||
"clock",
|
||||
"ripple",
|
||||
"honeycomb",
|
||||
"glitter",
|
||||
"vortex",
|
||||
"shred",
|
||||
"switch",
|
||||
"flip",
|
||||
"gallery",
|
||||
"cube",
|
||||
"doors",
|
||||
"box",
|
||||
"comb",
|
||||
"zoom",
|
||||
"pan",
|
||||
"ferris",
|
||||
"conveyor",
|
||||
"rotate",
|
||||
"window",
|
||||
"orbit",
|
||||
"flythrough",
|
||||
];
|
||||
|
||||
fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec<Layer> {
|
||||
use Source::{Black, New, Old, White};
|
||||
let o = option;
|
||||
let s = smooth(t);
|
||||
/// Does `kind` animate the slides' backgrounds and contents separately (PowerPoint's Dynamic
|
||||
/// Content transitions and Reveal)? Then the frame uses [`Source::OldBack`] & co.
|
||||
pub fn uses_slide_parts(kind: &str, option: &str) -> bool {
|
||||
match kind {
|
||||
"none" => vec![Layer::full(New, 1.0)],
|
||||
"cut" => {
|
||||
if o == "throughBlack" && t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0)]
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0)]
|
||||
}
|
||||
}
|
||||
"fade" if o == "throughBlack" => {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)]
|
||||
} else {
|
||||
vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)]
|
||||
}
|
||||
}
|
||||
"fade" | "morph" => crossfade(t),
|
||||
"push" | "pan" => {
|
||||
let (dx, dy) = dir(o, (0.0, -1.0));
|
||||
vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
|
||||
}
|
||||
"uncover" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)]
|
||||
}
|
||||
"cover" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
|
||||
}
|
||||
"wipe" => wipe(o, t),
|
||||
"split" => {
|
||||
let horz = o.starts_with("horz");
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
let rects: Vec<R> = if o.ends_with("In") {
|
||||
vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]]
|
||||
} else {
|
||||
vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]]
|
||||
};
|
||||
for r in rects {
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"reveal" => {
|
||||
let dx = if o.ends_with("Right") { 1.0 } else { -1.0 };
|
||||
if o.starts_with("black") {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)]
|
||||
} else {
|
||||
vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)]
|
||||
}
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)]
|
||||
}
|
||||
}
|
||||
"randomBar" => {
|
||||
let horz = o == "horz";
|
||||
let n = 48;
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for i in 0..n {
|
||||
if t > rnd(i as u64 + 17) * 0.98 {
|
||||
let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0];
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"shape" => {
|
||||
let form = match o {
|
||||
"circle" | "diamond" | "plus" => o,
|
||||
_ => "box",
|
||||
};
|
||||
let inward = o == "in";
|
||||
if inward {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
v.extend(shape_form(Old, form, 1.0 - s));
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
v.extend(shape_form(New, form, s));
|
||||
v
|
||||
}
|
||||
}
|
||||
"flash" => {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))]
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)]
|
||||
}
|
||||
}
|
||||
"fallOver" => {
|
||||
let sg = if o == "r" { 1.0 } else { -1.0 };
|
||||
let a = t * t * PI / 2.0;
|
||||
let top = 1.0 - a.cos();
|
||||
let ins = 0.12 * a.sin();
|
||||
let sh = sg * 0.15 * a.sin();
|
||||
let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]];
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))]
|
||||
}
|
||||
"drape" => {
|
||||
let k = t * t;
|
||||
let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]];
|
||||
let q = if o == "r" { mirror_q(q) } else { q };
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)]
|
||||
}
|
||||
"curtains" => {
|
||||
let w = 0.5 * (1.0 - s);
|
||||
let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]];
|
||||
let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]];
|
||||
vec![
|
||||
scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0),
|
||||
Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0),
|
||||
Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0),
|
||||
]
|
||||
}
|
||||
"wind" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let r = offset(FULL, d * 1.2 * s, -0.1 * s);
|
||||
let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]];
|
||||
vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)]
|
||||
}
|
||||
"prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)],
|
||||
"fracture" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(8, 6) {
|
||||
let i = (r * 8 + c) as u64;
|
||||
let q = ramp(t, rnd(i + 101) * 0.5, 0.5);
|
||||
let cc = center(cell);
|
||||
let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q);
|
||||
let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
|
||||
}
|
||||
v
|
||||
}
|
||||
"crush" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(4, 4) {
|
||||
let i = (r * 4 + c) as u64;
|
||||
let cc = center(cell);
|
||||
let k = 1.0 - s;
|
||||
let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k];
|
||||
let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3)));
|
||||
let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]);
|
||||
let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - t * t));
|
||||
}
|
||||
v
|
||||
}
|
||||
"peelOff" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r"));
|
||||
v
|
||||
}
|
||||
"pageCurlDouble" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
let left_first = o != "r";
|
||||
v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first));
|
||||
v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first));
|
||||
v
|
||||
}
|
||||
"airplane" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = 1.0 - 0.8 * s;
|
||||
let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s);
|
||||
let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s });
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)]
|
||||
}
|
||||
"origami" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
if t < 0.5 {
|
||||
let f = t * 2.0;
|
||||
let w = 0.5 * (1.0 - 0.8 * f);
|
||||
v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0));
|
||||
v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0));
|
||||
} else {
|
||||
let f = (t - 0.5) * 2.0;
|
||||
let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f);
|
||||
v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f));
|
||||
}
|
||||
v
|
||||
}
|
||||
"dissolve" => {
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(32, 18) {
|
||||
if t > rnd((r * 32 + c) as u64 + 991) * 0.98 {
|
||||
v.push(Layer::inplace(New, cell, 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"checker" => {
|
||||
let vert = o == "vert";
|
||||
let (n, m) = (10usize, 8usize);
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(n, m) {
|
||||
let across = if vert { c } else { r };
|
||||
let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0);
|
||||
if f <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let rc = if vert {
|
||||
[cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f]
|
||||
} else {
|
||||
[cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]]
|
||||
};
|
||||
v.push(Layer::inplace(New, rc, 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"blinds" => {
|
||||
let horz = o == "horz";
|
||||
let n = 8;
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let r = [x0, 0.0, x0 + s / n as f64, 1.0];
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"clock" => clock(o, s),
|
||||
"ripple" => {
|
||||
let origin = match o {
|
||||
"lu" => [0.0, 0.0],
|
||||
"ru" => [1.0, 0.0],
|
||||
"ld" => [0.0, 1.0],
|
||||
"rd" => [1.0, 1.0],
|
||||
_ => [0.5, 0.5],
|
||||
};
|
||||
let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 };
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (_, _, cell) in grid(24, 14) {
|
||||
let cc = center(cell);
|
||||
let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd;
|
||||
let a = ramp(t, d.min(1.0), 0.3);
|
||||
if a > 0.0 {
|
||||
let sc = 1.0 + 0.15 * (a * PI).sin();
|
||||
v.push(Layer::rect(New, cell, scale_rect(cell, sc), a));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"honeycomb" => {
|
||||
let (cols, rows) = (12usize, 8usize);
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for c in 0..cols {
|
||||
let odd = c % 2 == 1;
|
||||
let extra = if odd { 1 } else { 0 };
|
||||
for r in 0..rows + extra {
|
||||
let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64;
|
||||
let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)];
|
||||
let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35);
|
||||
if a > 0.0 {
|
||||
v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"glitter" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(20, 12) {
|
||||
let cc = center(cell);
|
||||
let grad = if dx < 0.0 {
|
||||
1.0 - cc[0]
|
||||
} else if dx > 0.0 {
|
||||
cc[0]
|
||||
} else if dy < 0.0 {
|
||||
1.0 - cc[1]
|
||||
} else {
|
||||
cc[1]
|
||||
};
|
||||
let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77);
|
||||
let a = ramp(t, order, 0.25);
|
||||
if a > 0.0 {
|
||||
let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0);
|
||||
v.push(Layer::quad(New, cell, quad, a));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"vortex" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(16, 9) {
|
||||
let i = (r * 16 + c) as u64;
|
||||
let cc = center(cell);
|
||||
let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy);
|
||||
let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4);
|
||||
let ang = q * 2.0 * PI + rnd(i) * PI;
|
||||
let rad = q * 1.2;
|
||||
let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3);
|
||||
let pos = [cc[0] + ox, cc[1] + oy];
|
||||
let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
|
||||
}
|
||||
v
|
||||
}
|
||||
"shred" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
if o == "particles" {
|
||||
for (c, r, cell) in grid(24, 14) {
|
||||
let i = (r * 24 + c) as u64;
|
||||
let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4);
|
||||
let dy = q * q * 1.2;
|
||||
let dx = (rnd(i + 9) - 0.5) * 0.3 * q;
|
||||
v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q));
|
||||
}
|
||||
} else {
|
||||
let n = 16;
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let x1 = (i + 1) as f64 / n as f64;
|
||||
let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7);
|
||||
let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q };
|
||||
v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"switch" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = (t * PI).sin();
|
||||
let old = Layer::quad(
|
||||
Old,
|
||||
FULL,
|
||||
persp_h(
|
||||
offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0),
|
||||
if d > 0.0 { 0.0 } else { 0.08 * k },
|
||||
if d > 0.0 { 0.08 * k } else { 0.0 },
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
let new = Layer::quad(
|
||||
New,
|
||||
FULL,
|
||||
persp_h(
|
||||
offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0),
|
||||
if d > 0.0 { 0.08 * k } else { 0.0 },
|
||||
if d > 0.0 { 0.0 } else { 0.08 * k },
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
if t < 0.5 { vec![new, old] } else { vec![old, new] }
|
||||
}
|
||||
"flip" => {
|
||||
let a = PI * s;
|
||||
let w = a.cos().abs();
|
||||
let k = 0.1 * a.sin();
|
||||
let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0];
|
||||
let right = o != "l";
|
||||
if t < 0.5 {
|
||||
let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) };
|
||||
vec![Layer::quad(Old, FULL, q, 1.0)]
|
||||
} else {
|
||||
let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) };
|
||||
vec![Layer::quad(New, FULL, q, 1.0)]
|
||||
}
|
||||
}
|
||||
"gallery" | "conveyor" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = (s * PI).sin();
|
||||
let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k;
|
||||
let gap = if kind == "gallery" { 1.15 } else { 1.05 };
|
||||
let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0);
|
||||
let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0);
|
||||
let p = 0.08 * k;
|
||||
vec![
|
||||
Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0),
|
||||
Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0),
|
||||
]
|
||||
}
|
||||
"cube" | "box" | "rotate" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let vertical = dy != 0.0 && dx == 0.0;
|
||||
let neg = if vertical { dy < 0.0 } else { dx < 0.0 };
|
||||
let k = (s * PI).sin();
|
||||
let (far_old, far_new, shared) = match kind {
|
||||
"cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k),
|
||||
"box" => (0.0, 0.0, 0.18 * k),
|
||||
_ => (0.25 * s, 0.25 * (1.0 - s), 0.0),
|
||||
};
|
||||
// Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1].
|
||||
let split = 1.0 - s;
|
||||
let (old_q, new_q) = if kind == "rotate" {
|
||||
if t < 0.5 {
|
||||
(Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None)
|
||||
} else {
|
||||
(None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new)))
|
||||
}
|
||||
} else {
|
||||
(Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new)))
|
||||
};
|
||||
let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] {
|
||||
let q = if neg { q } else { mirror_q(q) };
|
||||
if vertical { swap_q(q) } else { q }
|
||||
};
|
||||
let mut v = vec![Layer::full(Black, 1.0)];
|
||||
if let Some(q) = old_q {
|
||||
v.push(Layer::quad(Old, FULL, fix(q), 1.0));
|
||||
}
|
||||
if let Some(q) = new_q {
|
||||
v.push(Layer::quad(New, FULL, fix(q), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"doors" | "window" => {
|
||||
let horz = o == "horz";
|
||||
let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) };
|
||||
let w = 0.5 * (1.0 - s);
|
||||
let k = persp * s;
|
||||
let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]];
|
||||
let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]];
|
||||
let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) };
|
||||
let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) };
|
||||
vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)]
|
||||
}
|
||||
"comb" => {
|
||||
let vert = o == "vert";
|
||||
let n = 8;
|
||||
let mut v = Vec::new();
|
||||
for i in 0..n {
|
||||
let a = i as f64 / n as f64;
|
||||
let b = (i + 1) as f64 / n as f64;
|
||||
let d = if i % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] };
|
||||
let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) };
|
||||
let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) };
|
||||
v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0));
|
||||
v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"zoom" => {
|
||||
if o == "out" {
|
||||
vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
|
||||
} else {
|
||||
vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)]
|
||||
}
|
||||
}
|
||||
"orbit" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let k = (s * PI).sin();
|
||||
let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s);
|
||||
let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s);
|
||||
if t < 0.5 { vec![new, old] } else { vec![old, new] }
|
||||
}
|
||||
"flythrough" => {
|
||||
let bounce = o.ends_with("Bounce");
|
||||
let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base };
|
||||
if o.starts_with("out") {
|
||||
vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)]
|
||||
} else {
|
||||
vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
|
||||
}
|
||||
}
|
||||
"ferris" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let pivot = [0.5, 2.5];
|
||||
let a = 50.0 * s * d;
|
||||
let oq = rot_quad(corners(FULL), pivot, a);
|
||||
let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d);
|
||||
vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)]
|
||||
}
|
||||
"random" if depth == 0 => {
|
||||
let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64));
|
||||
let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade");
|
||||
let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or("");
|
||||
layers(k, opt, t, depth + 1)
|
||||
}
|
||||
_ => crossfade(t),
|
||||
"pan" | "ferris" | "conveyor" | "rotate" | "window" | "orbit" | "flythrough" => true,
|
||||
"reveal" => !option.starts_with("black"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
||||
// Mirror x and keep the tl, tr, br, bl order (swap left/right corners).
|
||||
let m = |p: [f64; 2]| [1.0 - p[0], p[1]];
|
||||
[m(q[1]), m(q[0]), m(q[3]), m(q[2])]
|
||||
}
|
||||
|
||||
fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
||||
// Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners.
|
||||
let s = |p: [f64; 2]| [p[1], p[0]];
|
||||
[s(q[0]), s(q[3]), s(q[2]), s(q[1])]
|
||||
}
|
||||
|
||||
fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] {
|
||||
persp_h(r, left_inset, right_inset)
|
||||
}
|
||||
|
||||
fn wipe(o: &str, t: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::full(Source::Old, 1.0)];
|
||||
let diag = o.len() == 2;
|
||||
if diag {
|
||||
// Diagonal: columns revealed by a slanted edge.
|
||||
let (mx, my) = (o.contains('l'), o.contains('u'));
|
||||
let n = 32;
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let x1 = (i + 1) as f64 / n as f64;
|
||||
let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0);
|
||||
if h > 0.0 {
|
||||
v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0));
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
let (mx, swap) = match o {
|
||||
"l" => (true, false),
|
||||
"d" => (false, true),
|
||||
"u" => (true, true),
|
||||
_ => (false, false),
|
||||
};
|
||||
let soft = 0.15;
|
||||
let e = t * (1.0 + soft);
|
||||
let solid = (e - soft).clamp(0.0, 1.0);
|
||||
if solid > 0.0 {
|
||||
v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0));
|
||||
}
|
||||
let n = 6;
|
||||
for k in 0..n {
|
||||
let a = e - soft + soft * k as f64 / n as f64;
|
||||
let b = e - soft + soft * (k + 1) as f64 / n as f64;
|
||||
let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
|
||||
if b > a {
|
||||
let alpha = 1.0 - (k as f64 + 0.5) / n as f64;
|
||||
v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips.
|
||||
fn shape_form(src: Source, form: &str, f: f64) -> Vec<Layer> {
|
||||
let f = f.clamp(0.0, 1.0);
|
||||
if f <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
match form {
|
||||
"plus" => {
|
||||
let h = f / 2.0;
|
||||
vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)]
|
||||
}
|
||||
"circle" | "diamond" => {
|
||||
let n = 40;
|
||||
let r = if form == "circle" { f * 0.75 } else { f * 1.02 };
|
||||
let mut v = Vec::new();
|
||||
for i in 0..n {
|
||||
let y0 = i as f64 / n as f64;
|
||||
let y1 = (i + 1) as f64 / n as f64;
|
||||
let dy = ((y0 + y1) / 2.0 - 0.5).abs();
|
||||
let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy };
|
||||
if half > 0.0 {
|
||||
let half = half.min(0.5);
|
||||
v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap.
|
||||
fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec<Layer> {
|
||||
let w = area[2] - area[0];
|
||||
let p = s * w;
|
||||
if toward_right {
|
||||
let edge = area[0] + p;
|
||||
let stay = [edge, area[1], area[2], area[3]];
|
||||
let flap_src = [area[0], area[1], edge, area[3]];
|
||||
let x2 = edge + p;
|
||||
let flap = Layer {
|
||||
source: src,
|
||||
src: flap_src,
|
||||
quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]],
|
||||
uv: corners(flap_src),
|
||||
alpha: 0.85,
|
||||
};
|
||||
vec![Layer::inplace(src, stay, 1.0), flap]
|
||||
} else {
|
||||
let edge = area[2] - p;
|
||||
let stay = [area[0], area[1], edge, area[3]];
|
||||
let flap_src = [edge, area[1], area[2], area[3]];
|
||||
let x2 = edge - p;
|
||||
let flap = Layer {
|
||||
source: src,
|
||||
src: flap_src,
|
||||
quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]],
|
||||
uv: corners(flap_src),
|
||||
alpha: 0.85,
|
||||
};
|
||||
vec![Layer::inplace(src, stay, 1.0), flap]
|
||||
}
|
||||
}
|
||||
|
||||
/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from
|
||||
/// 12 o'clock).
|
||||
fn boundary(a: f64) -> [f64; 2] {
|
||||
let (dx, dy) = (a.sin(), -a.cos());
|
||||
let m = dx.abs().max(dy.abs()).max(1e-9);
|
||||
[0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m]
|
||||
}
|
||||
|
||||
fn clock(o: &str, s: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::full(Source::Old, 1.0)];
|
||||
let c = [0.5, 0.5];
|
||||
let sweep = s * 2.0 * PI;
|
||||
let (a0, a1) = match o {
|
||||
"counterClockwise" => (-sweep, 0.0),
|
||||
"wedge" => (-sweep / 2.0, sweep / 2.0),
|
||||
_ => (0.0, sweep),
|
||||
};
|
||||
let span = a1 - a0;
|
||||
if span <= 0.0 {
|
||||
return v;
|
||||
}
|
||||
let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96);
|
||||
let mut angles: Vec<f64> = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect();
|
||||
// Square corners inside the sweep, so the fan reaches them.
|
||||
for k in -8i32..=8 {
|
||||
let a = PI / 4.0 + k as f64 * PI / 2.0;
|
||||
if a > a0 && a < a1 {
|
||||
angles.push(a);
|
||||
}
|
||||
}
|
||||
angles.sort_by(f64::total_cmp);
|
||||
for w in angles.windows(2) {
|
||||
if let [x, y] = w
|
||||
&& y > x
|
||||
{
|
||||
v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
//! Geometry for transition effects: easing, 3-D cards, opacity fields, polygons.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::{ease, smooth};
|
||||
use crate::transition::{FULL, Layer, P, Q, R, Source, corners};
|
||||
|
||||
// ---------- easing ----------
|
||||
|
||||
/// Smoothstep ease in-out (PowerPoint's fades).
|
||||
pub(crate) fn ss(x: f64) -> f64 {
|
||||
smooth(x)
|
||||
}
|
||||
|
||||
/// Quadratic ease in-out: velocity ramps up for the first half and down for the second
|
||||
/// (PowerPoint's moves: Push, Cover, Uncover, Pan…).
|
||||
pub(crate) fn q2(x: f64) -> f64 {
|
||||
ease(x, 0.5, 0.5, 0.0)
|
||||
}
|
||||
|
||||
/// Power ease in-out of degree `n` (2: quadratic, 3: cubic, 4: quartic).
|
||||
pub(crate) fn pow_inout(x: f64, n: f64) -> f64 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x < 0.5 { 0.5 * (2.0 * x).powf(n) } else { 1.0 - 0.5 * (2.0 - 2.0 * x).powf(n) }
|
||||
}
|
||||
|
||||
/// Quadratic ease-in.
|
||||
pub(crate) fn ein(x: f64) -> f64 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x
|
||||
}
|
||||
|
||||
/// Quadratic ease-out.
|
||||
pub(crate) fn eout(x: f64) -> f64 {
|
||||
let x = 1.0 - x.clamp(0.0, 1.0);
|
||||
1.0 - x * x
|
||||
}
|
||||
|
||||
/// Progress through the window [a, b] of t, clamped to 0..1.
|
||||
pub(crate) fn win(t: f64, a: f64, b: f64) -> f64 {
|
||||
if b <= a {
|
||||
return if t >= b { 1.0 } else { 0.0 };
|
||||
}
|
||||
((t - a) / (b - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
// ---------- randomness ----------
|
||||
|
||||
pub(crate) fn hash64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random value in [0, 1).
|
||||
pub(crate) fn rnd(i: u64) -> f64 {
|
||||
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
// ---------- 2-D ----------
|
||||
|
||||
pub(crate) fn offset(r: R, dx: f64, dy: f64) -> R {
|
||||
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
|
||||
}
|
||||
|
||||
pub(crate) fn center(r: R) -> P {
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
}
|
||||
|
||||
pub(crate) fn scale_rect(r: R, s: f64) -> R {
|
||||
scale_rect_about(r, s, center(r))
|
||||
}
|
||||
|
||||
pub(crate) fn scale_rect_about(r: R, s: f64, c: P) -> R {
|
||||
[c[0] + (r[0] - c[0]) * s, c[1] + (r[1] - c[1]) * s, c[0] + (r[2] - c[0]) * s, c[1] + (r[3] - c[1]) * s]
|
||||
}
|
||||
|
||||
/// The whole source moved by (dx, dy).
|
||||
pub(crate) fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// The whole source scaled by `s` about (cx, cy) and moved.
|
||||
pub(crate) fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(scale_rect_about(FULL, s, [cx, cy]), dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// Rotate a point about a pivot by `deg` (clockwise on screen), in a slide `a` wide per unit of
|
||||
/// height, so angles and shapes stay true.
|
||||
pub(crate) fn rot(p: P, c: P, deg: f64, a: f64) -> P {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let (x, y) = ((p[0] - c[0]) * a, p[1] - c[1]);
|
||||
[c[0] + (x * co - y * s) / a, c[1] + x * s + y * co]
|
||||
}
|
||||
|
||||
pub(crate) fn rot_quad(q: Q, c: P, deg: f64, a: f64) -> Q {
|
||||
[rot(q[0], c, deg, a), rot(q[1], c, deg, a), rot(q[2], c, deg, a), rot(q[3], c, deg, a)]
|
||||
}
|
||||
|
||||
/// Motion direction of an option: l, r, u, d and diagonals.
|
||||
pub(crate) fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
|
||||
let mut d = (0.0, 0.0);
|
||||
if opt.contains('l') {
|
||||
d.0 = -1.0;
|
||||
}
|
||||
if opt.contains('r') {
|
||||
d.0 = 1.0;
|
||||
}
|
||||
if opt.contains('u') {
|
||||
d.1 = -1.0;
|
||||
}
|
||||
if opt.contains('d') {
|
||||
d.1 = 1.0;
|
||||
}
|
||||
if d == (0.0, 0.0) { default } else { d }
|
||||
}
|
||||
|
||||
/// A rectangle with a soft border `f` wide (slide units) fading out to nothing: the rectangle at
|
||||
/// full opacity, four edge ramps and four corner ramps. `src` maps onto `dst`; the border samples
|
||||
/// the texture's edge.
|
||||
pub(crate) fn soft_rect(source: Source, src: R, dst: R, f: f64, alpha: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::rect(source, src, dst, alpha)];
|
||||
if f <= 1e-6 {
|
||||
return v;
|
||||
}
|
||||
let (l, t, r, b) = (dst[0], dst[1], dst[2], dst[3]);
|
||||
let (fx, fy) = (f, f * 16.0 / 9.0);
|
||||
let uv_at = |x: f64, y: f64| -> P {
|
||||
let u = if r > l { src[0] + (x.clamp(l, r) - l) / (r - l) * (src[2] - src[0]) } else { src[0] };
|
||||
let w = if b > t { src[1] + (y.clamp(t, b) - t) / (b - t) * (src[3] - src[1]) } else { src[1] };
|
||||
[u, w]
|
||||
};
|
||||
let mut q = |pts: Q, fade: [f64; 4]| {
|
||||
let uv = pts.map(|p| uv_at(p[0], p[1]));
|
||||
let xs = uv.map(|p| p[0]);
|
||||
let ys = uv.map(|p| p[1]);
|
||||
let mn = |a: [f64; 4]| a.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let mx = |a: [f64; 4]| a.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
v.push(Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: pts, uv, alpha, fade });
|
||||
};
|
||||
q([[l, t - fy], [r, t - fy], [r, t], [l, t]], [0.0, 0.0, 1.0, 1.0]);
|
||||
q([[l, b], [r, b], [r, b + fy], [l, b + fy]], [1.0, 1.0, 0.0, 0.0]);
|
||||
q([[l - fx, t], [l, t], [l, b], [l - fx, b]], [0.0, 1.0, 1.0, 0.0]);
|
||||
q([[r, t], [r + fx, t], [r + fx, b], [r, b]], [1.0, 0.0, 0.0, 1.0]);
|
||||
q([[l - fx, t - fy], [l, t - fy], [l, t], [l - fx, t]], [0.0, 0.0, 1.0, 0.0]);
|
||||
q([[r, t - fy], [r + fx, t - fy], [r + fx, t], [r, t]], [0.0, 0.0, 0.0, 1.0]);
|
||||
q([[r, b], [r + fx, b], [r + fx, b + fy], [r, b + fy]], [1.0, 0.0, 0.0, 0.0]);
|
||||
q([[l - fx, b], [l, b], [l, b + fy], [l - fx, b + fy]], [0.0, 1.0, 0.0, 0.0]);
|
||||
v
|
||||
}
|
||||
|
||||
// ---------- opacity fields ----------
|
||||
|
||||
/// The source shown in place through an opacity field `f(x, y)` (0..1) sampled on an `n` × `m`
|
||||
/// grid: each cell is a quad with its corners' opacities. Cells with nothing to show are left out
|
||||
/// and runs of fully opaque cells merge, so most frames stay small.
|
||||
pub(crate) fn field(source: Source, n: usize, m: usize, f: &dyn Fn(f64, f64) -> f64) -> Vec<Layer> {
|
||||
let (n, m) = (n.clamp(1, 256), m.clamp(1, 256));
|
||||
let mut vals = vec![0.0; (n + 1) * (m + 1)];
|
||||
for j in 0..=m {
|
||||
for i in 0..=n {
|
||||
let v = f(i as f64 / n as f64, j as f64 / m as f64);
|
||||
if let Some(x) = vals.get_mut(j * (n + 1) + i) {
|
||||
*x = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = |i: usize, j: usize| vals.get(j * (n + 1) + i).copied().unwrap_or(0.0);
|
||||
let mut out = Vec::new();
|
||||
for j in 0..m {
|
||||
let (y0, y1) = (j as f64 / m as f64, (j + 1) as f64 / m as f64);
|
||||
let mut run: Option<usize> = None;
|
||||
for i in 0..=n {
|
||||
let full = i < n && at(i, j) >= 1.0 && at(i + 1, j) >= 1.0 && at(i, j + 1) >= 1.0 && at(i + 1, j + 1) >= 1.0;
|
||||
if full {
|
||||
run.get_or_insert(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = run.take() {
|
||||
out.push(Layer::inplace(source, [s as f64 / n as f64, y0, i as f64 / n as f64, y1], 1.0));
|
||||
}
|
||||
if i == n {
|
||||
break;
|
||||
}
|
||||
let c = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
|
||||
if c.iter().all(|v| *v <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
out.push(Layer::inplace(source, [i as f64 / n as f64, y0, (i + 1) as f64 / n as f64, y1], 1.0).faded(c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Opacity of a soft edge at position `p` when the edge's middle is at `c` and it ramps over `w`.
|
||||
pub(crate) fn edge(c: f64, p: f64, w: f64) -> f64 {
|
||||
if w <= 1e-9 {
|
||||
return if p <= c { 1.0 } else { 0.0 };
|
||||
}
|
||||
((c - p) / w + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Point on the unit square's boundary seen from its centre at angle `ang` (radians, clockwise
|
||||
/// from 12 o'clock, measured on a slide `a` wide per unit of height).
|
||||
pub(crate) fn boundary(ang: f64, a: f64) -> P {
|
||||
let (dx, dy) = (ang.sin() / a, -ang.cos());
|
||||
let m = (dx.abs() / 0.5).max(dy.abs() / 0.5).max(1e-9);
|
||||
[0.5 + dx / m, 0.5 + dy / m]
|
||||
}
|
||||
|
||||
/// The source shown in place through an angular opacity field `f(angle)` (radians clockwise from
|
||||
/// 12 o'clock, 0..2π): a fan of thin wedges from the centre.
|
||||
pub(crate) fn fan(source: Source, a: f64, steps: usize, f: &dyn Fn(f64) -> f64) -> Vec<Layer> {
|
||||
let n = steps.clamp(8, 720);
|
||||
let mut angles: Vec<f64> = (0..=n).map(|k| 2.0 * PI * k as f64 / n as f64).collect();
|
||||
// The square's corners, so wedges reach them.
|
||||
for c in [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]] {
|
||||
let ang = ((c[0] - 0.5) * a).atan2(-(c[1] - 0.5));
|
||||
angles.push(ang.rem_euclid(2.0 * PI));
|
||||
}
|
||||
angles.sort_by(f64::total_cmp);
|
||||
angles.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||
let mut out = Vec::new();
|
||||
let c = [0.5, 0.5];
|
||||
for w in angles.windows(2) {
|
||||
let [x, y] = w else { continue };
|
||||
let (fx, fy) = (f(*x).clamp(0.0, 1.0), f(*y).clamp(0.0, 1.0));
|
||||
if fx <= 0.0 && fy <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (bx, by) = (boundary(*x, a), boundary(*y, a));
|
||||
out.push(Layer::tri_uv(source, [c, bx, by], [c, bx, by], [(fx + fy) / 2.0, fx, fy], 1.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- polygons ----------
|
||||
|
||||
/// Keep the part of a convex polygon where `n · p <= d`.
|
||||
pub(crate) fn clip(poly: &[P], n: P, d: f64) -> Vec<P> {
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
let side = |p: P| n[0] * p[0] + n[1] * p[1] - d;
|
||||
for (i, p) in poly.iter().enumerate() {
|
||||
let q = poly.get((i + 1) % poly.len()).copied().unwrap_or(*p);
|
||||
let (sp, sq) = (side(*p), side(q));
|
||||
if sp <= 0.0 {
|
||||
out.push(*p);
|
||||
}
|
||||
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
|
||||
let k = sp / (sp - sq);
|
||||
out.push([p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A convex polygon as triangles: vertices drawn at `map(p)` with texture coordinate `p`.
|
||||
pub(crate) fn poly_layers(source: Source, poly: &[P], alpha: f64, map: &dyn Fn(P) -> P) -> Vec<Layer> {
|
||||
let Some(first) = poly.first().copied() else { return vec![] };
|
||||
poly.windows(2)
|
||||
.skip(1)
|
||||
.filter_map(|w| match w {
|
||||
[b, c] => Some(Layer::tri_uv(source, [map(first), map(*b), map(*c)], [first, *b, *c], [1.0; 3], alpha)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn rect_poly(r: R) -> Vec<P> {
|
||||
corners(r).to_vec()
|
||||
}
|
||||
|
||||
// ---------- 3-D ----------
|
||||
|
||||
/// A point in the 3-D scene: x right and y down in slide heights (the slide spans x ∈ [−a/2, a/2],
|
||||
/// y ∈ [−½, ½] at z = 0), z away from the viewer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct V3 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
pub(crate) fn v3(x: f64, y: f64, z: f64) -> V3 {
|
||||
V3 { x, y, z }
|
||||
}
|
||||
|
||||
impl V3 {
|
||||
pub fn add(self, o: V3) -> V3 {
|
||||
v3(self.x + o.x, self.y + o.y, self.z + o.z)
|
||||
}
|
||||
pub fn sub(self, o: V3) -> V3 {
|
||||
v3(self.x - o.x, self.y - o.y, self.z - o.z)
|
||||
}
|
||||
/// Rotate about the vertical axis through `c` (degrees; positive turns the right side away).
|
||||
pub fn yaw(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x * co - p.z * s, p.y, p.x * s + p.z * co).add(c)
|
||||
}
|
||||
/// Rotate about the horizontal axis through `c` (degrees; positive turns the top away).
|
||||
pub fn pitch(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x, p.y * co + p.z * s, -p.y * s + p.z * co).add(c)
|
||||
}
|
||||
/// Rotate in the screen plane about `c` (degrees, clockwise).
|
||||
pub fn roll(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x * co - p.y * s, p.x * s + p.y * co, p.z).add(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A perspective camera looking at the slide plane from `d` slide heights away.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Cam {
|
||||
pub a: f64,
|
||||
pub d: f64,
|
||||
}
|
||||
|
||||
impl Cam {
|
||||
pub fn p(&self, v: V3) -> P {
|
||||
let k = self.d / (self.d + v.z).max(self.d * 0.02);
|
||||
[v.x * k / self.a + 0.5, v.y * k + 0.5]
|
||||
}
|
||||
/// The 3-D corners (tl, tr, br, bl) of the unit-space rectangle `r` lying on the slide plane.
|
||||
pub fn rect(&self, r: R) -> [V3; 4] {
|
||||
corners(r).map(|p| v3((p[0] - 0.5) * self.a, p[1] - 0.5, 0.0))
|
||||
}
|
||||
/// The unit-space point of a 3-D point on the slide plane.
|
||||
pub fn unit(&self, x: f64, y: f64) -> V3 {
|
||||
v3((x - 0.5) * self.a, y - 0.5, 0.0)
|
||||
}
|
||||
pub fn quad(&self, c: [V3; 4]) -> Q {
|
||||
c.map(|v| self.p(v))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed area of a quad (positive: front side facing the viewer).
|
||||
pub(crate) fn area(q: &Q) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
s / 2.0
|
||||
}
|
||||
|
||||
/// A card: `src` of the source mapped onto 3-D corners, if it faces the viewer; `shade` (0..1)
|
||||
/// darkens it. Seen from behind it is left out unless `back` is set (then drawn mirrored).
|
||||
pub(crate) fn card(cam: &Cam, source: Source, src: R, c: [V3; 4], alpha: f64, shade: f64, back: bool) -> Vec<Layer> {
|
||||
let q = cam.quad(c);
|
||||
if !q.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let front = area(&q) > 0.0;
|
||||
if !front && !back {
|
||||
return vec![];
|
||||
}
|
||||
let l = Layer::quad(source, src, q, alpha);
|
||||
let mut v = vec![l];
|
||||
if shade > 0.001 {
|
||||
v.push(l.shade(shade));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// How much a card facing along `n` (unit normal toward the viewer is (0, 0, −1)) is darkened by
|
||||
/// the light behind the viewer: 0 facing straight on.
|
||||
pub(crate) fn facing_shade(c: [V3; 4], k: f64) -> f64 {
|
||||
let u = c[1].sub(c[0]);
|
||||
let w = c[3].sub(c[0]);
|
||||
let n = v3(u.y * w.z - u.z * w.y, u.z * w.x - u.x * w.z, u.x * w.y - u.y * w.x);
|
||||
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
|
||||
if len < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let cos = (n.z / len).abs();
|
||||
(k * (1.0 - cos)).clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Transition curves and geometry against values measured on PowerPoint's video export
|
||||
//! (`plan/powerpoint/tools/synth_transitions.ps1`, 2 s transitions, 30 fps).
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
|
||||
use crate::transition::{Layer, Source, transition_frame, transition_layers, uses_slide_parts};
|
||||
use crate::transition_geom::{edge, pow_inout, q2, ss};
|
||||
|
||||
const A: f64 = 16.0 / 9.0;
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
fn of(ls: &[Layer], src: Source) -> Vec<&Layer> {
|
||||
ls.iter().filter(|l| l.source == src).collect()
|
||||
}
|
||||
|
||||
fn left(l: &Layer) -> f64 {
|
||||
l.quad.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn top(l: &Layer) -> f64 {
|
||||
l.quad.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_follows_powerpoints_s_curve() {
|
||||
// Measured share of the new slide in a 2 s Fade at t = 0.25, 0.5, 0.75: 0.155, 0.49, 0.82.
|
||||
for (t, want) in [(0.25, 0.155), (0.5, 0.49), (0.75, 0.82)] {
|
||||
let ls = transition_layers("fade", "smoothly", t);
|
||||
let new = of(&ls, Source::New);
|
||||
assert!(near(new[0].alpha, want, 0.04), "t={t}: {}", new[0].alpha);
|
||||
}
|
||||
// Through black: the old slide is gone to black at the middle, then the new one fades in.
|
||||
let mid = transition_layers("fade", "throughBlack", 0.5);
|
||||
assert!(mid.iter().all(|l| l.source == Source::Black));
|
||||
let q = transition_layers("fade", "throughBlack", 0.25);
|
||||
assert!(near(of(&q, Source::Old)[0].alpha, 0.5, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_moves_both_slides_with_an_eased_curve() {
|
||||
for (o, dx, dy) in [("l", -1.0, 0.0), ("r", 1.0, 0.0), ("u", 0.0, -1.0), ("d", 0.0, 1.0)] {
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
let ls = transition_layers("push", o, t);
|
||||
let (old, new) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
|
||||
let s = q2(t);
|
||||
assert!(near(old.quad[0][0], dx * s, 1e-9) && near(old.quad[0][1], dy * s, 1e-9), "{o} {t}");
|
||||
assert!(near(new.quad[0][0], dx * (s - 1.0), 1e-9) && near(new.quad[0][1], dy * (s - 1.0), 1e-9), "{o} {t}");
|
||||
}
|
||||
}
|
||||
// Ease in-out: slow at both ends.
|
||||
assert!(q2(0.1) < 0.05 && q2(0.9) > 0.95 && near(q2(0.5), 0.5, 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_and_uncover_match_measured_positions() {
|
||||
// Offset of the new slide in Cover Left (fraction of the width) at measured times.
|
||||
for (t, off) in [(0.445, 0.655), (0.495, 0.512), (0.544, 0.359), (0.643, 0.134), (0.742, 0.046)] {
|
||||
let ls = transition_layers("cover", "l", t);
|
||||
assert!(near(left(of(&ls, Source::New)[0]), off, 0.05), "cover t={t}");
|
||||
}
|
||||
// Offset of the old slide in Uncover Left.
|
||||
for (t, off) in [(0.244, -0.06), (0.343, -0.14), (0.442, -0.32), (0.508, -0.5), (0.558, -0.63)] {
|
||||
let ls = transition_layers("uncover", "l", t);
|
||||
assert!(near(left(of(&ls, Source::Old)[0]), off, 0.05), "uncover t={t}");
|
||||
}
|
||||
// Diagonal Cover shoots in and settles slowly.
|
||||
for (t, off) in [(0.142, 0.728), (0.257, 0.49), (0.356, 0.29), (0.455, 0.146), (0.604, 0.053)] {
|
||||
let ls = transition_layers("cover", "lu", t);
|
||||
let n = of(&ls, Source::New)[0];
|
||||
assert!(near(left(n), off, 0.06) && near(top(n), off, 0.06), "cover lu t={t}");
|
||||
}
|
||||
assert!(near(pow_inout(0.25, 3.0), 0.0625, 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipe_has_a_wide_soft_edge() {
|
||||
// Measured along a row of Wipe Right at t = 0.5: about half-way through at x = 0.6, nearly
|
||||
// nothing at the right edge, fully new on the left third.
|
||||
let c = -0.4 + 1.8 * 1.05 * 0.5;
|
||||
assert!(near(edge(c, 0.6, 0.8), 0.49, 0.1));
|
||||
assert!(edge(c, 1.0, 0.8) < 0.1);
|
||||
assert!(edge(c, 0.2, 0.8) > 0.8);
|
||||
// Directions: "r" starts on the left, "l" on the right, "u" at the bottom, "d" at the top,
|
||||
// diagonals at the opposite corner.
|
||||
let first = |o: &str| {
|
||||
let ls = transition_layers("wipe", o, 0.15);
|
||||
let n = of(&ls, Source::New);
|
||||
assert!(!n.is_empty(), "{o}");
|
||||
let cx = n.iter().map(|l| (l.quad[0][0] + l.quad[2][0]) / 2.0).sum::<f64>() / n.len() as f64;
|
||||
let cy = n.iter().map(|l| (l.quad[0][1] + l.quad[2][1]) / 2.0).sum::<f64>() / n.len() as f64;
|
||||
(cx, cy)
|
||||
};
|
||||
assert!(first("r").0 < 0.3 && first("l").0 > 0.7);
|
||||
assert!(first("u").1 > 0.7 && first("d").1 < 0.3);
|
||||
let ru = first("ru");
|
||||
assert!(ru.0 < 0.4 && ru.1 > 0.6, "{ru:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_shape_open_from_the_middle() {
|
||||
for o in ["horzOut", "vertOut"] {
|
||||
let ls = transition_layers("split", o, 0.2);
|
||||
let n = of(&ls, Source::New);
|
||||
assert!(n.iter().all(|l| {
|
||||
let c = [(l.quad[0][0] + l.quad[2][0]) / 2.0, (l.quad[0][1] + l.quad[2][1]) / 2.0];
|
||||
if o.starts_with("horz") { (c[1] - 0.5).abs() < 0.4 } else { (c[0] - 0.5).abs() < 0.4 }
|
||||
}));
|
||||
}
|
||||
// Shape In is done with the old slide at half time; Shape Out starts the new one then.
|
||||
assert!(of(&transition_layers("shape", "in", 0.55), Source::Old).is_empty());
|
||||
assert!(of(&transition_layers("shape", "out", 0.45), Source::New).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_content_curves() {
|
||||
// Pan: the new content's offset (fraction of the width) at measured times.
|
||||
for (t, off) in [(0.6, 0.134), (0.7, 0.086), (0.8, 0.039), (0.9, 0.011)] {
|
||||
let ls = transition_layers("pan", "l", t);
|
||||
assert!(near(left(of(&ls, Source::NewContent)[0]), off, 0.07), "pan t={t}");
|
||||
}
|
||||
// The backgrounds cross-fade under the moving contents.
|
||||
let ls = transition_layers("pan", "l", 0.5);
|
||||
assert_eq!(ls.first().map(|l| l.source), Some(Source::OldBack));
|
||||
assert!(of(&ls, Source::NewBack)[0].alpha > 0.5);
|
||||
// Ferris Wheel: the old content drops (top edge, slide heights) as measured.
|
||||
for (t, dy) in [(0.147, 0.06), (0.246, 0.17), (0.345, 0.36), (0.444, 0.70)] {
|
||||
let ls = transition_layers("ferris", "l", t);
|
||||
let q = of(&ls, Source::OldContent)[0].quad;
|
||||
let cy = q.iter().map(|p| p[1]).sum::<f64>() / 4.0 - 0.5;
|
||||
assert!(near(cy, dy, 0.08), "ferris t={t}: {cy}");
|
||||
}
|
||||
for k in ["pan", "ferris", "conveyor", "rotate", "window", "orbit", "flythrough"] {
|
||||
assert!(uses_slide_parts(k, ""), "{k}");
|
||||
}
|
||||
assert!(uses_slide_parts("reveal", "smoothlyLeft") && !uses_slide_parts("reveal", "blackLeft"));
|
||||
assert!(!uses_slide_parts("cube", "l") && !uses_slide_parts("push", "l"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_turns_toward_the_option() {
|
||||
// Cube Left at the middle: the shared edge is near the middle, the old face on the left.
|
||||
let ls = transition_frame("cube", "l", 0.5, A);
|
||||
let (o, n) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
|
||||
let o_right = o.quad.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!(near(o_right, left(n), 0.02), "shared edge: {o_right} vs {}", left(n));
|
||||
assert!(o_right > 0.3 && o_right < 0.7);
|
||||
// Up: the new face comes from below.
|
||||
let ls = transition_frame("cube", "u", 0.5, A);
|
||||
assert!(top(of(&ls, Source::New)[0]) > top(of(&ls, Source::Old)[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_swaps_the_cards_past_halfway() {
|
||||
let order = |t: f64| {
|
||||
let ls = transition_frame("switch", "l", t, A);
|
||||
let pos = |s: Source| ls.iter().position(|l| l.source == s);
|
||||
(pos(Source::Old), pos(Source::New))
|
||||
};
|
||||
let (o, n) = order(0.3);
|
||||
assert!(o > n, "old in front early");
|
||||
let (o, n) = order(0.8);
|
||||
assert!(n > o, "new in front late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_hand_sweeps_at_constant_speed() {
|
||||
// The share of the slide covered grows linearly.
|
||||
let area = |t: f64| {
|
||||
let ls = transition_layers("clock", "clockwise", t);
|
||||
of(&ls, Source::New)
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let [a, b, c, _] = l.quad;
|
||||
((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])).abs() / 2.0 * (l.fade[0] + l.fade[1] + l.fade[2]) / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
};
|
||||
let (a1, a2, a3) = (area(0.25), area(0.5), area(0.75));
|
||||
assert!(near(a2 - a1, a3 - a2, 0.08), "{a1} {a2} {a3}");
|
||||
assert!(near(a2, 0.5, 0.08), "{a2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_change_the_frame() {
|
||||
// Every option of a transition draws something different from its other options midway.
|
||||
for (id, _, _, _, opts) in TRANSITIONS {
|
||||
if matches!(*id, "morph" | "none" | "random") || opts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for (i, a) in opts.iter().enumerate() {
|
||||
for b in opts.iter().skip(i + 1) {
|
||||
let differs = [0.2, 0.35, 0.5, 0.8].iter().any(|t| transition_layers(id, a, *t) != transition_layers(id, b, *t));
|
||||
assert!(differs, "{id}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_cheap() {
|
||||
// Per frame, the show draws these meshes from cached slide textures: keep them small.
|
||||
for (id, _, _, _, opts) in TRANSITIONS {
|
||||
let mut options: Vec<&str> = opts.to_vec();
|
||||
options.push("");
|
||||
for o in options {
|
||||
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
|
||||
let tris: usize = transition_frame(id, o, t, A).iter().map(|l| crate::layer_mesh(l).1.len()).sum();
|
||||
assert!(tris < 30_000, "{id}/{o} t={t}: {tris} triangles");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_aspects_are_safe() {
|
||||
for a in [f64::NAN, 0.0, -3.0, 1e9, f64::INFINITY] {
|
||||
for (id, ..) in TRANSITIONS {
|
||||
for l in transition_frame(id, "", 0.4, a) {
|
||||
assert!(l.quad.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
|
||||
assert!(l.fade.iter().all(|f| (0.0..=1.0).contains(f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(near(ss(0.5), 0.5, 1e-12));
|
||||
}
|
||||
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
||||
cmd!(
|
||||
"arrange.reorder",
|
||||
"Reorder Objects",
|
||||
["Arrange"],
|
||||
None,
|
||||
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||
has_slide,
|
||||
reorder
|
||||
),
|
||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
// Already in place (front of its group, say): no change and no undo step.
|
||||
let mut trial = s.doc()?.shapes().to_vec();
|
||||
order_tree(&mut trial, &ids, how, 0);
|
||||
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order_tree(list, &ids, how, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Every shape id with its depth, in tree order.
|
||||
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||
let mut out = vec![];
|
||||
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||
order_list(list, ids, how);
|
||||
}
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(children) = sh.children_mut() {
|
||||
order_tree(children, ids, how, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||
let st = s.doc()?;
|
||||
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||
Some(None) => st.shapes(),
|
||||
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||
};
|
||||
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||
return Ok(json!({"index": from}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
||||
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let sh = list.remove(i);
|
||||
list.insert(to.min(list.len()), sh);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
let at = to.min(list.len());
|
||||
list.insert(at, sh);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
edit_shapes(s, p, "arrange.align", |sh| {
|
||||
edit_geometry(s, p, "arrange.align", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let b = x.bounds();
|
||||
let (dx, dy) = match edge.as_str() {
|
||||
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets_pos.push((*id, d));
|
||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||
}
|
||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
||||
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||
if horiz {
|
||||
x.x += d;
|
||||
|
||||
@@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
@@ -118,26 +127,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
|
||||
/// includeHidden, textLayer, frame}`.
|
||||
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
|
||||
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
|
||||
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
|
||||
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
|
||||
o.slides = match p.get("slides") {
|
||||
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
|
||||
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
|
||||
_ => None,
|
||||
};
|
||||
o.include_hidden = bool_or(p, "includeHidden", false);
|
||||
o.text_layer = bool_or(p, "textLayer", true);
|
||||
o.frame_slides = bool_or(p, "frame", true);
|
||||
o
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
|
||||
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
|
||||
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
|
||||
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
|
||||
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
@@ -264,7 +306,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": written}))
|
||||
}
|
||||
"pdf" => {
|
||||
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = pdf_bytes(&st.doc, p)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -276,6 +318,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Info: what the Info page shows about the active presentation.
|
||||
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
"format": st.path.as_deref().map(format_for_path),
|
||||
"dirty": st.is_dirty(),
|
||||
"slides": doc.slides.len(),
|
||||
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
|
||||
"sections": doc.sections.len(),
|
||||
"bytes": bytes,
|
||||
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if let Some(i) = s.active_index() {
|
||||
s.close_document(i);
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
color
|
||||
),
|
||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
|
||||
cmd!(
|
||||
"format.case",
|
||||
"Change Case",
|
||||
["Home", "Font"],
|
||||
Some("Shift+F3"),
|
||||
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
|
||||
has_text_or_shapes,
|
||||
case
|
||||
),
|
||||
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
||||
cmd!(
|
||||
"format.spacing",
|
||||
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
if a == b {
|
||||
return ok();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = mode.clone();
|
||||
super::text::edit_text(s, move |body, _| {
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
pub mod table;
|
||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(format::specs());
|
||||
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
||||
})
|
||||
}
|
||||
|
||||
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||
/// locked targets are left out, and when every target is locked nothing changes.
|
||||
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(cmd, "the object is locked"));
|
||||
}
|
||||
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||
}
|
||||
|
||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"selection.select",
|
||||
"Select",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||
has_slide,
|
||||
select
|
||||
),
|
||||
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||
/// object from the list shows it again.
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||
let st = s.doc()?;
|
||||
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(bad("selection.select", "no such shape"));
|
||||
}
|
||||
let kind = valid.first().and_then(in_group);
|
||||
valid.retain(|i| in_group(i) == kind);
|
||||
let keep: Vec<ShapeId> = if toggle || add {
|
||||
let current = st.selection.shapes.clone();
|
||||
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||
return Ok(json!({"selected": current}));
|
||||
}
|
||||
current
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut next = keep;
|
||||
for i in &valid {
|
||||
match next.iter().position(|x| x == i) {
|
||||
Some(k) if toggle => {
|
||||
next.remove(k);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => next.push(*i),
|
||||
}
|
||||
}
|
||||
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||
if reveal.is_empty() {
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
})?;
|
||||
} else {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||
for id in &reveal {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
sh.hidden = false;
|
||||
}
|
||||
}
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||
}
|
||||
|
||||
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn any_hidden(v: &[Shape]) -> bool {
|
||||
let mut found = false;
|
||||
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||
found
|
||||
}
|
||||
if !any_hidden(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
fn rec(v: &mut [Shape], depth: usize) {
|
||||
for sh in v {
|
||||
sh.hidden = false;
|
||||
if depth < 64
|
||||
&& let Some(c) = sh.children_mut()
|
||||
{
|
||||
rec(c, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||
sh.hidden = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||
let x = 20.0 + 110.0 * i as f64;
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
ids.extend([h, g]);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||
fn tree(s: &Session) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(s.doc().unwrap().shapes())
|
||||
}
|
||||
|
||||
/// (hidden, locked)
|
||||
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||
(sh.hidden, sh.locked)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u32> {
|
||||
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn bx(s: &mut Session, id: u32) -> Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
// A child moves among its siblings only and stops at the group's front.
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||
// Steps that changed nothing left no undo step.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||
// Drag A to the front of the slide (an index past the end clamps).
|
||||
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||
// A child is dragged within its group: it never leaves it.
|
||||
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||
// Dropping where it already is changes nothing (no undo step).
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// One undo step per drop.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_show_all_and_hide_all() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||
assert!(flags(&s, b).0);
|
||||
// Hiding a group hides only the group; its children keep their own flags.
|
||||
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||
// Hide All hides the top level only.
|
||||
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||
// Show All shows everything, children included.
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(flags(&s, a).1);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(!flags(&s, a).1, "toggles");
|
||||
// Locking a group locks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||
// A child can be unlocked on its own.
|
||||
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||
// Unlocking the group unlocks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
let before = bx(&mut s, a);
|
||||
for (cmd, p) in [
|
||||
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||
}
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||
let bb = bx(&mut s, b);
|
||||
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||
// Formatting and the name still change.
|
||||
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||
// The arrow keys don't nudge it.
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let _ = s.key("ArrowRight", Mods::default());
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||
let (mut s, ids) = deck();
|
||||
let a = ids[0];
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// A: x 20..120, y 50..130.
|
||||
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_from_the_pane() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b, c]);
|
||||
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c, a, b]);
|
||||
// A child inside a group; children across nesting levels go together.
|
||||
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d]);
|
||||
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// Picking makes no undo step...
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// ...unless it shows a hidden object again.
|
||||
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, c).0);
|
||||
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_the_pane_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let e = ids[4];
|
||||
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||
undo(&mut s);
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||
}
|
||||
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||||
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
|
||||
cmd!(
|
||||
"shape.lock",
|
||||
"Lock",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||||
has_selection,
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_shapes(s, p, "shape.move", |sh| {
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||||
let lock = bool_or(p, "lockAspect", false);
|
||||
edit_shapes(s, p, "shape.resize", |sh| {
|
||||
edit_geometry(s, p, "shape.resize", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "shape.setBounds", |sh| {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_shapes(s, p, "shape.rotate", |sh| {
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_shapes(s, p, "shape.flip", |sh| {
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_shapes(s, p, "shape.adjust", |sh| {
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_shapes(s, p, "shape.change", |sh| {
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||||
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
sh.locked = v.unwrap_or(!sh.locked);
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||||
sh.locked = to;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for c in children {
|
||||
set_locked(c, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||||
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
edit_shapes(s, p, "picture.crop", |sh| {
|
||||
edit_geometry(s, p, "picture.crop", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
for (i, v) in c.iter().enumerate() {
|
||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||
|
||||
@@ -39,6 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
Some("Backspace"),
|
||||
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", 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),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||
@@ -187,7 +197,9 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point,
|
||||
&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 })?;
|
||||
// After End on a wrapped line the caret is drawn at that line's end.
|
||||
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||
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)))
|
||||
}
|
||||
@@ -232,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -503,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if a != b {
|
||||
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||
// (PowerPoint for Windows).
|
||||
ed::delete_word(body, a, dir == "wordForward")
|
||||
} else if a != b {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
"wordBackward" => ed::word_move(body, a, false),
|
||||
"wordForward" => ed::word_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
@@ -536,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
|
||||
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||
Some(line) => line.rtl,
|
||||
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_text::Affinity;
|
||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
let st = s.doc()?;
|
||||
@@ -545,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layout = layout_for(st, &t);
|
||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||
let caret = t.caret;
|
||||
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||
let to = match (to.as_str(), rtl) {
|
||||
("left", true) => "right",
|
||||
("right", true) => "left",
|
||||
("wordLeft", true) => "wordRight",
|
||||
("wordRight", true) => "wordLeft",
|
||||
(x, _) => x,
|
||||
}
|
||||
.to_string();
|
||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||
let mut upstream = false;
|
||||
let new = match to.as_str() {
|
||||
"left" if t.is_range() && !extend => collapse_to(true),
|
||||
"right" if t.is_range() && !extend => collapse_to(false),
|
||||
"left" => ed::char_move(&body, caret, false),
|
||||
"right" => ed::char_move(&body, caret, true),
|
||||
"wordLeft" => ed::word_move(&body, caret, false),
|
||||
"wordRight" => ed::word_move(&body, caret, true),
|
||||
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||
"paraStart" => (caret.0, 0),
|
||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
"paraUp" => ed::para_up(&body, caret),
|
||||
"paraDown" => ed::para_down(&body, caret),
|
||||
"start" => (0, 0),
|
||||
"end" => ed::end(&body),
|
||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||
Some(l) => {
|
||||
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
||||
let r = match to.as_str() {
|
||||
"up" => l.vertical(lp(caret), false, x),
|
||||
"down" => l.vertical(lp(caret), true, x),
|
||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
||||
_ => l.line_bounds(lp(caret)).1,
|
||||
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||
let (r, a) = match to.as_str() {
|
||||
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||
_ => l.line_end_caret(lp(caret), aff),
|
||||
};
|
||||
upstream = a == Affinity::Upstream;
|
||||
(r.para, r.ch)
|
||||
}
|
||||
None => match to.as_str() {
|
||||
@@ -581,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = new;
|
||||
tt.line_end = if upstream { Some(new) } else { None };
|
||||
if !extend {
|
||||
tt.anchor = new;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"caret": [new.0, new.1]}))
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -633,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
let body = body_of(st, &t);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = (at.0, n);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
|
||||
+79
-11
@@ -20,6 +20,30 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
||||
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
||||
}
|
||||
|
||||
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
|
||||
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
|
||||
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
|
||||
pub fn key_token(key: &str) -> String {
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
match k {
|
||||
"period" | ">" => ".",
|
||||
"comma" | "<" => ",",
|
||||
"openbracket" | "{" => "[",
|
||||
"closebracket" | "}" => "]",
|
||||
"equals" => "=",
|
||||
"minus" => "-",
|
||||
"plus" => "+",
|
||||
"slash" => "/",
|
||||
"backslash" => "\\",
|
||||
"semicolon" => ";",
|
||||
"quote" => "'",
|
||||
"backtick" => "`",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
@@ -28,21 +52,17 @@ impl Session {
|
||||
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();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
let r = self.execute(id, ¶ms)?;
|
||||
return Ok(json!({"handled": true, "command": id, "result": r}));
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
|
||||
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
|
||||
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
|
||||
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
|
||||
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
|
||||
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
|
||||
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
|
||||
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
||||
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
||||
"tab" => {
|
||||
@@ -117,6 +137,10 @@ impl Session {
|
||||
}
|
||||
// Shortcuts from the registry.
|
||||
let key_name = match k {
|
||||
"left" => "Left",
|
||||
"right" => "Right",
|
||||
"up" => "Up",
|
||||
"down" => "Down",
|
||||
"enter" | "return" => "Enter",
|
||||
"escape" | "esc" => "Escape",
|
||||
"delete" => "Delete",
|
||||
@@ -127,7 +151,7 @@ impl Session {
|
||||
"end" => "End",
|
||||
"tab" => "Tab",
|
||||
"space" | " " => "Space",
|
||||
_ => key,
|
||||
_ => k,
|
||||
};
|
||||
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
||||
if let Some(id) = found {
|
||||
@@ -137,6 +161,50 @@ impl Session {
|
||||
Ok(json!({"handled": false}))
|
||||
}
|
||||
|
||||
/// The text-editing command for a navigation or deletion key while editing text, by
|
||||
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
|
||||
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
|
||||
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
|
||||
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
|
||||
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
|
||||
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
|
||||
if mods.cmd && !mods.alt && !mods.shift {
|
||||
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
|
||||
match k {
|
||||
"]" => return Some(("format.grow", json!({}))),
|
||||
"[" => return Some(("format.shrink", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let plain = !mods.cmd && !mods.alt;
|
||||
let to = match k {
|
||||
"left" if word => "wordLeft",
|
||||
"right" if word => "wordRight",
|
||||
"left" if mac_cmd => "lineStart",
|
||||
"right" if mac_cmd => "lineEnd",
|
||||
"left" if plain => "left",
|
||||
"right" if plain => "right",
|
||||
"up" if mac_cmd => "start",
|
||||
"down" if mac_cmd => "end",
|
||||
"up" if word && self.mac_keys => "paraStart",
|
||||
"down" if word && self.mac_keys => "paraEnd",
|
||||
"up" if word => "paraUp",
|
||||
"down" if word => "paraDown",
|
||||
"up" if plain => "up",
|
||||
"down" if plain => "down",
|
||||
"home" if mods.cmd && !mods.alt => "start",
|
||||
"end" if mods.cmd && !mods.alt => "end",
|
||||
"home" if plain => "lineStart",
|
||||
"end" if plain => "lineEnd",
|
||||
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
|
||||
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
|
||||
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
|
||||
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
|
||||
_ => return None,
|
||||
};
|
||||
Some(("text.move", json!({"to": to, "extend": mods.shift})))
|
||||
}
|
||||
|
||||
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
||||
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
||||
let st = self.doc()?;
|
||||
|
||||
@@ -68,6 +68,10 @@ pub struct TextSel {
|
||||
/// Editing the slide's speaker notes (shape is ignored).
|
||||
#[serde(default)]
|
||||
pub notes: bool,
|
||||
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
|
||||
/// start of the next line. Ignored once the caret moves anywhere else.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub line_end: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TextSel {
|
||||
@@ -77,6 +81,10 @@ impl TextSel {
|
||||
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
||||
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
||||
}
|
||||
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
|
||||
pub fn at_line_end(&self) -> bool {
|
||||
self.line_end == Some(self.caret)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
||||
@@ -339,6 +347,10 @@ pub struct Session {
|
||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
|
||||
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
|
||||
/// platform DeckCraft was built for.
|
||||
pub mac_keys: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -366,6 +378,7 @@ impl Session {
|
||||
recovery_dir: None,
|
||||
media_status: Default::default(),
|
||||
gif_paused: Default::default(),
|
||||
mac_keys: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
@@ -492,6 +505,10 @@ impl Session {
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
|
||||
if let Some(t) = st.selection.text.as_mut() {
|
||||
t.line_end = None;
|
||||
}
|
||||
f(&doc, &mut st.selection);
|
||||
clamp_selection(st);
|
||||
st.revision += 1;
|
||||
@@ -536,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_keys;
|
||||
|
||||
@@ -774,3 +774,64 @@ fn ime_composition_underlines_follow_the_text() {
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// File › Info (#76): the active presentation's name, location, counts and properties.
|
||||
#[test]
|
||||
fn file_info_reports_the_presentation() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 1})).unwrap();
|
||||
s.execute("slide.hide", &json!({})).unwrap();
|
||||
s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap();
|
||||
let v = s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(v["slides"], 3);
|
||||
assert_eq!(v["hiddenSlides"], 1);
|
||||
assert_eq!(v["path"], serde_json::Value::Null);
|
||||
assert_eq!(v["dirty"], true);
|
||||
assert_eq!(v["properties"]["title"], "Quarterly");
|
||||
assert_eq!(v["properties"]["author"], "Ada");
|
||||
assert!(v["title"].as_str().is_some_and(|t| !t.is_empty()));
|
||||
// A query: no undo step, and nothing changes.
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
||||
// Saved to disk, it reports where and how big.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Info test.pptx").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
let v = s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(v["path"], path.as_str());
|
||||
assert_eq!(v["format"], "pptx");
|
||||
assert_eq!(v["dirty"], false);
|
||||
assert!(v["bytes"].as_u64().is_some_and(|b| b > 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Nothing open: an error, not a panic.
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute("file.info", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based.
|
||||
#[test]
|
||||
fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
use cmd::file::parse_slide_range as r;
|
||||
assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]);
|
||||
assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats");
|
||||
assert_eq!(r(" 6 ", 6).unwrap(), vec![5]);
|
||||
assert!(r("7", 6).is_err(), "past the last slide");
|
||||
assert!(r("0", 6).is_err());
|
||||
assert!(r("a-b", 6).is_err());
|
||||
assert!(r(" , ", 6).is_err());
|
||||
assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused");
|
||||
// file.export takes the same text for a PDF.
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap();
|
||||
let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap();
|
||||
assert!(one.len() < both.len());
|
||||
assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err());
|
||||
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP
|
||||
//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::Mods;
|
||||
|
||||
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
|
||||
const CTRL: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||
const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true };
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||
|
||||
type Pos = (usize, usize);
|
||||
|
||||
/// A session editing a new text box holding `text` (Windows keys), caret at `at`.
|
||||
fn editing(text: &str, at: Pos) -> (Session, u32) {
|
||||
editing_in(text, at, [60.0, 60.0, 600.0, 300.0])
|
||||
}
|
||||
|
||||
fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) {
|
||||
let mut s = Session::with_new();
|
||||
s.mac_keys = false;
|
||||
s.prefs.autocorrect = false;
|
||||
s.prefs.smart_quotes = false;
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap();
|
||||
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("text.set", &json!({"id": id, "text": text})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (Pos, Pos) {
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
(t.anchor, t.caret)
|
||||
}
|
||||
|
||||
fn caret(s: &Session) -> Pos {
|
||||
sel(s).1
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, key: &str, mods: Mods) -> Value {
|
||||
let r = s.key(key, mods).unwrap();
|
||||
assert_eq!(r["handled"], true, "{key} {mods:?} not handled");
|
||||
r
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, id: u32) -> String {
|
||||
s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Press `key` until the caret stops moving; the carets visited (in the first paragraph).
|
||||
fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec<usize> {
|
||||
let mut out = vec![caret(s).1];
|
||||
for _ in 0..200 {
|
||||
let before = caret(s);
|
||||
press(s, key, mods);
|
||||
if caret(s) == before {
|
||||
break;
|
||||
}
|
||||
out.push(caret(s).1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_stop_where_powerpoint_does() {
|
||||
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||
let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74];
|
||||
let (mut s, _) = editing(t, (0, 0));
|
||||
assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops);
|
||||
let mut back = stops.clone();
|
||||
back.reverse();
|
||||
assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() {
|
||||
let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]);
|
||||
// Arabic and Hebrew words inside a left-to-right paragraph: in logical order.
|
||||
let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]);
|
||||
// An emoji ZWJ sequence and a combining mark are never split.
|
||||
let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]);
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() {
|
||||
let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0));
|
||||
let mut seen = vec![caret(&s)];
|
||||
for _ in 0..9 {
|
||||
press(&mut s, "Right", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]);
|
||||
// A line break and a tab are stops of their own.
|
||||
s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_arrows_extend_by_words() {
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2));
|
||||
let mut carets = vec![];
|
||||
for _ in 0..6 {
|
||||
press(&mut s, "Right", CTRL_SHIFT);
|
||||
carets.push(sel(&s));
|
||||
}
|
||||
let a = (0, 2);
|
||||
assert_eq!(carets.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]);
|
||||
assert!(carets.iter().all(|x| x.0 == a));
|
||||
let mut back = vec![];
|
||||
for _ in 0..7 {
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
back.push(caret(&s));
|
||||
}
|
||||
assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]);
|
||||
assert_eq!(sel(&s).0, a);
|
||||
// From a caret inside a word going left: the word's start, then on.
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 2), (0, 21)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_leave_a_selection_from_its_ends() {
|
||||
for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] {
|
||||
let (mut s, _) = editing("Hello world foo, bar", anchor);
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 13), (0, 13)));
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 0), (0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_backspace_and_delete() {
|
||||
let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13));
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para");
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ","));
|
||||
assert_eq!(caret(&s), (0, 5));
|
||||
// At a paragraph's ends they join paragraphs.
|
||||
s.execute("text.move", &json!({"to": "paraEnd"})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello, barnext para");
|
||||
// A selection is not deleted: the word goes from its start (PowerPoint for Windows).
|
||||
let (mut s, id) = editing("Hello world", (0, 2));
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap();
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0))));
|
||||
let (mut s, id) = editing("Hello world", (0, 7));
|
||||
s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2))));
|
||||
// It is one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&mut s, id), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_up_down_move_by_paragraph() {
|
||||
let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para";
|
||||
let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Down", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]);
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
press(&mut s, "Left", NONE);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Up", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]);
|
||||
// With Shift they extend; back past the anchor the selection flips.
|
||||
s.execute("text.edit", &json!({"at": [0, 5]})).unwrap();
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (2, 0)));
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_end_and_ctrl_home_end() {
|
||||
let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond";
|
||||
let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]);
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let layout = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect();
|
||||
assert!(lines.len() >= 3, "{lines:?}");
|
||||
let (l1, l2) = (lines[1], lines[2]);
|
||||
// End goes past a wrapped line's trailing space, to where the next line starts, as in
|
||||
// PowerPoint, and the caret stays drawn on the line it ended.
|
||||
let next = lines[2].0;
|
||||
s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap();
|
||||
press(&mut s, "Home", NONE);
|
||||
assert_eq!(caret(&s), (0, l1.0));
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
let drawn = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let l = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap()
|
||||
};
|
||||
let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top;
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// End again stays on the line; Home goes back to its start, not the next line's.
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
press(&mut s, "Home", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, l1.0)));
|
||||
press(&mut s, "End", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, next)));
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// Down from a line's end lands on the next line, not two lines down.
|
||||
press(&mut s, "Down", NONE);
|
||||
let c = caret(&s);
|
||||
assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}");
|
||||
// The caret is drawn at the end of the line it belongs to.
|
||||
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||
let ts = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(ts.at_line_end());
|
||||
// Ctrl+Home / Ctrl+End, with Shift extending.
|
||||
press(&mut s, "End", CTRL);
|
||||
assert_eq!(caret(&s), (1, 6));
|
||||
press(&mut s, "Home", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 6), (0, 0)));
|
||||
press(&mut s, "Home", CTRL);
|
||||
press(&mut s, "End", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 6)));
|
||||
// A caret placed anywhere else is drawn at the start of its line again.
|
||||
s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap();
|
||||
assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
assert!(drawn(&s).1 > line1_top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_swap_left_and_right() {
|
||||
let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0));
|
||||
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||
// Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back.
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]);
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]);
|
||||
press(&mut s, "Left", NONE);
|
||||
assert_eq!(caret(&s), (0, 1));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 1), (0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_keys() {
|
||||
let (mut s, id) = editing("Hello world foo\nnext", (0, 8));
|
||||
s.mac_keys = true;
|
||||
press(&mut s, "Right", ALT);
|
||||
assert_eq!(caret(&s), (0, 12));
|
||||
press(&mut s, "Left", ALT);
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(caret(&s), (0, 15));
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
press(&mut s, "Down", CTRL);
|
||||
assert_eq!(caret(&s), (1, 4));
|
||||
press(&mut s, "Up", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap();
|
||||
press(&mut s, "Backspace", ALT);
|
||||
assert_eq!(text(&mut s, id), "Hello foo\nnext");
|
||||
// Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows.
|
||||
s.mac_keys = false;
|
||||
let before = caret(&s);
|
||||
let r = s.key("Right", ALT).unwrap();
|
||||
assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_keys() {
|
||||
let (mut s, _) = editing("hello wOrld. foo bar", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||
let base = size(&mut s).unwrap_or(18.0);
|
||||
// egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`.
|
||||
press(&mut s, "Period", CTRL_SHIFT);
|
||||
let up = size(&mut s).unwrap();
|
||||
assert!(up > base);
|
||||
press(&mut s, "Comma", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, "CloseBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
press(&mut s, "OpenBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, ">", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_underline_and_clear_on_a_caret_in_a_word() {
|
||||
let (mut s, id) = editing("hello wOrld. foo bar", (0, 8));
|
||||
let r = press(&mut s, "b", CTRL);
|
||||
assert_eq!(r["command"], "format.bold");
|
||||
let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
let runs: Vec<(String, Option<bool>)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect();
|
||||
assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld"));
|
||||
// At a word boundary nothing visible changes.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap();
|
||||
press(&mut s, "i", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true)));
|
||||
// Ctrl+Space clears the word's formatting.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "Space", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true)));
|
||||
press(&mut s, "u", CTRL);
|
||||
let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect();
|
||||
assert_eq!(under, "wOrld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_keys() {
|
||||
let (mut s, _) = editing("one\ntwo", (0, 1));
|
||||
for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] {
|
||||
press(&mut s, k, CTRL);
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["align"], want, "Ctrl+{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_f3_cycles_case() {
|
||||
let (mut s, id) = editing("hello world", (0, 0));
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..3 {
|
||||
press(&mut s, "F3", SHIFT);
|
||||
seen.push(text(&mut s, id));
|
||||
}
|
||||
assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]);
|
||||
s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello WORLD. foo");
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 6)));
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello world. foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_and_word_and_paragraph_selection() {
|
||||
let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7));
|
||||
s.execute("text.selectWord", &json!({})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 11)));
|
||||
s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 11), (0, 12)));
|
||||
// Triple-click: the paragraph and its mark.
|
||||
s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 0)));
|
||||
s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap();
|
||||
assert_eq!(sel(&s), ((1, 0), (1, 4)));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap();
|
||||
press(&mut s, "Enter", SHIFT);
|
||||
let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
assert_eq!(b.paragraphs.len(), 2);
|
||||
assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_never_panic_on_any_text() {
|
||||
let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "];
|
||||
let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"];
|
||||
for t in texts {
|
||||
for k in keys {
|
||||
for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] {
|
||||
for at in [(0, 0), (0, 1), (5, 5)] {
|
||||
let (mut s, _) = editing(t, at);
|
||||
for _ in 0..3 {
|
||||
let _ = s.key(k, m).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,10 @@ impl Session {
|
||||
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
||||
for id in st.selection.shapes.iter().rev() {
|
||||
let Some(sh) = st.shape(*id) else { continue };
|
||||
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
|
||||
if sh.locked {
|
||||
continue;
|
||||
}
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if sh.is_line() {
|
||||
let a = x.affine();
|
||||
@@ -247,8 +251,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
if sh.hidden || sh.locked {
|
||||
if sh.hidden {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hit_shape(st, sh, p, tol) {
|
||||
@@ -423,7 +428,9 @@ impl Session {
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// Locked objects stay put when the selection is dragged.
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint.
|
||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||
@@ -433,7 +440,8 @@ impl Session {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
|
||||
let duplicate = mods.alt && origs.len() == ids.len();
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
|
||||
// Format Painter applies on click.
|
||||
if self.painter.is_some() && !toggle {
|
||||
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
||||
@@ -464,7 +472,7 @@ impl Session {
|
||||
match drag {
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
let mut d = p - start;
|
||||
if !moved && d.hypot() < tol.max(1.0) {
|
||||
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
|
||||
@@ -17,6 +17,11 @@ use skrifa::raw::TableProvider;
|
||||
use crate::script::{ScriptTag, script_of};
|
||||
use crate::{CRAFT_FONTS, FontDb, FontFace};
|
||||
|
||||
/// Variation selector 15: text presentation.
|
||||
const TEXT_SELECTOR: char = '\u{FE0E}';
|
||||
/// Combining enclosing keycap.
|
||||
const KEYCAP: char = '\u{20E3}';
|
||||
|
||||
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
|
||||
fn ignorable(c: char) -> bool {
|
||||
c.is_whitespace()
|
||||
@@ -24,20 +29,72 @@ fn ignorable(c: char) -> bool {
|
||||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
|
||||
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
|
||||
/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone
|
||||
/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text
|
||||
/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector.
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
let default_emoji = |c: char| {
|
||||
matches!(
|
||||
c as u32,
|
||||
0x1F300..=0x1F64F
|
||||
| 0x1F680..=0x1F6FF
|
||||
| 0x1F900..=0x1F9FF
|
||||
| 0x1FA70..=0x1FAFF
|
||||
| 0x1F1E6..=0x1F1FF
|
||||
| 0x1F004
|
||||
| 0x1F0CF
|
||||
| 0x1F18E
|
||||
| 0x1F191..=0x1F19A
|
||||
| 0x1F201
|
||||
| 0x1F21A
|
||||
| 0x1F22F
|
||||
| 0x1F232..=0x1F236
|
||||
| 0x1F238..=0x1F23A
|
||||
| 0x1F250..=0x1F251
|
||||
| 0x231A..=0x231B
|
||||
| 0x23E9..=0x23EC
|
||||
| 0x23F0
|
||||
| 0x23F3
|
||||
| 0x25FD..=0x25FE
|
||||
| 0x2614..=0x2615
|
||||
| 0x2648..=0x2653
|
||||
| 0x267F
|
||||
| 0x2693
|
||||
| 0x26A1
|
||||
| 0x26AA..=0x26AB
|
||||
| 0x26BD..=0x26BE
|
||||
| 0x26C4..=0x26C5
|
||||
| 0x26CE
|
||||
| 0x26D4
|
||||
| 0x26EA
|
||||
| 0x26F2..=0x26F3
|
||||
| 0x26F5
|
||||
| 0x26FA
|
||||
| 0x26FD
|
||||
| 0x2705
|
||||
| 0x270A..=0x270B
|
||||
| 0x2728
|
||||
| 0x274C
|
||||
| 0x274E
|
||||
| 0x2753..=0x2755
|
||||
| 0x2757
|
||||
| 0x2795..=0x2797
|
||||
| 0x27B0
|
||||
| 0x27BF
|
||||
| 0x2B1B..=0x2B1C
|
||||
| 0x2B50
|
||||
| 0x2B55
|
||||
)
|
||||
};
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
@@ -86,12 +143,13 @@ impl FontDb {
|
||||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
|
||||
let text_style = cluster.contains(&TEXT_SELECTOR) || cluster.contains(&KEYCAP);
|
||||
if text_style
|
||||
&& let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.plain_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
@@ -124,11 +182,23 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
|
||||
match self.fallback_for(key, exclude) {
|
||||
let f = match self.fallback_for(key, exclude) {
|
||||
Some(f) if covers_cluster(&f, cluster) => f,
|
||||
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
|
||||
_ => first(),
|
||||
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
|
||||
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
|
||||
// font with missing-glyph boxes.
|
||||
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
|
||||
};
|
||||
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
|
||||
// colour emoji font: PowerPoint draws it from a plain symbol font.
|
||||
if f.is_color()
|
||||
&& !is_emoji_cluster(cluster)
|
||||
&& let Some(plain) = self.plain_face_for(key, cluster)
|
||||
{
|
||||
return plain;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
|
||||
|
||||
@@ -134,6 +134,12 @@ pub struct FontFace {
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
/// Underline position (top edge, below the baseline) and thickness in font units (`post`
|
||||
/// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there.
|
||||
pub underline: (f64, f64),
|
||||
/// Strikethrough position (top edge, above the baseline) and thickness in font units (OS/2;
|
||||
/// 0.26 and 0.05 em when the font has none). PowerPoint draws single strikethroughs there.
|
||||
pub strikeout: (f64, f64),
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -586,6 +592,8 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64),
|
||||
strikeout: strikeout_metrics(m.strikeout, m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
@@ -597,6 +605,30 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
})
|
||||
}
|
||||
|
||||
/// The underline's top edge below the baseline and its thickness, font units, from the `post`
|
||||
/// table (its position is the top edge: Arial's -217/2048 is where PowerPoint's underline starts,
|
||||
/// 10 px below the baseline at 96 pt); fonts without a usable one get 0.1 em and 0.05 em.
|
||||
fn underline_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
|
||||
match d {
|
||||
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && (d.offset as f64).abs() < upem * 0.5 => {
|
||||
(-(d.offset as f64), d.thickness as f64)
|
||||
}
|
||||
_ => (upem * 0.1, upem * 0.05),
|
||||
}
|
||||
}
|
||||
|
||||
/// The strikethrough's top edge above the baseline and its thickness, font units, from OS/2 (its
|
||||
/// position is the top edge: Arial's 530/2048 is where PowerPoint's strikethrough starts, 25 px
|
||||
/// above the baseline at 96 pt); fonts without a usable one get 0.26 em and 0.05 em.
|
||||
fn strikeout_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
|
||||
match d {
|
||||
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && d.offset > 0.0 && (d.offset as f64) < upem => {
|
||||
(d.offset as f64, d.thickness as f64)
|
||||
}
|
||||
_ => (upem * 0.26, upem * 0.05),
|
||||
}
|
||||
}
|
||||
|
||||
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
|
||||
pub fn base_style(style: &str) -> &str {
|
||||
style.split('{').next().unwrap_or(style).trim()
|
||||
@@ -1060,6 +1092,79 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// A loaded plain (not colour) face drawing all of `cluster`, loading an installed plain font
|
||||
/// that covers `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn plain_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
// Symbol fonts first (PowerPoint draws ⚠ ♥ ⬆ from the system's symbol font), then the
|
||||
// usual fallback families in order, then any other.
|
||||
let rank = |f: &FontFace| {
|
||||
if f.family.contains("Symbol") {
|
||||
return 0;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(i) = SYSTEM_FALLBACKS.iter().position(|s| s.eq_ignore_ascii_case(&f.family)) {
|
||||
return i + 1;
|
||||
}
|
||||
1000
|
||||
};
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect();
|
||||
v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_plain_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed font without colour tables covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_plain_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
!has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
|
||||
@@ -141,10 +141,15 @@ mod tests {
|
||||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
// As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even
|
||||
// without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and
|
||||
// keycaps always as text.
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"]
|
||||
{
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"]
|
||||
{
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+359
-40
@@ -2,8 +2,11 @@
|
||||
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
|
||||
//! Output is device-space items; `lib.rs` maps them into the picture frame.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align};
|
||||
use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
|
||||
|
||||
pub(crate) const MM_TEXT: u32 = 1;
|
||||
@@ -15,9 +18,21 @@ pub(crate) const PS_NULL: u32 = 5;
|
||||
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
|
||||
const BS_SOLID: u32 = 0;
|
||||
pub(crate) const BS_NULL: u32 = 1;
|
||||
pub(crate) const BS_HATCHED: u32 = 2;
|
||||
/// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile.
|
||||
const BS_PATTERN: u32 = 3;
|
||||
const BS_DIBPATTERN: u32 = 5;
|
||||
const BS_DIBPATTERNPT: u32 = 6;
|
||||
const WINDING: u32 = 2;
|
||||
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
|
||||
/// SETROP2 mix modes. R2_NOP leaves the destination alone: outlines and fills draw nothing.
|
||||
/// R2_XORPEN and R2_MASKPEN draw the classic see-through shadow (XOR a colour, AND a pattern, XOR
|
||||
/// the colour again); the others draw as R2_COPYPEN.
|
||||
const R2_XORPEN: u32 = 7;
|
||||
const R2_MASKPEN: u32 = 9;
|
||||
const R2_NOP: u32 = 11;
|
||||
const R2_COPYPEN: u32 = 13;
|
||||
/// SETBKMODE OPAQUE: text cells and hatch gaps are filled with the background colour.
|
||||
const OPAQUE: u32 = 2;
|
||||
const PATCOPY: u32 = 0x00F0_0021;
|
||||
const BLACKNESS: u32 = 0x0000_0042;
|
||||
const WHITENESS: u32 = 0x00FF_0062;
|
||||
@@ -48,20 +63,78 @@ pub(crate) struct Pen {
|
||||
pub color: [u8; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct Brush {
|
||||
pub style: u32,
|
||||
pub color: [u8; 4],
|
||||
/// HS_* hatch style of a BS_HATCHED brush.
|
||||
pub hatch: u32,
|
||||
/// The tile of a pattern brush.
|
||||
pub pattern: Option<Arc<Pattern>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
impl Brush {
|
||||
pub fn new(style: u32, color: [u8; 4]) -> Self {
|
||||
Brush { style, color, hatch: 0, pattern: None }
|
||||
}
|
||||
|
||||
/// A pattern brush painting `img`. A monochrome brush paints its dark pixels in the text
|
||||
/// colour and its light ones in the background colour. None for an empty or oversized tile.
|
||||
pub fn pattern(img: Rgba, mono: bool) -> Option<Self> {
|
||||
if img.w == 0 || img.h == 0 || img.w > MAX_TILE_SIDE || img.h > MAX_TILE_SIDE || img.px.len() != img.w * img.h * 4 {
|
||||
return None;
|
||||
}
|
||||
let pattern = Arc::new(Pattern { w: img.w, h: img.h, px: img.px, mono, plain: OnceLock::new() });
|
||||
Some(Brush { style: BS_DIBPATTERNPT, color: [0, 0, 0, 255], hatch: 0, pattern: Some(pattern) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern brush's tile, top-down RGBA.
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub(crate) struct Pattern {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
pub mono: bool,
|
||||
/// The tile as drawn when nothing recolours it, shared by every fill with this brush.
|
||||
pub plain: OnceLock<Arc<RawTile>>,
|
||||
}
|
||||
|
||||
/// Largest pattern tile side kept (GDI brushes are typically 8×8).
|
||||
pub(crate) const MAX_TILE_SIDE: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Obj {
|
||||
Pen(Pen),
|
||||
Brush(Brush),
|
||||
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
|
||||
Font(Arc<LogFont>),
|
||||
/// Palettes and regions: they take a table slot but draw nothing.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A font object: the neutral [`Font`] and its escapement (tenths of a degree, counter-clockwise),
|
||||
/// which the text transform takes.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub(crate) struct LogFont {
|
||||
pub font: Font,
|
||||
pub escapement: f64,
|
||||
}
|
||||
|
||||
/// A brush tile in device units: `cell` device units per tile pixel, laid from the device origin.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct RawTile {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
pub cell: f64,
|
||||
}
|
||||
|
||||
/// What a fill paints.
|
||||
enum Paint {
|
||||
Solid([u8; 4]),
|
||||
Tile(Arc<RawTile>),
|
||||
}
|
||||
|
||||
/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA.
|
||||
pub(crate) fn colorref(c: u32) -> [u8; 4] {
|
||||
[(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255]
|
||||
@@ -166,8 +239,34 @@ fn ratio(num: f64, den: f64) -> f64 {
|
||||
|
||||
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
|
||||
pub(crate) enum Raw {
|
||||
Path { ops: Vec<Op>, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
|
||||
Bitmap { rect: [f64; 4], img: Rgba },
|
||||
Path {
|
||||
ops: Vec<Op>,
|
||||
fill: Option<[u8; 4]>,
|
||||
stroke: Option<([u8; 4], f64)>,
|
||||
even_odd: bool,
|
||||
},
|
||||
/// A path filled with a repeating tile.
|
||||
Pattern {
|
||||
ops: Vec<Op>,
|
||||
even_odd: bool,
|
||||
tile: Arc<RawTile>,
|
||||
},
|
||||
Bitmap {
|
||||
rect: [f64; 4],
|
||||
img: Rgba,
|
||||
},
|
||||
/// Text whose transform and clip are in device units.
|
||||
Text(Box<Text>),
|
||||
}
|
||||
|
||||
/// Text drawing state: colours, background mode, alignment and the selected font.
|
||||
#[derive(Clone)]
|
||||
struct TextState {
|
||||
color: [u8; 4],
|
||||
bk_color: [u8; 4],
|
||||
bk_opaque: bool,
|
||||
align: u32,
|
||||
font: Option<Arc<LogFont>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -176,7 +275,8 @@ struct Saved {
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
nop: bool,
|
||||
rop2: u32,
|
||||
text: TextState,
|
||||
}
|
||||
|
||||
pub(crate) struct Canvas {
|
||||
@@ -193,8 +293,13 @@ pub(crate) struct Canvas {
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
/// The mix mode is R2_NOP: shapes draw nothing.
|
||||
nop: bool,
|
||||
/// SETROP2 mix mode.
|
||||
rop2: u32,
|
||||
/// The colour of the last fill drawn with R2_XORPEN, for the see-through shadow idiom.
|
||||
xor_color: Option<[u8; 4]>,
|
||||
text: TextState,
|
||||
/// Device units per pattern-brush pixel: brushes are laid out in 96-dpi pixels.
|
||||
pub pattern_px: f64,
|
||||
pos: P,
|
||||
in_path: bool,
|
||||
path: Vec<Op>,
|
||||
@@ -212,9 +317,12 @@ impl Canvas {
|
||||
used: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
even_odd: true,
|
||||
nop: false,
|
||||
rop2: R2_COPYPEN,
|
||||
xor_color: None,
|
||||
text: TextState { color: [0, 0, 0, 255], bk_color: [255, 255, 255, 255], bk_opaque: true, align: 0, font: None },
|
||||
pattern_px: 1.0,
|
||||
pos: (0.0, 0.0),
|
||||
in_path: false,
|
||||
path: Vec::new(),
|
||||
@@ -230,7 +338,14 @@ impl Canvas {
|
||||
fn push(&mut self, raw: Raw) {
|
||||
let cost = match &raw {
|
||||
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
|
||||
// A tile shared with its brush is counted once (by the brush); one made for this fill
|
||||
// counts here.
|
||||
Raw::Pattern { ops, tile, .. } => {
|
||||
let own = if Arc::strong_count(tile) == 1 { tile.px.len() } else { 0 };
|
||||
ops.len() * std::mem::size_of::<Op>() + 64 + own
|
||||
}
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
};
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
@@ -288,9 +403,31 @@ impl Canvas {
|
||||
self.even_odd = mode != WINDING;
|
||||
}
|
||||
|
||||
/// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
|
||||
/// SETROP2. R2_NOP draws nothing; R2_XORPEN and R2_MASKPEN are read as the see-through shadow
|
||||
/// idiom (see [`Canvas::fill_paint`]); the other mix modes draw as copy.
|
||||
pub fn set_rop2(&mut self, mode: u32) {
|
||||
self.nop = mode == R2_NOP;
|
||||
self.rop2 = mode;
|
||||
}
|
||||
|
||||
pub fn set_text_color(&mut self, c: u32) {
|
||||
self.text.color = colorref(c);
|
||||
}
|
||||
|
||||
pub fn set_bk_color(&mut self, c: u32) {
|
||||
self.text.bk_color = colorref(c);
|
||||
}
|
||||
|
||||
pub fn set_bk_mode(&mut self, mode: u32) {
|
||||
self.text.bk_opaque = mode == OPAQUE;
|
||||
}
|
||||
|
||||
pub fn set_text_align(&mut self, ta: u32) {
|
||||
self.text.align = ta;
|
||||
}
|
||||
|
||||
/// The character set of the selected font (ANSI without one).
|
||||
pub fn font_charset(&self) -> u8 {
|
||||
self.text.font.as_ref().map_or(0, |f| f.font.charset)
|
||||
}
|
||||
|
||||
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
|
||||
@@ -309,7 +446,14 @@ impl Canvas {
|
||||
/// could no longer match the file.
|
||||
pub fn save_dc(&mut self) {
|
||||
if self.saved.len() < MAX_STACK {
|
||||
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
|
||||
self.saved.push(Saved {
|
||||
map: self.map,
|
||||
pen: self.pen,
|
||||
brush: self.brush.clone(),
|
||||
even_odd: self.even_odd,
|
||||
rop2: self.rop2,
|
||||
text: self.text.clone(),
|
||||
});
|
||||
} else {
|
||||
self.full = true;
|
||||
}
|
||||
@@ -325,11 +469,21 @@ impl Canvas {
|
||||
self.pen = s.pen;
|
||||
self.brush = s.brush;
|
||||
self.even_odd = s.even_odd;
|
||||
self.nop = s.nop;
|
||||
self.rop2 = s.rop2;
|
||||
self.text = s.text;
|
||||
}
|
||||
|
||||
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
|
||||
pub fn add_object(&mut self, slot: Option<usize>, obj: Obj) {
|
||||
// A pattern brush's pixels count against the byte budget like drawn items.
|
||||
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
|
||||
let cost = p.px.len();
|
||||
if self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.used += cost;
|
||||
}
|
||||
match slot {
|
||||
Some(i) if i < MAX_OBJECTS => {
|
||||
if self.objects.len() <= i {
|
||||
@@ -363,9 +517,10 @@ impl Canvas {
|
||||
if i & STOCK != 0 {
|
||||
return self.select_stock(i & !STOCK);
|
||||
}
|
||||
match self.objects.get(i as usize).copied().flatten() {
|
||||
match self.objects.get(i as usize).cloned().flatten() {
|
||||
Some(Obj::Pen(p)) => self.pen = p,
|
||||
Some(Obj::Brush(b)) => self.brush = b,
|
||||
Some(Obj::Font(f)) => self.text.font = Some(f),
|
||||
Some(Obj::Other) | None => {}
|
||||
}
|
||||
}
|
||||
@@ -373,17 +528,19 @@ impl Canvas {
|
||||
fn select_stock(&mut self, i: u32) {
|
||||
let white = [255, 255, 255, 255];
|
||||
let black = [0, 0, 0, 255];
|
||||
let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] };
|
||||
let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]);
|
||||
match i {
|
||||
0 | 18 => self.brush = Brush { style: BS_SOLID, color: white },
|
||||
0 | 18 => self.brush = Brush::new(BS_SOLID, white),
|
||||
1 => self.brush = gray(192),
|
||||
2 => self.brush = gray(128),
|
||||
3 => self.brush = gray(64),
|
||||
4 => self.brush = Brush { style: BS_SOLID, color: black },
|
||||
5 => self.brush = Brush { style: BS_NULL, color: black },
|
||||
4 => self.brush = Brush::new(BS_SOLID, black),
|
||||
5 => self.brush = Brush::new(BS_NULL, black),
|
||||
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
|
||||
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
|
||||
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
|
||||
// The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face.
|
||||
10..=17 => self.text.font = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -397,8 +554,81 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_color(&self) -> Option<[u8; 4]> {
|
||||
(self.brush.style == BS_SOLID).then_some(self.brush.color)
|
||||
/// The current brush's paint. Hatch and monochrome brushes take the background colour (when
|
||||
/// the background mode is opaque) and the text colour. Under R2_MASKPEN (destination AND
|
||||
/// brush) a tile's white pixels keep what is below, and its black ones take the colour of the
|
||||
/// XOR fill before it: XOR, AND, XOR again leaves that colour where the tile is black.
|
||||
fn fill_paint(&self) -> Option<Paint> {
|
||||
let b = &self.brush;
|
||||
match b.style {
|
||||
BS_SOLID => Some(Paint::Solid(b.color)),
|
||||
BS_HATCHED => {
|
||||
let bg = self.text.bk_opaque.then_some(self.text.bk_color);
|
||||
let px = hatch_tile(b.hatch, b.color, bg)?;
|
||||
Some(Paint::Tile(Arc::new(RawTile { w: 8, h: 8, px, cell: self.pattern_px })))
|
||||
}
|
||||
BS_PATTERN | BS_DIBPATTERN | BS_DIBPATTERNPT => {
|
||||
let p = b.pattern.as_ref()?;
|
||||
if !p.mono && self.rop2 != R2_MASKPEN {
|
||||
let cell = self.pattern_px;
|
||||
return Some(Paint::Tile(p.plain.get_or_init(|| Arc::new(RawTile { w: p.w, h: p.h, px: p.px.clone(), cell })).clone()));
|
||||
}
|
||||
let mut px = p.px.clone();
|
||||
for c in px.as_chunks_mut::<4>().0 {
|
||||
if p.mono {
|
||||
let light = u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]) >= 384;
|
||||
let v = if light { self.text.bk_color } else { self.text.color };
|
||||
*c = [v[0], v[1], v[2], 255];
|
||||
}
|
||||
if self.rop2 == R2_MASKPEN {
|
||||
if c[..3] == [255, 255, 255] {
|
||||
c[3] = 0;
|
||||
} else if c[..3] == [0, 0, 0]
|
||||
&& let Some(x) = self.xor_color
|
||||
{
|
||||
*c = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Paint::Tile(Arc::new(RawTile { w: p.w, h: p.h, px, cell: self.pattern_px })))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes device ops filled with `fill` and stroked with `stroke`: a tile fill and its outline
|
||||
/// are two items.
|
||||
fn push_shape(&mut self, ops: Vec<Op>, fill: Option<Paint>, stroke: Option<([u8; 4], f64)>) {
|
||||
let even_odd = self.even_odd;
|
||||
match fill {
|
||||
Some(Paint::Tile(tile)) => {
|
||||
if stroke.is_some() {
|
||||
self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile });
|
||||
self.push(Raw::Path { ops, fill: None, stroke, even_odd });
|
||||
} else {
|
||||
self.push(Raw::Pattern { ops, even_odd, tile });
|
||||
}
|
||||
}
|
||||
Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }),
|
||||
None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the mix mode suppresses drawing: R2_NOP draws nothing, and R2_XORPEN shapes only
|
||||
/// remember their fill colour (an XOR pair cancels; the see-through shadow idiom draws its
|
||||
/// pattern in that colour).
|
||||
fn mix_skips(&mut self, fill: bool) -> bool {
|
||||
match self.rop2 {
|
||||
R2_NOP => true,
|
||||
R2_XORPEN => {
|
||||
if fill && self.brush.style == BS_SOLID {
|
||||
self.xor_color = Some(self.brush.color);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_pen(&self) -> Option<([u8; 4], f64)> {
|
||||
@@ -426,16 +656,12 @@ impl Canvas {
|
||||
if self.in_path {
|
||||
return self.push_path_ops(dev);
|
||||
}
|
||||
if self.nop {
|
||||
if self.mix_skips(fill) || dev.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let even_odd = self.even_odd;
|
||||
self.push(Raw::Path { ops: dev, fill, stroke, even_odd });
|
||||
self.push_shape(dev, fill, stroke);
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, p: P) {
|
||||
@@ -508,23 +734,22 @@ impl Canvas {
|
||||
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
|
||||
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
|
||||
let ops = std::mem::take(&mut self.path);
|
||||
if self.nop {
|
||||
if self.mix_skips(fill) || ops.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let even_odd = self.even_odd;
|
||||
self.push(Raw::Path { ops, fill, stroke, even_odd });
|
||||
self.push_shape(ops, fill, stroke);
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid).
|
||||
pub fn fill_brush_rect(&mut self, rect: [f64; 4]) {
|
||||
if let Some(color) = self.fill_color() {
|
||||
self.fill_rect_with(rect, color);
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (PATBLT, PATCOPY blits).
|
||||
pub fn fill_brush_rect(&mut self, [l, t, r, b]: [f64; 4]) {
|
||||
if self.mix_skips(true) {
|
||||
return;
|
||||
}
|
||||
let fill = self.fill_paint();
|
||||
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
|
||||
self.push_shape(dev, fill, None);
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
|
||||
@@ -550,6 +775,75 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `text` (one `dx` advance per character, or none) with the current font, colours and
|
||||
/// alignment. The reference point is `at`, or the current position under TA_UPDATECP (which
|
||||
/// then moves past the string). `rect` (logical `[l, t, r, b]`) is filled with the background
|
||||
/// colour under ETO_OPAQUE and clips the text under ETO_CLIPPED. Text ignores the mix mode, as
|
||||
/// GDI's does.
|
||||
pub fn text(&mut self, at: P, text: String, dx: Vec<f32>, options: u32, rect: Option<[f64; 4]>) {
|
||||
if let (true, Some(r)) = (options & ETO_OPAQUE != 0, rect) {
|
||||
self.fill_rect_with(r, self.text.bk_color);
|
||||
}
|
||||
let lf = self.text.font.as_deref().cloned().unwrap_or_default();
|
||||
let update = self.text.align & TA_UPDATECP != 0;
|
||||
let origin = if update { self.pos } else { at };
|
||||
let (h_align, v_align) = align(self.text.align);
|
||||
let o = self.map.device(origin);
|
||||
let ux = self.map.device((origin.0 + 1.0, origin.1));
|
||||
let uy = self.map.device((origin.0, origin.1 + 1.0));
|
||||
let (ux, uy) = ((ux.0 - o.0, ux.1 - o.1), (uy.0 - o.0, uy.1 - o.1));
|
||||
if update {
|
||||
// The current position moves along the baseline past the string (back past it when
|
||||
// right-aligned). Without a dx array the advance is estimated from the height.
|
||||
let adv: f64 = if dx.is_empty() {
|
||||
f64::from(lf.font.height.abs().max(1.0)) * 0.5 * text.chars().count() as f64
|
||||
} else {
|
||||
dx.iter().map(|d| f64::from(*d)).sum()
|
||||
};
|
||||
let adv = match h_align {
|
||||
crate::HAlign::Left => adv,
|
||||
crate::HAlign::Right => -adv,
|
||||
crate::HAlign::Center => 0.0,
|
||||
};
|
||||
let theta = (lf.escapement / 10.0).to_radians();
|
||||
let y_sign = if uy.1 < 0.0 { 1.0 } else { -1.0 };
|
||||
let p = (origin.0 + adv * theta.cos(), origin.1 + adv * theta.sin() * y_sign);
|
||||
if p.0.is_finite() && p.1.is_finite() {
|
||||
self.pos = p;
|
||||
}
|
||||
}
|
||||
let (sx, sy) = (ux.0.hypot(ux.1), uy.0.hypot(uy.1));
|
||||
let drawable = !text.is_empty() && [sx, sy, o.0, o.1].iter().all(|v| v.is_finite()) && sx > 0.0 && sy > 0.0;
|
||||
if !drawable {
|
||||
return;
|
||||
}
|
||||
// The baseline runs along the device direction of the logical x axis (rotated by a world
|
||||
// transform; a mirroring mapping does not mirror glyphs), turned counter-clockwise by the
|
||||
// escapement.
|
||||
let det = ux.0 * uy.1 - ux.1 * uy.0;
|
||||
let phi = if det > 0.0 { ux.1.atan2(ux.0) } else { ux.1.atan2(ux.0.abs()) };
|
||||
let a = phi - (lf.escapement / 10.0).to_radians();
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
let clip = match (options & ETO_CLIPPED != 0, rect) {
|
||||
(true, Some([l, t, r, b])) => {
|
||||
let (p, q) = (self.map.device((l, t)), self.map.device((r, b)));
|
||||
Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
self.push(Raw::Text(Box::new(Text {
|
||||
transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1],
|
||||
text,
|
||||
dx,
|
||||
font: lf.font,
|
||||
color: self.text.color,
|
||||
background: self.text.bk_opaque.then_some(self.text.bk_color),
|
||||
h_align,
|
||||
v_align,
|
||||
clip,
|
||||
})));
|
||||
}
|
||||
|
||||
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
|
||||
/// bitmap, as the mapping does.
|
||||
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
|
||||
@@ -562,6 +856,31 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS,
|
||||
/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is
|
||||
/// transparent. None for an unknown style.
|
||||
pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option<Vec<u8>> {
|
||||
if style > 5 {
|
||||
return None;
|
||||
}
|
||||
let mut px = Vec::with_capacity(8 * 8 * 4);
|
||||
for y in 0..8u32 {
|
||||
for x in 0..8u32 {
|
||||
let (h, v, f, b) = (y == 7, x == 7, x == y, x + y == 7);
|
||||
let on = match style {
|
||||
0 => h,
|
||||
1 => v,
|
||||
2 => f,
|
||||
3 => b,
|
||||
4 => h || v,
|
||||
_ => f || b,
|
||||
};
|
||||
px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) });
|
||||
}
|
||||
}
|
||||
Some(px)
|
||||
}
|
||||
|
||||
/// Cubic segments from groups of three points (control, control, end). A trailing partial group is
|
||||
/// dropped.
|
||||
fn cubics(pts: &[P]) -> Vec<Op> {
|
||||
|
||||
+120
-2
@@ -1,12 +1,20 @@
|
||||
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
|
||||
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
|
||||
use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
|
||||
use crate::dib::{self, Blend};
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit
|
||||
/// characters (EMR_SMALLTEXTOUT).
|
||||
const ETO_NO_RECT: u32 = 0x0100;
|
||||
const ETO_SMALL_CHARS: u32 = 0x0200;
|
||||
|
||||
const EMF_SIGNATURE: u32 = 0x464D_4520;
|
||||
/// Hundredths of a millimetre to points.
|
||||
const HMM_TO_PT: f64 = 72.0 / 2540.0;
|
||||
@@ -100,6 +108,14 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
|
||||
38 => create_pen(cv, r),
|
||||
95 => create_ext_pen(cv, r),
|
||||
39 => create_brush(cv, r),
|
||||
93 | 94 => create_dib_brush(cv, r, typ == 93),
|
||||
82 => create_font(cv, r),
|
||||
18 => r.u32(8).map(|m| cv.set_bk_mode(m)),
|
||||
22 => r.u32(8).map(|a| cv.set_text_align(a)),
|
||||
24 => r.u32(8).map(|c| cv.set_text_color(c)),
|
||||
25 => r.u32(8).map(|c| cv.set_bk_color(c)),
|
||||
83 | 84 => ext_text_out(cv, r, typ == 84),
|
||||
108 => small_text_out(cv, r),
|
||||
40 => r.u32(8).map(|i| cv.delete_object(i)),
|
||||
27 => xy32(r, 8).map(|p| cv.move_to(p)),
|
||||
54 => xy32(r, 8).map(|p| cv.line_to(p)),
|
||||
@@ -246,7 +262,109 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let style = r.u32(12)?;
|
||||
let color = r.u32(16)?;
|
||||
cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) }));
|
||||
let mut b = Brush::new(style, colorref(color));
|
||||
if style == BS_HATCHED {
|
||||
b.hatch = r.u32(20).unwrap_or(0);
|
||||
}
|
||||
cv.add_object(Some(slot), Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the
|
||||
/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices,
|
||||
/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot
|
||||
/// empty.
|
||||
fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let usage = r.u32(12)?;
|
||||
let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize);
|
||||
let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize);
|
||||
let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?;
|
||||
let b = Brush::pattern(img, mono || usage == 1)?;
|
||||
cv.add_object(Some(slot), Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement,
|
||||
/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then
|
||||
/// the face name in 32 UTF-16 units (NUL-terminated).
|
||||
fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let height = r.i32(12)?;
|
||||
let width = r.i32(16).unwrap_or(0);
|
||||
let escapement = r.i32(20).unwrap_or(0);
|
||||
let weight = r.i32(28).unwrap_or(0);
|
||||
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
|
||||
let units: Vec<u16> = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect();
|
||||
let (family, _) = decode_utf16(&units, &[]);
|
||||
let font = Font {
|
||||
family: family.trim().to_string(),
|
||||
height: height as f32,
|
||||
width: width as f32,
|
||||
weight: weight.clamp(0, 1000) as u16,
|
||||
italic: byte(32) != 0,
|
||||
underline: byte(33) != 0,
|
||||
strike: byte(34) != 0,
|
||||
charset: byte(35),
|
||||
};
|
||||
cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) })));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only
|
||||
/// dx is used). Empty when absent or short.
|
||||
fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec<f32> {
|
||||
let step = if opts & ETO_PDY != 0 { 8 } else { 4 };
|
||||
if off == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let dx: Vec<f32> = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect();
|
||||
if dx.len() == n { dx } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText
|
||||
/// at 36: reference point, character count, string offset, options, the rectangle (absent under
|
||||
/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name
|
||||
/// glyphs of a font the reader cannot match, so they are left out.
|
||||
fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> {
|
||||
let at = xy32(r, 36)?;
|
||||
let n = (r.u32(44)? as usize).min(MAX_CHARS);
|
||||
let off = r.u32(48)? as usize;
|
||||
let opts = r.u32(52)?;
|
||||
let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) };
|
||||
if opts & ETO_GLYPH_INDEX != 0 {
|
||||
return None;
|
||||
}
|
||||
let dx = dx_array(r, dx_off as usize, n, opts);
|
||||
let (text, dx) = if wide {
|
||||
let raw = r.slice(off, n.checked_mul(2)?)?;
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
decode_utf16(&units, &dx)
|
||||
} else {
|
||||
decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx)
|
||||
};
|
||||
cv.text(at, text, dx, opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the
|
||||
/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16.
|
||||
fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let at = xy32(r, 8)?;
|
||||
let n = (r.u32(16)? as usize).min(MAX_CHARS);
|
||||
let opts = r.u32(20)?;
|
||||
if opts & ETO_GLYPH_INDEX != 0 {
|
||||
return None;
|
||||
}
|
||||
let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) };
|
||||
let (text, _) = if opts & ETO_SMALL_CHARS != 0 {
|
||||
decode_bytes(r.slice(off, n)?, cv.font_charset(), &[])
|
||||
} else {
|
||||
let raw = r.slice(off, n.checked_mul(2)?)?;
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
decode_utf16(&units, &[])
|
||||
};
|
||||
cv.text(at, text, Vec::new(), opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
|
||||
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA
|
||||
//! bitmaps, in a picture frame with y down.
|
||||
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern
|
||||
//! fills, RGBA bitmaps and text strings, in a picture frame with y down.
|
||||
//!
|
||||
//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the
|
||||
//! string, its advances and alignment; the renderer resolves the font and draws the glyphs.
|
||||
//!
|
||||
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
|
||||
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
|
||||
@@ -16,15 +19,20 @@ mod dib;
|
||||
mod emf;
|
||||
mod geom;
|
||||
mod place;
|
||||
mod text;
|
||||
mod wmf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use canvas::Raw;
|
||||
use geom::{Op, P};
|
||||
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
|
||||
pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign};
|
||||
|
||||
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
|
||||
pub(crate) const MAX_ITEMS: usize = 200_000;
|
||||
@@ -61,6 +69,22 @@ pub enum Item {
|
||||
},
|
||||
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
|
||||
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
|
||||
/// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same
|
||||
/// brush share its tile.
|
||||
Pattern { segs: Vec<Seg>, even_odd: bool, tile: Arc<Tile> },
|
||||
/// A text string.
|
||||
Text(Box<Text>),
|
||||
}
|
||||
|
||||
/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each
|
||||
/// pixel `cell` picture units, with a tile's top-left corner at `origin`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Tile {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
pub cell: [f32; 2],
|
||||
pub origin: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -94,6 +118,8 @@ pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
|
||||
let (cv, frame) = if emf::is_emf(b) {
|
||||
emf::parse(b)?
|
||||
} else if wmf::is_wmf(b) {
|
||||
// A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws
|
||||
// the WMF's own records (its dx rounding shows), so the copy is not used.
|
||||
wmf::parse(b)?
|
||||
} else {
|
||||
return Err(Error::NotMetafile);
|
||||
@@ -124,6 +150,8 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
|
||||
let stroke_scale = (sx + sy) / 2.0;
|
||||
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
|
||||
let diag = size.0.hypot(size.1);
|
||||
// Tiles shared by several fills stay shared.
|
||||
let mut tiles: HashMap<*const canvas::RawTile, (Arc<canvas::RawTile>, Arc<Tile>)> = HashMap::new();
|
||||
let items = raw.into_iter().filter_map(|r| match r {
|
||||
Raw::Path { ops, fill, stroke, even_odd } => {
|
||||
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
|
||||
@@ -138,6 +166,44 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
|
||||
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
|
||||
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
|
||||
}
|
||||
Raw::Pattern { ops, even_odd, tile } => {
|
||||
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let key = Arc::as_ptr(&tile);
|
||||
let tile = match tiles.get(&key) {
|
||||
Some((_, t)) => t.clone(),
|
||||
None => {
|
||||
let (ox, oy) = tf((0.0, 0.0));
|
||||
let t = Arc::new(Tile {
|
||||
width: u32::try_from(tile.w).ok()?,
|
||||
height: u32::try_from(tile.h).ok()?,
|
||||
rgba: tile.px.clone(),
|
||||
cell: [f(tile.cell * sx)?, f(tile.cell * sy)?],
|
||||
origin: [f(ox)?, f(oy)?],
|
||||
});
|
||||
// The raw tile is kept with its key, so the address is never reused by another.
|
||||
tiles.insert(key, (tile.clone(), t.clone()));
|
||||
t
|
||||
}
|
||||
};
|
||||
Some(Item::Pattern { segs, even_odd, tile })
|
||||
}
|
||||
Raw::Text(mut t) => {
|
||||
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
|
||||
let [a, b, c, d, e, g] = t.transform;
|
||||
let (e, g) = tf((e, g));
|
||||
t.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
|
||||
if !t.transform.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = t.clip {
|
||||
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
|
||||
t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
|
||||
}
|
||||
Some(Item::Text(t))
|
||||
}
|
||||
});
|
||||
let items = items.collect();
|
||||
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
|
||||
@@ -185,7 +251,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
|
||||
};
|
||||
for r in raw {
|
||||
match r {
|
||||
Raw::Path { ops, .. } => {
|
||||
Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => {
|
||||
for op in ops {
|
||||
match *op {
|
||||
Op::M(p) | Op::L(p) => grow(p),
|
||||
@@ -202,6 +268,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
|
||||
grow((rect[0], rect[1]));
|
||||
grow((rect[0] + rect[2], rect[1] + rect[3]));
|
||||
}
|
||||
Raw::Text(t) => grow((t.transform[4], t.transform[5])),
|
||||
}
|
||||
}
|
||||
b
|
||||
|
||||
@@ -19,6 +19,12 @@ pub enum PlacedItem {
|
||||
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
|
||||
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
|
||||
Bitmap { index: usize, rect: Rect },
|
||||
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
|
||||
/// repeated; `tile` maps tile pixels to target units.
|
||||
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine },
|
||||
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
|
||||
/// `clip` (target units) bounds what it draws.
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect> },
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
@@ -56,6 +62,26 @@ impl Picture {
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
|
||||
}
|
||||
}
|
||||
Item::Pattern { segs, even_odd, tile: t } => {
|
||||
let path = tf * bez(segs);
|
||||
let tile = tf
|
||||
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile });
|
||||
}
|
||||
}
|
||||
Item::Text(t) => {
|
||||
let transform = tf * Affine::new(t.transform);
|
||||
let clip =
|
||||
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
|
||||
let c = transform.as_coeffs();
|
||||
let origin = Rect::new(c[4], c[5], c[4], c[5]);
|
||||
if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) {
|
||||
out.push(PlacedItem::Text { index, transform, clip });
|
||||
}
|
||||
}
|
||||
Item::Bitmap { rect: [x, y, w, h], .. } => {
|
||||
let (x0, y0) = (f64::from(*x), f64::from(*y));
|
||||
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
|
||||
|
||||
@@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255
|
||||
fn path_segs(item: &Item) -> (&Vec<Seg>, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) {
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd),
|
||||
Item::Bitmap { .. } => panic!("expected a path"),
|
||||
other => panic!("expected a path: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,3 +1016,505 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() {
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}");
|
||||
}
|
||||
|
||||
// ---- Text, pattern and hatch brushes, mix-mode idioms ----
|
||||
|
||||
use crate::{HAlign, Picture, Text, VAlign};
|
||||
|
||||
/// META_CREATEFONTINDIRECT: height, width, escapement (and orientation), weight, the italic,
|
||||
/// underline, strike-out and charset bytes, four precision bytes, then the NUL-terminated face.
|
||||
fn wfont(height: i32, weight: i32, flags: [u8; 3], charset: u8, esc: i32, face: &[u8]) -> Vec<u8> {
|
||||
let mut v = cat(vec![h16(height), h16(0), h16(esc), h16(esc), h16(weight)]);
|
||||
v.extend(flags);
|
||||
v.extend([charset, 0, 0, 0, 0]);
|
||||
v.extend(face);
|
||||
v.push(0);
|
||||
wrec(0x02FB, &v)
|
||||
}
|
||||
|
||||
/// META_EXTTEXTOUT at (x, y): the rectangle when given, the string padded to a word, then `dx`.
|
||||
fn wtext(x: i32, y: i32, s: &[u8], opts: i32, rect: Option<[i32; 4]>, dx: &[i32]) -> Vec<u8> {
|
||||
let mut v = cat(vec![h16(y), h16(x), h16(s.len() as i32), h16(opts)]);
|
||||
for c in rect.into_iter().flatten() {
|
||||
v.extend(h16(c));
|
||||
}
|
||||
v.extend(s);
|
||||
if s.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
for d in dx {
|
||||
v.extend(h16(*d));
|
||||
}
|
||||
wrec(0x0A32, &v)
|
||||
}
|
||||
|
||||
/// A plain WMF whose 200 × 100 window maps one logical unit to one picture unit.
|
||||
fn wmf200(records: &[Vec<u8>]) -> Picture {
|
||||
let mut recs = vec![wrec(0x020C, &cat(vec![h16(100), h16(200)]))];
|
||||
recs.extend(records.iter().cloned());
|
||||
parse(&wmf(&recs, None)).unwrap()
|
||||
}
|
||||
|
||||
fn texts(p: &Picture) -> Vec<&Text> {
|
||||
p.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
Item::Text(t) => Some(t.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn close6(a: [f64; 6], b: [f64; 6]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wmf_text_reads_the_font_string_dx_colour_and_alignment() {
|
||||
let recs = [
|
||||
wfont(-20, 700, [1, 1, 1], 0, 0, b"Arial"),
|
||||
wrec(0x012D, &h16(0)),
|
||||
wrec(0x0209, &h32(0x0000_00FF)),
|
||||
wrec(0x0102, &h16(1)),
|
||||
wrec(0x012E, &h16(24 | 6)),
|
||||
wtext(50, 60, b"Hi!", 0, None, &[10, 8, 6]),
|
||||
];
|
||||
let p = wmf200(&recs);
|
||||
let t = texts(&p);
|
||||
assert_eq!(t.len(), 1, "{:?}", p.items);
|
||||
let t = t[0];
|
||||
assert_eq!(t.text, "Hi!");
|
||||
assert_eq!(t.dx, vec![10.0, 8.0, 6.0], "the dx array follows the word-padded odd string");
|
||||
assert_eq!(t.font.family, "Arial");
|
||||
assert_eq!((t.font.height, t.font.weight, t.font.italic, t.font.underline, t.font.strike), (-20.0, 700, true, true, true));
|
||||
assert_eq!(t.color, [255, 0, 0, 255]);
|
||||
assert_eq!(t.background, None, "TRANSPARENT background mode");
|
||||
assert_eq!((t.h_align, t.v_align), (HAlign::Center, VAlign::Baseline));
|
||||
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]), "{:?}", t.transform);
|
||||
assert_eq!(t.clip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_height_sign_and_default_state_are_kept() {
|
||||
// A positive height is the cell height; it stays positive for the renderer to resolve. Without
|
||||
// SETTEXTALIGN the reference point is the top left; without SETBKMODE the cell is filled white.
|
||||
let recs = [wfont(30, 400, [0, 0, 0], 0, 0, b"Times New Roman"), wrec(0x012D, &h16(0)), wtext(5, 5, b"x", 0, None, &[])];
|
||||
let p = wmf200(&recs);
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!(t.font.height, 30.0);
|
||||
assert_eq!(t.font.family, "Times New Roman");
|
||||
assert_eq!((t.h_align, t.v_align), (HAlign::Left, VAlign::Top));
|
||||
assert_eq!(t.background, Some([255, 255, 255, 255]));
|
||||
assert!(t.dx.is_empty(), "no dx array in the record");
|
||||
// Right and bottom.
|
||||
let p = wmf200(&[wrec(0x012E, &h16(2 | 8)), wtext(5, 5, b"x", 0, None, &[])]);
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!((t.h_align, t.v_align), (HAlign::Right, VAlign::Bottom));
|
||||
assert_eq!(t.font, crate::Font::default(), "no font selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapement_turns_the_baseline_counter_clockwise() {
|
||||
let recs = [wfont(-20, 400, [0, 0, 0], 0, 900, b"Arial"), wrec(0x012D, &h16(0)), wtext(100, 90, b"Up", 0, None, &[])];
|
||||
let p = wmf200(&recs);
|
||||
let t = texts(&p)[0];
|
||||
// Text x runs up the picture (0, -1); text y (down the glyphs) runs right (1, 0).
|
||||
assert!(close6(t.transform, [0.0, -1.0, 1.0, 0.0, 100.0, 90.0]), "{:?}", t.transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_y_up_mapping_moves_text_but_does_not_mirror_it() {
|
||||
// Window 200 × -100 onto a 200 × 100 viewport: y goes up.
|
||||
let recs = [
|
||||
wrec(0x0103, &h16(8)),
|
||||
wrec(0x020C, &cat(vec![h16(-100), h16(200)])),
|
||||
wrec(0x020E, &cat(vec![h16(100), h16(200)])),
|
||||
wtext(40, -30, b"A", 0, None, &[]),
|
||||
];
|
||||
let p = parse(&wmf(&recs, None)).unwrap();
|
||||
let t = texts(&p)[0];
|
||||
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 30.0]), "{:?}", t.transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_cp_starts_at_the_current_position_and_moves_past_the_string() {
|
||||
let recs =
|
||||
[wrec(0x012E, &h16(1)), wrec(0x0214, &cat(vec![h16(20), h16(10)])), wtext(0, 0, b"ab", 0, None, &[5, 7]), wtext(0, 0, b"c", 0, None, &[3])];
|
||||
let p = wmf200(&recs);
|
||||
let t = texts(&p);
|
||||
assert_eq!(t.len(), 2);
|
||||
assert!(close6(t[0].transform, [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]), "{:?}", t[0].transform);
|
||||
assert!(close6(t[1].transform, [1.0, 0.0, 0.0, 1.0, 22.0, 20.0]), "{:?}", t[1].transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eto_opaque_fills_the_rectangle_and_eto_clipped_clips() {
|
||||
let recs = [wrec(0x0201, &h32(0x0000_FF00)), wtext(12, 30, b"ok", 2 | 4, Some([10, 10, 60, 40]), &[])];
|
||||
let p = wmf200(&recs);
|
||||
assert_eq!(p.items.len(), 2, "{:?}", p.items);
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 10.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0));
|
||||
assert_eq!(path_segs(&p.items[0]).1, Some([0, 255, 0, 255]));
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!(t.clip, Some([10.0, 10.0, 60.0, 40.0]));
|
||||
assert_eq!(t.background, Some([0, 255, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_sets_decode_bytes() {
|
||||
let one = |charset: u8, s: &[u8]| {
|
||||
let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, s, 0, None, &[])]);
|
||||
texts(&p)[0].text.clone()
|
||||
};
|
||||
assert_eq!(one(0, &[b'A', 0x80, 0x93, 0x94, 0xE9]), "A€“”é");
|
||||
assert_eq!(one(238, &[0xB9, 0xE8, 0x9E, 0xA3]), "ąčžŁ");
|
||||
assert_eq!(one(204, &[0xC0, 0xFF, 0xA8]), "АяЁ");
|
||||
assert_eq!(one(2, b"n\xA7"), "\u{F06E}\u{F0A7}", "symbol fonts keep their codes in the private-use area");
|
||||
assert_eq!(one(0, &[0x81]), "\u{FFFD}", "an undefined byte");
|
||||
// A double-byte character set: a lead and trail byte become one blank with both advances.
|
||||
let p =
|
||||
wmf200(&[wfont(-10, 400, [0, 0, 0], 128, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x41, 0x82, 0xA0, 0x42], 0, None, &[1, 2, 3, 4])]);
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!((t.text.as_str(), t.dx.as_slice()), ("A B", [1.0, 5.0, 4.0].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wmf_textout_reads_its_string_and_position() {
|
||||
let mut v = cat(vec![h16(3)]);
|
||||
v.extend(b"abc\0");
|
||||
v.extend(cat(vec![h16(40), h16(30)]));
|
||||
let p = wmf200(&[wrec(0x0521, &v)]);
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!(t.text, "abc");
|
||||
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 30.0, 40.0]), "{:?}", t.transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn savedc_restores_text_colour_and_font() {
|
||||
let recs = [
|
||||
wfont(-10, 400, [0, 0, 0], 0, 0, b"One"),
|
||||
wrec(0x012D, &h16(0)),
|
||||
wrec(0x0209, &h32(0x0000_00FF)),
|
||||
wrec(0x001E, &[]),
|
||||
wfont(-10, 400, [0, 0, 0], 0, 0, b"Two"),
|
||||
wrec(0x012D, &h16(1)),
|
||||
wrec(0x0209, &h32(0x00FF_0000)),
|
||||
wrec(0x0127, &h16(-1)),
|
||||
wtext(0, 0, b"x", 0, None, &[]),
|
||||
];
|
||||
let p = wmf200(&recs);
|
||||
let t = texts(&p)[0];
|
||||
assert_eq!((t.font.family.as_str(), t.color), ("One", [255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
/// EMR_EXTCREATEFONTINDIRECTW in slot 1: LogFont at 12, face at 40.
|
||||
fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec<u8> {
|
||||
let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]);
|
||||
v.extend([italic, 0, 0, charset, 0, 0, 0, 0]);
|
||||
let mut name: Vec<u8> = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
name.resize(64, 0);
|
||||
v.extend(name);
|
||||
erec(82, &v)
|
||||
}
|
||||
|
||||
/// EMR_EXTTEXTOUTW (or A) at (x, y) with options, the rectangle (absent under ETO_NO_RECT), the
|
||||
/// string at 76 and the dx array after it.
|
||||
fn etext(wide: bool, x: i32, y: i32, units: &[u16], opts: u32, dx: &[i32]) -> Vec<u8> {
|
||||
let no_rect = opts & 0x100 != 0;
|
||||
let fixed = if no_rect { 60 } else { 76 };
|
||||
let s: Vec<u8> = if wide { units.iter().flat_map(|u| u.to_le_bytes()).collect() } else { units.iter().map(|u| *u as u8).collect() };
|
||||
let s_off = fixed;
|
||||
let dx_off = (s_off + s.len()).next_multiple_of(4);
|
||||
let len = dx_off + dx.len() * 4;
|
||||
let mut f = vec![(24, 1u32), (36, x as u32), (40, y as u32), (44, units.len() as u32), (48, s_off as u32), (52, opts)];
|
||||
f.push((if no_rect { 56 } else { 72 }, if dx.is_empty() { 0 } else { dx_off as u32 }));
|
||||
let dxb: Vec<u8> = dx.iter().flat_map(|d| d.to_le_bytes()).collect();
|
||||
erec_at(if wide { 84 } else { 83 }, len.next_multiple_of(4), &f, &[(s_off, &s), (dx_off, &dxb)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_exttextoutw_reads_utf16_dx_and_the_font() {
|
||||
let recs = [
|
||||
efont(24, 0, 400, 1, 0, "Segoe UI"),
|
||||
erec(37, &h32(1)),
|
||||
erec(22, &h32(0x18 | 2)),
|
||||
erec(24, &h32(0x0000_FF00)),
|
||||
erec(18, &h32(1)),
|
||||
etext(true, 100, 50, &[0x41, 0xD83D, 0xDE00], 0, &[10, 6, 0]),
|
||||
];
|
||||
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
let t = texts(&p);
|
||||
assert_eq!(t.len(), 1, "{:?}", p.items);
|
||||
let t = t[0];
|
||||
assert_eq!(t.text, "A\u{1F600}");
|
||||
assert_eq!(t.dx, vec![10.0, 6.0], "a surrogate pair takes both advances");
|
||||
assert_eq!((t.font.family.as_str(), t.font.height, t.font.italic), ("Segoe UI", 24.0, true));
|
||||
assert_eq!((t.h_align, t.v_align, t.color, t.background), (HAlign::Right, VAlign::Baseline, [0, 255, 0, 255], None));
|
||||
// Upright, uniformly scaled.
|
||||
let [a, b, c, d, ..] = t.transform;
|
||||
assert!(a > 0.0 && d > 0.0 && b.abs() < 1e-9 && c.abs() < 1e-9 && (a - d).abs() < 1e-9, "{:?}", t.transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_text_options_pdy_no_rect_ansi_and_glyph_indices() {
|
||||
// ETO_PDY: (dx, dy) pairs, of which dx is used. ETO_NO_RECT moves the dx offset to 56.
|
||||
let recs = [etext(true, 0, 0, &[0x61, 0x62], 0x2000 | 0x100, &[7, 1, 9, 2])];
|
||||
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
assert_eq!(texts(&p)[0].dx, vec![7.0, 9.0]);
|
||||
// EXTTEXTOUTA decodes through the font's charset.
|
||||
let recs = [efont(-10, 0, 400, 0, 238, "F"), erec(37, &h32(1)), etext(false, 0, 0, &[0xB9], 0, &[])];
|
||||
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
assert_eq!(texts(&p)[0].text, "ą");
|
||||
// Glyph indices name glyphs of a font we cannot match: left out.
|
||||
let recs = [etext(true, 0, 0, &[5, 6], 0x10, &[])];
|
||||
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
assert!(texts(&p).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_smalltextout_reads_8_bit_and_16_bit_strings() {
|
||||
// x, y, count, options, mode, scales, (no bounds under ETO_NO_RECT), characters.
|
||||
let small = |opts: u32, chars: &[u8]| {
|
||||
let mut v = cat(vec![e32(3), e32(4), h32(2), h32(opts), h32(1), h32(0), h32(0)]);
|
||||
v.extend(chars);
|
||||
erec(108, &v)
|
||||
};
|
||||
let p = parse(&emf(&[small(0x100 | 0x200, b"hi")], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
assert_eq!(texts(&p)[0].text, "hi");
|
||||
let p = parse(&emf(&[small(0x100, &[b'o', 0, b'k', 0])], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
assert_eq!(texts(&p)[0].text, "ok");
|
||||
}
|
||||
|
||||
/// An 8 × 8 one-bit DIB (palette white, black; stored bottom-up): `rows` top to bottom.
|
||||
fn mono_dib(rows: [u8; 8]) -> Vec<u8> {
|
||||
let mut v = info_header(8, 8, 1, 32);
|
||||
v.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
for r in rows.iter().rev() {
|
||||
v.extend([*r, 0, 0, 0]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn pattern_of(item: &Item) -> &crate::Tile {
|
||||
match item {
|
||||
Item::Pattern { tile, .. } => tile,
|
||||
other => panic!("expected a pattern fill: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel (x, y) of a tile.
|
||||
fn tile_px(t: &crate::Tile, x: usize, y: usize) -> [u8; 4] {
|
||||
let o = (y * t.width as usize + x) * 4;
|
||||
t.rgba[o..o + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wmf_dib_pattern_brush_paints_its_rows_top_down() {
|
||||
let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0xFF, 0x80, 0, 0, 0, 0, 0, 0x01])]));
|
||||
let recs = [
|
||||
brush,
|
||||
// A pen after the brush: the brush took handle 0.
|
||||
wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0x0000_00FF)])),
|
||||
wrec(0x012D, &h16(0)),
|
||||
wrec(0x012D, &h16(1)),
|
||||
wrec(0x041B, &cat(vec![h16(720), h16(1440), h16(0), h16(0)])),
|
||||
];
|
||||
let p = parse(&wmf(&recs, Some([0, 0, 1440, 720]))).unwrap();
|
||||
assert_eq!(p.items.len(), 2, "the tile fill and the outline: {:?}", p.items);
|
||||
let t = pattern_of(&p.items[0]);
|
||||
assert_eq!((t.width, t.height), (8, 8));
|
||||
for x in 0..8 {
|
||||
assert_eq!(tile_px(t, x, 0), [0, 0, 0, 255], "top row is set: black");
|
||||
}
|
||||
assert_eq!(tile_px(t, 0, 1), [0, 0, 0, 255]);
|
||||
assert_eq!(tile_px(t, 1, 1), [255, 255, 255, 255]);
|
||||
assert_eq!(tile_px(t, 7, 7), [0, 0, 0, 255]);
|
||||
assert_eq!(tile_px(t, 0, 7), [255, 255, 255, 255]);
|
||||
// 96-dpi pixels of a picture in points, laid from the picture's corner.
|
||||
assert!(close(t.cell[0], 0.75) && close(t.cell[1], 0.75) && t.origin == [0.0, 0.0], "{:?} {:?}", t.cell, t.origin);
|
||||
assert_eq!(path_segs(&p.items[1]).2.map(|s| s.0), Some([255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_brushes_draw_lines_over_the_background_mode() {
|
||||
let hatch = |style: i32| wrec(0x02FC, &cat(vec![h16(2), h32(0x00FF_0000), h16(style)]));
|
||||
let rect = wrec(0x041B, &cat(vec![h16(50), h16(50), h16(0), h16(0)]));
|
||||
// HS_CROSS over a transparent background.
|
||||
let p = wmf200(&[hatch(4), wrec(0x012D, &h16(0)), wrec(0x0102, &h16(1)), rect.clone()]);
|
||||
let t = pattern_of(&p.items[0]);
|
||||
let blue = [0, 0, 255, 255];
|
||||
assert_eq!((tile_px(t, 3, 7), tile_px(t, 7, 3)), (blue, blue));
|
||||
assert_eq!(tile_px(t, 3, 3)[3], 0, "gaps are transparent");
|
||||
// HS_BDIAGONAL over the opaque white background.
|
||||
let p = wmf200(&[hatch(3), wrec(0x012D, &h16(0)), rect.clone()]);
|
||||
let t = pattern_of(&p.items[0]);
|
||||
assert_eq!((tile_px(t, 0, 7), tile_px(t, 7, 0)), (blue, blue));
|
||||
assert_eq!(tile_px(t, 0, 0), [255, 255, 255, 255]);
|
||||
// An unknown hatch style draws no fill.
|
||||
let p = wmf200(&[hatch(9), wrec(0x012D, &h16(0)), rect]);
|
||||
assert!(p.items.iter().all(|i| !matches!(i, Item::Pattern { .. })), "{:?}", p.items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_mask_xor_draws_the_pattern_in_the_xor_colour() {
|
||||
// The see-through shadow: XOR a grey polygon, AND a checker pattern, XOR the polygon again.
|
||||
let square = wrec(0x0324, &cat(vec![h16(4), h16(0), h16(0), h16(40), h16(0), h16(40), h16(40), h16(0), h16(40)]));
|
||||
let recs = [
|
||||
wrec(0x0104, &h16(7)),
|
||||
wrec(0x02FC, &cat(vec![h16(0), h32(0x0080_8080), h16(0)])),
|
||||
wrec(0x012D, &h16(0)),
|
||||
square.clone(),
|
||||
wrec(0x0104, &h16(9)),
|
||||
wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA])])),
|
||||
wrec(0x012D, &h16(1)),
|
||||
wrec(0x041B, &cat(vec![h16(40), h16(40), h16(0), h16(0)])),
|
||||
wrec(0x0104, &h16(7)),
|
||||
wrec(0x012D, &h16(0)),
|
||||
square,
|
||||
];
|
||||
let p = wmf200(&recs);
|
||||
assert_eq!(p.items.len(), 2, "only the AND pattern (and its outline) draw: {:?}", p.items);
|
||||
let t = pattern_of(&p.items[0]);
|
||||
// 0x55 = 0101 0101: white, black, …
|
||||
assert_eq!(tile_px(t, 0, 0)[3], 0, "white keeps what is below");
|
||||
assert_eq!(tile_px(t, 1, 0), [128, 128, 128, 255], "black takes the XOR colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patblt_fills_with_the_brush() {
|
||||
let patblt = |rop: u32| wrec(0x061D, &cat(vec![h32(rop), h16(30), h16(40), h16(10), h16(20)]));
|
||||
let recs = [wrec(0x02FC, &cat(vec![h16(0), h32(0x00FF_0000), h16(0)])), wrec(0x012D, &h16(0)), patblt(0x00F0_0021), patblt(0x0055_0009)];
|
||||
let p = wmf200(&recs);
|
||||
assert_eq!(p.items.len(), 1, "DSTINVERT draws nothing: {:?}", p.items);
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 20.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0), "{x0} {y0} {x1} {y1}");
|
||||
assert_eq!(path_segs(&p.items[0]).1, Some([0, 0, 255, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_dib_pattern_and_mono_brushes() {
|
||||
let dib = mono_dib([0xF0; 8]);
|
||||
let (info, bits) = dib.split_at(48);
|
||||
let brush = |typ: u32| {
|
||||
let f = [(8, 1u32), (12, 0), (16, 32), (20, info.len() as u32), (24, 32 + info.len() as u32), (28, bits.len() as u32)];
|
||||
erec_at(typ, 32 + dib.len(), &f, &[(32, info), (32 + info.len(), bits)])
|
||||
};
|
||||
let rect = erec(43, &cat(vec![e32(0), e32(0), e32(40), e32(40)]));
|
||||
let null_pen = erec(37, &h32(0x8000_0008));
|
||||
let p = parse(&emf(&[brush(94), erec(37, &h32(1)), null_pen.clone(), rect.clone()], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
let t = pattern_of(&p.items[0]);
|
||||
assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([0, 0, 0, 255], [255, 255, 255, 255]));
|
||||
// A mono brush paints in the text colour (red) and the background colour (green).
|
||||
let recs = [erec(24, &h32(0xFF)), erec(25, &h32(0xFF00)), brush(93), erec(37, &h32(1)), null_pen, rect];
|
||||
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
|
||||
let t = pattern_of(&p.items[0]);
|
||||
assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([255, 0, 0, 255], [0, 255, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_and_brush_records_never_panic() {
|
||||
// A string longer than its record is cut to what the record holds.
|
||||
let mut v = cat(vec![h16(0), h16(0), h16(0x7FFF), h16(0)]);
|
||||
v.extend(b"abcd");
|
||||
let p = wmf200(&[wrec(0x0A32, &v)]);
|
||||
assert_eq!(texts(&p)[0].text, "abcd");
|
||||
// The rectangle flag without room for the rectangle draws nothing.
|
||||
let p = wmf200(&[wrec(0x0A32, &cat(vec![h16(0), h16(0), h16(1), h16(6)]))]);
|
||||
assert!(texts(&p).is_empty());
|
||||
// A face name with no terminator stops at 32 bytes.
|
||||
let p = wmf200(&[wfont(-10, 400, [0, 0, 0], 0, 0, &[b'A'; 40]), wrec(0x012D, &h16(0)), wtext(0, 0, b"x", 0, None, &[])]);
|
||||
assert_eq!(texts(&p)[0].font.family.len(), 32);
|
||||
// A pattern brush whose DIB is cut short, or too big for a tile, still takes its handle slot
|
||||
// and draws no fill.
|
||||
let short = wrec(0x0142, &cat(vec![h16(5), h16(0), info_header(8, 8, 1, 32)]));
|
||||
let mut huge = info_header(300, 300, 1, 0);
|
||||
huge.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
huge.extend(vec![0u8; 40 * 300]);
|
||||
let huge = wrec(0x0142, &cat(vec![h16(5), h16(0), huge]));
|
||||
for b in [short, huge] {
|
||||
let recs = [
|
||||
b,
|
||||
wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0xFF)])),
|
||||
wrec(0x012D, &h16(0)),
|
||||
wrec(0x012D, &h16(1)),
|
||||
wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])),
|
||||
];
|
||||
let p = wmf200(&recs);
|
||||
assert_eq!(p.items.len(), 1, "{:?}", p.items);
|
||||
let (_, fill, stroke, _) = path_segs(&p.items[0]);
|
||||
assert_eq!((fill, stroke.map(|s| s.0)), (Some([255, 255, 255, 255]), Some([255, 0, 0, 255])));
|
||||
}
|
||||
// EMF: the string offset past the record, a huge count, a dx offset past the record.
|
||||
let bad = [
|
||||
erec_at(84, 80, &[(44, 2), (48, 4000), (52, 0)], &[]),
|
||||
erec_at(84, 84, &[(44, u32::MAX), (48, 76), (52, 0)], &[(76, &[b'a', 0, b'b', 0])]),
|
||||
erec_at(84, 80, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])]),
|
||||
erec_at(82, 20, &[(8, 1)], &[]),
|
||||
erec_at(94, 32, &[(8, 1), (16, 0xFFFF_FFFF), (20, 40), (24, 0), (28, 0xFFFF_FFFF)], &[]),
|
||||
erec_at(108, 40, &[(16, u32::MAX), (20, 0x300)], &[]),
|
||||
];
|
||||
for r in bad {
|
||||
let _ = parse(&emf(&[r], [0, 0, 200, 100], [0, 0, 5000, 2500]));
|
||||
}
|
||||
let p = parse(&emf(
|
||||
&[erec_at(84, 84, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])])],
|
||||
[0, 0, 200, 100],
|
||||
[0, 0, 5000, 2500],
|
||||
))
|
||||
.unwrap();
|
||||
assert!(texts(&p)[0].dx.is_empty(), "a dx array past the record is dropped");
|
||||
// Mutated text and brush files.
|
||||
let wfile = wmf(
|
||||
&[
|
||||
wfont(-20, 700, [1, 1, 1], 238, 450, b"Arial"),
|
||||
wrec(0x012D, &h16(0)),
|
||||
wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55; 8])])),
|
||||
wrec(0x012D, &h16(1)),
|
||||
wrec(0x012E, &h16(1)),
|
||||
wtext(5, 5, b"Hello", 6, Some([0, 0, 50, 50]), &[1, 2, 3, 4, 5]),
|
||||
wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])),
|
||||
],
|
||||
Some([0, 0, 100, 100]),
|
||||
);
|
||||
let efile = emf(
|
||||
&[efont(24, 900, 400, 1, 0, "Segoe UI"), erec(37, &h32(1)), etext(true, 1, 2, &[0x41, 0xD83D, 0xDE00], 0x2000, &[1, 2, 3, 4, 5, 6])],
|
||||
[0, 0, 200, 100],
|
||||
[0, 0, 5000, 2500],
|
||||
);
|
||||
let mut rng = Lcg(0x9E37_79B9_7F4A_7C15);
|
||||
for file in [&wfile, &efile] {
|
||||
for n in 0..=file.len() {
|
||||
let _ = parse(&file[..n]);
|
||||
}
|
||||
for _ in 0..1500 {
|
||||
let mut buf = file.clone();
|
||||
for _ in 0..6 {
|
||||
let at = rng.rnd() as usize % buf.len();
|
||||
buf[at] = rng.rnd() as u8;
|
||||
}
|
||||
let _ = parse(&buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_items_are_capped_like_any_other() {
|
||||
let t = wtext(0, 0, b"x", 0, None, &[]);
|
||||
let recs: Vec<Vec<u8>> = (0..MAX_ITEMS + 5).map(|_| t.clone()).collect();
|
||||
let p = wmf200(&recs);
|
||||
assert_eq!(p.items.len(), MAX_ITEMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_brush_pixels_count_against_the_byte_budget() {
|
||||
// 256 × 256 one-bit pattern brushes (256 KiB of RGBA each): far fewer than the object table
|
||||
// holds fit the budget, and the parse stops there.
|
||||
let mut dib = info_header(256, 256, 1, 0);
|
||||
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
dib.extend(vec![0x55u8; 32 * 256]);
|
||||
let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), dib]));
|
||||
let mut recs: Vec<Vec<u8>> = (0..1100).map(|_| brush.clone()).collect();
|
||||
recs.push(wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])));
|
||||
let p = wmf200(&recs);
|
||||
assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Text records: the font (LOGFONT), the text string decoded from its character set, and the
|
||||
//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the
|
||||
//! font's metrics and glyph drawing are left to the renderer.
|
||||
|
||||
/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont).
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Font {
|
||||
/// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty.
|
||||
pub family: String,
|
||||
/// LOGFONT height in text units: negative is the character (em) height, positive the cell
|
||||
/// height (ascent plus descent), 0 the default size.
|
||||
pub height: f32,
|
||||
/// Average character width in text units; 0 keeps the font's own proportions.
|
||||
pub width: f32,
|
||||
/// 100..=900 (400 regular, 700 bold); 0 is the default weight.
|
||||
pub weight: u16,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub strike: bool,
|
||||
/// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…).
|
||||
pub charset: u8,
|
||||
}
|
||||
|
||||
/// SYMBOL_CHARSET: byte codes are the font's own symbol codes.
|
||||
pub const SYMBOL_CHARSET: u8 = 2;
|
||||
|
||||
/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the
|
||||
/// character cell, the baseline or the bottom of the cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Text {
|
||||
/// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`,
|
||||
/// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the
|
||||
/// metafile's logical units, with the reference point at the origin. Escapement and the
|
||||
/// mapping are folded in; glyphs are never mirrored.
|
||||
pub transform: [f64; 6],
|
||||
pub text: String,
|
||||
/// Advance after each character of `text`, in text units (the dx array). Empty when the file
|
||||
/// gives none: the font's own advances apply.
|
||||
pub dx: Vec<f32>,
|
||||
pub font: Font,
|
||||
pub color: [u8; 4],
|
||||
/// Colour filled behind the character cells (OPAQUE background mode); None when transparent.
|
||||
pub background: Option<[u8; 4]>,
|
||||
pub h_align: HAlign,
|
||||
pub v_align: VAlign,
|
||||
/// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED).
|
||||
pub clip: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// TA_UPDATECP: the string starts at the current position, which moves past it.
|
||||
pub(crate) const TA_UPDATECP: u32 = 0x0001;
|
||||
/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it.
|
||||
pub(crate) const ETO_OPAQUE: u32 = 0x0002;
|
||||
pub(crate) const ETO_CLIPPED: u32 = 0x0004;
|
||||
/// The string holds glyph indices, not characters (drawn only when the font matches).
|
||||
pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010;
|
||||
/// The dx array holds (dx, dy) pairs.
|
||||
pub(crate) const ETO_PDY: u32 = 0x2000;
|
||||
/// Longest string kept from one record.
|
||||
pub(crate) const MAX_CHARS: usize = 16_384;
|
||||
|
||||
/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom,
|
||||
/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center).
|
||||
pub(crate) fn align(ta: u32) -> (HAlign, VAlign) {
|
||||
let h = match ta & 0x06 {
|
||||
6 => HAlign::Center,
|
||||
2 => HAlign::Right,
|
||||
_ => HAlign::Left,
|
||||
};
|
||||
let v = match ta & 0x18 {
|
||||
0x18 => VAlign::Baseline,
|
||||
0x08 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
(h, v)
|
||||
}
|
||||
|
||||
/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a
|
||||
/// double-byte character set's lead and trail bytes become one blank (its glyphs are not
|
||||
/// decoded) whose advance is the sum of both. `dx` may be empty.
|
||||
pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec<f32>) {
|
||||
let mut s = String::with_capacity(bytes.len());
|
||||
let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len()));
|
||||
let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136);
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 };
|
||||
let c = if n == 2 { ' ' } else { byte_char(b, charset) };
|
||||
s.push(c);
|
||||
if !dx.is_empty() {
|
||||
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
(s, out_dx)
|
||||
}
|
||||
|
||||
/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of
|
||||
/// its two advances. Unpaired surrogates become U+FFFD.
|
||||
pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec<f32>) {
|
||||
let mut s = String::with_capacity(units.len());
|
||||
let mut out_dx = Vec::with_capacity(dx.len().min(units.len()));
|
||||
let mut i = 0;
|
||||
for r in char::decode_utf16(units.iter().copied()) {
|
||||
let c = r.unwrap_or(char::REPLACEMENT_CHARACTER);
|
||||
let n = c.len_utf16();
|
||||
s.push(c);
|
||||
if !dx.is_empty() {
|
||||
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
(s, out_dx)
|
||||
}
|
||||
|
||||
/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use
|
||||
/// range (U+F000 + code), where symbol fonts map them.
|
||||
fn byte_char(b: u8, charset: u8) -> char {
|
||||
if b < 0x80 && charset != SYMBOL_CHARSET {
|
||||
return char::from(b);
|
||||
}
|
||||
let cp = match charset {
|
||||
SYMBOL_CHARSET => 0xF000 | u32::from(b),
|
||||
238 => high(b, &CP1250),
|
||||
204 => high(b, &CP1251),
|
||||
_ => high(b, &CP1252),
|
||||
};
|
||||
char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER)
|
||||
}
|
||||
|
||||
/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are
|
||||
/// undefined bytes, drawn as U+FFFD.
|
||||
fn high(b: u8, table: &[u16; 128]) -> u32 {
|
||||
match table.get(usize::from(b.wrapping_sub(0x80))) {
|
||||
Some(0) | None => 0xFFFD,
|
||||
Some(v) => u32::from(*v),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F.
|
||||
const CP1252: [u16; 128] = {
|
||||
let mut t = [0u16; 128];
|
||||
let low: [u16; 32] = [
|
||||
0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C,
|
||||
0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < 128 {
|
||||
t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 };
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// Windows-1250 (Central European) 0x80..=0xFF.
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, //
|
||||
0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, //
|
||||
0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, //
|
||||
0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, //
|
||||
0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, //
|
||||
0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, //
|
||||
0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, //
|
||||
0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order.
|
||||
const CP1251: [u16; 128] = {
|
||||
let low: [u16; 64] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, //
|
||||
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, //
|
||||
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, //
|
||||
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
||||
];
|
||||
let mut t = [0u16; 128];
|
||||
let mut i = 0;
|
||||
while i < 128 {
|
||||
t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) };
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
+114
-7
@@ -1,14 +1,19 @@
|
||||
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
|
||||
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::{Brush, Canvas, Obj, Pen, colorref};
|
||||
use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref};
|
||||
use crate::dib;
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
|
||||
const DEFAULT_INCH: f64 = 1440.0;
|
||||
/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels.
|
||||
const PT_PER_PX: f64 = 72.0 / 96.0;
|
||||
|
||||
pub(crate) fn is_wmf(b: Bytes) -> bool {
|
||||
if b.u32(0) == Some(PLACEABLE_KEY) {
|
||||
@@ -42,6 +47,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
let mut cv = Canvas::new();
|
||||
if let Some((c, size)) = bounds {
|
||||
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
|
||||
cv.pattern_px = PT_PER_PX;
|
||||
}
|
||||
let mut player_viewport = bounds.is_some();
|
||||
let mut parsed = 0usize;
|
||||
@@ -128,12 +134,31 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) {
|
||||
}),
|
||||
0x02FA => create_pen(cv, r),
|
||||
0x02FC => create_brush(cv, r),
|
||||
// Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT
|
||||
// handles count them; selecting one draws nothing.
|
||||
0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => {
|
||||
0x02FB => {
|
||||
let f = font(r).unwrap_or_default();
|
||||
cv.add_object(None, Obj::Font(Arc::new(f)));
|
||||
Some(())
|
||||
}
|
||||
0x0142 => {
|
||||
// A pattern the reader cannot decode still takes its handle slot.
|
||||
let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush);
|
||||
cv.add_object(None, b);
|
||||
Some(())
|
||||
}
|
||||
// Palettes, old-style pattern brushes and regions take a handle slot too, so later
|
||||
// SELECTOBJECT handles count them; selecting one draws nothing.
|
||||
0x00F7 | 0x01F9 | 0x06FF => {
|
||||
cv.add_object(None, Obj::Other);
|
||||
Some(())
|
||||
}
|
||||
0x0209 => r.u32(6).map(|c| cv.set_text_color(c)),
|
||||
0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)),
|
||||
0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))),
|
||||
0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))),
|
||||
0x0A32 => ext_text_out(cv, r),
|
||||
0x0521 => text_out(cv, r),
|
||||
// PATBLT: rop(4), height, width, y, x.
|
||||
0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)),
|
||||
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
|
||||
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
|
||||
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
|
||||
@@ -168,11 +193,93 @@ fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn).
|
||||
/// CREATEBRUSHINDIRECT: style, colour, hatch.
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = r.u16(6)?;
|
||||
let style = u32::from(r.u16(6)?);
|
||||
let color = r.u32(8)?;
|
||||
cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) }));
|
||||
let mut b = Brush::new(style, colorref(color));
|
||||
if style == BS_HATCHED {
|
||||
b.hatch = u32::from(r.u16(12).unwrap_or(0));
|
||||
}
|
||||
cv.add_object(None, Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette
|
||||
/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a
|
||||
/// monochrome DIB takes the text and background colours.
|
||||
fn dib_pattern_brush(r: Bytes) -> Option<Brush> {
|
||||
let usage = r.u16(8)?;
|
||||
let dib = r.tail(10)?;
|
||||
let n = dib::info_len(dib)?;
|
||||
let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?;
|
||||
Brush::pattern(img, usage == 1)
|
||||
}
|
||||
|
||||
/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic,
|
||||
/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the
|
||||
/// face name (up to 32 bytes, NUL-terminated, in the font's character set).
|
||||
fn font(r: Bytes) -> Option<LogFont> {
|
||||
let height = r.i16(6)?;
|
||||
let width = r.i16(8).unwrap_or(0);
|
||||
let escapement = r.i16(10).unwrap_or(0);
|
||||
let weight = r.i16(14).unwrap_or(0);
|
||||
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
|
||||
let charset = byte(19);
|
||||
let name = r.0.get(24..).unwrap_or(&[]);
|
||||
let name = name.get(..32).unwrap_or(name);
|
||||
let name = name.split(|b| *b == 0).next().unwrap_or(&[]);
|
||||
// Face names are plain ASCII in practice; other bytes go through the Western code page.
|
||||
let (family, _) = decode_bytes(name, 0, &[]);
|
||||
Some(LogFont {
|
||||
font: Font {
|
||||
family: family.trim().to_string(),
|
||||
height: f32::from(height),
|
||||
width: f32::from(width),
|
||||
weight: weight.clamp(0, 1000) as u16,
|
||||
italic: byte(16) != 0,
|
||||
underline: byte(17) != 0,
|
||||
strike: byte(18) != 0,
|
||||
charset,
|
||||
},
|
||||
escapement: f64::from(escapement),
|
||||
})
|
||||
}
|
||||
|
||||
/// The charset of the selected font, for decoding 8-bit text.
|
||||
fn charset(cv: &Canvas) -> u8 {
|
||||
cv.font_charset()
|
||||
}
|
||||
|
||||
/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right,
|
||||
/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx
|
||||
/// array of i16 advances.
|
||||
fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let (y, x) = (r.i16(6)?, r.i16(8)?);
|
||||
let n = usize::from(r.u16(10)?).min(MAX_CHARS);
|
||||
let opts = u32::from(r.u16(12)?);
|
||||
let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0;
|
||||
let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None };
|
||||
let at = if has_rect { 22 } else { 14 };
|
||||
// A string running past the record is cut to what the record holds.
|
||||
let avail = r.0.len().saturating_sub(at);
|
||||
let n = n.min(avail);
|
||||
let bytes = r.slice(at, n)?;
|
||||
let dx_at = at + n.next_multiple_of(2);
|
||||
let dx: Vec<f32> = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() };
|
||||
let (text, dx) = decode_bytes(bytes, charset(cv), &dx);
|
||||
cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x.
|
||||
fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let n = usize::from(r.u16(6)?).min(MAX_CHARS);
|
||||
let bytes = r.slice(8, n)?;
|
||||
let at = 8 + n.next_multiple_of(2);
|
||||
let (y, x) = (r.i16(at)?, r.i16(at + 2)?);
|
||||
let (text, _) = decode_bytes(bytes, charset(cv), &[]);
|
||||
cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None);
|
||||
Some(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
+125
-4
@@ -63,7 +63,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[
|
||||
("fracture", "Fracture", "Exciting", 2000, &[]),
|
||||
("crush", "Crush", "Exciting", 2000, &[]),
|
||||
("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]),
|
||||
("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r"]),
|
||||
("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]),
|
||||
("airplane", "Airplane", "Exciting", 2000, &["l", "r"]),
|
||||
("origami", "Origami", "Exciting", 2000, &["l", "r"]),
|
||||
("dissolve", "Dissolve", "Exciting", 1200, &[]),
|
||||
@@ -72,9 +72,9 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[
|
||||
("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]),
|
||||
("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]),
|
||||
("honeycomb", "Honeycomb", "Exciting", 3000, &[]),
|
||||
("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d"]),
|
||||
("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]),
|
||||
("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]),
|
||||
("shred", "Shred", "Exciting", 2000, &["strips", "particles"]),
|
||||
("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]),
|
||||
("switch", "Switch", "Exciting", 1250, &["l", "r"]),
|
||||
("flip", "Flip", "Exciting", 1250, &["l", "r"]),
|
||||
("gallery", "Gallery", "Exciting", 1500, &["l", "r"]),
|
||||
@@ -82,7 +82,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[
|
||||
("doors", "Doors", "Exciting", 1400, &["vert", "horz"]),
|
||||
("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]),
|
||||
("comb", "Comb", "Exciting", 1000, &["horz", "vert"]),
|
||||
("zoom", "Zoom", "Exciting", 1000, &["in", "out"]),
|
||||
("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]),
|
||||
("random", "Random", "Exciting", 2000, &[]),
|
||||
("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]),
|
||||
("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]),
|
||||
@@ -93,6 +93,101 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[
|
||||
("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]),
|
||||
];
|
||||
|
||||
/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the
|
||||
/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction
|
||||
/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall
|
||||
/// Over, Wind…) name the side itself; Ripple names where the wave heads.
|
||||
pub fn transition_option_label(kind: &str, option: &str) -> String {
|
||||
let side = |c: char| match c {
|
||||
'l' => "Left",
|
||||
'r' => "Right",
|
||||
'u' => "Top",
|
||||
_ => "Bottom",
|
||||
};
|
||||
let opposite = |c: char| match c {
|
||||
'l' => 'r',
|
||||
'r' => 'l',
|
||||
'u' => 'd',
|
||||
_ => 'u',
|
||||
};
|
||||
let corner = |o: &str, flip: bool| -> Option<String> {
|
||||
let mut cs = o.chars();
|
||||
let (h, v) = (cs.next()?, cs.next()?);
|
||||
let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) };
|
||||
Some(format!("From {}-{}", side(v), side(h)))
|
||||
};
|
||||
let moving = matches!(
|
||||
kind,
|
||||
"push"
|
||||
| "wipe"
|
||||
| "cover"
|
||||
| "uncover"
|
||||
| "pan"
|
||||
| "vortex"
|
||||
| "cube"
|
||||
| "box"
|
||||
| "rotate"
|
||||
| "orbit"
|
||||
| "gallery"
|
||||
| "conveyor"
|
||||
| "ferris"
|
||||
| "switch"
|
||||
| "flip"
|
||||
);
|
||||
match (kind, option) {
|
||||
(_, "horz") => "Horizontal".into(),
|
||||
(_, "vert") => "Vertical".into(),
|
||||
("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }),
|
||||
("fade" | "cut", "throughBlack") => "Through Black".into(),
|
||||
("fade", _) => "Smoothly".into(),
|
||||
("cut", _) => "Cut".into(),
|
||||
("clock", "counterClockwise") => "Counterclockwise".into(),
|
||||
("clock", "wedge") => "Wedge".into(),
|
||||
("clock", _) => "Clockwise".into(),
|
||||
("zoom", "rotate") => "Zoom and Rotate".into(),
|
||||
("ripple", "center") => "Center".into(),
|
||||
("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()),
|
||||
("glitter", o) => {
|
||||
let (pat, d) = match o.strip_prefix("hex") {
|
||||
Some(d) => ("Hexagons", d.to_ascii_lowercase()),
|
||||
None => ("Diamonds", o.to_string()),
|
||||
};
|
||||
format!("{pat} From {}", side(d.chars().next().unwrap_or('l')))
|
||||
}
|
||||
("pageCurlDouble", o) => match o {
|
||||
"singleL" => "Single Left".into(),
|
||||
"singleR" => "Single Right".into(),
|
||||
"r" => "Double Right".into(),
|
||||
_ => "Double Left".into(),
|
||||
},
|
||||
("shred", o) => {
|
||||
format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" })
|
||||
}
|
||||
("reveal", o) => format!(
|
||||
"{} {}",
|
||||
if o.starts_with("black") { "Through Black" } else { "Smoothly" },
|
||||
if o.ends_with("Right") { "From Right" } else { "From Left" }
|
||||
),
|
||||
("flythrough", o) => {
|
||||
format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" })
|
||||
}
|
||||
("morph", o) => match o {
|
||||
"words" => "Words".into(),
|
||||
"characters" => "Characters".into(),
|
||||
_ => "Objects".into(),
|
||||
},
|
||||
(_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(),
|
||||
(_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))),
|
||||
(_, "l") => "Left".into(),
|
||||
(_, "r") => "Right".into(),
|
||||
(_, "u") => "Top".into(),
|
||||
(_, "d") => "Bottom".into(),
|
||||
(_, "in") => "In".into(),
|
||||
(_, "out") => "Out".into(),
|
||||
(_, o) => o.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AnimClass {
|
||||
@@ -291,3 +386,29 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
|
||||
pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str])> {
|
||||
ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod option_label_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transition_option_labels() {
|
||||
assert_eq!(transition_option_label("push", "l"), "From Right");
|
||||
assert_eq!(transition_option_label("push", "u"), "From Bottom");
|
||||
assert_eq!(transition_option_label("wipe", "r"), "From Left");
|
||||
assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left");
|
||||
assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left");
|
||||
assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top");
|
||||
assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left");
|
||||
assert_eq!(transition_option_label("wind", "l"), "Left");
|
||||
assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right");
|
||||
assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out");
|
||||
assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate");
|
||||
assert_eq!(transition_option_label("split", "vertIn"), "Vertical In");
|
||||
for (id, _, _, _, opts) in TRANSITIONS {
|
||||
for o in opts.iter() {
|
||||
assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ pub struct Series {
|
||||
/// Combo charts: this series' own type.
|
||||
pub kind: Option<ChartType>,
|
||||
pub secondary_axis: bool,
|
||||
/// The number format of the series' data labels (Excel code): the labels' own, else the
|
||||
/// source data's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -144,6 +148,15 @@ pub struct Chart {
|
||||
pub categories: Vec<String>,
|
||||
pub series: Vec<Series>,
|
||||
pub legend: Option<String>,
|
||||
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
|
||||
pub legend_overlay: bool,
|
||||
/// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's);
|
||||
/// `None`: the file gives none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_size: Option<f64>,
|
||||
/// Title text size in points (its rich text or `c:txPr`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title_size: Option<f64>,
|
||||
pub data_labels: bool,
|
||||
pub gridlines: bool,
|
||||
pub axis_titles: (Option<String>, Option<String>),
|
||||
@@ -159,6 +172,20 @@ pub struct Chart {
|
||||
pub vary_colors: bool,
|
||||
pub y_min: Option<f64>,
|
||||
pub y_max: Option<f64>,
|
||||
/// The value axis' major unit, when the chart fixes it.
|
||||
pub y_major: Option<f64>,
|
||||
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
|
||||
pub y_format: Option<String>,
|
||||
/// The value axis is deleted (`c:delete`): no labels, though gridlines may stay.
|
||||
pub y_hidden: bool,
|
||||
/// A combo chart's secondary value axis: bounds, major unit and number format.
|
||||
pub y2_min: Option<f64>,
|
||||
pub y2_max: Option<f64>,
|
||||
pub y2_major: Option<f64>,
|
||||
pub y2_format: Option<String>,
|
||||
pub y2_hidden: bool,
|
||||
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
|
||||
pub lang: Option<String>,
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
@@ -172,6 +199,9 @@ impl Default for Chart {
|
||||
categories: vec![],
|
||||
series: vec![],
|
||||
legend: Some("b".into()),
|
||||
legend_overlay: false,
|
||||
text_size: None,
|
||||
title_size: None,
|
||||
data_labels: false,
|
||||
gridlines: true,
|
||||
axis_titles: (None, None),
|
||||
@@ -186,6 +216,15 @@ impl Default for Chart {
|
||||
vary_colors: false,
|
||||
y_min: None,
|
||||
y_max: None,
|
||||
y_major: None,
|
||||
y_format: None,
|
||||
y_hidden: false,
|
||||
y2_min: None,
|
||||
y2_max: None,
|
||||
y2_major: None,
|
||||
y2_format: None,
|
||||
y2_hidden: false,
|
||||
lang: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +249,81 @@ impl Chart {
|
||||
c.vary_colors = n_series == 1;
|
||||
c
|
||||
}
|
||||
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
|
||||
/// chart's.
|
||||
pub fn series_kind(&self, s: &Series) -> ChartType {
|
||||
match (self.kind, s.kind) {
|
||||
(ChartType::Combo, Some(k)) if k != ChartType::Combo => k,
|
||||
(ChartType::Combo, _) => ChartType::Column,
|
||||
(k, _) => k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series is plotted against the secondary value axis (combo charts only).
|
||||
pub fn on_secondary(&self, s: &Series) -> bool {
|
||||
self.kind == ChartType::Combo && s.secondary_axis
|
||||
}
|
||||
|
||||
/// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo
|
||||
/// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series
|
||||
/// uses that axis.
|
||||
pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> {
|
||||
if self.kind != ChartType::Combo {
|
||||
return (!secondary).then(|| self.value_range());
|
||||
}
|
||||
let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect();
|
||||
if ser.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f64, 0.0_f64);
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let mut percent = false;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
if percent {
|
||||
hi = hi.max(1.0);
|
||||
}
|
||||
Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) })
|
||||
}
|
||||
|
||||
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
|
||||
/// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 %
|
||||
/// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis.
|
||||
pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> {
|
||||
let (lo, hi) = self.axis_range(secondary)?;
|
||||
let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) };
|
||||
let full_percent = !secondary && self.kind.percent();
|
||||
Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent))
|
||||
}
|
||||
|
||||
/// A value axis label in the axis' number format and the chart's language.
|
||||
pub fn axis_label(&self, secondary: bool, v: f64) -> String {
|
||||
let code = if secondary { &self.y2_format } else { &self.y_format };
|
||||
crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// A data label of series `s`: its number format, else its axis', in the chart's language.
|
||||
pub fn data_label(&self, s: &Series, v: f64) -> String {
|
||||
let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format };
|
||||
let code = s.number_format.as_ref().or(axis.as_ref());
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
@@ -242,6 +356,53 @@ impl Chart {
|
||||
}
|
||||
}
|
||||
|
||||
/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major`
|
||||
/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`)
|
||||
/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals.
|
||||
pub fn axis_scale(
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
fmin: Option<f64>,
|
||||
fmax: Option<f64>,
|
||||
major: Option<f64>,
|
||||
max_intervals: usize,
|
||||
headroom: bool,
|
||||
) -> (f64, f64, f64) {
|
||||
// Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite.
|
||||
let fin = |v: Option<f64>| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100));
|
||||
let (fmin, fmax) = (fin(fmin), fin(fmax));
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) };
|
||||
let mut lo = fmin.unwrap_or(lo.min(0.0));
|
||||
let mut hi = fmax.unwrap_or(hi.max(lo));
|
||||
if hi - lo < 1e-12 {
|
||||
hi = lo + lo.abs().max(1.0);
|
||||
}
|
||||
let span = hi - lo;
|
||||
if headroom && fmax.is_none() && hi > 0.0 {
|
||||
hi += span * 0.05;
|
||||
}
|
||||
if headroom && fmin.is_none() && lo < 0.0 {
|
||||
lo -= span * 0.05;
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
|
||||
let mut k = 0;
|
||||
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
|
||||
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
|
||||
step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 };
|
||||
k += 1;
|
||||
}
|
||||
step
|
||||
}
|
||||
};
|
||||
let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step);
|
||||
let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step);
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||||
@@ -280,6 +441,67 @@ mod tests {
|
||||
assert!(b > a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_axis_scales_like_powerpoint() {
|
||||
// Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000.
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0));
|
||||
// Clicks up to 6 600: 0–7 000 by 1 000.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0));
|
||||
// Fewer intervals fit on a short axis.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0));
|
||||
// Negative data gets room below; fixed bounds and unit are kept.
|
||||
let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true);
|
||||
assert!(min <= -30.0 && max >= 105.0);
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0));
|
||||
// 100 % stacked stays at 0–1 without headroom.
|
||||
assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2));
|
||||
// Nonsense never loops or divides by zero.
|
||||
for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] {
|
||||
let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true);
|
||||
assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_labels_use_the_format_and_language() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into());
|
||||
c.lang = Some("ru-RU".into());
|
||||
assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽");
|
||||
assert_eq!(c.axis_label(false, 0.0), "- ₽");
|
||||
// No format: General.
|
||||
assert_eq!(c.axis_label(true, 2.5), "2,5");
|
||||
c.lang = None;
|
||||
assert_eq!(c.axis_label(true, 2.5), "2.5");
|
||||
// Data labels: the series' format, else the axis'.
|
||||
c.lang = Some("ru-RU".into());
|
||||
c.series[0].number_format = Some(r#"#,##0\ "₽""#.into());
|
||||
assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽");
|
||||
assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_axis_ranges() {
|
||||
// Spend in columns on the primary axis, clicks as a line on the secondary one.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.series.truncate(2);
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(310_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(6_000.0), Some(7_400.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
assert_eq!(c.axis_range(false), Some((0.0, 310_000.0)));
|
||||
assert_eq!(c.axis_range(true), Some((0.0, 7_400.0)));
|
||||
assert_eq!(c.series_kind(&c.series[1]), ChartType::Line);
|
||||
// Without a secondary series there is no secondary axis; a non-combo chart never has one.
|
||||
c.series[1].secondary_axis = false;
|
||||
assert_eq!(c.axis_range(true), None);
|
||||
let col = Chart::sample(ChartType::Column);
|
||||
assert_eq!(col.axis_range(false), Some(col.value_range()));
|
||||
assert_eq!(col.axis_range(true), None);
|
||||
assert!(!col.on_secondary(&col.series[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_ranges() {
|
||||
let c = Chart::sample(ChartType::StackedColumn);
|
||||
|
||||
+248
-45
@@ -2,6 +2,7 @@
|
||||
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
|
||||
|
||||
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
||||
use crate::words;
|
||||
|
||||
/// (paragraph, char offset in the paragraph).
|
||||
pub type Pos = (usize, usize);
|
||||
@@ -275,65 +276,109 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
caret
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '\''
|
||||
fn para_text(body: &TextBody, pi: usize) -> String {
|
||||
body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Word boundaries around `pos` (double-click selection).
|
||||
/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
|
||||
/// trailing spaces, as PowerPoint selects it.
|
||||
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
if t.is_empty() {
|
||||
return ((pi, 0), (pi, 0));
|
||||
}
|
||||
let at = ch.min(t.len().saturating_sub(1));
|
||||
let w = t.get(at).copied().is_some_and(is_word);
|
||||
let mut s = at;
|
||||
while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = at;
|
||||
while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) {
|
||||
e += 1;
|
||||
}
|
||||
// Include trailing spaces after a word.
|
||||
while w && e < t.len() && t.get(e) == Some(&' ') {
|
||||
e += 1;
|
||||
}
|
||||
((pi, s), (pi, e.max(s)))
|
||||
let (s, e) = words::word_at(¶_text(body, pi), ch);
|
||||
((pi, s), (pi, e))
|
||||
}
|
||||
|
||||
/// Next/previous word start (Option/Ctrl + arrows).
|
||||
/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
|
||||
/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
|
||||
/// end of the previous one.
|
||||
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
let t = para_text(body, pi);
|
||||
if forward {
|
||||
if ch >= t.len() {
|
||||
return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) };
|
||||
match words::next_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
|
||||
None => (pi, ch),
|
||||
}
|
||||
let mut i = ch;
|
||||
while i < t.len() && t.get(i).copied().is_some_and(is_word) {
|
||||
i += 1;
|
||||
}
|
||||
while i < t.len() && !t.get(i).copied().is_some_and(is_word) {
|
||||
i += 1;
|
||||
}
|
||||
(pi, i)
|
||||
} else {
|
||||
if ch == 0 {
|
||||
return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) };
|
||||
match words::prev_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)),
|
||||
None => (pi, 0),
|
||||
}
|
||||
let mut i = ch;
|
||||
while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && t.get(i - 1).copied().is_some_and(is_word) {
|
||||
i -= 1;
|
||||
}
|
||||
(pi, i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
|
||||
pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) }
|
||||
}
|
||||
|
||||
/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
|
||||
pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
|
||||
let (pi, _) = clamp(body, pos);
|
||||
if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) }
|
||||
}
|
||||
|
||||
/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
|
||||
/// PowerPoint; returns the caret.
|
||||
///
|
||||
/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
|
||||
/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
|
||||
/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
|
||||
/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
|
||||
/// at the end of the paragraph after a word go, and sentence punctuation left against a word
|
||||
/// gets a space. At a paragraph's end it joins the next paragraph.
|
||||
pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
if body.paragraphs.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t = para_text(body, pi);
|
||||
if !forward {
|
||||
let to = match words::prev_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None => char_move(body, (pi, ch), false),
|
||||
};
|
||||
return delete(body, to, (pi, ch));
|
||||
}
|
||||
let Some(e) = words::delete_forward_end(&t, ch) else {
|
||||
let to = char_move(body, (pi, ch), true);
|
||||
return delete(body, (pi, ch), to);
|
||||
};
|
||||
let at = delete(body, (pi, ch), (pi, e));
|
||||
smart_space(body, at)
|
||||
}
|
||||
|
||||
/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
|
||||
fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
|
||||
let (pi, s) = at;
|
||||
let chars: Vec<char> = para_text(body, pi).chars().collect();
|
||||
let after = chars.get(s).copied();
|
||||
let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||
if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
|
||||
let mut w = s;
|
||||
while w > 0 && chars.get(w - 1) == Some(&' ') {
|
||||
w -= 1;
|
||||
}
|
||||
if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
|
||||
return delete(body, (pi, w), (pi, s));
|
||||
}
|
||||
return at;
|
||||
}
|
||||
if let (Some(b), Some(a)) = (before, after)
|
||||
&& words::is_sentence_punct(b)
|
||||
&& a.is_alphanumeric()
|
||||
{
|
||||
let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
|
||||
if !digits {
|
||||
return insert(body, at, " ", None);
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// One character left/right across paragraph boundaries.
|
||||
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
@@ -361,6 +406,18 @@ pub fn end(body: &TextBody) -> Pos {
|
||||
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
|
||||
/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
|
||||
pub fn next_case(s: &str) -> &'static str {
|
||||
if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
|
||||
"title"
|
||||
} else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
|
||||
"lower"
|
||||
} else {
|
||||
"upper"
|
||||
}
|
||||
}
|
||||
|
||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
match mode {
|
||||
@@ -456,7 +513,7 @@ mod tests {
|
||||
#[test]
|
||||
fn words_and_moves() {
|
||||
let b = body("Hello brave world");
|
||||
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12)));
|
||||
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
|
||||
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
|
||||
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
|
||||
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
|
||||
@@ -472,4 +529,150 @@ mod tests {
|
||||
assert_eq!(b.paragraphs[0].char_len(), 3);
|
||||
assert_eq!(b.paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
|
||||
fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
|
||||
let mut b = body(text);
|
||||
let c = delete_word(&mut b, at, forward);
|
||||
(b.text(), c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_backspace_like_powerpoint() {
|
||||
let t = "ab cd...ef gh";
|
||||
let want = [
|
||||
("ab cd...ef gh", 0),
|
||||
("b cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
("cd...ef gh", 0),
|
||||
("ab d...ef gh", 5),
|
||||
("ab ...ef gh", 5),
|
||||
("ab cd..ef gh", 7),
|
||||
("ab cd.ef gh", 7),
|
||||
("ab cdef gh", 7),
|
||||
("ab cd...f gh", 10),
|
||||
("ab cd... gh", 10),
|
||||
("ab cd...gh", 10),
|
||||
("ab cd...ef h", 13),
|
||||
("ab cd...ef ", 13),
|
||||
];
|
||||
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||
assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||
}
|
||||
let w = "Hello world foo, bar\nnext para";
|
||||
assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6)));
|
||||
assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16)));
|
||||
// At a paragraph's start it joins the paragraph before.
|
||||
assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21)));
|
||||
assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0)));
|
||||
// A line break is a word of its own.
|
||||
let mut b = body("ab");
|
||||
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||
assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
|
||||
assert_eq!(b.text(), "abcd");
|
||||
let mut b = body("ab");
|
||||
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||
assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
|
||||
assert_eq!(b.text(), "abcd");
|
||||
assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
|
||||
// Emoji sequences and combining marks are never split.
|
||||
assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2)));
|
||||
assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0)));
|
||||
assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0)));
|
||||
// Each CJK character is a word.
|
||||
assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_delete_like_powerpoint() {
|
||||
let t = "ab cd...ef gh";
|
||||
let want = [
|
||||
("cd...ef gh", 0),
|
||||
("a cd...ef gh", 1),
|
||||
("ab...ef gh", 2),
|
||||
("ab cd...ef gh", 3),
|
||||
("ab cd...ef gh", 4),
|
||||
("ab...ef gh", 2),
|
||||
("ab c...ef gh", 6),
|
||||
("ab cdef gh", 7),
|
||||
("ab cd. ef gh", 9),
|
||||
("ab cd.. ef gh", 10),
|
||||
("ab cd... gh", 11),
|
||||
("ab cd...e gh", 11),
|
||||
("ab cd...ef", 12),
|
||||
("ab cd...ef", 12),
|
||||
("ab cd...ef g", 14),
|
||||
("ab cd...ef gh", 15),
|
||||
];
|
||||
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||
assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||
}
|
||||
let w = "Hello world foo, bar\nnext para";
|
||||
let want = [
|
||||
(0, "world foo, bar\nnext para", 0),
|
||||
(2, "He world foo, bar\nnext para", 2),
|
||||
(5, "Hello foo, bar\nnext para", 5),
|
||||
(6, "Hello foo, bar\nnext para", 6),
|
||||
(8, "Hello wo foo, bar\nnext para", 8),
|
||||
(11, "Hello world, bar\nnext para", 11),
|
||||
(12, "Hello world foo, bar\nnext para", 12),
|
||||
(16, "Hello world foobar\nnext para", 16),
|
||||
(17, "Hello world foo,\nnext para", 17),
|
||||
(18, "Hello world foo, \nnext para", 18),
|
||||
(21, "Hello world foo, barnext para", 21),
|
||||
];
|
||||
for (c, txt, caret) in want {
|
||||
assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}");
|
||||
}
|
||||
assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0)));
|
||||
assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2)));
|
||||
assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3)));
|
||||
assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5)));
|
||||
assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4)));
|
||||
assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
|
||||
// Right-to-left text deletes in logical order like any other.
|
||||
assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6)));
|
||||
assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6)));
|
||||
// Numbers keep their separators: no space is pushed into "3.14".
|
||||
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
|
||||
// Hostile positions clamp.
|
||||
assert_eq!(del_word("ab", (9, 9), true).0, "ab");
|
||||
assert_eq!(del_word("", (0, 0), false).0, "");
|
||||
let mut empty = TextBody::default();
|
||||
assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_moves() {
|
||||
let b = body("one two\nthree\n\nfour");
|
||||
assert_eq!(para_down(&b, (0, 2)), (1, 0));
|
||||
assert_eq!(para_down(&b, (1, 0)), (2, 0));
|
||||
assert_eq!(para_down(&b, (3, 1)), (3, 4));
|
||||
assert_eq!(para_up(&b, (3, 2)), (3, 0));
|
||||
assert_eq!(para_up(&b, (3, 0)), (2, 0));
|
||||
assert_eq!(para_up(&b, (0, 0)), (0, 0));
|
||||
assert_eq!(word_move(&b, (0, 4), true), (0, 7));
|
||||
assert_eq!(word_move(&b, (0, 7), true), (1, 0));
|
||||
assert_eq!(word_move(&b, (1, 0), false), (0, 7));
|
||||
assert_eq!(word_move(&b, (2, 0), true), (3, 0));
|
||||
assert_eq!(word_move(&b, (3, 4), true), (3, 4));
|
||||
assert_eq!(word_move(&b, (0, 0), false), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_cycle() {
|
||||
assert_eq!(next_case("hello world"), "title");
|
||||
assert_eq!(next_case("Hello World"), "upper");
|
||||
assert_eq!(next_case("HELLO WORLD"), "lower");
|
||||
assert_eq!(next_case("Hello world"), "upper");
|
||||
assert_eq!(next_case("hELLO wORLD"), "upper");
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ pub mod anim;
|
||||
pub mod chart;
|
||||
pub mod defaults;
|
||||
pub mod edit;
|
||||
pub mod numfmt;
|
||||
pub mod resolve;
|
||||
pub mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod words;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -443,6 +445,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The list holding `id` among its siblings: the tree's top level, or a group's children.
|
||||
pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
|
||||
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
if shapes.iter().any(|s| s.id == id) {
|
||||
return Some(shapes);
|
||||
}
|
||||
for s in shapes.iter_mut() {
|
||||
if let ShapeKind::Group { children, .. } = &mut s.kind
|
||||
&& let Some(f) = rec(children, id, depth + 1)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
|
||||
pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
|
||||
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some(parent);
|
||||
}
|
||||
if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, None, id, 0)
|
||||
}
|
||||
|
||||
/// Background of a slide, layout or master.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart
|
||||
//! axes and data labels show them, in the chart's language (digit grouping and decimal mark).
|
||||
|
||||
/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`).
|
||||
fn marks(lang: Option<&str>) -> (char, char) {
|
||||
let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase();
|
||||
match l.as_str() {
|
||||
// Space-grouped, comma decimal.
|
||||
"ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy"
|
||||
| "ka" | "az" => ('\u{a0}', ','),
|
||||
// Dot-grouped, comma decimal.
|
||||
"de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','),
|
||||
_ => (',', '.'),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok {
|
||||
/// Text shown as is.
|
||||
Lit(String),
|
||||
/// A digit placeholder: `0`, `#` or `?`.
|
||||
Digit(char),
|
||||
Dot,
|
||||
Comma,
|
||||
Percent,
|
||||
}
|
||||
|
||||
/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out).
|
||||
fn tokens(section: &str) -> Vec<Tok> {
|
||||
let mut out = vec![];
|
||||
let mut it = section.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == '"' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
out.push(Tok::Lit(s));
|
||||
}
|
||||
'\\' => {
|
||||
if let Some(d) = it.next() {
|
||||
out.push(Tok::Lit(d.to_string()));
|
||||
}
|
||||
}
|
||||
// Padding the width of the next character: a space.
|
||||
'_' => {
|
||||
it.next();
|
||||
out.push(Tok::Lit(" ".into()));
|
||||
}
|
||||
// Repeat-to-fill: nothing on an axis.
|
||||
'*' => {
|
||||
it.next();
|
||||
}
|
||||
'[' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == ']' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
if let Some(sym) = s.strip_prefix('$') {
|
||||
let sym = sym.split('-').next().unwrap_or("");
|
||||
if !sym.is_empty() {
|
||||
out.push(Tok::Lit(sym.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
'0' | '#' | '?' => out.push(Tok::Digit(c)),
|
||||
'.' => out.push(Tok::Dot),
|
||||
',' => out.push(Tok::Comma),
|
||||
'%' => out.push(Tok::Percent),
|
||||
'@' => {}
|
||||
_ => out.push(Tok::Lit(c.to_string())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The format's sections split at `;` (outside quotes, brackets and escapes).
|
||||
fn sections(code: &str) -> Vec<&str> {
|
||||
let mut out = vec![];
|
||||
let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false);
|
||||
for (i, c) in code.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\\' if !quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
'[' if !quoted => bracket = true,
|
||||
']' if !quoted => bracket = false,
|
||||
';' if !quoted && !bracket => {
|
||||
out.push(code.get(start..i).unwrap_or(""));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(code.get(start..).unwrap_or(""));
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` in the General format: up to 10 significant digits, no grouping.
|
||||
fn general(v: f64, dec: char) -> String {
|
||||
if v == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
let a = v.abs();
|
||||
let s = if !(1e-9..1e11).contains(&a) {
|
||||
format!("{v:.5E}")
|
||||
} else {
|
||||
let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize;
|
||||
let s = format!("{v:.digits$}");
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
};
|
||||
s.replace('.', &dec.to_string())
|
||||
}
|
||||
|
||||
/// Group an integer's digits in threes with `sep`.
|
||||
fn group(digits: &str, sep: char) -> String {
|
||||
let n = digits.chars().count();
|
||||
let mut out = String::new();
|
||||
for (i, d) in digits.chars().enumerate() {
|
||||
if i > 0 && (n - i).is_multiple_of(3) {
|
||||
out.push(sep);
|
||||
}
|
||||
out.push(d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
|
||||
/// section of a format doesn't apply).
|
||||
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
|
||||
let (sep, dec) = marks(lang);
|
||||
if !v.is_finite() {
|
||||
return String::new();
|
||||
}
|
||||
let secs = sections(code);
|
||||
// The section for the value's sign; a negative or zero section shows the value unsigned.
|
||||
let (sec, v, minus) = match (secs.as_slice(), v) {
|
||||
([_, neg, ..], v) if v < 0.0 => (*neg, -v, false),
|
||||
([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false),
|
||||
([pos, ..], v) => (*pos, v.abs(), v < 0.0),
|
||||
([], v) => ("General", v.abs(), v < 0.0),
|
||||
};
|
||||
if sec.trim().eq_ignore_ascii_case("general") || sec.trim().is_empty() {
|
||||
let s = general(v, dec);
|
||||
return if minus { format!("-{s}") } else { s };
|
||||
}
|
||||
let toks = tokens(sec);
|
||||
let first = toks.iter().position(|t| matches!(t, Tok::Digit(_)));
|
||||
let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_)));
|
||||
let (Some(first), Some(last)) = (first, last) else {
|
||||
// No digits: just the section's text ("-" for zero, a label).
|
||||
let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect();
|
||||
return s.trim().to_string();
|
||||
};
|
||||
let percent = toks.contains(&Tok::Percent);
|
||||
let number = toks.get(first..=last).unwrap_or(&[]);
|
||||
let dot = number.iter().position(|t| *t == Tok::Dot);
|
||||
let (int_part, frac_part) = match dot {
|
||||
Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])),
|
||||
None => (number, &[][..]),
|
||||
};
|
||||
// Grouping: a comma (or the locale's space, written literally) between integer digits.
|
||||
let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty()));
|
||||
// Commas right after the last digit scale by a thousand each (`#,##0,` = thousands).
|
||||
let after = toks.get(last + 1..).unwrap_or(&[]);
|
||||
let scale_commas = if dot.is_none() { after.iter().take_while(|t| **t == Tok::Comma).count() } else { 0 };
|
||||
let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count();
|
||||
let frac_digits: Vec<char> = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect();
|
||||
let max_frac = frac_digits.len();
|
||||
let min_frac = frac_digits.iter().filter(|c| **c == '0').count();
|
||||
let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32);
|
||||
if !x.is_finite() {
|
||||
x = 0.0;
|
||||
}
|
||||
let s = format!("{x:.max_frac$}");
|
||||
let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), ""));
|
||||
let mut fp = fp.to_string();
|
||||
while fp.len() > min_frac && fp.ends_with('0') {
|
||||
fp.pop();
|
||||
}
|
||||
let ip = ip.trim_start_matches('0');
|
||||
let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() };
|
||||
let mut num = if grouping { group(&ip, sep) } else { ip };
|
||||
if !fp.is_empty() {
|
||||
num.push(dec);
|
||||
num.push_str(&fp);
|
||||
}
|
||||
let lits = |ts: &[Tok]| -> String {
|
||||
ts.iter()
|
||||
.filter_map(|t| match t {
|
||||
Tok::Lit(s) => Some(s.clone()),
|
||||
Tok::Percent => Some("%".into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let before = lits(toks.get(..first).unwrap_or(&[]));
|
||||
let after = lits(toks.get(last + 1..).unwrap_or(&[]));
|
||||
let out = format!("{before}{num}{after}");
|
||||
let out = out.trim();
|
||||
if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
|
||||
|
||||
#[test]
|
||||
fn russian_accounting_roubles() {
|
||||
assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽");
|
||||
assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_codes() {
|
||||
assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000");
|
||||
assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%");
|
||||
assert_eq!(format("0.0%", 0.125, None), "12.5%");
|
||||
assert_eq!(format("0%", 0.0, None), "0%");
|
||||
assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5");
|
||||
assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000");
|
||||
assert_eq!(format("0.00", 1.23456, None), "1.23");
|
||||
assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5");
|
||||
assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346");
|
||||
assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500");
|
||||
assert_eq!(format("0;[Red]-0", -3.0, None), "-3");
|
||||
assert_eq!(format("0;0", -3.0, None), "3");
|
||||
assert_eq!(format("0", -3.0, None), "-3");
|
||||
assert_eq!(format("\"x\"0", 5.0, None), "x5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics() {
|
||||
for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] {
|
||||
for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] {
|
||||
let _ = format(code, v, Some("ru"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,16 @@ pub struct Effects {
|
||||
pub bevel: Option<String>,
|
||||
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
|
||||
pub raw3d: Option<String>,
|
||||
/// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`,
|
||||
/// `darken`, `lighten`). Only solid and gradient overlays are kept.
|
||||
pub fill_overlay: Option<FillOverlay>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FillOverlay {
|
||||
pub blend: String,
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
impl Effects {
|
||||
@@ -397,6 +407,7 @@ impl Effects {
|
||||
&& self.soft_edge.is_none()
|
||||
&& self.reflection.is_none()
|
||||
&& self.bevel.is_none()
|
||||
&& self.fill_overlay.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -210,3 +210,23 @@ fn a_layout_colour_map_override_applies_to_its_slides() {
|
||||
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_lists_and_parents_reach_into_groups() {
|
||||
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
|
||||
let group = |id: u32, children: Vec<Shape>| Shape {
|
||||
id: ShapeId(id),
|
||||
kind: ShapeKind::Group { children, child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
|
||||
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
|
||||
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(9)), None);
|
||||
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
|
||||
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ pub enum TextDir {
|
||||
Vertical270,
|
||||
Stacked,
|
||||
EaVertical,
|
||||
/// `mongolianVert`: turned like `vert`, lines running from left to right.
|
||||
MongolianVertical,
|
||||
/// `wordArtVertRtl`: stacked, lines running from right to left.
|
||||
StackedRtl,
|
||||
}
|
||||
|
||||
impl TextDir {
|
||||
@@ -65,14 +69,18 @@ impl TextDir {
|
||||
TextDir::Vertical270 => "vert270",
|
||||
TextDir::Stacked => "wordArtVert",
|
||||
TextDir::EaVertical => "eaVert",
|
||||
TextDir::MongolianVertical => "mongolianVert",
|
||||
TextDir::StackedRtl => "wordArtVertRtl",
|
||||
}
|
||||
}
|
||||
pub fn from_xml(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"horz" => TextDir::Horizontal,
|
||||
"vert" | "mongolianVert" => TextDir::Vertical,
|
||||
"vert" => TextDir::Vertical,
|
||||
"mongolianVert" => TextDir::MongolianVertical,
|
||||
"vert270" => TextDir::Vertical270,
|
||||
"wordArtVert" | "wordArtVertRtl" => TextDir::Stacked,
|
||||
"wordArtVert" => TextDir::Stacked,
|
||||
"wordArtVertRtl" => TextDir::StackedRtl,
|
||||
"eaVert" => TextDir::EaVertical,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and
|
||||
//! double-click see them (observed black-box on PowerPoint for Windows, see
|
||||
//! `docs/ui-parity.md`, Text editing keys).
|
||||
//!
|
||||
//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus
|
||||
//! the spaces that follow it:
|
||||
//!
|
||||
//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `’`) joins a word when a
|
||||
//! letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when
|
||||
//! a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits
|
||||
//! (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens).
|
||||
//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`,
|
||||
//! `?!`, `),`, `。。`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation.
|
||||
//! - **symbol**: emoji and pictographs (a run of them is one token, `👍👍`), `©`, `®`, `™`.
|
||||
//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own.
|
||||
//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own
|
||||
//! token instead of joining it.
|
||||
//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces
|
||||
//! join the token before them; spaces at the start of a paragraph (or after a tab or line break)
|
||||
//! are a token of their own.
|
||||
//!
|
||||
//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is
|
||||
//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands
|
||||
//! inside an emoji sequence or between a letter and its combining marks.
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Class {
|
||||
Space,
|
||||
Tab,
|
||||
Break,
|
||||
Word,
|
||||
Ideo,
|
||||
Punct,
|
||||
Symbol,
|
||||
}
|
||||
|
||||
/// One grapheme cluster: its first character offset and class.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cluster {
|
||||
start: usize,
|
||||
class: Class,
|
||||
}
|
||||
|
||||
/// A space for word movement: absorbed into the token before it.
|
||||
pub fn is_space(c: char) -> bool {
|
||||
matches!(c, ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
|
||||
}
|
||||
|
||||
/// CJK ideographs, kana and Hangul: every one is a word of its own.
|
||||
fn is_ideo(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{1100}'..='\u{11ff}'
|
||||
| '\u{2e80}'..='\u{2fdf}'
|
||||
| '\u{3005}'..='\u{3007}'
|
||||
| '\u{3021}'..='\u{3029}'
|
||||
| '\u{3031}'..='\u{3035}'
|
||||
| '\u{3038}'..='\u{303c}'
|
||||
| '\u{3040}'..='\u{309f}'
|
||||
| '\u{30a0}'..='\u{30ff}'
|
||||
| '\u{3100}'..='\u{318f}'
|
||||
| '\u{31a0}'..='\u{31ff}'
|
||||
| '\u{3400}'..='\u{4dbf}'
|
||||
| '\u{4e00}'..='\u{9fff}'
|
||||
| '\u{a960}'..='\u{a97f}'
|
||||
| '\u{ac00}'..='\u{d7ff}'
|
||||
| '\u{f900}'..='\u{faff}'
|
||||
| '\u{ff66}'..='\u{ffdc}'
|
||||
| '\u{20000}'..='\u{3134f}')
|
||||
}
|
||||
|
||||
/// Emoji, pictographs and the common letter-like symbols.
|
||||
fn is_symbol(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{a9}' | '\u{ae}' | '\u{2122}'
|
||||
| '\u{2190}'..='\u{21ff}'
|
||||
| '\u{2300}'..='\u{23ff}'
|
||||
| '\u{25a0}'..='\u{27bf}'
|
||||
| '\u{2b00}'..='\u{2bff}'
|
||||
| '\u{1f000}'..='\u{1faff}')
|
||||
}
|
||||
|
||||
fn base_class(c: char) -> Class {
|
||||
match c {
|
||||
'\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
|
||||
'\t' => Class::Tab,
|
||||
c if is_space(c) => Class::Space,
|
||||
c if is_ideo(c) => Class::Ideo,
|
||||
c if c.is_alphanumeric() => Class::Word,
|
||||
c if is_symbol(c) => Class::Symbol,
|
||||
// A combining mark with no base (or any other mark) stays with letters.
|
||||
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
|
||||
_ => Class::Punct,
|
||||
}
|
||||
}
|
||||
|
||||
fn clusters(text: &str) -> Vec<Cluster> {
|
||||
let mut out: Vec<Cluster> = Vec::new();
|
||||
let mut firsts: Vec<char> = Vec::new();
|
||||
let mut pos = 0;
|
||||
for g in text.graphemes(true) {
|
||||
let n = g.chars().count();
|
||||
let first = g.chars().next().unwrap_or(' ');
|
||||
// "\r\n" is one grapheme; any other cluster takes the class of its first character.
|
||||
out.push(Cluster { start: pos, class: base_class(first) });
|
||||
firsts.push(first);
|
||||
pos += n;
|
||||
}
|
||||
// Context: apostrophes, hyphens and number separators that belong to a word.
|
||||
let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
|
||||
for (i, cl) in out.iter_mut().enumerate() {
|
||||
let Some(&c) = firsts.get(i) else { continue };
|
||||
let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
|
||||
let next = firsts.get(i + 1).copied();
|
||||
let joins = match c {
|
||||
'\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
|
||||
'-' => prev.is_some_and(char::is_alphanumeric),
|
||||
'.' | ',' | ':' => prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric),
|
||||
_ => false,
|
||||
};
|
||||
if joins && classes.get(i) == Some(&Class::Punct) {
|
||||
cl.class = Class::Word;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does a token start at cluster `cur` (after `prev`)?
|
||||
fn starts_token(prev: Class, cur: Class) -> bool {
|
||||
if cur == Class::Space {
|
||||
// Spaces join the token before them, except after a tab or a line break.
|
||||
return matches!(prev, Class::Tab | Class::Break);
|
||||
}
|
||||
prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
|
||||
}
|
||||
|
||||
/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the
|
||||
/// end of the text. Ctrl+Right goes to the next one.
|
||||
pub fn stops(text: &str) -> Vec<usize> {
|
||||
let cs = clusters(text);
|
||||
let mut out = vec![0];
|
||||
for w in cs.windows(2) {
|
||||
if let [a, b] = w
|
||||
&& starts_token(a.class, b.class)
|
||||
{
|
||||
out.push(b.start);
|
||||
}
|
||||
}
|
||||
let n = text.chars().count();
|
||||
if out.last() != Some(&n) {
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two
|
||||
/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII
|
||||
/// space follows it), and a symbol followed by punctuation (`👍?`) is one token.
|
||||
pub fn back_stops(text: &str) -> Vec<usize> {
|
||||
let cs = clusters(text);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut out = vec![0];
|
||||
for w in cs.windows(2) {
|
||||
if let [a, b] = w {
|
||||
let symbol_punct = matches!((a.class, b.class), (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol));
|
||||
if starts_token(a.class, b.class) && !symbol_punct {
|
||||
out.push(b.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
let n = chars.len();
|
||||
if out.last() != Some(&n) {
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text.
|
||||
pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
|
||||
stops(text).into_iter().find(|s| *s > ch)
|
||||
}
|
||||
|
||||
/// The previous stop before `ch` (Ctrl+Left), or `None` at the start.
|
||||
pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
|
||||
back_stops(text).into_iter().rev().find(|s| *s < ch)
|
||||
}
|
||||
|
||||
/// Is the character at `i` a space (for word purposes)?
|
||||
fn space_at(chars: &[char], i: usize) -> bool {
|
||||
chars.get(i).is_some_and(|c| is_space(*c))
|
||||
}
|
||||
|
||||
/// The end of the token's text (before its trailing spaces) for the token containing `ch`.
|
||||
fn content_end(text: &str, ch: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let next = next_stop(text, ch).unwrap_or(chars.len());
|
||||
let mut e = ch;
|
||||
while e < next && !space_at(&chars, e) {
|
||||
e += 1;
|
||||
}
|
||||
// A token made only of spaces (leading spaces) ends at the next stop.
|
||||
if e == ch && space_at(&chars, ch) { next } else { e }
|
||||
}
|
||||
|
||||
/// The word a double-click at `ch` selects: the token under the caret without its trailing
|
||||
/// spaces; on a space just after a token, that token; in a run of spaces, the spaces.
|
||||
pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let ch = ch.min(n);
|
||||
let on_space = ch >= n || space_at(&chars, ch);
|
||||
let after_token = ch > 0 && !space_at(&chars, ch - 1);
|
||||
let at = if on_space && after_token { ch - 1 } else { ch.min(n - 1) };
|
||||
if space_at(&chars, at) {
|
||||
let mut s = at;
|
||||
while s > 0 && space_at(&chars, s - 1) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = at;
|
||||
while e < n && space_at(&chars, e) {
|
||||
e += 1;
|
||||
}
|
||||
return (s, e);
|
||||
}
|
||||
let s = stops(text).into_iter().rev().find(|s| *s <= at).unwrap_or(0);
|
||||
(s, content_end(text, s).max(at + 1).min(n))
|
||||
}
|
||||
|
||||
/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its
|
||||
/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up
|
||||
/// to the next stop. `None` at the end of the text.
|
||||
pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
if ch >= n {
|
||||
return None;
|
||||
}
|
||||
let st = stops(text);
|
||||
let at_stop = st.contains(&ch);
|
||||
if !at_stop && !space_at(&chars, ch) {
|
||||
return Some(content_end(text, ch));
|
||||
}
|
||||
let prev_is_text = ch > 0 && chars.get(ch - 1).is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
|
||||
if space_at(&chars, ch) && prev_is_text {
|
||||
let mut j = ch;
|
||||
while j < n && space_at(&chars, j) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= n {
|
||||
return Some(n);
|
||||
}
|
||||
return Some(content_end(text, j));
|
||||
}
|
||||
st.into_iter().find(|s| *s > ch)
|
||||
}
|
||||
|
||||
/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` → Ctrl+Delete
|
||||
/// on `cd` gives `ab, ef`).
|
||||
pub fn is_closing_punct(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2026}' | '\u{201d}' | '\u{2019}' | '\u{bb}')
|
||||
}
|
||||
|
||||
/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` →
|
||||
/// Ctrl+Delete on `ef` gives `cd... gh`).
|
||||
pub fn is_sentence_punct(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Stops drawn as `|` in the text, for readable assertions.
|
||||
fn marked(text: &str, st: &[usize]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in text.chars().enumerate() {
|
||||
if st.contains(&i) {
|
||||
out.push('|');
|
||||
}
|
||||
out.push(match c {
|
||||
'\u{b}' => '↵',
|
||||
'\t' => '→',
|
||||
c => c,
|
||||
});
|
||||
}
|
||||
if st.contains(&text.chars().count()) {
|
||||
out.push('|');
|
||||
}
|
||||
out
|
||||
}
|
||||
fn fwd(text: &str) -> String {
|
||||
marked(text, &stops(text))
|
||||
}
|
||||
fn back(text: &str) -> String {
|
||||
marked(text, &back_stops(text))
|
||||
}
|
||||
|
||||
// Every expected string below is a stop trace observed in PowerPoint for Windows.
|
||||
#[test]
|
||||
fn punctuation_and_spaces() {
|
||||
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||
let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
|
||||
assert_eq!(fwd(t), want);
|
||||
assert_eq!(back(t), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphens_apostrophes_numbers() {
|
||||
let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
|
||||
assert_eq!(
|
||||
fwd(t),
|
||||
"|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
|
||||
);
|
||||
let t = "it’s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 —x x— a's 'tis o'clock ‘q’ end";
|
||||
assert_eq!(fwd(t), "|it’s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |—|x |x|— |a's |'tis |o'clock |‘|q|’ |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_emoji_symbols() {
|
||||
let t = "café e\u{301}te 👍🏽 x 👨\u{200d}👩\u{200d}👧 y ½ ®z «q» “w” ¿no? ok—em dash–en x";
|
||||
let want = "|café |e\u{301}te |👍🏽 |x |👨\u{200d}👩\u{200d}👧 |y |½ |®|z |«|q|» |“|w|” |¿|no|? |ok|—|em |dash|–|en |x|";
|
||||
assert_eq!(fwd(t), want);
|
||||
assert_eq!(back(t), want);
|
||||
// A run of emoji is one token; emoji then punctuation is two going right, one going left.
|
||||
assert_eq!(fwd("👍👍 x 👍? x👍y"), "|👍👍 |x |👍|? |x|👍|y|");
|
||||
assert_eq!(back("👍👍 x 👍? x👍y"), "|👍👍 |x |👍? |x|👍|y|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_each_character() {
|
||||
let t = "日本語のテキストです。カタカナ漢字 abc 中文字符,标点。한국어 문장 a。。b";
|
||||
assert_eq!(fwd(t), "|日|本|語|の|テ|キ|ス|ト|で|す|。|カ|タ|カ|ナ|漢|字 |abc |中|文|字|符|,|标|点|。|한|국|어 |문|장 |a|。。|b|");
|
||||
assert_eq!(back(t), fwd(t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_words_in_logical_order() {
|
||||
let t = "Hello مرحبا بالعالم world שלום עולם end";
|
||||
assert_eq!(fwd(t), "|Hello |مرحبا |بالعالم |world |שלום |עולם |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_breaks_and_other_spaces() {
|
||||
assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
|
||||
assert_eq!(fwd("Five\u{b}six seven\tTab x"), "|Five|↵|six |seven|→|Tab |x|");
|
||||
assert_eq!(fwd("a\t b x \u{b} y q\u{b}\u{b}z"), "|a|→| |b |x |↵| |y |q|↵|↵|z|");
|
||||
let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
|
||||
assert_eq!(fwd(t), "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||
assert_eq!(back(t), "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_and_prev() {
|
||||
let t = " lead x mid ";
|
||||
assert_eq!(next_stop(t, 1), Some(2));
|
||||
assert_eq!(next_stop(t, 9), Some(11));
|
||||
assert_eq!(next_stop(t, 16), None);
|
||||
assert_eq!(prev_stop(t, 1), Some(0));
|
||||
assert_eq!(prev_stop(t, 9), Some(7));
|
||||
assert_eq!(prev_stop(t, 15), Some(11));
|
||||
assert_eq!(prev_stop(t, 0), None);
|
||||
assert_eq!(next_stop("", 0), None);
|
||||
assert_eq!(prev_stop("", 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_word() {
|
||||
let t = "Hello world, foo bar";
|
||||
assert_eq!(word_at(t, 7), (6, 11));
|
||||
assert_eq!(word_at(t, 15), (14, 17));
|
||||
assert_eq!(word_at(t, 11), (11, 12));
|
||||
assert_eq!(word_at(t, 12), (11, 12));
|
||||
assert_eq!(word_at(t, 13), (12, 14));
|
||||
assert_eq!(word_at(t, 21), (18, 21));
|
||||
assert_eq!(word_at("日本語のテキスト abc", 5), (5, 6));
|
||||
assert_eq!(word_at("", 3), (0, 0));
|
||||
assert_eq!(word_at("x👨\u{200d}👩\u{200d}👧y", 2), (1, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_extent() {
|
||||
let t = "ab cd...ef gh";
|
||||
let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
|
||||
// Before smart spacing: PowerPoint's results after it are tested in `edit`.
|
||||
assert_eq!(
|
||||
ends,
|
||||
vec![
|
||||
Some(5),
|
||||
Some(2),
|
||||
Some(7),
|
||||
Some(5),
|
||||
Some(5),
|
||||
Some(7),
|
||||
Some(7),
|
||||
Some(10),
|
||||
Some(10),
|
||||
Some(10),
|
||||
Some(13),
|
||||
Some(12),
|
||||
Some(15),
|
||||
Some(15),
|
||||
Some(15),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
|
||||
assert_eq!(delete_forward_end("ab ", 2), Some(4));
|
||||
}
|
||||
}
|
||||
+63
-24
@@ -17,7 +17,12 @@ pub fn transition_from_xml(e: &El) -> Option<(String, String)> {
|
||||
Some((k, if has_opts { o } else { String::new() }))
|
||||
}
|
||||
|
||||
/// Preset transitions (`p15:prstTrans`) whose "Left" option PowerPoint writes with `invX="1"`
|
||||
/// (the others write it for "Right"), as observed on PowerPoint's own files.
|
||||
const INVX_LEFT: &[&str] = &["wind", "airplane", "origami"];
|
||||
|
||||
fn transition_from_xml_raw(e: &El) -> Option<(String, String)> {
|
||||
// Defaults are what PowerPoint leaves out: e.g. it writes Push Left as `<p:push/>`.
|
||||
let dir = |d: &str| e.attr("dir").unwrap_or(d).to_string();
|
||||
let k = |s: &str| s.to_string();
|
||||
Some(match e.local() {
|
||||
@@ -25,16 +30,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> {
|
||||
"cut" => (k("cut"), if e.bool("thruBlk") == Some(true) { k("throughBlack") } else { String::new() }),
|
||||
"push" => (k("push"), dir("l")),
|
||||
"wipe" => (k("wipe"), dir("l")),
|
||||
// Diagonal wipes.
|
||||
"strips" => (k("wipe"), dir("lu")),
|
||||
"split" => {
|
||||
let o = e.attr("orient").unwrap_or("horz");
|
||||
let d = e.attr("dir").unwrap_or("out");
|
||||
(k("split"), format!("{o}{}", if d == "in" { "In" } else { "Out" }))
|
||||
}
|
||||
"randomBar" => (k("randomBar"), dir("vert")),
|
||||
"randomBar" => (k("randomBar"), dir("horz")),
|
||||
"circle" => (k("shape"), k("circle")),
|
||||
"diamond" => (k("shape"), k("diamond")),
|
||||
"plus" => (k("shape"), k("plus")),
|
||||
"zoom" => (k("zoom"), dir("in")),
|
||||
// Shape In / Out (the slide zooming in a box).
|
||||
"zoom" => (k("shape"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }),
|
||||
// The Zoom transition, and its Zoom and Rotate option.
|
||||
"warp" => (k("zoom"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }),
|
||||
"newsflash" => (k("zoom"), k("rotate")),
|
||||
"pull" => (k("uncover"), dir("l")),
|
||||
"cover" => (k("cover"), dir("l")),
|
||||
"checker" => (k("checker"), dir("horz")),
|
||||
@@ -47,14 +58,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> {
|
||||
"switch" => (k("switch"), dir("r")),
|
||||
"flip" => (k("flip"), dir("r")),
|
||||
"ripple" => (k("ripple"), dir("center")),
|
||||
"doors" => (k("doors"), dir("vert")),
|
||||
"window" => (k("window"), dir("vert")),
|
||||
"doors" => (k("doors"), dir("horz")),
|
||||
"window" => (k("window"), dir("horz")),
|
||||
"ferris" => (k("ferris"), dir("l")),
|
||||
"gallery" => (k("gallery"), dir("l")),
|
||||
"conveyor" => (k("conveyor"), dir("l")),
|
||||
"pan" => (k("pan"), dir("d")),
|
||||
"glitter" => (k("glitter"), dir("l")),
|
||||
"shred" => (k("shred"), if e.attr("pattern") == Some("crush") { k("particles") } else { k("strips") }),
|
||||
"pan" => (k("pan"), dir("l")),
|
||||
"glitter" => {
|
||||
let d = dir("l");
|
||||
if e.attr("pattern") == Some("hexagon") { (k("glitter"), format!("hex{}", d.to_ascii_uppercase())) } else { (k("glitter"), d) }
|
||||
}
|
||||
"shred" => {
|
||||
// `crush` is what DeckCraft wrote before it knew PowerPoint's `rectangle`.
|
||||
let particles = matches!(e.attr("pattern"), Some("rectangle" | "crush"));
|
||||
let out = e.attr("dir") == Some("out");
|
||||
(k("shred"), format!("{}{}", if particles { "particles" } else { "strips" }, if out { "Out" } else { "In" }))
|
||||
}
|
||||
"reveal" => {
|
||||
let black = e.bool("thruBlk") == Some(true);
|
||||
let right = e.attr("dir") == Some("r");
|
||||
@@ -78,11 +97,16 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> {
|
||||
}
|
||||
"prstTrans" => {
|
||||
let prst = e.attr("prst").unwrap_or("fallOver");
|
||||
let inv = e.bool("invX") == Some(true);
|
||||
if prst == "pageCurlSingle" {
|
||||
return Some((k("pageCurlDouble"), if inv { k("singleR") } else { k("singleL") }));
|
||||
}
|
||||
let known = deckcraft_model::anim::TRANSITIONS.iter().any(|t| t.0 == prst);
|
||||
if !known {
|
||||
return None;
|
||||
}
|
||||
(k(prst), if e.bool("invX") == Some(true) { k("r") } else { k("l") })
|
||||
let left = if INVX_LEFT.contains(&prst) { inv } else { !inv };
|
||||
(k(prst), if left { k("l") } else { k("r") })
|
||||
}
|
||||
"morph" => (
|
||||
k("morph"),
|
||||
@@ -119,7 +143,10 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static
|
||||
"fade" => w.empty("p:fade", A::new().t("thruBlk", option == "throughBlack")),
|
||||
"cut" => w.empty("p:cut", A::new().t("thruBlk", option == "throughBlack")),
|
||||
"push" => w.empty("p:push", A::new().a("dir", lrud("u"))),
|
||||
"wipe" => w.empty("p:wipe", A::new().a("dir", lrud("r"))),
|
||||
"wipe" => match option {
|
||||
"lu" | "ru" | "ld" | "rd" => w.empty("p:strips", A::new().a("dir", option)),
|
||||
_ => w.empty("p:wipe", A::new().a("dir", lrud("r"))),
|
||||
},
|
||||
"split" => {
|
||||
let (o, d) = match option {
|
||||
"vertIn" => ("vert", "in"),
|
||||
@@ -136,7 +163,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static
|
||||
"in" | "out" => w.empty("p:zoom", A::new().a("dir", option)),
|
||||
_ => w.empty0("p:circle"),
|
||||
},
|
||||
"zoom" => w.empty("p:zoom", A::new().a("dir", if option == "out" { "out" } else { "in" })),
|
||||
"zoom" => match option {
|
||||
"rotate" => w.empty0("p:newsflash"),
|
||||
_ => {
|
||||
w.empty("p14:warp", A::new().a("dir", if option == "out" { "out" } else { "in" }));
|
||||
return Some("p14");
|
||||
}
|
||||
},
|
||||
"uncover" => w.empty("p:pull", A::new().a("dir", eight("l"))),
|
||||
"cover" => w.empty("p:cover", A::new().a("dir", eight("l"))),
|
||||
"checker" => w.empty("p:checker", A::new().a("dir", hv("horz"))),
|
||||
@@ -160,9 +193,15 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static
|
||||
w.empty0("p14:flash");
|
||||
return Some("p14");
|
||||
}
|
||||
"vortex" | "glitter" => {
|
||||
let name = if kind == "vortex" { "p14:vortex" } else { "p14:glitter" };
|
||||
w.empty(name, A::new().a("dir", lrud("l")));
|
||||
"vortex" => {
|
||||
w.empty("p14:vortex", A::new().a("dir", lrud("l")));
|
||||
return Some("p14");
|
||||
}
|
||||
"glitter" => {
|
||||
let hex = option.starts_with("hex");
|
||||
let d = option.trim_start_matches("hex").to_ascii_lowercase();
|
||||
let d = if matches!(d.as_str(), "l" | "r" | "u" | "d") { d } else { "l".to_string() };
|
||||
w.empty("p14:glitter", A::new().a("dir", d).o("pattern", hex.then_some("hexagon")));
|
||||
return Some("p14");
|
||||
}
|
||||
"switch" | "flip" | "gallery" | "conveyor" | "ferris" => {
|
||||
@@ -194,7 +233,11 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static
|
||||
return Some("p14");
|
||||
}
|
||||
"shred" => {
|
||||
w.empty("p14:shred", A::new().a("pattern", if option == "particles" { "crush" } else { "strip" }));
|
||||
let particles = option.starts_with("particles");
|
||||
w.empty(
|
||||
"p14:shred",
|
||||
A::new().a("pattern", if particles { "rectangle" } else { "strip" }).a("dir", if option.ends_with("Out") { "out" } else { "in" }),
|
||||
);
|
||||
return Some("p14");
|
||||
}
|
||||
"reveal" => {
|
||||
@@ -215,8 +258,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static
|
||||
);
|
||||
return Some("p14");
|
||||
}
|
||||
"pageCurlDouble" if option.starts_with("single") => {
|
||||
w.empty("p15:prstTrans", A::new().a("prst", "pageCurlSingle").t("invX", option == "singleR"));
|
||||
return Some("p15");
|
||||
}
|
||||
"fallOver" | "drape" | "curtains" | "wind" | "prestige" | "fracture" | "crush" | "peelOff" | "pageCurlDouble" | "airplane" | "origami" => {
|
||||
w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", option == "r"));
|
||||
let inv = if INVX_LEFT.contains(&kind) { option == "l" } else { option == "r" };
|
||||
w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", inv));
|
||||
return Some("p15");
|
||||
}
|
||||
"morph" => {
|
||||
@@ -342,16 +390,7 @@ mod tests {
|
||||
assert!(!ns.is_empty());
|
||||
let d = parse(w.s.as_bytes()).unwrap();
|
||||
let (k, opt) = transition_from_xml(&d.root).unwrap_or_else(|| panic!("no read for {kind}"));
|
||||
// `shape` in/out are written as zoom; everything else must come back.
|
||||
if *kind == "shape" && (o == "in" || o == "out") {
|
||||
assert_eq!(k, "zoom");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(k, *kind, "kind for {kind}/{o}");
|
||||
// PresentationML wipes have no diagonal directions.
|
||||
if *kind == "wipe" && o.len() == 2 {
|
||||
continue;
|
||||
}
|
||||
if !o.is_empty() {
|
||||
assert_eq!(opt, o, "option for {kind}");
|
||||
}
|
||||
|
||||
@@ -97,10 +97,30 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.title = None;
|
||||
}
|
||||
c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string());
|
||||
c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false);
|
||||
c.chart_fill = simple_fill(root.child("spPr"));
|
||||
c.plot_fill = simple_fill(pa.child("spPr"));
|
||||
|
||||
let mut kinds: Vec<ChartType> = vec![];
|
||||
// The value axis the first plot group uses is the primary one; a group on another value axis
|
||||
// (a combo chart's line on the right) is on the secondary axis.
|
||||
let val_ids: Vec<(&str, bool)> =
|
||||
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
||||
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
||||
let group_val_axis = |g: &El| {
|
||||
let ids: Vec<(&str, bool)> =
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
||||
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
||||
};
|
||||
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
||||
// sits on the right or top, or crosses the category axis at its maximum.
|
||||
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
||||
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
||||
let primary_val: Option<String> = used
|
||||
.iter()
|
||||
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
||||
.or(used.first())
|
||||
.cloned();
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
@@ -166,6 +186,8 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
let val_axis = group_val_axis(g);
|
||||
let secondary = val_axis.is_some() && val_axis != primary_val;
|
||||
for s in g.children_named("ser").take(1000) {
|
||||
let mut ser = Series {
|
||||
name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(),
|
||||
@@ -174,11 +196,22 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false),
|
||||
marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from),
|
||||
kind: Some(kind),
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
ser.data_labels = Some(d);
|
||||
}
|
||||
// Data labels' number format: the series' labels', the group's, else the source data's.
|
||||
let label_fmt = |d: Option<&El>| {
|
||||
d.and_then(|d| d.child("numFmt"))
|
||||
.filter(|n| n.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|n| n.attr("formatCode"))
|
||||
.map(str::to_string)
|
||||
};
|
||||
let source =
|
||||
s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text());
|
||||
ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty());
|
||||
for dp in s.children_named("dPt") {
|
||||
if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) {
|
||||
ser.point_fills.push((i as usize, f));
|
||||
@@ -207,11 +240,29 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
if c.kind != ChartType::Combo {
|
||||
for s in &mut c.series {
|
||||
s.kind = None;
|
||||
s.secondary_axis = false;
|
||||
}
|
||||
}
|
||||
// Axes.
|
||||
c.lang = root.child_val("lang").map(str::to_string);
|
||||
let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r"));
|
||||
let val_ax = pa.children_named("valAx").find(vertical).or_else(|| pa.children_named("valAx").last());
|
||||
let by_id = |id: &Option<String>| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref());
|
||||
let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last());
|
||||
let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string);
|
||||
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true);
|
||||
if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) {
|
||||
let primary_id = val_ax.and_then(|a| a.child_val("axId"));
|
||||
// The other vertical value axis is the secondary one.
|
||||
if let Some(a) = pa.children_named("valAx").find(|a| a.child_val("axId") != primary_id && vertical(a)) {
|
||||
let sc = a.child("scaling");
|
||||
c.y2_min = sc.and_then(|s| num(s, "min"));
|
||||
c.y2_max = sc.and_then(|s| num(s, "max"));
|
||||
c.y2_major = num(a, "majorUnit");
|
||||
c.y2_format = format_code(a);
|
||||
c.y2_hidden = deleted(a);
|
||||
}
|
||||
}
|
||||
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
|
||||
if let Some(v) = val_ax {
|
||||
c.gridlines = v.child("majorGridlines").is_some();
|
||||
@@ -219,9 +270,122 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
c.y_major = num(v, "majorUnit");
|
||||
c.y_format = format_code(v);
|
||||
c.y_hidden = deleted(v);
|
||||
}
|
||||
if let Some(a) = cat_ax {
|
||||
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
}
|
||||
// Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to
|
||||
// the chart): the axes' and legend's, else the whole chart's; the title's own.
|
||||
let chart_size = text_size(root.child("txPr"));
|
||||
c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size);
|
||||
c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths).
|
||||
fn text_size(e: Option<&El>) -> Option<f64> {
|
||||
fn rec(e: &El, depth: usize) -> Option<f64> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
for c in e.elements() {
|
||||
if (c.is("defRPr") || c.is("rPr"))
|
||||
&& let Some(v) = c.attr("sz").and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0)
|
||||
{
|
||||
return Some((v / 100.0).clamp(1.0, 400.0));
|
||||
}
|
||||
if let Some(v) = rec(c, depth + 1) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(e?, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legend_position_and_overlay_are_read() {
|
||||
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(c.legend_overlay);
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(!c.legend_overlay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_with_a_secondary_axis() {
|
||||
// Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right.
|
||||
let xml = br##"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:lang val="ru-RU"/><c:chart><c:plotArea>
|
||||
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
||||
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
|
||||
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
|
||||
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
|
||||
</c:plotArea></c:chart></c:chartSpace>"##;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
assert_eq!(c.kind, ChartType::Combo);
|
||||
assert_eq!(c.series.len(), 2);
|
||||
assert!(!c.series[0].secondary_axis);
|
||||
assert!(c.series[1].secondary_axis);
|
||||
assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers));
|
||||
assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01)));
|
||||
// Each value axis' number format, bounds and unit; the chart's language.
|
||||
assert_eq!(c.y_format.as_deref(), Some("#,##0"));
|
||||
assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0")));
|
||||
assert_eq!(c.lang.as_deref(), Some("ru-RU"));
|
||||
// Group order doesn't decide: columns on the right-hand axis are the secondary series even
|
||||
// when their group comes first, and the line on the left is primary.
|
||||
let swapped = String::from_utf8_lossy(xml)
|
||||
.replace(
|
||||
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
)
|
||||
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
|
||||
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
|
||||
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
|
||||
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
|
||||
// A chart of one type on one axis has no secondary series.
|
||||
let single = String::from_utf8_lossy(xml)
|
||||
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
|
||||
let c = read_chart_xml(single.as_bytes()).unwrap();
|
||||
assert!(c.series.iter().all(|s| !s.secondary_axis));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_sizes_are_read_from_the_file() {
|
||||
// PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to
|
||||
// 18.62 pt; a chart that only sets its overall text size uses it everywhere.
|
||||
let ns =
|
||||
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
let tx = |sz: u32| format!(r#"<c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="{sz}"/></a:pPr></a:p></c:txPr>"#);
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1862"/></a:pPr><a:r><a:t>T</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/>{}</c:catAx><c:valAx><c:axId val="2"/>{}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#,
|
||||
tx(1197),
|
||||
tx(1197)
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!((c.text_size, c.title_size), (Some(11.97), Some(18.62)));
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart>{}</c:chartSpace>"#,
|
||||
tx(1400)
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!((c.text_size, c.title_size), (Some(14.0), None));
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
);
|
||||
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,14 @@ pub fn effects(el: &El) -> Effects {
|
||||
"innerShdw" => fx.inner_shadow = Some(shadow(e, true)),
|
||||
"glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }),
|
||||
"softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)),
|
||||
"fillOverlay" => {
|
||||
let fill = e.elements().find_map(|f| match f.local() {
|
||||
"solidFill" => color(f).map(|c| Fill::Solid { color: c }),
|
||||
"gradFill" => Some(Fill::Gradient(gradient(f))),
|
||||
_ => None,
|
||||
});
|
||||
fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill });
|
||||
}
|
||||
"reflection" => {
|
||||
fx.reflection = Some(Reflection {
|
||||
blur: pt(e, "blurRad").unwrap_or(0.0),
|
||||
@@ -608,5 +616,13 @@ mod tests {
|
||||
assert_eq!(b.vert, Some(TextDir::Vertical270));
|
||||
assert_eq!(b.columns, Some(2));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
|
||||
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
|
||||
// written back as it was read.
|
||||
let d = parse(br#"<a:bodyPr vert="mongolianVert"/>"#).unwrap();
|
||||
let b = body_pr(&d.root);
|
||||
assert_eq!(b.vert, Some(TextDir::MongolianVertical));
|
||||
assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert"));
|
||||
let d = parse(br#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
if let Some(tree) = csld.child("spTree") {
|
||||
s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
if let Some(c) = csld.child("controls") {
|
||||
let pics = shapes::controls(imp, part, &mut ctx, c);
|
||||
s.shapes.extend(pics);
|
||||
}
|
||||
}
|
||||
if let Some(t) = root.child("transition") {
|
||||
s.transition = timing::transition(imp, part, t);
|
||||
|
||||
@@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us
|
||||
out
|
||||
}
|
||||
|
||||
/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows
|
||||
/// for it, as a picture above the slide's shapes. Controls without one are left out.
|
||||
pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec<Shape> {
|
||||
let mut out = vec![];
|
||||
for c in controls.children_named("control") {
|
||||
if ctx.count >= MAX_SHAPES {
|
||||
break;
|
||||
}
|
||||
if let Some(p) = c.child("pic") {
|
||||
ctx.count += 1;
|
||||
out.push(pic(imp, part, ctx, p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
@@ -104,6 +120,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking
|
||||
/// attributes; it reads an object as locked only when the whole set for its kind is there, so a
|
||||
/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock.
|
||||
fn is_locked(l: &El) -> bool {
|
||||
let set: &[&str] = match l.local() {
|
||||
"grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"],
|
||||
"graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"],
|
||||
_ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"],
|
||||
};
|
||||
set.iter().all(|a| l.bool(a) == Some(true))
|
||||
}
|
||||
|
||||
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
|
||||
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
let c = nv.and_then(|n| n.child("cNvPr"));
|
||||
@@ -122,10 +150,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
match e.local() {
|
||||
"cNvSpPr" => {
|
||||
s.text_box = e.bool("txBox").unwrap_or(false);
|
||||
s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.child("spLocks").is_some_and(is_locked);
|
||||
}
|
||||
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
|
||||
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
|
||||
}
|
||||
"nvPr" => {
|
||||
if let Some(ph) = e.child("ph") {
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
||||
};
|
||||
w.open0("c:legend");
|
||||
w.val("c:legendPos", p);
|
||||
w.val("c:overlay", 0);
|
||||
w.val("c:overlay", u8::from(c.legend_overlay));
|
||||
w.close("c:legend");
|
||||
}
|
||||
w.val("c:plotVisOnly", 1);
|
||||
|
||||
@@ -76,40 +76,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
match f {
|
||||
Fill::None => w.empty0("a:noFill"),
|
||||
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.
|
||||
let mut stops = g.stops.clone();
|
||||
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");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
}
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
||||
Fill::Pattern(p) => {
|
||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
||||
@@ -275,8 +242,55 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||
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.
|
||||
let mut stops = g.stops.clone();
|
||||
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");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })),
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
|
||||
pub fn effects(w: &mut W, fx: &Effects) {
|
||||
w.open0("a:effectLst");
|
||||
// Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge.
|
||||
if let Some(ov) = &fx.fill_overlay {
|
||||
let blend = match ov.blend.as_str() {
|
||||
b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b,
|
||||
_ => "over",
|
||||
};
|
||||
w.open("a:fillOverlay", A::new().a("blend", blend));
|
||||
match &ov.fill {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
_ => w.empty0("a:noFill"),
|
||||
}
|
||||
w.close("a:fillOverlay");
|
||||
}
|
||||
if let Some(g) = &fx.glow {
|
||||
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
||||
color(w, &g.color);
|
||||
|
||||
@@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and
|
||||
/// reads back as locked): see `read::shapes::is_locked`.
|
||||
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
||||
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
|
||||
let a = extra.t("noGrp", s.ph.is_some() || s.locked);
|
||||
let a = match tag {
|
||||
"a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked),
|
||||
_ => {
|
||||
let a = a
|
||||
.t("noRot", s.locked)
|
||||
.t("noMove", s.locked)
|
||||
.t("noResize", s.locked)
|
||||
.t("noEditPoints", s.locked)
|
||||
.t("noAdjustHandles", s.locked)
|
||||
.t("noChangeArrowheads", s.locked)
|
||||
.t("noChangeShapeType", s.locked);
|
||||
if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a }
|
||||
}
|
||||
};
|
||||
if a.v.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
||||
w.open0("p:nvGraphicFramePr");
|
||||
cnvpr(w, x, o, id, s);
|
||||
w.open0("p:cNvGraphicFramePr");
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.close("p:cNvGraphicFramePr");
|
||||
nv_pr(w, s);
|
||||
w.close("p:nvGraphicFramePr");
|
||||
|
||||
@@ -102,3 +102,34 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_round_trips() {
|
||||
use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let overlay = FillOverlay {
|
||||
blend: "mult".into(),
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![
|
||||
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
|
||||
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 4.5, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
};
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Overlaid".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))),
|
||||
effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }),
|
||||
..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 == "Overlaid").expect("shape");
|
||||
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
|
||||
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
|
||||
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
|
||||
fn deck() -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png());
|
||||
let mut v = vec![];
|
||||
let mut a = shape(&mut p, "Locked", 0.0);
|
||||
a.locked = true;
|
||||
v.push(a);
|
||||
v.push(shape(&mut p, "Free", 100.0));
|
||||
let mut t = shape(&mut p, "Hidden text", 200.0);
|
||||
t.text_box = true;
|
||||
t.hidden = true;
|
||||
t.locked = true;
|
||||
v.push(t);
|
||||
let mut c1 = shape(&mut p, "Child locked", 300.0);
|
||||
c1.locked = true;
|
||||
let c2 = shape(&mut p, "Child free", 400.0);
|
||||
let mut c3 = shape(&mut p, "Child hidden", 500.0);
|
||||
c3.hidden = true;
|
||||
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
|
||||
let mut g = Shape {
|
||||
id: ShapeId(p.alloc_id()),
|
||||
name: "Group".into(),
|
||||
xfrm: Some(gx),
|
||||
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
|
||||
locked: true,
|
||||
..Default::default()
|
||||
};
|
||||
g.hidden = false;
|
||||
v.push(g);
|
||||
let mut pic = shape(&mut p, "Pic", 0.0);
|
||||
pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } };
|
||||
pic.locked = true;
|
||||
v.push(pic);
|
||||
let mut cx = shape(&mut p, "Conn", 100.0);
|
||||
cx.kind = ShapeKind::Connector { start: None, end: None };
|
||||
cx.geom = deckcraft_model::Geom::preset("straightConnector1");
|
||||
cx.locked = true;
|
||||
v.push(cx);
|
||||
let mut tb = shape(&mut p, "Tbl", 200.0);
|
||||
tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0));
|
||||
tb.locked = true;
|
||||
v.push(tb);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = v;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape {
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(&s.shapes, &mut |x, _| {
|
||||
if found.is_none() && x.name == name {
|
||||
found = Some(x);
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no shape {name}"))
|
||||
}
|
||||
|
||||
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
(0..z.len())
|
||||
.map(|i| {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
(f.name().to_string(), d)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rezip(es: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, d) in es {
|
||||
z.start_file(n.as_str(), o).expect("start");
|
||||
z.write_all(d).expect("write");
|
||||
}
|
||||
z.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn slide1_xml(bytes: &[u8]) -> String {
|
||||
let es = entries(bytes);
|
||||
let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
String::from_utf8(d.clone()).expect("utf8")
|
||||
}
|
||||
|
||||
/// The element following `name="…"` up to the end of the object's non-visual properties.
|
||||
fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}"));
|
||||
let rest = &xml[i..];
|
||||
let j = rest.find("<p:nvPr").unwrap_or(rest.len());
|
||||
&rest[..j]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hidden_round_trip() {
|
||||
let p = deck();
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let s = &q.slides[0];
|
||||
for (name, locked, hidden) in [
|
||||
("Locked", true, false),
|
||||
("Free", false, false),
|
||||
("Hidden text", true, true),
|
||||
("Group", true, false),
|
||||
("Child locked", true, false),
|
||||
("Child free", false, false),
|
||||
("Child hidden", false, true),
|
||||
("Pic", true, false),
|
||||
("Conn", true, false),
|
||||
("Tbl", true, false),
|
||||
] {
|
||||
let sh = find(s, name);
|
||||
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
|
||||
}
|
||||
// The stacking order of the slide and of the group survives too.
|
||||
let names: Vec<&str> = s.shapes.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]);
|
||||
let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_writes_powerpoints_full_attribute_set() {
|
||||
let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export"));
|
||||
let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#;
|
||||
assert!(nv_of(&xml, "Locked").contains(&format!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#));
|
||||
assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free"));
|
||||
assert!(nv_of(&xml, "Group").contains(r#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
|
||||
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
|
||||
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
|
||||
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
|
||||
}
|
||||
|
||||
/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's
|
||||
/// partial lock), or the set without `noGrp`, isn't locked.
|
||||
#[test]
|
||||
fn partial_locks_are_not_lock() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
let b = shape(&mut p, "B", 100.0);
|
||||
let c = shape(&mut p, "C", 200.0);
|
||||
let mut d = shape(&mut p, "D", 300.0);
|
||||
d.locked = true;
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8");
|
||||
let swap = |xml: String, name: &str, locks: &str| {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).expect("name");
|
||||
let j = i + xml[i..].find("<p:cNvSpPr").expect("cNvSpPr");
|
||||
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
|
||||
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
|
||||
};
|
||||
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
|
||||
let xml = swap(
|
||||
xml,
|
||||
"B",
|
||||
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
|
||||
);
|
||||
let xml =
|
||||
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
let s = &q.slides[0];
|
||||
assert!(!find(s, "A").locked);
|
||||
assert!(!find(s, "B").locked);
|
||||
assert!(!find(s, "C").locked);
|
||||
assert!(find(s, "D").locked);
|
||||
}
|
||||
|
||||
/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked.
|
||||
#[test]
|
||||
fn empty_locks_element_is_unlocked() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
assert!(!find(&q.slides[0], "A").locked);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls
|
||||
//! shown by their pictures.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::defaults;
|
||||
|
||||
const FRAME: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s2050" name="Slide" r:id="rIdOle" progId="Thing.1"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
|
||||
const VML: &str = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><v:shape id="_x0000_s2049"><v:imagedata o:relid="rIdA"/></v:shape><v:shape id="_x0000_s2050" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rIdB" o:title=""/></v:shape></xml>"#;
|
||||
|
||||
const VML_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/></Relationships>"#;
|
||||
|
||||
const SLIDE_RELS: &str = r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/>"#;
|
||||
|
||||
/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture.
|
||||
const CONTROLS: &str = r#"<p:controls><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:control spid="1137" name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"/></mc:Choice><mc:Fallback><p:control name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"><p:pic><p:nvPicPr><p:cNvPr id="4" name="CheckBox1"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="12700" y="25400"/><a:ext cx="127000" cy="63500"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:control></mc:Fallback></mc:AlternateContent><p:control name="NoPicture" r:id="rIdY"/></p:controls>"#;
|
||||
|
||||
fn png(v: u8) -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures.
|
||||
fn deck() -> Vec<u8> {
|
||||
let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).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 mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let at = s.rfind("</p:spTree>").expect("spTree");
|
||||
let s = format!("{}{FRAME}{}", &s[..at], &s[at..]);
|
||||
let at = s.rfind("</p:cSld>").expect("cSld");
|
||||
d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes();
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
d = s.replace("</Relationships>", &format!("{SLIDE_RELS}</Relationships>")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in [
|
||||
("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()),
|
||||
("ppt/media/other.png", png(10)),
|
||||
("ppt/media/preview.png", png(200)),
|
||||
] {
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_picture_shows_its_vml_preview() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame");
|
||||
let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(label, "Thing.1");
|
||||
let media = p.media.iter().find(|x| x.id == *m).expect("media");
|
||||
// The shape whose id is the object's spid, not the first one in the drawing.
|
||||
assert_eq!(media.name, "preview.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activex_controls_show_their_pictures() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture");
|
||||
let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png"));
|
||||
let x = s.xfrm.expect("xfrm");
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0));
|
||||
assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing");
|
||||
}
|
||||
@@ -857,3 +857,135 @@ fn wordart_warps_and_their_adjusts_round_trip() {
|
||||
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
|
||||
assert!(inflate.warp_adj.is_empty());
|
||||
}
|
||||
|
||||
/// Slide XML of a deck exported with one slide per transition (kind, option, ms, advance after).
|
||||
fn transition_deck(list: &[(&str, &str, u32, Option<u32>)]) -> (Presentation, Vec<String>) {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let base = p.slides[0].as_ref().clone();
|
||||
p.slides.clear();
|
||||
for (kind, option, ms, adv) in list {
|
||||
let mut s = base.clone();
|
||||
s.id = SlideId(p.alloc_id());
|
||||
s.transition = Some(Transition {
|
||||
kind: kind.to_string(),
|
||||
option: option.to_string(),
|
||||
duration_ms: *ms,
|
||||
advance_on_click: adv.is_none(),
|
||||
advance_after_ms: *adv,
|
||||
..Default::default()
|
||||
});
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let xml = (1..=list.len())
|
||||
.map(|i| {
|
||||
let mut x = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name(&format!("ppt/slides/slide{i}.xml")).expect("slide"), &mut x).expect("read");
|
||||
x
|
||||
})
|
||||
.collect();
|
||||
(p, xml)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_xml_matches_powerpoints_elements() {
|
||||
// The elements, attributes and namespaces PowerPoint itself writes for these effect options
|
||||
// (observed on files it saved), inside `mc:AlternateContent` with a `p:fade` fallback for
|
||||
// the 2010+ effects.
|
||||
let cases: &[(&str, &str, &[&str], &str)] = &[
|
||||
("wipe", "ru", &[r#"<p:strips dir="ru"/>"#], "p14"),
|
||||
("wipe", "u", &[r#"<p:wipe dir="u"/>"#], "p14"),
|
||||
("zoom", "in", &[r#"<p14:warp dir="in"/>"#], "p14"),
|
||||
("zoom", "rotate", &["<p:newsflash/>"], "p14"),
|
||||
("shape", "out", &[r#"<p:zoom dir="out"/>"#], "p14"),
|
||||
("glitter", "hexD", &[r#"<p14:glitter dir="d" pattern="hexagon"/>"#], "p14"),
|
||||
("shred", "particlesOut", &[r#"<p14:shred pattern="rectangle" dir="out"/>"#], "p14"),
|
||||
("wind", "l", &[r#"<p15:prstTrans prst="wind" invX="1"/>"#], "p15"),
|
||||
("wind", "r", &[r#"<p15:prstTrans prst="wind"/>"#], "p15"),
|
||||
("fallOver", "r", &[r#"<p15:prstTrans prst="fallOver" invX="1"/>"#], "p15"),
|
||||
("pageCurlDouble", "singleR", &[r#"<p15:prstTrans prst="pageCurlSingle" invX="1"/>"#], "p15"),
|
||||
("orbit", "u", &[r#"<p14:prism dir="u" isContent="1" isInverted="1"/>"#], "p14"),
|
||||
("vortex", "r", &[r#"<p14:vortex dir="r"/>"#], "p14"),
|
||||
("morph", "words", &[r#"<p159:morph option="byWord"/>"#], "p159"),
|
||||
];
|
||||
let list: Vec<(&str, &str, u32, Option<u32>)> = cases.iter().map(|c| (c.0, c.1, 2000, Some(1000))).collect();
|
||||
let (p, xml) = transition_deck(&list);
|
||||
for ((kind, option, wants, req), x) in cases.iter().zip(&xml) {
|
||||
for w in *wants {
|
||||
assert!(x.contains(w), "{kind}/{option}: {w} in {x}");
|
||||
}
|
||||
assert!(x.contains(&format!(r#"Requires="{req}""#)), "{kind}/{option}");
|
||||
assert!(x.contains(r#"p14:dur="2000""#) && x.contains(r#"advTm="1000""#) && x.contains(r#"advClick="0""#), "{kind}/{option}");
|
||||
assert!(x.contains("<mc:Fallback>"), "{kind}/{option}");
|
||||
}
|
||||
// Back in, everything is as it was.
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
for ((kind, option, ..), s) in cases.iter().zip(&q.slides) {
|
||||
let t = s.transition.as_ref().expect("transition");
|
||||
assert_eq!(
|
||||
(t.kind.as_str(), t.option.as_str(), t.duration_ms, t.advance_after_ms, t.advance_on_click),
|
||||
(*kind, *option, 2000, Some(1000), false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_defaults_read_like_powerpoint() {
|
||||
// PowerPoint leaves out an option when it is the element's default; these are its defaults.
|
||||
let cases: &[(&str, &str, &str)] = &[
|
||||
("<p:push/>", "push", "l"),
|
||||
("<p:wipe/>", "wipe", "l"),
|
||||
("<p:strips/>", "wipe", "lu"),
|
||||
("<p:randomBar/>", "randomBar", "horz"),
|
||||
("<p:checker/>", "checker", "horz"),
|
||||
("<p:blinds/>", "blinds", "horz"),
|
||||
("<p:comb/>", "comb", "horz"),
|
||||
("<p:zoom/>", "shape", "out"),
|
||||
(r#"<p:zoom dir="in"/>"#, "shape", "in"),
|
||||
("<p:split/>", "split", "horzOut"),
|
||||
("<p14:doors/>", "doors", "horz"),
|
||||
("<p14:window/>", "window", "horz"),
|
||||
("<p14:pan/>", "pan", "l"),
|
||||
("<p14:warp/>", "zoom", "out"),
|
||||
("<p14:glitter/>", "glitter", "l"),
|
||||
("<p14:shred/>", "shred", "stripsIn"),
|
||||
("<p14:reveal/>", "reveal", "smoothlyLeft"),
|
||||
("<p14:prism/>", "cube", "l"),
|
||||
(r#"<p15:prstTrans prst="airplane"/>"#, "airplane", "r"),
|
||||
(r#"<p15:prstTrans prst="peelOff"/>"#, "peelOff", "l"),
|
||||
(r#"<p15:prstTrans prst="pageCurlSingle"/>"#, "pageCurlDouble", "singleL"),
|
||||
];
|
||||
let (_, xml) = transition_deck(&[("fade", "smoothly", 2000, None)]);
|
||||
let Some(x) = xml.first() else { panic!("no slide") };
|
||||
let start = x.find("<mc:AlternateContent").expect("transition");
|
||||
let end = x.find("</mc:AlternateContent>").expect("transition end") + "</mc:AlternateContent>".len();
|
||||
let p14 = r#"xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main""#;
|
||||
let p15 = r#"xmlns:p15="http://schemas.microsoft.com/office/powerpoint/2012/main""#;
|
||||
for (el, kind, option) in cases {
|
||||
let tr = format!(r#"<p:transition {p14} {p15} spd="slow" p14:dur="2000">{el}</p:transition>"#);
|
||||
let edited = format!("{}{}{}", &x[..start], tr, &x[end..]);
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let base = p.slides[0].as_ref().clone();
|
||||
p.slides = vec![Arc::new(base)];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
data = edited.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
std::io::Write::write_all(&mut out, &data).expect("write");
|
||||
}
|
||||
let bytes = out.finish().expect("finish").into_inner();
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let t = q.slides[0].transition.as_ref().expect("transition");
|
||||
assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ deckcraft-geom = { workspace = true }
|
||||
deckcraft-color = { workspace = true }
|
||||
deckcraft-metafile = { workspace = true }
|
||||
deckcraft-model = { workspace = true }
|
||||
deckcraft-anim = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
deckcraft-text = { workspace = true }
|
||||
vello_cpu = { workspace = true }
|
||||
|
||||
+267
-97
@@ -2,7 +2,7 @@
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::chart::{Chart, ChartType, nice_scale};
|
||||
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
|
||||
@@ -36,6 +36,29 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart
|
||||
}
|
||||
}
|
||||
|
||||
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
|
||||
/// palette's.
|
||||
fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025").
|
||||
fn split_label(name: &str) -> String {
|
||||
let chars: Vec<char> = name.chars().collect();
|
||||
let mid = chars.len() / 2;
|
||||
let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid));
|
||||
match best {
|
||||
Some(i) if i > 0 && i + 1 < chars.len() => {
|
||||
let (a, b) = chars.split_at(i);
|
||||
format!("{}\n{}", a.iter().collect::<String>().trim_end(), b.iter().collect::<String>().trim_start())
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) {
|
||||
let mut body = TextBody::from_text(text);
|
||||
body.body.inset_l = Some(0.0);
|
||||
@@ -63,6 +86,42 @@ fn fmt_num(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
|
||||
}
|
||||
|
||||
/// Where the legend goes for `c:legendPos`, and the plot area left over.
|
||||
/// `size` is the legend block (width, height); the result is its top-left corner and the plot area.
|
||||
/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred
|
||||
/// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows.
|
||||
/// An overlaid legend leaves the plot area as it was.
|
||||
fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) {
|
||||
let (lw, lh) = size;
|
||||
let gap = base * 0.5;
|
||||
let cx = (area.x0 + area.x1) / 2.0;
|
||||
let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0);
|
||||
let mut plot = area;
|
||||
let origin = match pos {
|
||||
"b" => {
|
||||
plot.y1 = area.y1 - lh - gap;
|
||||
Point::new(cx - lw / 2.0, area.y1 - lh)
|
||||
}
|
||||
"t" => {
|
||||
plot.y0 = area.y0 + lh + gap;
|
||||
Point::new(cx - lw / 2.0, area.y0)
|
||||
}
|
||||
"l" => {
|
||||
plot.x0 = area.x0 + lw + gap;
|
||||
Point::new(area.x0, cy)
|
||||
}
|
||||
"tr" => {
|
||||
plot.x1 = area.x1 - lw - gap;
|
||||
Point::new(area.x1 - lw, area.y0)
|
||||
}
|
||||
_ => {
|
||||
plot.x1 = area.x1 - lw - gap;
|
||||
Point::new(area.x1 - lw, cy)
|
||||
}
|
||||
};
|
||||
(origin, if overlay { area } else { plot })
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||||
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
@@ -80,32 +139,53 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
rctx.pres,
|
||||
);
|
||||
}
|
||||
let base = (h.min(w) / 22.0).clamp(7.0, 18.0);
|
||||
// The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size
|
||||
// that suits the chart.
|
||||
let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0));
|
||||
let title_size = c.title_size.unwrap_or(base * 1.4);
|
||||
let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96);
|
||||
if let Some(t) = &c.title {
|
||||
label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + base * 1.8), base * 1.4, Align::Center, Anchor::Top, text_col, false);
|
||||
area.y0 += base * 2.4;
|
||||
label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false);
|
||||
area.y0 += title_size * 1.7;
|
||||
}
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend at the bottom.
|
||||
// Legend placed by `c:legendPos`.
|
||||
let legend_items: Vec<(String, Rgba)> = if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
} else {
|
||||
c.series.iter().enumerate().map(|(i, s)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect()
|
||||
c.series
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let k = c.series_kind(s);
|
||||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if c.legend.is_some() && !legend_items.is_empty() {
|
||||
let ly = area.y1 - base * 1.4;
|
||||
let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::<f64>()).min(w * 0.9);
|
||||
let mut x = (w - item_w) / 2.0;
|
||||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let size = if vertical {
|
||||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||||
} else {
|
||||
(item_ws.iter().sum::<f64>().min(w * 0.9), base * 1.4)
|
||||
};
|
||||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||||
area = plot_area;
|
||||
let (mut x, mut y) = (origin.x, origin.y);
|
||||
for (name, col) in &legend_items {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*col, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, ly + base * 0.3, x + base * 0.7, ly + base));
|
||||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
x += tw + base * 0.6;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
if vertical {
|
||||
y += base * 1.5;
|
||||
} else {
|
||||
x += tw + base * 0.6;
|
||||
}
|
||||
}
|
||||
area.y1 = ly - base * 0.5;
|
||||
}
|
||||
if pie {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
@@ -162,26 +242,50 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = c.value_range();
|
||||
let (min, max, step) = nice_scale(lo.min(0.0), hi);
|
||||
let horiz = c.kind.horizontal();
|
||||
// Axis label gutter.
|
||||
let gutter = base * 3.0;
|
||||
let plot = if horiz {
|
||||
Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6)
|
||||
let combo = c.kind == ChartType::Combo;
|
||||
// Bars are drawn first and lines over them; a combo chart has both.
|
||||
let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like());
|
||||
let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() };
|
||||
// As many major units as the value axis has room for labels (PowerPoint's automatic unit).
|
||||
let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 };
|
||||
let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize;
|
||||
let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true));
|
||||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||||
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
|
||||
let ticks = |(lo, hi, st): (f64, f64, f64)| {
|
||||
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
};
|
||||
// Label gutters as wide as the longest value label.
|
||||
let label_w =
|
||||
|sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6;
|
||||
let gutter = if horiz {
|
||||
base * 4.5
|
||||
} else if c.y_hidden {
|
||||
base * 0.5
|
||||
} else {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8)
|
||||
label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3)
|
||||
};
|
||||
let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0);
|
||||
let plot = if horiz {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6)
|
||||
} else {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1 - gutter_r, area.y1 - base * 1.8)
|
||||
};
|
||||
if let Some(f) = &c.plot_fill {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
let span = (max - min).max(1e-9);
|
||||
let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() };
|
||||
// A series' value as a position on its own axis.
|
||||
let to_axis = |sec: bool, v: f64| match (sec, second) {
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||||
_ => to_v(v),
|
||||
};
|
||||
// Gridlines and value labels.
|
||||
let n_steps = ((span / step).round() as i64).clamp(1, 50);
|
||||
for k in 0..=n_steps {
|
||||
let v = min + k as f64 * step;
|
||||
for v in ticks((min, max, step)) {
|
||||
let pos = to_v(v);
|
||||
let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) };
|
||||
let mut g = BezPath::new();
|
||||
if horiz {
|
||||
g.move_to((pos, plot.y0));
|
||||
@@ -190,7 +294,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5),
|
||||
base,
|
||||
Align::Center,
|
||||
@@ -205,7 +309,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base),
|
||||
base,
|
||||
Align::Right,
|
||||
@@ -221,9 +325,28 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx.stroke_path(&g);
|
||||
}
|
||||
}
|
||||
// The secondary axis' labels, on the right, without gridlines of their own.
|
||||
if let Some(sc) = second.filter(|_| !c.y2_hidden) {
|
||||
for v in ticks(sc) {
|
||||
let pos = to_axis(true, v);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&c.axis_label(true, v),
|
||||
Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base),
|
||||
base,
|
||||
Align::Left,
|
||||
Anchor::Middle,
|
||||
text_col,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
|
||||
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
|
||||
// Category labels.
|
||||
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
|
||||
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
|
||||
for (i, name) in c.categories.iter().enumerate() {
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
|
||||
if horiz {
|
||||
@@ -240,12 +363,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let text = if wraps { split_label(name) } else { name.clone() };
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
name,
|
||||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6),
|
||||
&text,
|
||||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }),
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Top,
|
||||
@@ -254,24 +378,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
);
|
||||
}
|
||||
}
|
||||
let ns = c.series.len().max(1);
|
||||
let zero = to_v(0.0f64.clamp(min, max));
|
||||
if c.kind.is_bar_like() {
|
||||
if let Some(bk) = bar_kind {
|
||||
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
|
||||
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
|
||||
let ns = bars.len().max(1);
|
||||
let gap = c.gap_width.clamp(0.0, 5.0);
|
||||
let group = band / (1.0 + gap);
|
||||
let stacked = c.kind.stacked();
|
||||
let stacked = bk.stacked();
|
||||
let bar = if stacked { group } else { group / ns as f64 };
|
||||
for i in 0..ncat {
|
||||
let mut pos_acc = 0.0;
|
||||
let mut neg_acc = 0.0;
|
||||
let total: f64 = if c.kind.percent() {
|
||||
c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||||
let total: f64 = if bk.percent() {
|
||||
bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
for (bi, (si, s)) in bars.iter().enumerate() {
|
||||
let sec = c.on_secondary(s);
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * si as f64 };
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let (a, b) = if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
@@ -288,13 +415,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
let r = if horiz {
|
||||
Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar)
|
||||
} else {
|
||||
Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a))
|
||||
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0));
|
||||
ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
if c.data_labels {
|
||||
let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let lr = if horiz {
|
||||
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
|
||||
} else {
|
||||
@@ -315,66 +442,72 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lines, areas, scatter, radar (drawn as lines).
|
||||
let stacked = c.kind.stacked();
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let col = series_fill(rctx, c, si, Some(s));
|
||||
let mut pts = vec![];
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let v = if stacked {
|
||||
let base_v = acc.get(i).copied().unwrap_or(0.0);
|
||||
if let Some(a) = acc.get_mut(i) {
|
||||
*a += v;
|
||||
}
|
||||
base_v + v
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let x = match c.kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_v(v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
// Lines, areas, scatter, radar (drawn as lines): the series that aren't bars.
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let kind = c.series_kind(s);
|
||||
if kind.is_bar_like() {
|
||||
continue;
|
||||
}
|
||||
let sec = c.on_secondary(s);
|
||||
let stacked = kind.stacked();
|
||||
let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
|
||||
let mut pts = vec![];
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let v = if stacked {
|
||||
let base_v = acc.get(i).copied().unwrap_or(0.0);
|
||||
if let Some(a) = acc.get_mut(i) {
|
||||
*a += v;
|
||||
}
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) {
|
||||
let mut a = p.clone();
|
||||
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
|
||||
a.line_to((l.x, zero));
|
||||
a.line_to((f.x, zero));
|
||||
a.close_path();
|
||||
base_v + v
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let x = match kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
ctx.set_paint(color(col, 0.85));
|
||||
ctx.fill_path(&a);
|
||||
} else if c.kind != ChartType::Scatter {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&p);
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
if matches!(c.kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
for q in &pts {
|
||||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if area_kind {
|
||||
let mut a = p.clone();
|
||||
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
|
||||
a.line_to((l.x, zero));
|
||||
a.line_to((f.x, zero));
|
||||
a.close_path();
|
||||
}
|
||||
ctx.set_paint(color(col, 0.85));
|
||||
ctx.fill_path(&a);
|
||||
} else if kind != ChartType::Scatter {
|
||||
let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0);
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&p);
|
||||
}
|
||||
if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
for q in &pts {
|
||||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,3 +525,40 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.stroke_path(&ax);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[test]
|
||||
fn right_legend_is_a_centred_column_and_narrows_the_plot() {
|
||||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y), (340.0, 100.0));
|
||||
assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_top_and_corner_positions() {
|
||||
let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0));
|
||||
let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0));
|
||||
let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_legend_keeps_the_old_row_layout() {
|
||||
let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_does_not_shrink_the_plot() {
|
||||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true);
|
||||
assert_eq!(plot, AREA);
|
||||
assert_eq!(o.x, 340.0);
|
||||
}
|
||||
}
|
||||
|
||||
+206
-13
@@ -23,6 +23,7 @@ mod pathgrad;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
mod transition;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -41,12 +42,14 @@ 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 deckcraft_anim::{transition_frame, transition_layers, uses_slide_parts};
|
||||
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;
|
||||
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
|
||||
pub use table::row_heights as table_row_heights;
|
||||
pub use transition::{SlideParts, composite_transition, render_transition};
|
||||
|
||||
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
|
||||
pub const MAX_SIDE: u32 = 16_000;
|
||||
@@ -238,11 +241,14 @@ pub struct Renderer {
|
||||
/// 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>,
|
||||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||||
view: Affine,
|
||||
}
|
||||
|
||||
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, drawn: None, view: Affine::IDENTITY }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +332,30 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which part of a slide [`render_slide_part`] draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SlidePart {
|
||||
/// The background fill with the master and layout graphics.
|
||||
Backdrop,
|
||||
/// The slide's own shapes over transparency.
|
||||
Content,
|
||||
}
|
||||
|
||||
/// Render one part of slide `index` (transitions that move the contents over the backgrounds).
|
||||
/// A panic inside the renderer becomes a transparent image (logged), never a crash.
|
||||
pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
|
||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
log::error!("rendering part of slide {index} panicked; showing nothing");
|
||||
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![0; w as usize * h as usize * 4] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
|
||||
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
|
||||
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
|
||||
@@ -547,6 +577,25 @@ impl Renderer {
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||||
match part {
|
||||
SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view),
|
||||
SlidePart::Content => {
|
||||
// Shapes filled with the background (`useBgFill`) still show it.
|
||||
let (fill, ph) = resolve::background(&rctx, Some(slide));
|
||||
self.bg = Some((fill, ph, view));
|
||||
for s in &slide.shapes {
|
||||
self.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend(
|
||||
&mut self,
|
||||
@@ -660,6 +709,43 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth == 0 {
|
||||
let outer = std::mem::replace(&mut self.view, parent);
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
self.view = outer;
|
||||
} else {
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
|
||||
/// one that takes the shape's outline into it. Inside a group they are the slide-level view and
|
||||
/// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
|
||||
/// never the line width; outside a group, `m` itself and no change to the outline.
|
||||
fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
|
||||
let view = self.view;
|
||||
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
|
||||
}
|
||||
|
||||
/// The size and transform a shape's text is set in: inside a group whose child coordinates
|
||||
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
|
||||
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
|
||||
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
|
||||
let (_, rel) = self.line_space(m, depth);
|
||||
let c = rel.as_coeffs();
|
||||
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
|
||||
let ok = |k: f64| k.is_finite() && k > 1e-9;
|
||||
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
|
||||
return (w, h, m);
|
||||
}
|
||||
let (tw, th) = (w * sx, h * sy);
|
||||
if !tw.is_finite() || !th.is_finite() {
|
||||
return (w, h, m);
|
||||
}
|
||||
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
}
|
||||
@@ -718,20 +804,34 @@ impl Renderer {
|
||||
bp.line_to((*px, *py));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(parent);
|
||||
let (base, rel) = self.line_space(parent, depth);
|
||||
bp.apply_affine(rel);
|
||||
ctx.set_transform(base);
|
||||
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&bp);
|
||||
}
|
||||
}
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st),
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn geometry_shape(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
rctx: &Ctx,
|
||||
s: &Shape,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
st: &ShapeState,
|
||||
depth: usize,
|
||||
) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
@@ -749,7 +849,12 @@ impl Renderer {
|
||||
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
|
||||
// Effects below the shape.
|
||||
if let Some(e) = &effects {
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||||
let content = match (&s.kind, &fill) {
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
|
||||
}
|
||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||
if let Some(r) = soft {
|
||||
@@ -813,14 +918,41 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
|
||||
// a gradient over a tiled picture), inside the filled outline, under the line.
|
||||
if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
|
||||
&& fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
let mix = match ov.blend.as_str() {
|
||||
"mult" => peniko::Mix::Multiply,
|
||||
"screen" => peniko::Mix::Screen,
|
||||
"darken" => peniko::Mix::Darken,
|
||||
"lighten" => peniko::Mix::Lighten,
|
||||
_ => peniko::Mix::Normal,
|
||||
};
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None);
|
||||
paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
let (base, rel) = self.line_space(m, depth);
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
|
||||
let path = if rel == Affine::IDENTITY {
|
||||
std::borrow::Cow::Borrowed(&sp.path)
|
||||
} else {
|
||||
let mut moved = sp.path.clone();
|
||||
moved.apply_affine(rel);
|
||||
std::borrow::Cow::Owned(moved)
|
||||
};
|
||||
stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke);
|
||||
}
|
||||
if s.is_line() || geo.is_open() {
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
|
||||
}
|
||||
}
|
||||
if let Some(r) = soft {
|
||||
@@ -872,6 +1004,16 @@ impl Renderer {
|
||||
if let Some(body) = &s.text
|
||||
&& !skip_text
|
||||
{
|
||||
// Text in a group is set at the shape's size on the slide, in points: a group's
|
||||
// child-coordinate scale sizes and places the box, never the type (#233's twin).
|
||||
let (tw, th, m) = self.text_space(m, depth, w, h);
|
||||
let text_geo;
|
||||
let geo = if (tw, th) == (w, h) {
|
||||
&geo
|
||||
} else {
|
||||
text_geo = shape_geometry(s, tw, th);
|
||||
&text_geo
|
||||
};
|
||||
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 });
|
||||
@@ -915,6 +1057,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -942,8 +1085,24 @@ impl Renderer {
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
match content {
|
||||
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
|
||||
// transparent background casts the logo's shadow): the picture, clipped to the
|
||||
// shape, recoloured to the shadow colour.
|
||||
Some(pf) => {
|
||||
ctx.push_clip_layer(&silhouette);
|
||||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
None => {
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
@@ -1069,9 +1228,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||
Some(((first, second), (last, prev)))
|
||||
}
|
||||
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, m: Affine, lw: f64) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
|
||||
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
|
||||
let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return };
|
||||
// The outline's ends in the space the arrowheads are sized and drawn in.
|
||||
let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
|
||||
let col = match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
|
||||
_ => Rgba::BLACK,
|
||||
@@ -1237,7 +1399,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
ctx.fill_rect(&d.rect);
|
||||
fill_deco(ctx, d);
|
||||
};
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
@@ -1264,7 +1426,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let at = m * glyph_place(run, *gid, *x, *y);
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
@@ -1285,6 +1447,37 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a
|
||||
/// quarter turn back about its em box's centre when it stands upright in vertical text.
|
||||
pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine {
|
||||
let at = Affine::translate((x, y));
|
||||
match run.upright_centre(gid, x, y) {
|
||||
Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at,
|
||||
None => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill an underline, strikethrough or highlight with the current paint and transform: its box,
|
||||
/// its dashes, or its zigzag stroked.
|
||||
pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
|
||||
if let Some((pts, w)) = d.wave() {
|
||||
let mut path = BezPath::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 {
|
||||
path.move_to(*p);
|
||||
} else {
|
||||
path.line_to(*p);
|
||||
}
|
||||
}
|
||||
ctx.set_stroke(kurbo::Stroke::new(w.max(0.01)));
|
||||
ctx.stroke_path(&path);
|
||||
return;
|
||||
}
|
||||
for r in d.pieces() {
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
@@ -1335,7 +1528,7 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T
|
||||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
|
||||
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
|
||||
//! through the same crop, tile and recolour path as any other picture.
|
||||
//!
|
||||
//! Text items name the font the file asks for; they are drawn with the face slide text would use
|
||||
//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol
|
||||
//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each
|
||||
//! character.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_metafile::PlacedItem;
|
||||
use kurbo::{Affine, Rect, Shape as _};
|
||||
use deckcraft_fonts::{FontDb, FontFace};
|
||||
use deckcraft_metafile::{HAlign, Item, PlacedItem, Text, Tile, VAlign};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
use skrifa::MetadataProvider as _;
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
|
||||
|
||||
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
|
||||
pub const DEFAULT_SIDE: u32 = 1024;
|
||||
/// Largest side drawn.
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
/// Pattern cells smaller than this (output pixels) are drawn as the tile's average colour: a
|
||||
/// sub-pixel pattern would only alias.
|
||||
const MIN_CELL_PX: f64 = 1.5;
|
||||
/// Size of text whose font gives no height (LOGFONT height 0), in text units.
|
||||
const DEFAULT_EM: f64 = 12.0;
|
||||
|
||||
/// Is `bytes` a WMF or EMF file?
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
@@ -31,6 +44,8 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
|
||||
// Pattern tiles shared by several fills are converted once.
|
||||
let mut tiles: HashMap<*const Tile, (Option<Arc<Pixmap>>, peniko::Color)> = HashMap::new();
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
ctx.push_clip_layer(&r.to_path(0.1));
|
||||
@@ -60,6 +75,43 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
PlacedItem::Pattern { index, path, even_odd, tile } => {
|
||||
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue };
|
||||
let key = Arc::as_ptr(t);
|
||||
let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone();
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
let cell = tile.determinant().abs().sqrt();
|
||||
match pm {
|
||||
Some(pm) if cell >= MIN_CELL_PX => {
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler {
|
||||
x_extend: peniko::Extend::Repeat,
|
||||
y_extend: peniko::Extend::Repeat,
|
||||
quality: peniko::ImageQuality::Low,
|
||||
alpha: 1.0,
|
||||
},
|
||||
});
|
||||
ctx.set_paint_transform(tile);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
_ => {
|
||||
ctx.set_paint(avg);
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
PlacedItem::Text { index, transform, clip } => {
|
||||
let Some(Item::Text(t)) = pic.items.get(index) else { continue };
|
||||
if let Some(c) = clip {
|
||||
ctx.push_clip_layer(&c.to_path(0.1));
|
||||
}
|
||||
draw_text(&mut ctx, t, transform);
|
||||
if clip.is_some() {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
@@ -81,10 +133,213 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
/// One glyph placed in text space (x along the baseline from the string's start, font units at
|
||||
/// `scale` text units each).
|
||||
struct Glyph {
|
||||
face: Arc<FontFace>,
|
||||
gid: u32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
/// Draws a text item; `m` maps its text space to the output.
|
||||
fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {
|
||||
let db = FontDb::global();
|
||||
let font = &t.font;
|
||||
let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() };
|
||||
let (bold, italic) = (font.weight >= 600, font.italic);
|
||||
let style = match (bold, italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
};
|
||||
let face = db.document_face(family, style);
|
||||
let upem = face.upem.max(1.0);
|
||||
let (asc, desc) = win_metrics(&face);
|
||||
// LOGFONT height: negative is the em, positive the cell (ascent plus descent).
|
||||
let h = f64::from(font.height);
|
||||
let em = if h < 0.0 {
|
||||
-h
|
||||
} else if h > 0.0 {
|
||||
h * upem / (asc + desc).max(1.0)
|
||||
} else {
|
||||
DEFAULT_EM
|
||||
};
|
||||
let k = em / upem;
|
||||
if !k.is_finite() || k <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the
|
||||
// face that has it.
|
||||
let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect();
|
||||
let faces: Vec<Arc<FontFace>> = chars
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_whitespace() || c.is_control() || face.covers(*c) { face.clone() } else { db.cascade(&[*c], std::slice::from_ref(&face), style) }
|
||||
})
|
||||
.collect();
|
||||
// Shape runs of one face; record each glyph's pen position and its character.
|
||||
let mut shaped: Vec<(Glyph, usize)> = Vec::with_capacity(chars.len());
|
||||
let mut char_pen: Vec<f64> = vec![0.0; chars.len() + 1];
|
||||
let mut pen = 0.0;
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let Some(f) = faces.get(i).cloned() else { break };
|
||||
let mut j = i + 1;
|
||||
while faces.get(j).is_some_and(|g| g.id() == f.id()) {
|
||||
j += 1;
|
||||
}
|
||||
let run: String = chars.get(i..j).unwrap_or(&[]).iter().collect();
|
||||
// Byte offset in the run → character index in the string.
|
||||
let byte_char: Vec<usize> = run.char_indices().map(|(b, _)| b).collect();
|
||||
let ku = f.upem.max(1.0);
|
||||
for g in deckcraft_fonts::shape(&f, &run, &[], |c| c) {
|
||||
let ci = i + byte_char.partition_point(|b| *b < g.cluster).min(j - i - 1);
|
||||
let s = em / ku;
|
||||
shaped.push((Glyph { face: f.clone(), gid: g.gid, x: pen + f64::from(g.x_offset) * s, y: -f64::from(g.y_offset) * s }, ci));
|
||||
pen += f64::from(g.x_advance) * s;
|
||||
if let Some(p) = char_pen.get_mut(ci + 1) {
|
||||
*p = pen;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
// Character start pens: a character's start is the previous one's end.
|
||||
for ci in 1..char_pen.len() {
|
||||
let prev = char_pen.get(ci - 1).copied().unwrap_or(0.0);
|
||||
if let Some(p) = char_pen.get_mut(ci)
|
||||
&& *p < prev
|
||||
{
|
||||
*p = prev;
|
||||
}
|
||||
}
|
||||
// The file's advances place each character; glyphs keep their offset within their character.
|
||||
let use_dx = !t.dx.is_empty() && t.dx.len() == chars.len();
|
||||
let mut width = pen;
|
||||
let mut x_scale = 1.0;
|
||||
if use_dx {
|
||||
let mut starts = Vec::with_capacity(chars.len() + 1);
|
||||
let mut x = 0.0;
|
||||
for d in &t.dx {
|
||||
starts.push(x);
|
||||
x += f64::from(*d);
|
||||
}
|
||||
width = x;
|
||||
for (g, ci) in &mut shaped {
|
||||
let (start, pen_start) = (starts.get(*ci).copied().unwrap_or(0.0), char_pen.get(*ci).copied().unwrap_or(0.0));
|
||||
g.x = start + (g.x - pen_start);
|
||||
}
|
||||
} else if font.width > 0.0 {
|
||||
// A LOGFONT width narrows or widens the font against its average character width.
|
||||
let avg = avg_char_width(&face) * k;
|
||||
if avg > 0.0 {
|
||||
x_scale = (f64::from(font.width) / avg).clamp(0.05, 20.0);
|
||||
width *= x_scale;
|
||||
for (g, _) in &mut shaped {
|
||||
g.x *= x_scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
let x0 = match t.h_align {
|
||||
HAlign::Left => 0.0,
|
||||
HAlign::Center => -width / 2.0,
|
||||
HAlign::Right => -width,
|
||||
};
|
||||
let (a, d) = (asc * k, desc * k);
|
||||
let base = match t.v_align {
|
||||
VAlign::Baseline => 0.0,
|
||||
VAlign::Top => a,
|
||||
VAlign::Bottom => -d,
|
||||
};
|
||||
let at = m * Affine::translate((x0, base));
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
if let Some(bg) = t.background
|
||||
&& width > 0.0
|
||||
{
|
||||
ctx.set_paint(rgba(bg));
|
||||
ctx.fill_path(&(at * Rect::new(0.0, -a, width, d).to_path(0.1)));
|
||||
}
|
||||
let color = rgba(t.color);
|
||||
ctx.set_paint(color);
|
||||
for (g, _) in &shaped {
|
||||
let path = db.outline(&g.face, g.gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = em / g.face.upem.max(1.0);
|
||||
let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s);
|
||||
let p: BezPath = gm * path.as_ref().clone();
|
||||
ctx.fill_path(&p);
|
||||
if bold && g.face.weight < 600.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&p);
|
||||
}
|
||||
}
|
||||
// Underline and strike-out: the font's own positions and thickness.
|
||||
let metrics = face.skrifa().map(|f| f.metrics(skrifa::instance::Size::unscaled(), face.location()));
|
||||
let deco = |on: bool, d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| -> Option<Rect> {
|
||||
if !on || width <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (off, th) = d.map_or(fallback, |d| (f64::from(d.offset) * k, f64::from(d.thickness) * k));
|
||||
let th = if th > 0.0 { th } else { em / 20.0 };
|
||||
// Offsets are y-up from the baseline; text space is y-down.
|
||||
Some(Rect::new(0.0, -off - th / 2.0, width, -off + th / 2.0))
|
||||
};
|
||||
let lines = [
|
||||
deco(font.underline, metrics.as_ref().and_then(|m| m.underline), (-em * 0.1, em / 20.0)),
|
||||
deco(font.strike, metrics.as_ref().and_then(|m| m.strikeout), (em * 0.26, em / 20.0)),
|
||||
];
|
||||
for r in lines.into_iter().flatten() {
|
||||
ctx.fill_path(&(at * r.to_path(0.1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean scale of an affine transform's linear part.
|
||||
fn scale_of(m: Affine) -> f64 {
|
||||
m.determinant().abs().sqrt()
|
||||
}
|
||||
|
||||
/// The face's Windows ascent and descent (OS/2 `usWinAscent`/`usWinDescent`, as GDI measures the
|
||||
/// cell), in font units; the typographic ones when the font has no OS/2 table.
|
||||
fn win_metrics(face: &FontFace) -> (f64, f64) {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
let os2 = face.skrifa().and_then(|f| f.os2().ok()).map(|o| (f64::from(o.us_win_ascent()), f64::from(o.us_win_descent())));
|
||||
match os2 {
|
||||
Some((a, d)) if a + d > 0.0 => (a, d),
|
||||
_ => (face.ascent.max(0.0), face.descent.max(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// OS/2 `xAvgCharWidth` in font units (half an em without it).
|
||||
fn avg_char_width(face: &FontFace) -> f64 {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
face.skrifa().and_then(|f| f.os2().ok()).map(|o| f64::from(o.x_avg_char_width())).filter(|w| *w > 0.0).unwrap_or(face.upem * 0.5)
|
||||
}
|
||||
|
||||
fn rgba(c: [u8; 4]) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
|
||||
/// The average colour of straight RGBA pixels (alpha-weighted).
|
||||
fn average(px: &[u8]) -> peniko::Color {
|
||||
let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64);
|
||||
for p in px.as_chunks::<4>().0 {
|
||||
let w = u64::from(p[3]);
|
||||
r += u64::from(p[0]) * w;
|
||||
g += u64::from(p[1]) * w;
|
||||
b += u64::from(p[2]) * w;
|
||||
a += w;
|
||||
n += 1;
|
||||
}
|
||||
if a == 0 || n == 0 {
|
||||
return peniko::Color::from_rgba8(0, 0, 0, 0);
|
||||
}
|
||||
peniko::Color::from_rgba8((r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8)
|
||||
}
|
||||
|
||||
/// Straight RGBA rows to a premultiplied pixmap.
|
||||
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
|
||||
|
||||
@@ -219,7 +219,7 @@ pub(crate) fn draw(
|
||||
for d in &l.decos {
|
||||
ctx.set_transform(place);
|
||||
ctx.set_paint(color(d.color, alpha));
|
||||
ctx.fill_rect(&d.rect);
|
||||
crate::fill_deco(ctx, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,36 @@ fn effects_tables_charts_render() {
|
||||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
|
||||
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
|
||||
// the line must sit up in the plot, not flat along the bottom of the primary scale.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.series.truncate(2);
|
||||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
|
||||
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let green = |x: u32, y0: u32, y1: u32| {
|
||||
(y0..y1).any(|y| {
|
||||
let p = img.pixel(x, y);
|
||||
p[1] > 120 && p[0] < 80 && p[2] < 140
|
||||
})
|
||||
};
|
||||
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
|
||||
assert!(green(480, 20, 270), "line not in the upper half");
|
||||
assert!(!green(480, 400, 500), "line along the bottom");
|
||||
let col = img.pixel(480, 480);
|
||||
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
@@ -1268,3 +1298,411 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
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));
|
||||
}
|
||||
|
||||
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
|
||||
/// bytes).
|
||||
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (func, params) in records {
|
||||
let mut p = params.clone();
|
||||
if p.len() % 2 == 1 {
|
||||
p.push(0);
|
||||
}
|
||||
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
|
||||
body.extend(func.to_le_bytes());
|
||||
body.extend(p);
|
||||
}
|
||||
body.extend([3, 0, 0, 0, 0, 0]);
|
||||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
f.extend(0u16.to_le_bytes());
|
||||
for v in [0i16, 0, w, h] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend(1440u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let total = (f.len() + 18 + body.len()) / 2;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend((total as u32).to_le_bytes());
|
||||
f.extend(16u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
f.extend(body);
|
||||
f
|
||||
}
|
||||
|
||||
fn i16s(v: &[i32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
|
||||
/// background, and the alignment `ta`.
|
||||
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
|
||||
let mut f = i16s(&[height, 0, esc, esc, 400]);
|
||||
f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]);
|
||||
f.extend(face.as_bytes());
|
||||
f.push(0);
|
||||
vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))]
|
||||
}
|
||||
|
||||
/// EXTTEXTOUT at (x, y) with an optional dx array.
|
||||
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
|
||||
let mut v = i16s(&[y, x, s.len() as i32, 0]);
|
||||
v.extend(s.as_bytes());
|
||||
if s.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v.extend(i16s(dx));
|
||||
(0x0A32, v)
|
||||
}
|
||||
|
||||
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
|
||||
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut b: Option<(u32, u32, u32, u32)> = None;
|
||||
for (x, y, p) in img.enumerate_pixels() {
|
||||
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
|
||||
b = Some(match b {
|
||||
None => (x, y, x, y),
|
||||
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_ink_lands_at_its_reference_point() {
|
||||
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
|
||||
// aligned at (72, 18) pt.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push(text_record(1440, 360, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
|
||||
assert!((144..156).contains(&x0), "left edge at the reference point: {x0}");
|
||||
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
|
||||
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
|
||||
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
|
||||
assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_centre_baseline_and_escapement() {
|
||||
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
|
||||
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
|
||||
recs.push(text_record(1440, 720, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}");
|
||||
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
|
||||
// Escapement 90°: the string runs up the picture.
|
||||
let mut recs = font_records(-360, 900, "Arial", 24);
|
||||
recs.push(text_record(1440, 1300, "HHHH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}");
|
||||
assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_follows_its_dx_array() {
|
||||
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
|
||||
let mut recs = font_records(-360, 0, "Arial", 24);
|
||||
recs.push(text_record(360, 720, "HH", &[1440, 100]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
|
||||
assert!((36..56).any(col_has_ink), "first H at 36 px");
|
||||
assert!((180..200).any(col_has_ink), "second H one inch later");
|
||||
assert!(!(70..170).any(col_has_ink), "nothing between them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
|
||||
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
|
||||
// is Wingdings "l" in a symbol font that is not installed anywhere.
|
||||
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
|
||||
let mut recs = font_records(-1080, 0, face, 0);
|
||||
recs.push(text_record(360, 0, s, &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}");
|
||||
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
|
||||
assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle");
|
||||
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
|
||||
assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_opaque_text_fills_its_cell() {
|
||||
// OPAQUE background in red behind a space.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push((0x0102, i16s(&[2])));
|
||||
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
|
||||
recs.push(text_record(1440, 360, " ", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let p = img.get_pixel(148, 60);
|
||||
assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}");
|
||||
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
|
||||
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
|
||||
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend([0u8; 24]);
|
||||
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
for r in 0..8 {
|
||||
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
|
||||
}
|
||||
let mut brush = i16s(&[5, 0]);
|
||||
brush.extend(dib);
|
||||
let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))];
|
||||
let bytes = wmf_with(1440, 1440, &recs);
|
||||
// 4 px per point: a 0.75 pt cell is 3 px.
|
||||
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
|
||||
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
|
||||
assert!(a[3] > 200 && b[3] > 200);
|
||||
assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}");
|
||||
// Far below one pixel per cell: the average grey.
|
||||
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
|
||||
let p = img.get_pixel(16, 16);
|
||||
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
|
||||
}
|
||||
|
||||
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
|
||||
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
|
||||
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
|
||||
use deckcraft_model::style::{Dash, LineEnd};
|
||||
let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) };
|
||||
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
|
||||
Xfrm::new(a, b, c - a, d - b)
|
||||
};
|
||||
let rect = Shape {
|
||||
id: ShapeId(11),
|
||||
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
|
||||
geom: Geom::preset("rect"),
|
||||
line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() };
|
||||
let line = Shape {
|
||||
id: ShapeId(12),
|
||||
xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }),
|
||||
geom: Geom::preset("line"),
|
||||
line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect();
|
||||
let ink = Shape {
|
||||
id: ShapeId(13),
|
||||
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
|
||||
kind: ShapeKind::Ink {
|
||||
strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(10),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![rect, line, ink], child },
|
||||
..Default::default()
|
||||
};
|
||||
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
fn inked(img: &Image) -> usize {
|
||||
img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_child_scale_does_not_scale_line_widths() {
|
||||
// Children laid out 1:1 with the slide are the reference.
|
||||
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
|
||||
let drawn = inked(&reference);
|
||||
assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}");
|
||||
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
|
||||
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
|
||||
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0));
|
||||
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0));
|
||||
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
|
||||
let (n, d) = (inked(img), differing(&reference, img));
|
||||
assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}");
|
||||
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||||
// logo's shadow, not its frame's. Here the left half of the image is transparent.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let shadow = deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||
blur: 0.0,
|
||||
dist: 20.0,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
for as_fill in [false, true] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("logo.png", "image/png", png.clone());
|
||||
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
|
||||
if as_fill {
|
||||
sh.fill = Some(Fill::Picture(pf));
|
||||
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
} else {
|
||||
sh.kind = ShapeKind::Picture { fill: pf };
|
||||
}
|
||||
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..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() });
|
||||
// Below the picture, 10 pt down: shadow under the opaque right half only.
|
||||
let left = img.pixel(200, 310);
|
||||
let right = img.pixel(400, 310);
|
||||
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
|
||||
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
|
||||
// The transparent half shows the slide, not a shadow behind it.
|
||||
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_scaled_group_keeps_its_point_size() {
|
||||
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
|
||||
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
|
||||
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
|
||||
let draw = |scale: f64| {
|
||||
let mut p = Presentation::default();
|
||||
let mut body = TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.size = Some(24.0);
|
||||
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
|
||||
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
|
||||
let g = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let ((_, y0, _, y1), _) = ink(&draw(1.0));
|
||||
let plain = y1 - y0;
|
||||
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
|
||||
for scale in [1600.0, 4.0, 0.25] {
|
||||
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
|
||||
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
|
||||
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_blends_over_the_fill() {
|
||||
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
|
||||
// lightens a dark one; the overlay stays inside the shape.
|
||||
use deckcraft_model::style::FillOverlay;
|
||||
let draw = |base: [u8; 3], blend: &str| {
|
||||
let mut p = Presentation::default();
|
||||
let mut sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = Some(deckcraft_model::Effects {
|
||||
fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }),
|
||||
..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() });
|
||||
(img.pixel(200, 150), img.pixel(50, 50))
|
||||
};
|
||||
let (mult, outside) = draw([200, 100, 50], "mult");
|
||||
assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}");
|
||||
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
|
||||
let (screen, _) = draw([0, 0, 0], "screen");
|
||||
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
|
||||
let (over, _) = draw([200, 100, 50], "over");
|
||||
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_and_wavy_underlines_draw_their_pattern() {
|
||||
// Underlined spaces only, so the underline is the only ink: a red line under " ".
|
||||
let row_runs = |u: &str| {
|
||||
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text(" ");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(96.0);
|
||||
props.underline = Some(u.into());
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let red = |x: u32, y: u32| {
|
||||
let px = img.pixel(x, y);
|
||||
px[0] > 150 && px[1] < 120
|
||||
};
|
||||
// The ink's rows, and the number of separate red stretches along the most inked row.
|
||||
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
|
||||
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
|
||||
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
|
||||
(rows.len(), stretches)
|
||||
};
|
||||
let (sng_rows, sng_runs) = row_runs("sng");
|
||||
assert_eq!(sng_runs, 1, "a single line is one stretch");
|
||||
let (_, dotted_runs) = row_runs("dotted");
|
||||
assert!(dotted_runs > 10, "dots: {dotted_runs}");
|
||||
let (wavy_rows, _) = row_runs("wavy");
|
||||
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_vertical_text_stands_ideographs_upright() {
|
||||
// "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs
|
||||
// down the shape; in vert it lies on its side like Latin text.
|
||||
let ink = |dir: deckcraft_model::text::TextDir| {
|
||||
let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("一");
|
||||
t.body.vert = Some(dir);
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(80.0);
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
|
||||
for y in 0..540 {
|
||||
for x in 0..960 {
|
||||
if img.pixel(x, y)[0] < 128 {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}");
|
||||
(x1 - x0, y1 - y0)
|
||||
};
|
||||
let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical);
|
||||
assert!(w > h * 3, "eaVert: upright stroke {w}x{h}");
|
||||
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
|
||||
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Slide transition frames without a GPU: the layers of [`deckcraft_anim::transition_layers`]
|
||||
//! composited on the CPU from two rendered slide images. The slide show draws the same layers with
|
||||
//! the UI toolkit's meshes; this is the headless twin used by the CLI, comparisons and tests.
|
||||
|
||||
use deckcraft_anim::{Layer, Source, layer_mesh, transition_frame, uses_slide_parts};
|
||||
use deckcraft_model::{Presentation, ShapeId};
|
||||
|
||||
use crate::{Image, RenderOpts, ShapeState, SlidePart, render_blend, render_slide, render_slide_part};
|
||||
|
||||
/// The two slides of a transition rendered as background and content, for the transitions that
|
||||
/// move contents over the backgrounds ([`deckcraft_anim::uses_slide_parts`]). `None` parts of the
|
||||
/// old slide (the show's first slide) are black and nothing.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SlideParts {
|
||||
pub old_back: Option<Image>,
|
||||
pub old_content: Option<Image>,
|
||||
pub new_back: Image,
|
||||
pub new_content: Image,
|
||||
}
|
||||
|
||||
impl SlideParts {
|
||||
/// Render the parts of slides `old` and `new`.
|
||||
pub fn render(pres: &Presentation, old: Option<usize>, new: usize, opts: &RenderOpts) -> SlideParts {
|
||||
SlideParts {
|
||||
old_back: old.map(|i| render_slide_part(pres, i, SlidePart::Backdrop, opts)),
|
||||
old_content: old.map(|i| render_slide_part(pres, i, SlidePart::Content, opts)),
|
||||
new_back: render_slide_part(pres, new, SlidePart::Backdrop, opts),
|
||||
new_content: render_slide_part(pres, new, SlidePart::Content, opts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite transition `layers` over black into an image the size of `new`. `old` is the outgoing
|
||||
/// slide (`None`: black, as when the show starts); `parts` the slides' backgrounds and contents
|
||||
/// (without them, a background draws its whole slide and content draws nothing).
|
||||
pub fn composite_transition(old: Option<&Image>, new: &Image, parts: Option<&SlideParts>, layers: &[Layer]) -> Image {
|
||||
let (w, h) = (new.width, new.height);
|
||||
let n = (w as usize).saturating_mul(h as usize);
|
||||
let mut out = Image { width: w, height: h, pixels: [0u8, 0, 0, 255].repeat(n) };
|
||||
for l in layers {
|
||||
let src = match (l.source.is_part(), parts) {
|
||||
(true, None) => match l.source.whole() {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
},
|
||||
_ => l.source,
|
||||
};
|
||||
let tex = match (src, parts) {
|
||||
(Source::Old, _) => old.map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])),
|
||||
(Source::New, _) => Tex::Image(new),
|
||||
(Source::Black, _) => Tex::Solid([0.0, 0.0, 0.0]),
|
||||
(Source::White, _) => Tex::Solid([255.0, 255.0, 255.0]),
|
||||
(Source::OldBack, Some(p)) => p.old_back.as_ref().map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])),
|
||||
(Source::NewBack, Some(p)) => Tex::Image(&p.new_back),
|
||||
(Source::OldContent, Some(p)) => match &p.old_content {
|
||||
Some(i) => Tex::Image(i),
|
||||
None => continue,
|
||||
},
|
||||
(Source::NewContent, Some(p)) => Tex::Image(&p.new_content),
|
||||
(_, None) => continue,
|
||||
};
|
||||
draw_layer(&mut out, l, &tex);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The frame of slide `index`'s transition at progress `t` (0..1): the previous slide (black for
|
||||
/// the first) going out, slide `index` coming in, at `opts`' scale and size. Morph frames are
|
||||
/// rendered shape by shape like the show does.
|
||||
pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &RenderOpts) -> Image {
|
||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
||||
let (kind, option) = slide.transition.as_ref().map(|tr| (tr.kind.as_str(), tr.option.as_str())).unwrap_or(("none", ""));
|
||||
let prev = index.checked_sub(1).filter(|i| pres.slides.get(*i).is_some());
|
||||
if kind == "morph"
|
||||
&& let Some(from) = prev
|
||||
&& let Some(a) = pres.slides.get(from)
|
||||
{
|
||||
let tt = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if tt < 1.0 {
|
||||
let m = deckcraft_anim::morph_frame(pres, a, slide, tt, &|_| false);
|
||||
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
|
||||
let o = RenderOpts { state: Some(&f), ..copy_opts(opts) };
|
||||
let text: Vec<crate::TextMorph> =
|
||||
m.text.iter().map(|x| crate::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect();
|
||||
let paths: Vec<crate::PathMorph> = m
|
||||
.paths
|
||||
.iter()
|
||||
.map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })
|
||||
.collect();
|
||||
return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o);
|
||||
}
|
||||
}
|
||||
let new = render_slide(pres, index, opts);
|
||||
let old = prev.map(|i| render_slide(pres, i, opts));
|
||||
let parts = uses_slide_parts(kind, option).then(|| SlideParts::render(pres, prev, index, opts));
|
||||
let aspect = pres.slide_size.width / pres.slide_size.height.max(1.0);
|
||||
composite_transition(old.as_ref(), &new, parts.as_ref(), &transition_frame(kind, option, t, aspect))
|
||||
}
|
||||
|
||||
fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
|
||||
RenderOpts {
|
||||
scale: o.scale,
|
||||
edit: o.edit,
|
||||
show_hidden: o.show_hidden,
|
||||
state: o.state,
|
||||
skip: o.skip,
|
||||
skip_text: o.skip_text,
|
||||
gif_times: o.gif_times,
|
||||
threads: o.threads,
|
||||
offset: o.offset,
|
||||
size: o.size,
|
||||
clear: o.clear,
|
||||
date_text: o.date_text.clone(),
|
||||
now: o.now,
|
||||
}
|
||||
}
|
||||
|
||||
enum Tex<'a> {
|
||||
Image(&'a Image),
|
||||
/// Straight RGB, opaque.
|
||||
Solid([f32; 3]),
|
||||
}
|
||||
|
||||
impl Tex<'_> {
|
||||
/// Premultiplied RGBA (0..255) at texture coordinate (u, v), bilinear, clamped to the edge.
|
||||
fn sample(&self, u: f64, v: f64) -> [f32; 4] {
|
||||
match self {
|
||||
Tex::Solid(c) => [c[0], c[1], c[2], 255.0],
|
||||
Tex::Image(img) => {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let x = (u * w as f64 - 0.5).clamp(0.0, (w - 1) as f64);
|
||||
let y = (v * h as f64 - 0.5).clamp(0.0, (h - 1) as f64);
|
||||
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
|
||||
let (fx, fy) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||
let px = |xx: usize, yy: usize| -> [f32; 4] {
|
||||
let i = (yy * w + xx) * 4;
|
||||
match img.pixels.get(i..i + 4) {
|
||||
Some(p) => [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32],
|
||||
None => [0.0; 4],
|
||||
}
|
||||
};
|
||||
let (a, b, c, d) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1));
|
||||
let mut o = [0.0f32; 4];
|
||||
for k in 0..4 {
|
||||
let top = a[k] + (b[k] - a[k]) * fx;
|
||||
let bot = c[k] + (d[k] - c[k]) * fx;
|
||||
o[k] = top + (bot - top) * fy;
|
||||
}
|
||||
o
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_layer(out: &mut Image, l: &Layer, tex: &Tex) {
|
||||
if !(l.alpha.is_finite() && l.alpha > 0.0) {
|
||||
return;
|
||||
}
|
||||
let (verts, tris) = layer_mesh(l);
|
||||
let (w, h) = (out.width as f64, out.height as f64);
|
||||
for t in tris {
|
||||
let (Some(a), Some(b), Some(c)) = (verts.get(t[0] as usize), verts.get(t[1] as usize), verts.get(t[2] as usize)) else { continue };
|
||||
let p = [[a.pos[0] * w, a.pos[1] * h], [b.pos[0] * w, b.pos[1] * h], [c.pos[0] * w, c.pos[1] * h]];
|
||||
let uv = [a.uv, b.uv, c.uv];
|
||||
let al = [a.a, b.a, c.a].map(|x| if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 });
|
||||
if al.iter().all(|x| *x <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
fill_triangle(out, p, uv, al, tex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise one textured triangle: pixel centres inside it take the texture's colour at the
|
||||
/// barycentric texture coordinate, at the interpolated opacity, blended source-over.
|
||||
fn fill_triangle(out: &mut Image, p: [[f64; 2]; 3], uv: [[f64; 2]; 3], al: [f64; 3], tex: &Tex) {
|
||||
let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
|
||||
if !area.is_finite() || area.abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (out.width as i64, out.height as i64);
|
||||
let minx = p.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64;
|
||||
let maxx = (p.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w);
|
||||
let miny = p.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64;
|
||||
let maxy = (p.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(h);
|
||||
if minx >= maxx || miny >= maxy {
|
||||
return;
|
||||
}
|
||||
let edge = |a: [f64; 2], b: [f64; 2], x: f64, y: f64| (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
|
||||
let width = out.width as usize;
|
||||
for y in miny..maxy {
|
||||
// Sample a hair off the pixel centre so a point on an edge shared by two triangles falls
|
||||
// in exactly one of them (no double-blended seams).
|
||||
let fy = y as f64 + 0.5 + 2.31e-6;
|
||||
for x in minx..maxx {
|
||||
let fx = x as f64 + 0.5 + 1.17e-6;
|
||||
let w0 = edge(p[1], p[2], fx, fy) / area;
|
||||
let w1 = edge(p[2], p[0], fx, fy) / area;
|
||||
let w2 = edge(p[0], p[1], fx, fy) / area;
|
||||
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let u = w0 * uv[0][0] + w1 * uv[1][0] + w2 * uv[2][0];
|
||||
let v = w0 * uv[0][1] + w1 * uv[1][1] + w2 * uv[2][1];
|
||||
let alpha = (w0 * al[0] + w1 * al[1] + w2 * al[2]) as f32;
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let s = tex.sample(u, v);
|
||||
let i = (y as usize * width + x as usize) * 4;
|
||||
let Some(d) = out.pixels.get_mut(i..i + 4) else { continue };
|
||||
let sa = s[3] * alpha / 255.0;
|
||||
for k in 0..4 {
|
||||
let src = s[k] * alpha;
|
||||
let v = src + d[k] as f32 * (1.0 - sa);
|
||||
d[k] = v.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(w: u32, h: u32, c: [u8; 4]) -> Image {
|
||||
Image { width: w, height: h, pixels: c.repeat((w * h) as usize) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_composite_in_order() {
|
||||
let old = solid(40, 20, [255, 0, 0, 255]);
|
||||
let new = solid(40, 20, [0, 0, 255, 255]);
|
||||
let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 0.0));
|
||||
assert_eq!(img.pixel(20, 10), [255, 0, 0, 255]);
|
||||
let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 1.0));
|
||||
assert_eq!(img.pixel(20, 10), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_old_slide_is_black() {
|
||||
let new = solid(10, 10, [0, 255, 0, 255]);
|
||||
let img = composite_transition(None, &new, None, &[Layer::full(Source::Old, 1.0)]);
|
||||
assert_eq!(img.pixel(5, 5), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_alpha_blends() {
|
||||
let old = solid(10, 10, [200, 0, 0, 255]);
|
||||
let new = solid(10, 10, [0, 0, 200, 255]);
|
||||
let l = [Layer::full(Source::Old, 1.0), Layer::full(Source::New, 0.5)];
|
||||
let p = composite_transition(Some(&old), &new, None, &l).pixel(5, 5);
|
||||
assert!((p[0] as i32 - 100).abs() <= 1 && (p[2] as i32 - 100).abs() <= 1, "{p:?}");
|
||||
}
|
||||
|
||||
// ---------- golden-ish frames on synthetic slides ----------
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 90;
|
||||
const OLD: [u8; 4] = [220, 40, 40, 255];
|
||||
const NEW: [u8; 4] = [30, 60, 200, 255];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Is {
|
||||
Old,
|
||||
New,
|
||||
Black,
|
||||
White,
|
||||
/// Something between (a blend).
|
||||
Mix,
|
||||
}
|
||||
|
||||
fn classify(p: [u8; 4]) -> Is {
|
||||
let d = |c: [u8; 4]| (p[0] as i32 - c[0] as i32).abs() + (p[1] as i32 - c[1] as i32).abs() + (p[2] as i32 - c[2] as i32).abs();
|
||||
let near = 40;
|
||||
if d(OLD) < near {
|
||||
Is::Old
|
||||
} else if d(NEW) < near {
|
||||
Is::New
|
||||
} else if d([0, 0, 0, 255]) < near {
|
||||
Is::Black
|
||||
} else if d([255, 255, 255, 255]) < near {
|
||||
Is::White
|
||||
} else {
|
||||
// A face shaded in 3-D: a darker copy of one of the slides.
|
||||
let shaded = |c: [u8; 4]| {
|
||||
let dot = |a: [u8; 4], b: [u8; 4]| (0..3).map(|i| a[i] as f64 * b[i] as f64).sum::<f64>();
|
||||
let k = dot(p, c) / dot(c, c);
|
||||
(0.3..=1.05).contains(&k) && (0..3).all(|i| (p[i] as f64 - k * c[i] as f64).abs() < 14.0)
|
||||
};
|
||||
if shaded(OLD) {
|
||||
Is::Old
|
||||
} else if shaded(NEW) {
|
||||
Is::New
|
||||
} else {
|
||||
Is::Mix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(kind: &str, option: &str, t: f64) -> Image {
|
||||
let old = solid(W, H, OLD);
|
||||
let new = solid(W, H, NEW);
|
||||
composite_transition(Some(&old), &new, None, &transition_frame(kind, option, t, 16.0 / 9.0))
|
||||
}
|
||||
|
||||
fn at(img: &Image, x: f64, y: f64) -> Is {
|
||||
let px = ((x * W as f64) as u32).min(W - 1);
|
||||
let py = ((y * H as f64) as u32).min(H - 1);
|
||||
classify(img.pixel(px, py))
|
||||
}
|
||||
|
||||
/// (kind, option, t, x, y, what PowerPoint shows there), read off PowerPoint's own frames.
|
||||
const PROBES: &[(&str, &str, f64, f64, f64, Is)] = &[
|
||||
("fade", "smoothly", 0.1, 0.5, 0.5, Is::Old),
|
||||
("fade", "smoothly", 0.5, 0.5, 0.5, Is::Mix),
|
||||
("fade", "smoothly", 0.9, 0.5, 0.5, Is::New),
|
||||
("fade", "throughBlack", 0.5, 0.5, 0.5, Is::Black),
|
||||
("cut", "throughBlack", 0.3, 0.5, 0.5, Is::Black),
|
||||
("cut", "", 0.3, 0.5, 0.5, Is::New),
|
||||
// Push / Cover / Uncover: "l" moves things toward the left; the new slide comes from the right.
|
||||
("push", "l", 0.25, 0.97, 0.5, Is::New),
|
||||
("push", "l", 0.25, 0.1, 0.5, Is::Old),
|
||||
("push", "r", 0.25, 0.03, 0.5, Is::New),
|
||||
("push", "u", 0.25, 0.5, 0.97, Is::New),
|
||||
("push", "d", 0.25, 0.5, 0.03, Is::New),
|
||||
("cover", "l", 0.5, 0.75, 0.5, Is::New),
|
||||
("cover", "l", 0.5, 0.25, 0.5, Is::Old),
|
||||
("cover", "rd", 0.5, 0.05, 0.05, Is::New),
|
||||
("cover", "rd", 0.2, 0.97, 0.97, Is::Old),
|
||||
("uncover", "l", 0.5, 0.25, 0.5, Is::Old),
|
||||
("uncover", "l", 0.5, 0.75, 0.5, Is::New),
|
||||
("uncover", "lu", 0.5, 0.5, 0.5, Is::New),
|
||||
// Wipe "r" sweeps rightward with a soft edge; "u" upward; diagonals from a corner.
|
||||
("wipe", "r", 0.5, 0.05, 0.5, Is::New),
|
||||
("wipe", "r", 0.5, 0.97, 0.5, Is::Old),
|
||||
("wipe", "l", 0.5, 0.97, 0.5, Is::New),
|
||||
("wipe", "u", 0.5, 0.5, 0.97, Is::New),
|
||||
("wipe", "u", 0.5, 0.5, 0.03, Is::Old),
|
||||
("wipe", "d", 0.5, 0.5, 0.03, Is::New),
|
||||
("wipe", "ru", 0.5, 0.03, 0.97, Is::New),
|
||||
("wipe", "ru", 0.5, 0.97, 0.03, Is::Old),
|
||||
("wipe", "r", 0.5, 0.55, 0.5, Is::Mix),
|
||||
// Split: Out opens from the middle, In closes from the edges.
|
||||
("split", "horzOut", 0.5, 0.5, 0.5, Is::New),
|
||||
("split", "horzOut", 0.5, 0.5, 0.02, Is::Old),
|
||||
("split", "horzIn", 0.5, 0.5, 0.02, Is::New),
|
||||
("split", "horzIn", 0.5, 0.5, 0.5, Is::Old),
|
||||
("split", "vertOut", 0.5, 0.5, 0.5, Is::New),
|
||||
("split", "vertOut", 0.5, 0.02, 0.5, Is::Old),
|
||||
("split", "vertIn", 0.5, 0.02, 0.5, Is::New),
|
||||
// Shape: grows from the centre; In shrinks the old slide over the first half, Out grows
|
||||
// the new one over the second.
|
||||
("shape", "circle", 0.5, 0.5, 0.5, Is::New),
|
||||
("shape", "circle", 0.5, 0.02, 0.03, Is::Old),
|
||||
("shape", "diamond", 0.5, 0.5, 0.5, Is::New),
|
||||
("shape", "diamond", 0.5, 0.02, 0.03, Is::Old),
|
||||
("shape", "plus", 0.5, 0.5, 0.05, Is::New),
|
||||
("shape", "plus", 0.5, 0.05, 0.05, Is::Old),
|
||||
("shape", "in", 0.25, 0.5, 0.5, Is::Old),
|
||||
("shape", "in", 0.25, 0.05, 0.05, Is::New),
|
||||
("shape", "in", 0.75, 0.5, 0.5, Is::New),
|
||||
("shape", "out", 0.25, 0.5, 0.5, Is::Old),
|
||||
("shape", "out", 0.75, 0.5, 0.5, Is::New),
|
||||
("shape", "out", 0.75, 0.05, 0.05, Is::Old),
|
||||
// Flash: white in the middle.
|
||||
("flash", "", 0.3, 0.5, 0.5, Is::White),
|
||||
// Clock: a hand sweeping from 12 o'clock.
|
||||
("clock", "clockwise", 0.25, 0.6, 0.15, Is::New),
|
||||
("clock", "clockwise", 0.25, 0.4, 0.15, Is::Old),
|
||||
("clock", "counterClockwise", 0.25, 0.4, 0.15, Is::New),
|
||||
("clock", "counterClockwise", 0.25, 0.6, 0.15, Is::Old),
|
||||
("clock", "wedge", 0.5, 0.5, 0.05, Is::New),
|
||||
("clock", "wedge", 0.5, 0.5, 0.95, Is::Old),
|
||||
// Ripple: from the centre, or from the corner opposite the option.
|
||||
("ripple", "center", 0.5, 0.5, 0.5, Is::New),
|
||||
("ripple", "center", 0.5, 0.02, 0.03, Is::Old),
|
||||
("ripple", "ru", 0.4, 0.03, 0.97, Is::New),
|
||||
("ripple", "ru", 0.4, 0.97, 0.03, Is::Old),
|
||||
// Comb: the old slide's bands slide off, the bottom band first.
|
||||
("comb", "horz", 0.5, 0.5, 0.97, Is::New),
|
||||
("comb", "horz", 0.5, 0.5, 0.03, Is::Old),
|
||||
// Glitter "l": the sweep starts on the left.
|
||||
("glitter", "l", 0.45, 0.03, 0.5, Is::New),
|
||||
("glitter", "l", 0.45, 0.97, 0.5, Is::Old),
|
||||
("glitter", "r", 0.45, 0.97, 0.5, Is::New),
|
||||
// Vortex "l": the old slide whirls off toward the left, its right side first.
|
||||
("vortex", "l", 0.25, 0.03, 0.5, Is::Old),
|
||||
("vortex", "l", 0.97, 0.03, 0.5, Is::New),
|
||||
// Cube / Box "l": the new face comes in from the right.
|
||||
("cube", "l", 0.5, 0.85, 0.5, Is::New),
|
||||
("cube", "l", 0.5, 0.15, 0.5, Is::Old),
|
||||
("cube", "r", 0.5, 0.15, 0.5, Is::New),
|
||||
("box", "l", 0.25, 0.97, 0.5, Is::New),
|
||||
("box", "l", 0.25, 0.3, 0.5, Is::Old),
|
||||
("cube", "u", 0.5, 0.5, 0.9, Is::New),
|
||||
("cube", "d", 0.5, 0.5, 0.1, Is::New),
|
||||
// Switch "l": the old card is pulled out to the left, the new one to the right.
|
||||
("switch", "l", 0.25, 0.97, 0.5, Is::New),
|
||||
("switch", "l", 0.25, 0.3, 0.5, Is::Old),
|
||||
("switch", "r", 0.25, 0.03, 0.5, Is::New),
|
||||
// Gallery "l": the slides travel left along the wall.
|
||||
("gallery", "l", 0.6, 0.9, 0.5, Is::New),
|
||||
// Flip: the old face, then the new one.
|
||||
("flip", "l", 0.2, 0.5, 0.5, Is::Old),
|
||||
("flip", "l", 0.8, 0.5, 0.5, Is::New),
|
||||
// Doors open in the middle, the new slide coming forward between them.
|
||||
("doors", "vert", 0.9, 0.5, 0.5, Is::New),
|
||||
// Fall Over: the old slide tips backward, uncovering the new one at the top first.
|
||||
("fallOver", "l", 0.6, 0.5, 0.03, Is::New),
|
||||
("fallOver", "l", 0.6, 0.5, 0.97, Is::Old),
|
||||
// Peel Off "l": from the bottom-right corner.
|
||||
("peelOff", "l", 0.4, 0.98, 0.98, Is::New),
|
||||
("peelOff", "l", 0.4, 0.03, 0.03, Is::Old),
|
||||
("peelOff", "r", 0.4, 0.02, 0.98, Is::New),
|
||||
// Page Curl: the far half rolls up first.
|
||||
("pageCurlDouble", "l", 0.5, 0.9, 0.5, Is::New),
|
||||
("pageCurlDouble", "l", 0.5, 0.1, 0.5, Is::Old),
|
||||
("pageCurlDouble", "r", 0.5, 0.1, 0.5, Is::New),
|
||||
("pageCurlDouble", "singleL", 0.6, 0.95, 0.5, Is::New),
|
||||
("pageCurlDouble", "singleL", 0.6, 0.05, 0.5, Is::Old),
|
||||
// Airplane "l": a dart with its nose to the right, hanging low.
|
||||
("airplane", "l", 0.5, 0.15, 0.1, Is::New),
|
||||
("airplane", "l", 0.5, 0.8, 0.68, Is::Old),
|
||||
("airplane", "r", 0.5, 0.2, 0.68, Is::Old),
|
||||
// Origami "l": the bird flies off to the left.
|
||||
("origami", "l", 0.75, 0.97, 0.5, Is::New),
|
||||
// Fracture: shards fall, the top first.
|
||||
("fracture", "", 0.6, 0.5, 0.05, Is::New),
|
||||
// Crush: the old slide crumples toward the centre.
|
||||
("crush", "", 0.5, 0.03, 0.03, Is::New),
|
||||
("crush", "", 0.5, 0.45, 0.5, Is::Old),
|
||||
// Curtains part in the middle.
|
||||
("curtains", "", 0.4, 0.5, 0.5, Is::New),
|
||||
("curtains", "", 0.4, 0.02, 0.5, Is::Old),
|
||||
// Zoom and Rotate: the new slide spins in from the centre over the old one.
|
||||
("zoom", "rotate", 0.2, 0.03, 0.03, Is::Old),
|
||||
("zoom", "out", 0.8, 0.5, 0.5, Is::New),
|
||||
("dissolve", "", 0.02, 0.5, 0.5, Is::Old),
|
||||
("dissolve", "", 0.99, 0.5, 0.5, Is::New),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn frames_match_powerpoint_probes() {
|
||||
let mut bad = Vec::new();
|
||||
for (kind, option, t, x, y, want) in PROBES {
|
||||
let got = at(&frame(kind, option, *t), *x, *y);
|
||||
if got != *want {
|
||||
bad.push(format!("{kind}/{option} t={t} ({x},{y}): want {want:?}, got {got:?}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_transition_starts_old_and_ends_new() {
|
||||
for (kind, _, _, _, opts) in deckcraft_model::anim::TRANSITIONS {
|
||||
let mut options: Vec<&str> = opts.to_vec();
|
||||
options.push("");
|
||||
for o in options {
|
||||
assert_eq!(at(&frame(kind, o, 0.0), 0.5, 0.5), Is::Old, "{kind}/{o}");
|
||||
assert_eq!(at(&frame(kind, o, 1.0), 0.5, 0.5), Is::New, "{kind}/{o}");
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
let img = frame(kind, o, t);
|
||||
assert_eq!((img.width, img.height), (W, H));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_content_moves_contents_over_backgrounds() {
|
||||
// Old: red background with a white content square in the middle; new: blue background
|
||||
// with a yellow square.
|
||||
let content = |c: [u8; 4]| {
|
||||
let mut img = solid(W, H, [0, 0, 0, 0]);
|
||||
for y in 35..55u32 {
|
||||
for x in 70..90u32 {
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
if let Some(p) = img.pixels.get_mut(i..i + 4) {
|
||||
p.copy_from_slice(&c);
|
||||
}
|
||||
}
|
||||
}
|
||||
img
|
||||
};
|
||||
let parts = SlideParts {
|
||||
old_back: Some(solid(W, H, OLD)),
|
||||
old_content: Some(content([255, 255, 255, 255])),
|
||||
new_back: solid(W, H, NEW),
|
||||
new_content: content([255, 220, 0, 255]),
|
||||
};
|
||||
let old = solid(W, H, OLD);
|
||||
let new = solid(W, H, NEW);
|
||||
let f = |kind: &str, o: &str, t: f64| composite_transition(Some(&old), &new, Some(&parts), &transition_frame(kind, o, t, 16.0 / 9.0));
|
||||
// Pan left at the middle: the backgrounds blend, the old square has moved left.
|
||||
let img = f("pan", "l", 0.5);
|
||||
let bg = img.pixel(5, 5);
|
||||
assert!(bg[0] > 60 && bg[2] > 60, "{bg:?}");
|
||||
assert_eq!(img.pixel(80, 45), bg, "the middle shows background only");
|
||||
// Pan right: the new square arrives from the left.
|
||||
let img = f("pan", "r", 0.6);
|
||||
assert!((0..80).any(|x| {
|
||||
let p = img.pixel(x, 45);
|
||||
p[0] > 200 && p[1] > 180 && p[2] < 80
|
||||
}));
|
||||
// Reveal: the new content is in place near the end.
|
||||
let p = f("reveal", "smoothlyLeft", 0.97).pixel(80, 45);
|
||||
assert!(p[0] > 200 && p[1] > 180, "{p:?}");
|
||||
// Without parts the dynamic transitions still draw whole slides.
|
||||
let img = composite_transition(Some(&old), &new, None, &transition_frame("pan", "l", 0.5, 16.0 / 9.0));
|
||||
assert_ne!(img.pixel(80, 45), [0, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
@@ -140,10 +140,12 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
|
||||
}
|
||||
let y = (d.rect.y0 + d.rect.y1) / 2.0;
|
||||
let Some(li) = line_of(l, d.para, y) else { return };
|
||||
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
ctx.fill_path(&path);
|
||||
for r in d.pieces() {
|
||||
let path = warp_path(&r.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
};
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
|
||||
+178
-1
@@ -6,7 +6,7 @@
|
||||
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
|
||||
//! mirrorable characters in right-to-left items with their mirrored glyphs.
|
||||
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, CS, EN, ES, ET, FSI, L, LRE, LRI, LRO, NSM, ON, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::{BidiInfo, Level, bidi_class};
|
||||
|
||||
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
|
||||
@@ -27,6 +27,149 @@ pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
|
||||
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
|
||||
}
|
||||
|
||||
/// Is `lang` (BCP 47) a language written right to left? Its runs resolve neutral characters and
|
||||
/// digits the right-to-left way (see [`levels_lang`]).
|
||||
pub(crate) fn is_rtl_lang(lang: &str) -> bool {
|
||||
let primary = lang.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
matches!(primary.as_str(), "ar" | "he" | "iw" | "fa" | "ur" | "yi" | "ji" | "ps" | "sd" | "ug" | "dv" | "syr" | "ckb" | "arc")
|
||||
}
|
||||
|
||||
/// How a character resolves before levels are assigned.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Dir {
|
||||
L,
|
||||
R,
|
||||
/// A number inside right-to-left text: kept left to right, placed with the right-to-left text.
|
||||
Num,
|
||||
/// Not resolved yet (neutral).
|
||||
Open,
|
||||
}
|
||||
|
||||
/// Embedding levels as PowerPoint resolves them: the language of each character's run
|
||||
/// (`rtl_lang`, from `a:rPr/@lang`) decides how neutral characters and digits go, where UAX #9
|
||||
/// would look at the surrounding text only. Observed on desktop PowerPoint:
|
||||
/// - in a run of a left-to-right language (or none), digits and punctuation are left to right,
|
||||
/// and a space takes the direction of the character before it (so the space after a Hebrew word
|
||||
/// travels with that word);
|
||||
/// - in a run of a right-to-left language (Hebrew, Arabic…), punctuation and spaces between text
|
||||
/// of one direction take it, everywhere else they are right to left; digits are a number in
|
||||
/// right-to-left text unless left-to-right text precedes them.
|
||||
///
|
||||
/// Paragraphs with explicit embeddings or isolates keep the plain algorithm ([`levels`]).
|
||||
pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[bool]) -> Vec<u8> {
|
||||
let base = u8::from(rtl);
|
||||
let any_rtl_lang = rtl_lang.iter().any(|r| *r);
|
||||
if !rtl && !any_rtl_lang && !needs_bidi(chars) {
|
||||
return vec![0; chars.len()];
|
||||
}
|
||||
let classes: Vec<BidiClass> = chars.iter().map(|c| bidi_class(*c)).collect();
|
||||
if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) {
|
||||
return levels(text, chars, rtl);
|
||||
}
|
||||
let n = chars.len();
|
||||
let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false);
|
||||
let sos = if rtl { Dir::R } else { Dir::L };
|
||||
// W1: combining marks take the class of the character they sit on.
|
||||
let mut cls = classes;
|
||||
for i in 0..n {
|
||||
if cls.get(i) == Some(&NSM) {
|
||||
let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) };
|
||||
if let Some(c) = cls.get_mut(i) {
|
||||
*c = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dir = vec![Dir::Open; n];
|
||||
// Strong letters and digits.
|
||||
let mut last_strong: Option<BidiClass> = None;
|
||||
for i in 0..n {
|
||||
let c = cls.get(i).copied().unwrap_or(ON);
|
||||
let d = match c {
|
||||
L => Dir::L,
|
||||
R | AL => Dir::R,
|
||||
AN => Dir::Num,
|
||||
EN if !rl(i) => Dir::L,
|
||||
// W7: European digits after left-to-right text are left to right.
|
||||
EN if last_strong == Some(L) => Dir::L,
|
||||
EN => Dir::Num,
|
||||
_ => Dir::Open,
|
||||
};
|
||||
if matches!(c, L | R | AL) {
|
||||
last_strong = Some(c);
|
||||
}
|
||||
if let Some(x) = dir.get_mut(i) {
|
||||
*x = d;
|
||||
}
|
||||
}
|
||||
// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next
|
||||
// to them, belong to the number.
|
||||
for i in 0..n {
|
||||
if !rl(i) || dir.get(i) != Some(&Dir::Open) {
|
||||
continue;
|
||||
}
|
||||
let c = cls.get(i).copied().unwrap_or(ON);
|
||||
let num = |j: Option<usize>| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num);
|
||||
let joined = match c {
|
||||
ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)),
|
||||
ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))),
|
||||
_ => false,
|
||||
};
|
||||
if joined && let Some(x) = dir.get_mut(i) {
|
||||
*x = Dir::Num;
|
||||
}
|
||||
}
|
||||
// Punctuation in left-to-right runs is left to right.
|
||||
for i in 0..n {
|
||||
if !rl(i)
|
||||
&& dir.get(i) == Some(&Dir::Open)
|
||||
&& !matches!(cls.get(i), Some(WS | S))
|
||||
&& let Some(x) = dir.get_mut(i)
|
||||
{
|
||||
*x = Dir::L;
|
||||
}
|
||||
}
|
||||
// Neutrals in right-to-left runs: the direction around them when both sides agree, else
|
||||
// right to left. Unresolved spaces of left-to-right runs are looked through.
|
||||
let strong_at = |dir: &[Dir], j: usize| match dir.get(j) {
|
||||
Some(Dir::L) => Some(Dir::L),
|
||||
Some(Dir::R | Dir::Num) => Some(Dir::R),
|
||||
_ => None,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if !(rl(i) && dir.get(i) == Some(&Dir::Open)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) {
|
||||
i += 1;
|
||||
}
|
||||
let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
|
||||
let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
|
||||
let d = if before == after { before } else { Dir::R };
|
||||
for x in dir.get_mut(start..i).into_iter().flatten() {
|
||||
*x = d;
|
||||
}
|
||||
}
|
||||
// Spaces in left-to-right runs follow the character before them.
|
||||
let mut prev = Dir::L;
|
||||
for d in dir.iter_mut() {
|
||||
if *d == Dir::Open {
|
||||
*d = prev;
|
||||
}
|
||||
prev = if *d == Dir::Num { Dir::R } else { *d };
|
||||
}
|
||||
dir.iter()
|
||||
.map(|d| match (d, base % 2 == 1) {
|
||||
(Dir::L, false) | (Dir::R, true) => base,
|
||||
(Dir::L, true) | (Dir::R, false) | (Dir::Open, _) => base + 1,
|
||||
(Dir::Num, false) => base + 2,
|
||||
(Dir::Num, true) => base + 1,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
|
||||
/// segment/paragraph separator.
|
||||
fn l1_whitespace(c: BidiClass) -> bool {
|
||||
@@ -131,6 +274,40 @@ mod tests {
|
||||
assert_eq!(lv("ab", true), vec![2, 2]);
|
||||
}
|
||||
|
||||
/// Visual string of `s` laid out on one line with every character in a run of language
|
||||
/// direction `rtl_lang`, as PowerPoint orders it.
|
||||
fn vis_lang(s: &str, rtl: bool, rtl_lang: bool) -> String {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let l = levels_lang(s, &chars, rtl, &vec![rtl_lang; chars.len()]);
|
||||
visual_order(&line_levels(&chars, &l, u8::from(rtl))).iter().map(|&i| chars[i]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_language_decides_neutrals_like_powerpoint() {
|
||||
// Observed in PowerPoint with each space highlighted. An English run: the space after a
|
||||
// Hebrew word goes with it, punctuation is left to right.
|
||||
assert_eq!(vis_lang("x אב גד y", false, false), "x דג באy");
|
||||
assert_eq!(vis_lang("אב גד x y", false, false), " דג באx y");
|
||||
assert_eq!(vis_lang("x אב, גד! y", false, false), "x בא, דג! y");
|
||||
// Digits after Hebrew stay left to right in an English run.
|
||||
assert_eq!(vis_lang("אב 12", false, false), " בא12");
|
||||
// A Hebrew run: neutrals between different directions are right to left, and digits are
|
||||
// a number inside the right-to-left text.
|
||||
assert_eq!(vis_lang("אב גד x y", false, true), " דג באx y");
|
||||
assert_eq!(vis_lang("12 אב 34 x", false, true), " 34 בא 12x");
|
||||
assert_eq!(vis_lang("x אב, גד! y", false, true), "x !דג ,בא y");
|
||||
// Between two left-to-right words a Hebrew run's space stays left to right.
|
||||
assert_eq!(vis_lang("Version 2.0 ok", false, true), "Version 2.0 ok");
|
||||
// An English sentence in a right-to-left paragraph keeps its full stop at its end.
|
||||
assert_eq!(vis_lang("Hello world.", true, false), "Hello world.");
|
||||
// Right-to-left runs keep W4: "45.6%" is one number.
|
||||
assert_eq!(vis_lang("אב 45.6% גד", false, true), "דג 45.6% בא");
|
||||
// Without right-to-left text or runs, the fast path.
|
||||
let chars: Vec<char> = "plain, text".chars().collect();
|
||||
assert_eq!(levels_lang("plain, text", &chars, false, &[false; 11]), vec![0; 11]);
|
||||
assert!(is_rtl_lang("he-IL") && is_rtl_lang("ar") && is_rtl_lang("FA-ir") && !is_rtl_lang("en-US") && !is_rtl_lang(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l1_resets_trailing_whitespace_and_tabs() {
|
||||
let chars: Vec<char> = "ab\tcd ".chars().collect();
|
||||
|
||||
+722
-93
File diff suppressed because it is too large
Load Diff
+471
-3
@@ -101,11 +101,55 @@ fn pairs_are_kerned_only_from_the_kern_size() {
|
||||
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
|
||||
}
|
||||
|
||||
/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run
|
||||
/// has character spacing `spc`.
|
||||
fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 {
|
||||
let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
let mut second = t.paragraphs[0].clone();
|
||||
second.runs[0].props.spacing = Some(spc);
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
for r in &mut para.runs {
|
||||
r.props.font = Some(family.into());
|
||||
r.props.size = Some(40.0);
|
||||
r.props.kern = Some(12.0);
|
||||
}
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
l.lines[0].extent.1 - l.lines[0].extent.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_spacing_in_the_body_keeps_kerning() {
|
||||
let kerns = |f: &FontFace| {
|
||||
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
|
||||
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
|
||||
};
|
||||
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
|
||||
eprintln!("skipped: no font that kerns AVATAR");
|
||||
return;
|
||||
};
|
||||
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
|
||||
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
|
||||
assert!(on < off);
|
||||
// Spacing elsewhere in the box leaves the pairs kerned (Calibri, Segoe UI, Times New Roman in
|
||||
// PowerPoint; only Arial drops them).
|
||||
for spc in [0.0, 0.5, -1.0, 0.6, 2.0] {
|
||||
let w = kerned_width_beside_spacing(&family, spc);
|
||||
assert!((w - on).abs() < 0.01, "{family} spc {spc}: {w} vs {on}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_stay_for_scripts_that_need_them() {
|
||||
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
|
||||
let cells =
|
||||
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
|
||||
let cells = |s: &str| {
|
||||
s.chars()
|
||||
.map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true, upright: false })
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
|
||||
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
|
||||
// Arabic needs its ligatures; emoji sequences are joined by them.
|
||||
@@ -264,7 +308,11 @@ fn arabic_face() -> Option<Arc<FontFace>> {
|
||||
|
||||
#[test]
|
||||
fn rtl_mixed_string_is_reordered_per_line() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
// An Arabic run (PowerPoint tags typed Arabic `ar-…`): its digits and brackets go with the
|
||||
// right-to-left text.
|
||||
let mut run = Run::new(MIXED);
|
||||
run.props.lang = Some("ar-SA".into());
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let line = &l.lines[0];
|
||||
@@ -293,6 +341,20 @@ fn rtl_mixed_string_is_reordered_per_line() {
|
||||
assert_eq!(line.levels[10], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_english_run_keeps_digits_and_brackets_left_to_right() {
|
||||
// The same text in a run without a right-to-left language: PowerPoint keeps "2024 (Retrait) "
|
||||
// one left-to-right stretch, its number on the left.
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
|
||||
assert!(mid(0) > mid(10), "first Arabic word right of the Latin");
|
||||
assert!(mid(4) < mid(10), "number left of the Latin word");
|
||||
assert!(mid(9) < mid(10) && mid(17) > mid(16), "brackets around Retrait, unmirrored");
|
||||
assert!(mid(19) < mid(4), "last Arabic word on the left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
|
||||
@@ -748,3 +810,409 @@ fn an_installed_symbol_font_draws_its_own_bullet() {
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_char_maps_symbol_font_codes() {
|
||||
// Inter is not a symbol font, so the codes map to look-alikes.
|
||||
let face = FontDb::global().face("Inter", "Regular");
|
||||
assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font");
|
||||
assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}'));
|
||||
assert_eq!(symbol_char(&face, "Webdings", '\u{F06E}'), Some('\u{25CF}'), "the private-use form of the code");
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}'));
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_characters_draw_in_the_sym_font() {
|
||||
// PowerPoint stores a Symbol-font character as U+F0xx with `<a:sym typeface="Symbol"/>`:
|
||||
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
|
||||
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
|
||||
let symbol = "Symbol";
|
||||
if !FontDb::global().has_family(symbol) {
|
||||
eprintln!("skipped: no Symbol font installed");
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.font = Some("Arial".into());
|
||||
para.runs[0].props.font_sym = Some(symbol.into());
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let families: Vec<(String, (usize, usize))> = l.runs.iter().map(|r| (r.face.family.clone(), r.chars)).collect();
|
||||
assert!(families.iter().any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1), "{families:?}");
|
||||
// The letters around it keep the run's font.
|
||||
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_capitals_draw_smaller_than_the_capitals_beside_them() {
|
||||
let (p, mut sh) = setup("HxHx", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.caps = Some(Caps::Small);
|
||||
para.runs[0].props.size = Some(40.0);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect();
|
||||
// H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter).
|
||||
assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitals_keep_characters_whose_capital_is_a_sequence() {
|
||||
assert_eq!(caps_char('a'), 'A');
|
||||
assert_eq!(caps_char('dž'), 'DŽ');
|
||||
// PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps.
|
||||
assert_eq!(caps_char('ß'), 'ß');
|
||||
assert_eq!(caps_char('\u{FB01}'), '\u{FB01}');
|
||||
assert_eq!(caps_char('ʼn'), 'ʼn');
|
||||
let (p, mut sh) = setup("aß", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.runs[0].props.caps = Some(Caps::All);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let face = &r.face;
|
||||
if face.covers('ß') {
|
||||
assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_text_is_faded_once() {
|
||||
let (p, mut sh) = setup("Faded", 2000.0, 300.0);
|
||||
let run = &mut sh.text.as_mut().unwrap().paragraphs[0].runs[0];
|
||||
run.props.fill = Some(deckcraft_model::Fill::solid(
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)).with(deckcraft_color::ColorTransform::Alpha(40000)),
|
||||
));
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
// 60 % transparency: 40 % opaque in all, not 40 % of 40 %.
|
||||
let opacity = r.color.a as f64 / 255.0 * r.alpha;
|
||||
assert!((opacity - 0.4).abs() < 0.01, "{opacity}");
|
||||
}
|
||||
|
||||
/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent.
|
||||
fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout {
|
||||
let (p, mut sh) = setup(text, w, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(18.0);
|
||||
}
|
||||
let pp = &mut para.props;
|
||||
pp.bullet = Some(Bullet::None);
|
||||
pp.margin_left = Some(0.0);
|
||||
pp.indent = Some(0.0);
|
||||
pp.default_tab = Some(72.0);
|
||||
pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect());
|
||||
lay(&p, &sh, w, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_stops_align_the_text_after_them() {
|
||||
let x0 = |l: &TextLayout| l.inner.x0;
|
||||
// Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop.
|
||||
let l = tabbed("a\tright", &[(200.0, "r")], 600.0);
|
||||
assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent);
|
||||
let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0);
|
||||
let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1);
|
||||
assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01);
|
||||
let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0);
|
||||
assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop");
|
||||
// Text without a point ends at a decimal stop.
|
||||
let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0);
|
||||
assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01);
|
||||
// Text wider than the room before a right stop starts where the tab is; it never moves back.
|
||||
let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0);
|
||||
assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01);
|
||||
// A left stop and then default stops every 72 pt.
|
||||
let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0);
|
||||
assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01);
|
||||
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_past_the_end_moves_to_the_next_line() {
|
||||
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
|
||||
// line, so the text after it lands on that line's first stop.
|
||||
let l = tabbed("abcdefghij\tk", &[], 130.0);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::<Vec<_>>());
|
||||
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Underline decorations of "ab cd" underlined `u` at 40 pt.
|
||||
fn underline_decos(u: &str) -> (TextLayout, Vec<Deco>) {
|
||||
let (p, mut sh) = setup("ab cd", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(40.0);
|
||||
para.runs[0].props.underline = Some(u.into());
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let d = l.decos.iter().filter(|d| !d.behind).cloned().collect();
|
||||
(l, d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underlines_sit_at_the_font_underline_in_their_style() {
|
||||
let (l, sng) = underline_decos("sng");
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let k = 40.0 / r.face.upem;
|
||||
let base = l.lines[0].baseline;
|
||||
assert_eq!(sng.len(), 1);
|
||||
assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position");
|
||||
assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01);
|
||||
let t = sng[0].rect.height();
|
||||
// Double: two thin lines spanning from one thickness above the single line to its bottom.
|
||||
let (_, dbl) = underline_decos("dbl");
|
||||
assert_eq!(dbl.len(), 2);
|
||||
assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01);
|
||||
// Heavy is thicker; dotted is dots as long as the line is thick.
|
||||
let (_, heavy) = underline_decos("heavy");
|
||||
assert!(heavy[0].rect.height() > t * 1.4);
|
||||
let (_, dotted) = underline_decos("dotted");
|
||||
assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot));
|
||||
let pieces = dotted[0].pieces();
|
||||
assert!(pieces.len() > 3);
|
||||
assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01);
|
||||
// Wavy: a zigzag between the box's top and bottom.
|
||||
let (_, wavy) = underline_decos("wavy");
|
||||
let (pts, w) = wavy[0].wave().expect("a wave");
|
||||
assert!(pts.len() > 4 && w > 0.0 && w < t);
|
||||
assert!(pts[0].y < pts[1].y);
|
||||
// "words" underlines the spaces too, as PowerPoint draws it: one line.
|
||||
let (_, words) = underline_decos("words");
|
||||
assert_eq!(words.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_break_after_a_zero_width_space_or_before_an_em_dash() {
|
||||
let (allowed, _) = break_opportunities("ab\u{200B}cd", false);
|
||||
assert!(!allowed[3], "PowerPoint does not break after a zero-width space");
|
||||
let (allowed, _) = break_opportunities("ab\u{2014}cd", false);
|
||||
assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() {
|
||||
let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(24.0);
|
||||
let l = lay(&p, &sh, 150.0, 300.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first = &l.lines[0];
|
||||
let shy = first.end - 1;
|
||||
assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::<Vec<_>>());
|
||||
let (x0, x1) = first.edges[shy - first.start];
|
||||
assert!(x1 - x0 > 2.0, "the hyphen takes room");
|
||||
let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen");
|
||||
assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-'));
|
||||
// Soft hyphens draw only where a line ends; in a wide box, nowhere.
|
||||
let ends: Vec<usize> = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect();
|
||||
for r in &l.runs {
|
||||
for (g, c) in r.glyphs.iter().zip(&r.cells) {
|
||||
assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn");
|
||||
}
|
||||
}
|
||||
let wide = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(wide.lines.len(), 1);
|
||||
assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-')))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() {
|
||||
// "っ" and "ー" never start a line: the character before them moves down too.
|
||||
let w = one_line("あいうえお") + 0.5;
|
||||
for (text, first) in [("あいうえおっかき", "あいうえ"), ("あいうえおーかき", "あいうえ"), ("あいうえおかき", "あいうえお")]
|
||||
{
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], first, "{text}: {lines:?}");
|
||||
}
|
||||
// A closing bracket doesn't hang like "。" does: "「。」" overflowing by "」" moves down whole.
|
||||
let w = one_line("あいうえ「。") + 0.5;
|
||||
let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], "あいうえ", "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonumber_schemes_follow_powerpoint() {
|
||||
// As PowerPoint's export draws them (start values as in the probe deck).
|
||||
for (scheme, n, want) in [
|
||||
("ea1ChsPlain", 4, "四"),
|
||||
("ea1ChsPeriod", 10, "十."),
|
||||
("ea1ChtPeriod", 21, "二十一."),
|
||||
("ea1JpnKorPeriod", 10, "一〇."),
|
||||
("ea1JpnChsDbPeriod", 13, "一三."),
|
||||
("arabicDbPlain", 10, "10"),
|
||||
("arabicDbPeriod", 13, "13."),
|
||||
("circleNumDbPlain", 13, "⑬"),
|
||||
("circleNumWdWhitePlain", 13, "③"),
|
||||
("circleNumWdBlackPlain", 2, "❷"),
|
||||
("thaiAlphaPeriod", 1, "ก."),
|
||||
("thaiAlphaParenR", 4, "ง)"),
|
||||
("thaiAlphaParenBoth", 7, "(ช)"),
|
||||
("thaiNumPeriod", 10, "๑๐."),
|
||||
("hindiAlphaPeriod", 4, "ई."),
|
||||
("hindiAlpha1Period", 1, "क."),
|
||||
("hindiNumParenR", 13, "१३)"),
|
||||
("hebrew2Minus", 1, "א-"),
|
||||
("arabic1Minus", 13, "ش-"),
|
||||
("arabic2Minus", 10, "ي-"),
|
||||
("arabicPlain", 10, "10"),
|
||||
] {
|
||||
assert_eq!(autonum(scheme, n), want, "{scheme} {n}");
|
||||
}
|
||||
assert_eq!(chinese_num(105), "一百零五");
|
||||
assert_eq!(chinese_num(20), "二十");
|
||||
// Past the end of an alphabet the letter doubles, like "aa".
|
||||
assert_eq!(autonum("hebrew2Minus", 23), "אא-");
|
||||
assert_eq!(autonum("thaiAlphaPeriod", 0), ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_vertical_text_marks_cjk_upright() {
|
||||
let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical);
|
||||
let l = lay(&p, &sh, 300.0, 600.0);
|
||||
assert_eq!(l.rotation, 90.0);
|
||||
let upright: Vec<bool> = l
|
||||
.runs
|
||||
.iter()
|
||||
.filter(|r| !r.cells.is_empty())
|
||||
.flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright)))
|
||||
.filter(|(c, _)| *c != ' ')
|
||||
.map(|(_, u)| u)
|
||||
.collect();
|
||||
assert_eq!(upright, vec![true, true, true, false, false, false, false, false]);
|
||||
// In vert nothing stands upright.
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
|
||||
let l = lay(&p, &sh, 300.0, 600.0);
|
||||
assert!(l.runs.iter().all(|r| !r.upright));
|
||||
assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() {
|
||||
let (p, mut sh) = setup("one\ntwo", 200.0, 400.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
|
||||
let v = lay(&p, &sh, 200.0, 400.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical);
|
||||
let m = lay(&p, &sh, 200.0, 400.0);
|
||||
assert_eq!(m.rotation, 90.0);
|
||||
// In layout space (turned a quarter turn) vert puts the first line at the top, which shows on
|
||||
// the right; mongolianVert mirrors the line places, so the first line shows on the left.
|
||||
assert!(v.lines[0].top < v.lines[1].top);
|
||||
assert!(m.lines[0].top > m.lines[1].top);
|
||||
let mid = (m.inner.y0 + m.inner.y1) / 2.0;
|
||||
assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01);
|
||||
// Stacked: every letter upright, one em apart.
|
||||
let (p, mut sh) = setup("Lil", 200.0, 400.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.vert = Some(deckcraft_model::text::TextDir::Stacked);
|
||||
t.paragraphs[0].runs[0].props.size = Some(20.0);
|
||||
let s = lay(&p, &sh, 200.0, 400.0);
|
||||
assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
|
||||
// Its lines run left to right too.
|
||||
let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0);
|
||||
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked);
|
||||
let st = lay(&p2, &sh2, 200.0, 400.0);
|
||||
assert!(st.lines[0].top > st.lines[1].top);
|
||||
// wordArtVertRtl stacks the same way with lines from right to left.
|
||||
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl);
|
||||
let sr = lay(&p2, &sh2, 200.0, 400.0);
|
||||
assert!(sr.lines[0].top < sr.lines[1].top);
|
||||
assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
|
||||
let e = &s.lines[0].edges;
|
||||
assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_after_an_emoji_keep_a_text_face() {
|
||||
// "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text.
|
||||
let db = FontDb::global();
|
||||
if !db.cascade(&['✅'], &[], "Regular").is_color() {
|
||||
return;
|
||||
}
|
||||
let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
for r in &l.runs {
|
||||
for (_, c) in &r.cells {
|
||||
assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_faces_do_not_move_the_baseline() {
|
||||
// PowerPoint keeps a line's baseline and pitch from the run's own font when an emoji, a symbol
|
||||
// or a script the font lacks is drawn from another face.
|
||||
let base = |text: &str| {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
(l.lines[0].baseline, l.lines[0].bottom)
|
||||
};
|
||||
let plain = base("Hxg Hxg");
|
||||
for text in ["Hxg \u{1F600} Hxg", "Hxg \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}", "Hxg \u{222B}\u{2211}"] {
|
||||
let b = base(text);
|
||||
assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strikethrough_sits_at_the_font_strikeout_position() {
|
||||
let (p, mut sh) = setup("Struck", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(96.0);
|
||||
para.runs[0].props.strike = Some(Strike::Single);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let k = 96.0 / r.face.upem;
|
||||
let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough");
|
||||
assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position");
|
||||
assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() {
|
||||
let text = "word ".repeat(200);
|
||||
let (p, mut sh) = setup(&text, 400.0, 200.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.columns = Some(2);
|
||||
t.body.col_spacing = Some(20.0);
|
||||
t.paragraphs[0].runs[0].props.size = Some(12.0);
|
||||
let ltr = lay(&p, &sh, 400.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.rtl_col = Some(true);
|
||||
let rtl = lay(&p, &sh, 400.0, 200.0);
|
||||
let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap();
|
||||
let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap();
|
||||
assert!(first(<r) < second(<r));
|
||||
assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right");
|
||||
// anchor="just" and "dist" draw like "b".
|
||||
let (p, mut sh) = setup("one line", 400.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom);
|
||||
let b = lay(&p, &sh, 400.0, 300.0).lines[0].top;
|
||||
for a in [Anchor::Justified, Anchor::Distributed] {
|
||||
sh.text.as_mut().unwrap().body.anchor = Some(a);
|
||||
assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_combining_marks_take_a_face_that_draws_them() {
|
||||
// A letter with marks the run's font lacks draws in a face that has them all (no boxes).
|
||||
let db = FontDb::global();
|
||||
let cluster = ['Z', '\u{0334}', '\u{0321}', '\u{031B}'];
|
||||
let run_face = db.face("Inter", "Regular");
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
|
||||
if !deckcraft_fonts::covers_cluster(&run_face, &cluster) && ["DejaVu Sans", "Segoe UI"].iter().any(|fam| db.has_family(fam)) {
|
||||
// Make sure a face with the marks is loaded, then the cluster must take one.
|
||||
for fam in ["DejaVu Sans", "Segoe UI"] {
|
||||
if db.has_family(fam) {
|
||||
let _ = db.face(fam, "Regular");
|
||||
}
|
||||
}
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
|
||||
assert!(deckcraft_fonts::covers_cluster(&f, &cluster), "{} lacks the marks", f.family);
|
||||
}
|
||||
let _ = f;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
//! The File tab (Backstage, #76) through real egui input: the File tab, Alt+F and Esc, every page,
|
||||
//! and the commands its pages run.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn new_app() -> SlideApp {
|
||||
SlideApp::new(Session::with_new(), Services::default())
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's files.
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-backstage-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Desktop services whose Save dialog answers with `dir`/<suggested name> and records the names.
|
||||
fn saving_into(dir: &std::path::Path) -> (Services, Rc<RefCell<Vec<String>>>) {
|
||||
let asked: Rc<RefCell<Vec<String>>> = Default::default();
|
||||
let log = asked.clone();
|
||||
let dir = dir.to_path_buf();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(move |suggested: &str| {
|
||||
log.borrow_mut().push(suggested.to_string());
|
||||
Some(dir.join(suggested).to_string_lossy().to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(services, asked)
|
||||
}
|
||||
|
||||
/// Is `r` in the side menu (the harness insets the window by its default margin)?
|
||||
fn in_nav(r: egui::Rect) -> bool {
|
||||
r.left() < 20.0 && r.right() <= NAV_W + 20.0
|
||||
}
|
||||
|
||||
/// Clicks the button called `label` on the page (not the side menu's entry of the same name).
|
||||
fn click_on_page(h: &Harness<'_, SlideApp>, label: &str) {
|
||||
let node = h
|
||||
.query_all_by_role_and_label(Role::Button, label)
|
||||
.find(|n| !in_nav(n.rect()))
|
||||
.or_else(|| h.query_all_by_label(label).find(|n| !in_nav(n.rect())));
|
||||
node.unwrap_or_else(|| panic!("no “{label}” button on the page")).click();
|
||||
}
|
||||
|
||||
/// Clicks the side menu's entry called `label`.
|
||||
fn click_nav(h: &Harness<'_, SlideApp>, label: &str) {
|
||||
let node = h.query_all_by_label(label).find(|n| in_nav(n.rect()));
|
||||
node.unwrap_or_else(|| panic!("no “{label}” in the side menu")).click();
|
||||
}
|
||||
|
||||
fn page(h: &Harness<'_, SlideApp>) -> Option<Page> {
|
||||
h.state().backstage.as_ref().map(|b| b.page)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
for p in Page::ALL {
|
||||
assert_eq!(Page::parse(p.id()), Some(p));
|
||||
}
|
||||
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
|
||||
assert_eq!(Page::parse("share"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
let (top, bottom) = entries(false);
|
||||
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
|
||||
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]);
|
||||
assert_eq!(entries(true).0.len(), 9);
|
||||
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
|
||||
assert_eq!(format_bytes(512), "512 bytes");
|
||||
assert_eq!(format_bytes(33_485), "32.7 KB");
|
||||
}
|
||||
|
||||
/// The File tab opens Home in place of the ribbon; Esc and the back arrow return.
|
||||
#[test]
|
||||
fn the_file_tab_opens_the_backstage_and_esc_or_back_returns() {
|
||||
let mut h = harness(new_app());
|
||||
assert!(h.query_by_label("Blank Presentation").is_none(), "the slide shows first");
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert!(h.state().backstage_open());
|
||||
assert!(h.query_by_label("Blank Presentation").is_some(), "Home offers new presentations");
|
||||
assert!(h.query_by_label("File").is_none(), "the ribbon is hidden");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open(), "Esc goes back to the slide");
|
||||
assert!(h.query_by_label("File").is_some());
|
||||
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open(), "so does the back arrow");
|
||||
assert_eq!(h.state().session.documents().len(), 1, "nothing was created or closed");
|
||||
}
|
||||
|
||||
/// Alt+F opens it with the keyboard in the side menu; arrows walk it (pages show as they get the
|
||||
/// focus); Enter runs an action; Esc goes back.
|
||||
#[test]
|
||||
fn alt_f_arrows_enter_and_esc_drive_the_side_menu() {
|
||||
let mut h = harness(new_app());
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::F);
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0));
|
||||
for want in [Page::New, Page::Open, Page::Info] {
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(want));
|
||||
}
|
||||
// Save has the focus now; the page stays Info until it is run.
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::Info));
|
||||
// A presentation that was never saved goes to Save As, as in PowerPoint.
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::SaveAs));
|
||||
// Up from the top wraps to Options at the bottom.
|
||||
for _ in 0..20 {
|
||||
h.key_press(Key::ArrowUp);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open());
|
||||
assert!(!h.state().session.active().unwrap().is_dirty(), "the keys never reached the slide");
|
||||
}
|
||||
|
||||
/// Every page draws its own content.
|
||||
#[test]
|
||||
fn every_page_renders() {
|
||||
let mut h = harness(new_app());
|
||||
let last_theme = deckcraft_model::theme::builtin_themes().last().map(|t| t.name.clone()).unwrap();
|
||||
let checks: [(Page, &str); 8] = [
|
||||
(Page::Home, "Recent"),
|
||||
(Page::New, &last_theme),
|
||||
(Page::Open, "Browse"),
|
||||
(Page::Info, "Hidden slides"),
|
||||
(Page::SaveAs, "Not saved yet"),
|
||||
(Page::Print, "Print Hidden Slides"),
|
||||
(Page::Export, "Create PDF Document"),
|
||||
(Page::Account, "About DeckCraft"),
|
||||
];
|
||||
for (p, marker) in checks {
|
||||
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(p));
|
||||
assert!(h.query_all_by_label_contains(marker).next().is_some(), "{} shows “{marker}”", p.label());
|
||||
let selected = h.query_all_by_label(p.label()).any(|n| in_nav(n.rect()));
|
||||
assert!(selected, "{} is in the side menu", p.label());
|
||||
}
|
||||
assert!(h.state_mut().run("app.backstage", json!({"page": "share"})).is_err(), "Share (cloud) isn't offered");
|
||||
}
|
||||
|
||||
/// New and Home create presentations, which leaves the File tab.
|
||||
#[test]
|
||||
fn new_creates_a_presentation() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("app.backstage", json!({"page": "new"})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
assert!(h.state().backstage.is_none(), "the new presentation shows");
|
||||
|
||||
let theme = deckcraft_model::theme::builtin_themes().into_iter().nth(1).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label(&theme.name).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.name, theme.name);
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
/// Open lists recent presentations by name (filtered by the search box) and opens one.
|
||||
#[test]
|
||||
fn open_lists_recent_files_and_opens_one() {
|
||||
let dir = temp_dir("open");
|
||||
let path = dir.join("Quarterly review.pptx").to_string_lossy().to_string();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.recent = vec![path.clone(), dir.join("Other.pptx").to_string_lossy().to_string()];
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "open"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Other.pptx").is_some());
|
||||
h.state_mut().backstage.as_mut().unwrap().query = "quarter".into();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name");
|
||||
h.get_by_label("Quarterly review.pptx").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str()));
|
||||
assert!(h.state().backstage.is_none(), "the opened presentation shows");
|
||||
assert_eq!(h.state().ui.recent.first(), Some(&path));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Save As saves through the Save dialog and the presentation takes the new file; Export writes
|
||||
/// copies (PDF, another file type) and leaves the presentation's own file alone.
|
||||
#[test]
|
||||
fn save_as_and_export_run_the_file_commands() {
|
||||
let dir = temp_dir("saveas");
|
||||
let (services, asked) = saving_into(&dir);
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), services));
|
||||
let title = h.state().session.active().unwrap().title();
|
||||
|
||||
// Save on a presentation without a file goes to Save As.
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::SaveAs));
|
||||
click_on_page(&h, "Save");
|
||||
h.run_steps(3);
|
||||
let pptx = dir.join(format!("{title}.pptx")).to_string_lossy().to_string();
|
||||
assert!(std::path::Path::new(&pptx).is_file(), "saved as PowerPoint by default");
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()));
|
||||
assert!(h.state().backstage.is_none(), "a save returns to the slide");
|
||||
|
||||
// Export › Create PDF Document.
|
||||
h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Create PDF");
|
||||
h.run_steps(3);
|
||||
let pdf = std::fs::read(dir.join(format!("{title}.pdf"))).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
|
||||
// Export › Change File Type › Outline.
|
||||
click_on_page(&h, "Change File Type");
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Outline (*.txt)");
|
||||
h.run_steps(2);
|
||||
click_on_page(&h, "Save As");
|
||||
h.run_steps(3);
|
||||
assert!(dir.join(format!("{title}.txt")).is_file());
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()), "exports are copies");
|
||||
assert_eq!(*asked.borrow(), vec![format!("{title}.pptx"), format!("{title}.pdf"), format!("{title}.txt")]);
|
||||
|
||||
// Save with a file saves in place and goes back.
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.active().unwrap().is_dirty());
|
||||
assert!(h.state().backstage.is_none());
|
||||
assert_eq!(asked.borrow().len(), 3, "no Save dialog for a presentation with a file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Close closes a saved presentation at once and asks about unsaved changes first.
|
||||
#[test]
|
||||
fn close_closes_and_asks_about_unsaved_changes() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 0);
|
||||
assert_eq!(page(&h), Some(Page::Home), "the start screen shows");
|
||||
assert!(h.query_by_label("Back").is_none() && h.query_by_label("Close").is_none(), "nothing to go back to or close");
|
||||
|
||||
// Unsaved: Cancel keeps it, Don't Save closes it.
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert!(h.state().backstage_open(), "Cancel leaves the File tab as it was");
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Don't Save").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 0);
|
||||
}
|
||||
|
||||
/// Ctrl+W asks too (it used to close unsaved presentations without a word).
|
||||
#[test]
|
||||
fn ctrl_w_asks_before_closing_unsaved_changes() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
}
|
||||
|
||||
/// Info shows the presentation's counts and properties, and edits them through file.properties.
|
||||
#[test]
|
||||
fn info_shows_and_edits_the_properties() {
|
||||
let mut app = new_app();
|
||||
app.run("file.properties", json!({"title": "Quarterly", "keywords": "sales"})).unwrap();
|
||||
app.run("slide.new", json!({})).unwrap();
|
||||
app.run("slide.hide", json!({})).unwrap();
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Title").value().as_deref(), Some("Quarterly"));
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Tags").value().as_deref(), Some("sales"));
|
||||
assert!(h.query_all_by_label("Not saved yet").next().is_some());
|
||||
let author = h.get_by_role_and_label(Role::TextInput, "Author");
|
||||
author.click();
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.props.author, "Ada");
|
||||
// Esc in a field leaves the field; the next one goes back.
|
||||
assert!(h.state().backstage_open());
|
||||
}
|
||||
|
||||
/// With nothing open the File tab is the start screen: Home, New and Open, no back arrow, and Esc
|
||||
/// has nowhere to go.
|
||||
#[test]
|
||||
fn the_start_screen_is_file_home() {
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert!(h.query_by_label("Back").is_none());
|
||||
assert!(h.query_all_by_label("Info").next().is_none());
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::New));
|
||||
assert!(h.state_mut().run("app.backstage", json!({"page": "print"})).is_err(), "nothing to print");
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
/// Print previews the pages its settings make and prints through a PDF opened in the system
|
||||
/// viewer.
|
||||
#[test]
|
||||
fn print_previews_pages_and_prints_a_pdf() {
|
||||
let dir = temp_dir("print");
|
||||
let (services, _) = saving_into(&dir);
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
for _ in 0..3 {
|
||||
app.run("slide.new", json!({})).unwrap();
|
||||
}
|
||||
app.run("slide.hide", json!({"index": 1})).unwrap();
|
||||
let mut h = harness(app);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::P);
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Print), "Ctrl+P opens File › Print");
|
||||
assert!(h.query_by_label("1 of 3").is_some(), "hidden slides are left out");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_layout = "h4";
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("1 of 1").is_some(), "four slides a page");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_layout = "slides";
|
||||
h.state_mut().backstage.as_mut().unwrap().print_hidden = true;
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("1 of 4").is_some());
|
||||
h.get_by_label("Next Page").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("2 of 4").is_some());
|
||||
{
|
||||
let bs = h.state_mut().backstage.as_mut().unwrap();
|
||||
bs.print_scope = PrintScope::Custom;
|
||||
bs.print_range = "9".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("outside them").is_some(), "a bad range says why");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
|
||||
h.run_steps(2);
|
||||
click_on_page(&h, "Print");
|
||||
h.run_steps(2);
|
||||
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
|
||||
// The URL was handed to egui at the end of the frame; check what it pointed at.
|
||||
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
|
||||
assert!(printed.is_file(), "the PDF to print was written: {url}");
|
||||
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(printed);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_slides_follow_the_settings() {
|
||||
let mut s = Session::with_new();
|
||||
for _ in 0..4 {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("slide.hide", &json!({"index": 2})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let mut bs = Backstage::new(Page::Print, (1, Some(0)));
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![0, 1, 3, 4]);
|
||||
bs.print_hidden = true;
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap().len(), 5);
|
||||
bs.print_scope = PrintScope::Current;
|
||||
assert_eq!(print_slides(&doc, &bs, 2).unwrap(), vec![2], "the current slide prints even when hidden");
|
||||
bs.print_scope = PrintScope::Custom;
|
||||
bs.print_range = "2-4".into();
|
||||
bs.print_hidden = false;
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![1, 3]);
|
||||
bs.print_layout = "h6";
|
||||
assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6);
|
||||
assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts");
|
||||
}
|
||||
@@ -216,7 +216,7 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
/// 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() {
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
@@ -461,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
} else {
|
||||
poly(painter, corners, sel_stroke);
|
||||
}
|
||||
if sh.locked && !editing_this {
|
||||
// Locked: greyed handles that don't respond, no rotate or adjust handles.
|
||||
for h in box_handles(&x).0 {
|
||||
painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if st.selection.shapes.len() == 1 || !editing_this {
|
||||
let (hs, rot) = box_handles(&x);
|
||||
let rot_s = xf.to_screen(rot);
|
||||
@@ -640,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
|
||||
});
|
||||
let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked));
|
||||
if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() {
|
||||
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Link…").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
||||
ui.close();
|
||||
|
||||
@@ -73,7 +73,8 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"canvasScale": app.canvas_scale,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
|
||||
"home": app.home_open(),
|
||||
"home": app.backstage_open(),
|
||||
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
|
||||
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
|
||||
+36
-168
@@ -1,11 +1,11 @@
|
||||
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
|
||||
//! screen and the About window.
|
||||
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
|
||||
//! About window. The start screen is File › Home (`backstage.rs`).
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, ribbon};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Dialog {
|
||||
@@ -290,6 +290,37 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
done
|
||||
}
|
||||
"close" => {
|
||||
// File › Close and Ctrl+W on a presentation with unsaved changes.
|
||||
let Some(name) = app.session.active().map(|d| d.title()) else { return true };
|
||||
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong());
|
||||
ui.label("Your changes will be lost if you don't save them.");
|
||||
ui.add_space(8.0);
|
||||
let mut done = false;
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
|
||||
run(app, "file.close", json!({}));
|
||||
done = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
|
||||
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
app.save();
|
||||
// In the browser a save is a download: the presentation stays "unsaved".
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
|
||||
run(app, "file.close", json!({}));
|
||||
}
|
||||
done = true;
|
||||
}
|
||||
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
done
|
||||
}
|
||||
"zoom" => {
|
||||
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
|
||||
if ui.radio(false, format!("{pct}%")).clicked() {
|
||||
@@ -430,45 +461,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
let (ok, cancel) = buttons(ui, "Export");
|
||||
if ok {
|
||||
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
|
||||
let (ext, all) = match fmt.as_str() {
|
||||
"pdf" => ("pdf", false),
|
||||
"pngAll" => ("png", true),
|
||||
"jpeg" => ("jpg", false),
|
||||
"pptx" => ("pptx", false),
|
||||
"outline" => ("txt", false),
|
||||
_ => ("png", false),
|
||||
};
|
||||
let suggested = format!("{name}.{ext}");
|
||||
if let Some(pick) = app.services.pick_save.as_mut() {
|
||||
if let Some(path) = pick(&suggested) {
|
||||
let mut params = json!({"path": path, "all": all});
|
||||
if ext == "pdf"
|
||||
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
|
||||
{
|
||||
o.extend(extra.clone());
|
||||
}
|
||||
let r = app.session.execute("file.export", ¶ms);
|
||||
match r {
|
||||
Ok(_) => app.set_status(format!("Exported {path}")),
|
||||
Err(e) => app.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
|
||||
let bytes = match ext {
|
||||
"png" | "jpg" => {
|
||||
let img = deckcraft_render::render_slide(
|
||||
&s.doc,
|
||||
s.selection.slide,
|
||||
&deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() },
|
||||
);
|
||||
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
|
||||
}
|
||||
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
|
||||
};
|
||||
dl(&suggested, &bytes);
|
||||
}
|
||||
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
|
||||
app.export_as(&fmt, extra);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
@@ -835,129 +829,3 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
/// The start screen (no presentation open): new from themes, open, recent files.
|
||||
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
|
||||
paint_logo(ui.painter(), r);
|
||||
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
|
||||
});
|
||||
if app.home_open() {
|
||||
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
|
||||
let _ = app.run("app.home", json!({"on": false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
inset_wrapped(ui, |ui| {
|
||||
for th in deckcraft_model::theme::builtin_themes() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
|
||||
ribbon::paint_theme_tile(ui.painter(), tile, &th);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"theme": th.name}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button("Open…").clicked() {
|
||||
let _ = app.run("app.openDialog", json!({}));
|
||||
}
|
||||
if ui.button("Open the sample deck").clicked()
|
||||
&& let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session)
|
||||
{
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
});
|
||||
if !app.ui.recent.is_empty() {
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
|
||||
});
|
||||
for p in app.ui.recent.clone() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.link(&p).clicked()
|
||||
&& let Err(e) = app.open_path(&p)
|
||||
{
|
||||
app.set_status(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(30.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
|
||||
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
|
||||
/// would indent only the first row.
|
||||
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(add_contents);
|
||||
});
|
||||
}
|
||||
|
||||
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
|
||||
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
|
||||
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
|
||||
#[test]
|
||||
fn inset_wrapped_indents_every_row() {
|
||||
let left = Cell::new(0.0_f32);
|
||||
let tiles = RefCell::new(Vec::<Rect>::new());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
|
||||
left.set(ui.max_rect().left());
|
||||
tiles.borrow_mut().clear();
|
||||
inset_wrapped(ui, |ui| {
|
||||
for _ in 0..4 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
tiles.borrow_mut().push(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
harness.run();
|
||||
let tiles = tiles.borrow().clone();
|
||||
let first_top = tiles.first().map_or(0.0, |r| r.top());
|
||||
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
|
||||
let mut row_top = f32::NEG_INFINITY;
|
||||
for r in &tiles {
|
||||
if r.top() > row_top {
|
||||
row_top = r.top();
|
||||
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,8 @@ icons! {
|
||||
Info => "info",
|
||||
Warning => "warning",
|
||||
Lock => "lock",
|
||||
/// Open padlock (Selection pane: an object that isn't locked).
|
||||
Unlock => "unlock",
|
||||
Eye => "eye",
|
||||
EyeOff => "eye-off",
|
||||
Close => "close",
|
||||
@@ -304,6 +306,13 @@ icons! {
|
||||
Collapse => "collapse",
|
||||
Expand => "expand",
|
||||
Pin => "pin",
|
||||
// File tab (Backstage)
|
||||
BackCircle => "back-circle",
|
||||
SaveAs => "save-as",
|
||||
Account => "account",
|
||||
Recent => "recent",
|
||||
NewFile => "new-file",
|
||||
OpenFolder => "open-folder",
|
||||
// Status bar (small, monochrome)
|
||||
NotesSmall => "notes-small",
|
||||
CommentsSmall => "comments-small",
|
||||
@@ -2262,22 +2271,30 @@ fn draw(p: &Pen<'_>, icon: Icon) {
|
||||
p.seg((12.0, 9.0), (12.0, 14.0), D);
|
||||
p.dot((12.0, 17.2), 1.2, D);
|
||||
}
|
||||
// Selection pane toggles: monochrome outlines in the ink colour.
|
||||
I::Lock => {
|
||||
let mut sh = vec![(7.5, 10.5), (7.5, 8.0)];
|
||||
sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0));
|
||||
sh.push((16.5, 10.5));
|
||||
p.line(&sh, K);
|
||||
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K);
|
||||
p.dot((12.0, 15.0), 1.5, D);
|
||||
p.seg((12.0, 15.5), (12.0, 18.3), D);
|
||||
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K);
|
||||
p.seg((12.0, 14.5), (12.0, 17.5), K);
|
||||
}
|
||||
I::Unlock => {
|
||||
// The shackle swung open: its left leg out of the body, raised.
|
||||
let mut sh = vec![(12.0, 10.5), (12.0, 6.0)];
|
||||
sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0));
|
||||
sh.push((21.0, 8.5));
|
||||
p.line(&sh, K);
|
||||
p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K);
|
||||
p.seg((9.75, 14.5), (9.75, 17.5), K);
|
||||
}
|
||||
I::Eye | I::EyeOff => {
|
||||
let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16);
|
||||
e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1));
|
||||
let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16);
|
||||
e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1));
|
||||
e.pop();
|
||||
p.shape(&e, N, K);
|
||||
p.circle((12.0, 12.0), 3.4, B, N);
|
||||
p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W });
|
||||
p.circle((12.0, 12.0), 3.2, N, K);
|
||||
if icon == I::EyeOff {
|
||||
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
|
||||
}
|
||||
@@ -2351,6 +2368,33 @@ fn draw(p: &Pen<'_>, icon: Icon) {
|
||||
p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N);
|
||||
}
|
||||
|
||||
// ----- File tab (Backstage) navigation: outlines in the ink colour -----
|
||||
I::BackCircle => {
|
||||
p.circle((12.0, 12.0), 9.8, N, K);
|
||||
p.arrow((17.0, 12.0), (6.5, 12.0), K);
|
||||
}
|
||||
I::SaveAs => {
|
||||
p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K);
|
||||
p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K);
|
||||
p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4);
|
||||
p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0);
|
||||
}
|
||||
I::Account => {
|
||||
p.circle((12.0, 8.0), 4.5, N, K);
|
||||
p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K);
|
||||
}
|
||||
I::Recent => {
|
||||
p.circle((12.0, 12.0), 9.8, N, K);
|
||||
p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K);
|
||||
}
|
||||
I::NewFile => {
|
||||
p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K);
|
||||
}
|
||||
I::OpenFolder => {
|
||||
p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K);
|
||||
p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K);
|
||||
}
|
||||
|
||||
// ----- Status bar (monochrome) -----
|
||||
I::NotesSmall => {
|
||||
p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K);
|
||||
|
||||
+296
-29
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod backstage;
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
@@ -16,6 +17,7 @@ pub mod media;
|
||||
pub mod menus;
|
||||
pub mod panes;
|
||||
pub mod ribbon;
|
||||
pub mod selpane;
|
||||
pub mod show;
|
||||
pub mod sorter;
|
||||
pub mod status;
|
||||
@@ -153,6 +155,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("show.presenter", "Presenter View", None, "{on?: bool}"),
|
||||
("show.end", "End Show", None, "{}"),
|
||||
("app.home", "Home", None, "{on?}"),
|
||||
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"),
|
||||
("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
|
||||
("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"),
|
||||
("app.about", "About DeckCraft", None, "{}"),
|
||||
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
@@ -199,9 +204,10 @@ pub struct SlideApp {
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
/// The start screen is shown over the open presentations (Home), with the document count
|
||||
/// and active index when it was opened: opening or creating a presentation leaves it.
|
||||
pub home: Option<(usize, Option<usize>)>,
|
||||
/// The File tab's full-window view (Backstage). Over a presentation it replaces the ribbon,
|
||||
/// slides and status bar until Esc or Back; with nothing open it is the start screen. Opening,
|
||||
/// creating or closing a presentation leaves it.
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pending_urls: Vec<String>,
|
||||
shot_token: u64,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
@@ -219,6 +225,8 @@ pub struct SlideApp {
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||
/// Selection pane: folded groups, renaming, drag and drop.
|
||||
pub selpane: selpane::State,
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
@@ -244,7 +252,7 @@ impl SlideApp {
|
||||
canvas_region: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
backstage: None,
|
||||
pending_urls: vec![],
|
||||
shot_token: 0,
|
||||
queued_shots: vec![],
|
||||
@@ -258,6 +266,7 @@ impl SlideApp {
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,13 +294,153 @@ impl SlideApp {
|
||||
r
|
||||
}
|
||||
|
||||
fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
pub(crate) fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
(self.session.documents().len(), self.session.active_index())
|
||||
}
|
||||
|
||||
/// Home is open over a presentation (not just the start screen with nothing open).
|
||||
pub fn home_open(&self) -> bool {
|
||||
self.home.is_some() && self.session.active().is_some()
|
||||
/// The File tab's view is open over a presentation (not just the start screen with nothing
|
||||
/// open).
|
||||
pub fn backstage_open(&self) -> bool {
|
||||
self.backstage.is_some() && self.session.active().is_some()
|
||||
}
|
||||
|
||||
/// Opens the File tab's view on `page` (keeping its state when it is already open).
|
||||
pub fn open_backstage(&mut self, page: backstage::Page) {
|
||||
let key = self.docs_key();
|
||||
match &mut self.backstage {
|
||||
Some(b) => b.go(page),
|
||||
None => self.backstage = Some(backstage::Backstage::new(page, key)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Back to the presentation (no effect on the start screen, where nothing is open).
|
||||
pub fn close_backstage(&mut self) {
|
||||
if self.session.active().is_some() {
|
||||
self.backstage = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the File tab's view in step with the open presentations: it closes when one is
|
||||
/// opened, created or closed (the result shows), and it is the start screen when none is open.
|
||||
fn sync_backstage(&mut self) {
|
||||
let key = self.docs_key();
|
||||
let has_doc = self.session.active().is_some();
|
||||
let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key);
|
||||
if has_doc && stale {
|
||||
self.backstage = None;
|
||||
} else if !has_doc && (stale || self.backstage.is_none()) {
|
||||
self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key));
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved
|
||||
/// changes (Save, Don't Save, Cancel), as PowerPoint does.
|
||||
pub fn close_presentation(&mut self) {
|
||||
match self.session.active() {
|
||||
None => {}
|
||||
Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")),
|
||||
Some(_) => {
|
||||
let _ = self.run("file.close", json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name a save or export suggests: the presentation's title with `ext`.
|
||||
fn suggested_name(&self, ext: &str) -> String {
|
||||
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
|
||||
format!("{name}.{ext}")
|
||||
}
|
||||
|
||||
/// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`,
|
||||
/// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download
|
||||
/// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The
|
||||
/// presentation keeps its own file. Returns whether a file was written or handed over.
|
||||
pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool {
|
||||
let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false };
|
||||
let (ext, format, all) = match fmt {
|
||||
"pdf" => ("pdf", "pdf", false),
|
||||
"png" => ("png", "png", false),
|
||||
"pngAll" => ("png", "png", true),
|
||||
"jpeg" | "jpg" => ("jpg", "jpeg", false),
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
other => {
|
||||
self.set_status(format!("DeckCraft can't export “{other}”"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let suggested = self.suggested_name(ext);
|
||||
if let Some(pick) = self.services.pick_save.as_mut() {
|
||||
let Some(path) = pick(&suggested) else { return false };
|
||||
let mut params = json!({"path": path, "format": format, "all": all});
|
||||
if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) {
|
||||
o.extend(x.clone());
|
||||
}
|
||||
return match self.run("file.export", params) {
|
||||
Ok(_) => {
|
||||
self.set_status(format!("Exported {path}"));
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
}
|
||||
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false };
|
||||
let bytes = match format {
|
||||
"png" | "jpeg" => {
|
||||
let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() });
|
||||
Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) })
|
||||
}
|
||||
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
|
||||
};
|
||||
self.download(&suggested, bytes)
|
||||
}
|
||||
|
||||
/// Hands `bytes` to the browser as a download (web); false (with the reason in the status
|
||||
/// bar) when there are no bytes or no way to download.
|
||||
fn download(&mut self, name: &str, bytes: deckcraft_engine::Result<Vec<u8>>) -> bool {
|
||||
match (bytes, self.services.download.as_mut()) {
|
||||
(Ok(b), Some(dl)) => {
|
||||
dl(name, &b);
|
||||
true
|
||||
}
|
||||
(Err(e), _) => {
|
||||
self.set_status(e.to_string());
|
||||
false
|
||||
}
|
||||
(Ok(_), None) => {
|
||||
self.set_status("Saving a copy needs a file picker, which this window doesn't have.");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's
|
||||
/// layout, slides and options) is written to the temporary folder and opened in the system's
|
||||
/// PDF viewer, which has the printers; on the web it downloads.
|
||||
pub fn print_pdf(&mut self, params: Value) -> bool {
|
||||
let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false };
|
||||
let mut params = if params.is_object() { params } else { json!({}) };
|
||||
if self.services.pick_save.is_none() {
|
||||
// Web: the browser hands the PDF to the user.
|
||||
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms);
|
||||
return self.download(&format!("{title}.pdf"), bytes);
|
||||
}
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("format".into(), json!("pdf"));
|
||||
}
|
||||
match self.run("file.export", params) {
|
||||
Ok(_) => {
|
||||
self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/')));
|
||||
self.set_status("Opened the presentation as a PDF to print");
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(&mut self, params: &Value, cur: bool) -> bool {
|
||||
@@ -354,8 +503,34 @@ impl SlideApp {
|
||||
self.media.stop_owned(media::Owner::Show);
|
||||
}
|
||||
"app.home" => {
|
||||
let on = self.flag(p, self.home.is_some());
|
||||
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
|
||||
let on = self.flag(p, self.backstage_open());
|
||||
if on {
|
||||
self.open_backstage(backstage::Page::Home);
|
||||
} else {
|
||||
self.close_backstage();
|
||||
}
|
||||
}
|
||||
"app.backstage" => {
|
||||
if !p.get("on").and_then(Value::as_bool).unwrap_or(true) {
|
||||
self.close_backstage();
|
||||
return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}));
|
||||
}
|
||||
let page = match p.get("page").and_then(Value::as_str) {
|
||||
Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?,
|
||||
None => backstage::Page::Home,
|
||||
};
|
||||
if page.needs_doc() && self.session.active().is_none() {
|
||||
return Err(format!("File › {} needs an open presentation", page.label()));
|
||||
}
|
||||
self.open_backstage(page);
|
||||
return Ok(json!({"page": page.id()}));
|
||||
}
|
||||
"app.closePresentation" => self.close_presentation(),
|
||||
"app.print" => {
|
||||
if self.session.active().is_none() {
|
||||
return Err("nothing to print: no presentation is open".into());
|
||||
}
|
||||
return Ok(json!({"printed": self.print_pdf(p.clone())}));
|
||||
}
|
||||
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
|
||||
"app.palette" => self.palette = Some((String::new(), 0)),
|
||||
@@ -674,16 +849,22 @@ impl SlideApp {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
if self.home_open() {
|
||||
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
|
||||
// presentation: typing, pasting or deleting would edit the hidden presentation.
|
||||
if self.backstage.is_some() {
|
||||
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
|
||||
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
|
||||
// hidden presentation.
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
|
||||
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
|
||||
if key == egui::Key::Escape {
|
||||
self.home = None;
|
||||
} else if mods.cmd || mods.alt {
|
||||
menus::ui_shortcut(self, key, mods);
|
||||
if backstage::key(self, key, mods) || !(mods.cmd || mods.alt) || menus::ui_shortcut(self, key, mods) {
|
||||
continue;
|
||||
}
|
||||
// Engine shortcuts that don't touch the hidden slide (Ctrl+N).
|
||||
if mods.cmd
|
||||
&& matches!(key, egui::Key::N)
|
||||
&& let Err(e) = self.session.key(key_name(key), mods)
|
||||
{
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -759,18 +940,21 @@ impl SlideApp {
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
if self.home.is_some_and(|k| k != self.docs_key()) {
|
||||
self.home = None;
|
||||
}
|
||||
self.sync_backstage();
|
||||
self.keyboard(&ctx);
|
||||
self.sync_backstage();
|
||||
ribbon::title_bar(self, ui);
|
||||
if self.session.active().is_some() && !self.home_open() {
|
||||
if self.backstage.is_none() && self.session.active().is_some() {
|
||||
ribbon::show(self, ui);
|
||||
status::status_bar(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
if self.session.active().is_none() || self.home_open() {
|
||||
dialogs::start_screen(self, ui);
|
||||
if self.backstage.is_some() {
|
||||
// File tab: the Backstage view replaces everything below the title bar.
|
||||
backstage::show(self, ui);
|
||||
return;
|
||||
}
|
||||
if self.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
match self.ui.view {
|
||||
@@ -1006,7 +1190,7 @@ mod tests {
|
||||
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert!(harness.state().backstage_open());
|
||||
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
|
||||
|
||||
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
|
||||
@@ -1017,26 +1201,109 @@ mod tests {
|
||||
assert!(!harness.state().session.active().unwrap().is_dirty());
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open());
|
||||
assert!(!harness.state().backstage_open());
|
||||
|
||||
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert!(harness.state().backstage_open());
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
assert!(!harness.state().home_open());
|
||||
assert!(!harness.state().backstage_open());
|
||||
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
|
||||
harness.state_mut().run("file.new", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open(), "a new presentation leaves Home");
|
||||
assert!(!harness.state().backstage_open(), "a new presentation leaves Home");
|
||||
assert_eq!(harness.state().session.documents().len(), 2);
|
||||
}
|
||||
|
||||
/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real
|
||||
/// egui key events (Windows keys).
|
||||
#[test]
|
||||
fn ctrl_word_keys_edit_text() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut session = Session::with_new();
|
||||
session.mac_keys = false;
|
||||
session.prefs.autocorrect = false;
|
||||
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, Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("Hello, world. Foo bar".into()));
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Hello, world. Foo bar");
|
||||
let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap();
|
||||
let ctrl = Modifiers::COMMAND;
|
||||
let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
|
||||
// Ctrl+Left stops at every word and punctuation run, as in PowerPoint.
|
||||
let mut stops = vec![];
|
||||
for _ in 0..6 {
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowLeft);
|
||||
harness.run_steps(1);
|
||||
stops.push(caret(&harness).caret.1);
|
||||
}
|
||||
assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]);
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowRight);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (0, 5));
|
||||
|
||||
// Ctrl+Shift+Right selects to the next stops.
|
||||
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||
harness.run_steps(1);
|
||||
let t = caret(&harness);
|
||||
assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12)));
|
||||
|
||||
// Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one
|
||||
// before the caret.
|
||||
harness.key_press_modifiers(ctrl, Key::Delete);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Helloworld. Foo bar");
|
||||
harness.key_press(Key::End);
|
||||
harness.key_press_modifiers(ctrl, Key::Backspace);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Helloworld. Foo ");
|
||||
|
||||
// Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends.
|
||||
harness.key_press(Key::Enter);
|
||||
harness.event(egui::Event::Text("Next".into()));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (1, 0));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (0, 0));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowDown);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (1, 0));
|
||||
harness.key_press_modifiers(ctrl_shift, Key::Home);
|
||||
harness.run_steps(1);
|
||||
let t = caret(&harness);
|
||||
assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0)));
|
||||
|
||||
// Ctrl+Shift+> (egui reports the Period key) grows the selected text's font.
|
||||
let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||
let before = size(&mut harness);
|
||||
harness.key_press_modifiers(ctrl_shift, Key::Period);
|
||||
harness.run_steps(1);
|
||||
let after = size(&mut harness).unwrap();
|
||||
assert!(after > before.unwrap_or(0.0), "{before:?} → {after}");
|
||||
harness.key_press_modifiers(ctrl, Key::OpenBracket);
|
||||
harness.run_steps(1);
|
||||
assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
assert!(!app.backstage_open(), "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(
|
||||
|
||||
@@ -29,6 +29,25 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
|
||||
return true;
|
||||
}
|
||||
(S, true, true, false) => "app.saveAsDialog",
|
||||
// File tab: Alt+F opens it on Home with the keyboard in its side menu; Ctrl+P on Print.
|
||||
(F, false, false, true) => {
|
||||
app.open_backstage(crate::backstage::Page::Home);
|
||||
if let Some(b) = app.backstage.as_mut() {
|
||||
b.focus = Some(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(P, true, false, false) => {
|
||||
if app.session.active().is_none() {
|
||||
return false;
|
||||
}
|
||||
app.open_backstage(crate::backstage::Page::Print);
|
||||
return true;
|
||||
}
|
||||
(W, true, false, false) => {
|
||||
app.close_presentation();
|
||||
return true;
|
||||
}
|
||||
(O, true, false, false) => "app.openDialog",
|
||||
(P, true, true, false) => "app.palette",
|
||||
(Comma, true, false, false) => "app.preferences",
|
||||
@@ -133,10 +152,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("New Presentation", "file.new"),
|
||||
("Open…", "app.openDialog"),
|
||||
("-", ""),
|
||||
("Close", "file.close"),
|
||||
("Close", "app.closePresentation"),
|
||||
("Save", "file.save"),
|
||||
("Save As…", "app.saveAsDialog"),
|
||||
("Export…", "app.exportDialog"),
|
||||
("Print…", "app.print"),
|
||||
("-", ""),
|
||||
("Properties", "file.properties"),
|
||||
],
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
"format" => format_shape(app, ui),
|
||||
"background" => background(app, ui),
|
||||
"animation" => animation(app, ui),
|
||||
"selection" => selection(app, ui),
|
||||
"selection" => crate::selpane::show(app, ui),
|
||||
"comments" => comments(app, ui),
|
||||
"designer" => designer(app, ui),
|
||||
_ => {}
|
||||
@@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let _ = Stroke::NONE;
|
||||
}
|
||||
|
||||
fn selection(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let shapes: Vec<(u32, String, bool, &'static str)> =
|
||||
st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect();
|
||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Show All").clicked() {
|
||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
||||
run(app, "shape.visible", json!({"ids": ids, "visible": true}));
|
||||
}
|
||||
if ui.button("Hide All").clicked() {
|
||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
||||
run(app, "shape.visible", json!({"ids": ids, "visible": false}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let n = shapes.len();
|
||||
for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
|
||||
let sel = selected.contains(&id);
|
||||
if sel || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 8.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&name,
|
||||
theme::font(12.5),
|
||||
if hidden { t.text_faint } else { t.text },
|
||||
);
|
||||
ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint);
|
||||
let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false);
|
||||
let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
|
||||
if eye_resp.clicked() {
|
||||
run(app, "shape.visible", json!({"id": id, "visible": hidden}));
|
||||
} else if resp.clicked() {
|
||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
run(app, "edit.select", json!({"ids": [id], "toggle": add}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
||||
d.params = json!({"id": id, "name": name});
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Bring Forward").clicked() {
|
||||
run(app, "arrange.bringForward", json!({"ids": [id]}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Send Backward").clicked() {
|
||||
run(app, "arrange.sendBackward", json!({"ids": [id]}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
||||
d.params = json!({"id": id, "name": name});
|
||||
app.dialog = Some(d);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
let _ = (k, n);
|
||||
}
|
||||
}
|
||||
|
||||
fn comments(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if ui.button("+ New Comment").clicked() {
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
child.spacing_mut().item_spacing.x = 2.0;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() {
|
||||
let _ = app.run("app.home", json!({}));
|
||||
}
|
||||
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
|
||||
@@ -143,9 +143,24 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 };
|
||||
egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
// Tabs.
|
||||
// Tabs. File comes first and opens the File tab's full-window view (Backstage), as in
|
||||
// PowerPoint.
|
||||
let mut x = rect.min.x + 12.0;
|
||||
let y = rect.min.y + 2.0;
|
||||
{
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
|
||||
let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);
|
||||
if resp.on_hover_text("File (Alt+F)").clicked() {
|
||||
app.open_backstage(crate::backstage::Page::Home);
|
||||
}
|
||||
x += w + 2.0;
|
||||
}
|
||||
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
|
||||
for (name, contextual) in all {
|
||||
let font = theme::font(13.0);
|
||||
@@ -1367,7 +1382,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty());
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for o in opts {
|
||||
if ui.button(option_label(o)).clicked() {
|
||||
if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() {
|
||||
run(app, "transition.options", json!({"option": o}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Selection pane: every object on the slide, front-most first, groups expanded with their
|
||||
//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among
|
||||
//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move
|
||||
//! through the list. Everything it changes goes through engine commands.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_model::Shape;
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
pub const ROW_H: f32 = 24.0;
|
||||
const INDENT: f32 = 14.0;
|
||||
|
||||
/// Pane-only state (nothing here is part of the document).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {
|
||||
/// Groups folded shut.
|
||||
pub collapsed: HashSet<u32>,
|
||||
/// Object being renamed and the text typed so far.
|
||||
pub renaming: Option<(u32, String)>,
|
||||
rename_started: bool,
|
||||
/// Last row clicked without Shift (the start of a Shift+click run).
|
||||
pub anchor: Option<u32>,
|
||||
/// Row being dragged and the drop it would make: (index among siblings, line y).
|
||||
pub drag: Option<(u32, Option<(usize, f32)>)>,
|
||||
}
|
||||
|
||||
/// One line of the list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Row {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub depth: usize,
|
||||
pub group: bool,
|
||||
pub hidden: bool,
|
||||
/// Inside a hidden group: drawn dimmed.
|
||||
pub dimmed: bool,
|
||||
pub locked: bool,
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
|
||||
/// The rows as listed: front-most first, children under their group unless it's folded.
|
||||
pub fn rows(shapes: &[Shape], collapsed: &HashSet<u32>) -> Vec<Row> {
|
||||
fn rec(v: &[Shape], depth: usize, parent: Option<u32>, dim: bool, collapsed: &HashSet<u32>, out: &mut Vec<Row>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in v.iter().rev() {
|
||||
out.push(Row {
|
||||
id: sh.id.0,
|
||||
name: sh.name.clone(),
|
||||
depth,
|
||||
group: sh.is_group(),
|
||||
hidden: sh.hidden,
|
||||
dimmed: dim,
|
||||
locked: sh.locked,
|
||||
parent,
|
||||
});
|
||||
if sh.is_group() && !collapsed.contains(&sh.id.0) {
|
||||
rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
rec(shapes, 0, None, false, collapsed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's
|
||||
/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index
|
||||
/// among the siblings in the file's order (0 = back) and the y of the insertion line.
|
||||
pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> {
|
||||
let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect();
|
||||
let first = others.first()?;
|
||||
let mut gaps: Vec<f32> = vec![first.1.top()];
|
||||
gaps.extend(others.iter().map(|(_, r)| r.bottom()));
|
||||
let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?;
|
||||
// `g` siblings sit above the line; the dragged object goes behind them.
|
||||
Some((others.len() - g, *gy))
|
||||
}
|
||||
|
||||
fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// Each row is a widget with an id of its own (keyboard focus sits on the selected row).
|
||||
fn row_id(id: u32) -> Id {
|
||||
Id::new(("selection_pane_row", id))
|
||||
}
|
||||
|
||||
fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label));
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled);
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let rows = rows(st.shapes(), &app.selpane.collapsed);
|
||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||
let has_sel = !selected.is_empty();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||
run(app, "selection.showAll", json!({}));
|
||||
}
|
||||
if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||
run(app, "selection.hideAll", json!({}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() {
|
||||
run(app, "arrange.sendBackward", json!({}));
|
||||
}
|
||||
if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() {
|
||||
run(app, "arrange.bringForward", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
// The list has the keyboard while one of its rows has focus.
|
||||
let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id));
|
||||
if let Some(f) = focused_row {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
}
|
||||
let mut rects: Vec<Rect> = Vec::with_capacity(rows.len());
|
||||
let width = ui.available_width();
|
||||
for (k, row) in rows.iter().enumerate() {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag());
|
||||
rects.push(r);
|
||||
let sel = selected.contains(&row.id);
|
||||
resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name));
|
||||
let painter = ui.painter_at(r);
|
||||
if sel {
|
||||
painter.rect_filled(r, CornerRadius::same(3), t.selected);
|
||||
painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
painter.rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
let x0 = r.min.x + 4.0 + INDENT * row.depth as f32;
|
||||
// Group chevron.
|
||||
if row.group {
|
||||
let open = !app.selpane.collapsed.contains(&row.id);
|
||||
let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0));
|
||||
let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click());
|
||||
let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name);
|
||||
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
|
||||
icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false);
|
||||
if cresp.clicked() {
|
||||
if open {
|
||||
app.selpane.collapsed.insert(row.id);
|
||||
} else {
|
||||
app.selpane.collapsed.remove(&row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let name_x = x0 + 16.0;
|
||||
let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0));
|
||||
let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0));
|
||||
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0));
|
||||
let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone());
|
||||
if let Some(mut buf) = renaming {
|
||||
let edit_id = ui.id().with(("rename", row.id));
|
||||
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
|
||||
if !app.selpane.rename_started {
|
||||
out.request_focus();
|
||||
if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
|
||||
s.cursor.set_char_range(Some(all));
|
||||
s.store(ui.ctx(), edit_id);
|
||||
}
|
||||
app.selpane.rename_started = true;
|
||||
}
|
||||
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
if out.lost_focus() || enter || esc {
|
||||
app.selpane.renaming = None;
|
||||
app.selpane.rename_started = false;
|
||||
if !esc && buf.trim() != row.name && !buf.trim().is_empty() {
|
||||
run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()}));
|
||||
}
|
||||
ui.memory_mut(|m| m.request_focus(row_id(row.id)));
|
||||
} else if let Some((_, s)) = app.selpane.renaming.as_mut() {
|
||||
*s = buf;
|
||||
}
|
||||
} else {
|
||||
let color = if row.dimmed { t.text_faint } else { t.text };
|
||||
painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color);
|
||||
}
|
||||
// Eye and padlock.
|
||||
let ink = if row.dimmed { t.text_faint } else { t.text_dim };
|
||||
icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false);
|
||||
icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false);
|
||||
let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click());
|
||||
let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name);
|
||||
eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label));
|
||||
let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click());
|
||||
let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name);
|
||||
lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label));
|
||||
if eye_resp.clicked() {
|
||||
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||
} else if lock_resp.clicked() {
|
||||
run(app, "shape.lock", json!({"id": row.id}));
|
||||
} else if resp.double_clicked() {
|
||||
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
} else if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
click(app, &rows, k, m.command || m.ctrl, m.shift);
|
||||
ui.memory_mut(|mem| mem.request_focus(row_id(row.id)));
|
||||
}
|
||||
if resp.drag_started() {
|
||||
if !sel {
|
||||
run(app, "selection.select", json!({"id": row.id}));
|
||||
}
|
||||
app.selpane.drag = Some((row.id, None));
|
||||
}
|
||||
context_menu(app, &resp, row);
|
||||
}
|
||||
|
||||
// Dragging: the insertion line among the dragged row's siblings.
|
||||
if let Some((dragged, _)) = app.selpane.drag {
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down());
|
||||
let target = pointer.and_then(|p| {
|
||||
let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?;
|
||||
let blocks = sibling_blocks(&rows, &rects, parent);
|
||||
drop_target(dragged, &blocks, p.y)
|
||||
});
|
||||
app.selpane.drag = Some((dragged, target));
|
||||
if let Some((_, y)) = target {
|
||||
let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width);
|
||||
let p = ui.painter();
|
||||
let s = Stroke::new(2.0, t.accent);
|
||||
p.line_segment([pos2(x0, y), pos2(x1, y)], s);
|
||||
p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s);
|
||||
p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s);
|
||||
}
|
||||
if released {
|
||||
app.selpane.drag = None;
|
||||
if let Some((index, _)) = target {
|
||||
run(app, "arrange.reorder", json!({"id": dragged, "index": index}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keys while the list has focus.
|
||||
if let Some(f) = focused_row
|
||||
&& app.selpane.renaming.is_none()
|
||||
{
|
||||
keys(app, ui, &rows, &selected, f);
|
||||
}
|
||||
// Clicking the empty part of the list doesn't change anything; keep the pane's height.
|
||||
let rest = ui.available_size();
|
||||
if rest.y > 0.0 {
|
||||
ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom.
|
||||
fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option<u32>) -> Vec<(u32, Rect)> {
|
||||
let mut out: Vec<(u32, Rect)> = vec![];
|
||||
let depth_of = |id: Option<u32>| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0);
|
||||
let depth = depth_of(parent);
|
||||
let mut current: Option<(u32, Rect)> = None;
|
||||
for (row, rect) in rows.iter().zip(rects) {
|
||||
if row.parent == parent {
|
||||
if let Some(c) = current.take() {
|
||||
out.push(c);
|
||||
}
|
||||
current = Some((row.id, *rect));
|
||||
} else if row.depth > depth
|
||||
&& let Some((_, r)) = current.as_mut()
|
||||
{
|
||||
*r = r.union(*rect);
|
||||
} else if let Some(c) = current.take() {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(c) = current {
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) {
|
||||
let Some(row) = rows.get(k) else { return };
|
||||
if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) {
|
||||
// A run of rows of the same kind (top level, or inside groups) between the anchor and here.
|
||||
let (lo, hi) = if a <= k { (a, k) } else { (k, a) };
|
||||
let top = row.parent.is_none();
|
||||
let ids: Vec<u32> = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect();
|
||||
run(app, "selection.select", json!({"ids": ids, "add": ctrl}));
|
||||
return;
|
||||
}
|
||||
run(app, "selection.select", json!({"id": row.id, "toggle": ctrl}));
|
||||
app.selpane.anchor = Some(row.id);
|
||||
}
|
||||
|
||||
fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) {
|
||||
let (up, down, home, end, f2, esc) = ui.input(|i| {
|
||||
(
|
||||
i.key_pressed(egui::Key::ArrowUp),
|
||||
i.key_pressed(egui::Key::ArrowDown),
|
||||
i.key_pressed(egui::Key::Home),
|
||||
i.key_pressed(egui::Key::End),
|
||||
i.key_pressed(egui::Key::F2),
|
||||
i.key_pressed(egui::Key::Escape),
|
||||
)
|
||||
});
|
||||
let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id));
|
||||
let to = if up {
|
||||
Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0))
|
||||
} else if down {
|
||||
Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0))
|
||||
} else if home {
|
||||
Some(0)
|
||||
} else if end {
|
||||
Some(rows.len().saturating_sub(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(k) = to
|
||||
&& let Some(r) = rows.get(k)
|
||||
&& Some(k) != cur
|
||||
{
|
||||
run(app, "selection.select", json!({"id": r.id}));
|
||||
app.selpane.anchor = Some(r.id);
|
||||
ui.memory_mut(|m| m.request_focus(row_id(r.id)));
|
||||
}
|
||||
if f2
|
||||
&& let [one] = selected
|
||||
&& let Some(r) = rows.iter().find(|r| r.id == *one)
|
||||
{
|
||||
app.selpane.renaming = Some((r.id, r.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
}
|
||||
if esc {
|
||||
ui.memory_mut(|m| m.surrender_focus(row_id(focused)));
|
||||
}
|
||||
}
|
||||
|
||||
fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) {
|
||||
resp.context_menu(|ui| {
|
||||
for (label, cmd) in [
|
||||
("Bring to Front", "arrange.bringToFront"),
|
||||
("Bring Forward", "arrange.bringForward"),
|
||||
("Send Backward", "arrange.sendBackward"),
|
||||
("Send to Back", "arrange.sendToBack"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
run(app, cmd, json!({"ids": [row.id]}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename").clicked() {
|
||||
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() {
|
||||
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() {
|
||||
run(app, "shape.lock", json!({"id": row.id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r(id: u32, top: f32) -> (u32, Rect) {
|
||||
(id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_picks_the_nearest_gap() {
|
||||
// Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1.
|
||||
let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)];
|
||||
// Above everything: in front of all three others.
|
||||
assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0)));
|
||||
// Between 4 and 3: one sibling above → index 2.
|
||||
assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0)));
|
||||
// Below the last other row: the back.
|
||||
assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0)));
|
||||
// Nothing else to drop among.
|
||||
assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selpane_tests.rs"]
|
||||
mod ui_tests;
|
||||
@@ -0,0 +1,255 @@
|
||||
//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye,
|
||||
//! the padlock, renaming and the keyboard.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Modifiers, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open.
|
||||
fn harness() -> (Harness<'static, SlideApp>, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
ids.push(g);
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.pane = Some("selection".into());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn names(h: &Harness<'_, SlideApp>) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(h.state().session.active().unwrap().shapes())
|
||||
}
|
||||
|
||||
fn selected(h: &Harness<'_, SlideApp>) -> Vec<String> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect {
|
||||
h.get_by_label(name).rect()
|
||||
}
|
||||
|
||||
fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(st.shapes(), &mut |s, _| {
|
||||
if found.is_none() && s.name == name {
|
||||
found = Some(s);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_front_most_first_with_groups_expanded() {
|
||||
let (h, _) = harness();
|
||||
let ys: Vec<f32> = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect();
|
||||
// G is in front, then its children front-most first (E above D), then C, B, A.
|
||||
let order = ["G", "E", "D", "C", "B", "A"];
|
||||
let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect();
|
||||
by_y.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
assert_eq!(by_y.iter().map(|x| x.0).collect::<Vec<_>>(), order);
|
||||
// Children are indented under the group (the name's row starts at the same x; the group
|
||||
// has a fold button).
|
||||
assert!(h.query_by_label("Collapse G").is_some());
|
||||
assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_ctrl_click_and_shift_click_select() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("A").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["A"]);
|
||||
h.get_by_label("C").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["A", "C"]);
|
||||
h.get_by_label("A").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["C"]);
|
||||
// Shift+click: the run from the last click (C) down to A.
|
||||
h.get_by_label("C").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("A").click_modifiers(Modifiers::SHIFT);
|
||||
h.run_steps(2);
|
||||
let mut sel = selected(&h);
|
||||
sel.sort();
|
||||
assert_eq!(sel, ["A", "B", "C"]);
|
||||
// A child of the group.
|
||||
h.get_by_label("D").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["D"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_and_padlock_toggle_and_undo() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("Hide B").click();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "B").hidden);
|
||||
assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it");
|
||||
h.get_by_label("Lock G").click();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children");
|
||||
h.get_by_label("Unlock D").click();
|
||||
h.run_steps(2);
|
||||
assert!(!shape(&h, "D").locked && shape(&h, "G").locked);
|
||||
h.get_by_label("Hide All").click();
|
||||
h.run_steps(2);
|
||||
assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden));
|
||||
h.get_by_label("Show All").click();
|
||||
h.run_steps(2);
|
||||
assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "A").hidden);
|
||||
}
|
||||
|
||||
/// Drag a row with the mouse: press, move in steps, release.
|
||||
fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) {
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
let t = i as f32 / 6.0;
|
||||
h.hover_at(from + (to - from) * t);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_restacks_among_siblings() {
|
||||
let (mut h, _) = harness();
|
||||
assert_eq!(names(&h), "A B C G[D E]");
|
||||
// A (bottom row) dragged above G (top row): A goes to the front.
|
||||
let (a, g) = (row(&h, "A"), row(&h, "G"));
|
||||
drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0));
|
||||
assert_eq!(names(&h), "B C G[D E] A");
|
||||
assert_eq!(selected(&h), ["A"], "the dragged object is selected");
|
||||
// C dragged to the bottom of the list: the back.
|
||||
let (c, b) = (row(&h, "C"), row(&h, "B"));
|
||||
drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0));
|
||||
assert_eq!(names(&h), "C B G[D E] A");
|
||||
// D (a child) dragged far above its group stays in the group, at its front.
|
||||
let (d, a) = (row(&h, "D"), row(&h, "A"));
|
||||
drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0));
|
||||
assert_eq!(names(&h), "C B G[E D] A");
|
||||
// B dropped over the group lands among the top-level objects (here just in front of the
|
||||
// group), never inside it.
|
||||
let (b, g) = (row(&h, "B"), row(&h, "G"));
|
||||
drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0));
|
||||
assert_eq!(names(&h), "C G[E D] B A");
|
||||
// Each drop is one undo step.
|
||||
for _ in 0..4 {
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(names(&h), "A B C G[D E]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_buttons() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("A").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[D E]");
|
||||
h.get_by_label("D").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group");
|
||||
h.get_by_label("Send Backward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[D E]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_renames_in_place() {
|
||||
let (mut h, _) = harness();
|
||||
let b = row(&h, "B").center();
|
||||
h.hover_at(b);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming");
|
||||
// The whole name is selected: typing replaces it.
|
||||
h.event(egui::Event::Text("Bravo".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id);
|
||||
assert!(h.state().selpane.renaming.is_none());
|
||||
// Escape cancels.
|
||||
h.get_by_label("Bravo").click();
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::F2);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object");
|
||||
h.event(egui::Event::Text("Nope".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Bravo").is_some());
|
||||
assert!(h.query_by_label("Nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_through_the_list_and_collapse_hides_children() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("G").click();
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["E"]);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["C"]);
|
||||
// The arrows moved through the list: C itself didn't move on the slide.
|
||||
assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0));
|
||||
h.key_press(egui::Key::ArrowUp);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["D"]);
|
||||
h.get_by_label("Collapse G").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some());
|
||||
h.get_by_label("Expand G").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("D").is_some());
|
||||
}
|
||||
+58
-16
@@ -61,6 +61,10 @@ struct Transition {
|
||||
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
|
||||
from: Option<usize>,
|
||||
morph: Option<TextureHandle>,
|
||||
/// Backgrounds and contents of the old and new slides, rendered once when the transition
|
||||
/// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new
|
||||
/// back, new content.
|
||||
parts: Option<[Option<TextureHandle>; 4]>,
|
||||
start: f64,
|
||||
kind: String,
|
||||
option: String,
|
||||
@@ -138,7 +142,7 @@ impl Show {
|
||||
self.last_viewed = from;
|
||||
}
|
||||
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
|
||||
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
|
||||
Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -202,24 +206,43 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect {
|
||||
Rect::from_center_size(avail.center(), vec2(sw * s, sh * s))
|
||||
}
|
||||
|
||||
/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective
|
||||
/// cards subdivided so their pictures stay straight) with per-vertex opacity.
|
||||
fn quad_mesh(tex: Option<egui::TextureId>, rect: Rect, l: &Layer, color: Color32) -> Mesh {
|
||||
let mut m = match tex {
|
||||
Some(id) => Mesh::with_texture(id),
|
||||
None => Mesh::default(),
|
||||
};
|
||||
let a = (l.alpha.clamp(0.0, 1.0) * 255.0) as u8;
|
||||
let col = if tex.is_some() { Color32::from_white_alpha(a) } else { color.gamma_multiply(l.alpha.clamp(0.0, 1.0) as f32) };
|
||||
for k in 0..4 {
|
||||
let q = l.quad.get(k).copied().unwrap_or([0.0, 0.0]);
|
||||
let uv = l.uv.get(k).copied().unwrap_or([0.0, 0.0]);
|
||||
let p = pos2(rect.min.x + q[0] as f32 * rect.width(), rect.min.y + q[1] as f32 * rect.height());
|
||||
m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(uv[0] as f32, uv[1] as f32), color: col });
|
||||
let (verts, tris) = deckcraft_anim::layer_mesh(l);
|
||||
for v in &verts {
|
||||
let a = if v.a.is_finite() { v.a.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let col = if tex.is_some() { Color32::from_white_alpha((a * 255.0).round() as u8) } else { color.gamma_multiply(a as f32) };
|
||||
let p = pos2(rect.min.x + v.pos[0] as f32 * rect.width(), rect.min.y + v.pos[1] as f32 * rect.height());
|
||||
m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(v.uv[0] as f32, v.uv[1] as f32), color: col });
|
||||
}
|
||||
for t in tris {
|
||||
m.add_triangle(t[0], t[1], t[2]);
|
||||
}
|
||||
m.add_triangle(0, 1, 2);
|
||||
m.add_triangle(0, 2, 3);
|
||||
m
|
||||
}
|
||||
|
||||
/// Render the backgrounds and contents of a transition's two slides into textures, once.
|
||||
fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option<usize>, to: usize, size: (u32, u32)) -> [Option<TextureHandle>; 4] {
|
||||
use deckcraft_render::SlidePart;
|
||||
let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
|
||||
let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() };
|
||||
let tex = |i: usize, part: SlidePart, name: &str| {
|
||||
let img = deckcraft_render::render_slide_part(doc, i, part, &opts);
|
||||
ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)
|
||||
};
|
||||
[
|
||||
from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")),
|
||||
from.map(|i| tex(i, SlidePart::Content, "trans-old-content")),
|
||||
Some(tex(to, SlidePart::Backdrop, "trans-new-back")),
|
||||
Some(tex(to, SlidePart::Content, "trans-new-content")),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else {
|
||||
@@ -470,18 +493,37 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
tr.morph = Some(frame);
|
||||
drew = true;
|
||||
ctx.request_repaint();
|
||||
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
|
||||
} else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) {
|
||||
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
|
||||
if p < 1.0 {
|
||||
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {
|
||||
// Slides split into background and content are rendered once per transition;
|
||||
// every frame only composites cached textures.
|
||||
if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) {
|
||||
tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size));
|
||||
}
|
||||
let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0);
|
||||
let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten());
|
||||
let parts = [part(0), part(1), part(2), part(3)];
|
||||
for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) {
|
||||
let old_or_black = |t: Option<&TextureHandle>| match t {
|
||||
Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE),
|
||||
None => quad_mesh(None, srect, &l, Color32::BLACK),
|
||||
};
|
||||
let mesh = match l.source {
|
||||
Source::Old => match &tr.old {
|
||||
Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE),
|
||||
None => quad_mesh(None, srect, &l, Color32::BLACK),
|
||||
},
|
||||
Source::Old => old_or_black(tr.old.as_ref()),
|
||||
Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE),
|
||||
Source::Black => quad_mesh(None, srect, &l, Color32::BLACK),
|
||||
Source::White => quad_mesh(None, srect, &l, Color32::WHITE),
|
||||
Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())),
|
||||
Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE),
|
||||
Source::OldContent => match &parts[1] {
|
||||
Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE),
|
||||
None => continue,
|
||||
},
|
||||
Source::NewContent => match &parts[3] {
|
||||
Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
@@ -191,6 +191,16 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
|
||||
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);
|
||||
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
|
||||
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
|
||||
// unhinted, they draw.
|
||||
for (name, d) in defs.font_data.iter_mut() {
|
||||
if name.contains("BIZ UDPGothic") {
|
||||
let mut data = (**d).clone();
|
||||
data.tweak.hinting = Some(false);
|
||||
*d = std::sync::Arc::new(data);
|
||||
}
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
@@ -221,3 +231,38 @@ pub fn font(size: f32) -> egui::FontId {
|
||||
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
|
||||
/// has ink (a glyph image).
|
||||
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(super::font_definitions(&[]));
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
let mut out = vec![];
|
||||
ctx.fonts_mut(|f| {
|
||||
for size in [11.0, 12.0, 13.0, 16.0] {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
|
||||
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
|
||||
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
|
||||
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
// so notes and the outline showed gaps where they were.
|
||||
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
|
||||
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Transitions and animations: parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; effect lists counted from `crates/model/src/anim.rs` against PowerPoint 16.113's galleries) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Transitions and animations are what make a deck a presentation, and the part of PowerPoint people
|
||||
notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
|
||||
@@ -9,14 +9,31 @@ notice first when it's missing. Scores roll up into [target-app-parity.md](targe
|
||||
## Transitions: 48 of 48
|
||||
|
||||
`crates/model/src/anim.rs` `TRANSITIONS` lists every transition in PowerPoint's gallery, in its
|
||||
three groups, with its Effect Options. All 48 round-trip through PPTX
|
||||
(`every_transition_kind_round_trips`), including the PowerPoint 2010+ `p14`/`p15` ones.
|
||||
three groups, with its Effect Options (now also Page Curl Single, Glitter Hexagons, Shred In/Out
|
||||
and Zoom and Rotate). All 48 round-trip through PPTX with the elements, attributes and defaults
|
||||
PowerPoint itself writes (`every_transition_kind_round_trips`,
|
||||
`transition_xml_matches_powerpoints_elements`, `transition_defaults_read_like_powerpoint`): 145 of
|
||||
145 PowerPoint-built test slides reopen in PowerPoint with the same effect after a DeckCraft
|
||||
save.
|
||||
|
||||
**Measured against PowerPoint (2026-10-13).** Synthetic two-colour decks with asymmetric markers
|
||||
were built in PowerPoint for Windows over COM with every transition and option (150 variants,
|
||||
2 s each), exported with `CreateVideo` at 30 fps, and compared frame by frame with
|
||||
`deckcraft-cli render-transition` at the same progress (`plan/powerpoint/tools/synth_transitions.ps1`,
|
||||
`trans_compare.py`). Mean absolute difference over all variants at t = 0.1…0.9 (0–255): **35.7
|
||||
before, 15.0 after**. Curves (fades are a smoothstep, moves ease in-out; Cover, Uncover and Pan
|
||||
have their own measured curves), directions and option meanings (e.g. Glitter starts on the
|
||||
option's side, Ripple's corner is where the wave heads, Wind/Airplane/Origami "Left" is `invX`),
|
||||
soft edges (Wipe, Split, Shape, Clock, Ripple) and the Dynamic Content transitions moving the
|
||||
slides' contents over cross-fading backgrounds now follow PowerPoint. 3-D transitions are
|
||||
perspective-projected cards (`crates/anim/src/transition_geom.rs`) drawn with
|
||||
perspective-correct subdivided meshes.
|
||||
|
||||
| Group | PowerPoint | DeckCraft | Fidelity |
|
||||
|---|---|---|---|
|
||||
| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | 2-D, close to PowerPoint |
|
||||
| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | 3-D ones are fake 3-D (trapezoid quads, `crates/anim/src/transition.rs`); particle effects (Vortex, Shred, Fracture, Crush) approximate |
|
||||
| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Same approach |
|
||||
| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | Matches PowerPoint's frames (mean diff 1–8; Random Bars and Reveal Through Black ~15) |
|
||||
| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | Perspective 3-D cards with measured motion; cloth, paper and particle effects (Curtains, Peel Off, Page Curl, Airplane, Origami, Fracture, Crush, Honeycomb, Vortex, Shred) are flat approximations of PowerPoint's meshes |
|
||||
| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Contents move over cross-fading backgrounds, as in PowerPoint (mean diff 5–17) |
|
||||
|
||||
| Transition feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
@@ -28,7 +45,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX
|
||||
| Morph: Words and Characters | yes | yes (#5) |
|
||||
| Morph: geometry (circle to star) | yes | yes (#27) |
|
||||
| Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink |
|
||||
| Frame-by-frame comparison with PowerPoint | | **not done** |
|
||||
| Frame-by-frame comparison with PowerPoint | | done (Windows, 2026-10-13); headless frames: `deckcraft-cli render-transition` |
|
||||
|
||||
## Animations: 64 of ~190 effects
|
||||
|
||||
@@ -69,11 +86,12 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Rewind and other effect options on round trip (#77) | 1–2 |
|
||||
| Transition sounds | 1–2 |
|
||||
| Morph across pictures, `!!` names, align once per transition (#67) | 2–4 |
|
||||
| Reference captures from PowerPoint for the 3-D transitions, then tuning | 2–4 |
|
||||
| **Total** | **~14–26** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) |
|
||||
| Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 |
|
||||
| **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options |
|
||||
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DeckCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main at d556ff9; AGENTS.md §4 keeps the rules) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
|
||||
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
|
||||
@@ -49,8 +49,12 @@ Programmatic calls never open dialogs. Pointer gestures go through `Session::poi
|
||||
## Rendering and the show
|
||||
|
||||
The renderer draws a slide to an RGBA pixmap on the CPU; the UI uploads it as an egui texture.
|
||||
Transitions are layers of textured quads over the old and new slide images (fake 3-D from
|
||||
trapezoids). The show window and the presenter view are egui viewports; animation state per shape
|
||||
Transitions are layers of textured quads over the old and new slide images, or over their
|
||||
backgrounds and contents rendered apart (Dynamic Content), from `anim::transition_frame`: 3-D
|
||||
effects project cards through a perspective camera and `anim::layer_mesh` subdivides them so
|
||||
textures stay straight; soft edges are per-corner opacities. The show renders the slides once per
|
||||
transition and only composites cached textures each frame; `render::composite_transition` is the
|
||||
CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape
|
||||
comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and
|
||||
overlays an invisible real-text layer with embedded font subsets.
|
||||
|
||||
@@ -74,4 +78,5 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
@@ -15,7 +15,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
|
||||
| `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) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
|---|---|---|---|
|
||||
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
|
||||
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
|
||||
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped |
|
||||
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures |
|
||||
| HEIC | yes (macOS) | no | |
|
||||
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
|
||||
| WMA | yes (Windows) | embedded, not playable | |
|
||||
@@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | No notes-master editing view |
|
||||
| Handout master | no | no | |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
@@ -93,11 +93,13 @@ the round trip even though DeckCraft doesn't model it.
|
||||
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
|
||||
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
|
||||
2. The round-trip losses above (#70, #74, #77).
|
||||
3. SVG rendering, and text inside EMF/WMF.
|
||||
3. SVG rendering, and clip regions inside EMF/WMF.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
|
||||
+10
-9
@@ -20,7 +20,7 @@ revision history.
|
||||
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
|
||||
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
|
||||
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
@@ -77,12 +77,12 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
### SVG pictures don't render
|
||||
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
|
||||
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens
|
||||
and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG
|
||||
with a PNG fallback.
|
||||
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text
|
||||
drawn inside an EMF/WMF is missing.
|
||||
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h.
|
||||
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens,
|
||||
pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn).
|
||||
Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback.
|
||||
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An
|
||||
EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip.
|
||||
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF/WMF clip regions 2–4 h.
|
||||
|
||||
### Template, slide-show and macro variants are saved as plain `.pptx`
|
||||
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
|
||||
@@ -197,8 +197,8 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
blocker.**
|
||||
- **Format Shape pane thinner than PowerPoint's** (Fill/Line/Effects/Size/Text Box pages, picture
|
||||
and texture options, 3-D). 6–10 h. **Beta blocker.**
|
||||
- **First-run and start screen** (#17, #76: missing File overlay, broken layout; recent files and
|
||||
templates thin). 3–5 h.
|
||||
- **File tab depth** (#76 landed the Backstage): no online templates, cloud places, printer
|
||||
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 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 |
|
||||
|
||||
@@ -7,13 +7,13 @@ The summary between `<!-- SUMMARY -->
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 60 | 12 | 0 | 92% |
|
||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 71% |
|
||||
| Application shell | 77% |
|
||||
| Files | 71% |
|
||||
| Slides | 90% |
|
||||
| Views | 90% |
|
||||
@@ -36,13 +36,13 @@ The summary between `<!-- SUMMARY -->
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
|
||||
## Rows
|
||||
|
||||
| Area | Feature | Priority | Status |
|
||||
|---|---|---|---|
|
||||
| Application shell | Start screen (new, templates/themes, recent) | P0 | P |
|
||||
| Application shell | Start screen (new, templates/themes, recent) | P0 | D |
|
||||
| Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D |
|
||||
| Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P |
|
||||
| Application shell | Quick Access toolbar / title bar commands | P1 | P |
|
||||
|
||||
+67
-4
@@ -1,6 +1,8 @@
|
||||
# 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-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **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
|
||||
@@ -16,20 +18,49 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
|
||||
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
|
||||
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
|
||||
| Start screen: new, themes, templates, recent, open | yes | yes, thin (#17 broken layout, #76 missing overlay) | 3–5 h |
|
||||
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
|
||||
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
|
||||
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
|
||||
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
|
||||
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
|
||||
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
|
||||
| Quick Access Toolbar customization | yes | no | P1 partial |
|
||||
|
||||
## File tab (Backstage)
|
||||
|
||||
Observed black-box in PowerPoint for Windows (Microsoft 365, Version 2609, October 2026) on a
|
||||
synthetic presentation. Clicking **File** (or Alt+F) replaces the ribbon, slides and status bar with
|
||||
a full-window view; the title bar stays. A side menu 160 points wide, a step darker than the page,
|
||||
lists Back (a circled arrow), Home, New, Open, Share, a rule, Info, Save, Save As, Print, Export,
|
||||
Close and, at the bottom under a rule, Account and Options. Entries are 40 points tall with an
|
||||
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
|
||||
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
|
||||
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
|
||||
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and
|
||||
menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|
||||
| Page | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me |
|
||||
| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes and the sample deck (`file.new {theme}`). No online templates |
|
||||
| Open | Recent, Shared with Me, OneDrive, This PC, Add a Place, Browse; Presentations / Folders; Recover Unsaved Presentations | Recent and Browse (`app.openDialog`), recent presentations with a search box (`file.open`), Recover Unsaved Presentations (`file.recovery.open`). No cloud places or Folders tab |
|
||||
| Info | Name and path, Upload / Share / Copy path / Open file location; Protect, Inspect, Version History, Manage Presentation; Properties, Related Dates, Related People | Name and path, Copy path, Open file location; Inspect Presentation (Accessibility Checker), Manage Presentation (Revert); Size, Slides, Hidden slides and editable Title, Subject, Tags, Categories, Company, Comments, Author (`file.info`, `file.properties`). No Protect, Version History or dates |
|
||||
| Save | Saves; a presentation never saved goes to Save As | Same (`file.save`) |
|
||||
| Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) |
|
||||
| Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour |
|
||||
| Share | Cloud sharing | Not offered |
|
||||
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package |
|
||||
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
|
||||
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
|
||||
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
|
||||
|
||||
## Panes and dialogs
|
||||
|
||||
| Pane / dialog | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
||||
| Format Background pane | yes | yes |
|
||||
| Selection Pane | yes | yes |
|
||||
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes |
|
||||
| Design Ideas (Designer) | yes (cloud) | no |
|
||||
@@ -64,6 +95,35 @@ group/ungroup, bold/italic/underline, alignment, font size, list levels, Format
|
||||
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
||||
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
|
||||
|
||||
### Text editing keys
|
||||
|
||||
Observed black-box in PowerPoint for Windows (Microsoft 365, October 2026) on synthetic text, and
|
||||
pinned by tests in `crates/model/src/words.rs`, `crates/model/src/edit.rs` and
|
||||
`crates/engine/src/tests_keys.rs` (#268). Windows and Linux use Ctrl; the Mac uses Option for words
|
||||
and Cmd for line and text ends (`Session::mac_keys`).
|
||||
|
||||
| Keys | Behaviour |
|
||||
|---|---|
|
||||
| Ctrl+Left / Right | Previous / next word stop. A stop is the start of every word, punctuation run, symbol run, tab, line break and CJK character, plus the paragraph's end; spaces belong to the word before them. `it's`, `baz-qux`, `a-`, `3.14`, `1,000`, `3:45` are one word; `e.g.`, `a_b`, `x@y.com`, `dogs'` are not. Every CJK ideograph, kana and Hangul syllable is a word. Combining marks and emoji sequences never split. With a selection: Right goes on from its end, Left from its start |
|
||||
| Ctrl+Shift+Left / Right | Moves the selection's moving end by the same stops |
|
||||
| Ctrl+Up / Down | Start of the paragraph (then of the previous one) / start of the next paragraph (end of the text in the last one); Shift extends |
|
||||
| Ctrl+Backspace | Deletes back to the previous stop (word plus its trailing spaces); at a paragraph's start joins it to the previous paragraph |
|
||||
| Ctrl+Delete | Inside a word: the rest of it. On spaces after a word: the spaces and the next word. At a word's start: the word and its spaces. Then smart spacing: spaces left before closing punctuation (`ab cd, ef` → `ab, ef`) or at the paragraph's end after a word go; sentence punctuation left touching a word gets a space (`cd...ef gh` → `cd... gh`). At a paragraph's end joins the next paragraph |
|
||||
| Ctrl+Backspace / Delete with a selection | Leave the selection and delete the word part before / after its start |
|
||||
| Right-to-left paragraph | Left and Right (with or without Ctrl) swap: Left goes forward in the text. Arabic or Hebrew words in a left-to-right paragraph move in logical order |
|
||||
| Home / End | Start / end of the visual line. End on a wrapped line goes past its trailing space and the caret stays drawn at that line's end; End again stays; Home from there returns to the same line's start |
|
||||
| Ctrl+Home / End | Start / end of the text; Shift extends |
|
||||
| Ctrl+Shift+> / <, Ctrl+] / [ | Next / previous size in the font size list (18 → 20 → 24) |
|
||||
| Ctrl+B / I / U, Ctrl+Space | With a caret strictly inside a word, the whole word (without trailing spaces); at a word's edge, only what is typed next |
|
||||
| Ctrl+E / L / R / J | Center / left / right / justify the paragraphs in the selection |
|
||||
| Shift+F3 | Cycles lowercase → Capitalize Each Word → UPPERCASE → lowercase; other mixes go to UPPERCASE first. A caret in a word changes that word and moves to its start |
|
||||
| Shift+Enter | Line break |
|
||||
| Double-click / triple-click | The word without its trailing spaces (a CJK character on its own) / the paragraph including its paragraph mark |
|
||||
|
||||
Quirks kept on purpose because PowerPoint has them: Ctrl+Left (not Ctrl+Right) also stops at
|
||||
each no-break or ideographic space, and treats an emoji followed by punctuation as one word. Tab, Shift+Tab and
|
||||
Alt+Shift+arrows in text are handled elsewhere (#78, #93).
|
||||
|
||||
## Visual fidelity
|
||||
|
||||
Colours, spacing, ribbon card and tab geometry follow the observations in
|
||||
@@ -78,7 +138,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
|
||||
| Format Shape pane depth | 6–10 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
|
||||
| Start screen, window chrome, system appearance, palette fixes (#17, #41, #71, #76, #79) | 4–7 |
|
||||
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
|
||||
| Grid Options, edit points UI, crop depth | 4–7 |
|
||||
| Multiple windows | 3–5 |
|
||||
| A screenshot comparison pass on the most-used tabs | 2–4 |
|
||||
@@ -88,5 +148,8 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
|
||||
| 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