mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
16
Commits
Generated
+1
@@ -1063,6 +1063,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -128,7 +128,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | 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 | 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 |
|
||||
@@ -140,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -37,3 +37,7 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
s.add_document(DocState::new(p, path, title))
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
s.add_document(st)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+213
-15
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||
// The input method's composition: shown in place, an undo step only once committed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||
enabled: has_text,
|
||||
run: compose,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(
|
||||
noundo "text.commit",
|
||||
"Commit Composition",
|
||||
[],
|
||||
None,
|
||||
"{text} — the input method's result replaces its composition, as one undo step",
|
||||
has_text,
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(
|
||||
"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),
|
||||
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
||||
))
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||
/// table cells, which aren't laid out on the slide here.
|
||||
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let mut body = sh.text.clone().unwrap_or_default();
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
// 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)))
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -184,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]}))
|
||||
}
|
||||
@@ -340,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||
fn ime_text(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||
let Some(t) = &st.selection.text else { return false };
|
||||
let Some(body) = body_of(st, t) else { return false };
|
||||
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||
}
|
||||
|
||||
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||
/// goes back to how it was before the composition began.
|
||||
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||
let n = text.chars().count();
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x < y).then_some((x.min(n), y.min(n)))
|
||||
});
|
||||
let current = s.doc()?.composition.clone();
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
match current {
|
||||
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||
let (start, props) = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||
let c = ed::insert(body, at, &text, props.clone());
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
(at, props)
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
Some(c) if text.is_empty() => {
|
||||
let st = s.doc_mut()?;
|
||||
st.doc = c.before;
|
||||
st.selection = c.before_selection;
|
||||
st.composition = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"composing": false}))
|
||||
}
|
||||
Some(c) => {
|
||||
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||
edit_text(s, |body, t| {
|
||||
ed::delete(body, start, (start.0, start.1 + len));
|
||||
let e = ed::insert(body, start, &text, props);
|
||||
t.anchor = e;
|
||||
t.caret = e;
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||
compose(s, &json!({"text": text}))?;
|
||||
}
|
||||
s.end_composition();
|
||||
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||
}
|
||||
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||
let mut out = vec![];
|
||||
let mut add = |a: usize, b: usize, thick: bool| {
|
||||
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||
}
|
||||
};
|
||||
add(0, c.len, false);
|
||||
if let Some((a, b)) = c.active {
|
||||
add(a, b.min(c.len), true);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||
insert_text(s, &text, None)
|
||||
@@ -349,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
|
||||
@@ -382,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()?;
|
||||
@@ -391,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() {
|
||||
@@ -427,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> {
|
||||
@@ -479,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()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
for slide in &p.slides {
|
||||
let Some(ctx) = Ctx::for_slide(p, slide) else { continue };
|
||||
deckcraft_model::walk(&slide.shapes, &mut |s, _| {
|
||||
if let Some(body) = &s.text {
|
||||
body_fonts(&ctx, s, body, &mut add);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &s.kind {
|
||||
for cell in t.rows.iter().flat_map(|r| &r.cells) {
|
||||
body_fonts(&ctx, s, &cell.text, &mut add);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) {
|
||||
for para in &body.paragraphs {
|
||||
let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
if !has_text {
|
||||
continue;
|
||||
}
|
||||
for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) {
|
||||
let r = resolve::run(ctx, s, para, &run.props);
|
||||
add(resolve::font_family(ctx, &r));
|
||||
}
|
||||
let pp = resolve::para(ctx, s, para);
|
||||
if matches!(pp.bullet, Some(Bullet::Char { .. }))
|
||||
&& let Some(f) = pp.bullet_font.as_deref()
|
||||
{
|
||||
add(ctx.master.theme.font(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-11
@@ -20,28 +20,49 @@ 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`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
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" => {
|
||||
@@ -116,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",
|
||||
@@ -126,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 {
|
||||
@@ -136,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()?;
|
||||
@@ -151,6 +220,45 @@ impl Session {
|
||||
self.execute("text.insert", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
||||
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
||||
/// `active` is the part being converted, in characters of `text`.
|
||||
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
let mut p = json!({"text": text});
|
||||
if let Some((a, b)) = active {
|
||||
p["active"] = json!([a, b]);
|
||||
}
|
||||
self.execute("text.compose", &p)
|
||||
}
|
||||
|
||||
/// An input method's result (#101): replaces the composition as one undo step.
|
||||
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
||||
if st.selection.shapes.len() != 1 || text.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.execute("text.edit", &json!({"id": id}))?;
|
||||
self.execute("text.selectAll", &json!({}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -67,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 {
|
||||
@@ -76,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).
|
||||
@@ -130,6 +139,24 @@ pub struct Interaction {
|
||||
pub selection: Selection,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
|
||||
/// before it began makes the whole composition one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Composition {
|
||||
/// Where the composed text starts: (paragraph, char).
|
||||
pub start: (usize, usize),
|
||||
/// Its length in characters (it never spans paragraphs).
|
||||
pub len: usize,
|
||||
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
|
||||
pub active: Option<(usize, usize)>,
|
||||
/// The formatting the composed text is typed with (as `text.insert` would choose).
|
||||
pub props: Option<deckcraft_model::text::RunProps>,
|
||||
/// The presentation and selection before the composition began.
|
||||
pub before: Arc<Presentation>,
|
||||
pub before_selection: Selection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Presentation>,
|
||||
@@ -141,6 +168,11 @@ pub struct DocState {
|
||||
pub interaction: Option<Interaction>,
|
||||
pub uid: u64,
|
||||
pub title: String,
|
||||
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||
/// documents); the app mentions them once.
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// An input method's composition in progress ([`Composition`]).
|
||||
pub composition: Option<Composition>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -158,6 +190,8 @@ impl DocState {
|
||||
interaction: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -313,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 {
|
||||
@@ -340,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.
|
||||
@@ -398,6 +437,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
if !named {
|
||||
@@ -448,10 +491,24 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
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;
|
||||
@@ -496,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_keys;
|
||||
|
||||
@@ -504,6 +504,30 @@ fn hyperlink_on_a_word_or_shape_links_it() {
|
||||
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
||||
}
|
||||
|
||||
/// A .pptx (base64) whose first slide's title is set in `font`.
|
||||
fn deck_in_font(font: &str) -> String {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some(font.into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"]));
|
||||
assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]);
|
||||
// A font that is there (bundled or installed) isn't listed; new documents list nothing.
|
||||
let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||
@@ -543,3 +567,210 @@ fn inserted_hyperlinks_look_like_links() {
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
|
||||
/// like any other; one can be dragged back onto the slide or out of it.
|
||||
#[test]
|
||||
fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
// Inserting off the slide keeps the box where it was asked for.
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// Hover and click wholly off the slide.
|
||||
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
|
||||
// Drag it further off, then back onto the slide.
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
|
||||
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
|
||||
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
|
||||
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
|
||||
// The off-slide half of a straddling shape is hit too.
|
||||
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
// A marquee started on the pasteboard selects what it encloses there.
|
||||
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
}
|
||||
|
||||
fn title_text(s: &Session) -> String {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
|
||||
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
|
||||
/// committed text is one undo step, and nothing on the way is an undo step of its own.
|
||||
#[test]
|
||||
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A "})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
s.compose_text(pre, None).unwrap();
|
||||
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
|
||||
let c = s.doc().unwrap().composition.clone().unwrap();
|
||||
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
|
||||
}
|
||||
s.commit_text("한글").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A ");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
|
||||
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
|
||||
// then a new preedit.
|
||||
s.compose_text("ㄴ", None).unwrap();
|
||||
s.compose_text("", None).unwrap();
|
||||
s.commit_text("ㄴ").unwrap();
|
||||
s.compose_text("ㅁ", None).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
|
||||
s.commit_text("마").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ마");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ");
|
||||
}
|
||||
|
||||
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
|
||||
/// the text and the selection it replaced are as they were, with no undo step.
|
||||
#[test]
|
||||
fn ime_cancel_restores_the_text_and_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.compose_text("せかい", Some((0, 3))).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
|
||||
s.compose_text("世界", Some((0, 9))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
|
||||
s.compose_text("", None).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello world");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
assert!(s.doc().unwrap().revision > rev, "redrawn");
|
||||
// A commit of nothing without a composition is nothing.
|
||||
s.commit_text("").unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
// A commit without a preedit (some input methods send only the result) is one step.
|
||||
s.commit_text("世界").unwrap();
|
||||
assert_eq!(title_text(&s), "Hello 世界");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// #101: anything else settles the composition first: its text stays and is its own undo step.
|
||||
#[test]
|
||||
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
s.compose_text("안", None).unwrap();
|
||||
s.key("Right", Mods::default()).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(title_text(&s), "안");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
|
||||
s.compose_text("녕", None).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
|
||||
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.compose_text("하", None).unwrap();
|
||||
s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하!");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
// Text changed under the composition (here behind its back): a new one starts, no panic.
|
||||
s.compose_text("ㄱ", None).unwrap();
|
||||
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
|
||||
s.compose_text("가", None).unwrap();
|
||||
assert!(title_text(&s).ends_with('가'));
|
||||
// Control characters (an Enter sent as text) never split the paragraph.
|
||||
s.commit_text("가\n\r\u{b}").unwrap();
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
|
||||
/// does; with nothing selected there is nowhere to compose.
|
||||
#[test]
|
||||
fn ime_on_a_selected_shape_replaces_its_text() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
|
||||
s.compose_text("新", None).unwrap();
|
||||
s.commit_text("新しい").unwrap();
|
||||
let v = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes.clear();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
|
||||
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
|
||||
}
|
||||
|
||||
/// #101: the composition is underlined in place, under the composed characters, with a thicker
|
||||
/// line under the part being converted.
|
||||
#[test]
|
||||
fn ime_composition_underlines_follow_the_text() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
s.compose_text("にほんご", Some((2, 4))).unwrap();
|
||||
let u = cmd::text::composition_underlines(s.doc().unwrap());
|
||||
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
|
||||
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
|
||||
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
|
||||
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
|
||||
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
|
||||
for (a, b, _) in &u {
|
||||
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
|
||||
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
|
||||
}
|
||||
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -265,6 +270,10 @@ impl Session {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
@@ -419,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));
|
||||
@@ -429,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]}));
|
||||
@@ -460,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 {
|
||||
|
||||
@@ -450,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
|
||||
dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
|
||||
}
|
||||
// Fonts Office installs inside its apps.
|
||||
for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
|
||||
dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
|
||||
}
|
||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
@@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
let l = std::path::PathBuf::from(l);
|
||||
dirs.push(l.join("Microsoft\\Windows\\Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them;
|
||||
// DeckCraft never ships or needs them.
|
||||
dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
|
||||
+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 }
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod words;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -443,6 +444,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")]
|
||||
|
||||
@@ -134,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
|
||||
/// Effective fill and the style colour (`phClr`) to resolve it with.
|
||||
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(st) = &s.style {
|
||||
let style_fill = |s: &Shape| {
|
||||
s.style.as_ref().map(|st| {
|
||||
let (idx, col) = &st.fill_ref;
|
||||
let ph = ctx.color(col, None);
|
||||
let f = match *idx {
|
||||
@@ -146,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(),
|
||||
i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
|
||||
};
|
||||
return (f, Some(ph));
|
||||
(f, Some(ph))
|
||||
})
|
||||
};
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
// A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
|
||||
// gradient its style refers to, keeping its own direction, as PowerPoint draws it.
|
||||
if let Fill::Gradient(g) = f
|
||||
&& g.stops.is_empty()
|
||||
&& let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
|
||||
{
|
||||
let mut g = g.clone();
|
||||
g.stops = sg.stops;
|
||||
return (Some(Fill::Gradient(g)), ph);
|
||||
}
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(r) = style_fill(s) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if shape.text_box {
|
||||
|
||||
@@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() {
|
||||
assert!(ph.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
|
||||
// the style's fillRef names, in the style's colour, along the shape's own direction.
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
let (f, ph) = resolve::fill(&ctx, &sh);
|
||||
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
|
||||
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
|
||||
assert_eq!(g.stops, theme.stops);
|
||||
assert_eq!(g.shape, own.shape, "the shape's own direction");
|
||||
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
|
||||
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
|
||||
st.fill_ref.0 = 1;
|
||||
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = Presentation::default();
|
||||
@@ -187,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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
@@ -356,7 +384,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
let mut pics = vec![];
|
||||
e.find_all("blip", &mut pics);
|
||||
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
||||
};
|
||||
}
|
||||
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
|
||||
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||
} else if uri.ends_with("/diagram") {
|
||||
@@ -370,6 +399,21 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
s
|
||||
}
|
||||
|
||||
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
|
||||
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
|
||||
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
|
||||
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
|
||||
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
|
||||
let mut shapes = vec![];
|
||||
doc.root.find_all("shape", &mut shapes);
|
||||
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
|
||||
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
|
||||
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
|
||||
return imp.media_for(&target);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
|
||||
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open0("a:gsLst");
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.is_empty() {
|
||||
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
|
||||
}
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -79,3 +79,26 @@ fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
// `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving
|
||||
// must not turn it into a gradient of its own (it used to become solid white).
|
||||
use deckcraft_model::style::{Gradient, GradientShape};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(own.clone())),
|
||||
style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,98 @@
|
||||
//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{ShapeKind, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text.
|
||||
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
|
||||
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
if parts.iter().any(|(n, _)| *n == name) {
|
||||
continue;
|
||||
}
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
if name.ends_with(".xml") || name.ends_with(".rels") {
|
||||
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in parts {
|
||||
out.start_file(*name, o).expect("start");
|
||||
out.write_all(data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the
|
||||
/// VML shape with that id in the slide's legacy drawing part.
|
||||
fn ole_deck(spid_in_vml: &str) -> Vec<u8> {
|
||||
let vml = format!(
|
||||
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<v:shape id="_x0000_s9999" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rId7" o:title=""/></v:shape>
|
||||
<v:shape id="{spid_in_vml}" style='position:absolute;left:0;top:0;width:2in;height:1in'><v:imagedata o:relid="rId1" o:title=""/></v:shape>
|
||||
</xml>"#
|
||||
);
|
||||
let vml_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/></Relationships>"#;
|
||||
let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s681987" name="Slide" r:id="rIdOle" imgW="4664160" imgH="3497400" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
with_parts(
|
||||
&[
|
||||
("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()),
|
||||
("ppt/media/preview.png", png()),
|
||||
("ppt/media/other.png", vec![1, 2, 3]),
|
||||
("ppt/embeddings/oleObject1.bin", vec![0; 16]),
|
||||
],
|
||||
|name, s| {
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
s.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn object_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let p = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
|
||||
match &s.kind {
|
||||
ShapeKind::Opaque { preview, label, .. } => {
|
||||
assert_eq!(label, "PowerPoint.Slide.8");
|
||||
preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec())
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_ole_object_shows_its_vml_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The pasteboard: what the editor draws around the slide.
|
||||
//!
|
||||
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
|
||||
//! on the area around it, at full strength and with their effects (master and layout graphics
|
||||
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
|
||||
//!
|
||||
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
|
||||
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Line};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use kurbo::Rect;
|
||||
|
||||
use crate::MAX_SIDE;
|
||||
|
||||
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
|
||||
/// hostile files can't blow the pasteboard up.
|
||||
const MAX_SPILL: f64 = 2000.0;
|
||||
|
||||
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
|
||||
/// that is wider than the window re-renders only when a new tile comes into view.
|
||||
pub const TILE: i64 = 256;
|
||||
|
||||
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
|
||||
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
|
||||
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
|
||||
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
|
||||
let mut s: f64 = 0.0;
|
||||
if let Some(l) = line
|
||||
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
// Arrowheads reach up to a few line widths past the end point.
|
||||
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
|
||||
}
|
||||
if let Some(e) = effects {
|
||||
let big = fin(w).max(fin(h));
|
||||
if let Some(sh) = &e.outer_shadow {
|
||||
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
|
||||
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
s = s.max(fin(g.radius));
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
s = s.max(fin(h) + fin(r.dist));
|
||||
}
|
||||
}
|
||||
s.min(MAX_SPILL)
|
||||
}
|
||||
|
||||
/// Slide-space box a top-level shape draws into, its spill included.
|
||||
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
|
||||
if s.hidden {
|
||||
return None;
|
||||
}
|
||||
let x: Xfrm = resolve::xfrm(rctx, s);
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = match &s.kind {
|
||||
ShapeKind::Ink { strokes } => {
|
||||
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
|
||||
let (px, py, _) = pts.next()?;
|
||||
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
|
||||
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
|
||||
return Some(r.inflate(w, w));
|
||||
}
|
||||
_ => x.bounds(),
|
||||
};
|
||||
let (line, _) = resolve::line(rctx, s);
|
||||
let (fx, _) = resolve::effects(rctx, s);
|
||||
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
|
||||
Some(b.inflate(d, d))
|
||||
}
|
||||
|
||||
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
|
||||
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
|
||||
}
|
||||
|
||||
fn slide_rect(pres: &Presentation) -> Rect {
|
||||
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
|
||||
}
|
||||
|
||||
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
|
||||
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
|
||||
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
|
||||
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(slide) = pres.slides.get(index) else { return page };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
|
||||
let mut r = union_boxes(&rctx, &slide.shapes, page);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
|
||||
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(m) = pres.masters.get(master) else { return page };
|
||||
match layout.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
|
||||
if l.show_master_shapes {
|
||||
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The slide itself, `w` × `h` pixels.
|
||||
pub fn slide(w: u32, h: u32) -> Self {
|
||||
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
|
||||
}
|
||||
|
||||
fn edges(&self) -> (i64, i64, i64, i64) {
|
||||
let (x, y) = (self.x as i64, self.y as i64);
|
||||
(x, y, x + self.w as i64, y + self.h as i64)
|
||||
}
|
||||
|
||||
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
|
||||
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
|
||||
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
|
||||
}
|
||||
|
||||
/// Does this region cover all of `other`?
|
||||
pub fn contains(&self, other: &Region) -> bool {
|
||||
let (a, b) = (self.edges(), other.edges());
|
||||
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
|
||||
}
|
||||
|
||||
/// Is the region more than the slide (some of the pasteboard is drawn)?
|
||||
pub fn beyond(&self, slide: (u32, u32)) -> bool {
|
||||
*self != Region::slide(slide.0, slide.1)
|
||||
}
|
||||
|
||||
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
|
||||
pub fn offset(&self) -> (f64, f64) {
|
||||
(-(self.x as f64), -(self.y as f64))
|
||||
}
|
||||
|
||||
/// The region's size, for render options' `size`.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.w, self.h)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
|
||||
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
|
||||
let lim = i32::MAX as f64 / 2.0;
|
||||
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
|
||||
Some((
|
||||
px((r.x0 * scale).floor())? as i64,
|
||||
px((r.y0 * scale).floor())? as i64,
|
||||
px((r.x1 * scale).ceil())? as i64,
|
||||
px((r.y1 * scale).ceil())? as i64,
|
||||
))
|
||||
}
|
||||
|
||||
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
|
||||
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
|
||||
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
|
||||
/// area is rounded out to whole [`TILE`]s.
|
||||
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
|
||||
let page = Region::slide(slide_px.0, slide_px.1);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return page;
|
||||
}
|
||||
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
|
||||
let (vx0, vy0, vx1, vy1) = visible.edges();
|
||||
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
|
||||
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
|
||||
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
return page;
|
||||
}
|
||||
let (px0, py0, px1, py1) = page.edges();
|
||||
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use deckcraft_model::Shape;
|
||||
|
||||
fn black() -> deckcraft_model::ColorRef {
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
|
||||
}
|
||||
|
||||
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
|
||||
let p = deck_with(100.0, 100.0, 50.0, 50.0);
|
||||
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_off_the_slide_grow_the_extent() {
|
||||
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
|
||||
let e = slide_extent(&p, 0);
|
||||
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
|
||||
assert_eq!(e.y0, 0.0);
|
||||
assert_eq!(e.x1, p.slide_size.width);
|
||||
// Missing slides are just the page.
|
||||
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
|
||||
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
|
||||
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_count_towards_the_extent() {
|
||||
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
|
||||
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
|
||||
sh.effects = Some(Effects {
|
||||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||
color: black(),
|
||||
blur: 10.0,
|
||||
dist: 20.0,
|
||||
dir: 270.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(slide_extent(&p, 0).y0 <= -130.0);
|
||||
assert_eq!(
|
||||
spill(
|
||||
None,
|
||||
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
|
||||
1.0,
|
||||
1.0
|
||||
),
|
||||
MAX_SPILL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
|
||||
assert!(slide_extent(&p, 0).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
|
||||
let slide = (960, 540);
|
||||
let page = Region::slide(960, 540);
|
||||
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
|
||||
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
|
||||
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
|
||||
// Off to the left, in view: the region reaches it.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
|
||||
assert!(r.beyond(slide) && r.contains(&page));
|
||||
assert_eq!(r.offset(), (80.0, 0.0));
|
||||
// Far off and out of view (the view rounds out to tiles): only what's visible.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -256);
|
||||
// Wholly out of view: the slide.
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
|
||||
// Scale applies; nonsense falls back to the slide.
|
||||
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -40);
|
||||
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
|
||||
// Huge extents are capped.
|
||||
let r =
|
||||
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
|
||||
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_within_a_tile_keeps_the_region() {
|
||||
let slide = (2000, 1125);
|
||||
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
|
||||
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
|
||||
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
+176
-16
@@ -11,6 +11,7 @@ mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod extent;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
@@ -40,6 +41,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::{RenderContext, Resources, peniko};
|
||||
|
||||
pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
@@ -233,11 +235,17 @@ pub struct Renderer {
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||
drawn: Option<Rect>,
|
||||
/// 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 }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +293,38 @@ fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
|
||||
Ok(img) => img,
|
||||
render_slide_drawn(pres, index, opts).0
|
||||
}
|
||||
|
||||
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
|
||||
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
|
||||
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
|
||||
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
|
||||
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
|
||||
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
r.drawn = None;
|
||||
let img = r.slide(pres, slide, index, opts);
|
||||
(img, r.drawn.take())
|
||||
})
|
||||
})) {
|
||||
Ok((img, drawn)) => {
|
||||
let k = opts.scale;
|
||||
let to_slide =
|
||||
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
|
||||
let extent = match drawn {
|
||||
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
|
||||
_ => page,
|
||||
};
|
||||
(img, extent)
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("rendering slide {index} panicked; showing a blank slide");
|
||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||
let (w, h) = output_size(pres, opts);
|
||||
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
|
||||
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +504,14 @@ impl Renderer {
|
||||
(ctx, w, h, view)
|
||||
}
|
||||
|
||||
/// Note that `r` (local space, placed by `m`) was drawn.
|
||||
fn track(&mut self, m: Affine, r: Rect) {
|
||||
let b = m.transform_rect_bbox(r);
|
||||
if b.is_finite() {
|
||||
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
@@ -623,6 +663,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;
|
||||
}
|
||||
@@ -658,10 +735,21 @@ impl Renderer {
|
||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
|
||||
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
|
||||
ShapeKind::Table(t) => {
|
||||
// Rows grow with their text: what the cells cover, borders included.
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
|
||||
chart::draw(ctx, rctx, c, m, x.w, x.h)
|
||||
}
|
||||
ShapeKind::Ink { strokes } => {
|
||||
for sk in strokes {
|
||||
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
|
||||
for (px, py, _) in &sk.points {
|
||||
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
for (i, (px, py, _)) in sk.points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -670,20 +758,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()),
|
||||
@@ -692,6 +794,8 @@ impl Renderer {
|
||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||
let (line, line_ph) = resolve::line(rctx, s);
|
||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
|
||||
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
|
||||
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
||||
let outline = geo.outline();
|
||||
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
||||
@@ -699,7 +803,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 {
|
||||
@@ -717,6 +826,13 @@ impl Renderer {
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||||
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||||
ctx.set_transform(m);
|
||||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
@@ -759,11 +875,19 @@ impl Renderer {
|
||||
// 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 {
|
||||
@@ -815,9 +939,25 @@ 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 });
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
let pad = b.height() * 0.3;
|
||||
self.track(tm, b.inflate(pad, pad));
|
||||
}
|
||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||
@@ -852,6 +992,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -879,8 +1020,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 {
|
||||
@@ -1006,9 +1163,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -297,9 +297,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
ctx: &mut RenderContext,
|
||||
pres: &Presentation,
|
||||
rctx: &Ctx,
|
||||
t: &Table,
|
||||
m: Affine,
|
||||
_w: f64,
|
||||
_h: f64,
|
||||
fields: &dyn deckcraft_text::Fields,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
@@ -379,4 +391,5 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
+439
-1
@@ -1037,7 +1037,8 @@ fn wordart_arch_up_lifts_the_middle_of_the_line() {
|
||||
// at the box top and the ends sit clearly lower.
|
||||
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
|
||||
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
|
||||
assert!((80..=110).contains(&ay0), "arch top {ay0}");
|
||||
// At the box top (y = 100), give or take the font: Linux's fallback face reaches 79.
|
||||
assert!((70..=110).contains(&ay0), "arch top {ay0}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1145,3 +1146,440 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
|
||||
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
|
||||
// An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame,
|
||||
// as PowerPoint does; with no preview it draws nothing.
|
||||
let png = {
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([200, 20, 20])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
for with_preview in [true, false] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("image1.png", "image/png", png.clone());
|
||||
let sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
|
||||
let px = img.pixel(x, y);
|
||||
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
|
||||
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
|
||||
}
|
||||
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
|
||||
fn off_slide_deck() -> Presentation {
|
||||
let half = Shape {
|
||||
id: ShapeId(2700),
|
||||
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let below = Shape {
|
||||
id: ShapeId(2701),
|
||||
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||||
..Default::default()
|
||||
};
|
||||
deck_with(vec![half, below])
|
||||
}
|
||||
|
||||
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
|
||||
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_exports_and_thumbnails_clip_to_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (960, 540));
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
// The on-slide half is there, nothing else is drawn outside the slide.
|
||||
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
|
||||
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
|
||||
let p = off_slide_deck();
|
||||
let extent = slide_extent(&p, 0);
|
||||
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
|
||||
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
|
||||
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
|
||||
assert_eq!((img.width, img.height), region.size());
|
||||
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
|
||||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
|
||||
// Both halves of the straddling shape, at full strength, and the shape below the slide.
|
||||
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
|
||||
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
|
||||
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
|
||||
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
|
||||
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
|
||||
assert_eq!(at(200, 300), [255, 255, 255, 255]);
|
||||
// The drawing's reach, in slide space.
|
||||
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape {
|
||||
id: ShapeId(2702),
|
||||
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
|
||||
..Default::default()
|
||||
});
|
||||
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
|
||||
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
|
||||
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
|
||||
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
|
||||
for para in &mut t.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(40.0);
|
||||
}
|
||||
}
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
// The box estimate doesn't see the text below the slide; the render does.
|
||||
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
|
||||
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
assert!(drawn.y1 > 600.0, "{drawn:?}");
|
||||
// A slide with nothing off it reaches just the slide.
|
||||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
+107
-6
@@ -773,9 +773,37 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char
|
||||
}
|
||||
}
|
||||
|
||||
/// The character that draws `c`, a character of text set in the symbol font `font` (Wingdings,
|
||||
/// Symbol, Webdings…: its code, or U+F000 + its code), with `face`: the installed symbol font's own
|
||||
/// glyph when `face` is that font, else a Unicode look-alike as bullets use. None when `font` is not
|
||||
/// a symbol font or `c` is not a symbol code. Metafile text takes this path.
|
||||
pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
|
||||
let code = c as u32;
|
||||
if !is_symbol_font(font) || !(code < 0x100 || (0xF000..=0xF0FF).contains(&code)) {
|
||||
return None;
|
||||
}
|
||||
let plain = char::from_u32(code & 0xFF)?;
|
||||
let s = plain.to_string();
|
||||
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
|
||||
}
|
||||
|
||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.starts_with("webdings") {
|
||||
let m = match (first as u32) & 0xff {
|
||||
0x6e => '●',
|
||||
0x67 => '■',
|
||||
0x61 => '✓',
|
||||
0x72 => '✕',
|
||||
0x33 => '◀',
|
||||
0x34 => '▶',
|
||||
0x35 => '▲',
|
||||
0x36 => '▼',
|
||||
_ => '•',
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
||||
let code = (first as u32) & 0xff;
|
||||
// Wingdings 2 and 3 put different shapes at the same codes.
|
||||
@@ -1831,26 +1859,99 @@ impl TextLayout {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
|
||||
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
|
||||
/// in place). A composition that wraps gets one underline per line.
|
||||
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
|
||||
let (s, e) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut out = vec![];
|
||||
if s == e {
|
||||
return out;
|
||||
}
|
||||
for l in &self.lines {
|
||||
let lp = Pos { para: l.para, ch: l.start };
|
||||
let le = Pos { para: l.para, ch: l.end };
|
||||
if le <= s || lp >= e {
|
||||
continue;
|
||||
}
|
||||
let from = if s > lp { s.ch } else { l.start };
|
||||
let to = if e < le { e.ch } else { l.end };
|
||||
// Under the baseline, in the descent, never below the line.
|
||||
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
|
||||
for (x0, x1) in l.spans(from, to) {
|
||||
if x1 > x0 {
|
||||
out.push((x0, x1, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||||
let Some(li) = self.line_of(pos) else { return pos };
|
||||
let target = if down { li + 1 } else { li.wrapping_sub(1) };
|
||||
let Some(l) = self.lines.get(target) else { return pos };
|
||||
self.vertical_affine(pos, Affinity::Downstream, down, x).0
|
||||
}
|
||||
/// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End
|
||||
/// leaves it, after the previous line's trailing spaces) belongs to the line before.
|
||||
pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
|
||||
if affinity == Affinity::Upstream && pos.ch > 0 {
|
||||
for (i, w) in self.lines.windows(2).enumerate() {
|
||||
if let [a, b] = w
|
||||
&& b.para == pos.para
|
||||
&& a.para == pos.para
|
||||
&& b.start == pos.ch
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.line_of_affine(pos, affinity)
|
||||
}
|
||||
/// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`.
|
||||
pub fn vertical_affine(&self, pos: Pos, affinity: Affinity, down: bool, x: f64) -> (Pos, Affinity) {
|
||||
let Some(li) = self.line_index(pos, affinity) else { return (pos, affinity) };
|
||||
let target = if down { li.checked_add(1) } else { li.checked_sub(1) };
|
||||
let Some(l) = target.and_then(|t| self.lines.get(t)) else { return (pos, affinity) };
|
||||
let p = Point::new(x, (l.top + l.bottom) / 2.0);
|
||||
let mut h = self.hit(p);
|
||||
if h.para != l.para {
|
||||
h.para = l.para;
|
||||
h.ch = h.ch.clamp(l.start, l.end);
|
||||
}
|
||||
h
|
||||
h.ch = h.ch.clamp(l.start, l.end);
|
||||
(h, Affinity::Downstream)
|
||||
}
|
||||
/// Start/end of the visual line containing `pos` (logical: Home/End).
|
||||
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
|
||||
match self.line_of(pos).and_then(|i| self.lines.get(i)) {
|
||||
self.line_bounds_affine(pos, Affinity::Downstream)
|
||||
}
|
||||
/// Start/end of the visual line a caret with this affinity is on.
|
||||
pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) {
|
||||
match self.line_index(pos, affinity).and_then(|i| self.lines.get(i)) {
|
||||
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
|
||||
None => (pos, pos),
|
||||
}
|
||||
}
|
||||
/// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start
|
||||
/// of the next line with upstream affinity (so it is still drawn on this line), else the end
|
||||
/// of the paragraph.
|
||||
pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) {
|
||||
let Some(li) = self.line_index(pos, affinity) else { return (pos, Affinity::Downstream) };
|
||||
let Some(l) = self.lines.get(li) else { return (pos, Affinity::Downstream) };
|
||||
match self.lines.get(li + 1) {
|
||||
Some(n) if n.para == l.para && n.start > l.start => (Pos { para: l.para, ch: n.start }, Affinity::Upstream),
|
||||
_ => (Pos { para: l.para, ch: l.end }, Affinity::Downstream),
|
||||
}
|
||||
}
|
||||
/// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end
|
||||
/// of the line it belongs to.
|
||||
pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
|
||||
let li = self.line_index(pos, affinity)?;
|
||||
let l = self.lines.get(li)?;
|
||||
if pos.ch > l.end {
|
||||
let x = l.caret_x.last().copied()?;
|
||||
return Some((x, l.top, l.bottom));
|
||||
}
|
||||
self.caret_at(pos, affinity)
|
||||
}
|
||||
/// Bounding box of all glyphs/lines (shape-local).
|
||||
pub fn bounds(&self) -> Rect {
|
||||
let mut r: Option<Rect> = None;
|
||||
|
||||
@@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() {
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the text an input method is composing is underlined in place, under the baseline of each
|
||||
/// line it occupies, and only over the composed characters.
|
||||
#[test]
|
||||
fn composition_is_underlined_per_line() {
|
||||
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
|
||||
let l = lay(&p, &sh, 600.0, 100.0);
|
||||
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
|
||||
let u = l.composition_underlines(a, b);
|
||||
assert_eq!(u.len(), 1, "{u:?}");
|
||||
let (x0, x1, y) = u[0];
|
||||
let (cx0, ..) = l.caret(a).unwrap();
|
||||
let (cx1, ..) = l.caret(b).unwrap();
|
||||
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
|
||||
let line = &l.lines[0];
|
||||
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
|
||||
// Reversed ends give the same; an empty composition, nothing.
|
||||
assert_eq!(l.composition_underlines(b, a), u);
|
||||
assert!(l.composition_underlines(a, a).is_empty());
|
||||
|
||||
// Wrapped: one underline per line.
|
||||
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
|
||||
let l = lay(&p, &sh, 120.0, 400.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
|
||||
assert!(u.len() >= 2, "{u:?}");
|
||||
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
|
||||
// Positions past the text never panic.
|
||||
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_center_and_right() {
|
||||
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
||||
@@ -718,3 +748,14 @@ 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");
|
||||
}
|
||||
|
||||
+100
-11
@@ -1,6 +1,10 @@
|
||||
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
||||
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
||||
//! previews, the text caret and text selection).
|
||||
//!
|
||||
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
|
||||
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
|
||||
//! the slide show, exports and thumbnails clip to the slide.
|
||||
|
||||
use deckcraft_engine::tools::{Hit, box_handles};
|
||||
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
||||
@@ -108,18 +112,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let st = app.session.active();
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
let visible = visible_region(avail, slide_rect, pixel);
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
None => None,
|
||||
};
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if let Some((tex, region)) = tex {
|
||||
let at = Rect::from_min_size(
|
||||
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
|
||||
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
|
||||
);
|
||||
app.canvas_region = Some(at);
|
||||
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
app.perf.render_ms = app.textures.last_render_ms;
|
||||
media_frames(app, &painter, xf);
|
||||
@@ -152,11 +164,69 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
||||
/// top-left corner.
|
||||
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
|
||||
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
|
||||
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
|
||||
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
|
||||
let lim = i32::MAX as f64 / 4.0;
|
||||
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
|
||||
deckcraft_render::Region {
|
||||
x: x0 as i32,
|
||||
y: y0 as i32,
|
||||
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
|
||||
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
|
||||
/// nothing to type into.
|
||||
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
let st = app.session.active()?;
|
||||
if let Some(ts) = &st.selection.text {
|
||||
if ts.notes {
|
||||
return None;
|
||||
}
|
||||
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
||||
}
|
||||
let sh = st.shape(ts.shape)?;
|
||||
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
||||
}
|
||||
if st.selection.shapes.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(*st.selection.shapes.first()?)?;
|
||||
if sh.is_line() {
|
||||
return None;
|
||||
}
|
||||
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
||||
}
|
||||
|
||||
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||
let ctx = ui.ctx();
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
||||
let r = to_global * r;
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -240,14 +310,17 @@ fn master_tex(
|
||||
master: usize,
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
) -> Option<egui::TextureHandle> {
|
||||
visible: deckcraft_render::Region,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
||||
let img = deckcraft_render::render_layout(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
|
||||
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -388,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);
|
||||
@@ -422,12 +502,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||
}
|
||||
if !ts.is_range() {
|
||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
|
||||
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
|
||||
// An empty placeholder shows the caret where typing begins too.
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,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();
|
||||
|
||||
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
let Some(t) = s("text") else { return err("missing `text`") };
|
||||
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
||||
}
|
||||
"ui.ime" => {
|
||||
// An input method's event, through egui like a real one: it reaches the slide's text
|
||||
// or a focused text field (#101).
|
||||
let ev = if let Some(t) = s("commit") {
|
||||
egui::ImeEvent::Commit(t.into())
|
||||
} else if let Some(t) = s("preedit") {
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x <= y).then_some(x..y)
|
||||
});
|
||||
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
|
||||
} else {
|
||||
return err("missing `preedit` or `commit`");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Ime(ev));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.tool.select" => {
|
||||
let Some(name) = s("tool") else { return err("missing `tool`") };
|
||||
match tool_kind(name, s("preset")) {
|
||||
|
||||
@@ -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",
|
||||
@@ -2262,22 +2264,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
|
||||
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
|
||||
//! real glyphs rather than boxes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui::{Event, FontId, ImeEvent};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn preedit(text: &str) -> Event {
|
||||
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
|
||||
}
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn undo_len(app: &SlideApp) -> usize {
|
||||
app.session.active().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
|
||||
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
|
||||
/// is on while the slide's text is edited, with its window at the insertion point.
|
||||
#[test]
|
||||
fn korean_composes_in_a_slide_text_box() {
|
||||
let mut h = harness(Session::with_new());
|
||||
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
|
||||
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
|
||||
let caret0 = ime.rect;
|
||||
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
|
||||
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
|
||||
}
|
||||
h.run_steps(1);
|
||||
let ime = h.output().platform_output.ime.unwrap();
|
||||
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
|
||||
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
|
||||
|
||||
// Escape cancels a composition: an empty preedit, and the text is as it was.
|
||||
h.event(preedit("ㅁ"));
|
||||
h.event(preedit(""));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
|
||||
}
|
||||
|
||||
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
|
||||
/// composition the input method left open.
|
||||
#[test]
|
||||
fn japanese_conversion_shows_the_active_part() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
|
||||
h.run_steps(2);
|
||||
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
|
||||
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
|
||||
let undo0 = undo_len(h.state());
|
||||
h.key_press(egui::Key::End);
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本ご");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
}
|
||||
|
||||
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
|
||||
/// the input method is on for it.
|
||||
#[test]
|
||||
fn composing_on_a_selected_shape_starts_editing_it() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().selection.text.is_none());
|
||||
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
|
||||
h.event(preedit("新"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
|
||||
h.run_steps(2);
|
||||
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
}
|
||||
|
||||
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
|
||||
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
|
||||
#[test]
|
||||
fn korean_composes_in_the_notes_pane() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let id = egui::Id::new("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)));
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, "노트");
|
||||
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
|
||||
assert_eq!(notes, "노트");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
|
||||
|
||||
// Composing, then going to another slide: the notes stay with the slide they were typed for.
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.event(preedit("ㅇ"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("요".into())));
|
||||
h.run_steps(1);
|
||||
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
|
||||
h.run_steps(2);
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
|
||||
assert_eq!(d.doc.slides[1].notes_text(), "");
|
||||
}
|
||||
|
||||
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
|
||||
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
|
||||
fn system_has(s: &str) -> bool {
|
||||
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
|
||||
}
|
||||
|
||||
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
|
||||
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
|
||||
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
|
||||
#[test]
|
||||
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||
let h = harness(Session::with_new());
|
||||
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let korean = "한국어 발표 자료";
|
||||
let japanese = "日本語のスライド";
|
||||
let chinese = "简体中文演示文稿";
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
|
||||
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
let mut h = harness(s);
|
||||
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().title().contains(korean));
|
||||
let mut checked = 0;
|
||||
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
|
||||
if !system_has(text) {
|
||||
eprintln!("skipped {text:?}: no font on this machine draws it");
|
||||
continue;
|
||||
}
|
||||
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
|
||||
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if cfg!(windows) || cfg!(target_os = "macos") {
|
||||
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
|
||||
}
|
||||
// Text typed later (a dialog field, a new comment) is looked at too.
|
||||
let typed = "ไทย";
|
||||
h.event(Event::Text(typed.into()));
|
||||
h.run_steps(2);
|
||||
if system_has(typed) {
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
|
||||
}
|
||||
}
|
||||
+291
-1
@@ -16,12 +16,15 @@ pub mod media;
|
||||
pub mod menus;
|
||||
pub mod panes;
|
||||
pub mod ribbon;
|
||||
pub mod selpane;
|
||||
pub mod show;
|
||||
pub mod sorter;
|
||||
pub mod status;
|
||||
pub mod textures;
|
||||
pub mod theme;
|
||||
pub mod thumbs;
|
||||
pub mod toast;
|
||||
pub mod uifonts;
|
||||
pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -181,6 +184,10 @@ pub struct SlideApp {
|
||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||
pub media: media::MediaHost,
|
||||
pub status: Option<(String, f64)>,
|
||||
/// A brief notice over the bottom of the window (missing fonts on opening a file).
|
||||
pub toast: Option<toast::Toast>,
|
||||
/// Documents (by uid) whose missing fonts have been mentioned.
|
||||
fonts_noted: std::collections::HashSet<u64>,
|
||||
pub perf: Perf,
|
||||
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
@@ -188,6 +195,8 @@ pub struct SlideApp {
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
pub canvas_scale: f32,
|
||||
pub slide_rect: Option<egui::Rect>,
|
||||
/// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen.
|
||||
pub canvas_region: Option<egui::Rect>,
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
@@ -207,8 +216,12 @@ pub struct SlideApp {
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||
/// Selection pane: folded groups, renaming, drag and drop.
|
||||
pub selpane: selpane::State,
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
@@ -224,11 +237,14 @@ impl SlideApp {
|
||||
show: None,
|
||||
media: media::MediaHost::new(audio_out),
|
||||
status: None,
|
||||
toast: None,
|
||||
fonts_noted: Default::default(),
|
||||
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
|
||||
synthetic: vec![],
|
||||
canvas_rect: None,
|
||||
canvas_scale: 1.0,
|
||||
slide_rect: None,
|
||||
canvas_region: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
@@ -243,7 +259,9 @@ impl SlideApp {
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +553,7 @@ impl SlideApp {
|
||||
ctx.set_zoom_factor(scale);
|
||||
}
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||
self.styled = true;
|
||||
self.restyle = true;
|
||||
} else {
|
||||
@@ -558,6 +576,9 @@ impl SlideApp {
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.fonts_ready {
|
||||
self.note_ui_text(ctx);
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
// Closing the window with unsaved changes asks first.
|
||||
@@ -589,6 +610,52 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
|
||||
/// presentations' names and text, recent files, and what is typed or composed this frame.
|
||||
fn note_ui_text(&mut self, ctx: &egui::Context) {
|
||||
let typed: String = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
self.ui_fonts.note(ctx, &typed);
|
||||
for d in self.session.documents() {
|
||||
self.ui_fonts.note(ctx, &d.title());
|
||||
}
|
||||
for r in &self.ui.recent {
|
||||
self.ui_fonts.note(ctx, r);
|
||||
}
|
||||
if let Some(st) = self.session.active() {
|
||||
self.ui_fonts.note_doc(ctx, st);
|
||||
}
|
||||
self.ui_fonts.apply(ctx);
|
||||
}
|
||||
|
||||
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
|
||||
/// replaced on each update; the commit replaces it as one undo step.
|
||||
fn ime_event(&mut self, e: &egui::ImeEvent) {
|
||||
let r = match e {
|
||||
egui::ImeEvent::Preedit { text, active_range_chars } => {
|
||||
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
|
||||
}
|
||||
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
|
||||
#[allow(deprecated)]
|
||||
egui::ImeEvent::Disabled => {
|
||||
self.session.end_composition();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
@@ -636,6 +703,7 @@ impl SlideApp {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(ime) => self.ime_event(&ime),
|
||||
egui::Event::Copy => {
|
||||
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
||||
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
||||
@@ -730,6 +798,8 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
});
|
||||
toast::note_missing_fonts(self);
|
||||
toast::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
menus::palette(self, &ctx);
|
||||
for url in std::mem::take(&mut self.pending_urls) {
|
||||
@@ -877,6 +947,9 @@ fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
@@ -889,6 +962,37 @@ mod tests {
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_with_missing_fonts_shows_a_toast_once() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none(), "a new presentation has nothing to say");
|
||||
harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap();
|
||||
harness.run_steps(2);
|
||||
let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default();
|
||||
assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}");
|
||||
assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some());
|
||||
// Closed, it stays closed for that document.
|
||||
harness.get_by_label("Close").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none());
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none());
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
@@ -932,10 +1036,196 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let app = harness.state_mut();
|
||||
// Zoomed out, the canvas shows plenty of pasteboard around the slide.
|
||||
app.ui.zoom = Some(0.4);
|
||||
app.session.prefs.snap_to_grid = false;
|
||||
app.session.prefs.smart_guides = false;
|
||||
harness
|
||||
}
|
||||
|
||||
fn selected(app: &SlideApp) -> Vec<u64> {
|
||||
app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect()
|
||||
}
|
||||
|
||||
fn shape_x(app: &SlideApp, id: u64) -> f64 {
|
||||
let st = app.session.active().unwrap();
|
||||
st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x
|
||||
}
|
||||
|
||||
/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where
|
||||
/// they can be selected and moved.
|
||||
#[test]
|
||||
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
|
||||
let mut harness = canvas_harness();
|
||||
let off =
|
||||
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
harness.run_steps(3);
|
||||
|
||||
let app = harness.state();
|
||||
let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale);
|
||||
// The texture reaches past the slide's right edge to the shape's, and no further left/up.
|
||||
assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}");
|
||||
assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}");
|
||||
let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k);
|
||||
assert!(app.canvas_rect.unwrap().contains(centre));
|
||||
|
||||
// Drawn at full strength on the pasteboard (when a GPU renderer is available here).
|
||||
if let Ok(img) = harness.render() {
|
||||
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
|
||||
let shape = px(centre);
|
||||
let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k));
|
||||
let doc = &harness.state().session.active().unwrap().doc;
|
||||
let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4;
|
||||
assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}");
|
||||
assert_ne!(shape, board);
|
||||
}
|
||||
|
||||
// Click selects it.
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drop_at(centre);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(selected(harness.state()), vec![off]);
|
||||
|
||||
// Drag moves it, further off the slide.
|
||||
let to = centre + egui::vec2(40.0, 0.0);
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
harness.drop_at(to);
|
||||
harness.run_steps(2);
|
||||
let x = shape_x(harness.state(), off);
|
||||
assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}");
|
||||
// The pasteboard followed it.
|
||||
let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap());
|
||||
assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}");
|
||||
|
||||
// Idle frames don't render the canvas again.
|
||||
let n = harness.state().textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n);
|
||||
}
|
||||
|
||||
/// A slide with nothing off it renders just the slide, as before.
|
||||
#[test]
|
||||
fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() {
|
||||
let mut harness = canvas_harness();
|
||||
harness.run_steps(3);
|
||||
let app = harness.state();
|
||||
let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap());
|
||||
assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}");
|
||||
let n = app.textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
app.notes_buf = (uid, idx, current.clone());
|
||||
}
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if !focused && app.notes_buf.2 != current {
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
|
||||
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
|
||||
//!
|
||||
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
|
||||
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Rect;
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use deckcraft_render::{Image, Region, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -15,11 +19,15 @@ struct Key {
|
||||
size: (u32, u32),
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
key: Key,
|
||||
tex: TextureHandle,
|
||||
/// The region the texture covers (at least `key.want`).
|
||||
region: Region,
|
||||
// Keep the Arcs alive so their addresses can't be reused while cached.
|
||||
_slide: Arc<Slide>,
|
||||
_master: Option<Arc<Master>>,
|
||||
@@ -29,6 +37,14 @@ struct Entry {
|
||||
#[derive(Default)]
|
||||
pub struct Textures {
|
||||
canvas: Option<Entry>,
|
||||
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
|
||||
/// held (kept alive so their addresses can't be reused).
|
||||
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
|
||||
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
|
||||
/// its box shows there.
|
||||
drawn: Option<(usize, Rect)>,
|
||||
/// Canvas renders so far (tests, performance checks).
|
||||
pub canvas_renders: u64,
|
||||
thumbs: HashMap<usize, Entry>,
|
||||
clock: u64,
|
||||
pub last_render_ms: f64,
|
||||
@@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
|
||||
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn threads() -> u16 {
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
||||
}
|
||||
|
||||
impl Textures {
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
p: &Presentation,
|
||||
index: usize,
|
||||
size: (u32, u32),
|
||||
visible: Region,
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
) -> Option<TextureHandle> {
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let estimate = match &self.estimate {
|
||||
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
|
||||
_ => {
|
||||
let r = deckcraft_render::slide_extent(p, index);
|
||||
self.estimate = Some((slide.clone(), master.clone(), r));
|
||||
r
|
||||
}
|
||||
};
|
||||
let reach = match self.drawn {
|
||||
Some((i, d)) if i == index => estimate.union(d),
|
||||
_ => estimate,
|
||||
};
|
||||
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||
let key = Key {
|
||||
slide: Arc::as_ptr(&slide) as usize,
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size,
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64,
|
||||
want,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
{
|
||||
return Some(e.tex.clone());
|
||||
return Some((e.tex.clone(), e.region));
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
||||
let render = |region: Region| {
|
||||
let opts =
|
||||
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
};
|
||||
let (mut img, drawn) = render(want);
|
||||
self.canvas_renders += 1;
|
||||
let mut region = want;
|
||||
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||
// more than this render covered.
|
||||
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||
if !region.contains(&key.want) {
|
||||
region = key.want;
|
||||
img = render(region).0;
|
||||
self.canvas_renders += 1;
|
||||
}
|
||||
self.drawn = Some((index, drawn));
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
@@ -99,13 +158,15 @@ impl Textures {
|
||||
}
|
||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
|
||||
Some(tex)
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
}
|
||||
|
||||
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
|
||||
pub fn invalidate(&mut self) {
|
||||
self.canvas = None;
|
||||
self.estimate = None;
|
||||
self.drawn = None;
|
||||
self.thumbs.clear();
|
||||
}
|
||||
|
||||
@@ -121,6 +182,7 @@ impl Textures {
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
@@ -139,7 +201,8 @@ impl Textures {
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
||||
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
if self.thumbs.len() > 600 {
|
||||
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
|
||||
v.sort_by_key(|x| x.1);
|
||||
|
||||
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
install_fonts_with(ctx, &[]);
|
||||
}
|
||||
|
||||
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
|
||||
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
|
||||
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
|
||||
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
ctx.set_fonts(font_definitions(extra));
|
||||
}
|
||||
|
||||
/// The interface font definitions; see [`install_fonts_with`].
|
||||
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
|
||||
let mut defs = FontDefinitions::default();
|
||||
let mut names = vec![];
|
||||
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
||||
@@ -178,7 +190,34 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
|
||||
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
||||
ctx.set_fonts(defs);
|
||||
add_fallback_faces(&mut defs, extra);
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Add `faces` after every other font of each interface family (the last fallbacks).
|
||||
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
for f in faces {
|
||||
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
|
||||
if defs.font_data.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let mut data = FontData::from_owned(f.data().to_vec());
|
||||
data.index = f.index();
|
||||
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
|
||||
for list in defs.families.values_mut() {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(size: f32) -> egui::FontId {
|
||||
@@ -192,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Brief notices over the window's bottom edge: shown once, gone after a few seconds or when
|
||||
//! closed. Used to say a presentation's fonts aren't installed when it is first opened.
|
||||
|
||||
use egui::{Align2, CornerRadius, Margin, Stroke, vec2};
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// How long a toast stays, in milliseconds.
|
||||
const SHOW_MS: f64 = 8000.0;
|
||||
|
||||
pub struct Toast {
|
||||
pub text: String,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
/// The notice for fonts a presentation uses that aren't installed, or `None` when all are.
|
||||
pub fn missing_fonts_message(fonts: &[String]) -> Option<String> {
|
||||
let names = match fonts {
|
||||
[] => return None,
|
||||
[a] => a.clone(),
|
||||
[a, b] => format!("{a} and {b}"),
|
||||
[a, b, c] => format!("{a}, {b} and {c}"),
|
||||
[a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()),
|
||||
};
|
||||
let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") };
|
||||
Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font."))
|
||||
}
|
||||
|
||||
/// Toast the active presentation's missing fonts, once per opened document.
|
||||
pub fn note_missing_fonts(app: &mut SlideApp) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
if !app.fonts_noted.insert(d.uid) {
|
||||
return;
|
||||
}
|
||||
if let Some(text) = missing_fonts_message(&d.missing_fonts) {
|
||||
app.toast = Some(Toast { text, at: crate::now_ms() });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let Some(toast) = &app.toast else { return };
|
||||
if crate::now_ms() - toast.at > SHOW_MS {
|
||||
app.toast = None;
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let text = toast.text.clone();
|
||||
let mut close = false;
|
||||
egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.inner_margin(Margin::symmetric(14, 10))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow })
|
||||
.show(ui, |ui| {
|
||||
ui.set_max_width(520.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false);
|
||||
if resp.on_hover_text("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if close {
|
||||
app.toast = None;
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_fonts() {
|
||||
let f = |v: &[&str]| missing_fonts_message(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
assert_eq!(f(&[]), None);
|
||||
assert_eq!(f(&["Aptos"]).unwrap(), "Aptos isn't installed on this computer. Text that uses it is shown in a similar font.");
|
||||
assert!(f(&["Aptos", "Sora"]).unwrap().starts_with("Aptos and Sora aren't installed"));
|
||||
assert!(f(&["A", "B", "C"]).unwrap().starts_with("A, B and C aren't"));
|
||||
assert!(f(&["A", "B", "C", "D", "E"]).unwrap().starts_with("A, B, C and 2 more aren't"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
|
||||
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
|
||||
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
|
||||
//! character on screen that they lack, a face that has it is borrowed from the font database (the
|
||||
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
|
||||
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
|
||||
//! there are no installed fonts, only the embedded faces are used.
|
||||
//!
|
||||
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
|
||||
//! the composition and caption to every text the interface draws from the presentation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_fonts::{FontDb, FontFace};
|
||||
|
||||
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
|
||||
const MAX_FACE_BYTES: usize = 48 << 20;
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
/// Faces added after the interface fonts, in the order they were found.
|
||||
pub faces: Vec<Arc<FontFace>>,
|
||||
/// Characters already looked at (drawn, given a face, or found in no face).
|
||||
seen: HashSet<char>,
|
||||
/// The presentation last looked through: (uid, revision).
|
||||
doc: Option<(u64, u64)>,
|
||||
/// Faces were added since the fonts were last installed.
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
|
||||
fn wanted(c: char) -> bool {
|
||||
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
|
||||
}
|
||||
|
||||
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
|
||||
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
|
||||
/// very large faces are skipped (egui draws neither well).
|
||||
pub fn faces_for_text(
|
||||
text: &str,
|
||||
have: &[Arc<FontFace>],
|
||||
seen: &mut HashSet<char>,
|
||||
mut covered: impl FnMut(char) -> bool,
|
||||
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
|
||||
) -> Vec<Arc<FontFace>> {
|
||||
let mut out: Vec<Arc<FontFace>> = Vec::new();
|
||||
for c in text.chars().filter(|c| wanted(*c)) {
|
||||
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl UiFonts {
|
||||
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
|
||||
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
|
||||
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
|
||||
return;
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
self.faces.extend(new);
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Look through the text of a presentation the interface shows (once per revision): its
|
||||
/// title, slide text and names, notes, comments, sections and layout names.
|
||||
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
|
||||
if self.doc == Some((st.uid, st.revision)) {
|
||||
return;
|
||||
}
|
||||
self.doc = Some((st.uid, st.revision));
|
||||
let text = ui_text(st);
|
||||
self.note(ctx, &text);
|
||||
}
|
||||
|
||||
/// Install the interface fonts again when faces were added. True if they were.
|
||||
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
|
||||
if !std::mem::take(&mut self.changed) {
|
||||
return false;
|
||||
}
|
||||
crate::theme::install_fonts_with(ctx, &self.faces);
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a presentation that the interface can show (not the slides' pictures of it).
|
||||
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
|
||||
let mut out = st.title();
|
||||
let mut add = |s: &str| {
|
||||
if s.chars().any(wanted) {
|
||||
out.push('\n');
|
||||
out.push_str(s);
|
||||
}
|
||||
};
|
||||
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
|
||||
for p in &b.paragraphs {
|
||||
for r in &p.runs {
|
||||
add(&r.text);
|
||||
}
|
||||
}
|
||||
};
|
||||
let doc = &st.doc;
|
||||
add(&doc.props.title);
|
||||
for sec in &doc.sections {
|
||||
add(&sec.name);
|
||||
}
|
||||
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
|
||||
deckcraft_model::walk(list, &mut |sh, _| {
|
||||
add(&sh.name);
|
||||
add(&sh.title);
|
||||
add(&sh.descr);
|
||||
if let Some(t) = &sh.text {
|
||||
body(t, add);
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
for row in &tb.rows {
|
||||
for cell in &row.cells {
|
||||
body(&cell.text, add);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
for s in &doc.slides {
|
||||
add(&s.name);
|
||||
shapes(&s.shapes, &mut add);
|
||||
body(&s.notes, &mut add);
|
||||
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
|
||||
while let Some(c) = stack.pop() {
|
||||
add(&c.author);
|
||||
add(&c.text);
|
||||
stack.extend(c.replies.iter());
|
||||
}
|
||||
}
|
||||
for m in &doc.masters {
|
||||
add(&m.name);
|
||||
for l in &m.layouts {
|
||||
add(&l.name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn face(family: &str) -> Arc<FontFace> {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
fn one_face_per_script_and_each_character_once() {
|
||||
let inter = face("Inter");
|
||||
let looked = std::cell::Cell::new(0);
|
||||
let lookup = |_: char| {
|
||||
looked.set(looked.get() + 1);
|
||||
Some(inter.clone())
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
|
||||
assert_eq!(got.len(), 1, "one face, not one per character");
|
||||
// Inter doesn't cover Hangul, so every character was looked up once.
|
||||
assert_eq!(looked.get(), 5);
|
||||
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
|
||||
assert_eq!(looked.get(), 5);
|
||||
let mut seen = HashSet::new();
|
||||
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
|
||||
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
|
||||
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_text_of_a_presentation() {
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
|
||||
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
|
||||
let t = ui_text(s.doc().unwrap());
|
||||
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
|
||||
assert!(t.starts_with(&s.doc().unwrap().title()));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+15
-9
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -20,7 +20,7 @@ revision history.
|
||||
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
|
||||
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 4 | 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
|
||||
@@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
|
||||
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
|
||||
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
|
||||
- **No IME composition in text editing** (CJK input). 3–6 h.
|
||||
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
|
||||
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
|
||||
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
|
||||
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
|
||||
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
|
||||
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
|
||||
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
|
||||
|
||||
@@ -251,6 +255,8 @@ 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
|
||||
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
||||
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
||||
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
|
||||
| Pinch to zoom, two-finger gestures | yes | no |
|
||||
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
||||
| Touch screens (Windows) | yes | basic pointer only |
|
||||
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
|
||||
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
|
||||
| Dictation | yes (macOS) | no |
|
||||
|
||||
## Performance
|
||||
@@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME input methods (#101) |
|
||||
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
||||
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
||||
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
|
||||
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
|
||||
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
|
||||
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
|
||||
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
|
||||
|
||||
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
|
||||
@@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
|
||||
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
|
||||
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
|
||||
the system (#15), plural and number formatting, and an `xtask` check that every key has a
|
||||
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
|
||||
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
|
||||
per composition); the hours that included it are kept until the next re-measure.
|
||||
|
||||
## Proofing languages
|
||||
|
||||
@@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
|
||||
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||
|
||||
+37
-2
@@ -1,6 +1,8 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **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
|
||||
@@ -29,7 +31,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
@@ -41,6 +43,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
|---|---|---|
|
||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
||||
| Smart guides (edges, centres, equal spacing) | yes | yes |
|
||||
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
|
||||
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
||||
@@ -63,6 +66,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
|
||||
@@ -87,4 +119,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-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