mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
47
Commits
@@ -35,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -27,6 +27,10 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
|
||||
Generated
+38
-27
@@ -877,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -896,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -905,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -914,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -930,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -959,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -971,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -982,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -990,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -999,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1008,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1016,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1030,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1046,28 +1046,37 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1082,7 +1091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1098,23 +1107,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-metafile",
|
||||
"deckcraft-model",
|
||||
"deckcraft-text",
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1128,7 +1139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1150,7 +1161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1159,7 +1170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -5655,7 +5666,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft"
|
||||
# internal
|
||||
deckcraft-geom = { path = "crates/geom" }
|
||||
deckcraft-color = { path = "crates/color" }
|
||||
deckcraft-metafile = { path = "crates/metafile" }
|
||||
deckcraft-model = { path = "crates/model" }
|
||||
deckcraft-fonts = { path = "crates/fonts" }
|
||||
deckcraft-text = { path = "crates/text" }
|
||||
|
||||
@@ -128,6 +128,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -139,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -37,3 +37,7 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
||||
cmd!(
|
||||
"arrange.reorder",
|
||||
"Reorder Objects",
|
||||
["Arrange"],
|
||||
None,
|
||||
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||
has_slide,
|
||||
reorder
|
||||
),
|
||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
// Already in place (front of its group, say): no change and no undo step.
|
||||
let mut trial = s.doc()?.shapes().to_vec();
|
||||
order_tree(&mut trial, &ids, how, 0);
|
||||
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order_tree(list, &ids, how, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Every shape id with its depth, in tree order.
|
||||
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||
let mut out = vec![];
|
||||
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||
order_list(list, ids, how);
|
||||
}
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(children) = sh.children_mut() {
|
||||
order_tree(children, ids, how, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||
let st = s.doc()?;
|
||||
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||
Some(None) => st.shapes(),
|
||||
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||
};
|
||||
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||
return Ok(json!({"index": from}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
||||
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let sh = list.remove(i);
|
||||
list.insert(to.min(list.len()), sh);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
let at = to.min(list.len());
|
||||
list.insert(at, sh);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
edit_shapes(s, p, "arrange.align", |sh| {
|
||||
edit_geometry(s, p, "arrange.align", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let b = x.bounds();
|
||||
let (dx, dy) = match edge.as_str() {
|
||||
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets_pos.push((*id, d));
|
||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||
}
|
||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
||||
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||
if horiz {
|
||||
x.x += d;
|
||||
|
||||
@@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
s.add_document(DocState::new(p, path, title))
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
s.add_document(st)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
color
|
||||
),
|
||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
|
||||
cmd!(
|
||||
"format.case",
|
||||
"Change Case",
|
||||
["Home", "Font"],
|
||||
Some("Shift+F3"),
|
||||
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
|
||||
has_text_or_shapes,
|
||||
case
|
||||
),
|
||||
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
||||
cmd!(
|
||||
"format.spacing",
|
||||
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
if a == b {
|
||||
return ok();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = mode.clone();
|
||||
super::text::edit_text(s, move |body, _| {
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
pub mod table;
|
||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(format::specs());
|
||||
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
||||
})
|
||||
}
|
||||
|
||||
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||
/// locked targets are left out, and when every target is locked nothing changes.
|
||||
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(cmd, "the object is locked"));
|
||||
}
|
||||
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||
}
|
||||
|
||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"selection.select",
|
||||
"Select",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||
has_slide,
|
||||
select
|
||||
),
|
||||
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||
/// object from the list shows it again.
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||
let st = s.doc()?;
|
||||
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(bad("selection.select", "no such shape"));
|
||||
}
|
||||
let kind = valid.first().and_then(in_group);
|
||||
valid.retain(|i| in_group(i) == kind);
|
||||
let keep: Vec<ShapeId> = if toggle || add {
|
||||
let current = st.selection.shapes.clone();
|
||||
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||
return Ok(json!({"selected": current}));
|
||||
}
|
||||
current
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut next = keep;
|
||||
for i in &valid {
|
||||
match next.iter().position(|x| x == i) {
|
||||
Some(k) if toggle => {
|
||||
next.remove(k);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => next.push(*i),
|
||||
}
|
||||
}
|
||||
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||
if reveal.is_empty() {
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
})?;
|
||||
} else {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||
for id in &reveal {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
sh.hidden = false;
|
||||
}
|
||||
}
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||
}
|
||||
|
||||
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn any_hidden(v: &[Shape]) -> bool {
|
||||
let mut found = false;
|
||||
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||
found
|
||||
}
|
||||
if !any_hidden(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
fn rec(v: &mut [Shape], depth: usize) {
|
||||
for sh in v {
|
||||
sh.hidden = false;
|
||||
if depth < 64
|
||||
&& let Some(c) = sh.children_mut()
|
||||
{
|
||||
rec(c, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||
sh.hidden = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||
let x = 20.0 + 110.0 * i as f64;
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
ids.extend([h, g]);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||
fn tree(s: &Session) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(s.doc().unwrap().shapes())
|
||||
}
|
||||
|
||||
/// (hidden, locked)
|
||||
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||
(sh.hidden, sh.locked)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u32> {
|
||||
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn bx(s: &mut Session, id: u32) -> Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
// A child moves among its siblings only and stops at the group's front.
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||
// Steps that changed nothing left no undo step.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||
// Drag A to the front of the slide (an index past the end clamps).
|
||||
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||
// A child is dragged within its group: it never leaves it.
|
||||
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||
// Dropping where it already is changes nothing (no undo step).
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// One undo step per drop.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_show_all_and_hide_all() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||
assert!(flags(&s, b).0);
|
||||
// Hiding a group hides only the group; its children keep their own flags.
|
||||
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||
// Hide All hides the top level only.
|
||||
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||
// Show All shows everything, children included.
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(flags(&s, a).1);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(!flags(&s, a).1, "toggles");
|
||||
// Locking a group locks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||
// A child can be unlocked on its own.
|
||||
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||
// Unlocking the group unlocks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
let before = bx(&mut s, a);
|
||||
for (cmd, p) in [
|
||||
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||
}
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||
let bb = bx(&mut s, b);
|
||||
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||
// Formatting and the name still change.
|
||||
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||
// The arrow keys don't nudge it.
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let _ = s.key("ArrowRight", Mods::default());
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||
let (mut s, ids) = deck();
|
||||
let a = ids[0];
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// A: x 20..120, y 50..130.
|
||||
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_from_the_pane() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b, c]);
|
||||
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c, a, b]);
|
||||
// A child inside a group; children across nesting levels go together.
|
||||
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d]);
|
||||
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// Picking makes no undo step...
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// ...unless it shows a hidden object again.
|
||||
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, c).0);
|
||||
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_the_pane_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let e = ids[4];
|
||||
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||
undo(&mut s);
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||
}
|
||||
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||||
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
|
||||
cmd!(
|
||||
"shape.lock",
|
||||
"Lock",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||||
has_selection,
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_shapes(s, p, "shape.move", |sh| {
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||||
let lock = bool_or(p, "lockAspect", false);
|
||||
edit_shapes(s, p, "shape.resize", |sh| {
|
||||
edit_geometry(s, p, "shape.resize", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "shape.setBounds", |sh| {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_shapes(s, p, "shape.rotate", |sh| {
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_shapes(s, p, "shape.flip", |sh| {
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_shapes(s, p, "shape.adjust", |sh| {
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_shapes(s, p, "shape.change", |sh| {
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||||
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
sh.locked = v.unwrap_or(!sh.locked);
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||||
sh.locked = to;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for c in children {
|
||||
set_locked(c, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||||
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
edit_shapes(s, p, "picture.crop", |sh| {
|
||||
edit_geometry(s, p, "picture.crop", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
for (i, v) in c.iter().enumerate() {
|
||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||
|
||||
+213
-15
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||
// The input method's composition: shown in place, an undo step only once committed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||
enabled: has_text,
|
||||
run: compose,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(
|
||||
noundo "text.commit",
|
||||
"Commit Composition",
|
||||
[],
|
||||
None,
|
||||
"{text} — the input method's result replaces its composition, as one undo step",
|
||||
has_text,
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
Some("Backspace"),
|
||||
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
||||
))
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||
/// table cells, which aren't laid out on the slide here.
|
||||
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let mut body = sh.text.clone().unwrap_or_default();
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
// After End on a wrapped line the caret is drawn at that line's end.
|
||||
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -184,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -340,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||
fn ime_text(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||
let Some(t) = &st.selection.text else { return false };
|
||||
let Some(body) = body_of(st, t) else { return false };
|
||||
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||
}
|
||||
|
||||
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||
/// goes back to how it was before the composition began.
|
||||
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||
let n = text.chars().count();
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x < y).then_some((x.min(n), y.min(n)))
|
||||
});
|
||||
let current = s.doc()?.composition.clone();
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
match current {
|
||||
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||
let (start, props) = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||
let c = ed::insert(body, at, &text, props.clone());
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
(at, props)
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
Some(c) if text.is_empty() => {
|
||||
let st = s.doc_mut()?;
|
||||
st.doc = c.before;
|
||||
st.selection = c.before_selection;
|
||||
st.composition = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"composing": false}))
|
||||
}
|
||||
Some(c) => {
|
||||
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||
edit_text(s, |body, t| {
|
||||
ed::delete(body, start, (start.0, start.1 + len));
|
||||
let e = ed::insert(body, start, &text, props);
|
||||
t.anchor = e;
|
||||
t.caret = e;
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||
compose(s, &json!({"text": text}))?;
|
||||
}
|
||||
s.end_composition();
|
||||
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||
}
|
||||
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||
let mut out = vec![];
|
||||
let mut add = |a: usize, b: usize, thick: bool| {
|
||||
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||
}
|
||||
};
|
||||
add(0, c.len, false);
|
||||
if let Some((a, b)) = c.active {
|
||||
add(a, b.min(c.len), true);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||
insert_text(s, &text, None)
|
||||
@@ -349,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if a != b {
|
||||
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||
// (PowerPoint for Windows).
|
||||
ed::delete_word(body, a, dir == "wordForward")
|
||||
} else if a != b {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
"wordBackward" => ed::word_move(body, a, false),
|
||||
"wordForward" => ed::word_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
@@ -382,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
|
||||
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||
Some(line) => line.rtl,
|
||||
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_text::Affinity;
|
||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
let st = s.doc()?;
|
||||
@@ -391,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layout = layout_for(st, &t);
|
||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||
let caret = t.caret;
|
||||
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||
let to = match (to.as_str(), rtl) {
|
||||
("left", true) => "right",
|
||||
("right", true) => "left",
|
||||
("wordLeft", true) => "wordRight",
|
||||
("wordRight", true) => "wordLeft",
|
||||
(x, _) => x,
|
||||
}
|
||||
.to_string();
|
||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||
let mut upstream = false;
|
||||
let new = match to.as_str() {
|
||||
"left" if t.is_range() && !extend => collapse_to(true),
|
||||
"right" if t.is_range() && !extend => collapse_to(false),
|
||||
"left" => ed::char_move(&body, caret, false),
|
||||
"right" => ed::char_move(&body, caret, true),
|
||||
"wordLeft" => ed::word_move(&body, caret, false),
|
||||
"wordRight" => ed::word_move(&body, caret, true),
|
||||
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||
"paraStart" => (caret.0, 0),
|
||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
"paraUp" => ed::para_up(&body, caret),
|
||||
"paraDown" => ed::para_down(&body, caret),
|
||||
"start" => (0, 0),
|
||||
"end" => ed::end(&body),
|
||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||
Some(l) => {
|
||||
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
||||
let r = match to.as_str() {
|
||||
"up" => l.vertical(lp(caret), false, x),
|
||||
"down" => l.vertical(lp(caret), true, x),
|
||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
||||
_ => l.line_bounds(lp(caret)).1,
|
||||
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||
let (r, a) = match to.as_str() {
|
||||
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||
_ => l.line_end_caret(lp(caret), aff),
|
||||
};
|
||||
upstream = a == Affinity::Upstream;
|
||||
(r.para, r.ch)
|
||||
}
|
||||
None => match to.as_str() {
|
||||
@@ -427,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = new;
|
||||
tt.line_end = if upstream { Some(new) } else { None };
|
||||
if !extend {
|
||||
tt.anchor = new;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"caret": [new.0, new.1]}))
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -479,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
let body = body_of(st, &t);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = (at.0, n);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
for slide in &p.slides {
|
||||
let Some(ctx) = Ctx::for_slide(p, slide) else { continue };
|
||||
deckcraft_model::walk(&slide.shapes, &mut |s, _| {
|
||||
if let Some(body) = &s.text {
|
||||
body_fonts(&ctx, s, body, &mut add);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &s.kind {
|
||||
for cell in t.rows.iter().flat_map(|r| &r.cells) {
|
||||
body_fonts(&ctx, s, &cell.text, &mut add);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) {
|
||||
for para in &body.paragraphs {
|
||||
let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
if !has_text {
|
||||
continue;
|
||||
}
|
||||
for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) {
|
||||
let r = resolve::run(ctx, s, para, &run.props);
|
||||
add(resolve::font_family(ctx, &r));
|
||||
}
|
||||
let pp = resolve::para(ctx, s, para);
|
||||
if matches!(pp.bullet, Some(Bullet::Char { .. }))
|
||||
&& let Some(f) = pp.bullet_font.as_deref()
|
||||
{
|
||||
add(ctx.master.theme.font(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-11
@@ -20,28 +20,49 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
||||
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
||||
}
|
||||
|
||||
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
|
||||
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
|
||||
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
|
||||
pub fn key_token(key: &str) -> String {
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
match k {
|
||||
"period" | ">" => ".",
|
||||
"comma" | "<" => ",",
|
||||
"openbracket" | "{" => "[",
|
||||
"closebracket" | "}" => "]",
|
||||
"equals" => "=",
|
||||
"minus" => "-",
|
||||
"plus" => "+",
|
||||
"slash" => "/",
|
||||
"backslash" => "\\",
|
||||
"semicolon" => ";",
|
||||
"quote" => "'",
|
||||
"backtick" => "`",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
let r = self.execute(id, ¶ms)?;
|
||||
return Ok(json!({"handled": true, "command": id, "result": r}));
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
|
||||
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
|
||||
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
|
||||
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
|
||||
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
|
||||
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
|
||||
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
|
||||
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
||||
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
||||
"tab" => {
|
||||
@@ -116,6 +137,10 @@ impl Session {
|
||||
}
|
||||
// Shortcuts from the registry.
|
||||
let key_name = match k {
|
||||
"left" => "Left",
|
||||
"right" => "Right",
|
||||
"up" => "Up",
|
||||
"down" => "Down",
|
||||
"enter" | "return" => "Enter",
|
||||
"escape" | "esc" => "Escape",
|
||||
"delete" => "Delete",
|
||||
@@ -126,7 +151,7 @@ impl Session {
|
||||
"end" => "End",
|
||||
"tab" => "Tab",
|
||||
"space" | " " => "Space",
|
||||
_ => key,
|
||||
_ => k,
|
||||
};
|
||||
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
||||
if let Some(id) = found {
|
||||
@@ -136,6 +161,50 @@ impl Session {
|
||||
Ok(json!({"handled": false}))
|
||||
}
|
||||
|
||||
/// The text-editing command for a navigation or deletion key while editing text, by
|
||||
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
|
||||
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
|
||||
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
|
||||
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
|
||||
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
|
||||
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
|
||||
if mods.cmd && !mods.alt && !mods.shift {
|
||||
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
|
||||
match k {
|
||||
"]" => return Some(("format.grow", json!({}))),
|
||||
"[" => return Some(("format.shrink", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let plain = !mods.cmd && !mods.alt;
|
||||
let to = match k {
|
||||
"left" if word => "wordLeft",
|
||||
"right" if word => "wordRight",
|
||||
"left" if mac_cmd => "lineStart",
|
||||
"right" if mac_cmd => "lineEnd",
|
||||
"left" if plain => "left",
|
||||
"right" if plain => "right",
|
||||
"up" if mac_cmd => "start",
|
||||
"down" if mac_cmd => "end",
|
||||
"up" if word && self.mac_keys => "paraStart",
|
||||
"down" if word && self.mac_keys => "paraEnd",
|
||||
"up" if word => "paraUp",
|
||||
"down" if word => "paraDown",
|
||||
"up" if plain => "up",
|
||||
"down" if plain => "down",
|
||||
"home" if mods.cmd && !mods.alt => "start",
|
||||
"end" if mods.cmd && !mods.alt => "end",
|
||||
"home" if plain => "lineStart",
|
||||
"end" if plain => "lineEnd",
|
||||
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
|
||||
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
|
||||
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
|
||||
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
|
||||
_ => return None,
|
||||
};
|
||||
Some(("text.move", json!({"to": to, "extend": mods.shift})))
|
||||
}
|
||||
|
||||
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
||||
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
||||
let st = self.doc()?;
|
||||
@@ -151,6 +220,45 @@ impl Session {
|
||||
self.execute("text.insert", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
||||
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
||||
/// `active` is the part being converted, in characters of `text`.
|
||||
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
let mut p = json!({"text": text});
|
||||
if let Some((a, b)) = active {
|
||||
p["active"] = json!([a, b]);
|
||||
}
|
||||
self.execute("text.compose", &p)
|
||||
}
|
||||
|
||||
/// An input method's result (#101): replaces the composition as one undo step.
|
||||
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
||||
if st.selection.shapes.len() != 1 || text.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.execute("text.edit", &json!({"id": id}))?;
|
||||
self.execute("text.selectAll", &json!({}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -67,6 +68,10 @@ pub struct TextSel {
|
||||
/// Editing the slide's speaker notes (shape is ignored).
|
||||
#[serde(default)]
|
||||
pub notes: bool,
|
||||
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
|
||||
/// start of the next line. Ignored once the caret moves anywhere else.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub line_end: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TextSel {
|
||||
@@ -76,6 +81,10 @@ impl TextSel {
|
||||
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
||||
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
||||
}
|
||||
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
|
||||
pub fn at_line_end(&self) -> bool {
|
||||
self.line_end == Some(self.caret)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
||||
@@ -130,6 +139,24 @@ pub struct Interaction {
|
||||
pub selection: Selection,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
|
||||
/// before it began makes the whole composition one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Composition {
|
||||
/// Where the composed text starts: (paragraph, char).
|
||||
pub start: (usize, usize),
|
||||
/// Its length in characters (it never spans paragraphs).
|
||||
pub len: usize,
|
||||
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
|
||||
pub active: Option<(usize, usize)>,
|
||||
/// The formatting the composed text is typed with (as `text.insert` would choose).
|
||||
pub props: Option<deckcraft_model::text::RunProps>,
|
||||
/// The presentation and selection before the composition began.
|
||||
pub before: Arc<Presentation>,
|
||||
pub before_selection: Selection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Presentation>,
|
||||
@@ -141,6 +168,11 @@ pub struct DocState {
|
||||
pub interaction: Option<Interaction>,
|
||||
pub uid: u64,
|
||||
pub title: String,
|
||||
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||
/// documents); the app mentions them once.
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// An input method's composition in progress ([`Composition`]).
|
||||
pub composition: Option<Composition>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -158,6 +190,8 @@ impl DocState {
|
||||
interaction: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -313,6 +347,10 @@ pub struct Session {
|
||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
|
||||
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
|
||||
/// platform DeckCraft was built for.
|
||||
pub mac_keys: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -340,6 +378,7 @@ impl Session {
|
||||
recovery_dir: None,
|
||||
media_status: Default::default(),
|
||||
gif_paused: Default::default(),
|
||||
mac_keys: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
@@ -398,6 +437,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
if !named {
|
||||
@@ -448,10 +491,24 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
|
||||
if let Some(t) = st.selection.text.as_mut() {
|
||||
t.line_end = None;
|
||||
}
|
||||
f(&doc, &mut st.selection);
|
||||
clamp_selection(st);
|
||||
st.revision += 1;
|
||||
@@ -496,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_keys;
|
||||
|
||||
@@ -504,6 +504,30 @@ fn hyperlink_on_a_word_or_shape_links_it() {
|
||||
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
||||
}
|
||||
|
||||
/// A .pptx (base64) whose first slide's title is set in `font`.
|
||||
fn deck_in_font(font: &str) -> String {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some(font.into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"]));
|
||||
assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]);
|
||||
// A font that is there (bundled or installed) isn't listed; new documents list nothing.
|
||||
let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||
@@ -543,3 +567,210 @@ fn inserted_hyperlinks_look_like_links() {
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
|
||||
/// like any other; one can be dragged back onto the slide or out of it.
|
||||
#[test]
|
||||
fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
// Inserting off the slide keeps the box where it was asked for.
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// Hover and click wholly off the slide.
|
||||
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
|
||||
// Drag it further off, then back onto the slide.
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
|
||||
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
|
||||
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
|
||||
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
|
||||
// The off-slide half of a straddling shape is hit too.
|
||||
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
// A marquee started on the pasteboard selects what it encloses there.
|
||||
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
}
|
||||
|
||||
fn title_text(s: &Session) -> String {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
|
||||
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
|
||||
/// committed text is one undo step, and nothing on the way is an undo step of its own.
|
||||
#[test]
|
||||
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A "})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
s.compose_text(pre, None).unwrap();
|
||||
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
|
||||
let c = s.doc().unwrap().composition.clone().unwrap();
|
||||
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
|
||||
}
|
||||
s.commit_text("한글").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A ");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
|
||||
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
|
||||
// then a new preedit.
|
||||
s.compose_text("ㄴ", None).unwrap();
|
||||
s.compose_text("", None).unwrap();
|
||||
s.commit_text("ㄴ").unwrap();
|
||||
s.compose_text("ㅁ", None).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
|
||||
s.commit_text("마").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ마");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ");
|
||||
}
|
||||
|
||||
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
|
||||
/// the text and the selection it replaced are as they were, with no undo step.
|
||||
#[test]
|
||||
fn ime_cancel_restores_the_text_and_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.compose_text("せかい", Some((0, 3))).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
|
||||
s.compose_text("世界", Some((0, 9))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
|
||||
s.compose_text("", None).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello world");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
assert!(s.doc().unwrap().revision > rev, "redrawn");
|
||||
// A commit of nothing without a composition is nothing.
|
||||
s.commit_text("").unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
// A commit without a preedit (some input methods send only the result) is one step.
|
||||
s.commit_text("世界").unwrap();
|
||||
assert_eq!(title_text(&s), "Hello 世界");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// #101: anything else settles the composition first: its text stays and is its own undo step.
|
||||
#[test]
|
||||
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
s.compose_text("안", None).unwrap();
|
||||
s.key("Right", Mods::default()).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(title_text(&s), "안");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
|
||||
s.compose_text("녕", None).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
|
||||
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.compose_text("하", None).unwrap();
|
||||
s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하!");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
// Text changed under the composition (here behind its back): a new one starts, no panic.
|
||||
s.compose_text("ㄱ", None).unwrap();
|
||||
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
|
||||
s.compose_text("가", None).unwrap();
|
||||
assert!(title_text(&s).ends_with('가'));
|
||||
// Control characters (an Enter sent as text) never split the paragraph.
|
||||
s.commit_text("가\n\r\u{b}").unwrap();
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
|
||||
/// does; with nothing selected there is nowhere to compose.
|
||||
#[test]
|
||||
fn ime_on_a_selected_shape_replaces_its_text() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
|
||||
s.compose_text("新", None).unwrap();
|
||||
s.commit_text("新しい").unwrap();
|
||||
let v = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes.clear();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
|
||||
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
|
||||
}
|
||||
|
||||
/// #101: the composition is underlined in place, under the composed characters, with a thicker
|
||||
/// line under the part being converted.
|
||||
#[test]
|
||||
fn ime_composition_underlines_follow_the_text() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
s.compose_text("にほんご", Some((2, 4))).unwrap();
|
||||
let u = cmd::text::composition_underlines(s.doc().unwrap());
|
||||
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
|
||||
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
|
||||
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
|
||||
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
|
||||
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
|
||||
for (a, b, _) in &u {
|
||||
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
|
||||
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
|
||||
}
|
||||
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP
|
||||
//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::Mods;
|
||||
|
||||
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
|
||||
const CTRL: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||
const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true };
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||
|
||||
type Pos = (usize, usize);
|
||||
|
||||
/// A session editing a new text box holding `text` (Windows keys), caret at `at`.
|
||||
fn editing(text: &str, at: Pos) -> (Session, u32) {
|
||||
editing_in(text, at, [60.0, 60.0, 600.0, 300.0])
|
||||
}
|
||||
|
||||
fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) {
|
||||
let mut s = Session::with_new();
|
||||
s.mac_keys = false;
|
||||
s.prefs.autocorrect = false;
|
||||
s.prefs.smart_quotes = false;
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap();
|
||||
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("text.set", &json!({"id": id, "text": text})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (Pos, Pos) {
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
(t.anchor, t.caret)
|
||||
}
|
||||
|
||||
fn caret(s: &Session) -> Pos {
|
||||
sel(s).1
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, key: &str, mods: Mods) -> Value {
|
||||
let r = s.key(key, mods).unwrap();
|
||||
assert_eq!(r["handled"], true, "{key} {mods:?} not handled");
|
||||
r
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, id: u32) -> String {
|
||||
s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Press `key` until the caret stops moving; the carets visited (in the first paragraph).
|
||||
fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec<usize> {
|
||||
let mut out = vec![caret(s).1];
|
||||
for _ in 0..200 {
|
||||
let before = caret(s);
|
||||
press(s, key, mods);
|
||||
if caret(s) == before {
|
||||
break;
|
||||
}
|
||||
out.push(caret(s).1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_stop_where_powerpoint_does() {
|
||||
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||
let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74];
|
||||
let (mut s, _) = editing(t, (0, 0));
|
||||
assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops);
|
||||
let mut back = stops.clone();
|
||||
back.reverse();
|
||||
assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() {
|
||||
let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]);
|
||||
// Arabic and Hebrew words inside a left-to-right paragraph: in logical order.
|
||||
let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]);
|
||||
// An emoji ZWJ sequence and a combining mark are never split.
|
||||
let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]);
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() {
|
||||
let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0));
|
||||
let mut seen = vec![caret(&s)];
|
||||
for _ in 0..9 {
|
||||
press(&mut s, "Right", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]);
|
||||
// A line break and a tab are stops of their own.
|
||||
s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_arrows_extend_by_words() {
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2));
|
||||
let mut carets = vec![];
|
||||
for _ in 0..6 {
|
||||
press(&mut s, "Right", CTRL_SHIFT);
|
||||
carets.push(sel(&s));
|
||||
}
|
||||
let a = (0, 2);
|
||||
assert_eq!(carets.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]);
|
||||
assert!(carets.iter().all(|x| x.0 == a));
|
||||
let mut back = vec![];
|
||||
for _ in 0..7 {
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
back.push(caret(&s));
|
||||
}
|
||||
assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]);
|
||||
assert_eq!(sel(&s).0, a);
|
||||
// From a caret inside a word going left: the word's start, then on.
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 2), (0, 21)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_leave_a_selection_from_its_ends() {
|
||||
for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] {
|
||||
let (mut s, _) = editing("Hello world foo, bar", anchor);
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 13), (0, 13)));
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 0), (0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_backspace_and_delete() {
|
||||
let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13));
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para");
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ","));
|
||||
assert_eq!(caret(&s), (0, 5));
|
||||
// At a paragraph's ends they join paragraphs.
|
||||
s.execute("text.move", &json!({"to": "paraEnd"})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello, barnext para");
|
||||
// A selection is not deleted: the word goes from its start (PowerPoint for Windows).
|
||||
let (mut s, id) = editing("Hello world", (0, 2));
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap();
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0))));
|
||||
let (mut s, id) = editing("Hello world", (0, 7));
|
||||
s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2))));
|
||||
// It is one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&mut s, id), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_up_down_move_by_paragraph() {
|
||||
let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para";
|
||||
let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Down", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]);
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
press(&mut s, "Left", NONE);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Up", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]);
|
||||
// With Shift they extend; back past the anchor the selection flips.
|
||||
s.execute("text.edit", &json!({"at": [0, 5]})).unwrap();
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (2, 0)));
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_end_and_ctrl_home_end() {
|
||||
let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond";
|
||||
let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]);
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let layout = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect();
|
||||
assert!(lines.len() >= 3, "{lines:?}");
|
||||
let (l1, l2) = (lines[1], lines[2]);
|
||||
// End goes past a wrapped line's trailing space, to where the next line starts, as in
|
||||
// PowerPoint, and the caret stays drawn on the line it ended.
|
||||
let next = lines[2].0;
|
||||
s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap();
|
||||
press(&mut s, "Home", NONE);
|
||||
assert_eq!(caret(&s), (0, l1.0));
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
let drawn = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let l = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap()
|
||||
};
|
||||
let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top;
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// End again stays on the line; Home goes back to its start, not the next line's.
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
press(&mut s, "Home", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, l1.0)));
|
||||
press(&mut s, "End", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, next)));
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// Down from a line's end lands on the next line, not two lines down.
|
||||
press(&mut s, "Down", NONE);
|
||||
let c = caret(&s);
|
||||
assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}");
|
||||
// The caret is drawn at the end of the line it belongs to.
|
||||
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||
let ts = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(ts.at_line_end());
|
||||
// Ctrl+Home / Ctrl+End, with Shift extending.
|
||||
press(&mut s, "End", CTRL);
|
||||
assert_eq!(caret(&s), (1, 6));
|
||||
press(&mut s, "Home", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 6), (0, 0)));
|
||||
press(&mut s, "Home", CTRL);
|
||||
press(&mut s, "End", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 6)));
|
||||
// A caret placed anywhere else is drawn at the start of its line again.
|
||||
s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap();
|
||||
assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
assert!(drawn(&s).1 > line1_top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_swap_left_and_right() {
|
||||
let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0));
|
||||
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||
// Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back.
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]);
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]);
|
||||
press(&mut s, "Left", NONE);
|
||||
assert_eq!(caret(&s), (0, 1));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 1), (0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_keys() {
|
||||
let (mut s, id) = editing("Hello world foo\nnext", (0, 8));
|
||||
s.mac_keys = true;
|
||||
press(&mut s, "Right", ALT);
|
||||
assert_eq!(caret(&s), (0, 12));
|
||||
press(&mut s, "Left", ALT);
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(caret(&s), (0, 15));
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
press(&mut s, "Down", CTRL);
|
||||
assert_eq!(caret(&s), (1, 4));
|
||||
press(&mut s, "Up", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap();
|
||||
press(&mut s, "Backspace", ALT);
|
||||
assert_eq!(text(&mut s, id), "Hello foo\nnext");
|
||||
// Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows.
|
||||
s.mac_keys = false;
|
||||
let before = caret(&s);
|
||||
let r = s.key("Right", ALT).unwrap();
|
||||
assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_keys() {
|
||||
let (mut s, _) = editing("hello wOrld. foo bar", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||
let base = size(&mut s).unwrap_or(18.0);
|
||||
// egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`.
|
||||
press(&mut s, "Period", CTRL_SHIFT);
|
||||
let up = size(&mut s).unwrap();
|
||||
assert!(up > base);
|
||||
press(&mut s, "Comma", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, "CloseBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
press(&mut s, "OpenBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, ">", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_underline_and_clear_on_a_caret_in_a_word() {
|
||||
let (mut s, id) = editing("hello wOrld. foo bar", (0, 8));
|
||||
let r = press(&mut s, "b", CTRL);
|
||||
assert_eq!(r["command"], "format.bold");
|
||||
let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
let runs: Vec<(String, Option<bool>)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect();
|
||||
assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld"));
|
||||
// At a word boundary nothing visible changes.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap();
|
||||
press(&mut s, "i", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true)));
|
||||
// Ctrl+Space clears the word's formatting.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "Space", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true)));
|
||||
press(&mut s, "u", CTRL);
|
||||
let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect();
|
||||
assert_eq!(under, "wOrld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_keys() {
|
||||
let (mut s, _) = editing("one\ntwo", (0, 1));
|
||||
for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] {
|
||||
press(&mut s, k, CTRL);
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["align"], want, "Ctrl+{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_f3_cycles_case() {
|
||||
let (mut s, id) = editing("hello world", (0, 0));
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..3 {
|
||||
press(&mut s, "F3", SHIFT);
|
||||
seen.push(text(&mut s, id));
|
||||
}
|
||||
assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]);
|
||||
s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello WORLD. foo");
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 6)));
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello world. foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_and_word_and_paragraph_selection() {
|
||||
let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7));
|
||||
s.execute("text.selectWord", &json!({})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 11)));
|
||||
s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 11), (0, 12)));
|
||||
// Triple-click: the paragraph and its mark.
|
||||
s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 0)));
|
||||
s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap();
|
||||
assert_eq!(sel(&s), ((1, 0), (1, 4)));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap();
|
||||
press(&mut s, "Enter", SHIFT);
|
||||
let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
assert_eq!(b.paragraphs.len(), 2);
|
||||
assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_never_panic_on_any_text() {
|
||||
let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "];
|
||||
let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"];
|
||||
for t in texts {
|
||||
for k in keys {
|
||||
for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] {
|
||||
for at in [(0, 0), (0, 1), (5, 5)] {
|
||||
let (mut s, _) = editing(t, at);
|
||||
for _ in 0..3 {
|
||||
let _ = s.key(k, m).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,10 @@ impl Session {
|
||||
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
||||
for id in st.selection.shapes.iter().rev() {
|
||||
let Some(sh) = st.shape(*id) else { continue };
|
||||
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
|
||||
if sh.locked {
|
||||
continue;
|
||||
}
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if sh.is_line() {
|
||||
let a = x.affine();
|
||||
@@ -247,8 +251,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
if sh.hidden || sh.locked {
|
||||
if sh.hidden {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hit_shape(st, sh, p, tol) {
|
||||
@@ -265,6 +270,10 @@ impl Session {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
@@ -419,7 +428,9 @@ impl Session {
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// Locked objects stay put when the selection is dragged.
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint.
|
||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||
@@ -429,7 +440,8 @@ impl Session {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
|
||||
let duplicate = mods.alt && origs.len() == ids.len();
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
|
||||
// Format Painter applies on click.
|
||||
if self.painter.is_some() && !toggle {
|
||||
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
||||
@@ -460,7 +472,7 @@ impl Session {
|
||||
match drag {
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
let mut d = p - start;
|
||||
if !moved && d.hypot() < tol.max(1.0) {
|
||||
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
|
||||
@@ -24,6 +24,22 @@ fn ignorable(c: char) -> bool {
|
||||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
|
||||
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
|
||||
@@ -55,9 +71,28 @@ impl FontDb {
|
||||
/// bundled face for its script, else an installed face for the script, else any loaded
|
||||
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
|
||||
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
|
||||
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
|
||||
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
|
||||
if is_emoji_cluster(cluster) {
|
||||
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.color_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
}
|
||||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
let script = cluster.iter().find_map(|c| script_of(*c));
|
||||
|
||||
+227
-12
@@ -52,6 +52,53 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
)
|
||||
}
|
||||
|
||||
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
|
||||
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
|
||||
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
|
||||
/// substitutes (Arial CE, Times New Roman CYR…).
|
||||
fn office_twin(family: &str) -> Option<&'static str> {
|
||||
let f = family.to_ascii_lowercase();
|
||||
Some(match f.as_str() {
|
||||
"liberation sans" | "helvetica" | "helv" => "Arial",
|
||||
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
|
||||
_ => {
|
||||
let base = f
|
||||
.strip_suffix(" baltic")
|
||||
.or(f.strip_suffix(" ce"))
|
||||
.or(f.strip_suffix(" cyr"))
|
||||
.or(f.strip_suffix(" greek"))
|
||||
.or(f.strip_suffix(" tur"))?;
|
||||
match base {
|
||||
"arial" => "Arial",
|
||||
"courier new" => "Courier New",
|
||||
"times new roman" => "Times New Roman",
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
|
||||
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
|
||||
/// substitute ([`substitutes`]; Aptos as Inter).
|
||||
fn is_office_cloud_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
|
||||
}
|
||||
|
||||
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
|
||||
/// whatever the theme, `panose` or `pitchFamily` say.
|
||||
pub const MISSING_FONT_FAMILY: &str = "Calibri";
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
@@ -65,6 +112,10 @@ pub struct FontFace {
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
|
||||
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
|
||||
/// fonts this way.
|
||||
pub legacy: Option<(String, String)>,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
@@ -79,6 +130,10 @@ pub struct FontFace {
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
/// Where the baseline sits in a line, as a share of the line's height from its top: the
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -87,6 +142,8 @@ pub struct FontFace {
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
/// Colour glyph tables present (`is_color`), looked up on first use.
|
||||
color: std::sync::OnceLock<bool>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -154,6 +211,11 @@ impl FontFace {
|
||||
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
|
||||
/// fonts have)?
|
||||
pub fn is_color(&self) -> bool {
|
||||
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
@@ -242,6 +304,11 @@ pub struct FontDb {
|
||||
/// scan, so what they find doesn't depend on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
|
||||
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
|
||||
/// Semibold" loads a face of the family "Segoe UI").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex<std::collections::HashSet<String>>,
|
||||
/// System fallback state: enabled, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
@@ -254,6 +321,13 @@ struct SysFallback {
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Does face `index` of `data` carry colour glyph tables?
|
||||
fn has_color_tables(data: &[u8], index: u32) -> bool {
|
||||
use skrifa::raw::types::Tag;
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
|
||||
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
@@ -298,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
|
||||
/// typographic `family`.
|
||||
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
|
||||
let legacy = name(f, &[StringId::FAMILY_NAME])?;
|
||||
if legacy.eq_ignore_ascii_case(family) {
|
||||
return None;
|
||||
}
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
|
||||
/// variable font is cataloged under its default style; its named instances appear once it loads.
|
||||
@@ -344,8 +428,13 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
let f = skrifa::FontRef::new(&font).ok()?;
|
||||
let (family, style) = face_names(&f)?;
|
||||
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
|
||||
let legacy = legacy_names(&f, &family);
|
||||
Some(std::iter::once((family, style)).chain(legacy))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -361,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
|
||||
dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
|
||||
}
|
||||
// Fonts Office installs inside its apps.
|
||||
for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
|
||||
dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
|
||||
}
|
||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
@@ -375,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
let l = std::path::PathBuf::from(l);
|
||||
dirs.push(l.join("Microsoft\\Windows\\Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them;
|
||||
// DeckCraft never ships or needs them.
|
||||
dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
@@ -436,6 +535,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
|
||||
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
|
||||
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
|
||||
/// baseline (observed).
|
||||
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let (a, d) = match f.os2() {
|
||||
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
|
||||
_ => (ascent, descent),
|
||||
};
|
||||
let total = a + d;
|
||||
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
|
||||
return 0.8;
|
||||
}
|
||||
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -456,10 +572,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
legacy,
|
||||
weight,
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
@@ -467,6 +585,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
@@ -474,6 +593,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
color: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,6 +654,8 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex::new(std::collections::HashSet::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
@@ -639,6 +761,8 @@ impl FontDb {
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -719,17 +843,35 @@ impl FontDb {
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
// Every installed file of the family first, so the best style is among the candidates. The
|
||||
// lock is held while the files load, so another thread looking up the family waits for
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
loaded.insert(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
self.find(family, style)
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
@@ -745,7 +887,9 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -800,11 +944,27 @@ impl FontDb {
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let ns = norm(style);
|
||||
if cands.is_empty() {
|
||||
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
|
||||
// their Windows style ("Regular", "Bold"…).
|
||||
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
|
||||
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
|
||||
// by the caller, as Windows does, rather than a face of another weight.
|
||||
if let Some(f) = cands
|
||||
.iter()
|
||||
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
|
||||
{
|
||||
return Some((*f).clone());
|
||||
}
|
||||
}
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ns = norm(style);
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
@@ -880,6 +1040,61 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
|
||||
/// `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_color_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
|
||||
+93
-2
@@ -13,8 +13,10 @@ mod fontdb;
|
||||
pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
@@ -136,6 +138,25 @@ mod tests {
|
||||
FontDb::global().face(DEFAULT_FAMILY, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fonts_are_recognised() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
|
||||
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
|
||||
assert!(!fontdb::last_resort_face().is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
@@ -143,6 +164,17 @@ mod tests {
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_split_follows_the_windows_ascent_and_descent() {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = fontdb::last_resort_face();
|
||||
let font = skrifa::FontRef::new(f.data()).unwrap();
|
||||
let os2 = font.os2().unwrap();
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
|
||||
assert!(f.line_split > 0.5 && f.line_split < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_face_always_resolves() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
@@ -152,6 +184,65 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_family_names_find_their_faces() {
|
||||
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
|
||||
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
|
||||
let check = |db: &FontDb| {
|
||||
assert!(db.has_family("DeckCraft Legacy Semibold"));
|
||||
for style in ["Regular", "Bold"] {
|
||||
let f = db.face("DeckCraft Legacy Semibold", style);
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
|
||||
}
|
||||
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
|
||||
// The typographic name still works.
|
||||
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
|
||||
};
|
||||
// Loaded directly…
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
|
||||
check(&db);
|
||||
// …and found by the system font scan under its Windows name, then loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.set_system_fallback(false);
|
||||
check(&db);
|
||||
// Looking up the Windows name loads the Semibold file only; a later lookup of the
|
||||
// family's Regular must still load and pick the Regular file, not settle for Semibold.
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
|
||||
let regular = db.face("DeckCraft Legacy", "Regular");
|
||||
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
|
||||
let db = FontDb::global();
|
||||
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
|
||||
let f = db.document_face(missing, "Bold");
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
}
|
||||
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
|
||||
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
|
||||
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
|
||||
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Calibri").contains(&"Carlito"));
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -0,0 +1,55 @@
|
||||
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
|
||||
|
||||
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
|
||||
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
|
||||
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
|
||||
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
|
||||
a square for "A".
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def square():
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((100, 0))
|
||||
pen.lineTo((100, 700))
|
||||
pen.lineTo((600, 700))
|
||||
pen.lineTo((600, 0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def build(path, names, weight):
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder([".notdef", "A"])
|
||||
fb.setupCharacterMap({ord("A"): "A"})
|
||||
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
|
||||
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
|
||||
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
||||
fb.setupNameTable(names)
|
||||
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
|
||||
fb.setupPost()
|
||||
fb.save(path)
|
||||
|
||||
|
||||
build(sys.argv[1], {
|
||||
"familyName": "DeckCraft Legacy Semibold",
|
||||
"styleName": "Regular",
|
||||
"typographicFamily": "DeckCraft Legacy",
|
||||
"typographicSubfamily": "Semibold",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
|
||||
"fullName": "DeckCraft Legacy Semibold",
|
||||
"psName": "DeckCraftLegacy-Semibold",
|
||||
}, 600)
|
||||
build(sys.argv[2], {
|
||||
"familyName": "DeckCraft Legacy",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
|
||||
"fullName": "DeckCraft Legacy",
|
||||
"psName": "DeckCraftLegacy-Regular",
|
||||
}, 400)
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod morph;
|
||||
pub mod preset;
|
||||
pub mod warp;
|
||||
|
||||
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod extra;
|
||||
|
||||
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -148,6 +150,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("bentConnector3", "Connector: Elbow", Lines, [50000]),
|
||||
p!("curvedConnector3", "Connector: Curved", Lines, [50000]),
|
||||
p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []),
|
||||
p!("lineInv", "Line: Rising", Lines, []),
|
||||
p!("rect", "Rectangle", Rectangles, []),
|
||||
p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]),
|
||||
p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]),
|
||||
@@ -163,6 +166,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("rtTriangle", "Right Triangle", BasicShapes, []),
|
||||
p!("parallelogram", "Parallelogram", BasicShapes, [25000]),
|
||||
p!("trapezoid", "Trapezoid", BasicShapes, [25000]),
|
||||
p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]),
|
||||
p!("diamond", "Diamond", BasicShapes, []),
|
||||
p!("pentagon", "Regular Pentagon", BasicShapes, []),
|
||||
p!("hexagon", "Hexagon", BasicShapes, [25000]),
|
||||
@@ -171,9 +175,13 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("decagon", "Decagon", BasicShapes, []),
|
||||
p!("dodecagon", "Dodecagon", BasicShapes, []),
|
||||
p!("pie", "Partial Circle", BasicShapes, [0, 16200000]),
|
||||
p!("pieWedge", "Quarter Wedge", BasicShapes, []),
|
||||
p!("chord", "Chord", BasicShapes, [2700000, 16200000]),
|
||||
p!("teardrop", "Teardrop", BasicShapes, [100000]),
|
||||
p!("frame", "Frame", BasicShapes, [12500]),
|
||||
p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []),
|
||||
p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []),
|
||||
p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []),
|
||||
p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]),
|
||||
p!("corner", "L-Shape", BasicShapes, [50000, 50000]),
|
||||
p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]),
|
||||
@@ -192,6 +200,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("sun", "Sun", BasicShapes, [25000]),
|
||||
p!("moon", "Moon", BasicShapes, [50000]),
|
||||
p!("cloud", "Cloud", BasicShapes, []),
|
||||
p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]),
|
||||
p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]),
|
||||
p!("funnel", "Funnel", BasicShapes, []),
|
||||
p!("arc", "Arc", BasicShapes, [16200000, 0]),
|
||||
p!("bracketPair", "Double Bracket", BasicShapes, [16667]),
|
||||
p!("bracePair", "Double Brace", BasicShapes, [8333]),
|
||||
@@ -199,6 +210,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("rightBracket", "Right Bracket", BasicShapes, [8333]),
|
||||
p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]),
|
||||
p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]),
|
||||
p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []),
|
||||
p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []),
|
||||
p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []),
|
||||
p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]),
|
||||
p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]),
|
||||
p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]),
|
||||
@@ -209,16 +223,27 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]),
|
||||
p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]),
|
||||
p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]),
|
||||
p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]),
|
||||
p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]),
|
||||
p!("chevron", "Arrow: Chevron", BlockArrows, [50000]),
|
||||
p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]),
|
||||
p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]),
|
||||
p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]),
|
||||
p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]),
|
||||
p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
|
||||
p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
|
||||
p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]),
|
||||
p!("mathPlus", "Plus Sign", EquationShapes, [23520]),
|
||||
p!("mathMinus", "Minus Sign", EquationShapes, [23520]),
|
||||
p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]),
|
||||
@@ -253,6 +278,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []),
|
||||
p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []),
|
||||
p!("flowChartDisplay", "Flowchart: Display", Flowchart, []),
|
||||
p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []),
|
||||
p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []),
|
||||
p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []),
|
||||
p!("star4", "Star: 4 Points", StarsAndBanners, [12500]),
|
||||
@@ -267,6 +293,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("star32", "Star: 32 Points", StarsAndBanners, [37500]),
|
||||
p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]),
|
||||
p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]),
|
||||
p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]),
|
||||
p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]),
|
||||
p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]),
|
||||
p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]),
|
||||
p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]),
|
||||
p!("wave", "Wave", StarsAndBanners, [12500, 0]),
|
||||
@@ -276,6 +305,22 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]),
|
||||
p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]),
|
||||
p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!(
|
||||
"accentBorderCallout3",
|
||||
"Callout: Double Bent Line with Border and Accent Bar",
|
||||
Callouts,
|
||||
[18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]
|
||||
),
|
||||
p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []),
|
||||
p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []),
|
||||
p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []),
|
||||
@@ -1535,7 +1580,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
|
||||
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
|
||||
}
|
||||
_ => return None,
|
||||
_ => return extra::build(name, w, h, &a),
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved
|
||||
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
|
||||
//! construction, fitted to how the shapes look and to what their adjust values mean.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds the presets this module knows; `None` for any other name.
|
||||
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
|
||||
let ss = w.min(h);
|
||||
let full = Rect::new(0.0, 0.0, w, h);
|
||||
let mut b = Pb::new();
|
||||
let g = match name {
|
||||
"lineInv" => {
|
||||
let l = b.m(0.0, h).l(w, 0.0).take();
|
||||
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] }
|
||||
}
|
||||
"nonIsoscelesTrapezoid" => {
|
||||
let max = 50000.0 * w / ss.max(1e-9);
|
||||
let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max));
|
||||
let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5);
|
||||
let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h);
|
||||
g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max));
|
||||
g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max));
|
||||
g
|
||||
}
|
||||
"pieWedge" => {
|
||||
b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z();
|
||||
simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h)
|
||||
}
|
||||
"flowChartOfflineStorage" => {
|
||||
let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take();
|
||||
let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take();
|
||||
Geometry {
|
||||
paths: vec![fill(tri), stroke_only(bar)],
|
||||
text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8),
|
||||
handles: vec![],
|
||||
sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)],
|
||||
}
|
||||
}
|
||||
"chartX" | "chartStar" | "chartPlus" => {
|
||||
let body = corner_rect(w, h, [Corner::Square; 4]);
|
||||
let mut l = Pb::new();
|
||||
if name != "chartPlus" {
|
||||
l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0);
|
||||
}
|
||||
if name != "chartX" {
|
||||
l.m(w / 2.0, 0.0).l(w / 2.0, h);
|
||||
}
|
||||
if name == "chartPlus" {
|
||||
l.m(0.0, h / 2.0).l(w, h / 2.0);
|
||||
}
|
||||
Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
"cornerTabs" | "squareTabs" | "plaqueTabs" => {
|
||||
// Each tab's size is a twentieth of the diagonal.
|
||||
let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0);
|
||||
match name {
|
||||
"cornerTabs" => {
|
||||
b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]);
|
||||
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]);
|
||||
b.poly(&[(w, h - d), (w, h), (w - d, h)]);
|
||||
b.poly(&[(0.0, h - d), (d, h), (0.0, h)]);
|
||||
}
|
||||
"squareTabs" => {
|
||||
b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]);
|
||||
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]);
|
||||
b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]);
|
||||
b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]);
|
||||
}
|
||||
_ => {
|
||||
if d > 0.0 {
|
||||
b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z();
|
||||
b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z();
|
||||
b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z();
|
||||
b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z();
|
||||
} else {
|
||||
b.m(0.0, 0.0).z();
|
||||
}
|
||||
}
|
||||
}
|
||||
Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
"callout1"
|
||||
| "callout2"
|
||||
| "callout3"
|
||||
| "accentCallout1"
|
||||
| "accentCallout2"
|
||||
| "accentCallout3"
|
||||
| "borderCallout2"
|
||||
| "borderCallout3"
|
||||
| "accentBorderCallout1"
|
||||
| "accentBorderCallout2"
|
||||
| "accentBorderCallout3" => {
|
||||
let n = match name.chars().last() {
|
||||
Some('2') => 2,
|
||||
Some('3') => 3,
|
||||
_ => 1,
|
||||
};
|
||||
line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order"))
|
||||
}
|
||||
"leftRightArrowCallout" | "upDownArrowCallout" => {
|
||||
let vertical = name == "upDownArrowCallout";
|
||||
let (l, t) = if vertical { (h, w) } else { (w, h) };
|
||||
let ss = l.min(t);
|
||||
let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0);
|
||||
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
|
||||
let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0);
|
||||
let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl);
|
||||
let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0);
|
||||
let pts = [
|
||||
(0.0, c),
|
||||
(hl, c - head),
|
||||
(hl, c - shaft / 2.0),
|
||||
(b0, c - shaft / 2.0),
|
||||
(b0, 0.0),
|
||||
(b1, 0.0),
|
||||
(b1, c - shaft / 2.0),
|
||||
(l - hl, c - shaft / 2.0),
|
||||
(l - hl, c - head),
|
||||
(l, c),
|
||||
(l - hl, c + head),
|
||||
(l - hl, c + shaft / 2.0),
|
||||
(b1, c + shaft / 2.0),
|
||||
(b1, t),
|
||||
(b0, t),
|
||||
(b0, c + shaft / 2.0),
|
||||
(hl, c + shaft / 2.0),
|
||||
(hl, c + head),
|
||||
];
|
||||
let d = if vertical { Dir::Down } else { Dir::Right };
|
||||
let pts = orient(d, w, h, &pts);
|
||||
let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]);
|
||||
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
|
||||
}
|
||||
"quadArrowCallout" => {
|
||||
let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0);
|
||||
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
|
||||
let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0);
|
||||
let k = a(3).clamp(0.0, 100000.0) / 1e5;
|
||||
let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft));
|
||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
||||
let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0);
|
||||
let (s2, hd) = (shaft / 2.0, head);
|
||||
let pts = [
|
||||
(0.0, cy),
|
||||
(hl, cy - hd),
|
||||
(hl, cy - s2),
|
||||
(bx0, cy - s2),
|
||||
(bx0, by0),
|
||||
(cx - s2, by0),
|
||||
(cx - s2, hl),
|
||||
(cx - hd, hl),
|
||||
(cx, 0.0),
|
||||
(cx + hd, hl),
|
||||
(cx + s2, hl),
|
||||
(cx + s2, by0),
|
||||
(bx1, by0),
|
||||
(bx1, cy - s2),
|
||||
(w - hl, cy - s2),
|
||||
(w - hl, cy - hd),
|
||||
(w, cy),
|
||||
(w - hl, cy + hd),
|
||||
(w - hl, cy + s2),
|
||||
(bx1, cy + s2),
|
||||
(bx1, by1),
|
||||
(cx + s2, by1),
|
||||
(cx + s2, h - hl),
|
||||
(cx + hd, h - hl),
|
||||
(cx, h),
|
||||
(cx - hd, h - hl),
|
||||
(cx - s2, h - hl),
|
||||
(cx - s2, by1),
|
||||
(bx0, by1),
|
||||
(bx0, cy + s2),
|
||||
(hl, cy + s2),
|
||||
(hl, cy + hd),
|
||||
];
|
||||
simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h)
|
||||
}
|
||||
"curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)),
|
||||
"swooshArrow" => swoosh(w, h, a(0), a(1)),
|
||||
"ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)),
|
||||
"leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)),
|
||||
"gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)),
|
||||
"funnel" => funnel(w, h),
|
||||
"circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]),
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse
|
||||
/// `(rx, ry)`, its edges `th/2` either side.
|
||||
struct Ring {
|
||||
c: Point,
|
||||
rx: f64,
|
||||
ry: f64,
|
||||
th: f64,
|
||||
hw: f64,
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
/// The point at visual angle `deg` on the ellipse grown by `d`.
|
||||
fn at(&self, deg: f64, d: f64) -> Point {
|
||||
let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0));
|
||||
let t = vis(deg, ax, ay).to_radians();
|
||||
Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin())
|
||||
}
|
||||
/// Parametric angle (degrees) of `p` on the ellipse grown by `d`.
|
||||
fn param(&self, p: Point, d: f64) -> f64 {
|
||||
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
|
||||
((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees()
|
||||
}
|
||||
/// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b`
|
||||
/// on the side `sign` (+1 outwards, −1 inwards).
|
||||
fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point {
|
||||
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
|
||||
let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay);
|
||||
let (ux, uy) = (u.x / ax, u.y / ay);
|
||||
let qa = ux * ux + uy * uy;
|
||||
let qb = 2.0 * (px * ux + py * uy);
|
||||
let qc = px * px + py * py - 1.0;
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if qa < 1e-18 || disc < 0.0 {
|
||||
// The line misses (a long head on a thin ring): the edge then carries on from the
|
||||
// base centre itself, as the reference does.
|
||||
return b;
|
||||
}
|
||||
let r = disc.sqrt();
|
||||
let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa));
|
||||
let s = if sign > 0.0 {
|
||||
if s0 >= 0.0 { s0 } else { s1 }
|
||||
} else if s1 <= 0.0 {
|
||||
s1
|
||||
} else {
|
||||
s0
|
||||
};
|
||||
b + u * s
|
||||
}
|
||||
/// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip
|
||||
/// sits there at `base + sweep`. The base runs along the line from the centre to the tip.
|
||||
/// Returns (band outer edge, outer wing, tip, inner wing, band inner edge).
|
||||
fn head(&self, base: f64, sweep: f64) -> [Point; 5] {
|
||||
let b = self.at(base, 0.0);
|
||||
let tip = self.at(base + sweep, 0.0);
|
||||
let d = tip - self.c;
|
||||
let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) };
|
||||
let half = self.th / 2.0;
|
||||
[self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`.
|
||||
fn turn(a: f64, b: f64, dir: f64) -> f64 {
|
||||
if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) }
|
||||
}
|
||||
|
||||
/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise,
|
||||
/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`).
|
||||
/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width.
|
||||
fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
if ss <= 0.0 {
|
||||
return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h);
|
||||
}
|
||||
let a5 = a[4].clamp(0.0, 25000.0);
|
||||
let hw = ss * a5 / 1e5;
|
||||
let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5;
|
||||
let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw };
|
||||
let deg = |v: f64| (v / 60000.0) % 360.0;
|
||||
let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3]));
|
||||
let half = th / 2.0;
|
||||
let mut b = Pb::new();
|
||||
// Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in
|
||||
// the degenerate case above, where the arc shifts with it like the reference's).
|
||||
let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| {
|
||||
let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0));
|
||||
let (s, e) = (ring.param(from, d), ring.param(to, d));
|
||||
b.arc(ax, ay, s, turn(s, e, dir));
|
||||
};
|
||||
let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir);
|
||||
let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir);
|
||||
if name == "leftRightCircularArrow" {
|
||||
let hs = ring.head(start, -sweep);
|
||||
let he = ring.head(end, sweep);
|
||||
b.m(hs[0].x, hs[0].y);
|
||||
outer_arc(&mut b, hs[0], he[0], 1.0);
|
||||
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
|
||||
inner_arc(&mut b, he[4], hs[4], -1.0);
|
||||
b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z();
|
||||
} else {
|
||||
let dir = if name == "circularArrow" { 1.0 } else { -1.0 };
|
||||
let he = ring.head(end, sweep);
|
||||
let (o0, i0) = (ring.at(start, half), ring.at(start, -half));
|
||||
b.m(o0.x, o0.y);
|
||||
outer_arc(&mut b, o0, he[0], dir);
|
||||
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
|
||||
inner_arc(&mut b, he[4], i0, -dir);
|
||||
b.z();
|
||||
}
|
||||
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
|
||||
}
|
||||
|
||||
/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides
|
||||
/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed
|
||||
/// angles, fitted to the reference look.
|
||||
fn funnel(w: f64, h: f64) -> Geometry {
|
||||
const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse
|
||||
const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse
|
||||
let ss = w.min(h);
|
||||
let (cx, rim_y) = (w / 2.0, h / 4.0);
|
||||
let (sx, sy) = (w / 8.0, h / 15.0);
|
||||
let spout_y = h - sy;
|
||||
let at = |rx: f64, ry: f64, cy: f64, deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
Point::new(cx + rx * t.cos(), cy + ry * t.sin())
|
||||
};
|
||||
let p0 = at(cx, rim_y, rim_y, RIM);
|
||||
let p1 = at(sx, sy, spout_y, 180.0 - SPOUT);
|
||||
let mut b = Pb::new();
|
||||
b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM);
|
||||
b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z();
|
||||
let d = ss / 20.0;
|
||||
b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0));
|
||||
Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves
|
||||
/// the body at two points an equal angle either side of its centre, and its tip corners sit a
|
||||
/// tooth height further out along the body's normal and a flank lean further in.
|
||||
fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height
|
||||
let lean_max = if n == 6 { 5380.0 } else { 2730.0 };
|
||||
let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean
|
||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
||||
let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0));
|
||||
let r = (ss / 2.0 - th).max(1e-9);
|
||||
let half = ((th + l) / 2.0 / r).atan();
|
||||
let step = std::f64::consts::TAU / n as f64;
|
||||
let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin());
|
||||
let mut b = Pb::new();
|
||||
for k in 0..n {
|
||||
let t = -std::f64::consts::FRAC_PI_2 + step * k as f64;
|
||||
// The tooth stands along the body's normal at its centre.
|
||||
let nrm = Vec2::new(t.cos() * ry, t.sin() * rx);
|
||||
let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) };
|
||||
let v = Vec2::new(-u.y, u.x);
|
||||
let (r0, r1) = (on_body(t - half), on_body(t + half));
|
||||
let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l);
|
||||
if k == 0 {
|
||||
b.m(r0.x, r0.y);
|
||||
} else {
|
||||
b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees());
|
||||
}
|
||||
b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y);
|
||||
}
|
||||
let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64;
|
||||
b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z();
|
||||
let k = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k);
|
||||
simple(b.take(), text, w, h)
|
||||
}
|
||||
|
||||
/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low,
|
||||
/// and the band rolls over between them in a shaded half-ellipse curl.
|
||||
fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves
|
||||
let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness
|
||||
let cx = w / 2.0;
|
||||
let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0));
|
||||
let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length
|
||||
let yc = (h - sh) / 2.0;
|
||||
let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0);
|
||||
let (rt, rb) = (lt + sh, lb + sh);
|
||||
// The left half: filled as a closed outline, stroked only where it can show.
|
||||
let mut edge = Pb::new();
|
||||
edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt);
|
||||
edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0);
|
||||
let edge = edge.take();
|
||||
let mut left = Pb::new();
|
||||
left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0);
|
||||
left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z();
|
||||
let left = left.take();
|
||||
let mut curl = Pb::new();
|
||||
curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z();
|
||||
let mut right = Pb::new();
|
||||
right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0);
|
||||
right.l(w - hb, rt).l(w - hb, sh).z();
|
||||
Geometry {
|
||||
paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())],
|
||||
text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)),
|
||||
handles: vec![],
|
||||
sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at
|
||||
/// `xa`) to `xb`, as one quadratic curve.
|
||||
fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) {
|
||||
let hw = (w / 2.0).max(1e-9);
|
||||
let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2));
|
||||
let slope = -2.0 * d * (xa - hw) / (hw * hw);
|
||||
b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb));
|
||||
}
|
||||
|
||||
/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends
|
||||
/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically.
|
||||
fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre
|
||||
// Sag of every edge: never more than the drop, never so little that the band would rise
|
||||
// above the ends by more than half their height.
|
||||
let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th);
|
||||
let bw = w * a1.clamp(25000.0, 75000.0) / 1e5;
|
||||
let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0);
|
||||
let ear = w / 8.0;
|
||||
let (xf, xg) = (x0 + ear, x1 - ear);
|
||||
let e = h - th; // band height
|
||||
let off = th - d; // band top below the ends' top
|
||||
let t = |x: f64| {
|
||||
let hw = (w / 2.0).max(1e-9);
|
||||
d * (1.0 - ((x - hw) / hw).powi(2))
|
||||
};
|
||||
let mut ends = Pb::new();
|
||||
ends.m(0.0, 0.0);
|
||||
sag(&mut ends, w, d, 0.0, 0.0, xf);
|
||||
ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e);
|
||||
sag(&mut ends, w, d, e, x0, 0.0);
|
||||
ends.l(ear, t(ear) + e / 2.0).z();
|
||||
ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e);
|
||||
sag(&mut ends, w, d, e, w, x1);
|
||||
ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg));
|
||||
sag(&mut ends, w, d, 0.0, xg, w);
|
||||
ends.z();
|
||||
let mut folds = Pb::new();
|
||||
folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]);
|
||||
folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]);
|
||||
let mut band = Pb::new();
|
||||
band.m(x0, t(x0) + off);
|
||||
sag(&mut band, w, d, off, x0, x1);
|
||||
band.l(x1, t(x1) + off + e);
|
||||
sag(&mut band, w, d, off + e, x1, x0);
|
||||
band.z();
|
||||
let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())];
|
||||
let mut text = Rect::new(x0, th, x1, t(x0) + off + e);
|
||||
if up {
|
||||
let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]);
|
||||
for p in &mut paths {
|
||||
p.path.apply_affine(m);
|
||||
}
|
||||
text = m.transform_rect_bbox(text);
|
||||
}
|
||||
Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves
|
||||
/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter
|
||||
/// side above and below the band. The tilt and the tip's lift were fitted to the reference look.
|
||||
fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||
const TILT: f64 = 0.1126; // tan of the head base's lean from vertical
|
||||
const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height
|
||||
let ss = w.min(h);
|
||||
let e = ss / 8.0;
|
||||
let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0));
|
||||
// The head is at least long enough to keep its leaning base inside the frame.
|
||||
let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0));
|
||||
let qx = w - hl;
|
||||
let mut b = Pb::new();
|
||||
b.m(0.0, h)
|
||||
.q(w / 6.0, h / 3.0, qx, e)
|
||||
.l(qx - e * TILT, 0.0)
|
||||
.l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th))
|
||||
.l((qx + (e + th) * TILT).min(w), 2.0 * e + th)
|
||||
.l((qx + th * TILT).min(w), e + th)
|
||||
.q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h)
|
||||
.z();
|
||||
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
|
||||
}
|
||||
|
||||
/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right
|
||||
/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions.
|
||||
///
|
||||
/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the
|
||||
/// head side's edge, its horizontal radius is the full length, and the band has a constant
|
||||
/// vertical thickness. The back half (shaded) starts flat against the head side; the front half
|
||||
/// runs to the head, centred on the band where it ends.
|
||||
fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow");
|
||||
let (cw, ch) = if along_x { (w, h) } else { (h, w) };
|
||||
let ss = cw.min(ch);
|
||||
let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0);
|
||||
let th = (ss * a0.max(0.0) / 1e5).min(head);
|
||||
let hl = (ss * a2.max(0.0) / 1e5).min(cw);
|
||||
let hh = head / 2.0;
|
||||
// The band's centre line, carried on to the head side, ends at the tip, which sits half a
|
||||
// head width above the bottom.
|
||||
let r = ((ch - hh - th / 2.0) / 2.0).max(0.0);
|
||||
// Parametric angle where the front band reaches the head base.
|
||||
let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 };
|
||||
let mut back = Pb::new();
|
||||
back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z();
|
||||
let mut front = Pb::new();
|
||||
front.m(0.0, r).arc(cw, r, 180.0, te - 180.0);
|
||||
let yb = front.cur.y;
|
||||
let yc = yb + th / 2.0;
|
||||
front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z();
|
||||
let m = match name {
|
||||
"curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]),
|
||||
"curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]),
|
||||
"curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]),
|
||||
_ => kurbo::Affine::IDENTITY,
|
||||
};
|
||||
let (mut back, mut front) = (back.take(), front.take());
|
||||
back.apply_affine(m);
|
||||
front.apply_affine(m);
|
||||
Geometry {
|
||||
paths: vec![shade(back, FillMode::DarkenLess), fill(front)],
|
||||
text_rect: Rect::new(0.0, 0.0, w, h),
|
||||
handles: vec![],
|
||||
sites: mid_sites(w, h),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of
|
||||
/// the height and width), optionally with an accent bar at the first point's x and a border.
|
||||
fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry {
|
||||
let body = corner_rect(w, h, [Corner::Square; 4]);
|
||||
// Leaders may reach well outside the box, but not without bound.
|
||||
let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5);
|
||||
let mut l = Pb::new();
|
||||
let (x0, y0) = pt(0);
|
||||
l.m(x0, y0);
|
||||
for i in 1..pts {
|
||||
let (x, y) = pt(i);
|
||||
l.l(x, y);
|
||||
}
|
||||
let mut paths = vec![if border { fill(body) } else { fill_only(body) }];
|
||||
if accent {
|
||||
paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take()));
|
||||
}
|
||||
paths.push(stroke_only(l.take()));
|
||||
Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{CATALOG, FillMode, build, info};
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
|
||||
const NEW: [&str; 38] = [
|
||||
"lineInv",
|
||||
"nonIsoscelesTrapezoid",
|
||||
"pieWedge",
|
||||
"leftRightArrowCallout",
|
||||
"upDownArrowCallout",
|
||||
"quadArrowCallout",
|
||||
"circularArrow",
|
||||
"leftCircularArrow",
|
||||
"leftRightCircularArrow",
|
||||
"curvedRightArrow",
|
||||
"curvedLeftArrow",
|
||||
"curvedUpArrow",
|
||||
"curvedDownArrow",
|
||||
"swooshArrow",
|
||||
"callout1",
|
||||
"callout2",
|
||||
"callout3",
|
||||
"accentCallout1",
|
||||
"accentCallout2",
|
||||
"accentCallout3",
|
||||
"borderCallout2",
|
||||
"borderCallout3",
|
||||
"accentBorderCallout1",
|
||||
"accentBorderCallout2",
|
||||
"accentBorderCallout3",
|
||||
"ellipseRibbon",
|
||||
"ellipseRibbon2",
|
||||
"leftRightRibbon",
|
||||
"flowChartOfflineStorage",
|
||||
"gear6",
|
||||
"gear9",
|
||||
"funnel",
|
||||
"cornerTabs",
|
||||
"squareTabs",
|
||||
"plaqueTabs",
|
||||
"chartX",
|
||||
"chartStar",
|
||||
"chartPlus",
|
||||
];
|
||||
|
||||
fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath {
|
||||
build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest point of the (flattened) outline.
|
||||
fn dist(path: &BezPath, p: Point) -> f64 {
|
||||
let mut best = f64::MAX;
|
||||
let mut prev = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
kurbo::flatten(path.iter(), 0.01, |el| {
|
||||
let (a, b) = match el {
|
||||
PathEl::MoveTo(q) => {
|
||||
prev = q;
|
||||
start = q;
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(q) => (prev, q),
|
||||
PathEl::ClosePath => (prev, start),
|
||||
_ => return,
|
||||
};
|
||||
let d = b - a;
|
||||
let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min((a + d * t - p).hypot());
|
||||
prev = b;
|
||||
});
|
||||
best
|
||||
}
|
||||
|
||||
/// Asserts every point lies within 1pt of the outline.
|
||||
fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) {
|
||||
let path = outline(name, w, h, adj);
|
||||
for &(x, y) in pts {
|
||||
let d = dist(&path, Point::new(x, y));
|
||||
assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_presets_are_in_the_catalog() {
|
||||
for n in NEW {
|
||||
let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog"));
|
||||
assert!(!i.label.is_empty());
|
||||
}
|
||||
assert!(CATALOG.len() >= 183);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() {
|
||||
let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)];
|
||||
let adjusts: [&[f64]; 8] = [
|
||||
&[],
|
||||
&[0.0; 8],
|
||||
&[100000.0; 8],
|
||||
&[-50000.0; 8],
|
||||
&[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12],
|
||||
&[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN],
|
||||
&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0],
|
||||
&[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0],
|
||||
];
|
||||
for n in NEW {
|
||||
// Line callouts' leaders may reach anywhere, by design.
|
||||
let callout = n.contains("allout") && !n.contains("Arrow");
|
||||
for (w, h) in sizes {
|
||||
for adj in adjusts {
|
||||
let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build"));
|
||||
assert!(!g.paths.is_empty(), "{n} has no paths");
|
||||
for s in &g.paths {
|
||||
let bb = s.path.bounding_box();
|
||||
assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}");
|
||||
if !callout {
|
||||
// Circular arrows with a head longer than their ring can hold carry the
|
||||
// inner edge on from the head, as the reference does, so may spill.
|
||||
let spill = if n.contains("ircular") { 0.5 } else { 0.01 };
|
||||
let tol = 0.5 + w.max(h) * spill;
|
||||
assert!(
|
||||
bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol,
|
||||
"{n} {w}x{h} {adj:?} out of bounds: {bb:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let t = g.text_rect;
|
||||
assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}");
|
||||
}
|
||||
}
|
||||
let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}"));
|
||||
assert!(g.outline().elements().len() >= 2, "{n} default outline is empty");
|
||||
}
|
||||
}
|
||||
|
||||
// The spot checks below are outline points measured on reference renders.
|
||||
|
||||
#[test]
|
||||
fn simple_new_presets_match_reference_points() {
|
||||
on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]);
|
||||
on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]);
|
||||
on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]);
|
||||
on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]);
|
||||
on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]);
|
||||
on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]);
|
||||
on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]);
|
||||
on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]);
|
||||
on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]);
|
||||
on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]);
|
||||
// Chart markers and plain callouts leave the box unstroked.
|
||||
for n in ["chartX", "callout1", "accentCallout2"] {
|
||||
let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}"));
|
||||
assert!(!g.paths[0].stroke, "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_callouts_follow_their_adjusts() {
|
||||
on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]);
|
||||
let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0];
|
||||
let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3"));
|
||||
assert_eq!(g.paths.len(), 3);
|
||||
assert!(g.paths[0].stroke);
|
||||
let accent = g.paths[1].path.bounding_box();
|
||||
assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}");
|
||||
on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_callouts_match_reference_points() {
|
||||
on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]);
|
||||
on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]);
|
||||
on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]);
|
||||
on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_arrows_match_reference_points() {
|
||||
on(
|
||||
"curvedRightArrow",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)],
|
||||
);
|
||||
on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]);
|
||||
// The band never outgrows the head; the head never outgrows half the frame.
|
||||
on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]);
|
||||
on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]);
|
||||
on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]);
|
||||
on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]);
|
||||
let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow"));
|
||||
assert_eq!(g.paths[0].fill, FillMode::DarkenLess);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swoosh_matches_reference_points() {
|
||||
on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]);
|
||||
on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]);
|
||||
on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbons_match_reference_points() {
|
||||
on(
|
||||
"ellipseRibbon",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)],
|
||||
);
|
||||
on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]);
|
||||
on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]);
|
||||
on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]);
|
||||
on(
|
||||
"leftRightRibbon",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)],
|
||||
);
|
||||
on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gears_and_funnel_match_reference_points() {
|
||||
// Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3.
|
||||
on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]);
|
||||
on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]);
|
||||
on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]);
|
||||
on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]);
|
||||
let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel"));
|
||||
assert!(g.paths[0].even_odd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_arrows_match_reference_points() {
|
||||
on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]);
|
||||
on(
|
||||
"circularArrow",
|
||||
300.0,
|
||||
300.0,
|
||||
&[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0],
|
||||
&[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)],
|
||||
);
|
||||
on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]);
|
||||
on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]);
|
||||
// The band never grows past twice the head's half-width.
|
||||
on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
|
||||
//!
|
||||
//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
|
||||
//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
|
||||
//! and so on). A band either
|
||||
//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
|
||||
//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
|
||||
//! keeps the same scale, centred), and horizontally the text is scaled so that the
|
||||
//! widest band is filled along the mean length of its guides (one scale for the whole text), or
|
||||
//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
|
||||
//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
|
||||
//! guide being the line's width: insets don't count) and stand on
|
||||
//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer
|
||||
//! than the guide are shrunk to fit.
|
||||
//!
|
||||
//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
|
||||
//! bottom guide's length and interpolates between them. The guide curves were derived from
|
||||
//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
|
||||
//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
|
||||
use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
|
||||
|
||||
/// Flattening tolerance for guides, in points.
|
||||
const TOL: f64 = 0.02;
|
||||
|
||||
/// A guide curve flattened to a polyline with cumulative arc lengths.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Guide {
|
||||
pts: Vec<Point>,
|
||||
cum: Vec<f64>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
let mut cum: Vec<f64> = Vec::new();
|
||||
for p in raw {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
match pts.last() {
|
||||
Some(q) => {
|
||||
let d = (p - *q).hypot();
|
||||
if d > 1e-9 {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + d);
|
||||
pts.push(p);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
pts.push(p);
|
||||
cum.push(0.0);
|
||||
}
|
||||
}
|
||||
if pts.len() > 100_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Guide { pts, cum, closed }
|
||||
}
|
||||
|
||||
/// A guide through `path` (any curves), flattened.
|
||||
pub fn from_path(path: &BezPath, closed: bool) -> Guide {
|
||||
let mut pts = Vec::new();
|
||||
kurbo::flatten(path.iter(), TOL, |el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
|
||||
_ => {}
|
||||
});
|
||||
Guide::from_points(pts, closed)
|
||||
}
|
||||
|
||||
/// Total arc length.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.cum.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Whether the guide is a closed loop (positions past its end wrap around).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`. Open guides continue along their end tangents
|
||||
/// past either end; closed guides wrap.
|
||||
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let len = self.length();
|
||||
let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else {
|
||||
return (Point::ZERO, Vec2::new(1.0, 0.0));
|
||||
};
|
||||
if self.pts.len() < 2 || len <= 1e-9 {
|
||||
return (first, Vec2::new(1.0, 0.0));
|
||||
}
|
||||
let s = if s.is_finite() { s } else { 0.0 };
|
||||
let s = if self.closed { s.rem_euclid(len) } else { s };
|
||||
let seg = |i: usize| -> (Point, Point, f64) {
|
||||
let a = self.pts.get(i).copied().unwrap_or(first);
|
||||
let b = self.pts.get(i + 1).copied().unwrap_or(last);
|
||||
(a, b, self.cum.get(i).copied().unwrap_or(0.0))
|
||||
};
|
||||
let n = self.pts.len();
|
||||
let i = if s <= 0.0 {
|
||||
0
|
||||
} else if s >= len {
|
||||
n - 2
|
||||
} else {
|
||||
self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2)
|
||||
};
|
||||
let (a, b, c0) = seg(i);
|
||||
let d = b - a;
|
||||
let l = d.hypot();
|
||||
let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) };
|
||||
(a + t * (s - c0), t)
|
||||
}
|
||||
|
||||
/// The parallel curve `d` points away on the left of the direction of travel (above a
|
||||
/// guide running left to right).
|
||||
pub fn offset(&self, d: f64) -> Guide {
|
||||
let n = self.pts.len();
|
||||
if n < 2 || !d.is_finite() || d == 0.0 {
|
||||
return self.clone();
|
||||
}
|
||||
let unit = |i: usize| -> Vec2 {
|
||||
let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO));
|
||||
let v = b - a;
|
||||
let l = v.hypot();
|
||||
if l > 1e-12 { v / l } else { Vec2::ZERO }
|
||||
};
|
||||
let pts = (0..n).map(|i| {
|
||||
let before = if i > 0 {
|
||||
unit(i - 1)
|
||||
} else if self.closed {
|
||||
unit(n - 2)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
};
|
||||
let after = if i + 1 < n {
|
||||
unit(i)
|
||||
} else if self.closed {
|
||||
unit(0)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
};
|
||||
let t = before + after;
|
||||
let l = t.hypot();
|
||||
let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) };
|
||||
self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d
|
||||
});
|
||||
Guide::from_points(pts.collect::<Vec<_>>(), self.closed)
|
||||
}
|
||||
|
||||
/// The point at fraction `t` of the length.
|
||||
pub fn at_frac(&self, t: f64) -> Point {
|
||||
self.at(t * self.length()).0
|
||||
}
|
||||
}
|
||||
|
||||
/// Which line of a follow band sits on its guide.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineAnchor {
|
||||
/// The last line; earlier lines stack outwards (arch up, circle).
|
||||
Last,
|
||||
/// The first line; later lines stack below it (arch down).
|
||||
First,
|
||||
/// The middle of the band's lines.
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Band {
|
||||
/// Text stretched between two guides.
|
||||
Fill { top: Guide, bottom: Guide },
|
||||
/// Text at its natural size along a guide.
|
||||
Follow { path: Guide, anchor: LineAnchor },
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// The length text is measured against: the mean of a fill band's guides.
|
||||
pub fn length(&self) -> f64 {
|
||||
match self {
|
||||
Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0,
|
||||
Band::Follow { path, .. } => path.length(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved warp: its bands in shape-local points.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Warp {
|
||||
pub bands: Vec<Band>,
|
||||
}
|
||||
|
||||
/// Presets whose text follows a guide at its natural size (no wrapping).
|
||||
pub fn is_follow(name: &str) -> bool {
|
||||
matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton")
|
||||
}
|
||||
|
||||
/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets.
|
||||
pub fn adjust_defaults(name: &str) -> &'static [f64] {
|
||||
match name {
|
||||
"textPlain" => &[50_000.0],
|
||||
"textStop" => &[25_000.0],
|
||||
"textTriangle" | "textTriangleInverted" => &[50_000.0],
|
||||
"textChevron" => &[25_000.0],
|
||||
"textChevronInverted" => &[75_000.0],
|
||||
"textRingInside" | "textRingOutside" => &[60_000.0],
|
||||
"textArchUp" | "textCircle" | "textButton" => &[10_800_000.0],
|
||||
"textArchDown" => &[0.0],
|
||||
"textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0],
|
||||
"textArchDownPour" => &[0.0, 25_000.0],
|
||||
"textCurveUp" | "textCurveDown" => &[45_977.0],
|
||||
"textCanUp" => &[85_000.0],
|
||||
"textCanDown" => &[14_286.0],
|
||||
"textWave1" | "textWave2" => &[12_500.0, 0.0],
|
||||
"textDoubleWave1" | "textWave4" => &[6_250.0, 0.0],
|
||||
"textInflate" | "textDeflate" => &[18_750.0],
|
||||
"textInflateBottom" => &[60_000.0],
|
||||
"textDeflateBottom" | "textDeflateTop" => &[50_000.0],
|
||||
"textInflateTop" => &[40_000.0],
|
||||
"textDeflateInflate" => &[35_000.0],
|
||||
"textDeflateInflateDeflate" => &[25_000.0],
|
||||
"textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0],
|
||||
"textSlantUp" => &[55_556.0],
|
||||
"textSlantDown" => &[44_444.0],
|
||||
"textCascadeUp" | "textCascadeDown" => &[44_444.0],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of adjust values a warp preset takes.
|
||||
pub fn adjust_count(name: &str) -> usize {
|
||||
adjust_defaults(name).len()
|
||||
}
|
||||
|
||||
/// Whether `name` is a warp preset DeckCraft draws.
|
||||
pub fn is_supported(name: &str) -> bool {
|
||||
!adjust_defaults(name).is_empty()
|
||||
}
|
||||
|
||||
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Seg {
|
||||
M(f64, f64),
|
||||
L(f64, f64),
|
||||
Q(f64, f64, f64, f64),
|
||||
C(f64, f64, f64, f64, f64, f64),
|
||||
/// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise).
|
||||
A(f64, f64, f64, f64, f64, f64),
|
||||
}
|
||||
|
||||
enum Spec {
|
||||
Fill(Vec<Seg>, Vec<Seg>),
|
||||
Follow(Vec<Seg>, LineAnchor),
|
||||
}
|
||||
|
||||
struct Adj<'a> {
|
||||
raw: &'a [f64],
|
||||
defaults: &'static [f64],
|
||||
}
|
||||
|
||||
impl Adj<'_> {
|
||||
fn raw(&self, i: usize) -> f64 {
|
||||
self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0)
|
||||
}
|
||||
/// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions).
|
||||
fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 {
|
||||
(self.raw(i) / 100_000.0).clamp(lo, hi)
|
||||
}
|
||||
/// Adjust `i` as an angle in degrees (1/60 000), in 0..360.
|
||||
fn angle(&self, i: usize) -> f64 {
|
||||
(self.raw(i) / 60_000.0).rem_euclid(360.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn line(pts: &[(f64, f64)]) -> Vec<Seg> {
|
||||
pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect()
|
||||
}
|
||||
|
||||
/// Quadratic from (0, y0) through control (0.5, c) to (1, y0).
|
||||
fn quad(y0: f64, c: f64) -> Vec<Seg> {
|
||||
vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)]
|
||||
}
|
||||
|
||||
/// A sweep in (0, 360]: zero means a full turn.
|
||||
fn turn(deg: f64) -> f64 {
|
||||
let d = deg.rem_euclid(360.0);
|
||||
if d < 1e-9 { 360.0 } else { d }
|
||||
}
|
||||
|
||||
/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre.
|
||||
fn ellipse(s: f64, start: f64, sweep: f64) -> Vec<Seg> {
|
||||
vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)]
|
||||
}
|
||||
|
||||
/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise.
|
||||
fn arch_up(a: f64) -> (f64, f64) {
|
||||
(a, turn(2.0 * (270.0 - a)))
|
||||
}
|
||||
|
||||
/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise.
|
||||
fn arch_down(a: f64) -> (f64, f64) {
|
||||
(180.0 - a, -turn(180.0 - 2.0 * a))
|
||||
}
|
||||
|
||||
/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°.
|
||||
fn circle(a: f64) -> (f64, f64) {
|
||||
(a, turn(2.0 * (360.0 - a)))
|
||||
}
|
||||
|
||||
/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases).
|
||||
fn wave(out: &mut Vec<Seg>, x0: f64, x1: f64, y: f64, k: f64) {
|
||||
let l = x1 - x0;
|
||||
out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y));
|
||||
}
|
||||
|
||||
/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction).
|
||||
fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec {
|
||||
let d = 2.0 * shift;
|
||||
let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) };
|
||||
let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 };
|
||||
let guide = |(x0, x1): (f64, f64), y: f64| {
|
||||
let mut v = vec![Seg::M(x0, y)];
|
||||
let n = periods.max(1) as f64;
|
||||
for i in 0..periods.max(1) {
|
||||
let f = i as f64;
|
||||
wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k);
|
||||
}
|
||||
v
|
||||
};
|
||||
Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a))
|
||||
}
|
||||
|
||||
fn spec(name: &str, a: &Adj) -> Option<Vec<Spec>> {
|
||||
use Seg::*;
|
||||
let fill = |t: Vec<Seg>, b: Vec<Seg>| vec![Spec::Fill(t, b)];
|
||||
let top = || line(&[(0.0, 0.0), (1.0, 0.0)]);
|
||||
let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]);
|
||||
Some(match name {
|
||||
"textPlain" => {
|
||||
let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5);
|
||||
if d < 0.0 {
|
||||
fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)]))
|
||||
} else {
|
||||
fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)]))
|
||||
}
|
||||
}
|
||||
"textStop" => {
|
||||
let y = a.frac(0, 0.14286, 0.5);
|
||||
let t = 1.0 / 3.0;
|
||||
fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textTriangle" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom())
|
||||
}
|
||||
"textTriangleInverted" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textChevron" => {
|
||||
let y = a.frac(0, 0.0, 0.5);
|
||||
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)]))
|
||||
}
|
||||
"textChevronInverted" => {
|
||||
let y = a.frac(0, 0.5, 1.0);
|
||||
fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textFadeRight" => {
|
||||
let y = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textFadeLeft" => {
|
||||
let y = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)]))
|
||||
}
|
||||
"textFadeUp" => {
|
||||
let x = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom())
|
||||
}
|
||||
"textFadeDown" => {
|
||||
let x = a.frac(0, 0.0, 0.49999);
|
||||
fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)]))
|
||||
}
|
||||
"textSlantUp" => {
|
||||
let y = a.frac(0, 0.44444, 1.0);
|
||||
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textSlantDown" => {
|
||||
let y = a.frac(0, 0.0, 0.55556);
|
||||
fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)]))
|
||||
}
|
||||
"textCascadeUp" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textCascadeDown" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)]))
|
||||
}
|
||||
"textInflate" => {
|
||||
let y = a.frac(0, 0.0, 0.2);
|
||||
fill(quad(y, -y), quad(1.0 - y, 1.0 + y))
|
||||
}
|
||||
"textDeflate" => {
|
||||
let y = a.frac(0, 0.0, 0.375);
|
||||
fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y))
|
||||
}
|
||||
"textInflateBottom" => {
|
||||
let y = a.frac(0, 0.6, 1.0);
|
||||
fill(top(), quad(y, 1.0))
|
||||
}
|
||||
"textDeflateBottom" => {
|
||||
let y = a.frac(0, 0.0625, 1.0);
|
||||
fill(top(), quad(1.0, 2.0 * y - 1.0))
|
||||
}
|
||||
"textInflateTop" => {
|
||||
let y = a.frac(0, 0.0, 0.5);
|
||||
fill(quad(y, 0.0), bottom())
|
||||
}
|
||||
"textDeflateTop" => {
|
||||
let y = a.frac(0, 0.0, 0.9375);
|
||||
fill(quad(0.0, 2.0 * y), bottom())
|
||||
}
|
||||
"textDeflateInflate" => {
|
||||
// The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of
|
||||
// a band's height.
|
||||
let y = a.frac(0, 0.05, 0.95);
|
||||
let g = 0.05;
|
||||
vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())]
|
||||
}
|
||||
"textDeflateInflateDeflate" => {
|
||||
let y = a.frac(0, 0.01, 1.0 / 3.0);
|
||||
let g = 1.0 / 30.0;
|
||||
let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0);
|
||||
let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2);
|
||||
vec![
|
||||
Spec::Fill(top(), quad(t1 - g, c1 - g)),
|
||||
Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)),
|
||||
Spec::Fill(quad(t2 + g, c2 + g), bottom()),
|
||||
]
|
||||
}
|
||||
"textCurveUp" | "textCurveDown" => {
|
||||
let y = a.frac(0, 0.0, 0.56818);
|
||||
let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)];
|
||||
let t = if name == "textCurveUp" {
|
||||
vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)]
|
||||
} else {
|
||||
vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)]
|
||||
};
|
||||
fill(t, b)
|
||||
}
|
||||
"textCanUp" => {
|
||||
let r = 1.0 - a.frac(0, 0.5, 1.0);
|
||||
fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)])
|
||||
}
|
||||
"textCanDown" => {
|
||||
let r = a.frac(0, 0.0, 0.5);
|
||||
fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)])
|
||||
}
|
||||
"textRingInside" | "textRingOutside" => {
|
||||
let h = a.frac(0, 0.5, 0.99);
|
||||
let sweep = if name == "textRingInside" { 360.0 } else { -360.0 };
|
||||
fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)])
|
||||
}
|
||||
"textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)],
|
||||
"textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)],
|
||||
"textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)],
|
||||
"textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)],
|
||||
"textArchUp" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
|
||||
}
|
||||
"textArchDown" => {
|
||||
let (s, w) = arch_down(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)]
|
||||
}
|
||||
"textCircle" => {
|
||||
let (s, w) = circle(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
|
||||
}
|
||||
"textButton" => {
|
||||
let t = a.angle(0);
|
||||
let (s, w) = arch_up(t);
|
||||
let half = 0.5 * t.to_radians().cos().abs();
|
||||
vec![
|
||||
Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last),
|
||||
Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle),
|
||||
Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First),
|
||||
]
|
||||
}
|
||||
"textArchUpPour" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(1.0, s, w), ellipse(r, s, w))
|
||||
}
|
||||
"textArchDownPour" => {
|
||||
let (s, w) = arch_down(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(r, s, w), ellipse(1.0, s, w))
|
||||
}
|
||||
"textCirclePour" => {
|
||||
let (s, w) = circle(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(1.0, s, w), ellipse(r, s, w))
|
||||
}
|
||||
"textButtonPour" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
// The middle band runs between chords of the inner ellipse halfway to its top and
|
||||
// bottom.
|
||||
let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt());
|
||||
vec![
|
||||
Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)),
|
||||
Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])),
|
||||
Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)),
|
||||
]
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a guide from unit-space segments mapped onto `r`.
|
||||
fn guide(segs: &[Seg], r: Rect) -> Guide {
|
||||
let mut p = BezPath::new();
|
||||
let mut started = false;
|
||||
let mut closed = false;
|
||||
for s in segs {
|
||||
match *s {
|
||||
Seg::M(x, y) => {
|
||||
p.move_to((x, y));
|
||||
started = true;
|
||||
}
|
||||
Seg::L(x, y) => {
|
||||
if started {
|
||||
p.line_to((x, y))
|
||||
} else {
|
||||
p.move_to((x, y))
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
Seg::Q(cx, cy, x, y) => {
|
||||
if started {
|
||||
p.quad_to((cx, cy), (x, y));
|
||||
}
|
||||
}
|
||||
Seg::C(x1, y1, x2, y2, x, y) => {
|
||||
if started {
|
||||
p.curve_to((x1, y1), (x2, y2), (x, y));
|
||||
}
|
||||
}
|
||||
Seg::A(cx, cy, rx, ry, start, sweep) => {
|
||||
let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0);
|
||||
let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin());
|
||||
if started {
|
||||
p.line_to(s0);
|
||||
} else {
|
||||
p.move_to(s0);
|
||||
}
|
||||
started = true;
|
||||
for el in arc.append_iter(1e-5) {
|
||||
p.push(el);
|
||||
}
|
||||
closed = sweep.abs() >= 359.999;
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]);
|
||||
p.apply_affine(m);
|
||||
Guide::from_path(&p, closed)
|
||||
}
|
||||
|
||||
/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults)
|
||||
/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect.
|
||||
pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option<Warp> {
|
||||
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
|
||||
let a = Adj { raw: adj, defaults: adjust_defaults(name) };
|
||||
let specs = spec(name, &a)?;
|
||||
let bands = specs
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) },
|
||||
Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor },
|
||||
})
|
||||
.collect();
|
||||
Some(Warp { bands })
|
||||
}
|
||||
|
||||
/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines
|
||||
/// share the first band, and so on.
|
||||
pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize {
|
||||
if bands <= 1 || lines == 0 {
|
||||
return 0;
|
||||
}
|
||||
// Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k).
|
||||
(0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One laid-out line as the warp sees it (layout coordinates, points).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WarpLine {
|
||||
/// Ink box of the line's glyphs; `None` for a line without ink.
|
||||
pub ink: Option<Rect>,
|
||||
/// Advance extent of the line (left, right).
|
||||
pub extent: (f64, f64),
|
||||
/// Reference height for follow bands: the y that sits on the guide.
|
||||
pub ref_y: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum LineMap {
|
||||
Fill {
|
||||
band: usize,
|
||||
cx: f64,
|
||||
k: f64,
|
||||
len: f64,
|
||||
y0: f64,
|
||||
y1: f64,
|
||||
},
|
||||
/// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's.
|
||||
Follow {
|
||||
band: usize,
|
||||
guide: Option<usize>,
|
||||
e0: f64,
|
||||
s0: f64,
|
||||
k: f64,
|
||||
ref_y: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Maps points of laid-out lines onto a warp.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mapper<'a> {
|
||||
warp: &'a Warp,
|
||||
lines: Vec<LineMap>,
|
||||
offsets: Vec<Guide>,
|
||||
zoom: f64,
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
/// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in
|
||||
/// it gives its alignment along follow guides; the insets themselves don't shorten a guide).
|
||||
pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> {
|
||||
let nb = self.bands.len().max(1);
|
||||
let n = lines.len();
|
||||
let band_of: Vec<usize> = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect();
|
||||
// Ink of the whole text, for the shared horizontal centre and scale of fill bands.
|
||||
let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b));
|
||||
let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0);
|
||||
let mut k_fill = f64::INFINITY;
|
||||
let mut zoom: f64 = 1.0;
|
||||
let mut band_y: Vec<Option<(f64, f64)>> = vec![None; nb];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let Some(r) = l.ink else { continue };
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
if let Some(slot) = band_y.get_mut(b) {
|
||||
*slot = Some(match *slot {
|
||||
Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)),
|
||||
None => (r.y0, r.y1),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so
|
||||
// a band with fewer lines keeps them at the same size, centred between its guides.
|
||||
let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max);
|
||||
let band_y: Vec<Option<(f64, f64)>> =
|
||||
band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect();
|
||||
for (b, band) in self.bands.iter().enumerate() {
|
||||
if let Band::Fill { top, bottom } = band {
|
||||
let half = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| band_of.get(*i) == Some(&b))
|
||||
.filter_map(|(_, l)| l.ink)
|
||||
.map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs()))
|
||||
.fold(0.0, f64::max);
|
||||
if half > 1e-9 {
|
||||
k_fill = k_fill.min(band.length() / (2.0 * half));
|
||||
}
|
||||
if let Some(Some((y0, y1))) = band_y.get(b) {
|
||||
let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max);
|
||||
if y1 - y0 > 1e-9 {
|
||||
zoom = zoom.max(thick / (y1 - y0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !k_fill.is_finite() {
|
||||
k_fill = 1.0;
|
||||
}
|
||||
zoom = zoom.max(k_fill);
|
||||
let mut maps = Vec::with_capacity(n);
|
||||
let mut offsets = Vec::new();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
|
||||
match self.bands.get(b) {
|
||||
Some(Band::Fill { .. }) | None => {
|
||||
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
|
||||
let len = self.bands.get(b).map(Band::length).unwrap_or(0.0);
|
||||
maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 });
|
||||
}
|
||||
Some(Band::Follow { path, anchor }) => {
|
||||
let len = path.length();
|
||||
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
|
||||
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
|
||||
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
|
||||
let ref_y = match anchor {
|
||||
LineAnchor::Last => members.last().map(|l| l.ref_y),
|
||||
LineAnchor::First => members.first().map(|l| l.ref_y),
|
||||
LineAnchor::Middle => match (members.first(), members.last()) {
|
||||
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.unwrap_or(l.ref_y);
|
||||
// Lines off the guide run on a parallel curve at their natural length.
|
||||
let d = (ref_y - l.ref_y) * k;
|
||||
let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 {
|
||||
let g = path.offset(d);
|
||||
let len = g.length();
|
||||
offsets.push(g);
|
||||
(Some(offsets.len() - 1), len)
|
||||
} else {
|
||||
(None, len)
|
||||
};
|
||||
let (e0, e1) = l.extent;
|
||||
let w = (e1 - e0).max(0.0) * k;
|
||||
let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0));
|
||||
let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 };
|
||||
let s0 = f * (len - w);
|
||||
maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y });
|
||||
}
|
||||
}
|
||||
}
|
||||
Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } }
|
||||
}
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
/// How much the warp magnifies text at most (for flattening tolerances), ≥ 1.
|
||||
pub fn zoom(&self) -> f64 {
|
||||
self.zoom
|
||||
}
|
||||
|
||||
/// Map point `p` of line `line` (layout coordinates) onto the warp.
|
||||
pub fn map(&self, line: usize, p: Point) -> Point {
|
||||
let out = match self.lines.get(line) {
|
||||
Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => {
|
||||
let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p };
|
||||
let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 };
|
||||
let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 };
|
||||
let (t, b) = (top.at_frac(u), bottom.at_frac(u));
|
||||
t.lerp(b, v)
|
||||
}
|
||||
Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => {
|
||||
let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) {
|
||||
(Some(g), _) => g,
|
||||
(None, Some(Band::Follow { path, .. })) => path,
|
||||
_ => return p,
|
||||
};
|
||||
let s = s0 + (p.x - e0) * k;
|
||||
let d = (ref_y - p.y) * k;
|
||||
let (q, t) = path.at(s);
|
||||
q + Vec2::new(t.y, -t.x) * d
|
||||
}
|
||||
None => p,
|
||||
};
|
||||
if out.x.is_finite() && out.y.is_finite() { out } else { p }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,309 @@
|
||||
use super::*;
|
||||
|
||||
const ALL: &[&str] = &[
|
||||
"textPlain",
|
||||
"textStop",
|
||||
"textTriangle",
|
||||
"textTriangleInverted",
|
||||
"textChevron",
|
||||
"textChevronInverted",
|
||||
"textRingInside",
|
||||
"textRingOutside",
|
||||
"textArchUp",
|
||||
"textArchDown",
|
||||
"textCircle",
|
||||
"textButton",
|
||||
"textArchUpPour",
|
||||
"textArchDownPour",
|
||||
"textCirclePour",
|
||||
"textButtonPour",
|
||||
"textCurveUp",
|
||||
"textCurveDown",
|
||||
"textCanUp",
|
||||
"textCanDown",
|
||||
"textWave1",
|
||||
"textWave2",
|
||||
"textDoubleWave1",
|
||||
"textWave4",
|
||||
"textInflate",
|
||||
"textDeflate",
|
||||
"textInflateBottom",
|
||||
"textDeflateBottom",
|
||||
"textInflateTop",
|
||||
"textDeflateTop",
|
||||
"textDeflateInflate",
|
||||
"textDeflateInflateDeflate",
|
||||
"textFadeRight",
|
||||
"textFadeLeft",
|
||||
"textFadeUp",
|
||||
"textFadeDown",
|
||||
"textSlantUp",
|
||||
"textSlantDown",
|
||||
"textCascadeUp",
|
||||
"textCascadeDown",
|
||||
];
|
||||
|
||||
const W: f64 = 420.0;
|
||||
const H: f64 = 200.0;
|
||||
|
||||
fn rect() -> Rect {
|
||||
Rect::new(0.0, 0.0, W, H)
|
||||
}
|
||||
|
||||
/// One line whose ink box is `ink`.
|
||||
fn one_line(ink: Rect) -> Vec<WarpLine> {
|
||||
vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }]
|
||||
}
|
||||
|
||||
/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates.
|
||||
fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) {
|
||||
let w = warp(name, adj, rect()).unwrap();
|
||||
let ink = Rect::new(100.0, 50.0, 300.0, 90.0);
|
||||
let lines = one_line(ink);
|
||||
let m = w.mapper(&lines, rect().inset(-5.0));
|
||||
let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height()));
|
||||
(p.x / W, p.y / H)
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
|
||||
(a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) {
|
||||
for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] {
|
||||
let got = land(name, adj, u, v);
|
||||
assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_builds_finite_guides() {
|
||||
for name in ALL {
|
||||
assert!(is_supported(name), "{name}");
|
||||
let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}"));
|
||||
assert!(!w.bands.is_empty(), "{name}");
|
||||
for b in &w.bands {
|
||||
let l = b.length();
|
||||
assert!(l.is_finite() && l > 10.0, "{name}: band length {l}");
|
||||
}
|
||||
assert_eq!(adjust_count(name), adjust_defaults(name).len());
|
||||
}
|
||||
assert!(warp("textNoShape", &[], rect()).is_none());
|
||||
assert!(warp("bogus", &[], rect()).is_none());
|
||||
assert!(!is_supported("textNoShape"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_presets_put_the_ink_corners_on_their_guides() {
|
||||
corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
// A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in.
|
||||
corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0));
|
||||
corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75));
|
||||
corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25));
|
||||
corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75));
|
||||
corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667));
|
||||
corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0));
|
||||
corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0));
|
||||
corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
|
||||
corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0));
|
||||
corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
|
||||
corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_presets_bend_where_powerpoint_bends_them() {
|
||||
corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125));
|
||||
assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004));
|
||||
corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6));
|
||||
assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004));
|
||||
assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004));
|
||||
assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004));
|
||||
assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004));
|
||||
// Cans: half ellipses.
|
||||
corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0));
|
||||
assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004));
|
||||
assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004));
|
||||
// Curves and waves.
|
||||
let a = 0.45977;
|
||||
corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
|
||||
assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
|
||||
corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875));
|
||||
// Wave 1 rises first: its top is near the box top a quarter of the way along.
|
||||
assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02);
|
||||
assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23);
|
||||
// A horizontal shift moves the bottom guide right.
|
||||
corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875));
|
||||
assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02);
|
||||
assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_presets_follow_ellipses() {
|
||||
// Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2.
|
||||
corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5));
|
||||
assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004));
|
||||
assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004));
|
||||
assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004));
|
||||
assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
// Circle pour: once round from the left; the middle of the text is at the right.
|
||||
assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004));
|
||||
// Rings: the text goes round two stacked ellipses.
|
||||
assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004));
|
||||
assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004));
|
||||
assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01));
|
||||
assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_presets_keep_text_size_and_stack_lines_outwards() {
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
// A 40-wide centred line whose reference height is 80.
|
||||
let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 };
|
||||
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
|
||||
let m = w.mapper(&[line], inner);
|
||||
// The middle of the reference line lands on the top of the ellipse; 10 pt above it lands
|
||||
// 10 pt above the top.
|
||||
let p = m.map(0, Point::new(210.0, 80.0));
|
||||
assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}");
|
||||
let q = m.map(0, Point::new(210.0, 70.0));
|
||||
assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}");
|
||||
// Natural size: 40 pt of text spans about 40 pt of arc.
|
||||
let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0)));
|
||||
assert!(((b - a).hypot() - 40.0).abs() < 0.5);
|
||||
// Left-aligned text starts at the very start of the arc: insets don't shorten it.
|
||||
let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 };
|
||||
let m = w.mapper(&[left], inner);
|
||||
let p = m.map(0, Point::new(7.2, 80.0));
|
||||
assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}");
|
||||
// Two lines on an arch up: the last sits on the arc, the first stacks outside it.
|
||||
let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 };
|
||||
let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 };
|
||||
let m = w.mapper(&[l1, l2], inner);
|
||||
assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05);
|
||||
assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05);
|
||||
// Arch down: the first line sits on the bottom of the ellipse.
|
||||
let w = warp("textArchDown", &[], rect()).unwrap();
|
||||
let m = w.mapper(&[l1, l2], inner);
|
||||
assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05);
|
||||
assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05);
|
||||
// Text longer than the arc is shrunk to fit it.
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 };
|
||||
let m = w.mapper(&[long], inner);
|
||||
let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0)));
|
||||
assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_share_bands_in_order() {
|
||||
assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::<Vec<_>>(), [0, 1]);
|
||||
assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::<Vec<_>>(), [0, 0, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::<Vec<_>>(), [0, 0, 0, 1, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::<Vec<_>>(), [0, 0, 1, 1, 2]);
|
||||
assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::<Vec<_>>(), [0, 0, 1, 2]);
|
||||
assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::<Vec<_>>(), [0, 1]);
|
||||
assert_eq!(band_of_line(0, 1, 3), 0);
|
||||
assert_eq!(band_of_line(7, 9, 1), 0);
|
||||
assert_eq!(band_of_line(0, 0, 3), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_inflate_maps_each_band_separately() {
|
||||
let w = warp("textDeflateInflate", &[], rect()).unwrap();
|
||||
assert_eq!(w.bands.len(), 2);
|
||||
let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 };
|
||||
let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 };
|
||||
let m = w.mapper(&[l1, l2], rect());
|
||||
// Band 1 ends at 45 % height on the sides, band 2 starts at 55 %.
|
||||
assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004);
|
||||
assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004);
|
||||
assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004);
|
||||
// A band with fewer lines keeps them at the size of the fuller band, centred in its band:
|
||||
// two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three.
|
||||
let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 };
|
||||
let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 };
|
||||
let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 };
|
||||
let m = w.mapper(&[a, b, c], rect());
|
||||
let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H);
|
||||
// Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it.
|
||||
assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}");
|
||||
// A shorter first line keeps its share of the width.
|
||||
let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 };
|
||||
let m = w.mapper(&[short, l2], rect());
|
||||
let p = m.map(0, Point::new(150.0, 10.0));
|
||||
assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_extrapolate_and_wrap() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((10.0, 0.0));
|
||||
let g = Guide::from_path(&p, false);
|
||||
assert!((g.length() - 10.0).abs() < 1e-9);
|
||||
assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9);
|
||||
assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9);
|
||||
assert!(g.at(f64::NAN).0.x.is_finite());
|
||||
let w = warp("textCircle", &[], rect()).unwrap();
|
||||
let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() };
|
||||
assert!(path.is_closed());
|
||||
let l = path.length();
|
||||
let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0);
|
||||
assert!((a - b).hypot() < 1e-6);
|
||||
let empty = Guide::default();
|
||||
assert_eq!(empty.length(), 0.0);
|
||||
assert!(empty.at(3.0).0.x.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_adjusts_and_boxes_stay_finite() {
|
||||
let adjs: &[&[f64]] =
|
||||
&[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]];
|
||||
let rects = [
|
||||
Rect::new(0.0, 0.0, 0.0, 0.0),
|
||||
Rect::new(10.0, 10.0, 10.0, 200.0),
|
||||
Rect::new(0.0, 0.0, 300.0, 0.0),
|
||||
Rect::new(100.0, 100.0, -50.0, -20.0),
|
||||
Rect::new(0.0, 0.0, 1e7, 1e7),
|
||||
];
|
||||
let lines = [
|
||||
WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 },
|
||||
WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 },
|
||||
WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 },
|
||||
];
|
||||
for name in ALL {
|
||||
for adj in adjs {
|
||||
for r in rects {
|
||||
let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") };
|
||||
for b in &w.bands {
|
||||
assert!(b.length().is_finite());
|
||||
}
|
||||
let m = w.mapper(&lines, r);
|
||||
assert!(m.zoom().is_finite() && m.zoom() >= 1.0);
|
||||
let probes = [
|
||||
(0, Point::ZERO),
|
||||
(1, Point::new(5.0, 3.0)),
|
||||
(2, Point::new(1e6, -5.0)),
|
||||
(2, Point::new(f64::NAN, 1.0)),
|
||||
(9, Point::new(1.0, 1.0)),
|
||||
];
|
||||
for (i, p) in probes {
|
||||
let q = m.map(i, p);
|
||||
assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
|
||||
assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "deckcraft-metafile"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so
|
||||
//! a truncated or lying size field stops the parse instead of panicking.
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]);
|
||||
|
||||
impl<'a> Bytes<'a> {
|
||||
pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> {
|
||||
self.0.get(off..off.checked_add(len)?)
|
||||
}
|
||||
|
||||
pub fn tail(&self, off: usize) -> Option<Bytes<'a>> {
|
||||
self.0.get(off..).map(Bytes)
|
||||
}
|
||||
|
||||
fn arr<const N: usize>(&self, off: usize) -> Option<[u8; N]> {
|
||||
self.slice(off, N)?.try_into().ok()
|
||||
}
|
||||
|
||||
pub fn u16(&self, off: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn i16(&self, off: usize) -> Option<i16> {
|
||||
Some(i16::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn u32(&self, off: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn i32(&self, off: usize) -> Option<i32> {
|
||||
Some(i32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn f32(&self, off: usize) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
/// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`.
|
||||
pub fn points(&self, off: usize, n: usize, wide: bool) -> Option<Vec<(f64, f64)>> {
|
||||
let sz = if wide { 8 } else { 4 };
|
||||
let raw = self.slice(off, n.checked_mul(sz)?)?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for c in raw.chunks_exact(sz) {
|
||||
let p = if wide {
|
||||
let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?);
|
||||
let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?);
|
||||
(f64::from(x), f64::from(y))
|
||||
} else {
|
||||
let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?);
|
||||
let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?);
|
||||
(f64::from(x), f64::from(y))
|
||||
};
|
||||
out.push(p);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world
|
||||
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
|
||||
//! Output is device-space items; `lib.rs` maps them into the picture frame.
|
||||
|
||||
use 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;
|
||||
const MM_ISOTROPIC: u32 = 7;
|
||||
const MM_ANISOTROPIC: u32 = 8;
|
||||
const STOCK: u32 = 0x8000_0000;
|
||||
pub(crate) const PS_NULL: u32 = 5;
|
||||
/// Pen style bits; the rest of the style word holds end-cap, join and type flags.
|
||||
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
|
||||
const BS_SOLID: u32 = 0;
|
||||
pub(crate) const BS_NULL: u32 = 1;
|
||||
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 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;
|
||||
/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size.
|
||||
const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4;
|
||||
|
||||
/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`.
|
||||
pub(crate) type Xform = [f64; 6];
|
||||
const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`.
|
||||
pub(crate) fn mul(a: Xform, b: Xform) -> Xform {
|
||||
[
|
||||
a[0] * b[0] + a[1] * b[2],
|
||||
a[0] * b[1] + a[1] * b[3],
|
||||
a[2] * b[0] + a[3] * b[2],
|
||||
a[2] * b[1] + a[3] * b[3],
|
||||
a[4] * b[0] + a[5] * b[2] + b[4],
|
||||
a[4] * b[1] + a[5] * b[3] + b[5],
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct Pen {
|
||||
pub style: u32,
|
||||
/// Logical width; 0 means a cosmetic one-pixel hairline.
|
||||
pub width: f64,
|
||||
pub color: [u8; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, 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>>,
|
||||
}
|
||||
|
||||
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),
|
||||
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]
|
||||
}
|
||||
|
||||
/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS).
|
||||
fn metric_mm(mode: u32) -> Option<f64> {
|
||||
match mode {
|
||||
2 => Some(0.1),
|
||||
3 => Some(0.01),
|
||||
4 => Some(25.4 / 100.0),
|
||||
5 => Some(25.4 / 1000.0),
|
||||
6 => Some(25.4 / 1440.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Mapping {
|
||||
wo: P,
|
||||
we: P,
|
||||
vo: P,
|
||||
ve: P,
|
||||
mode: u32,
|
||||
world: Xform,
|
||||
/// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters).
|
||||
px_mm: P,
|
||||
}
|
||||
|
||||
impl Mapping {
|
||||
fn identity() -> Self {
|
||||
Mapping {
|
||||
wo: (0.0, 0.0),
|
||||
we: (1.0, 1.0),
|
||||
vo: (0.0, 0.0),
|
||||
ve: (1.0, 1.0),
|
||||
mode: MM_TEXT,
|
||||
world: IDENTITY,
|
||||
px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical point to device point: world transform, then window to viewport.
|
||||
pub fn device(&self, p: P) -> P {
|
||||
let w = self.world;
|
||||
let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]);
|
||||
let (sx, sy) = self.ratios();
|
||||
((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1)
|
||||
}
|
||||
|
||||
/// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed
|
||||
/// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's
|
||||
/// sign kept); anisotropic and unknown modes use the extents as given.
|
||||
fn ratios(&self) -> (f64, f64) {
|
||||
if self.mode == MM_TEXT {
|
||||
return (1.0, 1.0);
|
||||
}
|
||||
if let Some(mm) = metric_mm(self.mode) {
|
||||
return (mm * self.px_mm.0, -mm * self.px_mm.1);
|
||||
}
|
||||
let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1));
|
||||
if self.mode == MM_ISOTROPIC {
|
||||
let s = rx.abs().min(ry.abs());
|
||||
return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s });
|
||||
}
|
||||
(rx, ry)
|
||||
}
|
||||
|
||||
/// Approximate device length per logical length, for pen widths.
|
||||
fn scale(&self) -> f64 {
|
||||
let w = self.world;
|
||||
let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt();
|
||||
let (sx, sy) = self.ratios();
|
||||
det * (sx.abs() + sy.abs()) / 2.0
|
||||
}
|
||||
|
||||
/// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes).
|
||||
pub fn uses_extents(&self) -> bool {
|
||||
matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC)
|
||||
}
|
||||
|
||||
pub fn window_ext(&self) -> P {
|
||||
self.we
|
||||
}
|
||||
|
||||
pub fn viewport_ext(&self) -> P {
|
||||
self.ve
|
||||
}
|
||||
|
||||
/// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame
|
||||
/// of a plain WMF.
|
||||
pub fn window_rect(&self) -> [f64; 4] {
|
||||
let a = self.device(self.wo);
|
||||
let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1));
|
||||
[a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(num: f64, den: f64) -> f64 {
|
||||
if den == 0.0 { 1.0 } else { num / den }
|
||||
}
|
||||
|
||||
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
|
||||
pub(crate) enum Raw {
|
||||
Path {
|
||||
ops: Vec<Op>,
|
||||
fill: Option<[u8; 4]>,
|
||||
stroke: Option<([u8; 4], f64)>,
|
||||
even_odd: bool,
|
||||
},
|
||||
/// 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)]
|
||||
struct Saved {
|
||||
map: Mapping,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
rop2: u32,
|
||||
text: TextState,
|
||||
}
|
||||
|
||||
pub(crate) struct Canvas {
|
||||
pub items: Vec<Raw>,
|
||||
pub map: Mapping,
|
||||
/// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use.
|
||||
pub window_set: bool,
|
||||
/// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]).
|
||||
pub viewport_set: bool,
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
objects: Vec<Option<Obj>>,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
/// 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>,
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
pub fn new() -> Self {
|
||||
Canvas {
|
||||
items: Vec::new(),
|
||||
map: Mapping::identity(),
|
||||
window_set: false,
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
even_odd: true,
|
||||
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(),
|
||||
full: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the item cap is hit; the readers stop there and keep what they have.
|
||||
pub fn full(&self) -> bool {
|
||||
self.full
|
||||
}
|
||||
|
||||
fn push(&mut self, raw: Raw) {
|
||||
let cost = match &raw {
|
||||
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
|
||||
// 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;
|
||||
} else {
|
||||
self.used += cost;
|
||||
self.items.push(raw);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_window_org(&mut self, p: P) {
|
||||
self.map.wo = p;
|
||||
}
|
||||
|
||||
pub fn set_window_ext(&mut self, e: P) {
|
||||
self.map.we = e;
|
||||
self.window_set = true;
|
||||
}
|
||||
|
||||
pub fn set_viewport_org(&mut self, p: P) {
|
||||
self.map.vo = p;
|
||||
}
|
||||
|
||||
pub fn set_viewport_ext(&mut self, e: P) {
|
||||
self.map.ve = e;
|
||||
self.viewport_set = true;
|
||||
}
|
||||
|
||||
/// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic,
|
||||
/// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's
|
||||
/// own window records then replace the window; the viewport stays unless the file sets one.
|
||||
/// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport
|
||||
/// origin stay the file's.
|
||||
pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) {
|
||||
if let Some([x, y, w, h]) = window {
|
||||
self.map.mode = MM_ANISOTROPIC;
|
||||
self.map.wo = (x, y);
|
||||
self.map.we = (w, h);
|
||||
self.map.vo = (0.0, 0.0);
|
||||
}
|
||||
self.map.ve = size;
|
||||
}
|
||||
|
||||
pub fn set_map_mode(&mut self, mode: u32) {
|
||||
self.map.mode = mode;
|
||||
}
|
||||
|
||||
/// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive.
|
||||
pub fn set_device_mm(&mut self, px_mm: P) {
|
||||
if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 {
|
||||
self.map.px_mm = px_mm;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_polyfill(&mut self, mode: u32) {
|
||||
self.even_odd = mode != WINDING;
|
||||
}
|
||||
|
||||
/// SETROP2. 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.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).
|
||||
/// An unknown mode leaves the world transform as it was.
|
||||
pub fn modify_world(&mut self, x: Xform, mode: u32) {
|
||||
match mode {
|
||||
1 => self.map.world = IDENTITY,
|
||||
2 => self.map.world = mul(x, self.map.world),
|
||||
3 => self.map.world = mul(self.map.world, x),
|
||||
4 => self.map.world = x,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores
|
||||
/// could no longer match the file.
|
||||
pub fn save_dc(&mut self) {
|
||||
if self.saved.len() < MAX_STACK {
|
||||
self.saved.push(Saved {
|
||||
map: self.map,
|
||||
pen: self.pen,
|
||||
brush: self.brush.clone(),
|
||||
even_odd: self.even_odd,
|
||||
rop2: self.rop2,
|
||||
text: self.text.clone(),
|
||||
});
|
||||
} else {
|
||||
self.full = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`.
|
||||
pub fn restore_dc(&mut self, n: i32) {
|
||||
let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) };
|
||||
let Some(idx) = idx else { return };
|
||||
let Some(s) = self.saved.get(idx).cloned() else { return };
|
||||
self.saved.truncate(idx);
|
||||
self.map = s.map;
|
||||
self.pen = s.pen;
|
||||
self.brush = s.brush;
|
||||
self.even_odd = s.even_odd;
|
||||
self.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 {
|
||||
self.objects.resize(i + 1, None);
|
||||
}
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => match self.objects.iter().position(Option::is_none) {
|
||||
Some(i) => {
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
}
|
||||
None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)),
|
||||
None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, i: u32) {
|
||||
if let Some(s) = self.objects.get_mut(i as usize) {
|
||||
*s = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects an object by index; indices with the high bit set are stock objects.
|
||||
pub fn select(&mut self, i: u32) {
|
||||
if i & STOCK != 0 {
|
||||
return self.select_stock(i & !STOCK);
|
||||
}
|
||||
match self.objects.get(i as usize).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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_stock(&mut self, i: u32) {
|
||||
let white = [255, 255, 255, 255];
|
||||
let black = [0, 0, 0, 255];
|
||||
let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]);
|
||||
match i {
|
||||
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::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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_op(&self, op: Op) -> Op {
|
||||
match op {
|
||||
Op::M(p) => Op::M(self.map.device(p)),
|
||||
Op::L(p) => Op::L(self.map.device(p)),
|
||||
Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)),
|
||||
Op::Z => Op::Z,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
if (self.pen.style & PS_STYLE_MASK) == PS_NULL {
|
||||
return None;
|
||||
}
|
||||
// A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline.
|
||||
let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() };
|
||||
Some((self.pen.color, w))
|
||||
}
|
||||
|
||||
fn push_path_ops(&mut self, ops: impl IntoIterator<Item = Op>) {
|
||||
for op in ops {
|
||||
if self.path.len() >= MAX_PATH_OPS {
|
||||
return;
|
||||
}
|
||||
self.path.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to
|
||||
/// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH.
|
||||
pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) {
|
||||
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
|
||||
if self.in_path {
|
||||
return self.push_path_ops(dev);
|
||||
}
|
||||
if self.mix_skips(fill) || dev.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
self.push_shape(dev, fill, stroke);
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, p: P) {
|
||||
if self.in_path {
|
||||
self.push_path_ops([Op::M(self.map.device(p))]);
|
||||
}
|
||||
self.pos = p;
|
||||
}
|
||||
|
||||
/// LINETO from the current point, then moves the current point to `p`.
|
||||
pub fn line_to(&mut self, p: P) {
|
||||
self.line_chain(&[p], false, true);
|
||||
}
|
||||
|
||||
/// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point.
|
||||
pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) {
|
||||
// Inside an open bracket the path already ends at the current point, so no MoveTo is needed.
|
||||
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
|
||||
ops.extend(pts.iter().map(|p| Op::L(*p)));
|
||||
if let Some(last) = pts.last() {
|
||||
self.pos = *last;
|
||||
}
|
||||
self.shape(&ops, fill, stroke);
|
||||
}
|
||||
|
||||
/// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged.
|
||||
pub fn bezier(&mut self, from: P, pts: &[P]) {
|
||||
let mut ops = vec![Op::M(from)];
|
||||
ops.extend(cubics(pts));
|
||||
self.shape(&ops, false, true);
|
||||
}
|
||||
|
||||
/// POLYBEZIERTO: cubic segments from the current point through groups of three control or end
|
||||
/// points; the current point moves to the last point. Inside an open bracket the path already
|
||||
/// ends at the current point, so no MoveTo is needed.
|
||||
pub fn bezier_to(&mut self, pts: &[P]) {
|
||||
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
|
||||
ops.extend(cubics(pts));
|
||||
if let Some(last) = pts.last() {
|
||||
self.pos = *last;
|
||||
}
|
||||
self.shape(&ops, false, true);
|
||||
}
|
||||
|
||||
pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) {
|
||||
let Some(first) = pts.first() else { return };
|
||||
let mut ops = vec![Op::M(*first)];
|
||||
ops.extend(pts.iter().skip(1).map(|p| Op::L(*p)));
|
||||
if close {
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
self.shape(&ops, fill, stroke);
|
||||
}
|
||||
|
||||
pub fn begin_path(&mut self) {
|
||||
self.in_path = true;
|
||||
self.path.clear();
|
||||
}
|
||||
|
||||
pub fn end_path(&mut self) {
|
||||
self.in_path = false;
|
||||
}
|
||||
|
||||
pub fn close_figure(&mut self) {
|
||||
if self.in_path {
|
||||
self.push_path_ops([Op::Z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
|
||||
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
|
||||
let ops = std::mem::take(&mut self.path);
|
||||
if self.mix_skips(fill) || ops.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
self.push_shape(ops, fill, stroke);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) {
|
||||
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
|
||||
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true });
|
||||
}
|
||||
|
||||
/// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS).
|
||||
pub fn sourceless(rop: u32) -> bool {
|
||||
matches!(rop, PATCOPY | BLACKNESS | WHITENESS)
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the
|
||||
/// brush (when solid); the others fill black or white. Other operations are not handled here.
|
||||
pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) {
|
||||
let rect = [x, y, x + w, y + h];
|
||||
match rop {
|
||||
PATCOPY => self.fill_brush_rect(rect),
|
||||
BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]),
|
||||
WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let [x, y, w, h] = rect;
|
||||
let a = self.map.device((x, y));
|
||||
let b = self.map.device((x + w, y + h));
|
||||
crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1);
|
||||
let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
|
||||
self.push(Raw::Bitmap { rect: dev, img });
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut ops = Vec::with_capacity(pts.len() / 3);
|
||||
let mut it = pts.iter().copied();
|
||||
while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) {
|
||||
ops.push(Op::C(a, b, e));
|
||||
}
|
||||
ops
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
|
||||
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
|
||||
|
||||
use crate::{MAX_PIXELS, bytes::Bytes};
|
||||
|
||||
const BI_RGB: u32 = 0;
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
/// Most palette entries a DIB can carry (the colour table never exceeds 2^8).
|
||||
const MAX_PALETTE: usize = 256;
|
||||
const ROP_SRCAND: u32 = 0x0088_00C6;
|
||||
const ROP_SRCPAINT: u32 = 0x00EE_0086;
|
||||
const ROP_SRCINVERT: u32 = 0x0066_0046;
|
||||
|
||||
/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use.
|
||||
pub(crate) struct Rgba {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub bottom_up: bool,
|
||||
pub px: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How a decoded bitmap combines with the destination.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Blend {
|
||||
/// A raster operation (BITBLT, STRETCHDIB and friends).
|
||||
Rop(u32),
|
||||
/// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set
|
||||
/// (AC_SRC_ALPHA: the source colours are premultiplied by it).
|
||||
Alpha { constant: u8, per_pixel: bool },
|
||||
}
|
||||
|
||||
impl Blend {
|
||||
/// Whether the decoder must keep the 32-bit alpha byte of each pixel.
|
||||
pub fn per_pixel(self) -> bool {
|
||||
matches!(self, Blend::Alpha { per_pixel: true, .. })
|
||||
}
|
||||
}
|
||||
|
||||
struct Header {
|
||||
w: usize,
|
||||
h: usize,
|
||||
top_down: bool,
|
||||
bpp: usize,
|
||||
masks: [u32; 3],
|
||||
pal_off: usize,
|
||||
pal_n: usize,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
fn read(info: Bytes) -> Option<Header> {
|
||||
let size = info.u32(0)? as usize;
|
||||
let w = info.i32(4)?;
|
||||
let h = info.i32(8)?;
|
||||
let bpp = info.u16(14)? as usize;
|
||||
let comp = info.u32(16)?;
|
||||
let clr_used = info.u32(32)? as usize;
|
||||
if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) {
|
||||
return None;
|
||||
}
|
||||
let masks = if comp == BI_BITFIELDS {
|
||||
[info.u32(40)?, info.u32(44)?, info.u32(48)?]
|
||||
} else if bpp == 16 {
|
||||
[0x7C00, 0x03E0, 0x001F]
|
||||
} else {
|
||||
[0xFF_0000, 0xFF00, 0xFF]
|
||||
};
|
||||
let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 };
|
||||
let pal_off = size.checked_add(masks_len)?;
|
||||
// Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the
|
||||
// entries clrUsed names (WMF DIBs can have them), still capped.
|
||||
let pal_n = match (bpp <= 8, clr_used) {
|
||||
(true, 0) => 1usize << bpp,
|
||||
(true, n) => n.min(1usize << bpp),
|
||||
(false, n) => n.min(MAX_PALETTE),
|
||||
};
|
||||
Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n })
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it.
|
||||
pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
|
||||
let h = Header::read(dib)?;
|
||||
h.pal_off.checked_add(h.pal_n.checked_mul(4)?)
|
||||
}
|
||||
|
||||
/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The
|
||||
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
|
||||
/// are opaque.
|
||||
pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option<Rgba> {
|
||||
let hd = Header::read(info)?;
|
||||
let pixels = hd.w.checked_mul(hd.h)?;
|
||||
if pixels == 0 || pixels > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
|
||||
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
|
||||
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
|
||||
let mut px = vec![0u8; pixels * 4];
|
||||
for y in 0..hd.h {
|
||||
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
|
||||
let row = src_row * stride;
|
||||
for x in 0..hd.w {
|
||||
let p = pixel(data, row, x, &hd, pal, keep_alpha)?;
|
||||
let o = (y * hd.w + x) * 4;
|
||||
px.get_mut(o..o + 4)?.copy_from_slice(&p);
|
||||
}
|
||||
}
|
||||
Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px })
|
||||
}
|
||||
|
||||
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
|
||||
let [m_r, m_g, m_b] = hd.masks;
|
||||
let [r, g, b] = match hd.bpp {
|
||||
1 => {
|
||||
let b = *data.get(row + x / 8)?;
|
||||
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
|
||||
}
|
||||
4 => {
|
||||
let b = *data.get(row + x / 2)?;
|
||||
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
|
||||
}
|
||||
8 => palette(pal, usize::from(*data.get(row + x)?)),
|
||||
24 => {
|
||||
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
|
||||
[r, g, b]
|
||||
}
|
||||
16 => {
|
||||
let o = row + x * 2;
|
||||
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
|
||||
[field(v, m_r), field(v, m_g), field(v, m_b)]
|
||||
}
|
||||
_ => {
|
||||
let o = row + x * 4;
|
||||
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
|
||||
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
|
||||
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
|
||||
}
|
||||
};
|
||||
Some([r, g, b, 255])
|
||||
}
|
||||
|
||||
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
|
||||
fn palette(pal: &[u8], i: usize) -> [u8; 3] {
|
||||
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
|
||||
}
|
||||
|
||||
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
|
||||
fn field(v: u32, mask: u32) -> u8 {
|
||||
if mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = u64::from(mask >> shift);
|
||||
let val = u64::from((v & mask) >> shift);
|
||||
((val * 255 + max / 2) / max) as u8
|
||||
}
|
||||
|
||||
/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down)
|
||||
/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted
|
||||
/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the
|
||||
/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part,
|
||||
/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors).
|
||||
pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
|
||||
let (iw, ih) = (img.w as i64, img.h as i64);
|
||||
let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] };
|
||||
// The request's top row in top-down order.
|
||||
let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy };
|
||||
let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw));
|
||||
let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih));
|
||||
if x1 <= x0 || r1 <= r0 {
|
||||
return None;
|
||||
}
|
||||
let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64);
|
||||
let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky];
|
||||
let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize);
|
||||
let (ow, oh) = (x1 - x0, r1 - r0);
|
||||
let mut px = Vec::with_capacity(ow * oh * 4);
|
||||
for row in r0..r1 {
|
||||
let s = (row * img.w + x0) * 4;
|
||||
px.extend_from_slice(img.px.get(s..s + ow * 4)?);
|
||||
}
|
||||
Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out))
|
||||
}
|
||||
|
||||
/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so
|
||||
/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves
|
||||
/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other
|
||||
/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a
|
||||
/// per-pixel (premultiplied) source is made straight.
|
||||
pub(crate) fn prepare(img: &mut Rgba, blend: Blend) {
|
||||
match blend {
|
||||
Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]),
|
||||
Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]),
|
||||
Blend::Rop(_) => {}
|
||||
Blend::Alpha { constant, per_pixel } => {
|
||||
for p in img.px.as_chunks_mut::<4>().0 {
|
||||
if per_pixel {
|
||||
unpremultiply(p);
|
||||
}
|
||||
p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination.
|
||||
fn clear_where(img: &mut Rgba, rgb: [u8; 3]) {
|
||||
for p in img.px.as_chunks_mut::<4>().0 {
|
||||
if p[..3] == rgb {
|
||||
p[3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the
|
||||
/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes.
|
||||
fn unpremultiply(p: &mut [u8; 4]) {
|
||||
let a = u32::from(p[3]);
|
||||
if a == 0 || a == 255 {
|
||||
return;
|
||||
}
|
||||
for c in &mut p[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a
|
||||
/// short buffer flips only the rows it has.
|
||||
pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) {
|
||||
let (w, h) = (img.w, img.h);
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let row = w * 4;
|
||||
if horizontal {
|
||||
for line in img.px.chunks_exact_mut(row) {
|
||||
for x in 0..w / 2 {
|
||||
for c in 0..4 {
|
||||
line.swap(x * 4 + c, (w - 1 - x) * 4 + c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if vertical {
|
||||
// Rows the buffer actually holds: a short buffer flips only those.
|
||||
let n = h.min(img.px.len() / row);
|
||||
for y in 0..n / 2 {
|
||||
swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range.
|
||||
fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) {
|
||||
let Some((head, tail)) = px.split_at_mut_checked(b) else { return };
|
||||
if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) {
|
||||
top.swap_with_slice(bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! 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_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;
|
||||
/// SRCCOPY, the plain copy of a bitmap.
|
||||
const SRCCOPY: u32 = 0x00CC_0020;
|
||||
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
|
||||
const PS_TYPE_MASK: u32 = 0x000F_0000;
|
||||
const PS_COSMETIC: u32 = 0;
|
||||
|
||||
pub(crate) fn is_emf(b: Bytes) -> bool {
|
||||
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
|
||||
}
|
||||
|
||||
/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and
|
||||
/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point).
|
||||
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
if !is_emf(input) {
|
||||
return Err(Error::BadHeader);
|
||||
}
|
||||
let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize;
|
||||
let bounds = rect32(input, 8).ok_or(Error::BadHeader)?;
|
||||
let [l, t, r, b] = bounds;
|
||||
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
|
||||
let mut cv = Canvas::new();
|
||||
if let Some(px_mm) = device_px_per_mm(input) {
|
||||
cv.set_device_mm(px_mm);
|
||||
}
|
||||
let mut parsed = 0usize;
|
||||
let mut pos = hsize.max(88);
|
||||
while !cv.full() {
|
||||
let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break };
|
||||
let size = size as usize;
|
||||
if size < 8 || !size.is_multiple_of(4) {
|
||||
break;
|
||||
}
|
||||
let Some(rec) = input.slice(pos, size) else { break };
|
||||
if typ == 14 {
|
||||
break;
|
||||
}
|
||||
record(&mut cv, typ, Bytes(rec));
|
||||
parsed += 1;
|
||||
let Some(next) = pos.checked_add(size) else { break };
|
||||
pos = next;
|
||||
}
|
||||
if parsed == 0 {
|
||||
return Err(Error::NoRecords);
|
||||
}
|
||||
Ok((cv, frame))
|
||||
}
|
||||
|
||||
/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80).
|
||||
fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
|
||||
let (dw, dh) = (input.i32(72)?, input.i32(76)?);
|
||||
let (mw, mh) = (input.i32(80)?, input.i32(84)?);
|
||||
(dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh)))
|
||||
}
|
||||
|
||||
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
|
||||
fn frame_from_header(input: Bytes) -> Option<Frame> {
|
||||
let [fl, ft, fr, fb] = rect32(input, 24)?;
|
||||
if fr <= fl || fb <= ft {
|
||||
return None;
|
||||
}
|
||||
// Device units per hundredth of a millimetre.
|
||||
let (px_mm_x, px_mm_y) = device_px_per_mm(input)?;
|
||||
let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0);
|
||||
let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy];
|
||||
Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) })
|
||||
}
|
||||
|
||||
fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
|
||||
if cv.full() {
|
||||
return;
|
||||
}
|
||||
let _ = match typ {
|
||||
9 => xy32(r, 8).map(|p| cv.set_window_ext(p)),
|
||||
10 => xy32(r, 8).map(|p| cv.set_window_org(p)),
|
||||
11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)),
|
||||
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
|
||||
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
|
||||
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
|
||||
20 => r.u32(8).map(|m| cv.set_rop2(m)),
|
||||
33 => {
|
||||
cv.save_dc();
|
||||
Some(())
|
||||
}
|
||||
34 => r.i32(8).map(|n| cv.restore_dc(n)),
|
||||
35 => xform(r, 8).map(|x| cv.modify_world(x, 4)),
|
||||
36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)),
|
||||
37 => r.u32(8).map(|i| cv.select(i)),
|
||||
38 => create_pen(cv, r),
|
||||
95 => create_ext_pen(cv, r),
|
||||
39 => create_brush(cv, r),
|
||||
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)),
|
||||
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
|
||||
43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
|
||||
44 => {
|
||||
let (cw, ch) = (r.i32(24), r.i32(28));
|
||||
let (Some(cw), Some(ch)) = (cw, ch) else { return };
|
||||
rect32(r, 8).map(|[l, t, rr, b]| {
|
||||
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true);
|
||||
})
|
||||
}
|
||||
45..=47 => arc32(r).map(|(bounds, s, e)| {
|
||||
let kind = match typ {
|
||||
45 => ArcKind::Arc,
|
||||
46 => ArcKind::Chord,
|
||||
_ => ArcKind::Pie,
|
||||
};
|
||||
let [l, t, rr, b] = bounds;
|
||||
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
|
||||
}),
|
||||
4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)),
|
||||
3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)),
|
||||
6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)),
|
||||
// POLYBEZIER starts its own figure at the first point and leaves the current point alone;
|
||||
// POLYBEZIERTO continues from the current point.
|
||||
2 | 85 => counted(r, typ == 2).and_then(|pts| {
|
||||
let from = *pts.first()?;
|
||||
cv.bezier(from, pts.get(1..)?);
|
||||
Some(())
|
||||
}),
|
||||
5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)),
|
||||
7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)),
|
||||
8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)),
|
||||
59 => {
|
||||
cv.begin_path();
|
||||
Some(())
|
||||
}
|
||||
60 => {
|
||||
cv.end_path();
|
||||
Some(())
|
||||
}
|
||||
61 => {
|
||||
cv.close_figure();
|
||||
Some(())
|
||||
}
|
||||
62 => {
|
||||
cv.paint_path(true, false);
|
||||
Some(())
|
||||
}
|
||||
63 => {
|
||||
cv.paint_path(true, true);
|
||||
Some(())
|
||||
}
|
||||
64 => {
|
||||
cv.paint_path(false, true);
|
||||
Some(())
|
||||
}
|
||||
76 | 77 => bitblt(cv, r, typ == 77),
|
||||
80 => set_dibits(cv, r),
|
||||
81 => stretch_dibits(cv, r),
|
||||
114 => alphablend(cv, r),
|
||||
_ => Some(()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Two little-endian i32 values at `off`, as a point.
|
||||
fn xy32(r: Bytes, off: usize) -> Option<P> {
|
||||
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
|
||||
}
|
||||
|
||||
/// RECTL (left, top, right, bottom) as i32 values widened to f64.
|
||||
fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> {
|
||||
Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)])
|
||||
}
|
||||
|
||||
/// An i32 field as f64; 0 past the end.
|
||||
fn i32s(r: Bytes, off: usize) -> f64 {
|
||||
r.i32(off).map_or(0.0, f64::from)
|
||||
}
|
||||
|
||||
/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order.
|
||||
fn xform(r: Bytes, off: usize) -> Option<Xform> {
|
||||
let mut x = [0.0f64; 6];
|
||||
for (i, v) in x.iter_mut().enumerate() {
|
||||
*v = f64::from(r.f32(off + i * 4)?);
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32.
|
||||
fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> {
|
||||
Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?))
|
||||
}
|
||||
|
||||
/// A point count from a record, rejected when it exceeds the path budget (before any allocation).
|
||||
fn budget(n: u32) -> Option<usize> {
|
||||
let n = n as usize;
|
||||
(n <= MAX_PATH_OPS).then_some(n)
|
||||
}
|
||||
|
||||
/// Point count at 24, then the points (32-bit or 16-bit) at 28.
|
||||
fn counted(r: Bytes, wide: bool) -> Option<Vec<P>> {
|
||||
let n = budget(r.u32(24)?)?;
|
||||
r.tail(28)?.points(0, n, wide)
|
||||
}
|
||||
|
||||
/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points.
|
||||
fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec<usize>, Vec<P>)> {
|
||||
let n = budget(r.u32(24)?)?;
|
||||
// The record must hold the n counts before anything is allocated for them.
|
||||
let raw = r.slice(32, n.checked_mul(4)?)?;
|
||||
let counts: Vec<usize> = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect();
|
||||
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
|
||||
let total = budget(u32::try_from(total).ok()?)?;
|
||||
let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?;
|
||||
Some((counts, pts))
|
||||
}
|
||||
|
||||
/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour).
|
||||
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let style = r.u32(12)?;
|
||||
let width = r.i32(16)?;
|
||||
let color = r.u32(24)?;
|
||||
cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour).
|
||||
/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws
|
||||
/// no line at all.
|
||||
fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?);
|
||||
let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) };
|
||||
let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK };
|
||||
cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch).
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let style = r.u32(12)?;
|
||||
let color = r.u32(16)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
|
||||
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
|
||||
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
|
||||
let rop = r.u32(40)?;
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) };
|
||||
let src = [i32s(r, 44), i32s(r, 48), sw, sh];
|
||||
blit(cv, r, dst, src, (84, 92), Blend::Rop(rop))
|
||||
}
|
||||
|
||||
/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to
|
||||
/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha.
|
||||
fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
|
||||
let constant = *r.0.get(42)?;
|
||||
let per_pixel = (*r.0.get(43)? & 0x01) != 0;
|
||||
let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)];
|
||||
blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel })
|
||||
}
|
||||
|
||||
/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68,
|
||||
/// dest w/h at 72.
|
||||
fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)];
|
||||
let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)];
|
||||
let rop = r.u32(68)?;
|
||||
blit(cv, r, dst, src, (48, 56), Blend::Rop(rop))
|
||||
}
|
||||
|
||||
/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi
|
||||
/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line
|
||||
/// `iStartScan`, so the source y counts from there.
|
||||
fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let (cx, cy) = (i32s(r, 40), i32s(r, 44));
|
||||
if cx <= 0.0 || cy <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let dst = [i32s(r, 24), i32s(r, 28), cx, cy];
|
||||
let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy];
|
||||
blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY))
|
||||
}
|
||||
|
||||
/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both
|
||||
/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a
|
||||
/// non-positive source size means the whole bitmap. `blend` decides how it combines.
|
||||
fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> {
|
||||
let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize);
|
||||
let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize);
|
||||
if pl == 0 {
|
||||
return None;
|
||||
}
|
||||
let info = Bytes(r.slice(bo, bl)?);
|
||||
let pixels = Bytes(r.slice(po, pl)?);
|
||||
let mut img = dib::decode(info, pixels, blend.per_pixel())?;
|
||||
dib::prepare(&mut img, blend);
|
||||
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
|
||||
cv.bitmap(dst, img);
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs
|
||||
//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece).
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
pub(crate) type P = (f64, f64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Op {
|
||||
M(P),
|
||||
L(P),
|
||||
C(P, P, P),
|
||||
Z,
|
||||
}
|
||||
|
||||
pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
|
||||
vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z]
|
||||
}
|
||||
|
||||
/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap.
|
||||
pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec<Op> {
|
||||
// Reversed corners (right < left, bottom < top) describe the same rectangle.
|
||||
let (l, r) = (l.min(r), l.max(r));
|
||||
let (t, b) = (t.min(b), t.max(b));
|
||||
let rx = (ew / 2.0).abs().min((r - l) / 2.0);
|
||||
let ry = (eh / 2.0).abs().min((b - t) / 2.0);
|
||||
let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))];
|
||||
ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2));
|
||||
ops.push(Op::L((r, b - ry)));
|
||||
ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2));
|
||||
ops.push(Op::L((l + rx, b)));
|
||||
ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2));
|
||||
ops.push(Op::L((l, t + ry)));
|
||||
ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2));
|
||||
ops.push(Op::Z);
|
||||
ops
|
||||
}
|
||||
|
||||
pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
|
||||
let (cx, cy, rx, ry) = center(l, t, r, b);
|
||||
let mut ops = vec![Op::M((cx + rx, cy))];
|
||||
ops.extend(arc(cx, cy, rx, ry, 0.0, TAU));
|
||||
ops.push(Op::Z);
|
||||
ops
|
||||
}
|
||||
|
||||
/// Which closed or open shape an ARC/CHORD/PIE record draws.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ArcKind {
|
||||
Arc,
|
||||
Chord,
|
||||
Pie,
|
||||
}
|
||||
|
||||
/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point.
|
||||
pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec<Op> {
|
||||
let (cx, cy, rx, ry) = center(l, t, r, b);
|
||||
let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE));
|
||||
let (a0, a1) = (ang(s), ang(e));
|
||||
let mut d = (a0 - a1).rem_euclid(TAU);
|
||||
if d < 1e-12 {
|
||||
d = TAU;
|
||||
}
|
||||
let sweep = -d;
|
||||
let start = (cx + rx * a0.cos(), cy + ry * a0.sin());
|
||||
let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] };
|
||||
ops.extend(arc(cx, cy, rx, ry, a0, sweep));
|
||||
match kind {
|
||||
ArcKind::Arc => {}
|
||||
ArcKind::Chord => ops.push(Op::Z),
|
||||
ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]),
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) {
|
||||
((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0)
|
||||
}
|
||||
|
||||
/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point
|
||||
/// must already be at `(cx + rx cos a0, cy + ry sin a0)`.
|
||||
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec<Op> {
|
||||
let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8);
|
||||
let step = sweep / n as f64;
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin());
|
||||
let mut ops = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t0 = a0 + step * i as f64;
|
||||
let t1 = t0 + step;
|
||||
let (p0, p3) = (pt(t0), pt(t1));
|
||||
let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos());
|
||||
let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos());
|
||||
ops.push(Op::C(c1, c2, p3));
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the
|
||||
/// point list are cut short rather than failing the whole record.
|
||||
pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
|
||||
let mut ops = Vec::with_capacity(pts.len() + counts.len());
|
||||
let mut at = 0usize;
|
||||
for &c in counts {
|
||||
let end = at.saturating_add(c).min(pts.len());
|
||||
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
|
||||
at = end;
|
||||
continue;
|
||||
};
|
||||
ops.push(Op::M(*first));
|
||||
ops.extend(rest.iter().map(|p| Op::L(*p)));
|
||||
if close {
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
ops
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! 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, 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`],
|
||||
//! never a panic.
|
||||
//!
|
||||
//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bytes;
|
||||
mod canvas;
|
||||
mod dib;
|
||||
mod emf;
|
||||
mod geom;
|
||||
mod place;
|
||||
mod 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;
|
||||
/// Object table slots (WMF handles, EMF object indices).
|
||||
pub(crate) const MAX_OBJECTS: usize = 4096;
|
||||
/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA).
|
||||
pub(crate) const MAX_PIXELS: usize = 40_000_000;
|
||||
/// DC save-stack depth.
|
||||
pub(crate) const MAX_STACK: usize = 1024;
|
||||
/// Points in one open path bracket.
|
||||
pub(crate) const MAX_PATH_OPS: usize = 2_000_000;
|
||||
/// Bytes of path ops and pixels kept from one file (about 256 MB).
|
||||
pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Picture {
|
||||
/// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device
|
||||
/// or logical units otherwise.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Item {
|
||||
/// A filled and/or stroked path, in picture coordinates.
|
||||
Path {
|
||||
segs: Vec<Seg>,
|
||||
fill: Option<[u8; 4]>,
|
||||
/// Colour and width in picture units.
|
||||
stroke: Option<([u8; 4], f32)>,
|
||||
even_odd: bool,
|
||||
},
|
||||
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
|
||||
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
|
||||
/// 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)]
|
||||
pub enum Seg {
|
||||
Move(f32, f32),
|
||||
Line(f32, f32),
|
||||
Cubic(f32, f32, f32, f32, f32, f32),
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("not a WMF or EMF file")]
|
||||
NotMetafile,
|
||||
#[error("metafile header is truncated or invalid")]
|
||||
BadHeader,
|
||||
#[error("metafile has no readable records")]
|
||||
NoRecords,
|
||||
}
|
||||
|
||||
/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its
|
||||
/// size in picture units.
|
||||
pub(crate) struct Frame {
|
||||
pub rect: [f64; 4],
|
||||
pub size: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Parses a WMF (placeable or plain) or EMF file.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
|
||||
let b = Bytes(bytes);
|
||||
let (cv, frame) = if emf::is_emf(b) {
|
||||
emf::parse(b)?
|
||||
} else if wmf::is_wmf(b) {
|
||||
// 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);
|
||||
};
|
||||
Ok(build(cv.items, frame))
|
||||
}
|
||||
|
||||
/// True when the bytes start like a WMF (placeable or plain header) or an EMF.
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
let b = Bytes(bytes);
|
||||
emf::is_emf(b) || wmf::is_wmf(b)
|
||||
}
|
||||
|
||||
/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding
|
||||
/// box of the drawing, so the origin is still the top-left of what was drawn.
|
||||
fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
|
||||
let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0);
|
||||
let (rect, size) = match frame {
|
||||
Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))),
|
||||
None => match bbox(&raw) {
|
||||
Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)),
|
||||
None => return Picture { width: 0.0, height: 0.0, items: Vec::new() },
|
||||
},
|
||||
};
|
||||
let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 };
|
||||
let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 };
|
||||
let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy);
|
||||
let stroke_scale = (sx + sy) / 2.0;
|
||||
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
|
||||
let diag = size.0.hypot(size.1);
|
||||
// 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>>>()?;
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
|
||||
Some(Item::Path { segs, fill, stroke, even_odd })
|
||||
}
|
||||
Raw::Bitmap { rect: r, img } => {
|
||||
let (x, y) = tf((r[0], r[1]));
|
||||
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
|
||||
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
fn f(v: f64) -> Option<f32> {
|
||||
let v = v as f32;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option<Seg> {
|
||||
let pt = |p: P| {
|
||||
let (x, y) = tf(p);
|
||||
Some((f(x)?, f(y)?))
|
||||
};
|
||||
Some(match *op {
|
||||
Op::M(p) => {
|
||||
let (x, y) = pt(p)?;
|
||||
Seg::Move(x, y)
|
||||
}
|
||||
Op::L(p) => {
|
||||
let (x, y) = pt(p)?;
|
||||
Seg::Line(x, y)
|
||||
}
|
||||
Op::C(a, b, c) => {
|
||||
let (x1, y1) = pt(a)?;
|
||||
let (x2, y2) = pt(b)?;
|
||||
let (x3, y3) = pt(c)?;
|
||||
Seg::Cubic(x1, y1, x2, y2, x3, y3)
|
||||
}
|
||||
Op::Z => Seg::Close,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output.
|
||||
fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
let mut grow = |p: P| {
|
||||
if p.0.is_finite() && p.1.is_finite() {
|
||||
b = Some(match b {
|
||||
None => [p.0, p.1, p.0, p.1],
|
||||
Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)],
|
||||
});
|
||||
}
|
||||
};
|
||||
for r in raw {
|
||||
match r {
|
||||
Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => {
|
||||
for op in ops {
|
||||
match *op {
|
||||
Op::M(p) | Op::L(p) => grow(p),
|
||||
Op::C(a, c, d) => {
|
||||
grow(a);
|
||||
grow(c);
|
||||
grow(d);
|
||||
}
|
||||
Op::Z => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Raw::Bitmap { rect, .. } => {
|
||||
grow((rect[0], rect[1]));
|
||||
grow((rect[0] + rect[2], rect[1] + rect[3]));
|
||||
}
|
||||
Raw::Text(t) => grow((t.transform[4], t.transform[5])),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
|
||||
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
|
||||
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
|
||||
use crate::{Item, Picture, Seg};
|
||||
|
||||
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
|
||||
pub const MAX_BITMAP_SIDE: u32 = 30_000;
|
||||
/// Placed coordinates past this (target units) are far off any page; such items are left out.
|
||||
const MAX_COORD: f64 = 1e6;
|
||||
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
|
||||
const MIN_BITMAP_SIDE: f64 = 1e-6;
|
||||
|
||||
/// One item of a placed picture, in target units.
|
||||
pub enum PlacedItem {
|
||||
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
|
||||
/// the renderer draws at its minimum width.
|
||||
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
|
||||
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
|
||||
Bitmap { index: usize, rect: Rect },
|
||||
/// `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 {
|
||||
/// Whether the picture has a finite, positive size, so it can be fitted into a frame.
|
||||
pub fn has_size(&self) -> bool {
|
||||
self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0
|
||||
}
|
||||
|
||||
/// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right,
|
||||
/// bottom) removed first: the visible part fills `r`. None for a degenerate size.
|
||||
pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option<Affine> {
|
||||
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 });
|
||||
let (w, h) = (f64::from(self.width), f64::from(self.height));
|
||||
let sx = r.width() / (w * (1.0 - cl - cr).max(0.05));
|
||||
let sy = r.height() / (h * (1.0 - ct - cb).max(0.05));
|
||||
(sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0)
|
||||
.then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy))
|
||||
}
|
||||
|
||||
/// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6
|
||||
/// target units are left out (they are far off any page), as are bitmaps with no area. Stroke
|
||||
/// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps
|
||||
/// strokes even).
|
||||
pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec<PlacedItem> {
|
||||
let Some(tf) = self.fit(r, crop) else { return Vec::new() };
|
||||
let c = tf.as_coeffs();
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd } => {
|
||||
let path = tf * bez(segs);
|
||||
if in_range(path.bounding_box()) {
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
|
||||
}
|
||||
}
|
||||
Item::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)));
|
||||
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
|
||||
out.push(PlacedItem::Bitmap { index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height,
|
||||
/// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many
|
||||
/// pixels. None for paths and for bitmaps that cannot be drawn.
|
||||
pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let Item::Bitmap { width, height, rgba, .. } = self else { return None };
|
||||
let (w, h, px) = (*width, *height, std::mem::take(rgba));
|
||||
let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h);
|
||||
(sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px))
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
|
||||
fn bez(segs: &[Seg]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
let mut start = (0.0, 0.0);
|
||||
let mut open = false;
|
||||
for s in segs {
|
||||
match *s {
|
||||
Seg::Move(x, y) => {
|
||||
start = (f64::from(x), f64::from(y));
|
||||
p.move_to(start);
|
||||
open = true;
|
||||
}
|
||||
Seg::Line(x, y) => {
|
||||
if !open {
|
||||
p.move_to(start);
|
||||
}
|
||||
p.line_to((f64::from(x), f64::from(y)));
|
||||
open = true;
|
||||
}
|
||||
Seg::Cubic(a, b, c, d, e, f) => {
|
||||
if !open {
|
||||
p.move_to(start);
|
||||
}
|
||||
p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f)));
|
||||
open = true;
|
||||
}
|
||||
Seg::Close => {
|
||||
if open {
|
||||
p.close_path();
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether a placed box is finite and within ±[`MAX_COORD`].
|
||||
fn in_range(b: Rect) -> bool {
|
||||
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
//! 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::{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) {
|
||||
return true;
|
||||
}
|
||||
matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300))
|
||||
}
|
||||
|
||||
/// Reads all records. Returns the drawing state and the picture frame, or an error when no record
|
||||
/// could be read at all.
|
||||
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
let placeable = input.u32(0) == Some(PLACEABLE_KEY);
|
||||
// Placeable bounds: logical corners (left, top, right, bottom) and their size in points.
|
||||
let (hdr, bounds) = if placeable {
|
||||
let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from));
|
||||
let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) };
|
||||
let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
|
||||
let (w, h) = (r - l, b - t);
|
||||
let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch)));
|
||||
(22, bounds)
|
||||
} else {
|
||||
(0, None)
|
||||
};
|
||||
let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize;
|
||||
if hsize < 9 {
|
||||
return Err(Error::BadHeader);
|
||||
}
|
||||
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
|
||||
// Played into a target like Word does: the placeable bounds are the first window, the target (the
|
||||
// picture size) the viewport. A plain WMF's target is its first window.
|
||||
let mut cv = Canvas::new();
|
||||
if let Some((c, size)) = bounds {
|
||||
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
|
||||
cv.pattern_px = PT_PER_PX;
|
||||
}
|
||||
let mut player_viewport = bounds.is_some();
|
||||
let mut parsed = 0usize;
|
||||
while !cv.full() {
|
||||
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
|
||||
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break };
|
||||
let Some(rec) = input.slice(pos, len) else { break };
|
||||
if func == 0 {
|
||||
break;
|
||||
}
|
||||
record(&mut cv, func, Bytes(rec));
|
||||
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
|
||||
let we = cv.map.window_ext();
|
||||
cv.play_into(None, (we.0.abs(), we.1.abs()));
|
||||
player_viewport = true;
|
||||
}
|
||||
parsed += 1;
|
||||
let Some(next) = pos.checked_add(len) else { break };
|
||||
pos = next;
|
||||
}
|
||||
if parsed == 0 {
|
||||
return Err(Error::NoRecords);
|
||||
}
|
||||
let frame = match bounds {
|
||||
// Played into its target: the frame is the target.
|
||||
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
|
||||
// The file set its own viewport, so the drawing is in its device units and the logical bounds
|
||||
// go through the final mapping.
|
||||
Some((c, size)) => {
|
||||
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
|
||||
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
|
||||
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
|
||||
}
|
||||
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
|
||||
let ve = cv.map.viewport_ext();
|
||||
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
|
||||
}
|
||||
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
|
||||
None => None,
|
||||
};
|
||||
Ok((cv, frame))
|
||||
}
|
||||
|
||||
fn record(cv: &mut Canvas, func: u16, r: Bytes) {
|
||||
if cv.full() {
|
||||
return;
|
||||
}
|
||||
let _ = match func {
|
||||
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
|
||||
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
|
||||
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
|
||||
// Y is stored before X in these records.
|
||||
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
|
||||
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
|
||||
0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)),
|
||||
0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)),
|
||||
0x001E => {
|
||||
cv.save_dc();
|
||||
Some(())
|
||||
}
|
||||
0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))),
|
||||
0x0214 => xy16(r, 6).map(|p| cv.move_to(p)),
|
||||
0x0213 => xy16(r, 6).map(|p| cv.line_to(p)),
|
||||
0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)),
|
||||
0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)),
|
||||
0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)),
|
||||
0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
|
||||
0x061C => {
|
||||
let [ew, eh] = [r.i16(8), r.i16(6)];
|
||||
let (Some(ew), Some(eh)) = (ew, eh) else { return };
|
||||
rect16(r, 10).map(|[l, t, rr, b]| {
|
||||
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
|
||||
})
|
||||
}
|
||||
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
|
||||
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
|
||||
let kind = match func {
|
||||
0x0817 => ArcKind::Arc,
|
||||
0x081A => ArcKind::Pie,
|
||||
_ => ArcKind::Chord,
|
||||
};
|
||||
let [l, t, rr, b] = bounds;
|
||||
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
|
||||
}),
|
||||
0x02FA => create_pen(cv, r),
|
||||
0x02FC => create_brush(cv, r),
|
||||
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.
|
||||
0x0F43 => {
|
||||
let rop = r.u32(6).unwrap_or(0);
|
||||
dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)])
|
||||
}
|
||||
0x0B41 => dib_stretchblt(cv, r),
|
||||
0x0940 => dib_bitblt(cv, r),
|
||||
_ => Some(()),
|
||||
};
|
||||
}
|
||||
|
||||
/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the
|
||||
/// canvas reads with the 32-bit stock flag.
|
||||
fn wmf_handle(h: u16) -> u32 {
|
||||
let h = u32::from(h);
|
||||
if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h }
|
||||
}
|
||||
|
||||
/// An i16 field as f64; 0 past the end.
|
||||
fn i16s(r: Bytes, off: usize) -> f64 {
|
||||
r.i16(off).map_or(0.0, f64::from)
|
||||
}
|
||||
|
||||
/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used.
|
||||
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = r.u16(6)?;
|
||||
let width = r.i16(8)?;
|
||||
let color = r.u32(12)?;
|
||||
cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// CREATEBRUSHINDIRECT: style, colour, hatch.
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = u32::from(r.u16(6)?);
|
||||
let color = r.u32(8)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// (x, y) from a Y-then-X pair stored at `off`.
|
||||
fn xy16(r: Bytes, off: usize) -> Option<P> {
|
||||
Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?)))
|
||||
}
|
||||
|
||||
/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE).
|
||||
fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> {
|
||||
let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?);
|
||||
Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)])
|
||||
}
|
||||
|
||||
/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left).
|
||||
fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> {
|
||||
let end = xy16(r, 6)?;
|
||||
let start = xy16(r, 10)?;
|
||||
let bounds = rect16(r, 14)?;
|
||||
Some((bounds, start, end))
|
||||
}
|
||||
|
||||
/// A count of 16-bit points, then the points (POLYLINE, POLYGON).
|
||||
fn counted_points(r: Bytes) -> Option<Vec<P>> {
|
||||
let n = r.u16(6)? as usize;
|
||||
r.tail(8)?.points(0, n, false)
|
||||
}
|
||||
|
||||
/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points.
|
||||
fn polypolygon(r: Bytes) -> Option<Vec<geom::Op>> {
|
||||
let n = r.u16(6)? as usize;
|
||||
let mut counts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize);
|
||||
}
|
||||
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
|
||||
if total > MAX_PATH_OPS {
|
||||
return None;
|
||||
}
|
||||
let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?;
|
||||
Some(geom::subpaths(&pts, &counts, true))
|
||||
}
|
||||
|
||||
/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly
|
||||
/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3).
|
||||
fn has_bitmap(func: u16, r: Bytes) -> bool {
|
||||
r.0.len() / 2 > usize::from(func >> 8) + 3
|
||||
}
|
||||
|
||||
/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The
|
||||
/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or
|
||||
/// a source-less raster operation, fills the destination.
|
||||
fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let rop = r.u32(6)?;
|
||||
if !has_bitmap(0x0940, r) {
|
||||
cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop);
|
||||
return Some(());
|
||||
}
|
||||
let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)];
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]];
|
||||
dib_blit(cv, r, 22, rop, dst, src)
|
||||
}
|
||||
|
||||
/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at
|
||||
/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later;
|
||||
/// it, or a source-less raster operation, fills the destination.
|
||||
fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let rop = r.u32(6)?;
|
||||
if !has_bitmap(0x0B41, r) {
|
||||
cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop);
|
||||
return Some(());
|
||||
}
|
||||
let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)];
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)];
|
||||
dib_blit(cv, r, 26, rop, dst, src)
|
||||
}
|
||||
|
||||
/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`).
|
||||
/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]).
|
||||
fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> {
|
||||
let dib = r.tail(at)?;
|
||||
let n = dib::info_len(dib)?;
|
||||
let info = dib.slice(0, n)?;
|
||||
let bits = dib.tail(n)?;
|
||||
let mut img = dib::decode(Bytes(info), bits, false)?;
|
||||
dib::prepare(&mut img, dib::Blend::Rop(rop));
|
||||
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
|
||||
cv.bitmap(dst, img);
|
||||
Some(())
|
||||
}
|
||||
@@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
@@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
});
|
||||
};
|
||||
@@ -511,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
|
||||
first_slide_number: 1,
|
||||
next_id: next.max(256),
|
||||
embedded_fonts: vec![],
|
||||
table_styles: vec![],
|
||||
raw_ext: None,
|
||||
};
|
||||
if title_slide {
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
|
||||
+79
-15
@@ -16,6 +16,7 @@ pub mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod words;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -443,6 +444,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The list holding `id` among its siblings: the tree's top level, or a group's children.
|
||||
pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
|
||||
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
if shapes.iter().any(|s| s.id == id) {
|
||||
return Some(shapes);
|
||||
}
|
||||
for s in shapes.iter_mut() {
|
||||
if let ShapeKind::Group { children, .. } = &mut s.kind
|
||||
&& let Some(f) = rec(children, id, depth + 1)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
|
||||
pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
|
||||
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some(parent);
|
||||
}
|
||||
if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, None, id, 0)
|
||||
}
|
||||
|
||||
/// Background of a slide, layout or master.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
@@ -488,6 +529,10 @@ pub struct Slide {
|
||||
/// Speaker notes.
|
||||
pub notes: TextBody,
|
||||
pub comments: Vec<Comment>,
|
||||
/// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
|
||||
/// the layout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
/// Unmodelled XML kept for round-trip (custom data, tags).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
@@ -507,6 +552,7 @@ impl Default for Slide {
|
||||
animations: vec![],
|
||||
notes: TextBody::default(),
|
||||
comments: vec![],
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +642,10 @@ pub struct Layout {
|
||||
pub background: Option<Background>,
|
||||
pub show_master_shapes: bool,
|
||||
pub preserve: bool,
|
||||
/// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
|
||||
/// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
}
|
||||
@@ -641,24 +691,17 @@ impl Master {
|
||||
}
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
|
||||
pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
map_slot(&self.color_map, slot)
|
||||
}
|
||||
/// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
|
||||
pub fn scheme(&self) -> ColorScheme {
|
||||
self.scheme_with(&self.color_map)
|
||||
}
|
||||
/// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
|
||||
/// applied.
|
||||
pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
|
||||
let mut s = self.theme.colors.clone();
|
||||
if self.color_map.is_empty() {
|
||||
if map.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let base = self.theme.colors.clone();
|
||||
@@ -668,12 +711,30 @@ impl Master {
|
||||
(SchemeSlot::Bg2, SchemeSlot::Lt2),
|
||||
(SchemeSlot::Tx2, SchemeSlot::Dk2),
|
||||
] {
|
||||
s.set(slot, base.get(self.map_slot(alias)));
|
||||
s.set(slot, base.get(map_slot(map, alias)));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
|
||||
/// no entry).
|
||||
pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Section {
|
||||
@@ -778,6 +839,9 @@ pub struct Presentation {
|
||||
pub next_id: u32,
|
||||
/// Embedded fonts (family names; bytes in `media`).
|
||||
pub embedded_fonts: Vec<(String, MediaId)>,
|
||||
/// Table styles defined in the file (`ppt/tableStyles.xml`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub table_styles: Vec<table::TableStyleDef>,
|
||||
/// Unmodelled presentation-level XML kept for round-trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
|
||||
+30
-10
@@ -22,23 +22,27 @@ pub struct Ctx<'a> {
|
||||
pub layout: Option<&'a Layout>,
|
||||
pub owner: Owner,
|
||||
pub scheme: ColorScheme,
|
||||
/// The colour map in force: the slide's override, else its layout's, else the master's.
|
||||
pub color_map: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
|
||||
let (master, layout) = pres.master_for(slide)?;
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() })
|
||||
let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone();
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map })
|
||||
}
|
||||
pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() }
|
||||
let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone();
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map }
|
||||
}
|
||||
pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() }
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() }
|
||||
}
|
||||
pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
|
||||
let c = match &c.base {
|
||||
crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => {
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() }
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() }
|
||||
}
|
||||
_ => c.clone(),
|
||||
};
|
||||
@@ -130,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
|
||||
/// Effective fill and the style colour (`phClr`) to resolve it with.
|
||||
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(st) = &s.style {
|
||||
let style_fill = |s: &Shape| {
|
||||
s.style.as_ref().map(|st| {
|
||||
let (idx, col) = &st.fill_ref;
|
||||
let ph = ctx.color(col, None);
|
||||
let f = match *idx {
|
||||
@@ -142,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(),
|
||||
i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
|
||||
};
|
||||
return (f, Some(ph));
|
||||
(f, Some(ph))
|
||||
})
|
||||
};
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
// A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
|
||||
// gradient its style refers to, keeping its own direction, as PowerPoint draws it.
|
||||
if let Fill::Gradient(g) = f
|
||||
&& g.stops.is_empty()
|
||||
&& let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
|
||||
{
|
||||
let mut g = g.clone();
|
||||
g.stops = sg.stops;
|
||||
return (Some(Fill::Gradient(g)), ph);
|
||||
}
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(r) = style_fill(s) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if shape.text_box {
|
||||
@@ -219,6 +238,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
columns: Some(1),
|
||||
col_spacing: Some(0.0),
|
||||
warp: None,
|
||||
warp_adj: Vec::new(),
|
||||
rtl_col: Some(false),
|
||||
upright: Some(false),
|
||||
});
|
||||
|
||||
+109
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::style::{Fill, Line};
|
||||
use crate::style::{ColorRef, Fill, Line};
|
||||
use crate::text::{Anchor, TextBody};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -55,6 +55,9 @@ pub struct Table {
|
||||
pub last_col: bool,
|
||||
pub band_row: bool,
|
||||
pub band_col: bool,
|
||||
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Fill>,
|
||||
}
|
||||
|
||||
impl Default for Table {
|
||||
@@ -69,6 +72,7 @@ impl Default for Table {
|
||||
last_col: false,
|
||||
band_row: true,
|
||||
band_col: false,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +203,110 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
|
||||
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
|
||||
/// last rows and columns, corner cells) is filled, bordered and lettered.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyleDef {
|
||||
/// The style's GUID (`a:tableStyleId` on tables).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parts: Vec<(TablePart, TablePartStyle)>,
|
||||
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
|
||||
pub background: Option<TablePartStyle>,
|
||||
/// The definition as read, written back unchanged.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl TableStyleDef {
|
||||
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
|
||||
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a table style, in the order they apply (later ones win).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TablePart {
|
||||
WholeTbl,
|
||||
Band1H,
|
||||
Band2H,
|
||||
Band1V,
|
||||
Band2V,
|
||||
LastCol,
|
||||
FirstCol,
|
||||
LastRow,
|
||||
FirstRow,
|
||||
SeCell,
|
||||
SwCell,
|
||||
NeCell,
|
||||
NwCell,
|
||||
}
|
||||
|
||||
impl TablePart {
|
||||
pub const ALL: [TablePart; 13] = [
|
||||
TablePart::WholeTbl,
|
||||
TablePart::Band1H,
|
||||
TablePart::Band2H,
|
||||
TablePart::Band1V,
|
||||
TablePart::Band2V,
|
||||
TablePart::LastCol,
|
||||
TablePart::FirstCol,
|
||||
TablePart::LastRow,
|
||||
TablePart::FirstRow,
|
||||
TablePart::SeCell,
|
||||
TablePart::SwCell,
|
||||
TablePart::NeCell,
|
||||
TablePart::NwCell,
|
||||
];
|
||||
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
TablePart::WholeTbl => "wholeTbl",
|
||||
TablePart::Band1H => "band1H",
|
||||
TablePart::Band2H => "band2H",
|
||||
TablePart::Band1V => "band1V",
|
||||
TablePart::Band2V => "band2V",
|
||||
TablePart::LastCol => "lastCol",
|
||||
TablePart::FirstCol => "firstCol",
|
||||
TablePart::LastRow => "lastRow",
|
||||
TablePart::FirstRow => "firstRow",
|
||||
TablePart::SeCell => "seCell",
|
||||
TablePart::SwCell => "swCell",
|
||||
TablePart::NeCell => "neCell",
|
||||
TablePart::NwCell => "nwCell",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(s: &str) -> Option<TablePart> {
|
||||
TablePart::ALL.into_iter().find(|p| p.xml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
|
||||
/// style index with its colour.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TablePartStyle {
|
||||
pub fill: Option<Fill>,
|
||||
pub fill_ref: Option<(u32, ColorRef)>,
|
||||
/// Left, right, top, bottom, inside horizontal, inside vertical.
|
||||
pub borders: [Option<TableBorder>; 6],
|
||||
pub text_color: Option<ColorRef>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
}
|
||||
|
||||
/// A border of a table style part: a line, or a theme line style reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum TableBorder {
|
||||
Line { line: Line },
|
||||
Ref { idx: u32, color: ColorRef },
|
||||
}
|
||||
|
||||
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
|
||||
pub fn table_styles() -> Vec<(String, String)> {
|
||||
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
|
||||
|
||||
@@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() {
|
||||
assert!(ph.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
|
||||
// the style's fillRef names, in the style's colour, along the shape's own direction.
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
let (f, ph) = resolve::fill(&ctx, &sh);
|
||||
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
|
||||
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
|
||||
assert_eq!(g.stops, theme.stops);
|
||||
assert_eq!(g.shape, own.shape, "the shape's own direction");
|
||||
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
|
||||
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
|
||||
st.fill_ref.0 = 1;
|
||||
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = Presentation::default();
|
||||
@@ -159,3 +182,51 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
||||
let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() });
|
||||
assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_colour_map_override_applies_to_its_slides() {
|
||||
// A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master
|
||||
// mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it.
|
||||
let mut p = Presentation::default();
|
||||
let dark: Vec<(String, String)> =
|
||||
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let colors = m.theme.colors.clone();
|
||||
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone());
|
||||
let s: Slide = (*p.slides[0]).clone();
|
||||
let ctx = Ctx::for_slide(&p, &s).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2));
|
||||
assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1));
|
||||
// The slide's own override wins over the layout's.
|
||||
let mut own = s.clone();
|
||||
own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect());
|
||||
let ctx = Ctx::for_slide(&p, &own).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
// The layout itself draws with its override; the master keeps its own map.
|
||||
let m = &p.masters[0];
|
||||
let l = m.layout(layout).unwrap();
|
||||
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_lists_and_parents_reach_into_groups() {
|
||||
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
|
||||
let group = |id: u32, children: Vec<Shape>| Shape {
|
||||
id: ShapeId(id),
|
||||
kind: ShapeKind::Group { children, child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
|
||||
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
|
||||
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(9)), None);
|
||||
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
|
||||
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct BodyProps {
|
||||
pub col_spacing: Option<f64>,
|
||||
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
|
||||
pub warp: Option<String>,
|
||||
/// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
|
||||
/// empty = the preset's defaults.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warp_adj: Vec<f64>,
|
||||
pub rtl_col: Option<bool>,
|
||||
/// Keep text upright when the shape rotates.
|
||||
pub upright: Option<bool>,
|
||||
@@ -119,6 +123,7 @@ impl BodyProps {
|
||||
self.col_spacing = self.col_spacing.or(p.col_spacing);
|
||||
if self.warp.is_none() {
|
||||
self.warp.clone_from(&p.warp);
|
||||
self.warp_adj.clone_from(&p.warp_adj);
|
||||
}
|
||||
self.rtl_col = self.rtl_col.or(p.rtl_col);
|
||||
self.upright = self.upright.or(p.upright);
|
||||
@@ -223,6 +228,10 @@ pub struct ParaProps {
|
||||
pub rtl: Option<bool>,
|
||||
pub font_align: Option<String>,
|
||||
pub east_asian_line_break: Option<bool>,
|
||||
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
|
||||
pub latin_line_break: Option<bool>,
|
||||
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
|
||||
pub hanging_punct: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -253,6 +262,8 @@ impl ParaProps {
|
||||
self.font_align.clone_from(&p.font_align);
|
||||
}
|
||||
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
|
||||
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
|
||||
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
|
||||
"gradFill" => Fill::Gradient(gradient(f)),
|
||||
"blipFill" => match blip_fill(imp, part, f) {
|
||||
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
|
||||
// tiled at its own size, as PowerPoint draws it.
|
||||
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
|
||||
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
Fill::Picture(p)
|
||||
}
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
@@ -315,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps {
|
||||
}
|
||||
"spAutoFit" => p.autofit = Some(AutoFit::Shape),
|
||||
"noAutofit" => p.autofit = Some(AutoFit::None),
|
||||
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
|
||||
"prstTxWarp" => {
|
||||
p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from);
|
||||
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given);
|
||||
/// slots the file leaves out keep the preset's default.
|
||||
fn warp_adjusts(preset: &str, e: &El) -> Vec<f64> {
|
||||
let mut adj: Vec<f64> = Vec::new();
|
||||
let Some(av) = e.child("avLst") else { return adj };
|
||||
let defaults = deckcraft_geom::warp::adjust_defaults(preset);
|
||||
for gd in av.children_named("gd").take(16) {
|
||||
let n = gd.attr("name").unwrap_or("");
|
||||
let idx = if n == "adj" {
|
||||
0
|
||||
} else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::<usize>().ok()).filter(|k| (1..=8).contains(k)) {
|
||||
k - 1
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
while adj.len() <= idx {
|
||||
adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(idx) {
|
||||
*slot = v.clamp(-1.0e9, 1.0e9);
|
||||
}
|
||||
}
|
||||
adj
|
||||
}
|
||||
|
||||
fn spacing(e: &El) -> Option<Spacing> {
|
||||
if let Some(p) = e.child("spcPct") {
|
||||
return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0)));
|
||||
@@ -339,6 +377,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
rtl: p.bool("rtl"),
|
||||
font_align: p.attr("fontAlgn").map(String::from),
|
||||
east_asian_line_break: p.bool("eaLnBrk"),
|
||||
latin_line_break: p.bool("latinLnBrk"),
|
||||
hanging_punct: p.bool("hangingPunct"),
|
||||
default_tab: pt(p, "defTabSz"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+171
-1
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::{Size, emu_to_pt};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{
|
||||
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Properties, Section, ShowSettings, Slide, SlideId, defaults,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Imp<'a> {
|
||||
pub next: u32,
|
||||
pub slide_by_part: HashMap<String, SlideId>,
|
||||
pub custom_show_names: HashMap<String, String>,
|
||||
/// GUIDs (upper case) of the table styles the file defines.
|
||||
pub table_style_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
next: max_id.max(1),
|
||||
slide_by_part: HashMap::new(),
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
|
||||
@@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
if let Some(dts) = pres_el.child("defaultTextStyle") {
|
||||
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
|
||||
}
|
||||
if let Some(r) = pres_part.rels.first_of("tableStyles")
|
||||
&& let Some(d) = load(&pkg, &r.target)
|
||||
{
|
||||
let part = Part::new(&pkg, &r.target, &d);
|
||||
p.table_styles = table_styles(&mut imp, &part, &d.root);
|
||||
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
|
||||
}
|
||||
|
||||
// Masters and layouts.
|
||||
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
|
||||
@@ -387,6 +397,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
|
||||
}
|
||||
|
||||
inherit_placeholder_geometry(&mut p);
|
||||
p.header_footer = header_footer(&p);
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
@@ -419,6 +430,154 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
|
||||
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
|
||||
}
|
||||
|
||||
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
|
||||
/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a
|
||||
/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left
|
||||
/// is a rectangle.
|
||||
fn inherit_placeholder_geometry(p: &mut Presentation) {
|
||||
let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty());
|
||||
// Masters first, then layouts (from their masters), then slides (from layouts and masters).
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
walk_shapes_mut(&mut m.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
|
||||
for m in &p.masters {
|
||||
for l in &m.layouts {
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
|
||||
for (i, s) in l.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
for l in &mut m.layouts {
|
||||
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
|
||||
let _ = id;
|
||||
if let Some(s) = l.shapes.get_mut(*i) {
|
||||
s.geom = g.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
|
||||
for (si, sl) in p.slides.iter().enumerate() {
|
||||
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
|
||||
for (i, s) in sl.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
|
||||
slide_geoms.push((si, i, g.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (si, i, g) in slide_geoms {
|
||||
if let Some(sl) = p.slides.get_mut(si)
|
||||
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
|
||||
{
|
||||
s.geom = g;
|
||||
}
|
||||
}
|
||||
// Placeholders inside groups (rare) and anything still unset: rectangles.
|
||||
for sl in &mut p.slides {
|
||||
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
for l in &mut Arc::make_mut(m).layouts {
|
||||
walk_shapes_mut(&mut l.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shape, groups included.
|
||||
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
|
||||
for s in shapes {
|
||||
f(s);
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
|
||||
walk_shapes_mut(children, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `a:tblStyleLst`: the table styles the file defines.
|
||||
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
|
||||
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
root.children_named("tblStyle")
|
||||
.take(256)
|
||||
.filter_map(|st| {
|
||||
let id = st.attr("styleId")?.to_ascii_uppercase();
|
||||
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
|
||||
if let Some(bg) = st.child("tblBg") {
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = bg.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
def.background = Some(ps);
|
||||
}
|
||||
for el in st.elements() {
|
||||
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(tx) = el.child("tcTxStyle") {
|
||||
ps.text_color = dml::color(tx);
|
||||
ps.bold = tx.attr("b").map(|v| v == "on");
|
||||
ps.italic = tx.attr("i").map(|v| v == "on");
|
||||
}
|
||||
if let Some(tc) = el.child("tcStyle") {
|
||||
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = tc.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
if let Some(bd) = tc.child("tcBdr") {
|
||||
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
|
||||
let Some(b) = bd.child(side) else { continue };
|
||||
let border = if let Some(ln) = b.child("ln") {
|
||||
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
|
||||
} else {
|
||||
b.child("lnRef").map(|r| TableBorder::Ref {
|
||||
idx: r.u32("idx").unwrap_or(0),
|
||||
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
})
|
||||
};
|
||||
if let Some(slot) = ps.borders.get_mut(i) {
|
||||
*slot = border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
def.parts.push((kind, ps));
|
||||
}
|
||||
Some(def)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
|
||||
let bg = csld.child("bg")?;
|
||||
if let Some(pr) = bg.child("bgPr") {
|
||||
@@ -523,12 +682,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout {
|
||||
l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
}
|
||||
l.color_map = color_map_override(root);
|
||||
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
|
||||
l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none).
|
||||
fn color_map_override(root: &El) -> Option<Vec<(String, String)>> {
|
||||
root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map)
|
||||
}
|
||||
|
||||
fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide {
|
||||
let root = &doc.root;
|
||||
let mut s = Slide {
|
||||
@@ -536,6 +701,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
layout,
|
||||
hidden: root.bool("show") == Some(false),
|
||||
show_master_shapes: root.bool("showMasterSp").unwrap_or(true),
|
||||
color_map: color_map_override(root),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ctx = shapes::IdCtx::default();
|
||||
@@ -545,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
if let Some(tree) = csld.child("spTree") {
|
||||
s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
if let Some(c) = csld.child("controls") {
|
||||
let pics = shapes::controls(imp, part, &mut ctx, c);
|
||||
s.shapes.extend(pics);
|
||||
}
|
||||
}
|
||||
if let Some(t) = root.child("transition") {
|
||||
s.transition = timing::transition(imp, part, t);
|
||||
|
||||
@@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us
|
||||
out
|
||||
}
|
||||
|
||||
/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows
|
||||
/// for it, as a picture above the slide's shapes. Controls without one are left out.
|
||||
pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec<Shape> {
|
||||
let mut out = vec![];
|
||||
for c in controls.children_named("control") {
|
||||
if ctx.count >= MAX_SHAPES {
|
||||
break;
|
||||
}
|
||||
if let Some(p) = c.child("pic") {
|
||||
ctx.count += 1;
|
||||
out.push(pic(imp, part, ctx, p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
@@ -104,6 +120,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking
|
||||
/// attributes; it reads an object as locked only when the whole set for its kind is there, so a
|
||||
/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock.
|
||||
fn is_locked(l: &El) -> bool {
|
||||
let set: &[&str] = match l.local() {
|
||||
"grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"],
|
||||
"graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"],
|
||||
_ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"],
|
||||
};
|
||||
set.iter().all(|a| l.bool(a) == Some(true))
|
||||
}
|
||||
|
||||
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
|
||||
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
let c = nv.and_then(|n| n.child("cNvPr"));
|
||||
@@ -122,10 +150,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
match e.local() {
|
||||
"cNvSpPr" => {
|
||||
s.text_box = e.bool("txBox").unwrap_or(false);
|
||||
s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.child("spLocks").is_some_and(is_locked);
|
||||
}
|
||||
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
|
||||
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
|
||||
}
|
||||
"nvPr" => {
|
||||
if let Some(ph) = e.child("ph") {
|
||||
@@ -180,6 +208,15 @@ fn preset_geom(pg: &El) -> Geom {
|
||||
Geom::Preset { name, adj }
|
||||
}
|
||||
|
||||
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
|
||||
/// marked here with an empty custom geometry, filled in once the layouts are read
|
||||
/// (`inherit_placeholder_geometry`).
|
||||
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
|
||||
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
|
||||
s.geom = Geom::Custom { paths: vec![] };
|
||||
}
|
||||
}
|
||||
|
||||
/// `spPr` → transform, geometry, fill, line, effects.
|
||||
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
|
||||
if let Some(x) = pr.child("xfrm") {
|
||||
@@ -229,6 +266,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
|
||||
if let Some(pr) = e.child("spPr") {
|
||||
sp_pr(imp, part, &mut s, pr);
|
||||
}
|
||||
mark_inherited_geometry(&mut s, e.child("spPr"));
|
||||
if e.bool("useBgFill") == Some(true) {
|
||||
s.fill = Some(Fill::Background);
|
||||
}
|
||||
@@ -346,7 +384,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
let mut pics = vec![];
|
||||
e.find_all("blip", &mut pics);
|
||||
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
||||
};
|
||||
}
|
||||
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
|
||||
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||
} else if uri.ends_with("/diagram") {
|
||||
@@ -360,6 +399,21 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
s
|
||||
}
|
||||
|
||||
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
|
||||
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
|
||||
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
|
||||
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
|
||||
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
|
||||
let mut shapes = vec![];
|
||||
doc.root.find_all("shape", &mut shapes);
|
||||
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
|
||||
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
|
||||
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
|
||||
return imp.media_for(&target);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
@@ -384,6 +438,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
|
||||
if let Some(pr) = tbl.child("tblPr") {
|
||||
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
|
||||
t.first_row = pr.bool("firstRow").unwrap_or(false);
|
||||
t.first_col = pr.bool("firstCol").unwrap_or(false);
|
||||
t.last_row = pr.bool("lastRow").unwrap_or(false);
|
||||
@@ -391,7 +446,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
t.band_row = pr.bool("bandRow").unwrap_or(false);
|
||||
t.band_col = pr.bool("bandCol").unwrap_or(false);
|
||||
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
|
||||
t.style = crate::tables::style_from_guid(id.trim());
|
||||
// A style the file defines is drawn from its definition (by its GUID).
|
||||
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
|
||||
let up = id.trim().to_ascii_uppercase();
|
||||
t.style =
|
||||
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
|
||||
}
|
||||
}
|
||||
t.cols = tbl
|
||||
|
||||
@@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String {
|
||||
guid(&format!("deckcraft-table-style:{style}"))
|
||||
}
|
||||
|
||||
/// Is `g` the GUID DeckCraft writes for one of its own styles?
|
||||
pub fn is_own_guid(g: &str) -> bool {
|
||||
let up = g.to_ascii_uppercase();
|
||||
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
|
||||
}
|
||||
|
||||
/// Table style GUID → our style id (or the GUID itself when unknown).
|
||||
pub fn style_from_guid(g: &str) -> String {
|
||||
let up = g.to_ascii_uppercase();
|
||||
@@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) {
|
||||
w.close("a:tblStyle");
|
||||
}
|
||||
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
|
||||
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
|
||||
/// opened file defined (`defs`), written back as they were read.
|
||||
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
|
||||
let names = deckcraft_model::table::table_styles();
|
||||
@@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
done.push(s);
|
||||
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
|
||||
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
|
||||
{
|
||||
w.raw(&d.raw);
|
||||
continue;
|
||||
}
|
||||
let (_, define) = guid_for_style(s);
|
||||
if !define {
|
||||
continue;
|
||||
@@ -231,7 +244,7 @@ mod tests {
|
||||
"nwCell",
|
||||
];
|
||||
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
|
||||
let mut n = 0;
|
||||
for st in d.root.children_named("tblStyle") {
|
||||
n += 1;
|
||||
|
||||
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open0("a:gsLst");
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.is_empty() {
|
||||
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
|
||||
}
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
@@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
w.open("a:bodyPr", a);
|
||||
if let Some(p) = warp {
|
||||
w.open("a:prstTxWarp", A::new().a("prst", p));
|
||||
w.empty0("a:avLst");
|
||||
let adj: Vec<(usize, i64)> =
|
||||
b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect();
|
||||
if adj.is_empty() {
|
||||
w.empty0("a:avLst");
|
||||
} else {
|
||||
let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1;
|
||||
w.open0("a:avLst");
|
||||
for (i, v) in adj {
|
||||
let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) };
|
||||
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
|
||||
}
|
||||
w.close("a:avLst");
|
||||
}
|
||||
w.close("a:prstTxWarp");
|
||||
}
|
||||
match b.autofit {
|
||||
@@ -432,6 +444,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
|
||||
.b("rtl", p.rtl)
|
||||
.b("eaLnBrk", p.east_asian_line_break)
|
||||
.b("latinLnBrk", p.latin_line_break)
|
||||
.b("hangingPunct", p.hanging_punct)
|
||||
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
|
||||
let has_kids = p.line_spacing.is_some()
|
||||
|| p.space_before.is_some()
|
||||
|
||||
@@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [
|
||||
];
|
||||
|
||||
fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
w.empty("p:clrMap", clr_map_attrs(map));
|
||||
}
|
||||
|
||||
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
|
||||
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
|
||||
w.open0("p:clrMapOvr");
|
||||
match map {
|
||||
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
|
||||
None => w.empty0("a:masterClrMapping"),
|
||||
}
|
||||
w.close("p:clrMapOvr");
|
||||
}
|
||||
|
||||
/// The twelve colour map attributes, each a theme slot (the default for anything else).
|
||||
fn clr_map_attrs(map: &[(String, String)]) -> A {
|
||||
let mut a = A::new();
|
||||
for (k, d) in CLR_KEYS {
|
||||
let v = map
|
||||
@@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
.unwrap_or(d);
|
||||
a = a.a(k, v);
|
||||
}
|
||||
w.empty("p:clrMap", a);
|
||||
a
|
||||
}
|
||||
|
||||
fn ext_list(w: &mut W, raw: Option<&str>) {
|
||||
@@ -497,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
|
||||
pres.rels.add("tableStyles", "tableStyles.xml", false);
|
||||
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
|
||||
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles);
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
|
||||
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
|
||||
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
|
||||
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
|
||||
@@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
|
||||
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
|
||||
);
|
||||
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, l.color_map.as_deref());
|
||||
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
|
||||
w.close("p:sldLayout");
|
||||
w.finish()
|
||||
@@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
|
||||
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, s.color_map.as_deref());
|
||||
if let Some(t) = &s.transition {
|
||||
timing::transition(&mut w, x, o, t);
|
||||
}
|
||||
|
||||
@@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and
|
||||
/// reads back as locked): see `read::shapes::is_locked`.
|
||||
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
||||
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
|
||||
let a = extra.t("noGrp", s.ph.is_some() || s.locked);
|
||||
let a = match tag {
|
||||
"a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked),
|
||||
_ => {
|
||||
let a = a
|
||||
.t("noRot", s.locked)
|
||||
.t("noMove", s.locked)
|
||||
.t("noResize", s.locked)
|
||||
.t("noEditPoints", s.locked)
|
||||
.t("noAdjustHandles", s.locked)
|
||||
.t("noChangeArrowheads", s.locked)
|
||||
.t("noChangeShapeType", s.locked);
|
||||
if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a }
|
||||
}
|
||||
};
|
||||
if a.v.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
||||
w.open0("p:nvGraphicFramePr");
|
||||
cnvpr(w, x, o, id, s);
|
||||
w.open0("p:cNvGraphicFramePr");
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.close("p:cNvGraphicFramePr");
|
||||
nv_pr(w, s);
|
||||
w.close("p:nvGraphicFramePr");
|
||||
@@ -494,6 +510,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
.t("bandRow", t.band_row)
|
||||
.t("bandCol", t.band_col),
|
||||
);
|
||||
if let Some(f) = &t.background {
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
w.elt("a:tableStyleId", &guid);
|
||||
w.close("a:tblPr");
|
||||
w.open0("a:tblGrid");
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Fills read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
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.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A deck with one shape filled with a stretched picture, exported.
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let media = p.add_media("tile.png", "image/png", png());
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Textured".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
|
||||
..Default::default()
|
||||
});
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn textured_mode(bytes: &[u8]) -> PictureMode {
|
||||
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||
match &s.fill {
|
||||
Some(Fill::Picture(pf)) => pf.mode.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
|
||||
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
|
||||
// PowerPoint tiles the picture at its own size.
|
||||
let bare = edit_slides(&deck(), |s| {
|
||||
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
|
||||
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
|
||||
s
|
||||
});
|
||||
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
|
||||
// An explicit stretch is still a stretch.
|
||||
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
|
||||
}
|
||||
|
||||
/// `s` without the first `open`…`close` span inside a `blipFill`.
|
||||
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
|
||||
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
// `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving
|
||||
// must not turn it into a gradient of its own (it used to become solid white).
|
||||
use deckcraft_model::style::{Gradient, GradientShape};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(own.clone())),
|
||||
style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
|
||||
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
|
||||
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
|
||||
fn deck() -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png());
|
||||
let mut v = vec![];
|
||||
let mut a = shape(&mut p, "Locked", 0.0);
|
||||
a.locked = true;
|
||||
v.push(a);
|
||||
v.push(shape(&mut p, "Free", 100.0));
|
||||
let mut t = shape(&mut p, "Hidden text", 200.0);
|
||||
t.text_box = true;
|
||||
t.hidden = true;
|
||||
t.locked = true;
|
||||
v.push(t);
|
||||
let mut c1 = shape(&mut p, "Child locked", 300.0);
|
||||
c1.locked = true;
|
||||
let c2 = shape(&mut p, "Child free", 400.0);
|
||||
let mut c3 = shape(&mut p, "Child hidden", 500.0);
|
||||
c3.hidden = true;
|
||||
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
|
||||
let mut g = Shape {
|
||||
id: ShapeId(p.alloc_id()),
|
||||
name: "Group".into(),
|
||||
xfrm: Some(gx),
|
||||
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
|
||||
locked: true,
|
||||
..Default::default()
|
||||
};
|
||||
g.hidden = false;
|
||||
v.push(g);
|
||||
let mut pic = shape(&mut p, "Pic", 0.0);
|
||||
pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } };
|
||||
pic.locked = true;
|
||||
v.push(pic);
|
||||
let mut cx = shape(&mut p, "Conn", 100.0);
|
||||
cx.kind = ShapeKind::Connector { start: None, end: None };
|
||||
cx.geom = deckcraft_model::Geom::preset("straightConnector1");
|
||||
cx.locked = true;
|
||||
v.push(cx);
|
||||
let mut tb = shape(&mut p, "Tbl", 200.0);
|
||||
tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0));
|
||||
tb.locked = true;
|
||||
v.push(tb);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = v;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape {
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(&s.shapes, &mut |x, _| {
|
||||
if found.is_none() && x.name == name {
|
||||
found = Some(x);
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no shape {name}"))
|
||||
}
|
||||
|
||||
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
(0..z.len())
|
||||
.map(|i| {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
(f.name().to_string(), d)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rezip(es: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, d) in es {
|
||||
z.start_file(n.as_str(), o).expect("start");
|
||||
z.write_all(d).expect("write");
|
||||
}
|
||||
z.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn slide1_xml(bytes: &[u8]) -> String {
|
||||
let es = entries(bytes);
|
||||
let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
String::from_utf8(d.clone()).expect("utf8")
|
||||
}
|
||||
|
||||
/// The element following `name="…"` up to the end of the object's non-visual properties.
|
||||
fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}"));
|
||||
let rest = &xml[i..];
|
||||
let j = rest.find("<p:nvPr").unwrap_or(rest.len());
|
||||
&rest[..j]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hidden_round_trip() {
|
||||
let p = deck();
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let s = &q.slides[0];
|
||||
for (name, locked, hidden) in [
|
||||
("Locked", true, false),
|
||||
("Free", false, false),
|
||||
("Hidden text", true, true),
|
||||
("Group", true, false),
|
||||
("Child locked", true, false),
|
||||
("Child free", false, false),
|
||||
("Child hidden", false, true),
|
||||
("Pic", true, false),
|
||||
("Conn", true, false),
|
||||
("Tbl", true, false),
|
||||
] {
|
||||
let sh = find(s, name);
|
||||
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
|
||||
}
|
||||
// The stacking order of the slide and of the group survives too.
|
||||
let names: Vec<&str> = s.shapes.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]);
|
||||
let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_writes_powerpoints_full_attribute_set() {
|
||||
let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export"));
|
||||
let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#;
|
||||
assert!(nv_of(&xml, "Locked").contains(&format!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#));
|
||||
assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free"));
|
||||
assert!(nv_of(&xml, "Group").contains(r#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
|
||||
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
|
||||
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
|
||||
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
|
||||
}
|
||||
|
||||
/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's
|
||||
/// partial lock), or the set without `noGrp`, isn't locked.
|
||||
#[test]
|
||||
fn partial_locks_are_not_lock() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
let b = shape(&mut p, "B", 100.0);
|
||||
let c = shape(&mut p, "C", 200.0);
|
||||
let mut d = shape(&mut p, "D", 300.0);
|
||||
d.locked = true;
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8");
|
||||
let swap = |xml: String, name: &str, locks: &str| {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).expect("name");
|
||||
let j = i + xml[i..].find("<p:cNvSpPr").expect("cNvSpPr");
|
||||
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
|
||||
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
|
||||
};
|
||||
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
|
||||
let xml = swap(
|
||||
xml,
|
||||
"B",
|
||||
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
|
||||
);
|
||||
let xml =
|
||||
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
let s = &q.slides[0];
|
||||
assert!(!find(s, "A").locked);
|
||||
assert!(!find(s, "B").locked);
|
||||
assert!(!find(s, "C").locked);
|
||||
assert!(find(s, "D").locked);
|
||||
}
|
||||
|
||||
/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked.
|
||||
#[test]
|
||||
fn empty_locks_element_is_unlocked() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
assert!(!find(&q.slides[0], "A").locked);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{ShapeKind, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text.
|
||||
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
|
||||
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
if parts.iter().any(|(n, _)| *n == name) {
|
||||
continue;
|
||||
}
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
if name.ends_with(".xml") || name.ends_with(".rels") {
|
||||
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in parts {
|
||||
out.start_file(*name, o).expect("start");
|
||||
out.write_all(data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the
|
||||
/// VML shape with that id in the slide's legacy drawing part.
|
||||
fn ole_deck(spid_in_vml: &str) -> Vec<u8> {
|
||||
let vml = format!(
|
||||
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<v:shape id="_x0000_s9999" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rId7" o:title=""/></v:shape>
|
||||
<v:shape id="{spid_in_vml}" style='position:absolute;left:0;top:0;width:2in;height:1in'><v:imagedata o:relid="rId1" o:title=""/></v:shape>
|
||||
</xml>"#
|
||||
);
|
||||
let vml_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/></Relationships>"#;
|
||||
let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s681987" name="Slide" r:id="rIdOle" imgW="4664160" imgH="3497400" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
with_parts(
|
||||
&[
|
||||
("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()),
|
||||
("ppt/media/preview.png", png()),
|
||||
("ppt/media/other.png", vec![1, 2, 3]),
|
||||
("ppt/embeddings/oleObject1.bin", vec![0; 16]),
|
||||
],
|
||||
|name, s| {
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
s.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn object_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let p = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
|
||||
match &s.kind {
|
||||
ShapeKind::Opaque { preview, label, .. } => {
|
||||
assert_eq!(label, "PowerPoint.Slide.8");
|
||||
preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec())
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_ole_object_shows_its_vml_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls
|
||||
//! shown by their pictures.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::defaults;
|
||||
|
||||
const FRAME: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s2050" name="Slide" r:id="rIdOle" progId="Thing.1"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
|
||||
const VML: &str = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><v:shape id="_x0000_s2049"><v:imagedata o:relid="rIdA"/></v:shape><v:shape id="_x0000_s2050" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rIdB" o:title=""/></v:shape></xml>"#;
|
||||
|
||||
const VML_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/></Relationships>"#;
|
||||
|
||||
const SLIDE_RELS: &str = r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/>"#;
|
||||
|
||||
/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture.
|
||||
const CONTROLS: &str = r#"<p:controls><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:control spid="1137" name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"/></mc:Choice><mc:Fallback><p:control name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"><p:pic><p:nvPicPr><p:cNvPr id="4" name="CheckBox1"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="12700" y="25400"/><a:ext cx="127000" cy="63500"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:control></mc:Fallback></mc:AlternateContent><p:control name="NoPicture" r:id="rIdY"/></p:controls>"#;
|
||||
|
||||
fn png(v: u8) -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures.
|
||||
fn deck() -> Vec<u8> {
|
||||
let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let at = s.rfind("</p:spTree>").expect("spTree");
|
||||
let s = format!("{}{FRAME}{}", &s[..at], &s[at..]);
|
||||
let at = s.rfind("</p:cSld>").expect("cSld");
|
||||
d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes();
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
d = s.replace("</Relationships>", &format!("{SLIDE_RELS}</Relationships>")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in [
|
||||
("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()),
|
||||
("ppt/media/other.png", png(10)),
|
||||
("ppt/media/preview.png", png(200)),
|
||||
] {
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_picture_shows_its_vml_preview() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame");
|
||||
let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(label, "Thing.1");
|
||||
let media = p.media.iter().find(|x| x.id == *m).expect("media");
|
||||
// The shape whose id is the object's spid, not the first one in the drawing.
|
||||
assert_eq!(media.name, "preview.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activex_controls_show_their_pictures() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture");
|
||||
let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png"));
|
||||
let x = s.xfrm.expect("xfrm");
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0));
|
||||
assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Placeholder inheritance in decks written by PowerPoint.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{Geom, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
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.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_without_geometry_takes_its_layouts() {
|
||||
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
|
||||
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
|
||||
for s in &mut l.shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
|
||||
let bare = edit_slides(&bytes, |s| {
|
||||
let mut s = s;
|
||||
while let Some(i) = s.find("<a:prstGeom") {
|
||||
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
|
||||
s.replace_range(i..j, "");
|
||||
}
|
||||
s
|
||||
});
|
||||
let q = deckcraft_pptx::import(&bare).expect("import");
|
||||
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
|
||||
assert!(!phs.is_empty());
|
||||
for s in phs {
|
||||
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
|
||||
}
|
||||
// A placeholder with its own geometry keeps it.
|
||||
let first = p.slides[0].id;
|
||||
for s in &mut p.slide_mut(first).expect("slide").shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
|
||||
}
|
||||
@@ -730,3 +730,130 @@ fn date_fields_keep_their_type_and_saved_text() {
|
||||
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_map_overrides_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let dark: Vec<(String, String)> =
|
||||
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let layout = p.slides[0].layout;
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
|
||||
let q = round(&p);
|
||||
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
|
||||
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
|
||||
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
|
||||
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
|
||||
// Layouts and slides without one keep following the master.
|
||||
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_duotone_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
|
||||
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
|
||||
let mut pf = PictureFill { media: img, ..Default::default() };
|
||||
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
|
||||
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(pic);
|
||||
let q = round(&p);
|
||||
let sh = by_name(&q.slides[0], "Duotone");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
|
||||
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
|
||||
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
|
||||
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_options_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let first = p.slides[0].id;
|
||||
let s = p.slide_mut(first).expect("slide");
|
||||
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
|
||||
t.paragraphs[0].props.latin_line_break = Some(true);
|
||||
t.paragraphs[0].props.hanging_punct = Some(false);
|
||||
let q = round(&p);
|
||||
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
|
||||
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
|
||||
sh.kind = deckcraft_model::ShapeKind::Table(t);
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(sh);
|
||||
let q = round(&p);
|
||||
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
|
||||
assert!(
|
||||
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordart_warps_and_their_adjusts_round_trip() {
|
||||
let mut p = Presentation::default();
|
||||
let cases: [(&str, &str, Vec<f64>); 5] = [
|
||||
("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]),
|
||||
("Wave", "textWave1", vec![18_000.0, -5_000.0]),
|
||||
("Inflate", "textInflate", vec![30_000.0]),
|
||||
("Arch", "textArchUp", vec![]),
|
||||
("Ring", "textRingInside", vec![75_000.0]),
|
||||
];
|
||||
let mut shapes = vec![];
|
||||
for (i, (name, warp, adj)) in cases.iter().enumerate() {
|
||||
let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0);
|
||||
let mut body = TextBody::from_text("WordArt");
|
||||
body.body.warp = Some((*warp).into());
|
||||
body.body.warp_adj = adj.clone();
|
||||
s.text = Some(body);
|
||||
shapes.push(s);
|
||||
}
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
|
||||
let q = round(&p);
|
||||
for (name, warp, adj) in &cases {
|
||||
let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body;
|
||||
assert_eq!(b.warp.as_deref(), Some(*warp), "{name}");
|
||||
assert_eq!(&b.warp_adj, adj, "{name}");
|
||||
}
|
||||
// The single-adjust preset is written with the guide name `adj`, two-adjust ones with
|
||||
// `adj1`/`adj2`, and a preset with no adjusts given gets an empty list.
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
|
||||
// A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are
|
||||
// read as numbers or dropped.
|
||||
let edited = xml
|
||||
.replace(r#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
|
||||
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
data = edited.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
std::io::Write::write_all(&mut out, &data).expect("write");
|
||||
}
|
||||
let bytes = out.finish().expect("finish").into_inner();
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body;
|
||||
assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]);
|
||||
let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body;
|
||||
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
|
||||
assert!(inflate.warp_adj.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fills and styles read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
|
||||
|
||||
/// A table style definition as PowerPoint writes one: red table background, see-through white
|
||||
/// bands, 2 pt blue outer borders and no inside lines.
|
||||
fn style_xml() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// An exported deck with a 3x2 table using the probe style.
|
||||
fn table_deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
|
||||
t.style = "medium2-accent1".into();
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
|
||||
kind: deckcraft_model::ShapeKind::Table(t),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Point the table at the probe style and replace the style part.
|
||||
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/tableStyles.xml" {
|
||||
d = style_xml().into_bytes();
|
||||
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
|
||||
let end = s.find("</a:tableStyleId>");
|
||||
d = match (start, end) {
|
||||
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
|
||||
_ => s.into_bytes(),
|
||||
};
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_styles_the_file_defines_are_read_used_and_kept() {
|
||||
use deckcraft_model::table::{TableBorder, TablePart};
|
||||
let p = deckcraft_pptx::import(&table_deck()).expect("import");
|
||||
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
|
||||
assert_eq!(def.name, "Probe Style");
|
||||
let bg = def.background.as_ref().expect("table background");
|
||||
assert!(matches!(bg.fill_ref, Some((1, _))));
|
||||
let whole = def.part(TablePart::WholeTbl).expect("whole table");
|
||||
assert_eq!(whole.bold, Some(true));
|
||||
assert!(whole.text_color.is_some());
|
||||
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
|
||||
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
|
||||
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
|
||||
// The table keeps the style's GUID, so it's drawn from the definition.
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
|
||||
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
|
||||
assert_eq!(t.style, STYLE_GUID);
|
||||
// Saving writes the definition back, unchanged.
|
||||
let again = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&again).expect("reimport");
|
||||
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
|
||||
assert_eq!(def2.parts, def.parts);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
deckcraft-geom = { workspace = true }
|
||||
deckcraft-color = { workspace = true }
|
||||
deckcraft-metafile = { workspace = true }
|
||||
deckcraft-model = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
deckcraft-text = { workspace = true }
|
||||
@@ -19,6 +20,7 @@ vello_common = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
|
||||
//! them: layers of glyph outlines filled with solid colours or gradients.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_fonts::FontFace;
|
||||
use kurbo::{Affine, BezPath, Point, Rect};
|
||||
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
/// False when the glyph has no colour version, so the caller draws the plain outline.
|
||||
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
|
||||
let Some(font) = face.skrifa() else { return false };
|
||||
let glyphs = font.color_glyphs();
|
||||
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
|
||||
let palette: Vec<Rgba> = font
|
||||
.color_palettes()
|
||||
.get(0)
|
||||
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
while let Some(open) = p.opened.pop() {
|
||||
if open {
|
||||
p.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
p.ctx.pop_layer();
|
||||
ok
|
||||
}
|
||||
|
||||
struct Painter<'a> {
|
||||
ctx: &'a mut RenderContext,
|
||||
face: &'a FontFace,
|
||||
base: Affine,
|
||||
/// Transforms pushed by the glyph (font units), innermost last.
|
||||
stack: Vec<Affine>,
|
||||
palette: Vec<Rgba>,
|
||||
fg: Rgba,
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
|
||||
}
|
||||
|
||||
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(p.x as f64, p.y as f64)
|
||||
}
|
||||
|
||||
fn extend(e: Extend) -> peniko::Extend {
|
||||
match e {
|
||||
Extend::Repeat => peniko::Extend::Repeat,
|
||||
Extend::Reflect => peniko::Extend::Reflect,
|
||||
_ => peniko::Extend::Pad,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(m: CompositeMode) -> peniko::BlendMode {
|
||||
use peniko::{Compose, Mix};
|
||||
let (mix, compose) = match m {
|
||||
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
|
||||
CompositeMode::Src => (Mix::Normal, Compose::Copy),
|
||||
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
|
||||
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
|
||||
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
|
||||
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
|
||||
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
|
||||
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
|
||||
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
|
||||
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
|
||||
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
|
||||
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
|
||||
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
|
||||
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
|
||||
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
|
||||
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
|
||||
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
|
||||
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
|
||||
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
|
||||
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
|
||||
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
|
||||
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
|
||||
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
|
||||
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
|
||||
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
|
||||
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
|
||||
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
|
||||
_ => (Mix::Normal, Compose::SrcOver),
|
||||
};
|
||||
peniko::BlendMode::new(mix, compose)
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn current(&self) -> Affine {
|
||||
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
|
||||
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
|
||||
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
|
||||
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
|
||||
}
|
||||
|
||||
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
|
||||
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
|
||||
}
|
||||
|
||||
/// The glyph outline in font units, y up.
|
||||
fn outline(&self, gid: GlyphId) -> BezPath {
|
||||
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
|
||||
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
|
||||
}
|
||||
|
||||
fn push_clip(&mut self, path: &BezPath) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.push_clip_layer(path);
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop(&mut self) {
|
||||
if self.opened.pop() == Some(true) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the open clip area (a large box in the current transform) with `paint`.
|
||||
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.set_paint(paint);
|
||||
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPainter for Painter<'_> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
|
||||
self.stack.push(next);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, gid: GlyphId) {
|
||||
let path = self.outline(gid);
|
||||
self.push_clip(&path);
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
self.cover(c);
|
||||
}
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.push_blend_layer(blend(mode));
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
|
||||
//!
|
||||
//! PowerPoint splits the line's width into bands across the path, measured from its left side
|
||||
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
|
||||
//! export of 24 pt lines of each kind):
|
||||
//! - double: 1/3 line, 1/3 gap, 1/3 line;
|
||||
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
|
||||
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
|
||||
//!
|
||||
//! On a shape drawn clockwise (all presets) the left side is the outside.
|
||||
|
||||
use deckcraft_model::style::Compound;
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
/// The bands of `c` as (from, to) shares of the width, left side first.
|
||||
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
|
||||
match c {
|
||||
Compound::Single => &[(0.0, 1.0)],
|
||||
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
|
||||
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
|
||||
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
|
||||
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
|
||||
fn right(t: Vec2) -> Option<Vec2> {
|
||||
let l = t.hypot();
|
||||
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
|
||||
}
|
||||
|
||||
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
|
||||
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
|
||||
/// bridged by a straight line.
|
||||
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
if d.abs() < 1e-9 {
|
||||
return path.clone();
|
||||
}
|
||||
// Each subpath as segments, offset one by one.
|
||||
let mut segs: Vec<PathSeg> = Vec::new();
|
||||
let mut closed = false;
|
||||
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
|
||||
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
|
||||
let begin = out.elements().len();
|
||||
for (i, (seg, piece)) in pieces.iter().enumerate() {
|
||||
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
|
||||
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
|
||||
None => out.move_to(p),
|
||||
Some((prev, _)) => join(out, *prev, *seg, p, d),
|
||||
}
|
||||
for e in piece.elements().iter().skip(1) {
|
||||
out.push(*e);
|
||||
}
|
||||
}
|
||||
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
|
||||
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
|
||||
// Lines meeting at the start: both ends move to their miter point.
|
||||
match miter(*last, *first, d) {
|
||||
Some(m) if is_line(*last) && is_line(*first) => {
|
||||
set_last(out, m);
|
||||
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
|
||||
out.elements_mut()[begin] = PathEl::MoveTo(m);
|
||||
}
|
||||
}
|
||||
_ => out.line_to(p),
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
segs.clear();
|
||||
};
|
||||
let mut start = Point::ZERO;
|
||||
let mut cur = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
start = p;
|
||||
cur = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
|
||||
cur = p;
|
||||
}
|
||||
PathEl::QuadTo(a, b) => {
|
||||
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
|
||||
cur = b;
|
||||
}
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
|
||||
cur = c;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if (cur - start).hypot() > 1e-9 {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
|
||||
}
|
||||
closed = true;
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
cur = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut segs, closed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
|
||||
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
|
||||
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
|
||||
let ok = match s {
|
||||
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
|
||||
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
|
||||
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
|
||||
};
|
||||
if !ok || !d.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match s {
|
||||
PathSeg::Line(l) => {
|
||||
let n = right(l.p1 - l.p0)?;
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(l.p0 + n * d);
|
||||
p.line_to(l.p1 + n * d);
|
||||
Some(p)
|
||||
}
|
||||
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
|
||||
PathSeg::Cubic(c) => {
|
||||
// Nearly straight cubics (collinear control points) are offset as lines.
|
||||
let chord = c.p3 - c.p0;
|
||||
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
|
||||
if flat(c.p1) && flat(c.p2) {
|
||||
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment's tangent at its start and at its end (the first non-zero control vector).
|
||||
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
|
||||
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
|
||||
match s {
|
||||
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
|
||||
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
|
||||
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line(s: PathSeg) -> bool {
|
||||
matches!(s, PathSeg::Line(_))
|
||||
}
|
||||
|
||||
/// Move the path's last point to `p`.
|
||||
fn set_last(out: &mut BezPath, p: Point) {
|
||||
if let Some(e) = out.elements_mut().last_mut() {
|
||||
match e {
|
||||
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
|
||||
PathEl::QuadTo(_, q) => *q = p,
|
||||
PathEl::CurveTo(_, _, q) => *q = p,
|
||||
PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
|
||||
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
|
||||
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
|
||||
let (_, t0) = tangents(prev);
|
||||
let (t1, _) = tangents(next);
|
||||
let (n0, n1) = (right(t0)?, right(t1)?);
|
||||
let denom = 1.0 + n0.dot(n1);
|
||||
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
|
||||
}
|
||||
|
||||
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
|
||||
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
|
||||
/// doesn't loop); anything else is bridged straight.
|
||||
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
|
||||
match miter(prev, next, d) {
|
||||
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
|
||||
Some(m) => {
|
||||
out.line_to(m);
|
||||
out.line_to(p);
|
||||
}
|
||||
None => out.line_to(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn bands_split_the_width_like_powerpoint() {
|
||||
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
|
||||
let b = bands(c);
|
||||
assert_eq!(b.first().map(|x| x.0), Some(0.0));
|
||||
assert_eq!(b.last().map(|x| x.1), Some(1.0));
|
||||
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
|
||||
}
|
||||
// Thin-thick: the thin line on the left, thick-thin its mirror.
|
||||
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
|
||||
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
|
||||
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_rectangle_grows_or_shrinks_it() {
|
||||
// Clockwise in y-down coordinates: the left of travel is the outside.
|
||||
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
|
||||
let out = offset(&r, -5.0, 0.01).bounding_box();
|
||||
assert!(
|
||||
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
|
||||
"{out:?}"
|
||||
);
|
||||
let inn = offset(&r, 5.0, 0.01).bounding_box();
|
||||
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
|
||||
// Corners meet at their miter points: the area is the offset rectangle's.
|
||||
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_circle_changes_its_radius() {
|
||||
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
|
||||
let b = offset(&c, 4.0, 0.01).bounding_box();
|
||||
let w = b.width() / 2.0;
|
||||
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
|
||||
// A hostile path never panics and yields finite output.
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((0.0, 0.0));
|
||||
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
|
||||
p.close_path();
|
||||
let _ = offset(&p, 3.0, 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The pasteboard: what the editor draws around the slide.
|
||||
//!
|
||||
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
|
||||
//! on the area around it, at full strength and with their effects (master and layout graphics
|
||||
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
|
||||
//!
|
||||
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
|
||||
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Line};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use kurbo::Rect;
|
||||
|
||||
use crate::MAX_SIDE;
|
||||
|
||||
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
|
||||
/// hostile files can't blow the pasteboard up.
|
||||
const MAX_SPILL: f64 = 2000.0;
|
||||
|
||||
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
|
||||
/// that is wider than the window re-renders only when a new tile comes into view.
|
||||
pub const TILE: i64 = 256;
|
||||
|
||||
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
|
||||
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
|
||||
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
|
||||
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
|
||||
let mut s: f64 = 0.0;
|
||||
if let Some(l) = line
|
||||
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
// Arrowheads reach up to a few line widths past the end point.
|
||||
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
|
||||
}
|
||||
if let Some(e) = effects {
|
||||
let big = fin(w).max(fin(h));
|
||||
if let Some(sh) = &e.outer_shadow {
|
||||
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
|
||||
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
s = s.max(fin(g.radius));
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
s = s.max(fin(h) + fin(r.dist));
|
||||
}
|
||||
}
|
||||
s.min(MAX_SPILL)
|
||||
}
|
||||
|
||||
/// Slide-space box a top-level shape draws into, its spill included.
|
||||
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
|
||||
if s.hidden {
|
||||
return None;
|
||||
}
|
||||
let x: Xfrm = resolve::xfrm(rctx, s);
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = match &s.kind {
|
||||
ShapeKind::Ink { strokes } => {
|
||||
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
|
||||
let (px, py, _) = pts.next()?;
|
||||
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
|
||||
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
|
||||
return Some(r.inflate(w, w));
|
||||
}
|
||||
_ => x.bounds(),
|
||||
};
|
||||
let (line, _) = resolve::line(rctx, s);
|
||||
let (fx, _) = resolve::effects(rctx, s);
|
||||
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
|
||||
Some(b.inflate(d, d))
|
||||
}
|
||||
|
||||
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
|
||||
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
|
||||
}
|
||||
|
||||
fn slide_rect(pres: &Presentation) -> Rect {
|
||||
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
|
||||
}
|
||||
|
||||
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
|
||||
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
|
||||
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
|
||||
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(slide) = pres.slides.get(index) else { return page };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
|
||||
let mut r = union_boxes(&rctx, &slide.shapes, page);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
|
||||
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
|
||||
let page = slide_rect(pres);
|
||||
let Some(m) = pres.masters.get(master) else { return page };
|
||||
match layout.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
|
||||
if l.show_master_shapes {
|
||||
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||
}
|
||||
r
|
||||
}
|
||||
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The slide itself, `w` × `h` pixels.
|
||||
pub fn slide(w: u32, h: u32) -> Self {
|
||||
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
|
||||
}
|
||||
|
||||
fn edges(&self) -> (i64, i64, i64, i64) {
|
||||
let (x, y) = (self.x as i64, self.y as i64);
|
||||
(x, y, x + self.w as i64, y + self.h as i64)
|
||||
}
|
||||
|
||||
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
|
||||
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
|
||||
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
|
||||
}
|
||||
|
||||
/// Does this region cover all of `other`?
|
||||
pub fn contains(&self, other: &Region) -> bool {
|
||||
let (a, b) = (self.edges(), other.edges());
|
||||
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
|
||||
}
|
||||
|
||||
/// Is the region more than the slide (some of the pasteboard is drawn)?
|
||||
pub fn beyond(&self, slide: (u32, u32)) -> bool {
|
||||
*self != Region::slide(slide.0, slide.1)
|
||||
}
|
||||
|
||||
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
|
||||
pub fn offset(&self) -> (f64, f64) {
|
||||
(-(self.x as f64), -(self.y as f64))
|
||||
}
|
||||
|
||||
/// The region's size, for render options' `size`.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.w, self.h)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
|
||||
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
|
||||
let lim = i32::MAX as f64 / 2.0;
|
||||
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
|
||||
Some((
|
||||
px((r.x0 * scale).floor())? as i64,
|
||||
px((r.y0 * scale).floor())? as i64,
|
||||
px((r.x1 * scale).ceil())? as i64,
|
||||
px((r.y1 * scale).ceil())? as i64,
|
||||
))
|
||||
}
|
||||
|
||||
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
|
||||
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
|
||||
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
|
||||
/// area is rounded out to whole [`TILE`]s.
|
||||
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
|
||||
let page = Region::slide(slide_px.0, slide_px.1);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return page;
|
||||
}
|
||||
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
|
||||
let (vx0, vy0, vx1, vy1) = visible.edges();
|
||||
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
|
||||
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
|
||||
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
return page;
|
||||
}
|
||||
let (px0, py0, px1, py1) = page.edges();
|
||||
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use deckcraft_model::Shape;
|
||||
|
||||
fn black() -> deckcraft_model::ColorRef {
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
|
||||
}
|
||||
|
||||
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
|
||||
let p = deck_with(100.0, 100.0, 50.0, 50.0);
|
||||
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_off_the_slide_grow_the_extent() {
|
||||
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
|
||||
let e = slide_extent(&p, 0);
|
||||
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
|
||||
assert_eq!(e.y0, 0.0);
|
||||
assert_eq!(e.x1, p.slide_size.width);
|
||||
// Missing slides are just the page.
|
||||
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
|
||||
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
|
||||
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
|
||||
assert_eq!(slide_extent(&p, 0), page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_count_towards_the_extent() {
|
||||
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
|
||||
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
|
||||
sh.effects = Some(Effects {
|
||||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||
color: black(),
|
||||
blur: 10.0,
|
||||
dist: 20.0,
|
||||
dir: 270.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(slide_extent(&p, 0).y0 <= -130.0);
|
||||
assert_eq!(
|
||||
spill(
|
||||
None,
|
||||
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
|
||||
1.0,
|
||||
1.0
|
||||
),
|
||||
MAX_SPILL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
|
||||
let mut p = Presentation::default();
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
|
||||
assert!(slide_extent(&p, 0).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
|
||||
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
|
||||
let slide = (960, 540);
|
||||
let page = Region::slide(960, 540);
|
||||
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
|
||||
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
|
||||
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
|
||||
// Off to the left, in view: the region reaches it.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
|
||||
assert!(r.beyond(slide) && r.contains(&page));
|
||||
assert_eq!(r.offset(), (80.0, 0.0));
|
||||
// Far off and out of view (the view rounds out to tiles): only what's visible.
|
||||
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -256);
|
||||
// Wholly out of view: the slide.
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
|
||||
// Scale applies; nonsense falls back to the slide.
|
||||
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||
assert_eq!(r.x, -40);
|
||||
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
|
||||
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
|
||||
// Huge extents are capped.
|
||||
let r =
|
||||
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
|
||||
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_within_a_tile_keeps_the_region() {
|
||||
let slide = (2000, 1125);
|
||||
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
|
||||
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
|
||||
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -79,14 +79,40 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||
if let Some(c) = a.clear_color {
|
||||
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
||||
}
|
||||
if let Some((d, l)) = duotone_rgb(a) {
|
||||
for c in [d, l] {
|
||||
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
|
||||
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
||||
/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white.
|
||||
fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> {
|
||||
use deckcraft_model::style::ColorBase;
|
||||
let (d, l) = a.duotone.as_ref()?;
|
||||
let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base {
|
||||
ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb,
|
||||
_ => or,
|
||||
};
|
||||
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at
|
||||
/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
decode_at(bytes, crate::metafile::DEFAULT_SIDE)
|
||||
}
|
||||
|
||||
/// [`decode`], with a metafile drawn with its longer side `side` px.
|
||||
fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
return None;
|
||||
}
|
||||
if crate::metafile::is_metafile(bytes) {
|
||||
return crate::metafile::rasterize(bytes, side);
|
||||
}
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 {
|
||||
@@ -114,13 +140,13 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
}) {
|
||||
return Some(pm);
|
||||
}
|
||||
let Some(mut img) = decode(bytes) else {
|
||||
let target = 1u32 << bucket;
|
||||
let Some(mut img) = decode_at(bytes, target) else {
|
||||
with(|c| {
|
||||
c.bad.insert(addr, bytes.clone());
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let target = 1u32 << bucket;
|
||||
let (w, h) = img.dimensions();
|
||||
if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) {
|
||||
let k = target as f64 / w.max(h) as f64;
|
||||
@@ -233,6 +259,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
|
||||
return;
|
||||
}
|
||||
let duo = duotone_rgb(a);
|
||||
let b = a.brightness.clamp(-1.0, 1.0) * 255.0;
|
||||
let c = a.contrast.clamp(-1.0, 1.0);
|
||||
let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c };
|
||||
@@ -252,6 +279,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
*v = y + (*v - y) * s;
|
||||
*v = (*v - 128.0) * cf + 128.0 + b;
|
||||
}
|
||||
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
||||
if let Some((d, l)) = duo {
|
||||
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
|
||||
rgb =
|
||||
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
||||
}
|
||||
for (i, v) in rgb.iter().enumerate() {
|
||||
if let Some(ch) = p.0.get_mut(i) {
|
||||
*ch = v.round().clamp(0.0, 255.0) as u8;
|
||||
@@ -266,6 +299,25 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duotone_maps_luminance_between_its_two_colours() {
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::ColorRef;
|
||||
let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize]));
|
||||
let adj =
|
||||
PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() };
|
||||
apply_adjust(&mut img, &adj);
|
||||
assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour");
|
||||
assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour");
|
||||
let mid = img.get_pixel(1, 0).0;
|
||||
assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}");
|
||||
assert_eq!(mid[3], 200, "alpha is kept");
|
||||
// Different duotones are cached separately.
|
||||
let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() };
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&other));
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
+377
-32
@@ -8,12 +8,22 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod extent;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
|
||||
mod pathgrad;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -31,6 +41,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::{RenderContext, Resources, peniko};
|
||||
|
||||
pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
@@ -221,11 +232,20 @@ pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||
drawn: Option<Rect>,
|
||||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||||
view: Affine,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +263,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
return;
|
||||
}
|
||||
let (e, _) = resolve::effects(&rctx, s);
|
||||
if e.is_some_and(|e| !e.is_empty()) {
|
||||
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
@@ -256,17 +276,55 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
|
||||
/// text styles it inherits)? Such text is blurred, which needs filters.
|
||||
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
use deckcraft_model::text::{ListStyle, RunProps};
|
||||
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
|
||||
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
|
||||
let Some(t) = &s.text else { return false };
|
||||
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|
||||
|| styled(&t.list_style)
|
||||
|| styled(&rctx.master.title_style)
|
||||
|| styled(&rctx.master.body_style)
|
||||
|| styled(&rctx.master.other_style)
|
||||
}
|
||||
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
|
||||
Ok(img) => img,
|
||||
render_slide_drawn(pres, index, opts).0
|
||||
}
|
||||
|
||||
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
|
||||
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
|
||||
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
|
||||
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
|
||||
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
|
||||
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
r.drawn = None;
|
||||
let img = r.slide(pres, slide, index, opts);
|
||||
(img, r.drawn.take())
|
||||
})
|
||||
})) {
|
||||
Ok((img, drawn)) => {
|
||||
let k = opts.scale;
|
||||
let to_slide =
|
||||
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
|
||||
let extent = match drawn {
|
||||
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
|
||||
_ => page,
|
||||
};
|
||||
(img, extent)
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("rendering slide {index} panicked; showing a blank slide");
|
||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||
let (w, h) = output_size(pres, opts);
|
||||
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
|
||||
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +504,14 @@ impl Renderer {
|
||||
(ctx, w, h, view)
|
||||
}
|
||||
|
||||
/// Note that `r` (local space, placed by `m`) was drawn.
|
||||
fn track(&mut self, m: Affine, r: Rect) {
|
||||
let b = m.transform_rect_bbox(r);
|
||||
if b.is_finite() {
|
||||
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
@@ -588,10 +654,52 @@ impl Renderer {
|
||||
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
ctx.set_transform(view);
|
||||
let path = r.to_path(0.1);
|
||||
// PowerPoint lays every background over white: a picture stretched into part of the
|
||||
// slide, or a see-through colour, leaves white around and under it.
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.fill_path(&path);
|
||||
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
|
||||
self.bg = Some((fill, ph, view));
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth == 0 {
|
||||
let outer = std::mem::replace(&mut self.view, parent);
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
self.view = outer;
|
||||
} else {
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
|
||||
/// one that takes the shape's outline into it. Inside a group they are the slide-level view and
|
||||
/// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
|
||||
/// never the line width; outside a group, `m` itself and no change to the outline.
|
||||
fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
|
||||
let view = self.view;
|
||||
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
|
||||
}
|
||||
|
||||
/// The size and transform a shape's text is set in: inside a group whose child coordinates
|
||||
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
|
||||
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
|
||||
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
|
||||
let (_, rel) = self.line_space(m, depth);
|
||||
let c = rel.as_coeffs();
|
||||
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
|
||||
let ok = |k: f64| k.is_finite() && k > 1e-9;
|
||||
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
|
||||
return (w, h, m);
|
||||
}
|
||||
let (tw, th) = (w * sx, h * sy);
|
||||
if !tw.is_finite() || !th.is_finite() {
|
||||
return (w, h, m);
|
||||
}
|
||||
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
}
|
||||
@@ -627,10 +735,21 @@ impl Renderer {
|
||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
|
||||
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
|
||||
ShapeKind::Table(t) => {
|
||||
// Rows grow with their text: what the cells cover, borders included.
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
|
||||
chart::draw(ctx, rctx, c, m, x.w, x.h)
|
||||
}
|
||||
ShapeKind::Ink { strokes } => {
|
||||
for sk in strokes {
|
||||
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
|
||||
for (px, py, _) in &sk.points {
|
||||
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
for (i, (px, py, _)) in sk.points.iter().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -639,20 +758,34 @@ impl Renderer {
|
||||
bp.line_to((*px, *py));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(parent);
|
||||
let (base, rel) = self.line_space(parent, depth);
|
||||
bp.apply_affine(rel);
|
||||
ctx.set_transform(base);
|
||||
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&bp);
|
||||
}
|
||||
}
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st),
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn geometry_shape(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
rctx: &Ctx,
|
||||
s: &Shape,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
st: &ShapeState,
|
||||
depth: usize,
|
||||
) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
@@ -661,6 +794,8 @@ impl Renderer {
|
||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||
let (line, line_ph) = resolve::line(rctx, s);
|
||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
|
||||
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
|
||||
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
||||
let outline = geo.outline();
|
||||
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
||||
@@ -668,7 +803,12 @@ impl Renderer {
|
||||
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
|
||||
// Effects below the shape.
|
||||
if let Some(e) = &effects {
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||||
let content = match (&s.kind, &fill) {
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
|
||||
}
|
||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||
if let Some(r) = soft {
|
||||
@@ -683,9 +823,16 @@ impl Renderer {
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(&outline);
|
||||
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
|
||||
paint::picture(ctx, f.pres, pf, bounds, m, t);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||||
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||||
ctx.set_transform(m);
|
||||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
@@ -710,7 +857,16 @@ impl Renderer {
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres);
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
@@ -719,26 +875,39 @@ 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 {
|
||||
// Feather: fade the shape's alpha towards the outline.
|
||||
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
|
||||
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
|
||||
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
|
||||
ctx.set_transform(m);
|
||||
ctx.push_layer(
|
||||
None,
|
||||
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
|
||||
None,
|
||||
None,
|
||||
Some(blur(r * 0.5 * scale_of(m))),
|
||||
Some(blur(r / 3.0 * scale_of(m))),
|
||||
);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
|
||||
let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6);
|
||||
ctx.fill_rect(&inset);
|
||||
ctx.fill_path(&outline);
|
||||
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
|
||||
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&outline);
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
@@ -750,11 +919,14 @@ impl Renderer {
|
||||
ctx.push_clip_layer(&outline);
|
||||
let c = rctx.color(&sh.color, fx_ph);
|
||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
|
||||
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
|
||||
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
|
||||
// blur radius.
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
|
||||
let mut hole = outline.clone();
|
||||
hole.apply_affine(Affine::translate(d));
|
||||
hole.apply_affine(Affine::translate(-d));
|
||||
ring.extend(hole.iter());
|
||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
ctx.fill_path(&ring);
|
||||
@@ -767,10 +939,29 @@ impl Renderer {
|
||||
if let Some(body) = &s.text
|
||||
&& !skip_text
|
||||
{
|
||||
// Text in a group is set at the shape's size on the slide, in points: a group's
|
||||
// child-coordinate scale sizes and places the box, never the type (#233's twin).
|
||||
let (tw, th, m) = self.text_space(m, depth, w, h);
|
||||
let text_geo;
|
||||
let geo = if (tw, th) == (w, h) {
|
||||
&geo
|
||||
} else {
|
||||
text_geo = shape_geometry(s, tw, th);
|
||||
&text_geo
|
||||
};
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
draw_layout_with(ctx, &l, tr, m, &st.paras);
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
let pad = b.height() * 0.3;
|
||||
self.track(tm, b.inflate(pad, pad));
|
||||
}
|
||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||
}
|
||||
} else if f.opts.edit && s.ph.is_some() {
|
||||
let kind = s.ph_type().unwrap_or(PhType::Body);
|
||||
let prompt_body = prompt_body(rctx, s, kind);
|
||||
@@ -801,6 +992,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -825,19 +1017,38 @@ impl Renderer {
|
||||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
||||
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
|
||||
// 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 {
|
||||
let c = rctx.color(&g.color, ph);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
|
||||
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
|
||||
// out from the outline and gone by r.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&silhouette);
|
||||
ctx.fill_path(&silhouette);
|
||||
ctx.pop_layer();
|
||||
@@ -907,6 +1118,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||
}
|
||||
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
||||
}
|
||||
// Compound lines: each band stroked along the path moved to its place across the width.
|
||||
let compound = line.compound.unwrap_or_default();
|
||||
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
|
||||
let tolerance = 0.1 / scale_of(m).max(1e-6);
|
||||
for &(a, b) in compound::bands(compound) {
|
||||
let d = (-0.5 + (a + b) / 2.0) * lw;
|
||||
let mut band = sk.clone();
|
||||
band.width = (b - a) * lw;
|
||||
ctx.set_stroke(band);
|
||||
ctx.stroke_path(&compound::offset(path, d, tolerance));
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
}
|
||||
@@ -939,9 +1163,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||
Some(((first, second), (last, prev)))
|
||||
}
|
||||
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, m: Affine, lw: f64) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
|
||||
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
|
||||
let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return };
|
||||
// The outline's ends in the space the arrowheads are sized and drawn in.
|
||||
let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
|
||||
let col = match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
|
||||
_ => Rgba::BLACK,
|
||||
@@ -1084,6 +1311,13 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
|
||||
t
|
||||
}
|
||||
|
||||
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
|
||||
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
|
||||
let bp = resolve::body(rctx, s);
|
||||
let name = bp.warp.as_deref()?;
|
||||
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
|
||||
}
|
||||
|
||||
/// Draw an already laid-out text block.
|
||||
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
|
||||
draw_layout_with(ctx, l, tr, m, &[]);
|
||||
@@ -1105,6 +1339,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
// Text shadows, then glows, under all the text.
|
||||
for pass in [TextEffect::Shadow, TextEffect::Glow] {
|
||||
for run in &l.runs {
|
||||
let ps = para(run.para);
|
||||
if ps.visible && ps.opacity > 0.001 {
|
||||
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in &l.runs {
|
||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
continue;
|
||||
@@ -1114,8 +1357,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
continue;
|
||||
}
|
||||
let m = m * Affine::translate(ps.offset);
|
||||
// A gradient fill starts over on every line and spans that line's text (from its top to
|
||||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
};
|
||||
let li = line_at(l, run.para, *y);
|
||||
if !grads.iter().any(|(i, _)| *i == li) {
|
||||
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
|
||||
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
|
||||
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
|
||||
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
|
||||
}
|
||||
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
|
||||
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
@@ -1123,20 +1382,106 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, li)| li.para == para)
|
||||
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The box a line's text covers: its top and bottom, its first and last caret.
|
||||
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
|
||||
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
let r = Rect::new(x0, li.top, x1, li.bottom);
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum TextEffect {
|
||||
Shadow,
|
||||
Glow,
|
||||
}
|
||||
|
||||
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
|
||||
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
|
||||
let Some(fx) = run.effects else { return };
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let px = scale_of(m);
|
||||
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
|
||||
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
|
||||
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
|
||||
// past the glyph's edge and fades out by about two thirds of r.
|
||||
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
|
||||
_ => return,
|
||||
};
|
||||
let alpha = (c.a as f64 / 255.0) * opacity;
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
|
||||
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
|
||||
draw_glyph_with(ctx, run, gid, at, opacity, None);
|
||||
}
|
||||
|
||||
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
|
||||
/// the glyph's origin to that space) instead of the run's colour.
|
||||
fn draw_glyph_with(
|
||||
ctx: &mut RenderContext,
|
||||
run: &deckcraft_text::GlyphRun,
|
||||
gid: u32,
|
||||
at: Affine,
|
||||
opacity: f64,
|
||||
fill: Option<(&peniko::Gradient, Affine)>,
|
||||
) {
|
||||
let alpha = run.alpha * opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
|
||||
// strokes the plain glyph below.
|
||||
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
|
||||
return;
|
||||
}
|
||||
let path = glyph(&run.face, gid);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
ctx.set_transform(at * skew * Affine::scale(k));
|
||||
if alpha > 0.0 && run.color.a > 0 {
|
||||
if let Some((g, to_text)) = fill
|
||||
&& alpha > 0.0
|
||||
{
|
||||
ctx.set_paint(g.clone());
|
||||
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
} else if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! 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_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 {
|
||||
deckcraft_metafile::is_metafile(bytes)
|
||||
}
|
||||
|
||||
/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where
|
||||
/// it draws nothing). `None` when it doesn't parse or has no size.
|
||||
pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
let mut pic = deckcraft_metafile::parse(bytes).ok()?;
|
||||
if !pic.has_size() {
|
||||
return None;
|
||||
}
|
||||
let side = side.clamp(16, MAX_SIDE) as f64;
|
||||
let (pw, ph) = (pic.width as f64, pic.height as f64);
|
||||
let k = side / pw.max(ph);
|
||||
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
|
||||
// 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));
|
||||
for it in pic.place(r, [0.0; 4]) {
|
||||
match it {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd } => {
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(rgba(f));
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
// GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel.
|
||||
if let Some((s, sw)) = stroke {
|
||||
ctx.set_paint(rgba(s));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
PlacedItem::Bitmap { index, rect } => {
|
||||
let Some(Some(pm)) = pixmaps.get(index) else { continue };
|
||||
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
|
||||
});
|
||||
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
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);
|
||||
ctx.pop_layer();
|
||||
ctx.flush();
|
||||
let mut px = vec![0u8; w as usize * h as usize * 4];
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) {
|
||||
ctx.render(pm, &mut Resources::new());
|
||||
}
|
||||
// Premultiplied to straight.
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let a = p[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in p.iter_mut().take(3) {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let data = px
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.take(w as usize * h as usize)
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
|
||||
}
|
||||
+55
-46
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_geom::preset::FillMode;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::{PhType, Presentation};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
@@ -35,10 +35,21 @@ pub fn fill_path(
|
||||
if g.stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut stops: Vec<(f32, peniko::Color)> =
|
||||
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
|
||||
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
|
||||
|
||||
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
|
||||
// Rectangle and outline paths: rasterised (see `pathgrad`).
|
||||
if let GradientShape::Path { path: kind, focus } = &g.shape
|
||||
&& matches!(kind.as_str(), "rect" | "shape")
|
||||
{
|
||||
crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha);
|
||||
return;
|
||||
}
|
||||
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
let grad = match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
@@ -65,60 +76,58 @@ pub fn fill_path(
|
||||
}
|
||||
Fill::Picture(pf) => {
|
||||
ctx.push_clip_layer(path);
|
||||
picture(ctx, pres, pf, bounds, m, None);
|
||||
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Fill::Pattern(p) => {
|
||||
let fg = rctx.color(&p.fg, ph);
|
||||
let bg = rctx.color(&p.bg, ph);
|
||||
ctx.set_paint(color(bg, alpha));
|
||||
ctx.fill_path(path);
|
||||
ctx.push_clip_layer(path);
|
||||
ctx.set_paint(color(fg, alpha));
|
||||
pattern(ctx, &p.preset, bounds, m);
|
||||
ctx.pop_layer();
|
||||
crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a pattern's foreground marks over `bounds` (8 pt cells).
|
||||
fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) {
|
||||
let k = crate::scale_of(m);
|
||||
let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 };
|
||||
let cell = cell.max(2.0);
|
||||
let mut p = BezPath::new();
|
||||
let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000));
|
||||
let lw = cell * 0.18;
|
||||
let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90");
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
let x = b.x0 + i as f64 * cell;
|
||||
let y = b.y0 + j as f64 * cell;
|
||||
let r = match preset {
|
||||
p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw),
|
||||
p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell),
|
||||
p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => {
|
||||
p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new());
|
||||
Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell))
|
||||
}
|
||||
_ => {
|
||||
let d = if dense { cell * 0.35 } else { cell * 0.2 };
|
||||
Rect::new(x, y, x + d, y + d)
|
||||
}
|
||||
};
|
||||
p.extend(r.to_path(0.1).iter());
|
||||
if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") {
|
||||
p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.fill_path(&p);
|
||||
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
|
||||
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
|
||||
/// cache can apply and key them without a colour scheme.
|
||||
pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::borrow::Cow<'a, PictureFill> {
|
||||
let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) };
|
||||
let mut out = pf.clone();
|
||||
out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph))));
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
Rect::new(x, y, x + w, y + h).to_path(0.1)
|
||||
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
|
||||
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
|
||||
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
|
||||
for s in &mut stops {
|
||||
s.0 = s.0.clamp(0.0, 1.0);
|
||||
}
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
match shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
let a = angle.to_radians();
|
||||
let (dx, dy) = (a.cos(), a.sin());
|
||||
let c = bounds.center();
|
||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
||||
.with_stops(stops.as_slice())
|
||||
}
|
||||
GradientShape::Path { focus, .. } => {
|
||||
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
|
||||
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
|
||||
// Reach the farthest corner, so a gradient from a corner covers the whole box.
|
||||
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
|
||||
.iter()
|
||||
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
|
||||
.fold(1e-6, f64::max);
|
||||
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||
/// GIF shows its frame at `gif_time` seconds.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello
|
||||
//! gradient draws: they're rasterised into a small image of the bounds and painted with it.
|
||||
//!
|
||||
//! Measured on PowerPoint's export (black to grey to white from the centre):
|
||||
//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the
|
||||
//! focus, each as a share of the distance to that side's edge (X-shaped contours);
|
||||
//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a
|
||||
//! triangle triangles): the share of the way from the focus to the outline along the same ray.
|
||||
//!
|
||||
//! `circle` stays a radial vello gradient (see `paint::gradient`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect};
|
||||
use vello_cpu::{Pixmap, RenderContext, peniko};
|
||||
|
||||
/// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest).
|
||||
const MAX_SIDE: f64 = 384.0;
|
||||
|
||||
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
|
||||
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
|
||||
/// shape's flattened polygon(s) for `shape`.
|
||||
pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>], p: Point) -> f64 {
|
||||
let f = Rect::new(
|
||||
bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0),
|
||||
bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0),
|
||||
bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0),
|
||||
bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0),
|
||||
);
|
||||
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) };
|
||||
match kind {
|
||||
"shape" if !outline.is_empty() => shape_position(outline, f.center(), p),
|
||||
_ => {
|
||||
// Distance outside the focus rect along each axis, over the room to that side's edge.
|
||||
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
|
||||
if v < lo {
|
||||
(lo - v) / (lo - edge_lo).max(1e-9)
|
||||
} else if v > hi {
|
||||
(v - hi) / (edge_hi - hi).max(1e-9)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets
|
||||
/// no edge).
|
||||
fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||
let d = p - c;
|
||||
let len = d.hypot();
|
||||
if len < 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
// Nearest crossing of the ray c + s·d (s > 0) with an edge.
|
||||
let mut best = f64::INFINITY;
|
||||
for poly in outline {
|
||||
for w in poly.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let e = b - a;
|
||||
let den = d.cross(e);
|
||||
if den.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let ac = a - c;
|
||||
let s = ac.cross(e) / den;
|
||||
let u = ac.cross(d) / den;
|
||||
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
|
||||
best = best.min(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The outline flattened into closed polygons.
|
||||
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = Vec::new();
|
||||
let mut cur: Vec<Point> = Vec::new();
|
||||
kurbo::flatten(path.iter(), 0.25, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if cur.len() > 1 {
|
||||
out.push(cur);
|
||||
}
|
||||
for poly in &mut out {
|
||||
if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied())
|
||||
&& (f - l).hypot() > 1e-9
|
||||
{
|
||||
poly.push(f);
|
||||
}
|
||||
}
|
||||
out.retain(|p| p.len() > 2 && p.len() < 100_000);
|
||||
out
|
||||
}
|
||||
|
||||
/// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them).
|
||||
pub fn ramp(stops: &[(f64, Rgba)]) -> Vec<Rgba> {
|
||||
(0..256)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 255.0;
|
||||
let after = stops.iter().position(|s| s.0 >= t);
|
||||
match after {
|
||||
Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK),
|
||||
Some(j) => {
|
||||
let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK };
|
||||
let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
|
||||
a.1.lerp(b.1, k)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see
|
||||
/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) {
|
||||
if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let k = crate::scale_of(m);
|
||||
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
|
||||
let longest = bounds.width().max(bounds.height()) * k;
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
|
||||
let outline = if kind == "shape" { polygons(path) } else { Vec::new() };
|
||||
let lut = ramp(stops);
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
let mut data = Vec::with_capacity((pw * ph) as usize);
|
||||
for j in 0..ph {
|
||||
for i in 0..pw {
|
||||
let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height());
|
||||
let t = position(kind, bounds, focus, &outline, p);
|
||||
let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK);
|
||||
let ca = (c.a as f64 * a).round().clamp(0.0, 255.0);
|
||||
let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 });
|
||||
}
|
||||
}
|
||||
let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() },
|
||||
});
|
||||
ctx.set_paint_transform(
|
||||
Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64),
|
||||
);
|
||||
ctx.fill_path(path);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5];
|
||||
|
||||
#[test]
|
||||
fn rect_paths_take_the_larger_axis_share() {
|
||||
// PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5.
|
||||
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||||
let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y));
|
||||
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9);
|
||||
assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9);
|
||||
assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share");
|
||||
assert_eq!(at(0.0, 0.0), 1.0);
|
||||
// From a corner (fillToRect 0,0,100%,100%): the far corner is the end.
|
||||
let corner = [0.0, 0.0, 1.0, 1.0];
|
||||
assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_paths_follow_the_outline() {
|
||||
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||||
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
|
||||
let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y));
|
||||
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01);
|
||||
assert!((at(140.0, 120.0) - 0.2).abs() < 0.01);
|
||||
// Half way to the outline along any ray is 0.5.
|
||||
let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8);
|
||||
assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01);
|
||||
// A triangle: half way from its centre to its apex is 0.5.
|
||||
let mut tri = BezPath::new();
|
||||
tri.move_to((140.0, 0.0));
|
||||
tri.line_to((280.0, 200.0));
|
||||
tri.line_to((0.0, 200.0));
|
||||
tri.close_path();
|
||||
let polys = polygons(&tri);
|
||||
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ramp_runs_through_the_stops() {
|
||||
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||
let r = ramp(&stops);
|
||||
assert_eq!(r.len(), 256);
|
||||
assert_eq!(r[0], Rgba::rgb(0, 0, 0));
|
||||
assert_eq!(r[255], Rgba::rgb(255, 255, 255));
|
||||
assert!((r[64].r as i32 - 64).abs() <= 2);
|
||||
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
|
||||
//! (0.75 pt) of the slide, as PowerPoint draws them.
|
||||
//!
|
||||
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
|
||||
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
|
||||
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use kurbo::{Affine, BezPath};
|
||||
use vello_cpu::{Pixmap, RenderContext, peniko};
|
||||
|
||||
/// Side of one pattern cell, in points.
|
||||
pub const CELL: f64 = 0.75;
|
||||
|
||||
const TILES: &[(&str, [u8; 8])] = &[
|
||||
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
|
||||
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
|
||||
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
|
||||
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
|
||||
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
|
||||
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
|
||||
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
|
||||
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
|
||||
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
|
||||
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
|
||||
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
|
||||
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
|
||||
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
|
||||
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
|
||||
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
|
||||
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
|
||||
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
|
||||
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
|
||||
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
|
||||
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
|
||||
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
|
||||
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
|
||||
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
|
||||
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
|
||||
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
|
||||
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
|
||||
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
|
||||
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
|
||||
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
|
||||
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
|
||||
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
|
||||
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
|
||||
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
|
||||
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
|
||||
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
|
||||
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
|
||||
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
|
||||
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
|
||||
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
|
||||
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
|
||||
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
|
||||
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
|
||||
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
|
||||
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
|
||||
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
|
||||
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
|
||||
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
|
||||
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
|
||||
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
|
||||
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
|
||||
];
|
||||
|
||||
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
|
||||
pub fn tile(name: &str) -> [u8; 8] {
|
||||
TILES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
|
||||
.map(|(_, t)| *t)
|
||||
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
|
||||
}
|
||||
|
||||
/// Is cell (`x`, `y`) of the tile the foreground?
|
||||
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
|
||||
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
|
||||
}
|
||||
|
||||
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
|
||||
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
|
||||
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
|
||||
}
|
||||
|
||||
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
|
||||
/// `alpha`.
|
||||
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
|
||||
let det = m.determinant();
|
||||
if !det.is_finite() || det.abs() <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let t = tile(name);
|
||||
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
|
||||
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
|
||||
ctx.set_transform(m);
|
||||
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,
|
||||
},
|
||||
});
|
||||
// Cells are laid out on the output from its origin (the slide's top-left corner), so
|
||||
// neighbouring shapes' patterns line up, as in PowerPoint.
|
||||
let k = crate::scale_of(m);
|
||||
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
|
||||
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
|
||||
ctx.fill_path(path);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_drawingml_preset_has_a_tile() {
|
||||
// ECMA-376 ST_PresetPatternVal.
|
||||
let all = [
|
||||
"pct5",
|
||||
"pct10",
|
||||
"pct20",
|
||||
"pct25",
|
||||
"pct30",
|
||||
"pct40",
|
||||
"pct50",
|
||||
"pct60",
|
||||
"pct70",
|
||||
"pct75",
|
||||
"pct80",
|
||||
"pct90",
|
||||
"horz",
|
||||
"vert",
|
||||
"ltHorz",
|
||||
"ltVert",
|
||||
"dkHorz",
|
||||
"dkVert",
|
||||
"narHorz",
|
||||
"narVert",
|
||||
"dashHorz",
|
||||
"dashVert",
|
||||
"cross",
|
||||
"dnDiag",
|
||||
"upDiag",
|
||||
"ltDnDiag",
|
||||
"ltUpDiag",
|
||||
"dkDnDiag",
|
||||
"dkUpDiag",
|
||||
"wdDnDiag",
|
||||
"wdUpDiag",
|
||||
"dashDnDiag",
|
||||
"dashUpDiag",
|
||||
"diagCross",
|
||||
"smCheck",
|
||||
"lgCheck",
|
||||
"smGrid",
|
||||
"lgGrid",
|
||||
"dotGrid",
|
||||
"smConfetti",
|
||||
"lgConfetti",
|
||||
"horzBrick",
|
||||
"diagBrick",
|
||||
"solidDmnd",
|
||||
"openDmnd",
|
||||
"dotDmnd",
|
||||
"plaid",
|
||||
"sphere",
|
||||
"weave",
|
||||
"divot",
|
||||
"shingle",
|
||||
"wave",
|
||||
"trellis",
|
||||
"zigZag",
|
||||
];
|
||||
assert_eq!(all.len(), 54);
|
||||
for n in all {
|
||||
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
|
||||
}
|
||||
assert_eq!(TILES.len(), 54);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_presets_get_denser() {
|
||||
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
|
||||
// half, pct90 all but 2).
|
||||
let share = |n: &str| {
|
||||
let t = tile(n);
|
||||
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
|
||||
};
|
||||
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
|
||||
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
|
||||
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
|
||||
assert_eq!(tile("nonsense"), tile("pct50"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Gradient colour ramps as PowerPoint draws them.
|
||||
//!
|
||||
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
|
||||
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
|
||||
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
|
||||
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
|
||||
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
|
||||
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
|
||||
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
|
||||
//! pieces whose colours follow PowerPoint's ramp.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
|
||||
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
|
||||
const WEIGHT: [f64; 17] =
|
||||
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
|
||||
|
||||
/// Pieces each segment between two stops is cut into.
|
||||
const PIECES: usize = 16;
|
||||
|
||||
/// The next stop's share at `t` (0..1) between two stops.
|
||||
pub fn weight(t: f64) -> f64 {
|
||||
if !t.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
|
||||
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
|
||||
let f = x - i as f64;
|
||||
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
fn to_linear(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_srgb(v: f64) -> u8 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
(s * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
|
||||
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let w = weight(t);
|
||||
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
|
||||
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
|
||||
}
|
||||
|
||||
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
|
||||
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
|
||||
/// anything else unchanged.
|
||||
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
|
||||
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
|
||||
if !two_colour {
|
||||
return stops.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
|
||||
for (i, &(p, c)) in stops.iter().enumerate() {
|
||||
out.push((p, c));
|
||||
let Some(&(q, d)) = stops.get(i + 1) else { continue };
|
||||
if q - p <= 1e-6 || c == d {
|
||||
continue;
|
||||
}
|
||||
for k in 1..PIECES {
|
||||
let t = k as f64 / PIECES as f64;
|
||||
out.push((p + (q - p) * t, mix(c, d, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn black_to_white_follows_powerpoint() {
|
||||
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
|
||||
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
|
||||
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
|
||||
let got = mix(k, w, t).r as i32;
|
||||
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_pairs_follow_powerpoint() {
|
||||
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
|
||||
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
|
||||
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
|
||||
// Red to blue a quarter of the way: (238, 0, 105).
|
||||
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
|
||||
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
|
||||
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
|
||||
assert_eq!(mix(a, b, 0.0), a);
|
||||
assert_eq!(mix(a, b, 1.0), b);
|
||||
for k in 0..=32 {
|
||||
let t = k as f64 / 32.0;
|
||||
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
|
||||
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
|
||||
}
|
||||
assert_eq!(weight(f64::NAN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_keeps_stops_and_hard_edges() {
|
||||
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
|
||||
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
|
||||
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
|
||||
// Two equal colours add nothing.
|
||||
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
|
||||
let e = expand(&[(0.0, r), (1.0, g)]);
|
||||
assert_eq!(e.len(), PIECES + 1);
|
||||
assert_eq!(e.first(), Some(&(0.0, r)));
|
||||
assert_eq!(e.last(), Some(&(1.0, g)));
|
||||
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
|
||||
}
|
||||
}
|
||||
+163
-12
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
use deckcraft_model::text::{Anchor, TextBody, TextDir};
|
||||
use deckcraft_model::{Presentation, Shape};
|
||||
use deckcraft_text::Fields;
|
||||
@@ -18,6 +18,12 @@ pub struct CellLook {
|
||||
pub text: Option<Rgba>,
|
||||
pub bold: bool,
|
||||
pub border: Option<(Rgba, f64)>,
|
||||
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
|
||||
/// means no border); `None` falls back to `border`.
|
||||
pub sides: [Option<(Rgba, f64)>; 4],
|
||||
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
|
||||
/// placeholder colour; drawn instead of `fill`.
|
||||
pub paint: Option<(Fill, Option<Rgba>)>,
|
||||
}
|
||||
|
||||
fn accent(ctx: &Ctx, style: &str) -> Rgba {
|
||||
@@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba {
|
||||
ctx.color(&ColorRef::scheme(slot), None)
|
||||
}
|
||||
|
||||
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
|
||||
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
|
||||
/// fill, text and the borders on its region's edges (its inside lines within it).
|
||||
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let (rows, cols) = (t.rows.len(), t.cols.len());
|
||||
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
|
||||
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
|
||||
let last_r = rows.saturating_sub(1);
|
||||
let last_c = cols.saturating_sub(1);
|
||||
let header = t.first_row && r == 0;
|
||||
let total = t.last_row && r1 == last_r && rows > 1;
|
||||
let first_col = t.first_col && c == 0;
|
||||
let last_col = t.last_col && c1 == last_c && cols > 1;
|
||||
let body_r = r.saturating_sub(usize::from(t.first_row));
|
||||
let body_c = c.saturating_sub(usize::from(t.first_col));
|
||||
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
|
||||
for part in TablePart::ALL {
|
||||
// (applies, region rows, region cols)
|
||||
let (on, region) = match part {
|
||||
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
|
||||
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
|
||||
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
|
||||
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
|
||||
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
|
||||
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
|
||||
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
|
||||
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
|
||||
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
|
||||
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
|
||||
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
|
||||
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
|
||||
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
|
||||
};
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let Some(ps) = def.part(part) else { continue };
|
||||
if let Some(p) = style_fill(ctx, ps) {
|
||||
look.fill = fill_color(ctx, &p.0, p.1);
|
||||
look.paint = Some(p);
|
||||
}
|
||||
if let Some(tc) = &ps.text_color {
|
||||
look.text = Some(ctx.color(tc, None));
|
||||
}
|
||||
if let Some(b) = ps.bold {
|
||||
look.bold = b;
|
||||
}
|
||||
let (r0, rr1, c0, cc1) = region;
|
||||
// Left, right, top, bottom of the cell: the part's edge or its inside line.
|
||||
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
|
||||
for (side, i) in pick.iter().enumerate() {
|
||||
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
|
||||
*slot = Some(border_look(ctx, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
look
|
||||
}
|
||||
|
||||
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
|
||||
/// the placeholder colour.
|
||||
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
|
||||
if let Some((idx, col)) = &ps.fill_ref {
|
||||
let ph = ctx.color(col, None);
|
||||
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
|
||||
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
|
||||
_ if *idx == 0 => (Fill::None, None),
|
||||
_ => (Fill::solid(ColorRef::rgb(ph)), None),
|
||||
});
|
||||
}
|
||||
ps.fill.clone().map(|f| (f, None))
|
||||
}
|
||||
|
||||
/// A fill's colour for a table cell (gradients and pictures by their first colour).
|
||||
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
|
||||
match f {
|
||||
Fill::None => Some(Rgba::TRANSPARENT),
|
||||
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
|
||||
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
|
||||
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A style border's colour and width (a transparent colour for no line).
|
||||
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
|
||||
match b {
|
||||
TableBorder::Line { line } => match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
|
||||
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
|
||||
_ => (Rgba::TRANSPARENT, 0.0),
|
||||
},
|
||||
TableBorder::Ref { idx, color } => {
|
||||
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
|
||||
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
|
||||
return look_def(ctx, def, t, r, c);
|
||||
}
|
||||
let a = accent(ctx, &t.style);
|
||||
let kind = t.style.split('-').next().unwrap_or("medium2");
|
||||
let header = t.first_row && r == 0;
|
||||
@@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let white = Rgba::WHITE;
|
||||
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
match kind {
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => CellLook {
|
||||
fill: if band || band_c { Some(tint(20000)) } else { None },
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"light2" => CellLook {
|
||||
fill: if header { Some(a) } else { None },
|
||||
text: if header { Some(white) } else { None },
|
||||
bold: header || total || first_col,
|
||||
border: Some((a, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"dark1" => CellLook {
|
||||
fill: Some(if header {
|
||||
@@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
text: Some(white),
|
||||
bold: header || total || first_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"medium4" => CellLook {
|
||||
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
_ => CellLook {
|
||||
// medium2: strong header, banded light tints, white grid.
|
||||
@@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
text: if header || total || first_col || last_col { Some(white) } else { None },
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -179,9 +297,36 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
ctx: &mut RenderContext,
|
||||
pres: &Presentation,
|
||||
rctx: &Ctx,
|
||||
t: &Table,
|
||||
m: Affine,
|
||||
_w: f64,
|
||||
_h: f64,
|
||||
fields: &dyn deckcraft_text::Fields,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
.table_styles
|
||||
.iter()
|
||||
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
.and_then(|d| d.background.as_ref())
|
||||
.and_then(|b| style_fill(rctx, b));
|
||||
let own_bg = t.background.clone().map(|f| (f, None));
|
||||
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
|
||||
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
|
||||
ctx.set_transform(m);
|
||||
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
}
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
@@ -190,12 +335,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
Some(f) => {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
||||
}
|
||||
None => {
|
||||
if let Some(fc) = lk.fill {
|
||||
ctx.set_paint(color(fc, 1.0));
|
||||
ctx.fill_rect(rect);
|
||||
None => match &lk.paint {
|
||||
Some((f, ph)) => {
|
||||
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
|
||||
ctx.set_paint(color(fc, 1.0));
|
||||
ctx.fill_rect(rect);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Borders: explicit cell borders, else the style's grid.
|
||||
@@ -216,9 +366,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
Some(Fill::None) => continue,
|
||||
_ => continue,
|
||||
},
|
||||
None => match lk.border {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
|
||||
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
let mut p = BezPath::new();
|
||||
@@ -241,4 +391,5 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
+1063
-3
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]).
|
||||
//!
|
||||
//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point
|
||||
//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and
|
||||
//! highlights are warped the same way.
|
||||
|
||||
use deckcraft_geom::warp::{Warp, WarpLine};
|
||||
use deckcraft_text::{GlyphRun, TextLayout};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
use crate::{ParaState, color, glyph, scale_of, text_transform};
|
||||
|
||||
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
|
||||
const MAX_POINTS: usize = 40_000;
|
||||
|
||||
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
|
||||
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
|
||||
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
|
||||
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
|
||||
if best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
best.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Where a line meets a follow guide, above its baseline: 3/8 of the way up the font's
|
||||
/// ascent + descent box (measured on Arial, Times New Roman and Courier New in PowerPoint).
|
||||
fn ref_offset(run: &GlyphRun) -> f64 {
|
||||
let f = &run.face;
|
||||
let k = run.size / f.upem.max(1.0);
|
||||
((0.375 * (f.ascent + f.descent) - f.descent) * k).clamp(-run.size, run.size)
|
||||
}
|
||||
|
||||
/// `path` flattened with tolerance `tol`, cut into pieces of at most `seg` and mapped by `f`.
|
||||
fn warp_path(path: &BezPath, tol: f64, seg: f64, f: &dyn Fn(Point) -> Point) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut last: Option<Point> = None;
|
||||
let mut start: Option<Point> = None;
|
||||
let mut budget = MAX_POINTS;
|
||||
let segment = |out: &mut BezPath, a: Point, b: Point, budget: &mut usize| {
|
||||
let n = ((b - a).hypot() / seg).ceil();
|
||||
let n = if n.is_finite() { (n as usize).clamp(1, 512) } else { 1 };
|
||||
for i in 1..=n {
|
||||
if *budget == 0 {
|
||||
return;
|
||||
}
|
||||
*budget -= 1;
|
||||
out.line_to(f(a.lerp(b, i as f64 / n as f64)));
|
||||
}
|
||||
};
|
||||
kurbo::flatten(path.iter(), tol, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if let (Some(a), Some(s)) = (last, start)
|
||||
&& (a - s).hypot() > 1e-9
|
||||
{
|
||||
segment(&mut out, a, s, &mut budget);
|
||||
}
|
||||
out.move_to(f(p));
|
||||
last = Some(p);
|
||||
start = Some(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
if let Some(a) = last {
|
||||
segment(&mut out, a, p, &mut budget);
|
||||
}
|
||||
last = Some(p);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let (Some(a), Some(s)) = (last, start) {
|
||||
segment(&mut out, a, s, &mut budget);
|
||||
}
|
||||
out.close_path();
|
||||
last = start;
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
struct Item<'a> {
|
||||
run: &'a GlyphRun,
|
||||
path: BezPath,
|
||||
line: usize,
|
||||
state: ParaState,
|
||||
}
|
||||
|
||||
/// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`.
|
||||
pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) {
|
||||
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
|
||||
let m = text_transform(m, tr, l.rotation);
|
||||
let n = l.lines.len();
|
||||
let mut ink: Vec<Option<Rect>> = vec![None; n];
|
||||
let mut off: Vec<Option<f64>> = vec![None; n];
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
for run in &l.runs {
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let Some(li) = line_of(l, run.para, *y) else { continue };
|
||||
if let Some(o) = off.get_mut(li) {
|
||||
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
|
||||
}
|
||||
let outline = glyph(&run.face, *gid);
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut path = (*outline).clone();
|
||||
path.apply_affine(Affine::translate((*x, *y)) * skew * Affine::scale(k));
|
||||
let b = path.bounding_box();
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = ink.get_mut(li) {
|
||||
*slot = Some(slot.map_or(b, |r| r.union(b)));
|
||||
}
|
||||
items.push(Item { run, path, line: li, state: para(run.para) });
|
||||
}
|
||||
}
|
||||
let lines: Vec<WarpLine> = l
|
||||
.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, li)| {
|
||||
let o = off.get(i).copied().flatten().unwrap_or(0.18 * (li.bottom - li.top));
|
||||
WarpLine { ink: ink.get(i).copied().flatten(), extent: li.extent, ref_y: li.baseline - o }
|
||||
})
|
||||
.collect();
|
||||
let mapper = warp.mapper(&lines, l.inner);
|
||||
let px = scale_of(m).max(1e-6) * mapper.zoom();
|
||||
let tol = (0.1 / px).clamp(1e-4, 2.0);
|
||||
let seg = (2.0 / px).clamp(1e-3, 50.0);
|
||||
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
|
||||
let ps = para(d.para);
|
||||
if !ps.visible || ps.opacity <= 0.001 {
|
||||
return;
|
||||
}
|
||||
let y = (d.rect.y0 + d.rect.y1) / 2.0;
|
||||
let Some(li) = line_of(l, d.para, y) else { return };
|
||||
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
ctx.fill_path(&path);
|
||||
};
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
for it in &items {
|
||||
let (run, ps) = (it.run, it.state);
|
||||
if !ps.visible || ps.opacity <= 0.001 {
|
||||
continue;
|
||||
}
|
||||
let alpha = run.alpha * ps.opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
continue;
|
||||
}
|
||||
let path = warp_path(&it.path, tol, seg, &|p| mapper.map(it.line, p));
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.size * 0.025).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
if let Some((oc, ow)) = run.outline {
|
||||
ctx.set_paint(color(oc, ps.opacity));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn warp_path_subdivides_and_maps_every_point() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((10.0, 0.0));
|
||||
p.line_to((10.0, 10.0));
|
||||
p.close_path();
|
||||
let out = warp_path(&p, 0.01, 1.0, &|q| Point::new(q.x, q.y + q.x * q.x * 0.01));
|
||||
// 10 + 10 + ~15 pieces of at most 1 pt.
|
||||
let n = out.elements().len();
|
||||
assert!(n > 30, "{n}");
|
||||
// The first edge is bent onto the parabola.
|
||||
let mid = out.elements().iter().find_map(|e| if let PathEl::LineTo(q) = e { (q.x == 5.0).then_some(*q) } else { None });
|
||||
assert_eq!(mid, Some(Point::new(5.0, 0.25)));
|
||||
// A huge path with a tiny segment length stays bounded.
|
||||
let mut big = BezPath::new();
|
||||
big.move_to((0.0, 0.0));
|
||||
for i in 0..1000 {
|
||||
big.line_to((1e6 * (i % 2) as f64, i as f64));
|
||||
}
|
||||
let out = warp_path(&big, 0.01, 1e-3, &|q| q);
|
||||
assert!(out.elements().len() <= MAX_POINTS + 2);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+51
@@ -0,0 +1,51 @@
|
||||
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
|
||||
|
||||
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
|
||||
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
|
||||
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
|
||||
without colour support.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.colorLib.builder import buildCOLR, buildCPAL
|
||||
|
||||
|
||||
def square(x0, y0, x1, y1):
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((x0, y0))
|
||||
pen.lineTo((x0, y1))
|
||||
pen.lineTo((x1, y1))
|
||||
pen.lineTo((x1, y0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def empty():
|
||||
return TTGlyphPen(None).glyph()
|
||||
|
||||
|
||||
order = [".notdef", "space", "redblue", "ink", "big", "small"]
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder(order)
|
||||
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
|
||||
fb.setupGlyf({
|
||||
".notdef": empty(),
|
||||
"space": empty(),
|
||||
"redblue": square(0, 0, 1000, 1000),
|
||||
"ink": square(0, 0, 1000, 1000),
|
||||
"big": square(0, 0, 1000, 1000),
|
||||
"small": square(300, 300, 700, 700),
|
||||
})
|
||||
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
|
||||
fb.setupHorizontalHeader(ascent=1000, descent=0)
|
||||
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
|
||||
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
|
||||
fb.setupPost()
|
||||
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
|
||||
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
|
||||
fb.save(sys.argv[1])
|
||||
print("saved", sys.argv[1])
|
||||
+338
-25
@@ -57,6 +57,25 @@ pub struct GlyphRun {
|
||||
/// Alpha of the fill (0–1).
|
||||
pub alpha: f64,
|
||||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
||||
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
|
||||
pub effects: Option<RunEffects>,
|
||||
}
|
||||
|
||||
/// A text run's shadow and glow, colours resolved.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RunEffects {
|
||||
pub shadow: Option<TextShadow>,
|
||||
/// Glow colour (with its alpha) and radius in points.
|
||||
pub glow: Option<(Rgba, f64)>,
|
||||
}
|
||||
|
||||
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextShadow {
|
||||
pub color: Rgba,
|
||||
pub blur: f64,
|
||||
pub dx: f64,
|
||||
pub dy: f64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GlyphRun {
|
||||
@@ -174,6 +193,7 @@ struct Style {
|
||||
outline: Option<(Rgba, f64)>,
|
||||
link: bool,
|
||||
gradient: Option<deckcraft_model::style::Gradient>,
|
||||
effects: Option<RunEffects>,
|
||||
/// "Regular", "Bold"… for the slot and fallback faces.
|
||||
style_name: &'static str,
|
||||
/// BCP 47 language (`a:rPr/@lang`), for shaping.
|
||||
@@ -183,6 +203,8 @@ struct Style {
|
||||
cs: Option<String>,
|
||||
/// Heading font (`+mj-…`): the theme's major font set applies.
|
||||
major: bool,
|
||||
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
|
||||
kern: bool,
|
||||
}
|
||||
|
||||
/// One character of a paragraph after shaping.
|
||||
@@ -239,13 +261,20 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
let family = resolve::font_family(ctx, r);
|
||||
let bold = r.bold.unwrap_or(false);
|
||||
let italic = r.italic.unwrap_or(false);
|
||||
let face = FontDb::global().face(&family, style_name(bold, italic));
|
||||
let face = FontDb::global().document_face(&family, style_name(bold, italic));
|
||||
let fake_bold = bold && face.weight < 550.0;
|
||||
let fake_italic = italic && !face.italic;
|
||||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||||
let (mut color, alpha, gradient) = match &r.fill {
|
||||
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
|
||||
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
|
||||
let mut g = g.clone();
|
||||
for s in &mut g.stops {
|
||||
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
|
||||
}
|
||||
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
|
||||
}
|
||||
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
|
||||
_ => (resolve::text_color(ctx, r), 1.0, None),
|
||||
};
|
||||
@@ -281,11 +310,13 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
outline,
|
||||
link: r.link.is_some(),
|
||||
gradient,
|
||||
effects: run_effects(ctx, r, scale),
|
||||
style_name: style_name(bold, italic),
|
||||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||||
ea,
|
||||
cs,
|
||||
major,
|
||||
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +495,15 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
@@ -476,8 +516,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -485,8 +527,27 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
|
||||
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
||||
/// and other symbols missing from the text font), by the usual fallback.
|
||||
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
|
||||
let chars: Vec<char> = label.chars().collect();
|
||||
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
|
||||
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
|
||||
/// script, face and shaping style, each shaped with the whole paragraph as context.
|
||||
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
|
||||
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
|
||||
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
|
||||
let a = s.dir.to_radians();
|
||||
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
|
||||
});
|
||||
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
|
||||
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
|
||||
}
|
||||
|
||||
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
|
||||
let db = FontDb::global();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
@@ -524,7 +585,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
|
||||
// Spaces, digits and punctuation stay in the face of the text before them when it can
|
||||
// draw them, so a phrase isn't cut into items at every space.
|
||||
let neutral = cluster.iter().all(|c| script::script_of(*c).is_none());
|
||||
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
|
||||
let neutral =
|
||||
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
|
||||
let face = match prev.as_ref() {
|
||||
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
_ => {
|
||||
@@ -553,7 +616,8 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
|
||||
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
|
||||
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
|
||||
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
|
||||
let mut a = 0;
|
||||
while a < n {
|
||||
let mut b = a + 1;
|
||||
@@ -566,6 +630,27 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
cells
|
||||
}
|
||||
|
||||
/// Size of superscript and subscript text relative to the run's size.
|
||||
const SUB_SUPER: f64 = 0.66;
|
||||
|
||||
/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
|
||||
/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
|
||||
/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
|
||||
/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
|
||||
fn shaping_features(cells: &[Cell], styles: &[Style], script: Option<ScriptTag>) -> Vec<deckcraft_fonts::Feature> {
|
||||
let mut f = vec![];
|
||||
if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) {
|
||||
f.extend(deckcraft_fonts::feature("-kern"));
|
||||
}
|
||||
let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
|
||||
let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
|
||||
if alphabetic && !emoji {
|
||||
f.extend(deckcraft_fonts::feature("-liga"));
|
||||
f.extend(deckcraft_fonts::feature("-clig"));
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
|
||||
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -586,14 +671,19 @@ fn shape_item(
|
||||
let Some(face) = faces.get(fi) else { return };
|
||||
let rtl = level % 2 == 1;
|
||||
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
|
||||
let params = ShapeParams { features: &[], rtl, script, language: lang };
|
||||
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
|
||||
let params = ShapeParams { features: &features, rtl, script, language: lang };
|
||||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||||
let upem = face.upem.max(1.0);
|
||||
let scale_of = |cell: &Cell| {
|
||||
let st = styles.get(cell.style);
|
||||
let size = st.map(|s| s.size).unwrap_or(18.0);
|
||||
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
|
||||
(if small { size * 0.8 } else { size }) / upem
|
||||
let size = if small { size * 0.8 } else { size };
|
||||
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
|
||||
// two thirds too.
|
||||
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
|
||||
size / upem
|
||||
};
|
||||
// Cluster heads in this item, ascending; each cluster spans to the next head.
|
||||
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
|
||||
@@ -659,11 +749,87 @@ fn shape_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `family` a symbol font (characters at U+F000 + code)?
|
||||
fn is_symbol_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
|
||||
}
|
||||
|
||||
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
|
||||
/// symbol font that `face` is (not a substitute) and it has the glyph.
|
||||
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
|
||||
let font = font?;
|
||||
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
|
||||
return None;
|
||||
}
|
||||
let first = c.chars().next()?;
|
||||
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
|
||||
if face.covers(pua) {
|
||||
Some(pua)
|
||||
} else if face.covers(first) {
|
||||
Some(first)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if f.starts_with("wingdings 2") {
|
||||
let m = match code {
|
||||
0x97 | 0x98 => '●',
|
||||
0x9e..=0xa1 => '■',
|
||||
0xa2..=0xa5 => '◆',
|
||||
0xea..=0xf3 => '★',
|
||||
0xb7..=0xbf => '○',
|
||||
0x50 => '✓',
|
||||
_ => '•',
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
if f.starts_with("wingdings 3") {
|
||||
let m = match code {
|
||||
0x7d | 0x75 => '►',
|
||||
0x7c | 0x74 => '◄',
|
||||
0x70 => '▲',
|
||||
0x71 => '▼',
|
||||
0xc6 | 0xc7 => '➔',
|
||||
_ => '▶',
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
let m = match code {
|
||||
0xa7 => '▪',
|
||||
0xd8 => '➢',
|
||||
@@ -679,7 +845,8 @@ fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
c.to_string()
|
||||
// PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot).
|
||||
c.graphemes(true).next().unwrap_or("•").to_string()
|
||||
}
|
||||
|
||||
fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
@@ -694,13 +861,60 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
(x, out)
|
||||
}
|
||||
|
||||
/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
|
||||
/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
|
||||
/// and 40 pt), whatever the font's own ascent and descent.
|
||||
///
|
||||
/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
|
||||
/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
|
||||
/// Keep it a single tunable value, and re-measure on the other platform before changing it.
|
||||
const LINE_PITCH: f64 = 1.2;
|
||||
|
||||
/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
|
||||
/// the font's Windows ascent and descent put the baseline.
|
||||
fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||||
let k = size / face.upem.max(1.0);
|
||||
(face.ascent * k, face.descent * k)
|
||||
let h = size * LINE_PITCH;
|
||||
(h * face.line_split, h * (1.0 - face.line_split))
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i.
|
||||
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
|
||||
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
|
||||
/// syllables.
|
||||
fn is_hangul(c: char) -> bool {
|
||||
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
|
||||
}
|
||||
|
||||
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
|
||||
fn is_hanging_punct(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'、' | '。'
|
||||
| ','
|
||||
| '.'
|
||||
| ':'
|
||||
| ';'
|
||||
| '!'
|
||||
| '?'
|
||||
| ')'
|
||||
| '」'
|
||||
| '』'
|
||||
| '】'
|
||||
| '〕'
|
||||
| '〉'
|
||||
| '》'
|
||||
| '〙'
|
||||
| '〛'
|
||||
| '}'
|
||||
| ']'
|
||||
| '」'
|
||||
| '、'
|
||||
| '。'
|
||||
)
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
|
||||
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
|
||||
/// Korean breaks only between words.
|
||||
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
|
||||
let n = text.chars().count();
|
||||
let mut allowed = vec![false; n + 1];
|
||||
let mut mandatory = vec![false; n + 1];
|
||||
@@ -721,6 +935,17 @@ fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, w) in chars.windows(2).enumerate() {
|
||||
let (prev, next) = (w[0], w[1]);
|
||||
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
|
||||
if !keep
|
||||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||||
&& let Some(a) = allowed.get_mut(i + 1)
|
||||
{
|
||||
*a = false;
|
||||
}
|
||||
}
|
||||
(allowed, mandatory)
|
||||
}
|
||||
|
||||
@@ -742,7 +967,8 @@ fn break_lines(
|
||||
x0_rest: f64,
|
||||
) -> Vec<RawLine> {
|
||||
let text: String = p.cells.iter().map(|c| c.ch).collect();
|
||||
let (allowed, _) = break_opportunities(&text);
|
||||
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
|
||||
let hang = p.props.hanging_punct.unwrap_or(false);
|
||||
let n = p.cells.len();
|
||||
let mut lines = vec![];
|
||||
let mut start = 0;
|
||||
@@ -769,6 +995,13 @@ fn break_lines(
|
||||
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
|
||||
last_break = Some(i);
|
||||
}
|
||||
// Closing East Asian punctuation hangs past the end instead of wrapping
|
||||
// (`hangingPunct`); the line breaks after it.
|
||||
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
|
||||
end = i + 1;
|
||||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
|
||||
// Break at the last opportunity, or force mid-word (between clusters).
|
||||
let b = last_break
|
||||
@@ -869,7 +1102,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
|
||||
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
|
||||
);
|
||||
let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
||||
// Text that follows a WordArt arch or circle is set on one line per paragraph.
|
||||
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
|
||||
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
||||
let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;
|
||||
let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);
|
||||
let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);
|
||||
@@ -909,7 +1144,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0);
|
||||
let first_x = if p.bullet.is_some() {
|
||||
let bx = (margin + indent).max(0.0);
|
||||
if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
// A hanging bullet's text starts at the margin. When the hang reaches left of the box
|
||||
// (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the
|
||||
// hang: the first line's text starts at -indent (later lines stay at the margin).
|
||||
let stop = margin.max(-indent);
|
||||
if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
} else {
|
||||
(margin + indent).max(0.0)
|
||||
};
|
||||
@@ -1144,6 +1383,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
link: false,
|
||||
alpha: st.alpha,
|
||||
gradient: None,
|
||||
effects: None,
|
||||
});
|
||||
}
|
||||
// Group visually consecutive cells by (style, face).
|
||||
@@ -1155,7 +1395,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let Some(st) = p.styles.get(cell.style) else { continue };
|
||||
let Some(&(cx, cx1)) = line.edges.get(k) else { continue };
|
||||
let base_y = line.baseline - st.baseline * st.size;
|
||||
let size = if st.baseline != 0.0 { st.size * 0.66 } else { st.size };
|
||||
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
|
||||
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
|
||||
let key = (cell.style, cell.face);
|
||||
if key != cur_key {
|
||||
@@ -1177,6 +1417,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
link: st.link,
|
||||
alpha: st.alpha,
|
||||
gradient: st.gradient.clone(),
|
||||
effects: st.effects,
|
||||
});
|
||||
cur_key = key;
|
||||
}
|
||||
@@ -1204,9 +1445,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
}
|
||||
}
|
||||
}
|
||||
let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 };
|
||||
for (gid, gx, gy) in &cell.glyphs {
|
||||
r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy));
|
||||
r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
|
||||
r.cells.push((ci, cell.ch));
|
||||
}
|
||||
}
|
||||
@@ -1619,26 +1859,99 @@ impl TextLayout {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
|
||||
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
|
||||
/// in place). A composition that wraps gets one underline per line.
|
||||
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
|
||||
let (s, e) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut out = vec![];
|
||||
if s == e {
|
||||
return out;
|
||||
}
|
||||
for l in &self.lines {
|
||||
let lp = Pos { para: l.para, ch: l.start };
|
||||
let le = Pos { para: l.para, ch: l.end };
|
||||
if le <= s || lp >= e {
|
||||
continue;
|
||||
}
|
||||
let from = if s > lp { s.ch } else { l.start };
|
||||
let to = if e < le { e.ch } else { l.end };
|
||||
// Under the baseline, in the descent, never below the line.
|
||||
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
|
||||
for (x0, x1) in l.spans(from, to) {
|
||||
if x1 > x0 {
|
||||
out.push((x0, x1, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||||
let Some(li) = self.line_of(pos) else { return pos };
|
||||
let target = if down { li + 1 } else { li.wrapping_sub(1) };
|
||||
let Some(l) = self.lines.get(target) else { return pos };
|
||||
self.vertical_affine(pos, Affinity::Downstream, down, x).0
|
||||
}
|
||||
/// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End
|
||||
/// leaves it, after the previous line's trailing spaces) belongs to the line before.
|
||||
pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
|
||||
if affinity == Affinity::Upstream && pos.ch > 0 {
|
||||
for (i, w) in self.lines.windows(2).enumerate() {
|
||||
if let [a, b] = w
|
||||
&& b.para == pos.para
|
||||
&& a.para == pos.para
|
||||
&& b.start == pos.ch
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.line_of_affine(pos, affinity)
|
||||
}
|
||||
/// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`.
|
||||
pub fn vertical_affine(&self, pos: Pos, affinity: Affinity, down: bool, x: f64) -> (Pos, Affinity) {
|
||||
let Some(li) = self.line_index(pos, affinity) else { return (pos, affinity) };
|
||||
let target = if down { li.checked_add(1) } else { li.checked_sub(1) };
|
||||
let Some(l) = target.and_then(|t| self.lines.get(t)) else { return (pos, affinity) };
|
||||
let p = Point::new(x, (l.top + l.bottom) / 2.0);
|
||||
let mut h = self.hit(p);
|
||||
if h.para != l.para {
|
||||
h.para = l.para;
|
||||
h.ch = h.ch.clamp(l.start, l.end);
|
||||
}
|
||||
h
|
||||
h.ch = h.ch.clamp(l.start, l.end);
|
||||
(h, Affinity::Downstream)
|
||||
}
|
||||
/// Start/end of the visual line containing `pos` (logical: Home/End).
|
||||
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
|
||||
match self.line_of(pos).and_then(|i| self.lines.get(i)) {
|
||||
self.line_bounds_affine(pos, Affinity::Downstream)
|
||||
}
|
||||
/// Start/end of the visual line a caret with this affinity is on.
|
||||
pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) {
|
||||
match self.line_index(pos, affinity).and_then(|i| self.lines.get(i)) {
|
||||
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
|
||||
None => (pos, pos),
|
||||
}
|
||||
}
|
||||
/// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start
|
||||
/// of the next line with upstream affinity (so it is still drawn on this line), else the end
|
||||
/// of the paragraph.
|
||||
pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) {
|
||||
let Some(li) = self.line_index(pos, affinity) else { return (pos, Affinity::Downstream) };
|
||||
let Some(l) = self.lines.get(li) else { return (pos, Affinity::Downstream) };
|
||||
match self.lines.get(li + 1) {
|
||||
Some(n) if n.para == l.para && n.start > l.start => (Pos { para: l.para, ch: n.start }, Affinity::Upstream),
|
||||
_ => (Pos { para: l.para, ch: l.end }, Affinity::Downstream),
|
||||
}
|
||||
}
|
||||
/// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end
|
||||
/// of the line it belongs to.
|
||||
pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
|
||||
let li = self.line_index(pos, affinity)?;
|
||||
let l = self.lines.get(li)?;
|
||||
if pos.ch > l.end {
|
||||
let x = l.caret_x.last().copied()?;
|
||||
return Some((x, l.top, l.bottom));
|
||||
}
|
||||
self.caret_at(pos, affinity)
|
||||
}
|
||||
/// Bounding box of all glyphs/lines (shape-local).
|
||||
pub fn bounds(&self) -> Rect {
|
||||
let mut r: Option<Rect> = None;
|
||||
|
||||
@@ -45,6 +45,89 @@ fn bullets_are_drawn_for_body_text() {
|
||||
assert_eq!(l.para_lines.len(), 2);
|
||||
}
|
||||
|
||||
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
|
||||
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
|
||||
let (p, mut sh) = setup(text, 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
for r in &mut para.runs {
|
||||
r.props.font = Some(family.into());
|
||||
r.props.size = Some(40.0);
|
||||
r.props.kern = kern;
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
|
||||
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
|
||||
}
|
||||
|
||||
/// The first installed or bundled family of `families` for which `pick` holds.
|
||||
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
|
||||
let db = FontDb::global();
|
||||
families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone())
|
||||
}
|
||||
|
||||
const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"];
|
||||
|
||||
#[test]
|
||||
fn latin_text_draws_no_ligatures() {
|
||||
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
|
||||
// standard ligatures.
|
||||
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
|
||||
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
|
||||
eprintln!("skipped: no font with an ffi ligature");
|
||||
return;
|
||||
};
|
||||
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
|
||||
assert_eq!(n, 12, "{family}: one glyph per character");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_are_kerned_only_from_the_kern_size() {
|
||||
let kerns = |f: &FontFace| {
|
||||
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
|
||||
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
|
||||
};
|
||||
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
|
||||
eprintln!("skipped: no font that kerns AVATAR");
|
||||
return;
|
||||
};
|
||||
// `kern` is the smallest size kerned; 0 is no kerning.
|
||||
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
|
||||
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
|
||||
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
|
||||
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
|
||||
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
|
||||
assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size");
|
||||
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_stay_for_scripts_that_need_them() {
|
||||
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
|
||||
let cells =
|
||||
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
|
||||
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
|
||||
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
|
||||
// Arabic needs its ligatures; emoji sequences are joined by them.
|
||||
assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga"));
|
||||
assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga"));
|
||||
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_character_of_a_bullet_is_drawn() {
|
||||
// "••" (seen in the wild) draws one dot in PowerPoint.
|
||||
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
|
||||
let (p, mut sh) = setup("One", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
|
||||
}
|
||||
// The first grapheme cluster is kept whole.
|
||||
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_on_overflow() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
@@ -68,6 +151,36 @@ fn caret_and_hit_roundtrip() {
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the text an input method is composing is underlined in place, under the baseline of each
|
||||
/// line it occupies, and only over the composed characters.
|
||||
#[test]
|
||||
fn composition_is_underlined_per_line() {
|
||||
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
|
||||
let l = lay(&p, &sh, 600.0, 100.0);
|
||||
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
|
||||
let u = l.composition_underlines(a, b);
|
||||
assert_eq!(u.len(), 1, "{u:?}");
|
||||
let (x0, x1, y) = u[0];
|
||||
let (cx0, ..) = l.caret(a).unwrap();
|
||||
let (cx1, ..) = l.caret(b).unwrap();
|
||||
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
|
||||
let line = &l.lines[0];
|
||||
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
|
||||
// Reversed ends give the same; an empty composition, nothing.
|
||||
assert_eq!(l.composition_underlines(b, a), u);
|
||||
assert!(l.composition_underlines(a, a).is_empty());
|
||||
|
||||
// Wrapped: one underline per line.
|
||||
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
|
||||
let l = lay(&p, &sh, 120.0, 400.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
|
||||
assert!(u.len() >= 2, "{u:?}");
|
||||
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
|
||||
// Positions past the text never panic.
|
||||
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_center_and_right() {
|
||||
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
||||
@@ -395,3 +508,254 @@ fn hostile_bidi_input_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
|
||||
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
|
||||
let mut run = Run::new(word);
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = baseline;
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_advance_at_their_drawn_size() {
|
||||
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
|
||||
// letters apart ("s u p e r").
|
||||
let normal = run_width("superscript", None);
|
||||
for b in [Some(0.3), Some(-0.25)] {
|
||||
let w = run_width("superscript", b);
|
||||
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_glyphs_sit_inside_their_cells() {
|
||||
let mut run = Run::new("super");
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = Some(0.3);
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
|
||||
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
|
||||
let (x0, x1) = line.edges[*ci - line.start];
|
||||
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
|
||||
/// x, second line's text x).
|
||||
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
|
||||
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
|
||||
let (p, mut sh) = setup(text, 300.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(bullet);
|
||||
para.props.margin_left = Some(margin);
|
||||
para.props.indent = Some(indent);
|
||||
let l = lay(&p, &sh, 300.0, 300.0);
|
||||
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
|
||||
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
|
||||
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
|
||||
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
|
||||
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let (bx, first, rest) = hanging(0.0, -36.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
|
||||
let (bx, first, rest) = hanging(18.0, -54.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// A character bullet behaves the same.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
// Without a bullet the first line just starts at the edge.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
|
||||
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
|
||||
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
|
||||
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
|
||||
for size in [18.0, 40.0] {
|
||||
let mut run = Run::new("HHH");
|
||||
run.props.size = Some(size);
|
||||
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.autofit = Some(AutoFit::None);
|
||||
let second = t.paragraphs[0].clone();
|
||||
t.paragraphs.push(second.clone());
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.line_spacing = Some(Spacing::Pct(1.0));
|
||||
para.props.space_before = Some(Spacing::Pts(0.0));
|
||||
para.props.space_after = Some(Spacing::Pts(0.0));
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 600.0);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
// Each line is 1.2 × the size tall (the face is not compared across lines: another
|
||||
// test's thread can load the family between them, see #252).
|
||||
for line in &l.lines {
|
||||
assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top);
|
||||
}
|
||||
let face = &l.runs[0].face;
|
||||
let first = &l.lines[0];
|
||||
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
|
||||
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.inset_l = Some(0.0);
|
||||
t.body.inset_r = Some(0.0);
|
||||
set(&mut t.paragraphs[0].props);
|
||||
let l = lay(&p, &sh, w, 600.0);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Width of `text` on one line.
|
||||
fn one_line(text: &str) -> f64 {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 5000.0, 300.0);
|
||||
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_does_not_break_after_a_slash_or_backslash() {
|
||||
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
|
||||
// where it no longer fits; Unicode line breaking would allow a break after each slash.
|
||||
for sep in ["/", "\\"] {
|
||||
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
|
||||
let w = one_line(&text) * 0.6;
|
||||
let lines = wrapped(&text, w, |_| {});
|
||||
assert!(lines.len() >= 2, "{lines:?}");
|
||||
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
|
||||
}
|
||||
// latinLnBrk on: Unicode's break after a slash is allowed again.
|
||||
let text = "alpha/beta/gamma/delta/epsilon";
|
||||
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
|
||||
assert!(lines[0].ends_with('/'), "{lines:?}");
|
||||
// A slash followed by a space still breaks, and hyphens still break.
|
||||
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
|
||||
assert_eq!(lines[0], "alpha-beta-gamma-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_wraps_between_words() {
|
||||
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
|
||||
let w = one_line("한국어 안녕하세요 반") + 0.5;
|
||||
let lines = wrapped(text, w, |_| {});
|
||||
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
|
||||
// Japanese and Chinese still break between characters.
|
||||
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
|
||||
assert_eq!(lines[0], "日本語の文章");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
|
||||
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
|
||||
// edge instead of pulling the character before it onto the next line.
|
||||
let text = "白なしで続きます。句読点も含めて。";
|
||||
let w = one_line("白なしで続きます") + 0.5;
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
|
||||
// Without it, "。" can't start a line, so "す。" moves down together.
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
|
||||
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_take_a_colour_face_when_one_is_installed() {
|
||||
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
|
||||
// even when the run's font has a plain glyph for some of them. Skipped without an installed
|
||||
// colour emoji font.
|
||||
let db = FontDb::global();
|
||||
let probe = db.cascade(&['😀'], &[], "Regular");
|
||||
if !probe.is_color() {
|
||||
return;
|
||||
}
|
||||
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
|
||||
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
|
||||
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
|
||||
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
|
||||
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
|
||||
// bundled face has ①.
|
||||
let db = FontDb::global();
|
||||
if !db.cascade(&['①'], &[], "Regular").covers('①') {
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
|
||||
}
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
|
||||
assert_eq!(bullets.len(), 2);
|
||||
for b in bullets {
|
||||
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
|
||||
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_bullets_map_by_font() {
|
||||
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
|
||||
// Wingdings keeps its own table (0xA7 is a small square).
|
||||
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
|
||||
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
|
||||
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
|
||||
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
|
||||
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
|
||||
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_symbol_font_draws_its_own_bullet() {
|
||||
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
|
||||
// look-alike. Skipped without Wingdings 2.
|
||||
let db = FontDb::global();
|
||||
if !db.has_family("Wingdings 2") {
|
||||
return;
|
||||
}
|
||||
let face = db.face("Wingdings 2", "Regular");
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_char_maps_symbol_font_codes() {
|
||||
// Inter is not a symbol font, so the codes map to look-alikes.
|
||||
let face = FontDb::global().face("Inter", "Regular");
|
||||
assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font");
|
||||
assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}'));
|
||||
assert_eq!(symbol_char(&face, "Webdings", '\u{F06E}'), Some('\u{25CF}'), "the private-use form of the code");
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}'));
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code");
|
||||
}
|
||||
|
||||
+100
-11
@@ -1,6 +1,10 @@
|
||||
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
||||
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
||||
//! previews, the text caret and text selection).
|
||||
//!
|
||||
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
|
||||
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
|
||||
//! the slide show, exports and thumbnails clip to the slide.
|
||||
|
||||
use deckcraft_engine::tools::{Hit, box_handles};
|
||||
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
||||
@@ -108,18 +112,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let st = app.session.active();
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
let visible = visible_region(avail, slide_rect, pixel);
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
None => None,
|
||||
};
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if let Some((tex, region)) = tex {
|
||||
let at = Rect::from_min_size(
|
||||
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
|
||||
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
|
||||
);
|
||||
app.canvas_region = Some(at);
|
||||
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
app.perf.render_ms = app.textures.last_render_ms;
|
||||
media_frames(app, &painter, xf);
|
||||
@@ -152,11 +164,69 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
||||
/// top-left corner.
|
||||
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
|
||||
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
|
||||
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
|
||||
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
|
||||
let lim = i32::MAX as f64 / 4.0;
|
||||
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
|
||||
deckcraft_render::Region {
|
||||
x: x0 as i32,
|
||||
y: y0 as i32,
|
||||
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
|
||||
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
|
||||
/// nothing to type into.
|
||||
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
let st = app.session.active()?;
|
||||
if let Some(ts) = &st.selection.text {
|
||||
if ts.notes {
|
||||
return None;
|
||||
}
|
||||
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
||||
}
|
||||
let sh = st.shape(ts.shape)?;
|
||||
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
||||
}
|
||||
if st.selection.shapes.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(*st.selection.shapes.first()?)?;
|
||||
if sh.is_line() {
|
||||
return None;
|
||||
}
|
||||
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
||||
}
|
||||
|
||||
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||
let ctx = ui.ctx();
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
||||
let r = to_global * r;
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -240,14 +310,17 @@ fn master_tex(
|
||||
master: usize,
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
) -> Option<egui::TextureHandle> {
|
||||
visible: deckcraft_render::Region,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
||||
let img = deckcraft_render::render_layout(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
|
||||
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -388,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
} else {
|
||||
poly(painter, corners, sel_stroke);
|
||||
}
|
||||
if sh.locked && !editing_this {
|
||||
// Locked: greyed handles that don't respond, no rotate or adjust handles.
|
||||
for h in box_handles(&x).0 {
|
||||
painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if st.selection.shapes.len() == 1 || !editing_this {
|
||||
let (hs, rot) = box_handles(&x);
|
||||
let rot_s = xf.to_screen(rot);
|
||||
@@ -422,12 +502,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||
}
|
||||
if !ts.is_range() {
|
||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
|
||||
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
|
||||
// An empty placeholder shows the caret where typing begins too.
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
|
||||
});
|
||||
let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked));
|
||||
if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() {
|
||||
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Link…").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
||||
ui.close();
|
||||
|
||||
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
let Some(t) = s("text") else { return err("missing `text`") };
|
||||
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
||||
}
|
||||
"ui.ime" => {
|
||||
// An input method's event, through egui like a real one: it reaches the slide's text
|
||||
// or a focused text field (#101).
|
||||
let ev = if let Some(t) = s("commit") {
|
||||
egui::ImeEvent::Commit(t.into())
|
||||
} else if let Some(t) = s("preedit") {
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x <= y).then_some(x..y)
|
||||
});
|
||||
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
|
||||
} else {
|
||||
return err("missing `preedit` or `commit`");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Ime(ev));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.tool.select" => {
|
||||
let Some(name) = s("tool") else { return err("missing `tool`") };
|
||||
match tool_kind(name, s("preset")) {
|
||||
|
||||
@@ -284,6 +284,8 @@ icons! {
|
||||
Info => "info",
|
||||
Warning => "warning",
|
||||
Lock => "lock",
|
||||
/// Open padlock (Selection pane: an object that isn't locked).
|
||||
Unlock => "unlock",
|
||||
Eye => "eye",
|
||||
EyeOff => "eye-off",
|
||||
Close => "close",
|
||||
@@ -2262,22 +2264,30 @@ fn draw(p: &Pen<'_>, icon: Icon) {
|
||||
p.seg((12.0, 9.0), (12.0, 14.0), D);
|
||||
p.dot((12.0, 17.2), 1.2, D);
|
||||
}
|
||||
// Selection pane toggles: monochrome outlines in the ink colour.
|
||||
I::Lock => {
|
||||
let mut sh = vec![(7.5, 10.5), (7.5, 8.0)];
|
||||
sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0));
|
||||
sh.push((16.5, 10.5));
|
||||
p.line(&sh, K);
|
||||
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K);
|
||||
p.dot((12.0, 15.0), 1.5, D);
|
||||
p.seg((12.0, 15.5), (12.0, 18.3), D);
|
||||
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K);
|
||||
p.seg((12.0, 14.5), (12.0, 17.5), K);
|
||||
}
|
||||
I::Unlock => {
|
||||
// The shackle swung open: its left leg out of the body, raised.
|
||||
let mut sh = vec![(12.0, 10.5), (12.0, 6.0)];
|
||||
sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0));
|
||||
sh.push((21.0, 8.5));
|
||||
p.line(&sh, K);
|
||||
p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K);
|
||||
p.seg((9.75, 14.5), (9.75, 17.5), K);
|
||||
}
|
||||
I::Eye | I::EyeOff => {
|
||||
let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16);
|
||||
e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1));
|
||||
let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16);
|
||||
e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1));
|
||||
e.pop();
|
||||
p.shape(&e, N, K);
|
||||
p.circle((12.0, 12.0), 3.4, B, N);
|
||||
p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W });
|
||||
p.circle((12.0, 12.0), 3.2, N, K);
|
||||
if icon == I::EyeOff {
|
||||
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
|
||||
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
|
||||
//! real glyphs rather than boxes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui::{Event, FontId, ImeEvent};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn preedit(text: &str) -> Event {
|
||||
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
|
||||
}
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn undo_len(app: &SlideApp) -> usize {
|
||||
app.session.active().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
|
||||
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
|
||||
/// is on while the slide's text is edited, with its window at the insertion point.
|
||||
#[test]
|
||||
fn korean_composes_in_a_slide_text_box() {
|
||||
let mut h = harness(Session::with_new());
|
||||
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
|
||||
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
|
||||
let caret0 = ime.rect;
|
||||
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
|
||||
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
|
||||
}
|
||||
h.run_steps(1);
|
||||
let ime = h.output().platform_output.ime.unwrap();
|
||||
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
|
||||
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
|
||||
|
||||
// Escape cancels a composition: an empty preedit, and the text is as it was.
|
||||
h.event(preedit("ㅁ"));
|
||||
h.event(preedit(""));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
|
||||
}
|
||||
|
||||
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
|
||||
/// composition the input method left open.
|
||||
#[test]
|
||||
fn japanese_conversion_shows_the_active_part() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
|
||||
h.run_steps(2);
|
||||
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
|
||||
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
|
||||
let undo0 = undo_len(h.state());
|
||||
h.key_press(egui::Key::End);
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本ご");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
}
|
||||
|
||||
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
|
||||
/// the input method is on for it.
|
||||
#[test]
|
||||
fn composing_on_a_selected_shape_starts_editing_it() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().selection.text.is_none());
|
||||
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
|
||||
h.event(preedit("新"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
|
||||
h.run_steps(2);
|
||||
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
}
|
||||
|
||||
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
|
||||
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
|
||||
#[test]
|
||||
fn korean_composes_in_the_notes_pane() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let id = egui::Id::new("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)));
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, "노트");
|
||||
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
|
||||
assert_eq!(notes, "노트");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
|
||||
|
||||
// Composing, then going to another slide: the notes stay with the slide they were typed for.
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.event(preedit("ㅇ"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("요".into())));
|
||||
h.run_steps(1);
|
||||
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
|
||||
h.run_steps(2);
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
|
||||
assert_eq!(d.doc.slides[1].notes_text(), "");
|
||||
}
|
||||
|
||||
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
|
||||
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
|
||||
fn system_has(s: &str) -> bool {
|
||||
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
|
||||
}
|
||||
|
||||
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
|
||||
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
|
||||
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
|
||||
#[test]
|
||||
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||
let h = harness(Session::with_new());
|
||||
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let korean = "한국어 발표 자료";
|
||||
let japanese = "日本語のスライド";
|
||||
let chinese = "简体中文演示文稿";
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
|
||||
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
let mut h = harness(s);
|
||||
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().title().contains(korean));
|
||||
let mut checked = 0;
|
||||
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
|
||||
if !system_has(text) {
|
||||
eprintln!("skipped {text:?}: no font on this machine draws it");
|
||||
continue;
|
||||
}
|
||||
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
|
||||
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if cfg!(windows) || cfg!(target_os = "macos") {
|
||||
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
|
||||
}
|
||||
// Text typed later (a dialog field, a new comment) is looked at too.
|
||||
let typed = "ไทย";
|
||||
h.event(Event::Text(typed.into()));
|
||||
h.run_steps(2);
|
||||
if system_has(typed) {
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
|
||||
}
|
||||
}
|
||||
+291
-1
@@ -16,12 +16,15 @@ pub mod media;
|
||||
pub mod menus;
|
||||
pub mod panes;
|
||||
pub mod ribbon;
|
||||
pub mod selpane;
|
||||
pub mod show;
|
||||
pub mod sorter;
|
||||
pub mod status;
|
||||
pub mod textures;
|
||||
pub mod theme;
|
||||
pub mod thumbs;
|
||||
pub mod toast;
|
||||
pub mod uifonts;
|
||||
pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -181,6 +184,10 @@ pub struct SlideApp {
|
||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||
pub media: media::MediaHost,
|
||||
pub status: Option<(String, f64)>,
|
||||
/// A brief notice over the bottom of the window (missing fonts on opening a file).
|
||||
pub toast: Option<toast::Toast>,
|
||||
/// Documents (by uid) whose missing fonts have been mentioned.
|
||||
fonts_noted: std::collections::HashSet<u64>,
|
||||
pub perf: Perf,
|
||||
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
@@ -188,6 +195,8 @@ pub struct SlideApp {
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
pub canvas_scale: f32,
|
||||
pub slide_rect: Option<egui::Rect>,
|
||||
/// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen.
|
||||
pub canvas_region: Option<egui::Rect>,
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
@@ -207,8 +216,12 @@ pub struct SlideApp {
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||
/// Selection pane: folded groups, renaming, drag and drop.
|
||||
pub selpane: selpane::State,
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
@@ -224,11 +237,14 @@ impl SlideApp {
|
||||
show: None,
|
||||
media: media::MediaHost::new(audio_out),
|
||||
status: None,
|
||||
toast: None,
|
||||
fonts_noted: Default::default(),
|
||||
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
|
||||
synthetic: vec![],
|
||||
canvas_rect: None,
|
||||
canvas_scale: 1.0,
|
||||
slide_rect: None,
|
||||
canvas_region: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
@@ -243,7 +259,9 @@ impl SlideApp {
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +553,7 @@ impl SlideApp {
|
||||
ctx.set_zoom_factor(scale);
|
||||
}
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||
self.styled = true;
|
||||
self.restyle = true;
|
||||
} else {
|
||||
@@ -558,6 +576,9 @@ impl SlideApp {
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.fonts_ready {
|
||||
self.note_ui_text(ctx);
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
// Closing the window with unsaved changes asks first.
|
||||
@@ -589,6 +610,52 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
|
||||
/// presentations' names and text, recent files, and what is typed or composed this frame.
|
||||
fn note_ui_text(&mut self, ctx: &egui::Context) {
|
||||
let typed: String = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
self.ui_fonts.note(ctx, &typed);
|
||||
for d in self.session.documents() {
|
||||
self.ui_fonts.note(ctx, &d.title());
|
||||
}
|
||||
for r in &self.ui.recent {
|
||||
self.ui_fonts.note(ctx, r);
|
||||
}
|
||||
if let Some(st) = self.session.active() {
|
||||
self.ui_fonts.note_doc(ctx, st);
|
||||
}
|
||||
self.ui_fonts.apply(ctx);
|
||||
}
|
||||
|
||||
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
|
||||
/// replaced on each update; the commit replaces it as one undo step.
|
||||
fn ime_event(&mut self, e: &egui::ImeEvent) {
|
||||
let r = match e {
|
||||
egui::ImeEvent::Preedit { text, active_range_chars } => {
|
||||
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
|
||||
}
|
||||
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
|
||||
#[allow(deprecated)]
|
||||
egui::ImeEvent::Disabled => {
|
||||
self.session.end_composition();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
@@ -636,6 +703,7 @@ impl SlideApp {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(ime) => self.ime_event(&ime),
|
||||
egui::Event::Copy => {
|
||||
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
||||
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
||||
@@ -730,6 +798,8 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
});
|
||||
toast::note_missing_fonts(self);
|
||||
toast::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
menus::palette(self, &ctx);
|
||||
for url in std::mem::take(&mut self.pending_urls) {
|
||||
@@ -877,6 +947,9 @@ fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
@@ -889,6 +962,37 @@ mod tests {
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_with_missing_fonts_shows_a_toast_once() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none(), "a new presentation has nothing to say");
|
||||
harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap();
|
||||
harness.run_steps(2);
|
||||
let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default();
|
||||
assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}");
|
||||
assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some());
|
||||
// Closed, it stays closed for that document.
|
||||
harness.get_by_label("Close").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none());
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_none());
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
@@ -932,10 +1036,196 @@ mod tests {
|
||||
assert_eq!(harness.state().session.documents().len(), 2);
|
||||
}
|
||||
|
||||
/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real
|
||||
/// egui key events (Windows keys).
|
||||
#[test]
|
||||
fn ctrl_word_keys_edit_text() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut session = Session::with_new();
|
||||
session.mac_keys = false;
|
||||
session.prefs.autocorrect = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("Hello, world. Foo bar".into()));
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Hello, world. Foo bar");
|
||||
let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap();
|
||||
let ctrl = Modifiers::COMMAND;
|
||||
let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
|
||||
// Ctrl+Left stops at every word and punctuation run, as in PowerPoint.
|
||||
let mut stops = vec![];
|
||||
for _ in 0..6 {
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowLeft);
|
||||
harness.run_steps(1);
|
||||
stops.push(caret(&harness).caret.1);
|
||||
}
|
||||
assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]);
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowRight);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (0, 5));
|
||||
|
||||
// Ctrl+Shift+Right selects to the next stops.
|
||||
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||
harness.run_steps(1);
|
||||
let t = caret(&harness);
|
||||
assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12)));
|
||||
|
||||
// Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one
|
||||
// before the caret.
|
||||
harness.key_press_modifiers(ctrl, Key::Delete);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Helloworld. Foo bar");
|
||||
harness.key_press(Key::End);
|
||||
harness.key_press_modifiers(ctrl, Key::Backspace);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(title_text(harness.state()), "Helloworld. Foo ");
|
||||
|
||||
// Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends.
|
||||
harness.key_press(Key::Enter);
|
||||
harness.event(egui::Event::Text("Next".into()));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (1, 0));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (0, 0));
|
||||
harness.key_press_modifiers(ctrl, Key::ArrowDown);
|
||||
harness.run_steps(1);
|
||||
assert_eq!(caret(&harness).caret, (1, 0));
|
||||
harness.key_press_modifiers(ctrl_shift, Key::Home);
|
||||
harness.run_steps(1);
|
||||
let t = caret(&harness);
|
||||
assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0)));
|
||||
|
||||
// Ctrl+Shift+> (egui reports the Period key) grows the selected text's font.
|
||||
let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||
let before = size(&mut harness);
|
||||
harness.key_press_modifiers(ctrl_shift, Key::Period);
|
||||
harness.run_steps(1);
|
||||
let after = size(&mut harness).unwrap();
|
||||
assert!(after > before.unwrap_or(0.0), "{before:?} → {after}");
|
||||
harness.key_press_modifiers(ctrl, Key::OpenBracket);
|
||||
harness.run_steps(1);
|
||||
assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
}
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let app = harness.state_mut();
|
||||
// Zoomed out, the canvas shows plenty of pasteboard around the slide.
|
||||
app.ui.zoom = Some(0.4);
|
||||
app.session.prefs.snap_to_grid = false;
|
||||
app.session.prefs.smart_guides = false;
|
||||
harness
|
||||
}
|
||||
|
||||
fn selected(app: &SlideApp) -> Vec<u64> {
|
||||
app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect()
|
||||
}
|
||||
|
||||
fn shape_x(app: &SlideApp, id: u64) -> f64 {
|
||||
let st = app.session.active().unwrap();
|
||||
st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x
|
||||
}
|
||||
|
||||
/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where
|
||||
/// they can be selected and moved.
|
||||
#[test]
|
||||
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
|
||||
let mut harness = canvas_harness();
|
||||
let off =
|
||||
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
harness.run_steps(3);
|
||||
|
||||
let app = harness.state();
|
||||
let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale);
|
||||
// The texture reaches past the slide's right edge to the shape's, and no further left/up.
|
||||
assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}");
|
||||
assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}");
|
||||
let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k);
|
||||
assert!(app.canvas_rect.unwrap().contains(centre));
|
||||
|
||||
// Drawn at full strength on the pasteboard (when a GPU renderer is available here).
|
||||
if let Ok(img) = harness.render() {
|
||||
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
|
||||
let shape = px(centre);
|
||||
let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k));
|
||||
let doc = &harness.state().session.active().unwrap().doc;
|
||||
let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4;
|
||||
assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}");
|
||||
assert_ne!(shape, board);
|
||||
}
|
||||
|
||||
// Click selects it.
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drop_at(centre);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(selected(harness.state()), vec![off]);
|
||||
|
||||
// Drag moves it, further off the slide.
|
||||
let to = centre + egui::vec2(40.0, 0.0);
|
||||
harness.hover_at(centre);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(centre);
|
||||
harness.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
harness.drop_at(to);
|
||||
harness.run_steps(2);
|
||||
let x = shape_x(harness.state(), off);
|
||||
assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}");
|
||||
// The pasteboard followed it.
|
||||
let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap());
|
||||
assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}");
|
||||
|
||||
// Idle frames don't render the canvas again.
|
||||
let n = harness.state().textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n);
|
||||
}
|
||||
|
||||
/// A slide with nothing off it renders just the slide, as before.
|
||||
#[test]
|
||||
fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() {
|
||||
let mut harness = canvas_harness();
|
||||
harness.run_steps(3);
|
||||
let app = harness.state();
|
||||
let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap());
|
||||
assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}");
|
||||
let n = app.textures.canvas_renders;
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
"format" => format_shape(app, ui),
|
||||
"background" => background(app, ui),
|
||||
"animation" => animation(app, ui),
|
||||
"selection" => selection(app, ui),
|
||||
"selection" => crate::selpane::show(app, ui),
|
||||
"comments" => comments(app, ui),
|
||||
"designer" => designer(app, ui),
|
||||
_ => {}
|
||||
@@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let _ = Stroke::NONE;
|
||||
}
|
||||
|
||||
fn selection(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let shapes: Vec<(u32, String, bool, &'static str)> =
|
||||
st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect();
|
||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Show All").clicked() {
|
||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
||||
run(app, "shape.visible", json!({"ids": ids, "visible": true}));
|
||||
}
|
||||
if ui.button("Hide All").clicked() {
|
||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
||||
run(app, "shape.visible", json!({"ids": ids, "visible": false}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let n = shapes.len();
|
||||
for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
|
||||
let sel = selected.contains(&id);
|
||||
if sel || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 8.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&name,
|
||||
theme::font(12.5),
|
||||
if hidden { t.text_faint } else { t.text },
|
||||
);
|
||||
ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint);
|
||||
let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false);
|
||||
let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
|
||||
if eye_resp.clicked() {
|
||||
run(app, "shape.visible", json!({"id": id, "visible": hidden}));
|
||||
} else if resp.clicked() {
|
||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
run(app, "edit.select", json!({"ids": [id], "toggle": add}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
||||
d.params = json!({"id": id, "name": name});
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Bring Forward").clicked() {
|
||||
run(app, "arrange.bringForward", json!({"ids": [id]}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Send Backward").clicked() {
|
||||
run(app, "arrange.sendBackward", json!({"ids": [id]}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
||||
d.params = json!({"id": id, "name": name});
|
||||
app.dialog = Some(d);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
let _ = (k, n);
|
||||
}
|
||||
}
|
||||
|
||||
fn comments(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if ui.button("+ New Comment").clicked() {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Selection pane: every object on the slide, front-most first, groups expanded with their
|
||||
//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among
|
||||
//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move
|
||||
//! through the list. Everything it changes goes through engine commands.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_model::Shape;
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
pub const ROW_H: f32 = 24.0;
|
||||
const INDENT: f32 = 14.0;
|
||||
|
||||
/// Pane-only state (nothing here is part of the document).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {
|
||||
/// Groups folded shut.
|
||||
pub collapsed: HashSet<u32>,
|
||||
/// Object being renamed and the text typed so far.
|
||||
pub renaming: Option<(u32, String)>,
|
||||
rename_started: bool,
|
||||
/// Last row clicked without Shift (the start of a Shift+click run).
|
||||
pub anchor: Option<u32>,
|
||||
/// Row being dragged and the drop it would make: (index among siblings, line y).
|
||||
pub drag: Option<(u32, Option<(usize, f32)>)>,
|
||||
}
|
||||
|
||||
/// One line of the list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Row {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub depth: usize,
|
||||
pub group: bool,
|
||||
pub hidden: bool,
|
||||
/// Inside a hidden group: drawn dimmed.
|
||||
pub dimmed: bool,
|
||||
pub locked: bool,
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
|
||||
/// The rows as listed: front-most first, children under their group unless it's folded.
|
||||
pub fn rows(shapes: &[Shape], collapsed: &HashSet<u32>) -> Vec<Row> {
|
||||
fn rec(v: &[Shape], depth: usize, parent: Option<u32>, dim: bool, collapsed: &HashSet<u32>, out: &mut Vec<Row>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in v.iter().rev() {
|
||||
out.push(Row {
|
||||
id: sh.id.0,
|
||||
name: sh.name.clone(),
|
||||
depth,
|
||||
group: sh.is_group(),
|
||||
hidden: sh.hidden,
|
||||
dimmed: dim,
|
||||
locked: sh.locked,
|
||||
parent,
|
||||
});
|
||||
if sh.is_group() && !collapsed.contains(&sh.id.0) {
|
||||
rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
rec(shapes, 0, None, false, collapsed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's
|
||||
/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index
|
||||
/// among the siblings in the file's order (0 = back) and the y of the insertion line.
|
||||
pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> {
|
||||
let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect();
|
||||
let first = others.first()?;
|
||||
let mut gaps: Vec<f32> = vec![first.1.top()];
|
||||
gaps.extend(others.iter().map(|(_, r)| r.bottom()));
|
||||
let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?;
|
||||
// `g` siblings sit above the line; the dragged object goes behind them.
|
||||
Some((others.len() - g, *gy))
|
||||
}
|
||||
|
||||
fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// Each row is a widget with an id of its own (keyboard focus sits on the selected row).
|
||||
fn row_id(id: u32) -> Id {
|
||||
Id::new(("selection_pane_row", id))
|
||||
}
|
||||
|
||||
fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label));
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled);
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let rows = rows(st.shapes(), &app.selpane.collapsed);
|
||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||
let has_sel = !selected.is_empty();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||
run(app, "selection.showAll", json!({}));
|
||||
}
|
||||
if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||
run(app, "selection.hideAll", json!({}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() {
|
||||
run(app, "arrange.sendBackward", json!({}));
|
||||
}
|
||||
if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() {
|
||||
run(app, "arrange.bringForward", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
// The list has the keyboard while one of its rows has focus.
|
||||
let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id));
|
||||
if let Some(f) = focused_row {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
}
|
||||
let mut rects: Vec<Rect> = Vec::with_capacity(rows.len());
|
||||
let width = ui.available_width();
|
||||
for (k, row) in rows.iter().enumerate() {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag());
|
||||
rects.push(r);
|
||||
let sel = selected.contains(&row.id);
|
||||
resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name));
|
||||
let painter = ui.painter_at(r);
|
||||
if sel {
|
||||
painter.rect_filled(r, CornerRadius::same(3), t.selected);
|
||||
painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
painter.rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
let x0 = r.min.x + 4.0 + INDENT * row.depth as f32;
|
||||
// Group chevron.
|
||||
if row.group {
|
||||
let open = !app.selpane.collapsed.contains(&row.id);
|
||||
let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0));
|
||||
let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click());
|
||||
let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name);
|
||||
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
|
||||
icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false);
|
||||
if cresp.clicked() {
|
||||
if open {
|
||||
app.selpane.collapsed.insert(row.id);
|
||||
} else {
|
||||
app.selpane.collapsed.remove(&row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let name_x = x0 + 16.0;
|
||||
let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0));
|
||||
let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0));
|
||||
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0));
|
||||
let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone());
|
||||
if let Some(mut buf) = renaming {
|
||||
let edit_id = ui.id().with(("rename", row.id));
|
||||
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
|
||||
if !app.selpane.rename_started {
|
||||
out.request_focus();
|
||||
if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
|
||||
s.cursor.set_char_range(Some(all));
|
||||
s.store(ui.ctx(), edit_id);
|
||||
}
|
||||
app.selpane.rename_started = true;
|
||||
}
|
||||
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
if out.lost_focus() || enter || esc {
|
||||
app.selpane.renaming = None;
|
||||
app.selpane.rename_started = false;
|
||||
if !esc && buf.trim() != row.name && !buf.trim().is_empty() {
|
||||
run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()}));
|
||||
}
|
||||
ui.memory_mut(|m| m.request_focus(row_id(row.id)));
|
||||
} else if let Some((_, s)) = app.selpane.renaming.as_mut() {
|
||||
*s = buf;
|
||||
}
|
||||
} else {
|
||||
let color = if row.dimmed { t.text_faint } else { t.text };
|
||||
painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color);
|
||||
}
|
||||
// Eye and padlock.
|
||||
let ink = if row.dimmed { t.text_faint } else { t.text_dim };
|
||||
icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false);
|
||||
icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false);
|
||||
let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click());
|
||||
let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name);
|
||||
eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label));
|
||||
let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click());
|
||||
let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name);
|
||||
lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label));
|
||||
if eye_resp.clicked() {
|
||||
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||
} else if lock_resp.clicked() {
|
||||
run(app, "shape.lock", json!({"id": row.id}));
|
||||
} else if resp.double_clicked() {
|
||||
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
} else if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
click(app, &rows, k, m.command || m.ctrl, m.shift);
|
||||
ui.memory_mut(|mem| mem.request_focus(row_id(row.id)));
|
||||
}
|
||||
if resp.drag_started() {
|
||||
if !sel {
|
||||
run(app, "selection.select", json!({"id": row.id}));
|
||||
}
|
||||
app.selpane.drag = Some((row.id, None));
|
||||
}
|
||||
context_menu(app, &resp, row);
|
||||
}
|
||||
|
||||
// Dragging: the insertion line among the dragged row's siblings.
|
||||
if let Some((dragged, _)) = app.selpane.drag {
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down());
|
||||
let target = pointer.and_then(|p| {
|
||||
let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?;
|
||||
let blocks = sibling_blocks(&rows, &rects, parent);
|
||||
drop_target(dragged, &blocks, p.y)
|
||||
});
|
||||
app.selpane.drag = Some((dragged, target));
|
||||
if let Some((_, y)) = target {
|
||||
let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width);
|
||||
let p = ui.painter();
|
||||
let s = Stroke::new(2.0, t.accent);
|
||||
p.line_segment([pos2(x0, y), pos2(x1, y)], s);
|
||||
p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s);
|
||||
p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s);
|
||||
}
|
||||
if released {
|
||||
app.selpane.drag = None;
|
||||
if let Some((index, _)) = target {
|
||||
run(app, "arrange.reorder", json!({"id": dragged, "index": index}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keys while the list has focus.
|
||||
if let Some(f) = focused_row
|
||||
&& app.selpane.renaming.is_none()
|
||||
{
|
||||
keys(app, ui, &rows, &selected, f);
|
||||
}
|
||||
// Clicking the empty part of the list doesn't change anything; keep the pane's height.
|
||||
let rest = ui.available_size();
|
||||
if rest.y > 0.0 {
|
||||
ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom.
|
||||
fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option<u32>) -> Vec<(u32, Rect)> {
|
||||
let mut out: Vec<(u32, Rect)> = vec![];
|
||||
let depth_of = |id: Option<u32>| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0);
|
||||
let depth = depth_of(parent);
|
||||
let mut current: Option<(u32, Rect)> = None;
|
||||
for (row, rect) in rows.iter().zip(rects) {
|
||||
if row.parent == parent {
|
||||
if let Some(c) = current.take() {
|
||||
out.push(c);
|
||||
}
|
||||
current = Some((row.id, *rect));
|
||||
} else if row.depth > depth
|
||||
&& let Some((_, r)) = current.as_mut()
|
||||
{
|
||||
*r = r.union(*rect);
|
||||
} else if let Some(c) = current.take() {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(c) = current {
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) {
|
||||
let Some(row) = rows.get(k) else { return };
|
||||
if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) {
|
||||
// A run of rows of the same kind (top level, or inside groups) between the anchor and here.
|
||||
let (lo, hi) = if a <= k { (a, k) } else { (k, a) };
|
||||
let top = row.parent.is_none();
|
||||
let ids: Vec<u32> = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect();
|
||||
run(app, "selection.select", json!({"ids": ids, "add": ctrl}));
|
||||
return;
|
||||
}
|
||||
run(app, "selection.select", json!({"id": row.id, "toggle": ctrl}));
|
||||
app.selpane.anchor = Some(row.id);
|
||||
}
|
||||
|
||||
fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) {
|
||||
let (up, down, home, end, f2, esc) = ui.input(|i| {
|
||||
(
|
||||
i.key_pressed(egui::Key::ArrowUp),
|
||||
i.key_pressed(egui::Key::ArrowDown),
|
||||
i.key_pressed(egui::Key::Home),
|
||||
i.key_pressed(egui::Key::End),
|
||||
i.key_pressed(egui::Key::F2),
|
||||
i.key_pressed(egui::Key::Escape),
|
||||
)
|
||||
});
|
||||
let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id));
|
||||
let to = if up {
|
||||
Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0))
|
||||
} else if down {
|
||||
Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0))
|
||||
} else if home {
|
||||
Some(0)
|
||||
} else if end {
|
||||
Some(rows.len().saturating_sub(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(k) = to
|
||||
&& let Some(r) = rows.get(k)
|
||||
&& Some(k) != cur
|
||||
{
|
||||
run(app, "selection.select", json!({"id": r.id}));
|
||||
app.selpane.anchor = Some(r.id);
|
||||
ui.memory_mut(|m| m.request_focus(row_id(r.id)));
|
||||
}
|
||||
if f2
|
||||
&& let [one] = selected
|
||||
&& let Some(r) = rows.iter().find(|r| r.id == *one)
|
||||
{
|
||||
app.selpane.renaming = Some((r.id, r.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
}
|
||||
if esc {
|
||||
ui.memory_mut(|m| m.surrender_focus(row_id(focused)));
|
||||
}
|
||||
}
|
||||
|
||||
fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) {
|
||||
resp.context_menu(|ui| {
|
||||
for (label, cmd) in [
|
||||
("Bring to Front", "arrange.bringToFront"),
|
||||
("Bring Forward", "arrange.bringForward"),
|
||||
("Send Backward", "arrange.sendBackward"),
|
||||
("Send to Back", "arrange.sendToBack"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
run(app, cmd, json!({"ids": [row.id]}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename").clicked() {
|
||||
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||
app.selpane.rename_started = false;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() {
|
||||
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() {
|
||||
run(app, "shape.lock", json!({"id": row.id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r(id: u32, top: f32) -> (u32, Rect) {
|
||||
(id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_picks_the_nearest_gap() {
|
||||
// Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1.
|
||||
let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)];
|
||||
// Above everything: in front of all three others.
|
||||
assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0)));
|
||||
// Between 4 and 3: one sibling above → index 2.
|
||||
assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0)));
|
||||
// Below the last other row: the back.
|
||||
assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0)));
|
||||
// Nothing else to drop among.
|
||||
assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selpane_tests.rs"]
|
||||
mod ui_tests;
|
||||
@@ -0,0 +1,255 @@
|
||||
//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye,
|
||||
//! the padlock, renaming and the keyboard.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Modifiers, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open.
|
||||
fn harness() -> (Harness<'static, SlideApp>, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
ids.push(g);
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.pane = Some("selection".into());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn names(h: &Harness<'_, SlideApp>) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(h.state().session.active().unwrap().shapes())
|
||||
}
|
||||
|
||||
fn selected(h: &Harness<'_, SlideApp>) -> Vec<String> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect {
|
||||
h.get_by_label(name).rect()
|
||||
}
|
||||
|
||||
fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(st.shapes(), &mut |s, _| {
|
||||
if found.is_none() && s.name == name {
|
||||
found = Some(s);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_front_most_first_with_groups_expanded() {
|
||||
let (h, _) = harness();
|
||||
let ys: Vec<f32> = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect();
|
||||
// G is in front, then its children front-most first (E above D), then C, B, A.
|
||||
let order = ["G", "E", "D", "C", "B", "A"];
|
||||
let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect();
|
||||
by_y.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
assert_eq!(by_y.iter().map(|x| x.0).collect::<Vec<_>>(), order);
|
||||
// Children are indented under the group (the name's row starts at the same x; the group
|
||||
// has a fold button).
|
||||
assert!(h.query_by_label("Collapse G").is_some());
|
||||
assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_ctrl_click_and_shift_click_select() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("A").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["A"]);
|
||||
h.get_by_label("C").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["A", "C"]);
|
||||
h.get_by_label("A").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["C"]);
|
||||
// Shift+click: the run from the last click (C) down to A.
|
||||
h.get_by_label("C").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("A").click_modifiers(Modifiers::SHIFT);
|
||||
h.run_steps(2);
|
||||
let mut sel = selected(&h);
|
||||
sel.sort();
|
||||
assert_eq!(sel, ["A", "B", "C"]);
|
||||
// A child of the group.
|
||||
h.get_by_label("D").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["D"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_and_padlock_toggle_and_undo() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("Hide B").click();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "B").hidden);
|
||||
assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it");
|
||||
h.get_by_label("Lock G").click();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children");
|
||||
h.get_by_label("Unlock D").click();
|
||||
h.run_steps(2);
|
||||
assert!(!shape(&h, "D").locked && shape(&h, "G").locked);
|
||||
h.get_by_label("Hide All").click();
|
||||
h.run_steps(2);
|
||||
assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden));
|
||||
h.get_by_label("Show All").click();
|
||||
h.run_steps(2);
|
||||
assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(shape(&h, "A").hidden);
|
||||
}
|
||||
|
||||
/// Drag a row with the mouse: press, move in steps, release.
|
||||
fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) {
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
let t = i as f32 / 6.0;
|
||||
h.hover_at(from + (to - from) * t);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_restacks_among_siblings() {
|
||||
let (mut h, _) = harness();
|
||||
assert_eq!(names(&h), "A B C G[D E]");
|
||||
// A (bottom row) dragged above G (top row): A goes to the front.
|
||||
let (a, g) = (row(&h, "A"), row(&h, "G"));
|
||||
drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0));
|
||||
assert_eq!(names(&h), "B C G[D E] A");
|
||||
assert_eq!(selected(&h), ["A"], "the dragged object is selected");
|
||||
// C dragged to the bottom of the list: the back.
|
||||
let (c, b) = (row(&h, "C"), row(&h, "B"));
|
||||
drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0));
|
||||
assert_eq!(names(&h), "C B G[D E] A");
|
||||
// D (a child) dragged far above its group stays in the group, at its front.
|
||||
let (d, a) = (row(&h, "D"), row(&h, "A"));
|
||||
drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0));
|
||||
assert_eq!(names(&h), "C B G[E D] A");
|
||||
// B dropped over the group lands among the top-level objects (here just in front of the
|
||||
// group), never inside it.
|
||||
let (b, g) = (row(&h, "B"), row(&h, "G"));
|
||||
drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0));
|
||||
assert_eq!(names(&h), "C G[E D] B A");
|
||||
// Each drop is one undo step.
|
||||
for _ in 0..4 {
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(names(&h), "A B C G[D E]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_buttons() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("A").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[D E]");
|
||||
h.get_by_label("D").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Bring Forward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group");
|
||||
h.get_by_label("Send Backward").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), "B A C G[D E]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_renames_in_place() {
|
||||
let (mut h, _) = harness();
|
||||
let b = row(&h, "B").center();
|
||||
h.hover_at(b);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming");
|
||||
// The whole name is selected: typing replaces it.
|
||||
h.event(egui::Event::Text("Bravo".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id);
|
||||
assert!(h.state().selpane.renaming.is_none());
|
||||
// Escape cancels.
|
||||
h.get_by_label("Bravo").click();
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::F2);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object");
|
||||
h.event(egui::Event::Text("Nope".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Bravo").is_some());
|
||||
assert!(h.query_by_label("Nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_through_the_list_and_collapse_hides_children() {
|
||||
let (mut h, _) = harness();
|
||||
h.get_by_label("G").click();
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["E"]);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["C"]);
|
||||
// The arrows moved through the list: C itself didn't move on the slide.
|
||||
assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0));
|
||||
h.key_press(egui::Key::ArrowUp);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected(&h), ["D"]);
|
||||
h.get_by_label("Collapse G").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some());
|
||||
h.get_by_label("Expand G").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("D").is_some());
|
||||
}
|
||||
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
app.notes_buf = (uid, idx, current.clone());
|
||||
}
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if !focused && app.notes_buf.2 != current {
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
|
||||
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
|
||||
//!
|
||||
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
|
||||
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Rect;
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use deckcraft_render::{Image, Region, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -15,11 +19,15 @@ struct Key {
|
||||
size: (u32, u32),
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
key: Key,
|
||||
tex: TextureHandle,
|
||||
/// The region the texture covers (at least `key.want`).
|
||||
region: Region,
|
||||
// Keep the Arcs alive so their addresses can't be reused while cached.
|
||||
_slide: Arc<Slide>,
|
||||
_master: Option<Arc<Master>>,
|
||||
@@ -29,6 +37,14 @@ struct Entry {
|
||||
#[derive(Default)]
|
||||
pub struct Textures {
|
||||
canvas: Option<Entry>,
|
||||
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
|
||||
/// held (kept alive so their addresses can't be reused).
|
||||
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
|
||||
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
|
||||
/// its box shows there.
|
||||
drawn: Option<(usize, Rect)>,
|
||||
/// Canvas renders so far (tests, performance checks).
|
||||
pub canvas_renders: u64,
|
||||
thumbs: HashMap<usize, Entry>,
|
||||
clock: u64,
|
||||
pub last_render_ms: f64,
|
||||
@@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
|
||||
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn threads() -> u16 {
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
||||
}
|
||||
|
||||
impl Textures {
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
p: &Presentation,
|
||||
index: usize,
|
||||
size: (u32, u32),
|
||||
visible: Region,
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
) -> Option<TextureHandle> {
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let estimate = match &self.estimate {
|
||||
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
|
||||
_ => {
|
||||
let r = deckcraft_render::slide_extent(p, index);
|
||||
self.estimate = Some((slide.clone(), master.clone(), r));
|
||||
r
|
||||
}
|
||||
};
|
||||
let reach = match self.drawn {
|
||||
Some((i, d)) if i == index => estimate.union(d),
|
||||
_ => estimate,
|
||||
};
|
||||
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||
let key = Key {
|
||||
slide: Arc::as_ptr(&slide) as usize,
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size,
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64,
|
||||
want,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
{
|
||||
return Some(e.tex.clone());
|
||||
return Some((e.tex.clone(), e.region));
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
||||
let render = |region: Region| {
|
||||
let opts =
|
||||
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
};
|
||||
let (mut img, drawn) = render(want);
|
||||
self.canvas_renders += 1;
|
||||
let mut region = want;
|
||||
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||
// more than this render covered.
|
||||
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||
if !region.contains(&key.want) {
|
||||
region = key.want;
|
||||
img = render(region).0;
|
||||
self.canvas_renders += 1;
|
||||
}
|
||||
self.drawn = Some((index, drawn));
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
@@ -99,13 +158,15 @@ impl Textures {
|
||||
}
|
||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
|
||||
Some(tex)
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
}
|
||||
|
||||
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
|
||||
pub fn invalidate(&mut self) {
|
||||
self.canvas = None;
|
||||
self.estimate = None;
|
||||
self.drawn = None;
|
||||
self.thumbs.clear();
|
||||
}
|
||||
|
||||
@@ -121,6 +182,7 @@ impl Textures {
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
@@ -139,7 +201,8 @@ impl Textures {
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
||||
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
if self.thumbs.len() > 600 {
|
||||
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
|
||||
v.sort_by_key(|x| x.1);
|
||||
|
||||
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
install_fonts_with(ctx, &[]);
|
||||
}
|
||||
|
||||
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
|
||||
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
|
||||
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
|
||||
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
ctx.set_fonts(font_definitions(extra));
|
||||
}
|
||||
|
||||
/// The interface font definitions; see [`install_fonts_with`].
|
||||
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
|
||||
let mut defs = FontDefinitions::default();
|
||||
let mut names = vec![];
|
||||
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
||||
@@ -178,7 +190,34 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
|
||||
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
||||
ctx.set_fonts(defs);
|
||||
add_fallback_faces(&mut defs, extra);
|
||||
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
|
||||
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
|
||||
// unhinted, they draw.
|
||||
for (name, d) in defs.font_data.iter_mut() {
|
||||
if name.contains("BIZ UDPGothic") {
|
||||
let mut data = (**d).clone();
|
||||
data.tweak.hinting = Some(false);
|
||||
*d = std::sync::Arc::new(data);
|
||||
}
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
/// Add `faces` after every other font of each interface family (the last fallbacks).
|
||||
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
for f in faces {
|
||||
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
|
||||
if defs.font_data.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let mut data = FontData::from_owned(f.data().to_vec());
|
||||
data.index = f.index();
|
||||
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
|
||||
for list in defs.families.values_mut() {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(size: f32) -> egui::FontId {
|
||||
@@ -192,3 +231,38 @@ pub fn font(size: f32) -> egui::FontId {
|
||||
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
|
||||
/// has ink (a glyph image).
|
||||
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(super::font_definitions(&[]));
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
let mut out = vec![];
|
||||
ctx.fonts_mut(|f| {
|
||||
for size in [11.0, 12.0, 13.0, 16.0] {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
|
||||
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
|
||||
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
|
||||
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||
o.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
// so notes and the outline showed gaps where they were.
|
||||
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
|
||||
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Brief notices over the window's bottom edge: shown once, gone after a few seconds or when
|
||||
//! closed. Used to say a presentation's fonts aren't installed when it is first opened.
|
||||
|
||||
use egui::{Align2, CornerRadius, Margin, Stroke, vec2};
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// How long a toast stays, in milliseconds.
|
||||
const SHOW_MS: f64 = 8000.0;
|
||||
|
||||
pub struct Toast {
|
||||
pub text: String,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
/// The notice for fonts a presentation uses that aren't installed, or `None` when all are.
|
||||
pub fn missing_fonts_message(fonts: &[String]) -> Option<String> {
|
||||
let names = match fonts {
|
||||
[] => return None,
|
||||
[a] => a.clone(),
|
||||
[a, b] => format!("{a} and {b}"),
|
||||
[a, b, c] => format!("{a}, {b} and {c}"),
|
||||
[a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()),
|
||||
};
|
||||
let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") };
|
||||
Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font."))
|
||||
}
|
||||
|
||||
/// Toast the active presentation's missing fonts, once per opened document.
|
||||
pub fn note_missing_fonts(app: &mut SlideApp) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
if !app.fonts_noted.insert(d.uid) {
|
||||
return;
|
||||
}
|
||||
if let Some(text) = missing_fonts_message(&d.missing_fonts) {
|
||||
app.toast = Some(Toast { text, at: crate::now_ms() });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let Some(toast) = &app.toast else { return };
|
||||
if crate::now_ms() - toast.at > SHOW_MS {
|
||||
app.toast = None;
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let text = toast.text.clone();
|
||||
let mut close = false;
|
||||
egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.inner_margin(Margin::symmetric(14, 10))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow })
|
||||
.show(ui, |ui| {
|
||||
ui.set_max_width(520.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false);
|
||||
if resp.on_hover_text("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if close {
|
||||
app.toast = None;
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_fonts() {
|
||||
let f = |v: &[&str]| missing_fonts_message(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
assert_eq!(f(&[]), None);
|
||||
assert_eq!(f(&["Aptos"]).unwrap(), "Aptos isn't installed on this computer. Text that uses it is shown in a similar font.");
|
||||
assert!(f(&["Aptos", "Sora"]).unwrap().starts_with("Aptos and Sora aren't installed"));
|
||||
assert!(f(&["A", "B", "C"]).unwrap().starts_with("A, B and C aren't"));
|
||||
assert!(f(&["A", "B", "C", "D", "E"]).unwrap().starts_with("A, B, C and 2 more aren't"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
|
||||
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
|
||||
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
|
||||
//! character on screen that they lack, a face that has it is borrowed from the font database (the
|
||||
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
|
||||
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
|
||||
//! there are no installed fonts, only the embedded faces are used.
|
||||
//!
|
||||
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
|
||||
//! the composition and caption to every text the interface draws from the presentation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_fonts::{FontDb, FontFace};
|
||||
|
||||
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
|
||||
const MAX_FACE_BYTES: usize = 48 << 20;
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
/// Faces added after the interface fonts, in the order they were found.
|
||||
pub faces: Vec<Arc<FontFace>>,
|
||||
/// Characters already looked at (drawn, given a face, or found in no face).
|
||||
seen: HashSet<char>,
|
||||
/// The presentation last looked through: (uid, revision).
|
||||
doc: Option<(u64, u64)>,
|
||||
/// Faces were added since the fonts were last installed.
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
|
||||
fn wanted(c: char) -> bool {
|
||||
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
|
||||
}
|
||||
|
||||
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
|
||||
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
|
||||
/// very large faces are skipped (egui draws neither well).
|
||||
pub fn faces_for_text(
|
||||
text: &str,
|
||||
have: &[Arc<FontFace>],
|
||||
seen: &mut HashSet<char>,
|
||||
mut covered: impl FnMut(char) -> bool,
|
||||
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
|
||||
) -> Vec<Arc<FontFace>> {
|
||||
let mut out: Vec<Arc<FontFace>> = Vec::new();
|
||||
for c in text.chars().filter(|c| wanted(*c)) {
|
||||
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl UiFonts {
|
||||
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
|
||||
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
|
||||
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
|
||||
return;
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
self.faces.extend(new);
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Look through the text of a presentation the interface shows (once per revision): its
|
||||
/// title, slide text and names, notes, comments, sections and layout names.
|
||||
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
|
||||
if self.doc == Some((st.uid, st.revision)) {
|
||||
return;
|
||||
}
|
||||
self.doc = Some((st.uid, st.revision));
|
||||
let text = ui_text(st);
|
||||
self.note(ctx, &text);
|
||||
}
|
||||
|
||||
/// Install the interface fonts again when faces were added. True if they were.
|
||||
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
|
||||
if !std::mem::take(&mut self.changed) {
|
||||
return false;
|
||||
}
|
||||
crate::theme::install_fonts_with(ctx, &self.faces);
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a presentation that the interface can show (not the slides' pictures of it).
|
||||
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
|
||||
let mut out = st.title();
|
||||
let mut add = |s: &str| {
|
||||
if s.chars().any(wanted) {
|
||||
out.push('\n');
|
||||
out.push_str(s);
|
||||
}
|
||||
};
|
||||
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
|
||||
for p in &b.paragraphs {
|
||||
for r in &p.runs {
|
||||
add(&r.text);
|
||||
}
|
||||
}
|
||||
};
|
||||
let doc = &st.doc;
|
||||
add(&doc.props.title);
|
||||
for sec in &doc.sections {
|
||||
add(&sec.name);
|
||||
}
|
||||
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
|
||||
deckcraft_model::walk(list, &mut |sh, _| {
|
||||
add(&sh.name);
|
||||
add(&sh.title);
|
||||
add(&sh.descr);
|
||||
if let Some(t) = &sh.text {
|
||||
body(t, add);
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
for row in &tb.rows {
|
||||
for cell in &row.cells {
|
||||
body(&cell.text, add);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
for s in &doc.slides {
|
||||
add(&s.name);
|
||||
shapes(&s.shapes, &mut add);
|
||||
body(&s.notes, &mut add);
|
||||
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
|
||||
while let Some(c) = stack.pop() {
|
||||
add(&c.author);
|
||||
add(&c.text);
|
||||
stack.extend(c.replies.iter());
|
||||
}
|
||||
}
|
||||
for m in &doc.masters {
|
||||
add(&m.name);
|
||||
for l in &m.layouts {
|
||||
add(&l.name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn face(family: &str) -> Arc<FontFace> {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
fn one_face_per_script_and_each_character_once() {
|
||||
let inter = face("Inter");
|
||||
let looked = std::cell::Cell::new(0);
|
||||
let lookup = |_: char| {
|
||||
looked.set(looked.get() + 1);
|
||||
Some(inter.clone())
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
|
||||
assert_eq!(got.len(), 1, "one face, not one per character");
|
||||
// Inter doesn't cover Hangul, so every character was looked up once.
|
||||
assert_eq!(looked.get(), 5);
|
||||
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
|
||||
assert_eq!(looked.get(), 5);
|
||||
let mut seen = HashSet::new();
|
||||
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
|
||||
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
|
||||
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_text_of_a_presentation() {
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
|
||||
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
|
||||
let t = ui_text(s.doc().unwrap());
|
||||
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
|
||||
assert!(t.starts_with(&s.doc().unwrap().title()));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
|
||||
| Layer | Crates | What they do |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
|
||||
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
|
||||
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
|
||||
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
|
||||
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
|
||||
|
||||
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
|---|---|---|---|
|
||||
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
|
||||
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
|
||||
| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture |
|
||||
| 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,13 +63,13 @@ 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 (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
|
||||
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| Tables with styles | yes | yes | |
|
||||
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
|
||||
@@ -93,10 +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. EMF/WMF and SVG rendering.
|
||||
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 |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user