mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-14 01:42:18 -06:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69379cf689 | ||
|
|
7a24b20c8e | ||
|
|
ef9ac9ac7a | ||
|
|
eebede8ee4 | ||
|
|
a024a0e86c | ||
|
|
9850dc6088 | ||
|
|
3ce1886bea | ||
|
|
0a75620a73 | ||
|
|
74a0468917 | ||
|
|
6166cb0870 | ||
|
|
d2ee3ca9fc | ||
|
|
506db24b07 | ||
|
|
7f9acc1c27 | ||
|
|
3a92bcc93c | ||
|
|
18355eeca2 | ||
|
|
15a75a2662 | ||
|
|
2f8398d87b | ||
|
|
718b6da035 | ||
|
|
f78a805ac7 | ||
|
|
ec2937f298 | ||
|
|
5e771693fc | ||
|
|
1d584737b1 | ||
|
|
b8c19475d2 | ||
|
|
2d1e6ff348 | ||
|
|
ac3e46b748 | ||
|
|
6a3cbd18d2 | ||
|
|
5914363930 | ||
|
|
e0b0817288 | ||
|
|
63331b8ab6 | ||
|
|
3975b8cc5e | ||
|
|
db257178f5 | ||
|
|
e8802ccfbd | ||
|
|
730c6cde9c | ||
|
|
b7e5cf05ea | ||
|
|
ce2bd95414 | ||
|
|
41dd2bd3bb | ||
|
|
1469419354 | ||
|
|
70a0d7a2d8 | ||
|
|
ea97b18c70 | ||
|
|
17db75b74a | ||
|
|
cdca5bd09d | ||
|
|
7ebfa82297 | ||
|
|
de5398cfc5 | ||
|
|
2750e65096 | ||
|
|
c564070d9e | ||
|
|
3def50c98a | ||
|
|
a570031909 | ||
|
|
9d9610a893 | ||
|
|
5a2cab9483 | ||
|
|
3e2dc9ca7f | ||
|
|
d680914140 | ||
|
|
1c8b14edad | ||
|
|
09548fe55c | ||
|
|
ea809d93f5 | ||
|
|
26c1ac83dc | ||
|
|
23d0f67d17 | ||
|
|
fb67888e25 | ||
|
|
9e6758e276 | ||
|
|
6c0886a68a |
Generated
+22
-20
@@ -847,7 +847,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-audio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"thiserror 2.0.21",
|
||||
@@ -856,12 +856,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -884,15 +884,26 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metafile"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -901,7 +912,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -921,7 +932,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-video"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"rayon",
|
||||
@@ -1174,15 +1185,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.18.1"
|
||||
@@ -1233,11 +1235,11 @@ dependencies = [
|
||||
name = "deckcraft-render"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-metafile",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-metafile",
|
||||
"deckcraft-model",
|
||||
"deckcraft-text",
|
||||
"image",
|
||||
@@ -2741,7 +2743,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=63196e66f3033a6623eb629af95a104d26986725#63196e66f3033a6623eb629af95a104d26986725"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3520,7 +3522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+9
-8
@@ -14,7 +14,6 @@ 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" }
|
||||
@@ -50,14 +49,16 @@ vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
# craft-media, craft-audio and craft-video share types and stay on one rev.
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false }
|
||||
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0", default-features = false }
|
||||
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
|
||||
# H.264, HEVC, VP9 and AV1 decoders.
|
||||
craft-video = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["h264-decode", "hevc-decode", "vp9-decode", "av1-decode", "threads"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-video = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0", default-features = false, features = ["h264-decode", "hevc-decode", "vp9-decode", "av1-decode", "threads"] }
|
||||
# WMF, EMF and EMF+ reader.
|
||||
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "63196e66f3033a6623eb629af95a104d26986725", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -103,7 +104,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "63196e66f3033a6623eb629af95a104d26986725", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (slide-list entries with missing relationships are reported as lost slides and open as repaired copies; the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -155,6 +155,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
|
||||
| 2026-11-07 | PPTX slide-list entries with missing relationships are reported in lostSlides instead of silently omitted. The engine opens the partial deck as a repaired copy with no original save path. Slide lists over 10,000 entries are explicitly refused before a partial load; exactly 10,000 remain supported. Synthetic 13-slide regression; does not establish the cause of #1910's field report. |
|
||||
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
|
||||
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
|
||||
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
|
||||
|
||||
@@ -73,3 +73,7 @@ sync_github_name = true
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
[people.adikshan11]
|
||||
real_name = "Adithya Shankaran"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -365,6 +365,38 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(sh);
|
||||
}
|
||||
*list = out;
|
||||
// A group inside a group: its children take its place in the outer group, mapped into the
|
||||
// outer group's child space, where the inner group's own box is.
|
||||
for id in &ids {
|
||||
let Some(siblings) = deckcraft_model::sibling_list_mut(list, *id) else { continue };
|
||||
let Some(at) = siblings.iter().position(|x| x.id == *id) else { continue };
|
||||
let freed: Vec<Shape> = match siblings.get(at) {
|
||||
Some(Shape { kind: ShapeKind::Group { children, child }, xfrm: Some(gx), .. }) => children
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
if let Some(cx) = c.xfrm {
|
||||
c.xfrm = Some(child_to_parent(gx, child, &cx));
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect(),
|
||||
_ => continue,
|
||||
};
|
||||
let freed_ids: Vec<ShapeId> = freed.iter().map(|c| c.id).collect();
|
||||
siblings.splice(at..=at, freed);
|
||||
// Ungrouped along with an outer group, it was listed among that group's children (once per expanded ancestor).
|
||||
match new_sel.iter().position(|x| x == id) {
|
||||
Some(i) => {
|
||||
new_sel.splice(i..=i, freed_ids);
|
||||
new_sel.retain(|x| x != id);
|
||||
}
|
||||
None => new_sel.extend(freed_ids),
|
||||
}
|
||||
}
|
||||
// A shape freed from a named group and then from a named group around it is listed once.
|
||||
let mut listed = std::collections::HashSet::new();
|
||||
new_sel.retain(|x| listed.insert(*x));
|
||||
sel.shapes = new_sel.clone();
|
||||
Ok(json!({"ids": new_sel}))
|
||||
})
|
||||
|
||||
@@ -120,6 +120,12 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A shape in a group is copied with the box it is drawn in, so it pastes where it was seen.
|
||||
for sh in &mut shapes {
|
||||
if let Some(x) = sh.xfrm.and_then(|x| box_on_slide(&st.doc, &st.selection, st.shapes(), sh.id, &x)) {
|
||||
sh.xfrm = Some(x);
|
||||
}
|
||||
}
|
||||
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
|
||||
// paste can be in another deck. Slide Master view pastes the originals instead.
|
||||
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
|
||||
@@ -147,6 +153,39 @@ pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::
|
||||
}
|
||||
}
|
||||
|
||||
/// Where shape `id` is drawn on the slide when it is inside a group: `x`, its box in its group's
|
||||
/// space, mapped out through each group around it, as Ungroup places a freed shape. `None` for a
|
||||
/// shape on the top level or not found. A turned shape in a group stretched more one way than the
|
||||
/// other is drawn skewed, which a box cannot hold, so it gets the unskewed box Ungroup gives it.
|
||||
fn box_on_slide(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
shapes: &[Shape],
|
||||
id: ShapeId,
|
||||
x: &deckcraft_geom::Xfrm,
|
||||
) -> Option<deckcraft_geom::Xfrm> {
|
||||
fn rec(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
shapes: &[Shape],
|
||||
id: ShapeId,
|
||||
x: &deckcraft_geom::Xfrm,
|
||||
depth: usize,
|
||||
) -> Option<deckcraft_geom::Xfrm> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
shapes.iter().find_map(|sh| {
|
||||
let deckcraft_model::ShapeKind::Group { children, child } = &sh.kind else {
|
||||
return None;
|
||||
};
|
||||
let inner = if children.iter().any(|c| c.id == id) { *x } else { rec(doc, sel, children, id, x, depth + 1)? };
|
||||
Some(super::arrange::child_to_parent(&xfrm_of(doc, sel, sh), child, &inner))
|
||||
})
|
||||
}
|
||||
rec(doc, sel, shapes, id, x, 0)
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
@@ -559,6 +598,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (new_ids, made) = s.edit(|doc, sel| {
|
||||
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
// A shape inside a group is copied into that group, with the map from the group's space onto
|
||||
// the slide so that its copy is offset on the slide as a top-level copy is.
|
||||
let top = copies.len();
|
||||
let mut to_slide = vec![None; top];
|
||||
for id in grouped_targets(&list, &ids) {
|
||||
if let Some((m, a)) = crate::connect::locate(doc, sel, &list, id) {
|
||||
copies.push(m.clone());
|
||||
to_slide.push(Some(a));
|
||||
}
|
||||
}
|
||||
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
detach_on_slide(doc, sel, &mut copies);
|
||||
@@ -570,10 +619,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.and_then(|(_, _, pairs)| {
|
||||
let (src, copy) = pairs.first()?;
|
||||
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
|
||||
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
|
||||
let d = (b.x - a.x, b.y - a.y);
|
||||
(d.0.is_finite() && d.1.is_finite()).then_some(d)
|
||||
// Measured on the slide, so the copy of a shape in a group repeats its drawn move.
|
||||
let (a, to_a) = crate::connect::locate(doc, sel, &list, *src)?;
|
||||
let (b, to_b) = crate::connect::locate(doc, sel, &list, *copy)?;
|
||||
let (a, b) = (xfrm_of(doc, sel, a), xfrm_of(doc, sel, b));
|
||||
let d = to_b * deckcraft_geom::Point::new(b.x, b.y) - to_a * deckcraft_geom::Point::new(a.x, a.y);
|
||||
(d.x.is_finite() && d.y.is_finite()).then_some((d.x, d.y))
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
@@ -588,7 +639,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_automatic(c, &mut taken, count);
|
||||
count += 1;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
for ((c, b), to) in copies.iter_mut().zip(boxes).zip(&to_slide) {
|
||||
let (dx, dy) = to.map_or((dx, dy), |a| offset_in(a, dx, dy));
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
x.y += dy;
|
||||
@@ -599,7 +651,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
shapes.extend(copies);
|
||||
let mut copies = copies.into_iter();
|
||||
shapes.extend(copies.by_ref().take(top));
|
||||
// A member's copy goes last among its group's children, as a top-level copy goes last on the slide.
|
||||
for (c, src) in copies.zip(sources.iter().skip(top)) {
|
||||
deckcraft_model::sibling_list_mut(shapes, *src).ok_or_else(|| bad("edit.duplicate", "no such shape"))?.push(c);
|
||||
}
|
||||
sel.shapes = new_ids.clone();
|
||||
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
|
||||
Ok((new_ids, made))
|
||||
@@ -608,6 +665,34 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": new_ids}))
|
||||
}
|
||||
|
||||
/// The shapes in `ids` that are inside groups, in drawing order, without those in a group that is
|
||||
/// in `ids` too: they are copied along with it.
|
||||
fn grouped_targets(list: &[Shape], ids: &[ShapeId]) -> Vec<ShapeId> {
|
||||
fn rec(shapes: &[Shape], ids: &[ShapeId], nested: bool, out: &mut Vec<ShapeId>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if !ids.contains(&sh.id) {
|
||||
rec(sh.children(), ids, true, out, depth + 1);
|
||||
} else if nested {
|
||||
out.push(sh.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
rec(list, ids, false, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// The offset in the space `to_slide` maps onto the slide that moves a shape by (`dx`, `dy`) on
|
||||
/// the slide. A group with no width or height draws nothing, and the offset stays as it is.
|
||||
fn offset_in(to_slide: deckcraft_geom::Affine, dx: f64, dy: f64) -> (f64, f64) {
|
||||
let back = to_slide.inverse();
|
||||
let v = back * deckcraft_geom::Point::new(dx, dy) - back * deckcraft_geom::Point::ZERO;
|
||||
if v.x.is_finite() && v.y.is_finite() { (v.x, v.y) } else { (dx, dy) }
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() && ids_param(p, "ids").is_none() {
|
||||
return super::text::delete_dir(s, "forward");
|
||||
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"format.textTransparency",
|
||||
"Text Transparency",
|
||||
["Shape Format", "WordArt Styles"],
|
||||
["Shape Format", "Decorative Text Styles"],
|
||||
None,
|
||||
"{value: 0..1} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)",
|
||||
has_text_or_shapes,
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"format.textOutline",
|
||||
"Text Outline",
|
||||
["Shape Format", "WordArt Styles"],
|
||||
["Shape Format", "Decorative Text Styles"],
|
||||
None,
|
||||
"{color?, width?: pt} (no color = none)",
|
||||
has_text_or_shapes,
|
||||
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"format.textWarp",
|
||||
"Text Transform",
|
||||
["Shape Format", "WordArt Styles"],
|
||||
["Shape Format", "Decorative Text Styles"],
|
||||
None,
|
||||
"{preset: a prstTxWarp name or none, adj?: [finite raw guide values, at most 8]} — none clears the transform; omitted adj uses the preset's defaults",
|
||||
has_text_or_shapes,
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio),
|
||||
cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video),
|
||||
cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art),
|
||||
cmd!("insert.wordArt", "Decorative Text", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art),
|
||||
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
|
||||
cmd!(
|
||||
"insert.dateTime",
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"insert.smartArt",
|
||||
"SmartArt",
|
||||
"Diagram",
|
||||
["Insert", "Illustrations"],
|
||||
None,
|
||||
"{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}",
|
||||
|
||||
@@ -24,12 +24,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge Shapes",
|
||||
["Shape Format", "Insert Shapes", "Merge Shapes"],
|
||||
None,
|
||||
"{op: union|combine|fragment|intersect|subtract, ids?: shapes in selection order (the first one's formatting is kept)} → {ids}",
|
||||
has_two,
|
||||
"{op: union|combine|fragment|intersect|subtract, ids?: shapes in selection order (the first one's formatting is kept)} → {ids} (a shape inside a group is refused; ungroup it first)",
|
||||
can_merge,
|
||||
merge
|
||||
)]
|
||||
}
|
||||
|
||||
/// Why Merge Shapes is unavailable while a shape inside a group is selected.
|
||||
pub(crate) const IN_GROUP: &str = "a shape in a group can't be merged; ungroup it first";
|
||||
|
||||
/// Two or more shapes selected, none of them inside a group: the result replaces its sources in
|
||||
/// the slide's top-level list, so a member would be drawn outside its group's scale and offset.
|
||||
fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
has_two(s)?;
|
||||
let in_group =
|
||||
s.active().is_some_and(|st| st.selection.shapes.iter().any(|id| st.shape(*id).is_some() && !st.shapes().iter().any(|t| t.id == *id)));
|
||||
if in_group { Err(IN_GROUP.into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The filled outline of `s` in slide points (curves flattened). A text box with no fill and no
|
||||
/// outline is its letters, as in PowerPoint (merging text with a picture fills the letters).
|
||||
fn area_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area {
|
||||
@@ -197,6 +209,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let shapes: Vec<Shape> = ids.iter().filter_map(|id| st.shape(*id).cloned()).collect();
|
||||
if let Some(member) = shapes.iter().find(|x| !st.shapes().iter().any(|t| t.id == x.id)) {
|
||||
let name = if member.name.is_empty() { format!("shape {}", member.id.0) } else { format!("`{}`", member.name) };
|
||||
return Err(bad("shape.merge", format!("{name} is in a group; ungroup it first to merge it")));
|
||||
}
|
||||
let mergeable = |x: &Shape| matches!(x.kind, ShapeKind::Shape | ShapeKind::Picture { .. }) && !x.is_line();
|
||||
if shapes.len() < 2 || !shapes.iter().all(mergeable) {
|
||||
return Err(bad("shape.merge", "select two or more shapes (not lines, groups, tables or charts)"));
|
||||
@@ -379,4 +395,40 @@ mod tests {
|
||||
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Shapes refuses a shape inside a group, naming it, and is unavailable while one is
|
||||
/// selected. Before, the result went to the slide's top level with the member's box from the
|
||||
/// group's own space, so the shapes of a moved or resized group were redrawn somewhere else.
|
||||
#[test]
|
||||
fn a_shape_in_a_group_is_refused() {
|
||||
fn rect(s: &mut Session, x: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 500.0));
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.move", &json!({"ids": [g], "dx": 300})).unwrap();
|
||||
let before = s.doc().unwrap().current_slide().unwrap().shapes.clone();
|
||||
let name = s.doc().unwrap().shape(ShapeId(a as u32)).unwrap().name.clone();
|
||||
for ids in [json!([a, b]), json!([c, a])] {
|
||||
let err = s.execute("shape.merge", &json!({"op": "union", "ids": ids})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{name}` is in a group; ungroup it first")), "{ids}: {err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes, before, "a refused merge leaves the slide as it was");
|
||||
|
||||
let spec = crate::cmd::find_command("shape.merge").unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [a, c]})).unwrap();
|
||||
assert_eq!((spec.enabled)(&s), Err("a shape in a group can't be merged; ungroup it first".to_string()));
|
||||
assert!(s.execute("shape.merge", &json!({"op": "union"})).is_err());
|
||||
// Named top-level shapes don't depend on the selection.
|
||||
let d = rect(&mut s, 550.0);
|
||||
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!(s.execute("shape.merge", &json!({"op": "union", "ids": [c, d]})).unwrap()["ids"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
s.execute("arrange.ungroup", &json!({"ids": [g]})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!((spec.enabled)(&s), Ok(()));
|
||||
assert_eq!(s.execute("shape.merge", &json!({"op": "union"})).unwrap()["ids"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut graphic = false;
|
||||
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
|
||||
if graphic {
|
||||
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
return Err(bad("shape.flip", "Flip is not available for a Diagram graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
}
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
|
||||
@@ -162,7 +162,7 @@ pub fn preview(kind: &str, style: &str, colors: &str, nodes: Option<&[DiagramNod
|
||||
}
|
||||
Shape {
|
||||
id: ShapeId(9_999),
|
||||
name: "SmartArt preview".into(),
|
||||
name: "Diagram preview".into(),
|
||||
xfrm: Some(area),
|
||||
kind: ShapeKind::Group { children, child: area },
|
||||
..Default::default()
|
||||
@@ -178,16 +178,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
query "smartArt.nodes",
|
||||
"Text Pane",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
["Diagram Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?} → {id, kind, nodes: [{text, level}]} (id: the SmartArt group or one of its shapes; default the selection)",
|
||||
"{id?} → {id, kind, nodes: [{text, level}]} (id: the Diagram group or one of its shapes; default the selection)",
|
||||
has_selection,
|
||||
nodes
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.setNodes",
|
||||
"Edit SmartArt Text",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
"Edit Diagram Text",
|
||||
["Diagram Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?, title?} (index + text edits one node; title: its Name and Title box)",
|
||||
has_selection,
|
||||
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"smartArt.addNode",
|
||||
"Add Shape",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
["Diagram Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?, index?, position?: after|before|below|above|assistant, text?} → {index} (default: after the last node)",
|
||||
has_selection,
|
||||
@@ -205,18 +205,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"smartArt.removeNode",
|
||||
"Remove Shape",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
["Diagram Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?, index} (its sub-points move up one level)",
|
||||
has_selection,
|
||||
remove_node
|
||||
),
|
||||
cmd!("smartArt.promote", "Promote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, promote),
|
||||
cmd!("smartArt.demote", "Demote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, demote),
|
||||
cmd!("smartArt.promote", "Promote", ["Diagram Design", "Create Graphic"], None, "{id?, index}", has_selection, promote),
|
||||
cmd!("smartArt.demote", "Demote", ["Diagram Design", "Create Graphic"], None, "{id?, index}", has_selection, demote),
|
||||
cmd!(
|
||||
"smartArt.layout",
|
||||
"Change Layout",
|
||||
["SmartArt Design", "Layouts"],
|
||||
["Diagram Design", "Layouts"],
|
||||
None,
|
||||
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget}",
|
||||
has_selection,
|
||||
@@ -224,17 +224,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.fromText",
|
||||
"Convert to SmartArt",
|
||||
"Convert to Diagram",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{id?, kind?} → {id} (a text box or placeholder becomes a SmartArt graphic in its place: one node per paragraph, indent levels kept)",
|
||||
"{id?, kind?} → {id} (a text box or placeholder becomes a Diagram graphic in its place: one node per paragraph, indent levels kept)",
|
||||
has_selection,
|
||||
from_text
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.toText",
|
||||
"Convert to Text",
|
||||
["SmartArt Design", "Reset"],
|
||||
["Diagram Design", "Reset"],
|
||||
None,
|
||||
"{id?} → {id} (the graphic becomes a text box of its outline, bulleted by level)",
|
||||
has_selection,
|
||||
@@ -243,7 +243,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"smartArt.toShapes",
|
||||
"Convert to Shapes",
|
||||
["SmartArt Design", "Reset"],
|
||||
["Diagram Design", "Reset"],
|
||||
None,
|
||||
"{id?} (the graphic becomes a plain group of its shapes; the outline is dropped)",
|
||||
has_selection,
|
||||
@@ -251,8 +251,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.picture",
|
||||
"Insert Picture in SmartArt",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
"Insert Picture in Diagram",
|
||||
["Diagram Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?, index, data?: base64 | path?: string | reset?: bool} (the node's picture placeholder in picture layouts)",
|
||||
has_selection,
|
||||
@@ -260,8 +260,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.style",
|
||||
"SmartArt Styles",
|
||||
["SmartArt Design", "SmartArt Styles"],
|
||||
"Diagram Styles",
|
||||
["Diagram Design", "Diagram Styles"],
|
||||
None,
|
||||
"{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}",
|
||||
has_selection,
|
||||
@@ -270,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"smartArt.colors",
|
||||
"Change Colors",
|
||||
["SmartArt Design", "SmartArt Styles"],
|
||||
["Diagram Design", "Diagram Styles"],
|
||||
None,
|
||||
"{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}",
|
||||
has_selection,
|
||||
|
||||
@@ -38,7 +38,7 @@ fn target_group(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let ids = targets(s, p)?;
|
||||
ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic"))
|
||||
ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a Diagram graphic"))
|
||||
}
|
||||
|
||||
/// Children with fresh ids for diagram `d` inside a group whose child space is `area`.
|
||||
@@ -55,7 +55,7 @@ fn edit_diagram<T>(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Di
|
||||
let gid = target_group(s, p, cmd)?;
|
||||
s.edit(|doc, sel| {
|
||||
let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?;
|
||||
let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) };
|
||||
let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a Diagram graphic")) };
|
||||
sync_text(&mut d, &children);
|
||||
let out = f(&mut d)?;
|
||||
normalize(&mut d.nodes);
|
||||
@@ -90,7 +90,7 @@ pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Resul
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?);
|
||||
let g = Shape {
|
||||
id,
|
||||
name: format!("SmartArt ({kind})"),
|
||||
name: format!("Diagram ({kind})"),
|
||||
xfrm: Some(area),
|
||||
kind: ShapeKind::Group { children, child: area },
|
||||
diagram: Some(d),
|
||||
@@ -201,7 +201,7 @@ pub(super) fn from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
|
||||
let g = Shape {
|
||||
id: gid,
|
||||
name: format!("SmartArt ({kind})"),
|
||||
name: format!("Diagram ({kind})"),
|
||||
xfrm: Some(area),
|
||||
kind: ShapeKind::Group { children, child: area },
|
||||
diagram: Some(d),
|
||||
@@ -222,12 +222,12 @@ pub(super) fn from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.toText";
|
||||
let gid = target_group(s, p, CMD)?;
|
||||
let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
|
||||
let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a Diagram graphic"))?;
|
||||
// The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included).
|
||||
let (title_pt, bullet_pt) = {
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
|
||||
let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a Diagram graphic"))?;
|
||||
text_sizes(g.children())
|
||||
};
|
||||
let new = s.edit(|doc, sel| {
|
||||
|
||||
@@ -166,21 +166,21 @@ pub(super) fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: b
|
||||
if !title {
|
||||
body.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
}
|
||||
Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() }
|
||||
Shape { name: "Diagram shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A plain shape with no text (arrows between steps).
|
||||
pub(super) fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape {
|
||||
let mut x = x;
|
||||
x.rot = rot;
|
||||
Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }
|
||||
Shape { name: "Diagram arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }
|
||||
}
|
||||
|
||||
pub(super) fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape {
|
||||
let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs());
|
||||
x.flip_h = (b.0 < a.0) != (b.1 < a.1);
|
||||
Shape {
|
||||
name: "SmartArt connector".into(),
|
||||
name: "Diagram connector".into(),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset("line"),
|
||||
fill: Some(Fill::None),
|
||||
@@ -257,7 +257,7 @@ fn fit_words(sh: &mut Shape) {
|
||||
|
||||
/// A picture placeholder for node `node`: its picture when it has one, else a pale box.
|
||||
pub(super) fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape {
|
||||
let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() };
|
||||
let mut sh = Shape { name: "Diagram picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() };
|
||||
paint(d, &mut sh, i, true);
|
||||
if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) {
|
||||
sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() }));
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
"forward" => ed::cluster_end(body, a),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! `/work`), exactly: on Windows what exists is resolved to its real case first.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
@@ -249,30 +249,19 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
/// Is `path` (resolved) `root` or inside it, component by component? Exactly, on Windows too: what
|
||||
/// exists has its real case from [`resolve`], and a folder that doesn't exist yet may be a different
|
||||
/// one from the root where a parent folder is case sensitive.
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
root.components().all(|r| path.next() == Some(r))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`, `COM¹`… also with an extension or a colon), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
let stem = component.split(['.', ':']).next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered =
|
||||
|prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
@@ -377,6 +366,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A root and a path reached through links are compared after the links are followed.
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
@@ -413,20 +403,67 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A name that differs in case is another name until the file system says otherwise.
|
||||
#[test]
|
||||
fn names_are_compared_exactly() {
|
||||
let root = Path::new("/work/In");
|
||||
assert!(inside(Path::new("/work/In/new/a.deckcraft"), root));
|
||||
assert!(!inside(Path::new("/work/IN/a.deckcraft"), root));
|
||||
assert!(!inside(Path::new("/WORK/In/a.deckcraft"), root));
|
||||
assert!(!inside(Path::new("/work/in\u{212A}/a.deckcraft"), Path::new("/work/ink")), "the Kelvin sign folds to k");
|
||||
if cfg!(windows) {
|
||||
let root = Path::new(r"C:\work\In");
|
||||
assert!(inside(Path::new(r"c:\work\In\a.deckcraft"), root), "a drive letter is one drive in either case");
|
||||
assert!(!inside(Path::new(r"C:\work\IN\a.deckcraft"), root));
|
||||
}
|
||||
}
|
||||
|
||||
/// In a case-sensitive folder, `IN` beside the root `in` is another folder, there or not yet.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
fn a_case_sensitive_folder_keeps_a_differently_cased_name_out() {
|
||||
let base = std::env::temp_dir().join(format!("dc-file-access-case-sensitive-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let set = std::process::Command::new("fsutil").args(["file", "setCaseSensitiveInfo"]).arg(&base).arg("enable").output();
|
||||
if !set.as_ref().is_ok_and(|o| o.status.success()) {
|
||||
eprintln!("skipped: a case-sensitive folder can't be made here: {set:?}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
std::fs::create_dir(base.join("in")).unwrap();
|
||||
let root = [base.join("in")];
|
||||
let roots = AutomationRoots::new(&root, &root).unwrap().unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("in/new.deckcraft")), Access::Write), Ok(()));
|
||||
let e = roots.check(&s(base.join("IN/new.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
std::fs::create_dir(base.join("IN")).unwrap();
|
||||
std::fs::write(base.join("IN/secret.deckcraft"), "x").unwrap();
|
||||
let e = roots.check(&s(base.join("IN/secret.deckcraft")), Access::Read).unwrap_err();
|
||||
assert!(e.contains("outside the read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
/// On Windows a path finds its real case on disk, and device names are refused.
|
||||
#[test]
|
||||
fn windows_finds_the_real_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/a.deckcraft"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("OUT/New.deckcraft")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("OUT")), Access::Write), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
let e = roots.check(&s(base.join("out/COM¹.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Scopes nest, and a scope applies only on the thread that set it.
|
||||
#[test]
|
||||
fn scopes_nest_and_stay_on_their_thread() {
|
||||
let (base, roots) = folders("scopes");
|
||||
@@ -448,12 +485,13 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// What is a device name, with superscript digits and a name before a colon, and what isn't.
|
||||
#[test]
|
||||
fn device_names() {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL ", "COM¹", "Lpt²", "lpt³.txt", "COM1:", "nul:x", "COM¹:"] {
|
||||
assert!(is_device_name(d), "{d}");
|
||||
}
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] {
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x", "COM⁴", "com¹0", "lpt¹x", "console:x"] {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,7 +646,9 @@ impl Session {
|
||||
self.settle_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());
|
||||
// A command told which shapes to act on doesn't depend on what is selected.
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO || e == cmd::merge::IN_GROUP)
|
||||
&& (params.get("ids").is_some() || params.get("id").is_some());
|
||||
if !named {
|
||||
return Err(EngineError::Disabled(id.into(), e));
|
||||
}
|
||||
|
||||
+305
-5
@@ -98,6 +98,33 @@ fn text_editing_flow() {
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
|
||||
}
|
||||
|
||||
/// Forward Delete removes a whole grapheme cluster, not just its first scalar.
|
||||
#[test]
|
||||
fn forward_delete_removes_whole_grapheme_cluster() {
|
||||
for (input, rest) in [("\u{1F44D}\u{1F3FD}y", "y"), ("e\u{301}x", "x")] {
|
||||
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.selectAll", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": input})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
s.execute("text.delete", &json!({"dir": "forward"})).unwrap();
|
||||
let t = s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert_eq!(t["text"], rest);
|
||||
}
|
||||
// Inside a cluster, Delete stops at that cluster's end and leaves the next flag intact.
|
||||
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.selectAll", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{1F1FA}\u{1F1F8}\u{1F1E8}\u{1F1E6}"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "right"})).unwrap();
|
||||
s.execute("text.delete", &json!({"dir": "forward"})).unwrap();
|
||||
let t = s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert_eq!(t["text"], "\u{1F1FA}\u{1F1E8}\u{1F1E6}");
|
||||
}
|
||||
|
||||
/// `text.set` treats CRLF (or a lone CR) as one paragraph break and keeps no `\r` in the text.
|
||||
#[test]
|
||||
fn text_set_crlf_makes_clean_paragraphs() {
|
||||
@@ -214,6 +241,71 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
/// A shape copied or cut from inside a group pastes where it was drawn: on another slide in the
|
||||
/// same place, on its own slide offset as any paste there is. Its box is mapped out through each
|
||||
/// group around it, however the groups were moved, stretched, turned or flipped. Before, the
|
||||
/// clipboard kept the box inside the group, so the shape pasted where it had been before the group
|
||||
/// was moved or stretched.
|
||||
#[test]
|
||||
fn a_shape_copied_from_a_group_pastes_where_it_was_drawn() {
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
|
||||
let sites = |s: &Session, id: ShapeId| {
|
||||
let st = s.doc().unwrap();
|
||||
connect::sites_of(&st.doc, &st.selection, st.shapes(), id).unwrap()
|
||||
};
|
||||
type Edits<'a> = &'a [(&'a str, Value)];
|
||||
let edit = |s: &mut Session, id: u64, edits: Edits<'_>| {
|
||||
for (cmd, p) in edits {
|
||||
let mut p = p.clone();
|
||||
p["ids"] = json!([id]);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
}
|
||||
};
|
||||
// Edits to the shape's group, then to a group around that one (none: no outer group).
|
||||
let cases: [(Edits<'_>, Edits<'_>); 4] = [
|
||||
(&[("shape.move", json!({"dx": 200}))], &[]),
|
||||
(&[("shape.resize", json!({"w": 600}))], &[]),
|
||||
(&[("shape.rotate", json!({"deg": 30})), ("shape.flip", json!({"axis": "vertical"})), ("shape.move", json!({"dx": 50, "dy": 20}))], &[]),
|
||||
(&[("shape.move", json!({"dx": 200}))], &[("shape.resize", json!({"w": 450})), ("shape.move", json!({"dy": 60}))]),
|
||||
];
|
||||
let off = session().prefs.duplicate_offset;
|
||||
for (n, (inner_edits, outer_edits)) in cases.into_iter().enumerate() {
|
||||
for (cmd, same_slide) in [("edit.copy", false), ("edit.cut", false), ("edit.copy", true)] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0));
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
edit(&mut s, g, inner_edits);
|
||||
if !outer_edits.is_empty() {
|
||||
let c = rect(&mut s, 100.0, 300.0);
|
||||
let outer = s.execute("arrange.group", &json!({"ids": [g, c]})).unwrap()["id"].as_u64().unwrap();
|
||||
edit(&mut s, outer, outer_edits);
|
||||
}
|
||||
let was = sites(&s, sid(a));
|
||||
s.doc_mut().unwrap().selection.shapes = vec![sid(a)];
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(sid(a)).is_some(), cmd == "edit.copy", "case {n}: {cmd}");
|
||||
if !same_slide {
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
}
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let pasted = sid(r["ids"][0].as_u64().unwrap());
|
||||
assert!(s.doc().unwrap().shapes().iter().any(|x| x.id == pasted), "case {n}: {cmd} pastes on the top level");
|
||||
let d = if same_slide { off } else { 0.0 };
|
||||
let now = sites(&s, pasted);
|
||||
assert_eq!(was.len(), now.len(), "case {n}: {cmd}");
|
||||
assert!(
|
||||
was.iter().zip(&now).all(|(p, q)| (q.x - p.x - d).hypot(q.y - p.y - d) < 1e-6),
|
||||
"case {n}: {cmd} on the same slide {same_slide}: drawn at {was:?}, pasted at {now:?}"
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
|
||||
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
|
||||
fn resolved_look(s: &Session, id: ShapeId) -> String {
|
||||
@@ -885,6 +977,154 @@ fn group_ungroup_roundtrip_positions() {
|
||||
assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
/// Ungrouping a group inside another group frees its shapes into the outer group, in its place
|
||||
/// in the outer group's order, drawn where they were, and selects them. Before, arrange.ungroup
|
||||
/// looked only at the slide's top level: it answered `{ids: []}` and changed nothing.
|
||||
#[test]
|
||||
fn ungrouping_a_nested_group_frees_its_shapes_into_the_outer_group() {
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
|
||||
let sites = |s: &Session, id: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap()
|
||||
};
|
||||
// The outer group moved and stretched; then an inner group turned and flipped, inside an outer
|
||||
// group that is turned and moved.
|
||||
type Edits<'a> = &'a [(&'a str, Value)];
|
||||
let cases: [(Edits<'_>, Edits<'_>); 2] = [
|
||||
(&[], &[("shape.move", json!({"dx": 200})), ("shape.resize", json!({"w": 450}))]),
|
||||
(
|
||||
&[("shape.rotate", json!({"deg": 30})), ("shape.flip", json!({"axis": "horizontal"}))],
|
||||
&[("shape.rotate", json!({"deg": 45})), ("shape.move", json!({"dx": 120, "dy": 40}))],
|
||||
),
|
||||
];
|
||||
let edit = |s: &mut Session, id: u64, edits: Edits<'_>| {
|
||||
for (cmd, p) in edits {
|
||||
let mut p = p.clone();
|
||||
p["ids"] = json!([id]);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
}
|
||||
};
|
||||
for (n, (inner_edits, outer_edits)) in cases.into_iter().enumerate() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let (a, b, c) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0), rect(&mut s, 100.0, 300.0));
|
||||
let inner = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
edit(&mut s, inner, inner_edits);
|
||||
let outer = s.execute("arrange.group", &json!({"ids": [inner, c]})).unwrap()["id"].as_u64().unwrap();
|
||||
edit(&mut s, outer, outer_edits);
|
||||
let before = [sites(&s, a), sites(&s, b), sites(&s, c)];
|
||||
let r = s.execute("arrange.ungroup", &json!({"ids": [inner]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, b]), "case {n}");
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b)]);
|
||||
let st = s.doc().unwrap();
|
||||
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, [sid(a), sid(b), sid(c)], "case {n}");
|
||||
for (id, was) in [a, b, c].into_iter().zip(before) {
|
||||
let now = sites(&s, id);
|
||||
assert_eq!(was.len(), now.len(), "case {n}: shape {id}");
|
||||
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "case {n}: shape {id} moved from {was:?} to {now:?}");
|
||||
}
|
||||
}
|
||||
// Two named groups, one inside the other, inside a third: the shapes of both go to the third,
|
||||
// each listed once.
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let (a, b, c, d) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0), rect(&mut s, 100.0, 300.0), rect(&mut s, 300.0, 300.0));
|
||||
let inner = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let middle = s.execute("arrange.group", &json!({"ids": [inner, c]})).unwrap()["id"].as_u64().unwrap();
|
||||
let outer = s.execute("arrange.group", &json!({"ids": [middle, d]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.resize", &json!({"ids": [outer], "w": 600})).unwrap();
|
||||
let before = [a, b, c, d].map(|id| sites(&s, id));
|
||||
let r = s.execute("arrange.ungroup", &json!({"ids": [inner, middle]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, b, c]));
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b), sid(c)]);
|
||||
let st = s.doc().unwrap();
|
||||
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, [sid(a), sid(b), sid(c), sid(d)]);
|
||||
for (id, was) in [a, b, c, d].into_iter().zip(before) {
|
||||
let now = sites(&s, id);
|
||||
assert_eq!(was.len(), now.len(), "shape {id}");
|
||||
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "shape {id} moved from {was:?} to {now:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ungrouping nested groups in one call returns each freed shape once, whatever order the groups
|
||||
/// are named in. Before, a group freed into a group that the same call freed next was listed twice
|
||||
/// and replaced once, so the call returned the id of a group that no longer existed.
|
||||
#[test]
|
||||
fn ungrouping_nested_groups_in_any_order_returns_only_freed_shapes() {
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
fn group(s: &mut Session, ids: [u64; 2]) -> u64 {
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let [a, b, c, d, e] = [(100.0, 100.0), (300.0, 100.0), (100.0, 300.0), (300.0, 300.0), (500.0, 300.0)].map(|(x, y)| rect(&mut s, x, y));
|
||||
let inner = group(&mut s, [a, b]);
|
||||
let middle = group(&mut s, [inner, c]);
|
||||
let upper = group(&mut s, [middle, d]);
|
||||
let outer = group(&mut s, [upper, e]);
|
||||
let r = s.execute("arrange.ungroup", &json!({"ids": [middle, upper, inner]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, b, c, d]));
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b), sid(c), sid(d)]);
|
||||
let st = s.doc().unwrap();
|
||||
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, [sid(a), sid(b), sid(c), sid(d), sid(e)]);
|
||||
}
|
||||
|
||||
/// Ungrouping a top-level group and a group inside it in one call frees the shapes of both onto
|
||||
/// the slide, in the top-level group's place and drawn where they were, whichever is named first.
|
||||
#[test]
|
||||
fn ungrouping_a_top_level_group_with_a_group_inside_it_frees_both() {
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
fn group(s: &mut Session, ids: [u64; 2]) -> u64 {
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
|
||||
let sites = |s: &Session, id: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap()
|
||||
};
|
||||
let roots = |s: &Session| -> Vec<ShapeId> { s.doc().unwrap().shapes().iter().map(|x| x.id).collect() };
|
||||
for outer_first in [true, false] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let [a, b, c, z] = [(100.0, 100.0), (300.0, 100.0), (100.0, 300.0), (500.0, 500.0)].map(|(x, y)| rect(&mut s, x, y));
|
||||
let inner = group(&mut s, [a, b]);
|
||||
let outer = group(&mut s, [inner, c]);
|
||||
s.execute("shape.resize", &json!({"ids": [outer], "w": 600})).unwrap();
|
||||
let before = [a, b, c].map(|id| sites(&s, id));
|
||||
let freed = [sid(a), sid(b), sid(c)];
|
||||
let mut expected = vec![];
|
||||
for x in roots(&s) {
|
||||
if x == sid(outer) {
|
||||
expected.extend(freed);
|
||||
} else {
|
||||
expected.push(x);
|
||||
}
|
||||
}
|
||||
assert!(expected.contains(&sid(z)));
|
||||
let ids = if outer_first { [outer, inner] } else { [inner, outer] };
|
||||
let r = s.execute("arrange.ungroup", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, b, c]), "{ids:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, freed, "{ids:?}");
|
||||
assert_eq!(roots(&s), expected, "{ids:?}");
|
||||
for (id, was) in [a, b, c].into_iter().zip(before) {
|
||||
let now = sites(&s, id);
|
||||
assert_eq!(was.len(), now.len(), "{ids:?}: shape {id}");
|
||||
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{ids:?}: shape {id} moved from {was:?} to {now:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grouping a group again and again stops at `MAX_GROUP_DEPTH`: past about 39 levels a saved
|
||||
/// .deckcraft file no longer opens, and past 64 commands such as format.languageAll miss the text.
|
||||
#[test]
|
||||
@@ -4160,10 +4400,10 @@ fn smartart_round_trips_through_pptx_as_a_diagram() {
|
||||
}
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
assert!(back.validate().is_empty());
|
||||
let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group");
|
||||
let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a Diagram group");
|
||||
let d = g.diagram.as_ref().unwrap();
|
||||
assert_eq!(d.kind, "hierarchy");
|
||||
assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "SmartArt style and colours come back");
|
||||
assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "Diagram style and colours come back");
|
||||
assert!(d.imported);
|
||||
let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect();
|
||||
assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]);
|
||||
@@ -4191,7 +4431,7 @@ fn smartart_pictures_3d_styles_and_colour_variants() {
|
||||
let pics = |s: &Session| -> Vec<deckcraft_model::Shape> {
|
||||
let st = s.doc().unwrap();
|
||||
let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone();
|
||||
g.children().iter().filter(|c| c.name == "SmartArt picture").cloned().collect()
|
||||
g.children().iter().filter(|c| c.name == "Diagram picture").cloned().collect()
|
||||
};
|
||||
assert_eq!(pics(&s).len(), 2, "one picture placeholder per top-level node");
|
||||
assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_)))));
|
||||
@@ -6594,6 +6834,66 @@ fn duplicating_a_moved_copy_repeats_the_move() {
|
||||
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// Duplicating a shape inside a group puts the copy in that group, last among its children, and
|
||||
/// selects it. The copy is offset on the slide as a top-level copy is, however the group is moved,
|
||||
/// stretched or turned, and duplicating a moved copy repeats the move (#966). A shape whose group is
|
||||
/// duplicated as well goes along with the group's copy. Before, Duplicate looked only at the slide's
|
||||
/// top level: it answered `{ids: []}` and changed nothing.
|
||||
#[test]
|
||||
fn duplicating_a_shape_in_a_group_copies_it_into_the_group() {
|
||||
fn rect(s: &mut Session, x: f64) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
|
||||
// How far shape `to` is drawn from shape `from` on the slide, the same for each of its sites.
|
||||
let offset = |s: &Session, from: u64, to: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
let sites = |id| connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap();
|
||||
let (p, q) = (sites(from), sites(to));
|
||||
assert_eq!(p.len(), q.len(), "{p:?} {q:?}");
|
||||
let d = q[0] - p[0];
|
||||
assert!(p.iter().zip(&q).all(|(p, q)| (*q - *p - d).hypot() < 1e-6), "{p:?} {q:?}");
|
||||
(d.x, d.y)
|
||||
};
|
||||
let grouped = |edit: &str, p: Value| {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 300.0));
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut p = p;
|
||||
p["ids"] = json!([g]);
|
||||
s.execute(edit, &p).unwrap();
|
||||
(s, a, b, g)
|
||||
};
|
||||
for (edit, p) in [("shape.move", json!({"dx": 200})), ("shape.resize", json!({"w": 600})), ("shape.rotate", json!({"deg": 90}))] {
|
||||
let (mut s, a, b, g) = grouped(edit, p);
|
||||
let off = s.prefs.duplicate_offset;
|
||||
let r = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().map(Vec::len), Some(1), "{edit}: {r}");
|
||||
let c = r["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, [sid(c)]);
|
||||
let st = s.doc().unwrap();
|
||||
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(g)).unwrap().children().iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, [sid(a), sid(b), sid(c)], "{edit}");
|
||||
let (dx, dy) = offset(&s, a, c);
|
||||
assert!((dx - off).abs() < 1e-6 && (dy - off).abs() < 1e-6, "{edit}: the copy is drawn ({dx}, {dy}) from the original");
|
||||
// Moved, then duplicated again: the next copy is drawn as far from it again.
|
||||
s.execute("shape.move", &json!({"ids": [c], "dx": 30})).unwrap();
|
||||
let (mx, my) = offset(&s, a, c);
|
||||
let d = s.execute("edit.duplicate", &json!({"ids": [c]})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let (nx, ny) = offset(&s, c, d);
|
||||
assert!((nx - mx).abs() < 1e-6 && (ny - my).abs() < 1e-6, "{edit}: moved ({mx}, {my}), then ({nx}, {ny})");
|
||||
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
|
||||
}
|
||||
let (mut s, a, _, g) = grouped("shape.move", json!({"dx": 200}));
|
||||
let r = s.execute("edit.duplicate", &json!({"ids": [a, g]})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let st = s.doc().unwrap();
|
||||
let count = |id: u64| deckcraft_model::find_shape(st.shapes(), sid(id)).unwrap().children().len();
|
||||
assert_eq!((count(g), count(r["ids"][0].as_u64().unwrap())), (2, 2));
|
||||
assert_eq!(st.shapes().len(), 2);
|
||||
}
|
||||
|
||||
fn chart_of(s: &Session, id: u64) -> deckcraft_model::Chart {
|
||||
match &s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Chart(c) => (**c).clone(),
|
||||
@@ -7085,13 +7385,13 @@ fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
|
||||
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
|
||||
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
|
||||
assert!(e.contains("Diagram") && e.contains("smartArt.layout"), "{cmd}: {e}");
|
||||
}
|
||||
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
|
||||
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt"), "{e}");
|
||||
assert!(e.contains("Diagram"), "{e}");
|
||||
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
|
||||
// Still a SmartArt graphic: smartArt commands keep working.
|
||||
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
|
||||
|
||||
@@ -353,7 +353,7 @@ impl Session {
|
||||
bp.warp.map(|n| (n, bp.warp_adj))
|
||||
});
|
||||
if let Some((name, base)) = found {
|
||||
self.begin("Adjust WordArt");
|
||||
self.begin("Adjust Decorative Text");
|
||||
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[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 }
|
||||
image = { workspace = true }
|
||||
@@ -1,59 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,193 +0,0 @@
|
||||
//! Clipping regions for EMF and WMF (MS-EMF 2.3.12, MS-WMF 2.3.5).
|
||||
//!
|
||||
//! A region is a set of axis-aligned rectangles in device units: `None` is the default clip (the
|
||||
//! whole surface), `Some([])` draws nothing, and `Some(rects)` is their union. That is how GDI
|
||||
//! stores a region (`RGNDATA` is a list of rectangles). Counts are capped so a hostile file cannot
|
||||
//! grow the set without bound; past the cap, extra rectangles are dropped, which can only hide
|
||||
//! drawing.
|
||||
|
||||
/// Rectangles kept from one region. Past this, further rectangles are dropped.
|
||||
pub(crate) const MAX_CLIP_RECTS: usize = 64;
|
||||
|
||||
pub(crate) const RGN_AND: u32 = 1;
|
||||
pub(crate) const RGN_OR: u32 = 2;
|
||||
pub(crate) const RGN_XOR: u32 = 3;
|
||||
pub(crate) const RGN_DIFF: u32 = 4;
|
||||
pub(crate) const RGN_COPY: u32 = 5;
|
||||
|
||||
/// A rectangle `[x0, y0, x1, y1]` with a positive area, or none.
|
||||
pub(crate) fn normalize(rect: [f64; 4]) -> Option<[f64; 4]> {
|
||||
let [l, t, r, b] = rect;
|
||||
if ![l, t, r, b].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (x0, x1) = (l.min(r), l.max(r));
|
||||
let (y0, y1) = (t.min(b), t.max(b));
|
||||
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6).then_some([x0, y0, x1, y1])
|
||||
}
|
||||
|
||||
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
|
||||
let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1]));
|
||||
let (x1, y1) = (a[2].min(b[2]), a[3].min(b[3]));
|
||||
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6 && [x0, y0, x1, y1].iter().all(|v| v.is_finite())).then_some([x0, y0, x1, y1])
|
||||
}
|
||||
|
||||
/// `a` with `b` removed, as up to four rectangles.
|
||||
fn subtract_one(a: [f64; 4], b: [f64; 4]) -> Vec<[f64; 4]> {
|
||||
let Some(i) = intersection(a, b) else { return vec![a] };
|
||||
let mut out = Vec::new();
|
||||
if i[1] > a[1]
|
||||
&& let Some(r) = normalize([a[0], a[1], a[2], i[1]])
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
if i[3] < a[3]
|
||||
&& let Some(r) = normalize([a[0], i[3], a[2], a[3]])
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
if i[0] > a[0]
|
||||
&& let Some(r) = normalize([a[0], i[1], i[0], i[3]])
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
if i[2] < a[2]
|
||||
&& let Some(r) = normalize([i[2], i[1], a[2], i[3]])
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cap(mut rects: Vec<[f64; 4]>) -> Vec<[f64; 4]> {
|
||||
rects.truncate(MAX_CLIP_RECTS);
|
||||
rects
|
||||
}
|
||||
|
||||
fn subtract_all(subject: Vec<[f64; 4]>, cuts: &[[f64; 4]]) -> Vec<[f64; 4]> {
|
||||
let mut cur = subject;
|
||||
for cut in cuts {
|
||||
let mut next = Vec::new();
|
||||
for s in cur {
|
||||
for piece in subtract_one(s, *cut) {
|
||||
if next.len() >= MAX_CLIP_RECTS {
|
||||
return next;
|
||||
}
|
||||
next.push(piece);
|
||||
}
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// Intersects the current region with one rectangle.
|
||||
pub(crate) fn intersect(current: Option<Vec<[f64; 4]>>, rect: [f64; 4]) -> Option<Vec<[f64; 4]>> {
|
||||
let Some(rect) = normalize(rect) else { return Some(Vec::new()) };
|
||||
Some(match current {
|
||||
None => vec![rect],
|
||||
Some(rects) => cap(rects.iter().filter_map(|r| intersection(*r, rect)).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes one rectangle. `surface` is the device bounds, used when the clip is still the default
|
||||
/// (the whole surface) so the remainder can be named.
|
||||
pub(crate) fn exclude(current: Option<Vec<[f64; 4]>>, rect: [f64; 4], surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
|
||||
let Some(rect) = normalize(rect) else { return current };
|
||||
match current {
|
||||
Some(rects) => Some(subtract_all(rects, &[rect])),
|
||||
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &[rect])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines `incoming` with the current region in a region mode (`RGN_*`). An unknown mode leaves
|
||||
/// the region as it was. `RGN_COPY` replaces it. An `RGN_OR` or `RGN_XOR` against the default clip
|
||||
/// stays the default clip: the whole surface already covers the incoming rectangles.
|
||||
pub(crate) fn combine(current: Option<Vec<[f64; 4]>>, incoming: Vec<[f64; 4]>, mode: u32, surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
|
||||
let incoming: Vec<[f64; 4]> = cap(incoming.iter().copied().filter_map(normalize).collect());
|
||||
match mode {
|
||||
RGN_COPY => Some(incoming),
|
||||
RGN_AND => match current {
|
||||
None => Some(incoming),
|
||||
Some(rects) => {
|
||||
let mut out = Vec::new();
|
||||
for a in &rects {
|
||||
for b in &incoming {
|
||||
if out.len() >= MAX_CLIP_RECTS {
|
||||
return Some(out);
|
||||
}
|
||||
if let Some(r) = intersection(*a, *b) {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
},
|
||||
RGN_OR => match current {
|
||||
None => None,
|
||||
Some(mut rects) => {
|
||||
rects.extend(incoming);
|
||||
Some(cap(rects))
|
||||
}
|
||||
},
|
||||
RGN_DIFF => match current {
|
||||
Some(rects) => Some(subtract_all(rects, &incoming)),
|
||||
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &incoming)),
|
||||
},
|
||||
RGN_XOR => {
|
||||
let base = current?;
|
||||
let mut out = subtract_all(base.clone(), &incoming);
|
||||
let rest = subtract_all(incoming, &base);
|
||||
for r in rest {
|
||||
if out.len() >= MAX_CLIP_RECTS {
|
||||
break;
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
_ => current,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intersect_shrinks_to_the_overlap() {
|
||||
let r = intersect(None, [0.0, 0.0, 100.0, 50.0]);
|
||||
let r = intersect(r, [40.0, 10.0, 80.0, 40.0]);
|
||||
assert_eq!(r, Some(vec![[40.0, 10.0, 80.0, 40.0]]));
|
||||
assert_eq!(intersect(r, [0.0, 0.0, 10.0, 10.0]), Some(vec![]));
|
||||
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 20.0]]), [40.0, 0.0, 80.0, 20.0]), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_copy_and_or_diff_and_xor() {
|
||||
let a = Some(vec![[0.0, 0.0, 100.0, 40.0]]);
|
||||
assert_eq!(combine(a.clone(), vec![[10.0, 10.0, 30.0, 20.0]], RGN_COPY, None), Some(vec![[10.0, 10.0, 30.0, 20.0]]));
|
||||
let and = combine(a.clone(), vec![[50.0, 0.0, 150.0, 40.0]], RGN_AND, None);
|
||||
assert_eq!(and, Some(vec![[50.0, 0.0, 100.0, 40.0]]));
|
||||
let or = combine(a.clone(), vec![[200.0, 0.0, 210.0, 10.0]], RGN_OR, None).unwrap();
|
||||
assert_eq!(or.len(), 2);
|
||||
let diff = combine(a.clone(), vec![[20.0, 0.0, 40.0, 40.0]], RGN_DIFF, None).unwrap();
|
||||
assert_eq!(diff, vec![[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 100.0, 40.0]]);
|
||||
let xor = combine(a, vec![[80.0, 0.0, 120.0, 40.0]], RGN_XOR, None).unwrap();
|
||||
assert!(xor.contains(&[0.0, 0.0, 80.0, 40.0]));
|
||||
assert!(xor.contains(&[100.0, 0.0, 120.0, 40.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluding_from_the_default_clip_uses_the_surface() {
|
||||
let r = exclude(None, [10.0, 0.0, 20.0, 40.0], Some([0.0, 0.0, 40.0, 40.0])).unwrap();
|
||||
assert_eq!(r, vec![[0.0, 0.0, 10.0, 40.0], [20.0, 0.0, 40.0, 40.0]]);
|
||||
assert_eq!(exclude(None, [10.0, 0.0, 20.0, 40.0], None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_area_rectangle_clears_an_intersection() {
|
||||
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 10.0]]), [5.0, 5.0, 5.0, 8.0]), Some(vec![]));
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and
|
||||
//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside
|
||||
//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman
|
||||
//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a
|
||||
//! non-rectangular polygon) and text gets the polygon's bounding rectangle.
|
||||
|
||||
use crate::canvas::Raw;
|
||||
use crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
|
||||
/// Line segments per Bézier when a crossing shape is flattened.
|
||||
const CURVE_STEPS: usize = 16;
|
||||
|
||||
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
|
||||
/// it is dropped, as a single polygon over it already was.
|
||||
const MAX_CLIP_WORK: usize = 1_000_000;
|
||||
|
||||
/// The same allowance for intersecting two clip regions, a budget of its own: two convex
|
||||
/// 1,000-gons cost about 1.5 million tests once the cut polygon has grown.
|
||||
pub(crate) const MAX_AREA_CLIP_WORK: usize = 4 * MAX_CLIP_WORK;
|
||||
|
||||
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
|
||||
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
let mut add = |p: P| {
|
||||
let v = b.get_or_insert([p.0, p.1, p.0, p.1]);
|
||||
*v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)];
|
||||
};
|
||||
for op in ops {
|
||||
match *op {
|
||||
Op::M(p) | Op::L(p) => add(p),
|
||||
Op::C(a, c, d) => {
|
||||
add(a);
|
||||
add(c);
|
||||
add(d);
|
||||
}
|
||||
Op::Z => {}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
|
||||
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
|
||||
// An empty intersection stays empty (zero size) so it clips everything away.
|
||||
if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r }
|
||||
}
|
||||
|
||||
/// A rectangle `[l, t, r, b]` as a polygon.
|
||||
pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec<P> {
|
||||
vec![(l, t), (r, t), (r, b), (l, b)]
|
||||
}
|
||||
|
||||
/// The bounding box of a point list.
|
||||
pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> {
|
||||
let (&f, rest) = pts.split_first()?;
|
||||
Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]))
|
||||
}
|
||||
|
||||
fn cross(a: P, b: P, p: P) -> f64 {
|
||||
(b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)
|
||||
}
|
||||
|
||||
/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or
|
||||
/// degenerate); duplicate and collinear points are dropped.
|
||||
pub(crate) fn convex(pts: &[P]) -> Option<Vec<P>> {
|
||||
let mut v: Vec<P> = Vec::with_capacity(pts.len());
|
||||
for &p in pts {
|
||||
if !(p.0.is_finite() && p.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) {
|
||||
v.push(p);
|
||||
}
|
||||
}
|
||||
while v.len() > 1 && v.first() == v.last() {
|
||||
v.pop();
|
||||
}
|
||||
let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum();
|
||||
if v.len() < 3 || area.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
if area < 0.0 {
|
||||
v.reverse();
|
||||
}
|
||||
let n = v.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]);
|
||||
if c < -1e-9 * (1.0 + area.abs()) {
|
||||
return None;
|
||||
}
|
||||
if c.abs() > 1e-12 {
|
||||
out.push(v[i]);
|
||||
}
|
||||
}
|
||||
(out.len() >= 3).then_some(out)
|
||||
}
|
||||
|
||||
fn inside_poly(clip: &[P], p: P) -> bool {
|
||||
(0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9)
|
||||
}
|
||||
|
||||
/// Whether the clip polygon is an axis-aligned rectangle.
|
||||
fn is_rect(clip: &[P]) -> bool {
|
||||
pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3])))
|
||||
}
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip
|
||||
/// edge to `budget`: the polygon
|
||||
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||
/// once the budget is spent.
|
||||
pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
// The work actually done: this edge visits the polygon as it now stands.
|
||||
if poly.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= poly.len();
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
let (cp, cq) = (cross(a, b, p), cross(a, b, q));
|
||||
let t = cp / (cp - cq);
|
||||
(p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t)
|
||||
};
|
||||
let mut next = Vec::with_capacity(poly.len() + 4);
|
||||
for j in 0..poly.len() {
|
||||
let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]);
|
||||
match (keep(p), keep(q)) {
|
||||
(true, true) => next.push(q),
|
||||
(true, false) => next.push(cut(p, q)),
|
||||
(false, true) => {
|
||||
next.push(cut(p, q));
|
||||
next.push(q);
|
||||
}
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
poly = next;
|
||||
if poly.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(poly)
|
||||
}
|
||||
|
||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||
b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1]
|
||||
}
|
||||
|
||||
fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool {
|
||||
rect_poly(b).into_iter().all(|p| inside_poly(clip, p))
|
||||
}
|
||||
|
||||
/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes
|
||||
/// back as two items. An empty polygon clips everything away.
|
||||
pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() };
|
||||
match raw {
|
||||
Raw::Path { ops, fill, stroke, even_odd, region } => {
|
||||
let Some(b) = ops_bbox(&ops) else { return Vec::new() };
|
||||
if bbox_inside(b, clip) {
|
||||
return vec![Raw::Path { ops, fill, stroke, even_odd, region }];
|
||||
}
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
// One allowance for the fill and the stroke together; the item goes once it is spent.
|
||||
let mut budget = MAX_CLIP_WORK;
|
||||
let mut out = Vec::new();
|
||||
if fill.is_some() {
|
||||
let Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !f.is_empty() {
|
||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||
}
|
||||
}
|
||||
if stroke.is_some() {
|
||||
let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !s.is_empty() {
|
||||
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Raw::Pattern { ops, even_odd, tile, region } => {
|
||||
let Some(b) = ops_bbox(&ops) else { return Vec::new() };
|
||||
if bbox_inside(b, clip) {
|
||||
return vec![Raw::Pattern { ops, even_odd, tile, region }];
|
||||
}
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default();
|
||||
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
|
||||
}
|
||||
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
|
||||
Raw::Text { text: mut t, region } => {
|
||||
// Text clips to a rectangle: the one around the polygon.
|
||||
let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)]));
|
||||
t.clip = Some(cur.map(|v| v as f32));
|
||||
vec![Raw::Text { text: t, region }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subpaths as point lists with their closed flag.
|
||||
pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
|
||||
let mut subs: Vec<(Vec<P>, bool)> = Vec::new();
|
||||
for op in ops {
|
||||
match *op {
|
||||
Op::M(p) => subs.push((vec![p], false)),
|
||||
Op::L(p) => match subs.last_mut() {
|
||||
Some(s) => s.0.push(p),
|
||||
None => subs.push((vec![p], false)),
|
||||
},
|
||||
Op::C(a, b, d) => {
|
||||
let Some(s) = subs.last_mut() else { continue };
|
||||
let Some(&p0) = s.0.last() else { continue };
|
||||
for i in 1..=CURVE_STEPS {
|
||||
let t = i as f64 / CURVE_STEPS as f64;
|
||||
let u = 1.0 - t;
|
||||
let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||
s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1));
|
||||
}
|
||||
}
|
||||
Op::Z => {
|
||||
if let Some(s) = subs.last_mut() {
|
||||
s.1 = true;
|
||||
// Drawing after a close starts from the subpath's first point.
|
||||
let first = s.0.first().copied();
|
||||
if let Some(f) = first {
|
||||
subs.push((vec![f], false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
subs.retain(|s| s.0.len() > 1);
|
||||
subs
|
||||
}
|
||||
|
||||
/// The clipped fill of every subpath, its clipping work charged to `budget` with that of the
|
||||
/// stroke: `None` (the whole item goes) once it is spent.
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, _) in subs {
|
||||
let poly = clip_polygon_within(pts, clip, budget)?;
|
||||
if poly.len() >= 3 {
|
||||
ops.push(Op::M(poly[0]));
|
||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
}
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
/// The clipped outline of every subpath; `None` once `budget` is spent.
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, closed) in subs {
|
||||
let mut pts = pts.clone();
|
||||
if *closed && let Some(&f) = pts.first() {
|
||||
pts.push(f);
|
||||
}
|
||||
let mut last: Option<P> = None;
|
||||
for w in pts.windows(2) {
|
||||
let Some((a, b)) = segment(w[0], w[1], clip, budget)? else {
|
||||
last = None;
|
||||
continue;
|
||||
};
|
||||
if last != Some(a) {
|
||||
ops.push(Op::M(a));
|
||||
}
|
||||
ops.push(Op::L(b));
|
||||
last = Some(b);
|
||||
}
|
||||
}
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge
|
||||
/// visited; the outer `None` is a spent budget.
|
||||
fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option<Option<(P, P)>> {
|
||||
let d = (b.0 - a.0, b.1 - a.1);
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
for i in 0..clip.len() {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
|
||||
let f0 = cross(e0, e1, a);
|
||||
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
|
||||
if df == 0.0 {
|
||||
if f0 < 0.0 {
|
||||
return Some(None);
|
||||
}
|
||||
} else {
|
||||
let t = -f0 / df;
|
||||
if df > 0.0 {
|
||||
t0 = t0.max(t);
|
||||
} else {
|
||||
t1 = t1.min(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))))
|
||||
}
|
||||
|
||||
/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip
|
||||
/// polygon's box; pixels outside a non-rectangular polygon turn transparent.
|
||||
fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> {
|
||||
let [x, y, w, h] = rect;
|
||||
let b = [x, y, x + w, y + h];
|
||||
if bbox_inside(b, clip) {
|
||||
return Some((rect, img));
|
||||
}
|
||||
let c = pts_bbox(clip)?;
|
||||
if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 {
|
||||
return None;
|
||||
}
|
||||
let i = intersect(b, c);
|
||||
let (kx, ky) = (img.w as f64 / w, img.h as f64 / h);
|
||||
let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w);
|
||||
let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w);
|
||||
let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h);
|
||||
let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let mask = !is_rect(clip);
|
||||
let total_pixels = (x1 - x0).saturating_mul(y1 - y0);
|
||||
if mask && total_pixels.saturating_mul(clip.len()) > 5_000_000 {
|
||||
return None;
|
||||
}
|
||||
let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4);
|
||||
for row in y0..y1 {
|
||||
// Rows are top-down unless the image says otherwise.
|
||||
let r = if img.bottom_up { img.h - 1 - row } else { row };
|
||||
let s = (r * img.w + x0) * 4;
|
||||
let start = px.len();
|
||||
px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?);
|
||||
if mask {
|
||||
let cy = y + (row as f64 + 0.5) / ky;
|
||||
for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let cx = x + ((x0 + k) as f64 + 0.5) / kx;
|
||||
if !inside_poly(clip, (cx, cy)) {
|
||||
p[3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky];
|
||||
Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px }))
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
//! 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 r > l && b > t {
|
||||
cv.set_clip_surface([l, t, r, b]);
|
||||
}
|
||||
if let Some(px_mm) = device_px_per_mm(input) {
|
||||
cv.set_device_mm(px_mm);
|
||||
}
|
||||
let mut plus = crate::emfplus::Plus::new();
|
||||
plus.frame = frame.as_ref().map(|f| f.rect);
|
||||
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;
|
||||
}
|
||||
if typ == 70 && Bytes(rec).u32(12) == Some(crate::emfplus::EMF_PLUS) {
|
||||
if let Some(data) = Bytes(rec).tail(16) {
|
||||
plus.comment(&mut cv, data);
|
||||
}
|
||||
} else {
|
||||
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)),
|
||||
29 => rect32(r, 8).map(|rect| cv.exclude_clip_rect(rect)),
|
||||
30 => rect32(r, 8).map(|rect| cv.intersect_clip_rect(rect)),
|
||||
75 => ext_select_clip(cv, r),
|
||||
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(()),
|
||||
};
|
||||
}
|
||||
|
||||
/// EMR_EXTSELECTCLIPRGN: `cbRgnData` bytes of `RGNDATA` (rectangles in logical units) and a region
|
||||
/// mode. No region data selects the default clip.
|
||||
fn ext_select_clip(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let cb = r.u32(8)? as usize;
|
||||
let mode = r.u32(12)?;
|
||||
if cb == 0 {
|
||||
cv.reset_clip();
|
||||
return Some(());
|
||||
}
|
||||
let header = 16;
|
||||
if cb < 32 {
|
||||
return Some(());
|
||||
}
|
||||
// RDH_RECTANGLES is the only region type GDI writes.
|
||||
if r.u32(header + 4)? != 1 {
|
||||
return Some(());
|
||||
}
|
||||
let n = (r.u32(header + 8)? as usize).min(crate::clip::MAX_CLIP_RECTS);
|
||||
let end = header.checked_add(cb)?;
|
||||
let mut rects = Vec::new();
|
||||
for i in 0..n {
|
||||
let o = header + 32 + i * 16;
|
||||
if o + 16 > end {
|
||||
break;
|
||||
}
|
||||
rects.push(rect32(r, o)?);
|
||||
}
|
||||
cv.select_clip_rects(&rects, mode);
|
||||
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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
//! 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. Rectangle clip regions (`INTERSECTCLIPRECT`,
|
||||
//! `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit what later records draw. 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 clip;
|
||||
mod cliprect;
|
||||
mod dib;
|
||||
mod emf;
|
||||
mod emfplus;
|
||||
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,
|
||||
/// Clip region in picture units, each rectangle `[x0, y0, x1, y1]`. None is the default clip.
|
||||
region: Option<Vec<[f32; 4]>>,
|
||||
},
|
||||
/// 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>,
|
||||
/// Clip region in picture units. None is the default clip.
|
||||
region: Option<Vec<[f32; 4]>>,
|
||||
},
|
||||
/// 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>,
|
||||
/// Clip region in picture units. None is the default clip.
|
||||
region: Option<Vec<[f32; 4]>>,
|
||||
},
|
||||
/// 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, region } => {
|
||||
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, region: map_region(region, &tf)? })
|
||||
}
|
||||
Raw::Bitmap { rect: r, img, region } => {
|
||||
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, region: map_region(region, &tf)? })
|
||||
}
|
||||
Raw::Pattern { ops, even_odd, tile, region } => {
|
||||
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, region: map_region(region, &tf)? })
|
||||
}
|
||||
Raw::Text { mut text, region } => {
|
||||
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
|
||||
let [a, b, c, d, e, g] = text.transform;
|
||||
let (e, g) = tf((e, g));
|
||||
text.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
|
||||
if !text.transform.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = text.clip {
|
||||
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
|
||||
text.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))?]);
|
||||
}
|
||||
text.region = map_region(region, &tf)?;
|
||||
Some(Item::Text(text))
|
||||
}
|
||||
});
|
||||
let items = items.collect();
|
||||
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
|
||||
}
|
||||
|
||||
fn map_region(region: Option<Vec<[f64; 4]>>, tf: &impl Fn(P) -> P) -> Option<Option<Vec<[f32; 4]>>> {
|
||||
let Some(rects) = region else { return Some(None) };
|
||||
let mut out = Vec::with_capacity(rects.len());
|
||||
for [x0, y0, x1, y1] in rects {
|
||||
let (p, q) = (tf((x0, y0)), tf((x1, y1)));
|
||||
out.push([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(Some(out))
|
||||
}
|
||||
|
||||
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 { text: t, .. } => grow((t.transform[4], t.transform[5])),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//! 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 std::sync::Arc;
|
||||
|
||||
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;
|
||||
/// Maximum aggregate path elements retained in placed output.
|
||||
const MAX_PLACED_ELEMENTS: usize = 500_000;
|
||||
|
||||
/// 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, region: Option<Arc<BezPath>> },
|
||||
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
|
||||
Bitmap { index: usize, rect: Rect, region: Option<Arc<BezPath>> },
|
||||
/// `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, region: Option<Arc<BezPath>> },
|
||||
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
|
||||
/// `clip` (target units) bounds what it draws. `region` is the device-context clip.
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<Arc<BezPath>> },
|
||||
}
|
||||
|
||||
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());
|
||||
let mut total_elements = 0usize;
|
||||
let region_elements = std::cell::Cell::new(0usize);
|
||||
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
|
||||
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
|
||||
let (ptr, len) = match reg {
|
||||
None => (std::ptr::null(), 0),
|
||||
Some(v) => (v.as_ptr(), v.len()),
|
||||
};
|
||||
if let Some((c_ptr, c_len, ref res)) = cached_region
|
||||
&& c_ptr == ptr
|
||||
&& c_len == len
|
||||
{
|
||||
return res.clone();
|
||||
}
|
||||
let res = placed_region(&tf, reg);
|
||||
// Each distinct clip region is expanded once; its elements count against the placement budget.
|
||||
let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len());
|
||||
region_elements.set(region_elements.get().saturating_add(n));
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd, region } => {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
// This item's own region is expanded by now: it counts before it is kept.
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
|
||||
}
|
||||
}
|
||||
Item::Pattern { segs, even_odd, tile: t, region } => {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
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 {
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
|
||||
}
|
||||
}
|
||||
Item::Text(t) => {
|
||||
let transform = tf * Affine::new(t.transform);
|
||||
let Some(region) = get_region(&t.region) else { continue };
|
||||
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() && v.abs() <= MAX_COORD) && clip.is_none_or(in_range) {
|
||||
out.push(PlacedItem::Text { index, transform, clip, region });
|
||||
}
|
||||
}
|
||||
Item::Bitmap { rect: [x, y, w, h], region, .. } => {
|
||||
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)));
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
|
||||
out.push(PlacedItem::Bitmap { index, rect, region });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the
|
||||
/// region does not meet the page, so the item draws nothing.
|
||||
fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<Arc<BezPath>>> {
|
||||
let Some(rects) = region else { return Some(None) };
|
||||
let mut path = BezPath::new();
|
||||
for [x0, y0, x1, y1] in rects {
|
||||
let r = tf.transform_rect_bbox(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1)));
|
||||
if in_range(r) && r.width() > 1e-9 && r.height() > 1e-9 {
|
||||
path.extend(r.to_path(0.1));
|
||||
}
|
||||
}
|
||||
(!path.is_empty()).then_some(Some(Arc::new(path)))
|
||||
}
|
||||
|
||||
/// 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
@@ -1,203 +0,0 @@
|
||||
//! 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]>,
|
||||
/// Clip region from the device context, in picture units. None is the default clip. Drawn
|
||||
/// together with [`Text::clip`] when both are set.
|
||||
pub region: Option<Vec<[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
|
||||
};
|
||||
@@ -1,383 +0,0 @@
|
||||
//! 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.set_clip_surface([0.0, 0.0, size.0, size.1]);
|
||||
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);
|
||||
})
|
||||
}
|
||||
0x0415 => rect16(r, 6).map(|rect| cv.exclude_clip_rect(rect)),
|
||||
0x0416 => rect16(r, 6).map(|rect| cv.intersect_clip_rect(rect)),
|
||||
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(())
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
||||
use crate::words;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// (paragraph, char offset in the paragraph).
|
||||
pub type Pos = (usize, usize);
|
||||
@@ -419,6 +420,21 @@ fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
|
||||
at
|
||||
}
|
||||
|
||||
/// The position after the grapheme cluster at `pos`, or the next paragraph's start at a paragraph end.
|
||||
pub fn cluster_end(body: &TextBody, pos: Pos) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let text = body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default();
|
||||
// Boundaries come from the whole paragraph, so a caret inside a cluster deletes only to its end.
|
||||
let mut end = 0;
|
||||
for g in text.graphemes(true) {
|
||||
end += g.chars().count();
|
||||
if end > ch {
|
||||
return (pi, end);
|
||||
}
|
||||
}
|
||||
char_move(body, pos, true)
|
||||
}
|
||||
|
||||
/// One character left/right across paragraph boundaries.
|
||||
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
|
||||
@@ -66,6 +66,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
|
||||
/// [`import`], plus the part names of slides the file lists but that couldn't be read and were
|
||||
/// left out.
|
||||
/// Files with more than 10,000 slide-list entries return [`PptxError::Unsupported`] instead of a truncated presentation.
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
read::import_checked(bytes)
|
||||
}
|
||||
|
||||
+24
-6
@@ -554,12 +554,15 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
import_checked(bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
|
||||
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
|
||||
/// [`import`], plus the slides the file's slide list names but that couldn't be read (a missing
|
||||
/// or truncated slide part, or an entry whose relationship is missing): those slides are left
|
||||
/// out, so a caller can say so (#203).
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
import_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
|
||||
}
|
||||
|
||||
const MAX_SLIDE_ENTRIES: usize = 10_000;
|
||||
|
||||
/// [`import_checked`] with the presentation's chart point budget set (tests lower it).
|
||||
pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
let mut lost_slides: Vec<String> = vec![];
|
||||
@@ -571,9 +574,15 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
|
||||
if !pres_el.is("presentation") {
|
||||
return Err(PptxError::NotPptx(format!("{pres_path} is not a presentation part")));
|
||||
}
|
||||
// Refuse an oversized list before reading slide parts: truncating it would look like a
|
||||
// complete import and allow a later save to overwrite the omitted slides (#1910).
|
||||
// Looking only for the first entry past the cap keeps this check bounded.
|
||||
if pres_el.child("sldIdLst").is_some_and(|list| list.children_named("sldId").nth(MAX_SLIDE_ENTRIES).is_some()) {
|
||||
return Err(PptxError::Unsupported(format!("PPTX slide list exceeds the {MAX_SLIDE_ENTRIES}-entry import limit")));
|
||||
}
|
||||
let pres_part = Part::new(&pkg, &pres_path, &pres_doc);
|
||||
|
||||
let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part);
|
||||
let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part, &mut lost_slides);
|
||||
let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries);
|
||||
|
||||
let mut imp = Imp {
|
||||
@@ -630,7 +639,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
|
||||
|
||||
/// Masters, layouts and slides to read, in file order and without duplicates: (master paths,
|
||||
/// (file slide id, slide path)).
|
||||
fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec<String>, Vec<(u32, String)>) {
|
||||
fn master_and_slide_paths(pres_el: &El, pres_part: &Part, lost_slides: &mut Vec<String>) -> (Vec<String>, Vec<(u32, String)>) {
|
||||
let master_paths: Vec<String> = pres_el
|
||||
.child("sldMasterIdLst")
|
||||
.map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect())
|
||||
@@ -641,8 +650,17 @@ fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec<String>, Vec<(
|
||||
.child("sldIdLst")
|
||||
.map(|l| {
|
||||
l.children_named("sldId")
|
||||
.take(10_000)
|
||||
.filter_map(|s| Some((s.u32("id").unwrap_or(0), pres_part.rels.target(s.attr("r:id").unwrap_or(""))?.to_string())))
|
||||
.take(MAX_SLIDE_ENTRIES)
|
||||
.filter_map(|s| {
|
||||
let rid = s.attr("r:id").unwrap_or("");
|
||||
let Some(path) = pres_part.rels.target(rid) else {
|
||||
// A missing relationship still represents a lost slide. Report it so
|
||||
// the engine opens a repaired copy instead of overwriting the original.
|
||||
lost_slides.push(format!("slide {} (missing relationship {rid})", s.u32("id").unwrap_or(0)));
|
||||
return None;
|
||||
};
|
||||
Some((s.u32("id").unwrap_or(0), path.to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -389,8 +389,11 @@ fn read_series(s: &El, g: &El, group: (ChartType, bool, Option<bool>), categorie
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
// `<c:dLbls><c:delete val="1"/>` hides every label of the series, whatever the group says.
|
||||
if s.path(&["dLbls", "delete"]).map(|v| v.bool("val").unwrap_or(true)) == Some(true) {
|
||||
ser.data_labels = Some(false);
|
||||
// `<c:showVal/>` without a val attribute means true, as in the schema's default.
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) {
|
||||
} else if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) {
|
||||
ser.data_labels = Some(d);
|
||||
} else if let Some(false) = group_labels {
|
||||
// The group asks for no labels and the series does not say otherwise.
|
||||
|
||||
@@ -126,7 +126,7 @@ fn flat_starts(anims: &[&Animation]) -> Vec<u32> {
|
||||
/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by
|
||||
/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it.
|
||||
/// Every other group is left untouched.
|
||||
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option<u32>, usize, u32)]) {
|
||||
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool, usize, u32)], at: &[(Option<u32>, usize, u32)]) {
|
||||
let mut groups: Vec<Vec<usize>> = vec![];
|
||||
let mut slot: std::collections::HashMap<(Option<u32>, usize), usize> = std::collections::HashMap::new();
|
||||
for (i, &(t, g, _)) in at.iter().enumerate() {
|
||||
@@ -155,7 +155,7 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt
|
||||
let had_click = out[idx[0]].0.start == AnimStart::OnClick;
|
||||
let mut order: Vec<usize> = (0..idx.len()).collect();
|
||||
order.sort_by_key(|&k| abs[k]);
|
||||
let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
|
||||
let mut sorted: Vec<(Animation, u32, String, bool, usize, u32)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
|
||||
let (mut begin, mut end) = (0u32, 0u32);
|
||||
for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() {
|
||||
let a = &mut item.0;
|
||||
@@ -170,6 +170,7 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt
|
||||
a.start = AnimStart::WithPrevious;
|
||||
a.delay_ms = t.saturating_sub(begin);
|
||||
}
|
||||
item.5 = 0;
|
||||
end = end.max(t.saturating_add(span_ms(a)));
|
||||
}
|
||||
for (&i, item) in idx.iter().zip(sorted) {
|
||||
@@ -239,17 +240,19 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
let Some(tn) = timing.child("tnLst") else { return vec![] };
|
||||
let mut found = vec![];
|
||||
collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0);
|
||||
// Paragraph builds (`bldP build="p"`) by (spid, grpId).
|
||||
let by_para: Vec<(u32, String)> = timing
|
||||
// Paragraph builds (`bldP`) by (spid, grpId) -> whether `build="p"`.
|
||||
let bld_p: std::collections::HashMap<(u32, String), bool> = timing
|
||||
.child("bldLst")
|
||||
.map(|b| {
|
||||
b.children_named("bldP")
|
||||
.filter(|p| p.attr("build") == Some("p"))
|
||||
.filter_map(|p| Some((p.u32("spid")?, p.attr("grpId").unwrap_or("0").to_string())))
|
||||
.collect()
|
||||
.filter_map(|p| Some(((p.u32("spid")?, p.attr("grpId").unwrap_or("0").to_string()), p.attr("build") == Some("p"))))
|
||||
.fold(std::collections::HashMap::new(), |mut m, (k, v)| {
|
||||
m.entry(k).and_modify(|e| *e |= v).or_insert(v);
|
||||
m
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
|
||||
let mut out: Vec<(Animation, u32, String, bool, usize, u32)> = vec![];
|
||||
let mut at: Vec<(Option<u32>, usize, u32)> = vec![];
|
||||
for f in found {
|
||||
let c = f.ctn;
|
||||
@@ -314,23 +317,27 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
a.color = dml::color(clr);
|
||||
}
|
||||
let grp = c.attr("grpId").unwrap_or("0").to_string();
|
||||
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
|
||||
out.push((a, file_id, grp, build));
|
||||
let build = paragraph.is_some() && bld_p.get(&(file_id, grp.clone())) != Some(&false);
|
||||
out.push((a, file_id, grp, build, f.group, f.offset));
|
||||
at.push((f.trigger, f.group, f.offset));
|
||||
}
|
||||
place_by_parent_delays(&mut out, &at);
|
||||
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
|
||||
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
|
||||
// effect, and other shapes' effects can sit between them (#844).
|
||||
let same_build =
|
||||
|x: &(Animation, u32, String, bool), y: &(Animation, u32, String, bool)| x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger;
|
||||
let same_build = |x: &(Animation, u32, String, bool, usize, u32), y: &(Animation, u32, String, bool, usize, u32)| {
|
||||
x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger
|
||||
};
|
||||
let mut anims = Vec::with_capacity(out.len());
|
||||
for run in out.chunk_by(same_build) {
|
||||
let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect();
|
||||
let offsets: Vec<u32> = run.iter().map(|r| r.5).collect();
|
||||
let build = run.first().is_some_and(|r| r.3);
|
||||
let explicit_bld = run.first().is_some_and(|r| bld_p.get(&(r.1, r.2.clone())) == Some(&true));
|
||||
let same_click_group = run.first().is_some_and(|first| first.0.start == AnimStart::OnClick || run.iter().all(|r| r.4 == first.4));
|
||||
let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref());
|
||||
match (effects.first(), text) {
|
||||
(Some(first), Some(text)) if build && plays_as_build(&effects, text) => {
|
||||
(Some(first), Some(text)) if build && (explicit_bld || same_click_group) && plays_as_build(&effects, &offsets, text, explicit_bld) => {
|
||||
anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() });
|
||||
}
|
||||
_ => anims.extend(effects.into_iter().cloned()),
|
||||
@@ -343,7 +350,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
/// first one plays: one effect per paragraph the build animates, in paragraph order, later top
|
||||
/// paragraphs on click (after previous when the build doesn't start on click), deeper ones with
|
||||
/// their parent, all with the same settings?
|
||||
fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
|
||||
fn plays_as_build(effects: &[&Animation], offsets: &[u32], text: &TextBody, explicit_bld: bool) -> bool {
|
||||
let roles = paragraph_build_roles(text);
|
||||
let expected = roles.iter().enumerate().filter(|(i, r)| match r {
|
||||
BuildRole::Top => true,
|
||||
@@ -351,6 +358,16 @@ fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
|
||||
BuildRole::Unowned => false,
|
||||
});
|
||||
let Some(first) = effects.first() else { return false };
|
||||
let off0 = offsets.first().copied().unwrap_or(0);
|
||||
let flat = flat_starts(effects);
|
||||
let parent_starts_match = offsets
|
||||
.iter()
|
||||
.zip(effects.iter())
|
||||
.zip(flat.iter())
|
||||
.all(|((&off, a), &ft)| off == off0 || off.saturating_add(a.delay_ms) == off0.saturating_add(ft));
|
||||
if !explicit_bld && (!parent_starts_match || (first.start != AnimStart::OnClick && (span_ms(first) != 500 || first.delay_ms != 0 || off0 != 0))) {
|
||||
return false;
|
||||
}
|
||||
let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() };
|
||||
let base = settings(first);
|
||||
let mut matched = 0;
|
||||
|
||||
@@ -794,11 +794,11 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -
|
||||
}
|
||||
w.close("p:sldLayoutIdLst");
|
||||
w.open0("p:txStyles");
|
||||
let (dt, db, dother) = defaults::master_styles();
|
||||
let pick = |s: &deckcraft_model::text::ListStyle, d: deckcraft_model::text::ListStyle| if s.is_empty() { d } else { s.clone() };
|
||||
dml::list_style(&mut w, x, o, "p:titleStyle", &pick(&m.title_style, dt));
|
||||
dml::list_style(&mut w, x, o, "p:bodyStyle", &pick(&m.body_style, db));
|
||||
dml::list_style(&mut w, x, o, "p:otherStyle", &pick(&m.other_style, dother));
|
||||
// An imported empty body style has no bullets; saving must not author defaults into it.
|
||||
// New decks already carry their populated master styles in the model.
|
||||
dml::list_style(&mut w, x, o, "p:titleStyle", &m.title_style);
|
||||
dml::list_style(&mut w, x, o, "p:bodyStyle", &m.body_style);
|
||||
dml::list_style(&mut w, x, o, "p:otherStyle", &m.other_style);
|
||||
w.close("p:txStyles");
|
||||
ext_list(&mut w, ext_children(m.raw_ext.as_deref()).as_deref());
|
||||
w.close("p:sldMaster");
|
||||
|
||||
@@ -106,7 +106,9 @@ pub fn geometry(w: &mut W, g: &Geom) {
|
||||
let single = !adj1 && if defaults.is_empty() { vals.len() == 1 } else { defaults.len() == 1 };
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
let n = if single && i == 0 { "adj".to_string() } else { format!("adj{}", i + 1) };
|
||||
let v = if v.is_finite() { v.round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 };
|
||||
// A near-zero-width connector can need a large ratio for an ordinary-size bend.
|
||||
// Keep guide literals bounded to the signed integer serialization domain.
|
||||
let v = if v.is_finite() { v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 };
|
||||
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
|
||||
}
|
||||
for (n, v) in extra_guides(name) {
|
||||
@@ -303,7 +305,8 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
w.close("p:nvPicPr");
|
||||
dml::blip_fill(w, x, o, "p:blipFill", fill);
|
||||
let mut s2 = s.clone();
|
||||
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
|
||||
// Group fill is a separate backing behind transparent image pixels.
|
||||
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background)) {
|
||||
s2.fill = None;
|
||||
}
|
||||
sp_pr(w, x, o, &s2, true);
|
||||
@@ -940,7 +943,7 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
|
||||
// As for any picture, a colour or pattern fill shows behind the image (an object's preview
|
||||
// keeps its fallback picture's fill).
|
||||
let mut s2 = s.clone();
|
||||
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
|
||||
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background)) {
|
||||
s2.fill = None;
|
||||
}
|
||||
sp_pr(w, x, o, &s2, true);
|
||||
@@ -1031,6 +1034,23 @@ pub fn tree_header(w: &mut W) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guide_literals_remain_finite_bounded_integers() {
|
||||
for (guide, expected) in [
|
||||
(f64::NAN, 0),
|
||||
(f64::INFINITY, 0),
|
||||
(f64::NEG_INFINITY, 0),
|
||||
(f64::MAX, i64::MAX),
|
||||
(f64::MIN, i64::MIN),
|
||||
(12500.6, 12501),
|
||||
(-12500.6, -12501),
|
||||
] {
|
||||
let mut w = W::frag();
|
||||
geometry(&mut w, &Geom::Preset { name: "bentConnector3".into(), adj: vec![guide] });
|
||||
assert!(w.s.contains(&format!(r#"fmla="val {expected}""#)), "{}", w.s);
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint asks to repair a `star5` whose guide list lacks `hf`/`vf` (found black-box).
|
||||
#[test]
|
||||
fn preset_guides_are_complete() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! A series-level `c:dLbls/c:delete val="1"` hides that series' labels although the
|
||||
//! chart-wide `showVal` is on (#1977).
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults};
|
||||
|
||||
fn bar_chart_pptx() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
let mut shape =
|
||||
Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
|
||||
let mut chart = Chart::sample(ChartType::Column);
|
||||
chart.data_labels = false;
|
||||
for s in &mut chart.series {
|
||||
s.data_labels = None;
|
||||
}
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn reimport(bytes: &[u8], f: impl Fn(&str) -> String) -> Presentation {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).expect("entry");
|
||||
let name = entry.name().to_string();
|
||||
let mut data = vec![];
|
||||
entry.read_to_end(&mut data).expect("read");
|
||||
if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") {
|
||||
let xml = String::from_utf8(data).expect("chart xml");
|
||||
data = f(&xml).into_bytes();
|
||||
}
|
||||
out.start_file(&name, opts).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
let patched = out.finish().expect("finish").into_inner();
|
||||
deckcraft_pptx::import(&patched).expect("import")
|
||||
}
|
||||
|
||||
fn chart_of(p: &Presentation) -> &Chart {
|
||||
let s = p.slides.first().expect("slide");
|
||||
for sh in &s.shapes {
|
||||
if let ShapeKind::Chart(c) = &sh.kind {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
panic!("no chart in the deck");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_dlbls_delete_hides_that_series_labels() {
|
||||
let p = reimport(&bar_chart_pptx(), |xml| {
|
||||
xml.replacen("<c:cat>", r#"<c:dLbls><c:delete val="1"/></c:dLbls><c:cat>"#, 1).replacen(
|
||||
"<c:gapWidth",
|
||||
r#"<c:dLbls><c:showVal val="1"/></c:dLbls><c:gapWidth"#,
|
||||
1,
|
||||
)
|
||||
});
|
||||
let c = chart_of(&p);
|
||||
assert!(c.data_labels, "the group-level showVal turns labels on");
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the deleted series has no labels");
|
||||
assert!(!c.series_data_labels(&c.series[0]), "so its labels are not drawn");
|
||||
assert!(c.series_data_labels(&c.series[1]), "the next series shows the chart-wide labels");
|
||||
}
|
||||
@@ -316,3 +316,99 @@ fn left_of(p: &Presentation) -> ShapeId {
|
||||
fn right_of(p: &Presentation) -> ShapeId {
|
||||
by_name(p, "Right").id
|
||||
}
|
||||
|
||||
/// Return a copy of the `.pptx` archive `bytes` with `entry_name` rewritten by `f`.
|
||||
fn rewrite_zip_entry(bytes: &[u8], entry_name: &str, f: impl Fn(&str) -> String) -> Vec<u8> {
|
||||
use std::io::{Cursor, Read, Write};
|
||||
let mut r = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut w = zip::ZipWriter::new(&mut out);
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
for i in 0..r.len() {
|
||||
let mut file = r.by_index(i).unwrap();
|
||||
let name = file.name().to_string();
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).unwrap();
|
||||
w.start_file(&name, opts).unwrap();
|
||||
if name == entry_name {
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
w.write_all(f(&text).as_bytes()).unwrap();
|
||||
} else {
|
||||
w.write_all(&buf).unwrap();
|
||||
}
|
||||
}
|
||||
w.finish().unwrap();
|
||||
}
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_build_without_bld_lst_still_folds_into_by_paragraph() {
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild};
|
||||
let mut pres = defaults::new_presentation(None);
|
||||
let mut sh = shape(&mut pres, "Title 1", 50.0, 50.0, 400.0, 200.0);
|
||||
let shape_id = sh.id;
|
||||
sh.text = Some(TextBody { paragraphs: vec![Paragraph::new("First"), Paragraph::new("Second")], ..Default::default() });
|
||||
let s0 = Arc::make_mut(&mut pres.slides[0]);
|
||||
s0.shapes.push(sh);
|
||||
s0.animations.push(Animation {
|
||||
shape: shape_id,
|
||||
class: AnimClass::Entrance,
|
||||
effect: "fade".into(),
|
||||
start: AnimStart::OnClick,
|
||||
duration_ms: 500,
|
||||
text_build: TextBuild::ByParagraph,
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&pres).unwrap();
|
||||
let without_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| {
|
||||
let start = xml.find("<p:bldLst>").expect("bldLst");
|
||||
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
|
||||
format!("{}{}", &xml[..start], &xml[end..])
|
||||
});
|
||||
let back = deckcraft_pptx::import(&without_bld).unwrap();
|
||||
assert_eq!(back.slides[0].animations.len(), 1);
|
||||
assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph);
|
||||
|
||||
// Explicit bldP build="p" folds non-500ms After Previous builds, while without bldLst an 800ms
|
||||
// After Previous run stays separate so playback does not alter its 800ms stagger to 500ms.
|
||||
let mut pres_after = pres.clone();
|
||||
let s_after = Arc::make_mut(&mut pres_after.slides[0]);
|
||||
s_after.animations[0].start = AnimStart::AfterPrevious;
|
||||
s_after.animations[0].duration_ms = 800;
|
||||
let bytes_after = deckcraft_pptx::export(&pres_after).unwrap();
|
||||
let back_explicit_after = deckcraft_pptx::import(&bytes_after).unwrap();
|
||||
assert_eq!(back_explicit_after.slides[0].animations.len(), 1);
|
||||
assert_eq!(back_explicit_after.slides[0].animations[0].text_build, TextBuild::ByParagraph);
|
||||
assert_eq!(back_explicit_after.slides[0].animations[0].duration_ms, 800);
|
||||
|
||||
let without_bld_after = rewrite_zip_entry(&bytes_after, "ppt/slides/slide1.xml", |xml| {
|
||||
let start = xml.find("<p:bldLst>").expect("bldLst");
|
||||
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
|
||||
format!("{}{}", &xml[..start], &xml[end..])
|
||||
});
|
||||
let back_after = deckcraft_pptx::import(&without_bld_after).unwrap();
|
||||
assert_eq!(back_after.slides[0].animations.len(), 2);
|
||||
assert!(back_after.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
|
||||
// Explicit bldP with build!="p" (e.g. build="cust") opts out of folding.
|
||||
let custom_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| xml.replace(r#"build="p""#, r#"build="cust""#));
|
||||
let back_cust = deckcraft_pptx::import(&custom_bld).unwrap();
|
||||
assert_eq!(back_cust.slides[0].animations.len(), 2);
|
||||
assert!(back_cust.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
|
||||
|
||||
// Without bldLst, an AfterPrevious second paragraph whose parent container delay (1000ms)
|
||||
// disagrees with the 500ms first paragraph span must not fold into a 500ms build.
|
||||
let mut pres_with = pres.clone();
|
||||
Arc::make_mut(&mut pres_with.slides[0]).animations[0].start = AnimStart::WithPrevious;
|
||||
let bytes_with = deckcraft_pptx::export(&pres_with).unwrap();
|
||||
let mismatched_offset = rewrite_zip_entry(&bytes_with, "ppt/slides/slide1.xml", |xml| {
|
||||
let start = xml.find("<p:bldLst>").expect("bldLst");
|
||||
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
|
||||
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
|
||||
stripped.replacen(r#"<p:cond delay="500"/>"#, r#"<p:cond delay="1000"/>"#, 1)
|
||||
});
|
||||
let back_mismatch = deckcraft_pptx::import(&mismatched_offset).unwrap();
|
||||
assert_eq!(back_mismatch.slides[0].animations.len(), 2);
|
||||
assert!(back_mismatch.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod fills;
|
||||
mod kept_parts;
|
||||
mod locks;
|
||||
mod malformed;
|
||||
mod master_text_styles;
|
||||
mod math_ns;
|
||||
mod media_timing;
|
||||
mod notes;
|
||||
@@ -29,3 +30,5 @@ mod table_styles;
|
||||
mod tags;
|
||||
mod transition_xml;
|
||||
mod trigger_steps;
|
||||
|
||||
mod picture_backing;
|
||||
|
||||
@@ -288,6 +288,94 @@ fn unreadable_slide_is_reported() {
|
||||
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
||||
}
|
||||
|
||||
/// Missing slide relationships must not turn a damaged deck into a clean partial load (#1910).
|
||||
#[test]
|
||||
fn missing_slide_relationships_are_reported() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
for _ in 1..13 {
|
||||
let slide = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
p.slides.push(std::sync::Arc::new(slide));
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let pres = es.iter_mut().find(|(n, _)| n == "ppt/presentation.xml").expect("presentation");
|
||||
let xml = String::from_utf8(pres.1.clone()).expect("utf8");
|
||||
let doc = deckcraft_pptx::parse_xml(xml.as_bytes()).expect("xml");
|
||||
let slides = doc.root.child("sldIdLst").expect("slide list");
|
||||
let mut broken = xml.clone();
|
||||
for slide in slides.children_named("sldId").skip(6) {
|
||||
let rid = slide.attr("r:id").expect("relationship id");
|
||||
broken = broken.replace(&format!("r:id=\"{rid}\""), &format!("r:id=\"missing-{rid}\""));
|
||||
}
|
||||
pres.1 = broken.into_bytes();
|
||||
let damaged = rezip(&es);
|
||||
let (q, lost) = deckcraft_pptx::import_checked(&damaged).expect("import");
|
||||
assert_eq!(q.slides.len(), 6);
|
||||
assert_eq!(lost.len(), 7, "every omitted slide must be reported: {lost:?}");
|
||||
let loaded = deckcraft_engine::cmd::file::loaded_file("Original.pptx", &damaged).expect("load");
|
||||
let mut session = deckcraft_engine::Session::new();
|
||||
let token = session.stage_loaded(loaded);
|
||||
let result = session.execute("file.adopt", &serde_json::json!({"path": "Original.pptx", "token": token})).expect("adopt");
|
||||
assert_eq!(result["lostSlides"].as_array().expect("lost slides").len(), 7);
|
||||
assert!(session.doc().expect("doc").path.is_none(), "Save must not overwrite the damaged source");
|
||||
assert!(session.doc().expect("doc").title.ends_with("(Repaired)"));
|
||||
let (q, lost) = deckcraft_pptx::import_checked(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
assert_eq!(q.slides.len(), 13);
|
||||
assert!(lost.is_empty());
|
||||
}
|
||||
|
||||
/// Every listed entry has its own slide part and relationship: the resource cap must not
|
||||
/// silently turn an otherwise readable large deck into a complete-looking partial load.
|
||||
#[test]
|
||||
fn over_limit_slide_lists_are_refused() {
|
||||
fn deck(count: usize) -> Vec<u8> {
|
||||
let mut es = entries(&valid());
|
||||
let pres = es.iter_mut().find(|(n, _)| n == "ppt/presentation.xml").expect("presentation");
|
||||
let mut xml = String::from_utf8(pres.1.clone()).expect("utf8");
|
||||
let start = xml.find("<p:sldIdLst>").expect("slide list");
|
||||
let end = xml.find("</p:sldIdLst>").expect("slide list end") + "</p:sldIdLst>".len();
|
||||
let ids: String = (1..=count).map(|i| format!(r#"<p:sldId id="{}" r:id="capSlide{i}"/>"#, 255 + i)).collect();
|
||||
xml.replace_range(start..end, &format!("<p:sldIdLst>{ids}</p:sldIdLst>"));
|
||||
pres.1 = xml.into_bytes();
|
||||
let rels = es.iter_mut().find(|(n, _)| n == "ppt/_rels/presentation.xml.rels").expect("relationships");
|
||||
let mut xml = String::from_utf8(rels.1.clone()).expect("utf8");
|
||||
let targets: String = (1..=count).map(|i| format!(r#"<Relationship Id="capSlide{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{i}.xml"/>"#)).collect();
|
||||
xml = xml.replace("</Relationships>", &format!("{targets}</Relationships>"));
|
||||
rels.1 = xml.into_bytes();
|
||||
let types = es.iter_mut().find(|(n, _)| n == "[Content_Types].xml").expect("content types");
|
||||
let xml = String::from_utf8(types.1.clone()).expect("utf8");
|
||||
let overrides: String = (2..=count).map(|i| format!(r#"<Override PartName="/ppt/slides/slide{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>"#)).collect();
|
||||
types.1 = xml.replace("</Types>", &format!("{overrides}</Types>")).into_bytes();
|
||||
let blank = br#"<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld></p:sld>"#.to_vec();
|
||||
es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide").1 = blank.clone();
|
||||
let slide_rels = es.iter().find(|(n, _)| n == "ppt/slides/_rels/slide1.xml.rels").expect("slide relationships").1.clone();
|
||||
for i in 2..=count {
|
||||
es.push((format!("ppt/slides/slide{i}.xml"), blank.clone()));
|
||||
es.push((format!("ppt/slides/_rels/slide{i}.xml.rels"), slide_rels.clone()));
|
||||
}
|
||||
rezip(&es)
|
||||
}
|
||||
let at_limit = deck(10_000);
|
||||
let (p, lost) = deckcraft_pptx::import_checked(&at_limit).expect("exactly at the limit");
|
||||
assert_eq!(p.slides.len(), 10_000);
|
||||
assert!(lost.is_empty());
|
||||
drop(p);
|
||||
|
||||
let over_limit = deck(10_001);
|
||||
for error in [
|
||||
deckcraft_pptx::import_checked(&over_limit).expect_err("must refuse a partial load"),
|
||||
deckcraft_pptx::import(&over_limit).expect_err("plain import must also refuse"),
|
||||
] {
|
||||
assert!(matches!(error, deckcraft_pptx::PptxError::Unsupported(_)));
|
||||
assert!(error.to_string().contains("10000"), "{error}");
|
||||
assert!(error.to_string().contains("slide"), "{error}");
|
||||
}
|
||||
assert!(
|
||||
deckcraft_engine::cmd::file::loaded_file("Original.pptx", &over_limit).is_err(),
|
||||
"no partial file may be adopted with the original save path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
|
||||
/// slide that names it.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Imported empty master styles must not become the defaults used to author new decks.
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Align, Bullet, ParaProps, TextBody};
|
||||
use deckcraft_model::{PhType, Placeholder, Presentation, Shape, ShapeId, defaults};
|
||||
|
||||
fn subtitle(text: &str) -> Shape {
|
||||
let mut body = TextBody::from_text(text);
|
||||
body.paragraphs[0].props.align = Some(Align::Center);
|
||||
Shape {
|
||||
id: ShapeId(102),
|
||||
name: "Centered subtitle".into(),
|
||||
ph: Some(Placeholder { kind: PhType::SubTitle, idx: 1, ..Default::default() }),
|
||||
xfrm: Some(Xfrm::new(120.0, 120.0, 720.0, 90.0)),
|
||||
text: Some(body),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Author an input with empty title/other styles and the specified body style, without asking
|
||||
/// the serializer under test to supply its own master text styles in the input.
|
||||
fn imported(body_style: &str) -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let m = Arc::make_mut(&mut p.masters[0]);
|
||||
m.shapes.clear();
|
||||
for l in &mut m.layouts {
|
||||
l.shapes = vec![subtitle("")];
|
||||
}
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![subtitle("Centered subtitle")];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("base package");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut part = z.by_index(i).expect("part");
|
||||
let name = part.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
part.read_to_end(&mut data).expect("read");
|
||||
if name.starts_with("ppt/slideMasters/slideMaster") && name.ends_with(".xml") {
|
||||
let mut xml = String::from_utf8(data).expect("generated XML");
|
||||
let start = xml.find("<p:txStyles>").expect("styles start");
|
||||
let end = xml.find("</p:txStyles>").expect("styles end") + "</p:txStyles>".len();
|
||||
xml.replace_range(start..end, &format!("<p:txStyles><p:titleStyle/><p:bodyStyle>{body_style}</p:bodyStyle><p:otherStyle/></p:txStyles>"));
|
||||
data = xml.into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import fixture")
|
||||
}
|
||||
|
||||
fn resolved_subtitle(p: &Presentation) -> ParaProps {
|
||||
let slide = &p.slides[0];
|
||||
let shape = slide.shapes.iter().find(|s| s.name == "Centered subtitle").expect("subtitle");
|
||||
assert_eq!(shape.ph_type(), Some(PhType::SubTitle));
|
||||
assert_eq!(shape.xfrm, Some(Xfrm::new(120.0, 120.0, 720.0, 90.0)));
|
||||
let text = shape.text.as_ref().expect("text");
|
||||
assert_eq!(text.text(), "Centered subtitle");
|
||||
resolve::para(&Ctx::for_slide(p, slide).expect("context"), shape, &text.paragraphs[0])
|
||||
}
|
||||
|
||||
fn saved(p: &Presentation) -> Presentation {
|
||||
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("reopen")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_empty_master_keeps_its_unbulleted_subtitle_after_two_saves() {
|
||||
let mut p = imported("");
|
||||
let before = resolved_subtitle(&p);
|
||||
assert_eq!(before.bullet, None);
|
||||
assert_eq!(before.align, Some(Align::Center));
|
||||
for _ in 0..2 {
|
||||
p = saved(&p);
|
||||
assert_eq!(resolved_subtitle(&p).bullet, None, "saving must not add a subtitle bullet");
|
||||
let master = &p.masters[0];
|
||||
assert!(master.title_style.is_empty(), "do not invent an imported title style");
|
||||
assert!(master.body_style.is_empty(), "do not invent bullets in an imported body style");
|
||||
assert!(master.other_style.is_empty(), "do not invent an imported other-text style");
|
||||
assert_eq!(resolved_subtitle(&p), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_unbulleted_body_style_keeps_its_properties() {
|
||||
let mut p = imported(r#"<a:lvl1pPr><a:buNone/><a:defRPr sz="1900"><a:latin typeface="Carlito"/></a:defRPr></a:lvl1pPr>"#);
|
||||
let expected = p.masters[0].body_style.clone();
|
||||
assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None));
|
||||
for _ in 0..2 {
|
||||
p = saved(&p);
|
||||
assert_eq!(p.masters[0].body_style, expected);
|
||||
assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newly_authored_presentations_keep_their_populated_master_defaults() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let m = &p.masters[0];
|
||||
let expected = (m.title_style.clone(), m.body_style.clone(), m.other_style.clone());
|
||||
assert!(!expected.0.is_empty() && !expected.1.is_empty() && !expected.2.is_empty());
|
||||
assert_eq!(expected.1.levels.len(), 9);
|
||||
assert!(matches!(expected.1.level(0).expect("body level").para.bullet, Some(Bullet::Char { .. })));
|
||||
for _ in 0..2 {
|
||||
p = saved(&p);
|
||||
let m = &p.masters[0];
|
||||
assert_eq!((&m.title_style, &m.body_style, &m.other_style), (&expected.0, &expected.1, &expected.2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! A picture's fill is a backing behind its transparent image, independent of its blip fill.
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Presentation, Shape, ShapeId, ShapeKind, Xfrm, defaults};
|
||||
|
||||
fn deck(fill: Option<Fill>, object: bool) -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let image = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([0, 0, 128, 255]) });
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image).write_to(&mut png, image::ImageFormat::Png).expect("PNG");
|
||||
let media = p.add_media("transparent.png", "image/png", png.into_inner()).expect("media");
|
||||
let kind = if object {
|
||||
ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "synthetic preview".into(), parts: vec![] }
|
||||
} else {
|
||||
ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }
|
||||
};
|
||||
let pic = Shape { id: ShapeId(102), name: "Picture".into(), xfrm: Some(Xfrm::new(20.0, 10.0, 80.0, 40.0)), fill, kind, ..Default::default() };
|
||||
let group = Shape {
|
||||
id: ShapeId(101),
|
||||
name: "Group".into(),
|
||||
xfrm: Some(Xfrm::new(100.0, 150.0, 400.0, 180.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(Rgba::WHITE))),
|
||||
kind: ShapeKind::Group { children: vec![pic], child: Xfrm::new(0.0, 0.0, 200.0, 120.0) },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(group);
|
||||
p
|
||||
}
|
||||
|
||||
fn picture(p: &Presentation) -> &Shape {
|
||||
let g = p.slides[0].shapes.iter().find(|s| s.name == "Group").expect("group");
|
||||
let ShapeKind::Group { children, child } = &g.kind else { panic!("group kind") };
|
||||
assert_eq!(*child, Xfrm::new(0.0, 0.0, 200.0, 120.0));
|
||||
assert_eq!(g.xfrm, Some(Xfrm::new(100.0, 150.0, 400.0, 180.0)));
|
||||
assert_eq!(g.fill, Some(Fill::solid(ColorRef::rgb(Rgba::WHITE))));
|
||||
children.iter().find(|s| s.name == "Picture").expect("picture")
|
||||
}
|
||||
|
||||
fn check_saves(mut p: Presentation, fill: Option<Fill>) {
|
||||
let expected_media = p.media[0].data.clone();
|
||||
for _ in 0..2 {
|
||||
p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let pic = picture(&p);
|
||||
assert_eq!(pic.fill, fill, "keep the backing separate from the picture's blip fill");
|
||||
assert_eq!(pic.xfrm, Some(Xfrm::new(20.0, 10.0, 80.0, 40.0)));
|
||||
let ShapeKind::Picture { fill: image } = &pic.kind else { panic!("picture kind") };
|
||||
assert!(matches!(image.mode, PictureMode::Stretch { .. }));
|
||||
let media = p.media.iter().find(|m| m.id == image.media).expect("referenced image");
|
||||
assert_eq!(media.data, expected_media, "keep transparent PNG bytes unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparent_picture_keeps_its_inherited_group_backing() {
|
||||
check_saves(deck(Some(Fill::Group), false), Some(Fill::Group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_saved_as_its_picture_preview_keeps_its_inherited_group_backing() {
|
||||
// Empty kept XML deliberately exercises shape_with_id's preview fallback, not the ordinary
|
||||
// picture writer. Both must keep the same backing when they serialize p:pic.
|
||||
check_saves(deck(Some(Fill::Group), true), Some(Fill::Group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfilled_and_directly_filled_pictures_keep_their_own_backing() {
|
||||
for fill in [None, Some(Fill::None), Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 100, 20))))] {
|
||||
check_saves(deck(fill.clone(), false), fill);
|
||||
}
|
||||
}
|
||||
@@ -1396,6 +1396,45 @@ fn single_guide_names_follow_the_preset() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Large finite guides position a visible bend even when the connector is only one EMU wide.
|
||||
#[test]
|
||||
fn large_connector_adjustments_keep_their_elbows_through_two_saves() {
|
||||
let cases = [
|
||||
("ordinary", 80.0, 50000.0),
|
||||
("near control", 1588.0 / 12700.0, -12800000.0),
|
||||
("near negative", 1.0 / 12700.0, -20320000000.0),
|
||||
("near positive", 1.0 / 12700.0, 20320000000.0),
|
||||
("zero width", 0.0, 20320000000.0),
|
||||
("ordinary negative", 80.0, -20000.0),
|
||||
];
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let mut s = new_slide(&mut p, "Connectors");
|
||||
s.shapes.clear();
|
||||
for (name, width, guide) in cases {
|
||||
let mut sh = shape(&mut p, name, 100.0, 80.0, width, 140.0);
|
||||
sh.kind = ShapeKind::Connector { start: None, end: None };
|
||||
sh.geom = Geom::Preset { name: "bentConnector3".into(), adj: vec![guide] };
|
||||
s.shapes.push(sh);
|
||||
}
|
||||
p.slides.push(Arc::new(s));
|
||||
for generation in 1..=2 {
|
||||
p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reopen");
|
||||
for (name, width, guide) in cases {
|
||||
let sh = p.slides[0].shapes.iter().find(|sh| sh.name == name).expect("connector");
|
||||
let Geom::Preset { name: preset, adj } = &sh.geom else {
|
||||
panic!("preset connector");
|
||||
};
|
||||
assert_eq!(adj, &[guide], "{name}, save {generation}");
|
||||
let xfrm = sh.xfrm.expect("transform");
|
||||
assert_eq!((xfrm.w, xfrm.h), (width, 140.0));
|
||||
let expected = deckcraft_geom::preset::build("bentConnector3", width, 140.0, &[guide]).expect("expected geometry");
|
||||
let actual = deckcraft_geom::preset::build(preset, xfrm.w, xfrm.h, adj).expect("reopened geometry");
|
||||
assert_eq!(actual.outline(), expected.outline(), "{name}, save {generation}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #147: PowerPoint's Float Down is preset 47 (Float Up is 42); it imported as a plain fade.
|
||||
#[test]
|
||||
fn float_down_is_preset_47_both_ways() {
|
||||
|
||||
@@ -11,7 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
deckcraft-geom = { workspace = true }
|
||||
deckcraft-color = { workspace = true }
|
||||
deckcraft-metafile = { workspace = true }
|
||||
craft-metafile = { workspace = true }
|
||||
deckcraft-model = { workspace = true }
|
||||
deckcraft-anim = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
@@ -25,3 +25,6 @@ skrifa = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-metafile = { workspace = true, features = ["write"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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
|
||||
//! `craft-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
|
||||
@@ -10,8 +10,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_metafile::{Cap, HAlign, Item, Join, LineStyle, PlacedClip, PlacedItem, Text, Tile, VAlign};
|
||||
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};
|
||||
@@ -28,13 +28,13 @@ 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)
|
||||
craft_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()?;
|
||||
let mut pic = craft_metafile::parse(bytes).ok()?;
|
||||
if !pic.has_size() {
|
||||
return None;
|
||||
}
|
||||
@@ -51,31 +51,39 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
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, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Path { path, fill, stroke, line, even_odd, region, clips } => clipped(&mut ctx, region.as_deref(), &clips, |ctx| {
|
||||
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.
|
||||
// The pen's caps, joins and dashes (GDI's default: round, solid); 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.set_stroke(pen(sw.max(1.0), &line));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}),
|
||||
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Bitmap { index, rect, transform, region, clips } => clipped(&mut ctx, region.as_deref(), &clips, |ctx| {
|
||||
let Some(Some(pm)) = pixmaps.get(index) else { return };
|
||||
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);
|
||||
let c = transform.as_coeffs();
|
||||
if c[1] == 0.0 && c[2] == 0.0 {
|
||||
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
|
||||
ctx.fill_rect(&rect);
|
||||
} else {
|
||||
// A turned or skewed bitmap: `transform` maps the unit square onto its place.
|
||||
ctx.set_paint_transform(transform * Affine::scale_non_uniform(1.0 / bw, 1.0 / bh));
|
||||
ctx.fill_path(&(transform * Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1)));
|
||||
}
|
||||
ctx.reset_paint_transform();
|
||||
}),
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region, clips } => clipped(&mut ctx, region.as_deref(), &clips, |ctx| {
|
||||
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { return };
|
||||
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();
|
||||
@@ -102,7 +110,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
}
|
||||
}
|
||||
}),
|
||||
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Text { index, transform, clip, region, clips } => clipped(&mut ctx, region.as_deref(), &clips, |ctx| {
|
||||
let Some(Item::Text(t)) = pic.items.get(index) else { return };
|
||||
if let Some(c) = clip {
|
||||
ctx.push_clip_layer(&c.to_path(0.1));
|
||||
@@ -133,15 +141,40 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
/// Draws `draw` inside the device-context clip. No region is the default clip.
|
||||
fn clipped(ctx: &mut RenderContext, region: Option<&BezPath>, draw: impl FnOnce(&mut RenderContext)) {
|
||||
/// Draws `draw` inside the device-context clip and every clip path. No region is the default clip.
|
||||
fn clipped(ctx: &mut RenderContext, region: Option<&BezPath>, clips: &[Arc<PlacedClip>], draw: impl FnOnce(&mut RenderContext)) {
|
||||
if let Some(region) = region {
|
||||
ctx.push_clip_layer(region);
|
||||
draw(ctx);
|
||||
ctx.pop_layer();
|
||||
} else {
|
||||
draw(ctx);
|
||||
}
|
||||
for c in clips {
|
||||
ctx.set_fill_rule(if c.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.push_clip_layer(&c.path);
|
||||
}
|
||||
if !clips.is_empty() {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
draw(ctx);
|
||||
for _ in 0..clips.len() + usize::from(region.is_some()) {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// A GDI pen as a stroke `width` wide (target units): its caps, joins, miter limit and dashes.
|
||||
fn pen(width: f64, line: &LineStyle) -> kurbo::Stroke {
|
||||
let cap = match line.cap {
|
||||
Cap::Round => kurbo::Cap::Round,
|
||||
Cap::Square => kurbo::Cap::Square,
|
||||
Cap::Flat => kurbo::Cap::Butt,
|
||||
};
|
||||
let join = match line.join {
|
||||
Join::Round => kurbo::Join::Round,
|
||||
Join::Bevel => kurbo::Join::Bevel,
|
||||
Join::Miter => kurbo::Join::Miter,
|
||||
};
|
||||
let stroke = kurbo::Stroke::new(width).with_caps(cap).with_join(join).with_miter_limit(f64::from(line.miter_limit));
|
||||
// A dash pattern with no length would never advance: such a pen draws solid.
|
||||
let dash: Vec<f64> = line.dash.iter().map(|d| f64::from(*d)).collect();
|
||||
if dash.iter().all(|d| d.is_finite() && *d >= 0.0) && dash.iter().sum::<f64>() > 0.0 { stroke.with_dashes(0.0, dash) } else { stroke }
|
||||
}
|
||||
|
||||
/// One glyph placed in text space (x along the baseline from the string's start, font units at
|
||||
|
||||
@@ -1962,6 +1962,70 @@ fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_dashed_pen_leaves_gaps() {
|
||||
// CREATEPENINDIRECT PS_DASH | PS_ENDCAP_FLAT, 40 units (4 px) wide, black, then a line across
|
||||
// the picture at y = 72 px: 12 px dashes with 4 px gaps.
|
||||
let mut pen = i16s(&[0x0201, 40, 0]);
|
||||
pen.extend(0u32.to_le_bytes());
|
||||
let recs = vec![(0x02FA, pen), (0x012D, i16s(&[0])), (0x0214, i16s(&[720, 100])), (0x0213, i16s(&[720, 2780]))];
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let ink: Vec<bool> = (20..260).map(|x| img.get_pixel(x, 72)[3] > 128).collect();
|
||||
assert!(ink.iter().filter(|b| **b).count() > 120, "dashes are drawn");
|
||||
assert!(ink.iter().filter(|b| !**b).count() > 30, "with gaps between them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_skewed_bitmap_is_drawn_with_its_transform() {
|
||||
use craft_metafile::write::{Image as MetafileImage, Op, emf};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
let image = std::sync::Arc::new(MetafileImage { width: 2, height: 2, rgba: [255, 0, 0, 255].repeat(4) });
|
||||
let xf = Affine::translate((25.0, 25.0)) * Affine::new([1.0, 0.25, 0.2, 1.0, 0.0, 0.0]) * Affine::scale(24.0);
|
||||
let bytes = emf(&[Op::Image { image, xf, opacity: 1.0 }], Rect::new(0.0, 0.0, 100.0, 100.0), "DeckCraft", "skewed bitmap").expect("EMF");
|
||||
let img = crate::metafile::rasterize(&bytes, 160).expect("raster");
|
||||
let red: Vec<_> = img.enumerate_pixels().filter(|(_, _, p)| p[0] > 180 && p[1] < 80 && p[3] > 128).collect();
|
||||
assert!(red.len() > 100, "the transformed image is visible: {} red pixels", red.len());
|
||||
let (min_x, max_x) = red.iter().fold((u32::MAX, 0), |(lo, hi), (x, _, _)| (lo.min(*x), hi.max(*x)));
|
||||
let (min_y, max_y) = red.iter().fold((u32::MAX, 0), |(lo, hi), (_, y, _)| (lo.min(*y), hi.max(*y)));
|
||||
assert!(max_x - min_x > 25 && max_y - min_y > 25, "the image keeps both transformed axes: x={min_x}..{max_x}, y={min_y}..{max_y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_clip_path_and_nondefault_pen_geometry_are_applied() {
|
||||
use craft_metafile::write::{Op, Pen, emf};
|
||||
use craft_metafile::{Cap, Join};
|
||||
use kurbo::{BezPath, Rect, Shape as _};
|
||||
|
||||
let mut triangle = BezPath::new();
|
||||
triangle.move_to((10.0, 10.0));
|
||||
triangle.line_to((90.0, 10.0));
|
||||
triangle.line_to((50.0, 90.0));
|
||||
triangle.close_path();
|
||||
|
||||
let mut bevel = BezPath::new();
|
||||
bevel.move_to((15.0, 35.0));
|
||||
bevel.line_to((50.0, 60.0));
|
||||
bevel.line_to((85.0, 35.0));
|
||||
let mut miter = BezPath::new();
|
||||
miter.move_to((15.0, 45.0));
|
||||
miter.line_to((50.0, 70.0));
|
||||
miter.line_to((85.0, 45.0));
|
||||
let pen = |join| Pen { rgb: [0, 0, 0], width: 5.0, cap: Cap::Square, join, miter: 4.0, dash: None };
|
||||
let ops = [
|
||||
Op::Clip { path: triangle, even_odd: false },
|
||||
Op::Fill { path: Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1), even_odd: false, rgb: [255, 0, 0] },
|
||||
Op::Stroke { path: bevel, pen: pen(Join::Bevel) },
|
||||
Op::Stroke { path: miter, pen: pen(Join::Miter) },
|
||||
Op::Unclip,
|
||||
];
|
||||
let bytes = emf(&ops, Rect::new(0.0, 0.0, 100.0, 100.0), "DeckCraft", "clipped strokes").expect("EMF");
|
||||
let img = crate::metafile::rasterize(&bytes, 160).expect("raster");
|
||||
assert!(img.get_pixel(80, 40)[0] > 180, "inside the clip path is filled");
|
||||
assert_eq!(img.get_pixel(8, 80)[3], 0, "outside the clip path remains transparent");
|
||||
assert!(img.pixels().any(|p| p[3] > 128 && p[0] < 80), "square capped bevel and miter strokes are rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_ink_lands_at_its_reference_point() {
|
||||
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
|
||||
|
||||
@@ -238,7 +238,7 @@ impl SlideApp {
|
||||
"app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"),
|
||||
"app.smartArtPictureDialog" => {
|
||||
if self.services.pick_open.is_none() {
|
||||
return Err("picking a SmartArt picture needs the desktop app".into());
|
||||
return Err("picking a Diagram picture needs the desktop app".into());
|
||||
}
|
||||
self.pick_and_run("picture", "smartArt.picture", p);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn title(id: &str) -> &'static str {
|
||||
"customShow" => "Custom Shows",
|
||||
"chartData" => "Chart Data",
|
||||
"trim" => "Trim Media",
|
||||
"smartart" => "Choose a SmartArt Graphic",
|
||||
"smartart" => "Choose a Diagram Graphic",
|
||||
"bullets" => "Bullets and Numbering",
|
||||
"action" => "Action Settings",
|
||||
"quit" => "DeckCraft",
|
||||
|
||||
@@ -1260,7 +1260,7 @@ fn dlg_smartart(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
let mut v = d.get("text", "Plan\nBuild\nLaunch");
|
||||
ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0));
|
||||
ui.add(egui::TextEdit::multiline(&mut v).hint_text("Diagram items").desired_rows(5).desired_width(320.0));
|
||||
d.fields.insert("text".into(), v.clone());
|
||||
let (ok, cancel) = buttons(ui, "Insert");
|
||||
if ok {
|
||||
|
||||
@@ -143,7 +143,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
("app.openDialog", "Open…", None, "{purpose?}"),
|
||||
("app.insertPictureDialog", "Picture from File…", None, "{}"),
|
||||
("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"),
|
||||
("app.smartArtPictureDialog", "Insert Picture in Diagram…", None, "{id?, index}"),
|
||||
("app.changePictureDialog", "Change Picture…", None, "{}"),
|
||||
("app.insertAudioDialog", "Audio from File…", None, "{}"),
|
||||
("app.reuseSlidesDialog", "Reuse Slides…", None, "{keepFormatting?: bool}"),
|
||||
|
||||
@@ -248,7 +248,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("-", ""),
|
||||
("Section", "section.add"),
|
||||
("Text Box", "insert.textBox"),
|
||||
("WordArt", "insert.wordArt"),
|
||||
("Decorative Text", "insert.wordArt"),
|
||||
("Date and Time", "insert.dateTime"),
|
||||
("Slide Number", "insert.slideNumber"),
|
||||
("-", ""),
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::{SlideApp, Tokens, Ui, Value, json, run};
|
||||
/// Shift+Tab demote and promote, Backspace in an empty line removes it.
|
||||
pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else {
|
||||
ui.label("Select a SmartArt graphic to edit its text.");
|
||||
ui.label("Select a Diagram graphic to edit its text.");
|
||||
return;
|
||||
};
|
||||
let texts: Vec<String> = d.nodes.iter().map(|n| n.text.clone()).collect();
|
||||
@@ -24,7 +24,7 @@ pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0 * node.level as f32);
|
||||
ui.label("•");
|
||||
let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY);
|
||||
let te = egui::TextEdit::singleline(draft).id(id).hint_text("Diagram text").desired_width(f32::INFINITY);
|
||||
let r = ui.add(te);
|
||||
if app.ui.smartart_focus == Some(i) {
|
||||
r.request_focus();
|
||||
|
||||
@@ -66,12 +66,12 @@ fn home_convert_to_smartart_gallery_converts_the_text() {
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Convert to SmartArt").click();
|
||||
h.get_by_label("Convert to Diagram").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Basic Block List").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a SmartArt graphic");
|
||||
let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a Diagram graphic");
|
||||
assert_eq!(d.kind, "blockList");
|
||||
assert_eq!(d.nodes.len(), 3);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> {
|
||||
v.push("Layout");
|
||||
}
|
||||
if deckcraft_engine::cmd::smartart::selected(st).is_some() {
|
||||
v.push("SmartArt Design");
|
||||
v.push(DIAGRAM_TAB_ID);
|
||||
}
|
||||
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) {
|
||||
v.push("Chart Design");
|
||||
@@ -117,6 +117,13 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> {
|
||||
v
|
||||
}
|
||||
|
||||
// This is an existing saved/control-channel key, not the displayed label.
|
||||
const DIAGRAM_TAB_ID: &str = "SmartArt Design";
|
||||
|
||||
fn tab_label(id: &str) -> &str {
|
||||
if id == DIAGRAM_TAB_ID { "Diagram Design" } else { id }
|
||||
}
|
||||
|
||||
const TABS: [&str; 10] = ["Home", "Insert", "Draw", "Design", "Transitions", "Animations", "Slide Show", "Record", "Review", "View"];
|
||||
|
||||
/// Tab row and the ribbon card.
|
||||
@@ -149,18 +156,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
|
||||
for (name, contextual) in all {
|
||||
let label = tab_label(name);
|
||||
let font = theme::font(13.0);
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0;
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 18.0;
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("tab", name)), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let active = app.ui.tab == name;
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let col = if contextual { t.contextual } else { t.text };
|
||||
let f = if active { theme::bold(13.0) } else { font };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, f, col);
|
||||
if active {
|
||||
let uw = (w - 20.0).max(16.0);
|
||||
ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent);
|
||||
@@ -227,7 +235,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Layout" => table_layout(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
"SmartArt Design" => smartart_design(app, ui),
|
||||
DIAGRAM_TAB_ID => smartart_design(app, ui),
|
||||
"Playback" => playback(app, ui),
|
||||
"Slide Master" => slide_master(app, ui),
|
||||
"Handout Master" => handout_master(app, ui),
|
||||
@@ -462,3 +470,13 @@ mod inktoshape_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod picture_styles_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_name_tests {
|
||||
#[test]
|
||||
fn diagram_tab_keeps_its_saved_identity() {
|
||||
assert_eq!(super::DIAGRAM_TAB_ID, "SmartArt Design");
|
||||
assert_eq!(super::tab_label(super::DIAGRAM_TAB_ID), "Diagram Design");
|
||||
assert_eq!(super::tab_label("Home"), "Home");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false);
|
||||
let r = icon_toggle(ui, Icon::SmartArt, "Convert to Diagram", enabled(app, "smartArt.fromText"), false);
|
||||
egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", ("", ""), None));
|
||||
if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() {
|
||||
run(app, "format.paragraphDialog", json!({}));
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon"));
|
||||
egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(340.0).show(|ui| icon_library(app, ui));
|
||||
big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"}));
|
||||
big(app, ui, Icon::SmartArt, "Diagram", "app.dialog", json!({"id": "smartart"}));
|
||||
let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart"));
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for k in chart_menu_types() {
|
||||
@@ -111,10 +111,10 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) {
|
||||
group(ui, |ui| {
|
||||
big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({}));
|
||||
big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"}));
|
||||
let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt"));
|
||||
let r = big_button(ui, Icon::WordArt, "Decorative Text", enabled(app, "insert.wordArt"));
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for i in 0..6 {
|
||||
if ui.button(format!("WordArt style {}", i + 1)).clicked() {
|
||||
if ui.button(format!("Decorative Text style {}", i + 1)).clicked() {
|
||||
run(app, "insert.wordArt", json!({"style": i}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true);
|
||||
let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "Diagram Styles", gid.is_some(), false, true);
|
||||
if let Some(arrow) = arrow {
|
||||
widgets::split_menu(&main, &arrow, |ui| {
|
||||
for (k, label) in deckcraft_engine::cmd::smartart::STYLES {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Guard the production display surfaces without banning compatibility identifiers or notices.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const BANNED: &[&str] = &["SmartArt", "WordArt"];
|
||||
const ALLOWED: &[&str] = &["SmartArt Design"];
|
||||
const ROOTS: &[&str] = &["src", "../engine/src/cmd"];
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn banned_display(text: &str) -> bool {
|
||||
if ALLOWED.contains(&text) {
|
||||
return false;
|
||||
}
|
||||
BANNED.iter().any(|banned| {
|
||||
text.match_indices(banned)
|
||||
.any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + banned.len()..].chars().next().is_some_and(is_word))
|
||||
})
|
||||
}
|
||||
|
||||
// Read ordinary and raw Rust string literals, ignoring comments. No compiler dependency is
|
||||
// needed, so this guard can also run by itself when a full application build is unavailable.
|
||||
#[allow(clippy::cognitive_complexity)] // This small test-only lexer keeps raw-string/comment state together.
|
||||
fn literals(source: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
let mut test_module = false;
|
||||
let mut test_depth = 0_usize;
|
||||
while at < chars.len() {
|
||||
if chars.get(at..at + 12) == Some(&['#', '[', 'c', 'f', 'g', '(', 't', 'e', 's', 't', ')', ']']) {
|
||||
at += 12;
|
||||
let rest: String = chars[at..].iter().collect();
|
||||
if rest.trim_start().starts_with("mod ") {
|
||||
test_module = true;
|
||||
}
|
||||
} else if chars.get(at..at + 2) == Some(&['/', '/']) {
|
||||
while at < chars.len() && chars[at] != '\n' {
|
||||
at += 1;
|
||||
}
|
||||
} else if chars.get(at..at + 2) == Some(&['/', '*']) {
|
||||
at += 2;
|
||||
let mut depth = 1;
|
||||
while at < chars.len() && depth > 0 {
|
||||
if chars.get(at..at + 2) == Some(&['/', '*']) {
|
||||
depth += 1;
|
||||
at += 2;
|
||||
} else if chars.get(at..at + 2) == Some(&['*', '/']) {
|
||||
depth -= 1;
|
||||
at += 2;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
} else if chars[at] == 'r' && matches!(chars.get(at + 1), Some('"' | '#')) {
|
||||
let start = at;
|
||||
at += 1;
|
||||
while chars.get(at) == Some(&'#') {
|
||||
at += 1;
|
||||
}
|
||||
let hashes = at - start - 1;
|
||||
if chars.get(at) != Some(&'"') {
|
||||
continue;
|
||||
}
|
||||
at += 1;
|
||||
let begin = at;
|
||||
while at < chars.len() {
|
||||
if chars[at] == '"' && (1..=hashes).all(|n| chars.get(at + n) == Some(&'#')) {
|
||||
break;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
if !test_module {
|
||||
out.push(chars[begin..at].iter().collect());
|
||||
}
|
||||
at += 1 + hashes;
|
||||
} else if chars[at] == '"' {
|
||||
at += 1;
|
||||
let mut text = String::new();
|
||||
while at < chars.len() {
|
||||
let c = chars[at];
|
||||
at += 1;
|
||||
if c == '"' {
|
||||
break;
|
||||
}
|
||||
if c == '\\' && at < chars.len() {
|
||||
let escaped = chars[at];
|
||||
at += 1;
|
||||
match escaped {
|
||||
'n' => text.push('\n'),
|
||||
't' => text.push('\t'),
|
||||
'r' => text.push('\r'),
|
||||
'0' => text.push('\0'),
|
||||
'\n' | '\r' => {
|
||||
while chars.get(at).is_some_and(|c| c.is_whitespace()) {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
other => text.push(other),
|
||||
}
|
||||
} else {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
if !test_module {
|
||||
out.push(text);
|
||||
}
|
||||
} else if chars[at] == '\'' && chars.get(at + 1) == Some(&'\\') {
|
||||
// Skip the escape and its payload, including hexadecimal/Unicode escapes.
|
||||
at += 3;
|
||||
while at < chars.len() && chars[at] != '\'' {
|
||||
at += 1;
|
||||
}
|
||||
at += 1;
|
||||
} else if chars[at] == '\'' && chars.get(at + 2) == Some(&'\'') {
|
||||
at += 3; // A character literal such as '"', not the start of a string.
|
||||
} else {
|
||||
if test_module {
|
||||
match chars[at] {
|
||||
'{' => test_depth += 1,
|
||||
'}' => {
|
||||
test_depth = test_depth.saturating_sub(1);
|
||||
if test_depth == 0 {
|
||||
test_module = false;
|
||||
}
|
||||
}
|
||||
';' if test_depth == 0 => test_module = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_tree(path: &Path, failures: &mut Vec<String>) {
|
||||
if path.is_dir() {
|
||||
for entry in std::fs::read_dir(path).expect("source directory") {
|
||||
let path = entry.expect("source entry").path();
|
||||
if path.file_name().is_some_and(|name| name == "tests") {
|
||||
continue;
|
||||
}
|
||||
check_tree(&path, failures);
|
||||
}
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let name = path.file_name().expect("source filename").to_string_lossy();
|
||||
if name == "tests.rs" || name.ends_with("_tests.rs") {
|
||||
return;
|
||||
}
|
||||
let source = std::fs::read_to_string(path).expect("UTF-8 Rust source");
|
||||
// Skip only inline test modules, not later production following test-only imports.
|
||||
for text in literals(&source) {
|
||||
if banned_display(&text) {
|
||||
failures.push(format!("{}: {text:?}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_display_names_stay_neutral() {
|
||||
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut failures = Vec::new();
|
||||
for root in ROOTS {
|
||||
let root = base.join(root);
|
||||
assert!(root.exists(), "missing display source: {}", root.display());
|
||||
check_tree(&root, &mut failures);
|
||||
}
|
||||
assert!(failures.is_empty(), "Banned display names:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_distinguishes_labels_from_identifiers_and_comments() {
|
||||
for name in BANNED {
|
||||
assert!(banned_display(name), "{name}");
|
||||
}
|
||||
for name in ALLOWED {
|
||||
assert!(!banned_display(name), "{name}");
|
||||
}
|
||||
assert!(!banned_display("filter.cameraRaw"));
|
||||
assert!(!banned_display("tool.guidedUpright"));
|
||||
assert!(!banned_display("insert.smartArt"));
|
||||
assert_eq!(literals("// \"ignored\"\n/* \"ignored\" */ \"label\""), ["label"]);
|
||||
assert_eq!(literals("r#\"raw label\"#"), ["raw label"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_scans_production_after_test_only_items() {
|
||||
let source = r###"#[cfg(test)] mod tests { let _ = "ignored"; }
|
||||
#[cfg(test)] use fixtures::Helper;
|
||||
fn production() { let _ = r#"live label"#; }
|
||||
"###;
|
||||
assert_eq!(literals(source), ["live label"]);
|
||||
assert_eq!(literals("#[cfg(test)] mod tests; fn live() { let _ = \"live label\"; }"), ["live label"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_skips_escaped_character_literals() {
|
||||
assert_eq!(literals(r#"let q = '\"'; "label""#), ["label"]);
|
||||
assert_eq!(literals(r#"let q = '\''; "label""#), ["label"]);
|
||||
assert_eq!(literals(r#"let q = '\\'; "label""#), ["label"]);
|
||||
assert_eq!(literals(r#"let q = '\u{22}'; "label""#), ["label"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_decodes_whitespace_escapes() {
|
||||
assert_eq!(literals(r#""one\ntwo\tthree\rfour\0""#), ["one\ntwo\tthree\rfour\0"]);
|
||||
assert_eq!(literals("\"joined\\\n label\""), ["joinedlabel"]);
|
||||
for name in BANNED {
|
||||
let source = format!("\"\\t{name}\\n\"");
|
||||
assert!(literals(&source).iter().any(|label| banned_display(label)));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
|
||||
| Layer | Crates | What they do |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `color`, `metafile` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; EMF/WMF read into a neutral vector model (`metafile`) |
|
||||
| L0 | `geom`, `color` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms |
|
||||
| L1 | `model`, `fonts` | 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) |
|
||||
| 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 (MP4/MOV, MKV/WebM and Ogg demuxers from craft-media and the Opus decoder from craft-audio, the H.264, HEVC, VP9 and AV1 decoders from craft-video, all in craft-libs) |
|
||||
| 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 |
|
||||
@@ -81,6 +81,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | trivial | The `metafile` crate is gone: `render` reads EMF/WMF with craft-metafile from craft-libs |
|
||||
| 2026-11-12 | trivial | The `bitstream`, `h264`, `hevc`, `vp9` and `av1` crates are gone: `media` decodes video with craft-video from craft-libs |
|
||||
| 2026-11-11 | trivial | The `opus`, `ogg`, `isobmff` and `matroska` crates are gone: `media` uses craft-audio and craft-media from craft-libs |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Feature naming
|
||||
|
||||
DeckCraft uses **Diagram**, **Diagram Design**, and **Diagram Styles** for its diagram feature, and **Decorative Text** / **Decorative Text Styles** for styled text. Menus, dialogs, tooltips, command labels, errors and newly generated object names use these names.
|
||||
|
||||
## Compatibility boundary
|
||||
|
||||
The existing `smartArt.*`, `insert.smartArt`, `insert.wordArt`, dialog and icon IDs remain stable. The persisted/control-channel tab key `SmartArt Design` still opens the same tab; only its displayed and accessible label is Diagram Design. Dispatch does not depend on the new display text.
|
||||
|
||||
Existing document-owned object names are not rewritten. PPTX diagram parts, DrawingML names, public data types, import/export code and attribution remain unchanged.
|
||||
|
||||
DeckCraft currently authors these interface strings directly in English. Shared DeckCraft catalogs exist in craft-i18n, but this change does not add a catalog loader. Before enabling that integration, catalog keys must include the new Diagram and Decorative Text labels rather than only their retired English names. Diagram/format round-trip tests, UI tests and visual checks remain part of application verification.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
The standard-library-only `display_names` integration tests scan production Rust string literals for the retired display names and preserve explicit compatibility exceptions. They also test identifier, comment and raw-string handling. These focused checks do not replace application or visual testing.
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** minor (cell anchorCtr, vertOverflow/horzOverflow, compatLnSpc, normalizeH, thaiDist, table rtl, run altLang and reflection transforms round-trip; Reuse Slides brings referenced custom table styles; previous: minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-07 · **Change:** trivial (a slide whose relationship is missing is reported in `lostSlides`; previous: minor (cell anchorCtr, vertOverflow/horzOverflow, compatLnSpc, normalizeH, thaiDist, table rtl, run altLang and reflection transforms round-trip; Reuse Slides brings referenced custom table styles; previous: minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233))) · **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**
|
||||
@@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | trivial | A slide-list entry whose relationship is missing is reported in `lostSlides` like an unreadable slide part, so the deck opens as a repaired copy; slide lists over 10,000 entries are refused with an import-limit error rather than silently truncated (#1910) |
|
||||
| 2026-11-02 | minor | Optional table RTL direction is read and written as `a:tblPr/@rtl`; native and two PPTX saves preserve true, false and absent values with distinguishable column text |
|
||||
| 2026-11-02 | minor | Text runs preserve alternate-language metadata in native and PPTX round trips |
|
||||
| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles from native and PPTX sources; grouped tables, undo, native save/reopen and style-id conflicts are covered |
|
||||
|
||||
+2
-2
@@ -56,8 +56,8 @@ folders:
|
||||
its nearest existing folder's links are followed and the rest must be plain names, so `..` below a
|
||||
folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would
|
||||
create its target). The result is compared with the root by whole folder names: `/work2/x` is not
|
||||
inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
|
||||
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
|
||||
inside `/work`. On Windows names are compared exactly after what exists is resolved to its real case (so `OUT\x` reaches the root `out` on an ordinary volume, but not in a case-sensitive folder), UNC paths (`\\server\share\...`) are resolved like
|
||||
drive-letter ones, and device names (`CON`, `NUL`, `COM1`, `COM¹`..., also followed by an extension or a colon) are refused.
|
||||
- A refused path is an ordinary tool error the agent can read and correct, for example
|
||||
`automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or
|
||||
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.pptx`.
|
||||
|
||||
@@ -38,8 +38,6 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
// decoders come from craft-libs (craft-media, craft-audio, craft-video).
|
||||
("media", Class::Layer(2)),
|
||||
("color", Class::Layer(0)),
|
||||
// WMF/EMF reader (ported from WordCraft).
|
||||
("metafile", Class::Layer(0)),
|
||||
("model", Class::Layer(1)),
|
||||
("fonts", Class::Layer(1)),
|
||||
("text", Class::Layer(2)),
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::Command;
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
|
||||
const SHARED_REV: &str = "6e03f65bf45181db77ba6229716e6624db9eb25b";
|
||||
const SHARED_REV: &str = "63196e66f3033a6623eb629af95a104d26986725";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user