mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c5976ae2 | ||
|
|
c4f355773d | ||
|
|
d4bc8812a3 | ||
|
|
dc8a884389 | ||
|
|
e28851d2f1 | ||
|
|
d6ab3d10da | ||
|
|
6334d689d2 | ||
|
|
9d571ef644 |
Generated
+1
@@ -1063,6 +1063,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-2
@@ -129,11 +129,12 @@ 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 |
|
||||
@@ -145,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
Some("Backspace"),
|
||||
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||
@@ -187,7 +197,9 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?;
|
||||
// After End on a wrapped line the caret is drawn at that line's end.
|
||||
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
@@ -232,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -503,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if a != b {
|
||||
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||
// (PowerPoint for Windows).
|
||||
ed::delete_word(body, a, dir == "wordForward")
|
||||
} else if a != b {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
"wordBackward" => ed::word_move(body, a, false),
|
||||
"wordForward" => ed::word_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
@@ -536,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
|
||||
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||
Some(line) => line.rtl,
|
||||
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_text::Affinity;
|
||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
let st = s.doc()?;
|
||||
@@ -545,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layout = layout_for(st, &t);
|
||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||
let caret = t.caret;
|
||||
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||
let to = match (to.as_str(), rtl) {
|
||||
("left", true) => "right",
|
||||
("right", true) => "left",
|
||||
("wordLeft", true) => "wordRight",
|
||||
("wordRight", true) => "wordLeft",
|
||||
(x, _) => x,
|
||||
}
|
||||
.to_string();
|
||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||
let mut upstream = false;
|
||||
let new = match to.as_str() {
|
||||
"left" if t.is_range() && !extend => collapse_to(true),
|
||||
"right" if t.is_range() && !extend => collapse_to(false),
|
||||
"left" => ed::char_move(&body, caret, false),
|
||||
"right" => ed::char_move(&body, caret, true),
|
||||
"wordLeft" => ed::word_move(&body, caret, false),
|
||||
"wordRight" => ed::word_move(&body, caret, true),
|
||||
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||
"paraStart" => (caret.0, 0),
|
||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
"paraUp" => ed::para_up(&body, caret),
|
||||
"paraDown" => ed::para_down(&body, caret),
|
||||
"start" => (0, 0),
|
||||
"end" => ed::end(&body),
|
||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||
Some(l) => {
|
||||
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
||||
let r = match to.as_str() {
|
||||
"up" => l.vertical(lp(caret), false, x),
|
||||
"down" => l.vertical(lp(caret), true, x),
|
||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
||||
_ => l.line_bounds(lp(caret)).1,
|
||||
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||
let (r, a) = match to.as_str() {
|
||||
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||
_ => l.line_end_caret(lp(caret), aff),
|
||||
};
|
||||
upstream = a == Affinity::Upstream;
|
||||
(r.para, r.ch)
|
||||
}
|
||||
None => match to.as_str() {
|
||||
@@ -581,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = new;
|
||||
tt.line_end = if upstream { Some(new) } else { None };
|
||||
if !extend {
|
||||
tt.anchor = new;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"caret": [new.0, new.1]}))
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -633,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
let body = body_of(st, &t);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = (at.0, n);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
|
||||
+79
-11
@@ -20,6 +20,30 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
||||
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
||||
}
|
||||
|
||||
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
|
||||
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
|
||||
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
|
||||
pub fn key_token(key: &str) -> String {
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
match k {
|
||||
"period" | ">" => ".",
|
||||
"comma" | "<" => ",",
|
||||
"openbracket" | "{" => "[",
|
||||
"closebracket" | "}" => "]",
|
||||
"equals" => "=",
|
||||
"minus" => "-",
|
||||
"plus" => "+",
|
||||
"slash" => "/",
|
||||
"backslash" => "\\",
|
||||
"semicolon" => ";",
|
||||
"quote" => "'",
|
||||
"backtick" => "`",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
@@ -28,21 +52,17 @@ impl Session {
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
let r = self.execute(id, ¶ms)?;
|
||||
return Ok(json!({"handled": true, "command": id, "result": r}));
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
|
||||
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
|
||||
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
|
||||
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
|
||||
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
|
||||
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
|
||||
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
|
||||
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
||||
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
||||
"tab" => {
|
||||
@@ -117,6 +137,10 @@ impl Session {
|
||||
}
|
||||
// Shortcuts from the registry.
|
||||
let key_name = match k {
|
||||
"left" => "Left",
|
||||
"right" => "Right",
|
||||
"up" => "Up",
|
||||
"down" => "Down",
|
||||
"enter" | "return" => "Enter",
|
||||
"escape" | "esc" => "Escape",
|
||||
"delete" => "Delete",
|
||||
@@ -127,7 +151,7 @@ impl Session {
|
||||
"end" => "End",
|
||||
"tab" => "Tab",
|
||||
"space" | " " => "Space",
|
||||
_ => key,
|
||||
_ => k,
|
||||
};
|
||||
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
||||
if let Some(id) = found {
|
||||
@@ -137,6 +161,50 @@ impl Session {
|
||||
Ok(json!({"handled": false}))
|
||||
}
|
||||
|
||||
/// The text-editing command for a navigation or deletion key while editing text, by
|
||||
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
|
||||
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
|
||||
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
|
||||
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
|
||||
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
|
||||
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
|
||||
if mods.cmd && !mods.alt && !mods.shift {
|
||||
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
|
||||
match k {
|
||||
"]" => return Some(("format.grow", json!({}))),
|
||||
"[" => return Some(("format.shrink", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let plain = !mods.cmd && !mods.alt;
|
||||
let to = match k {
|
||||
"left" if word => "wordLeft",
|
||||
"right" if word => "wordRight",
|
||||
"left" if mac_cmd => "lineStart",
|
||||
"right" if mac_cmd => "lineEnd",
|
||||
"left" if plain => "left",
|
||||
"right" if plain => "right",
|
||||
"up" if mac_cmd => "start",
|
||||
"down" if mac_cmd => "end",
|
||||
"up" if word && self.mac_keys => "paraStart",
|
||||
"down" if word && self.mac_keys => "paraEnd",
|
||||
"up" if word => "paraUp",
|
||||
"down" if word => "paraDown",
|
||||
"up" if plain => "up",
|
||||
"down" if plain => "down",
|
||||
"home" if mods.cmd && !mods.alt => "start",
|
||||
"end" if mods.cmd && !mods.alt => "end",
|
||||
"home" if plain => "lineStart",
|
||||
"end" if plain => "lineEnd",
|
||||
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
|
||||
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
|
||||
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
|
||||
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
|
||||
_ => return None,
|
||||
};
|
||||
Some(("text.move", json!({"to": to, "extend": mods.shift})))
|
||||
}
|
||||
|
||||
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
||||
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
||||
let st = self.doc()?;
|
||||
|
||||
@@ -68,6 +68,10 @@ pub struct TextSel {
|
||||
/// Editing the slide's speaker notes (shape is ignored).
|
||||
#[serde(default)]
|
||||
pub notes: bool,
|
||||
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
|
||||
/// start of the next line. Ignored once the caret moves anywhere else.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub line_end: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TextSel {
|
||||
@@ -77,6 +81,10 @@ impl TextSel {
|
||||
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
||||
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
||||
}
|
||||
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
|
||||
pub fn at_line_end(&self) -> bool {
|
||||
self.line_end == Some(self.caret)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
||||
@@ -339,6 +347,10 @@ pub struct Session {
|
||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
|
||||
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
|
||||
/// platform DeckCraft was built for.
|
||||
pub mac_keys: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -366,6 +378,7 @@ impl Session {
|
||||
recovery_dir: None,
|
||||
media_status: Default::default(),
|
||||
gif_paused: Default::default(),
|
||||
mac_keys: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
@@ -492,6 +505,10 @@ impl Session {
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
|
||||
if let Some(t) = st.selection.text.as_mut() {
|
||||
t.line_end = None;
|
||||
}
|
||||
f(&doc, &mut st.selection);
|
||||
clamp_selection(st);
|
||||
st.revision += 1;
|
||||
@@ -536,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_keys;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+79
-10
@@ -238,11 +238,14 @@ pub struct Renderer {
|
||||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||
drawn: Option<Rect>,
|
||||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||||
view: Affine,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,6 +663,25 @@ 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) }
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
}
|
||||
@@ -718,20 +740,34 @@ impl Renderer {
|
||||
bp.line_to((*px, *py));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(parent);
|
||||
let (base, rel) = self.line_space(parent, depth);
|
||||
bp.apply_affine(rel);
|
||||
ctx.set_transform(base);
|
||||
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&bp);
|
||||
}
|
||||
}
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st),
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn geometry_shape(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
rctx: &Ctx,
|
||||
s: &Shape,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
st: &ShapeState,
|
||||
depth: usize,
|
||||
) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
@@ -749,7 +785,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 {
|
||||
@@ -816,11 +857,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 {
|
||||
@@ -915,6 +964,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -942,8 +992,24 @@ impl Renderer {
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
match content {
|
||||
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
|
||||
// transparent background casts the logo's shadow): the picture, clipped to the
|
||||
// shape, recoloured to the shadow colour.
|
||||
Some(pf) => {
|
||||
ctx.push_clip_layer(&silhouette);
|
||||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
None => {
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
@@ -1069,9 +1135,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 {
|
||||
|
||||
@@ -1268,3 +1268,288 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||
}
|
||||
|
||||
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
|
||||
/// bytes).
|
||||
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (func, params) in records {
|
||||
let mut p = params.clone();
|
||||
if p.len() % 2 == 1 {
|
||||
p.push(0);
|
||||
}
|
||||
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
|
||||
body.extend(func.to_le_bytes());
|
||||
body.extend(p);
|
||||
}
|
||||
body.extend([3, 0, 0, 0, 0, 0]);
|
||||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
f.extend(0u16.to_le_bytes());
|
||||
for v in [0i16, 0, w, h] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend(1440u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let total = (f.len() + 18 + body.len()) / 2;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend((total as u32).to_le_bytes());
|
||||
f.extend(16u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
f.extend(body);
|
||||
f
|
||||
}
|
||||
|
||||
fn i16s(v: &[i32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
|
||||
/// background, and the alignment `ta`.
|
||||
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
|
||||
let mut f = i16s(&[height, 0, esc, esc, 400]);
|
||||
f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]);
|
||||
f.extend(face.as_bytes());
|
||||
f.push(0);
|
||||
vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))]
|
||||
}
|
||||
|
||||
/// EXTTEXTOUT at (x, y) with an optional dx array.
|
||||
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
|
||||
let mut v = i16s(&[y, x, s.len() as i32, 0]);
|
||||
v.extend(s.as_bytes());
|
||||
if s.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v.extend(i16s(dx));
|
||||
(0x0A32, v)
|
||||
}
|
||||
|
||||
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
|
||||
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut b: Option<(u32, u32, u32, u32)> = None;
|
||||
for (x, y, p) in img.enumerate_pixels() {
|
||||
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
|
||||
b = Some(match b {
|
||||
None => (x, y, x, y),
|
||||
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_ink_lands_at_its_reference_point() {
|
||||
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
|
||||
// aligned at (72, 18) pt.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push(text_record(1440, 360, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
|
||||
assert!((144..156).contains(&x0), "left edge at the reference point: {x0}");
|
||||
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
|
||||
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
|
||||
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
|
||||
assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_centre_baseline_and_escapement() {
|
||||
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
|
||||
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
|
||||
recs.push(text_record(1440, 720, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}");
|
||||
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
|
||||
// Escapement 90°: the string runs up the picture.
|
||||
let mut recs = font_records(-360, 900, "Arial", 24);
|
||||
recs.push(text_record(1440, 1300, "HHHH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}");
|
||||
assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_follows_its_dx_array() {
|
||||
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
|
||||
let mut recs = font_records(-360, 0, "Arial", 24);
|
||||
recs.push(text_record(360, 720, "HH", &[1440, 100]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
|
||||
assert!((36..56).any(col_has_ink), "first H at 36 px");
|
||||
assert!((180..200).any(col_has_ink), "second H one inch later");
|
||||
assert!(!(70..170).any(col_has_ink), "nothing between them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
|
||||
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
|
||||
// is Wingdings "l" in a symbol font that is not installed anywhere.
|
||||
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
|
||||
let mut recs = font_records(-1080, 0, face, 0);
|
||||
recs.push(text_record(360, 0, s, &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}");
|
||||
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
|
||||
assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle");
|
||||
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
|
||||
assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_opaque_text_fills_its_cell() {
|
||||
// OPAQUE background in red behind a space.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push((0x0102, i16s(&[2])));
|
||||
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
|
||||
recs.push(text_record(1440, 360, " ", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let p = img.get_pixel(148, 60);
|
||||
assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}");
|
||||
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
|
||||
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
|
||||
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend([0u8; 24]);
|
||||
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
for r in 0..8 {
|
||||
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
|
||||
}
|
||||
let mut brush = i16s(&[5, 0]);
|
||||
brush.extend(dib);
|
||||
let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))];
|
||||
let bytes = wmf_with(1440, 1440, &recs);
|
||||
// 4 px per point: a 0.75 pt cell is 3 px.
|
||||
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
|
||||
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
|
||||
assert!(a[3] > 200 && b[3] > 200);
|
||||
assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}");
|
||||
// Far below one pixel per cell: the average grey.
|
||||
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
|
||||
let p = img.get_pixel(16, 16);
|
||||
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
|
||||
}
|
||||
|
||||
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
|
||||
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
|
||||
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
|
||||
use deckcraft_model::style::{Dash, LineEnd};
|
||||
let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) };
|
||||
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
|
||||
Xfrm::new(a, b, c - a, d - b)
|
||||
};
|
||||
let rect = Shape {
|
||||
id: ShapeId(11),
|
||||
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
|
||||
geom: Geom::preset("rect"),
|
||||
line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() };
|
||||
let line = Shape {
|
||||
id: ShapeId(12),
|
||||
xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }),
|
||||
geom: Geom::preset("line"),
|
||||
line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect();
|
||||
let ink = Shape {
|
||||
id: ShapeId(13),
|
||||
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
|
||||
kind: ShapeKind::Ink {
|
||||
strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(10),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![rect, line, ink], child },
|
||||
..Default::default()
|
||||
};
|
||||
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
fn inked(img: &Image) -> usize {
|
||||
img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_child_scale_does_not_scale_line_widths() {
|
||||
// Children laid out 1:1 with the slide are the reference.
|
||||
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
|
||||
let drawn = inked(&reference);
|
||||
assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}");
|
||||
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
|
||||
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
|
||||
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0));
|
||||
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0));
|
||||
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
|
||||
let (n, d) = (inked(img), differing(&reference, img));
|
||||
assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}");
|
||||
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||||
// logo's shadow, not its frame's. Here the left half of the image is transparent.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let shadow = deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||
blur: 0.0,
|
||||
dist: 20.0,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
for as_fill in [false, true] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("logo.png", "image/png", png.clone());
|
||||
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
|
||||
if as_fill {
|
||||
sh.fill = Some(Fill::Picture(pf));
|
||||
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
} else {
|
||||
sh.kind = ShapeKind::Picture { fill: pf };
|
||||
}
|
||||
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Below the picture, 10 pt down: shadow under the opaque right half only.
|
||||
let left = img.pixel(200, 310);
|
||||
let right = img.pixel(400, 310);
|
||||
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
|
||||
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
|
||||
// The transparent half shows the slide, not a shadow behind it.
|
||||
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
|
||||
}
|
||||
}
|
||||
|
||||
+80
-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.
|
||||
@@ -1860,24 +1888,70 @@ impl TextLayout {
|
||||
}
|
||||
/// 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;
|
||||
|
||||
@@ -748,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");
|
||||
}
|
||||
|
||||
@@ -1036,6 +1036,89 @@ 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());
|
||||
|
||||
@@ -191,6 +191,16 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
|
||||
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
||||
add_fallback_faces(&mut defs, extra);
|
||||
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
|
||||
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
|
||||
// unhinted, they draw.
|
||||
for (name, d) in defs.font_data.iter_mut() {
|
||||
if name.contains("BIZ UDPGothic") {
|
||||
let mut data = (**d).clone();
|
||||
data.tweak.hinting = Some(false);
|
||||
*d = std::sync::Arc::new(data);
|
||||
}
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
@@ -221,3 +231,38 @@ pub fn font(size: f32) -> egui::FontId {
|
||||
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
|
||||
/// has ink (a glyph image).
|
||||
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(super::font_definitions(&[]));
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
let mut out = vec![];
|
||||
ctx.fonts_mut(|f| {
|
||||
for size in [11.0, 12.0, 13.0, 16.0] {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
|
||||
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
|
||||
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
|
||||
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
// so notes and the outline showed gaps where they were.
|
||||
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
|
||||
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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 keep their width in points whatever the group's child-coordinate scale (#233); text in a group with a tiny child space is not drawn yet. |
|
||||
| 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 |
|
||||
|
||||
+8
-7
@@ -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
|
||||
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
|
||||
+32
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
> **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
|
||||
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
||||
@@ -64,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
|
||||
@@ -89,6 +120,6 @@ 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