Compare commits

...
Author SHA1 Message Date
FredAppsandFredApps 69379cf689 Naming: use Diagram and Decorative Text for own features (#1981)
* Naming: use Diagram and Decorative Text for own features

* test(naming): guard neutral display labels

* test(ui): handle escaped literals in feature-name guard

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-14 01:29:01 +01:00
Vincent Cloutierandcloutier 7a24b20c8e Document the PPTX slide-list import limit (#2103)
* M1951: document the PPTX slide-list import limit

* M1951: expose the slide-list limit in public API docs

---------

Co-authored-by: cloutier <1014864+cloutier@users.noreply.github.com>
2026-11-13 16:28:51 +00:00
FredApps ef9ac9ac7a Merge pull request #2062 from cloutier/fix/2060-large-adjustment-guides
PPTX: preserve large connector adjustment guides
2026-11-13 10:41:19 +01:00
FredApps eebede8ee4 Merge pull request #2058 from cloutier/fix/empty-master-text-styles
PPTX: preserve empty imported master text styles
2026-11-13 10:41:00 +01:00
FredApps a024a0e86c Merge pull request #1978 from aiongg/autocraft/fix-1977-pptx-bar-chart-series-level-c
Fix pPTX bar chart: series-level `<c:dLbls><c:delete val="1"/></c:dLbls>`
2026-11-13 10:40:24 +01:00
FredApps 9850dc6088 Merge pull request #1973 from scks1/fix/ungroup-nested-group
engine: arrange.ungroup frees a group nested in another group
2026-11-13 10:40:04 +01:00
FredApps 3ce1886bea Merge pull request #1972 from scks1/fix/duplicate-group-member
engine: edit.duplicate copies a shape inside a group into its group
2026-11-13 10:39:35 +01:00
FredApps 0a75620a73 Merge pull request #1968 from scks1/fix/copy-group-member-slide-box
engine: a shape copied from a group pastes where it was drawn
2026-11-13 10:39:13 +01:00
FredApps 74a0468917 Merge pull request #1967 from scks1/fix/merge-refuses-group-members
engine: shape.merge refuses a shape inside a group
2026-11-13 10:38:20 +01:00
FredApps 6166cb0870 Merge pull request #1951 from cloutier/fix/1910-slide-import-integrity
PPTX: report missing slide relationships and refuse over-limit lists
2026-11-13 10:36:39 +01:00
FredApps d2ee3ca9fc Merge pull request #1941 from neilcawse/fix/pptx-paragraph-build-inference
pptx: infer paragraph builds when bldP does not disable them
2026-11-13 10:36:06 +01:00
FredApps 506db24b07 Merge pull request #1966 from Silvertree2010/fix/h11008-delete-key-removes-only-the-firs
Delete a whole grapheme cluster with the forward Delete key
2026-11-13 10:34:40 +01:00
FredApps 7f9acc1c27 Merge pull request #2055 from cloutier/fix/picture-inherited-group-fill
PPTX: keep inherited group backing on transparent pictures
2026-11-13 10:27:28 +01:00
FredApps 3a92bcc93c Merge pull request #1915 from adikshan11/fix/automation-roots-exact-names
Automation roots: compare names exactly and refuse COM¹ to LPT³
2026-11-13 10:26:08 +01:00
Dylan M. Taylor 18355eeca2 Merge pull request #2064 from storytold/switch-metafile-craft-metafile
Metafile: read WMF, EMF and EMF+ with craft-metafile
2026-11-13 02:47:12 -05:00
Dylan M. Taylor 15a75a2662 Update pinned shared PDF revision check 2026-11-13 07:33:26 +00:00
Dylan M. Taylor 2f8398d87b Pin shared metafile updates from craft-libs 2026-11-13 07:13:55 +00:00
Dylan M. Taylor 718b6da035 Fix metafile clip coverage sample 2026-11-13 07:05:21 +00:00
Dylan M. Taylor f78a805ac7 Cover metafile transform and pen paths 2026-11-13 07:01:52 +00:00
Dylan M. Taylor ec2937f298 Merge remote-tracking branch 'origin/main' into codex/nextest-cli-bin-path
# Conflicts:
#	crates/pptx/src/opc.rs
2026-11-13 06:29:55 +00:00
Dylan M. Taylor 5e771693fc Merge pull request #2096 from storytold/codex/sonar-font-pin
ci: provide fonts to Sonar coverage tests
2026-11-13 01:28:51 -05:00
Dylan M. Taylor 1d584737b1 test(cli): resolve executable from nextest environment 2026-11-13 05:07:40 +00:00
Dylan M. Taylor b8c19475d2 Merge main and pin shared metafile candidate 2026-11-13 02:44:18 +00:00
Adithya Shankaran 2d1e6ff348 Drop the repeated test comment and show one backslash in the docs
The case-sensitive Windows test already says what it checks. In the MCP docs the OUT example is a code span, so one backslash is what a reader sees.
2026-11-12 12:10:41 +00:00
Dylan M. Taylor ac3e46b748 Metafile: read WMF, EMF and EMF+ with craft-metafile; delete crates/metafile
The renderer now draws what the shared reader adds: pen caps, joins and
dashes, clip paths (SELECTCLIPPATH) and turned or skewed bitmaps.
2026-11-12 01:09:45 +00:00
Vincent Cloutier 6a3cbd18d2 PPTX #2060: preserve large adjustment guides 2026-11-11 20:06:47 -05:00
Vincent Cloutier 5914363930 DC002: refuse over-limit PPTX slide lists 2026-11-11 19:32:31 -05:00
Vincent Cloutier e0b0817288 fix(pptx): preserve empty imported master styles (#2057) 2026-11-11 18:33:37 -05:00
autocraft-bot[bot] 63331b8ab6 Apply review fixes
- crates/pptx/src/read.rs
2026-11-11 23:14:10 +00:00
Vincent Cloutier 3975b8cc5e Merge official main to refresh PR1951 2026-11-11 18:02:42 -05:00
Vincent Cloutier db257178f5 fix(pptx): keep pictures inherited group backing (#2054) 2026-11-11 17:43:03 -05:00
Neil Cawse e8802ccfbd Merge remote-tracking branch 'upstream/main' into fix/pptx-paragraph-build-inference 2026-11-11 18:35:52 +00:00
Neil Cawse 730c6cde9c Merge remote-tracking branch 'origin/main' into fix/pptx-paragraph-build-inference 2026-11-11 02:18:40 +00:00
Neil Cawse b7e5cf05ea style(pptx): format timing.rs with rustfmt 2026-11-09 22:52:57 +00:00
Neil Cawse ce2bd95414 fix(pptx): require matching parent offsets for click-started inferred paragraph builds 2026-11-09 22:41:38 +00:00
Neil Cawse 41dd2bd3bb Merge remote-tracking branch 'origin/main' into fix/pptx-paragraph-build-inference 2026-11-09 14:23:39 +00:00
autocraft-bot[bot] 1469419354 Apply review fixes
- ROADMAP.md
- docs/file-format-parity.md
- docs/gaps.md
2026-11-08 21:35:34 +00:00
Simon Koch-Sultan 70a0d7a2d8 engine: test ungrouping a top-level group with a group inside it
Ungrouping a top-level group and a group inside it in one call, in
either order, frees the shapes of both onto the slide in the top-level
group's place, drawn where they were, returns them once each and
selects them. The other ungroup tests leave the outermost group
unnamed, so none checked that the inner group is found at the slide's
top level after the same call frees the group around it.
2026-11-08 15:36:50 -05:00
aiongg ea97b18c70 Fix pPTX bar chart: series-level <c:dLbls><c:delete val="1"/></c:dLbls>
Fixes #1977.

- crates/pptx/src/read/chart.rs
- crates/pptx/tests/chart_data_labels.rs
- crates/pptx/tests/autocraft_regression_1977.rs
2026-11-08 17:03:31 +00:00
Simon Koch-Sultan 17db75b74a engine: test ungrouping nested groups named out of order
Ungrouping a middle group, the group around it and then the innermost
group, all inside a fourth group, returns the four freed rectangles
once each, selects them, and leaves them in the fourth group in order.
Before the previous commit, the innermost group was listed twice and
replaced once, so the call also returned that group's id after the
group was gone.
2026-11-08 10:22:34 -05:00
aiongg cdca5bd09d Apply review fixes
- crates/engine/src/cmd/arrange.rs
2026-11-08 14:56:11 +00:00
Simon Koch-Sultan 7ebfa82297 engine: group copy test compares site counts
The test compared the pasted shape's connection sites with the original's
through zip, which stops at the shorter list. It now checks that both have
the same number of sites first.
2026-11-08 09:01:24 -05:00
Simon Koch-Sultan de5398cfc5 engine: nested ungroup test compares site counts and checks the selection
The test compared each shape's connection sites before and after through
zip, which stops at the shorter list, so a shape that lost sites would still
pass. It now checks the counts first. The case with three nested groups also
checks that the freed shapes are selected.
2026-11-08 09:01:24 -05:00
Simon Koch-Sultan 2750e65096 engine: group duplicate test compares site counts
The test compared the copy's connection sites with the original's through
zip, which stops at the shorter list. It now checks that both have the same
number of sites first.
2026-11-08 09:01:23 -05:00
Neil Cawse c564070d9e fix(pptx): check click group and parent-derived start offsets before folding inferred paragraph builds 2026-11-08 13:40:42 +00:00
Neil Cawse 3def50c98a fix(pptx): gate inferred non-click paragraph builds on 500ms/0ms delay when bldP is absent 2026-11-08 13:36:49 +00:00
Neil Cawse a570031909 fix(pptx): drop timing gate in plays_as_build, merge duplicate bldP entries, and expand build tests 2026-11-08 13:36:49 +00:00
Neil Cawse 9d9610a893 pptx: infer paragraph builds when bldP does not disable them 2026-11-08 13:36:49 +00:00
aiongg 5a2cab9483 Apply review fixes
- crates/model/src/edit.rs
- crates/engine/src/tests.rs
2026-11-08 07:34:41 +00:00
NetterSilverandClaude Haiku 5.5 3e2dc9ca7f Delete a whole grapheme cluster with the forward Delete key
Found while testing crates/engine/src/cmd/text/caret.rs:60 (delete_dir, forward) with crates/model/src/edit.rs:423 (char_move)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 07:17:28 +00:00
Simon Koch-Sultan d680914140 engine: a shape copied from a group pastes where it was drawn
edit.copy and edit.cut put a shape inside a group on the clipboard with
its box in the group's own space. Pasted, it landed where it would be
drawn without the group's move, stretch, turn or flip: a shape from a
group moved 200 pt to the right pasted 200 pt left of where it was
drawn, and one from a group stretched to twice its width pasted half as
wide.

The copied shape now carries the box it is drawn in on the slide, mapped
out through each group around it as Ungroup maps a freed shape. It
pastes at the slide's top level where it was drawn, offset as any paste
on the same slide is. Cut still deletes the shape from its group.
A turned shape in a group stretched more one way than the other is drawn
skewed, which a box cannot hold; it pastes with the unskewed box Ungroup
gives it. Copying with a text caret is unchanged.

Test: a_shape_copied_from_a_group_pastes_where_it_was_drawn.
2026-11-08 01:21:59 +00:00
Simon Koch-Sultan 1c8b14edad engine: arrange.ungroup frees a group nested in another group
arrange.ungroup looked only at the slide's top-level list, so a group
inside another group answered `{"ids": []}`, stayed grouped, and the
selection was emptied.

Such a group's children now take its place among the outer group's
children, with their boxes mapped into the outer group's space by
child_to_parent, as for a top-level group, so they are drawn where they
were. They are selected and returned. A shape freed from a named group
and then from a named group around it is listed once.

Test: ungrouping_a_nested_group_frees_its_shapes_into_the_outer_group.
2026-11-08 01:21:58 +00:00
Simon Koch-Sultan 09548fe55c engine: edit.duplicate copies a shape inside a group into its group
edit.duplicate took its copies from the slide's top-level list only, so
duplicating a shape inside a group answered `{"ids": []}` and changed
nothing.

A shape inside a group is now copied into that group, after its other
children, and the copy is selected and returned. The duplicate offset is
converted into the group's space, so the copy is drawn as far from the
original on the slide as a top-level copy is, however the groups around
it are moved, stretched or turned. Duplicating a moved copy again
repeats the move (#966), measured on the slide. A shape whose group is
duplicated in the same call is copied only with the group's copy.

Test: duplicating_a_shape_in_a_group_copies_it_into_the_group.
2026-11-08 01:21:57 +00:00
Simon Koch-Sultan ea809d93f5 engine: shape.merge refuses a shape inside a group
shape.merge worked on the slide's top-level list. Given shapes inside a
group, it merged them from their boxes in the group's own space, left
them in the group and added the result to the slide's top level, so in a
moved or resized group the merged shape was drawn away from the shapes
it came from, which stayed drawn as well.

A call whose shapes include one inside a group is now refused with the
shape's name and a request to ungroup it first, and the slide is left as
it was. Merge Shapes is unavailable while a shape inside a group is
selected; a call that names top-level shapes in `ids` still runs
whatever is selected, as it does when nothing is selected.

Test: a_shape_in_a_group_is_refused.
2026-11-08 01:21:55 +00:00
Vincent Cloutier 26c1ac83dc DC-001: report unresolved slide relationships as damage 2026-11-07 16:12:41 -05:00
Adithya Shankaran 23d0f67d17 Say what the touched tests check
Doc comments only, on the tests this change touches and the ones the docstring scan listed.
2026-11-06 15:45:24 +00:00
aiongg fb67888e25 Apply review fixes
- docs/mcp.md
2026-11-06 06:29:34 +00:00
Adithya Shankaran 9e6758e276 Credit my name in the About window 2026-11-06 00:14:47 +05:30
Adithya Shankaran 6c0886a68a Automation roots: compare names exactly and refuse COM¹ to LPT³
On Windows a folder can be set to tell case apart, and then IN beside the root in passed as inside
it, because names were compared with to_lowercase. Both paths have the spelling the file system
gives them by then, so another spelling of the root still matches with an exact compare. A missing
name keeps the spelling it was given, but it can only match a root that exists, and resolve has
already found that one in its real spelling.

is_device_name also missed COM¹, COM², COM³ and LPT¹, LPT², LPT³, which Windows reserves too.
It also cut the name only at a period, so COM1: and NUL:x were not seen as device names.
2026-11-06 00:14:47 +05:30
67 changed files with 1598 additions and 7699 deletions
Generated
+22 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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) |
+4
View File
@@ -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
+32
View File
@@ -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}))
})
+91 -6
View File
@@ -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");
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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}",
+54 -2
View File
@@ -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));
}
}
+1 -1
View File
@@ -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 {
+19 -19
View File
@@ -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,
+6 -6
View File
@@ -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| {
+4 -4
View File
@@ -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() }));
+1 -1
View File
@@ -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
+61 -23
View File
@@ -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}");
}
}
+3 -1
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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 });
}
}
-14
View File
@@ -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 }
-59
View File
@@ -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
-193
View File
@@ -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![]));
}
}
-357
View File
@@ -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 }))
}
-259
View File
@@ -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);
}
}
-478
View File
@@ -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
-118
View File
@@ -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
}
-306
View File
@@ -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
}
-206
View File
@@ -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
-203
View File
@@ -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
};
-383
View File
@@ -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(())
}
+16
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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();
+4 -1
View File
@@ -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.
+31 -14
View File
@@ -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;
+5 -5
View File
@@ -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");
+23 -3
View File
@@ -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");
}
+96
View File
@@ -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()));
}
+3
View File
@@ -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;
+88
View File
@@ -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]
+110
View File
@@ -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));
}
}
+72
View File
@@ -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);
}
}
+39
View File
@@ -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() {
+4 -1
View File
@@ -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"] }
+51 -18
View File
@@ -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
+64
View File
@@ -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
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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}"),
+1 -1
View File
@@ -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"),
("-", ""),
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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);
}
+23 -5
View File
@@ -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");
}
}
+1 -1
View File
@@ -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!({}));
+3 -3
View File
@@ -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}));
}
}
+1 -1
View File
@@ -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 {
+215
View File
@@ -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)));
}
}
+2 -1
View File
@@ -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 |
+15
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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`.
-2
View File
@@ -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)),
+1 -1
View File
@@ -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> {