mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec3926793 | ||
|
|
79bf9a688c | ||
|
|
1330ce4b86 | ||
|
|
a0eeb341d2 | ||
|
|
80f13011d5 | ||
|
|
399bc5a79f | ||
|
|
3821a8f7f7 | ||
|
|
08cc9ada67 | ||
|
|
7d21b25330 | ||
|
|
cc4629c2cd | ||
|
|
4668e0b45a | ||
|
|
ca919b0c9b | ||
|
|
85db5caed4 | ||
|
|
2b9b83ef83 | ||
|
|
aaaee73c6f | ||
|
|
d482443dd8 | ||
|
|
d863fac1c6 | ||
|
|
b23e6f2c8f | ||
|
|
da8db49aef | ||
|
|
149ea6b8da | ||
|
|
b913a0c509 | ||
|
|
2fd61c7c0c | ||
|
|
e167e08e41 | ||
|
|
6a1c7354c1 | ||
|
|
261e74e0c8 | ||
|
|
be56b0a1ac | ||
|
|
2ddd2abe67 | ||
|
|
ba59df768b | ||
|
|
0cffe2ca08 | ||
|
|
92ce587cd6 | ||
|
|
a7ab15d942 | ||
|
|
a9cafe64fc | ||
|
|
052afb3efd | ||
|
|
8f31193cc0 | ||
|
|
00f9df7d8d | ||
|
|
68c21778ef | ||
|
|
0c0cce3764 | ||
|
|
b8f707165b | ||
|
|
205ca7dfc0 | ||
|
|
7d4bc14c1d | ||
|
|
c207d6eb0f | ||
|
|
e89c824c6d | ||
|
|
38d2ade2b7 | ||
|
|
79c1d578be | ||
|
|
44d5a0e7b9 | ||
|
|
7be391a1be | ||
|
|
e8910cb253 | ||
|
|
528af4e503 | ||
|
|
202255cbbb | ||
|
|
44b33a2f57 | ||
|
|
56b15df0ab | ||
|
|
a453292729 | ||
|
|
910b03f4a1 | ||
|
|
e679fa500a | ||
|
|
027075d352 | ||
|
|
f86928d0d5 | ||
|
|
2bbfe46d3e | ||
|
|
9dcb7bab03 | ||
|
|
75bec53e08 | ||
|
|
c55a9f771e | ||
|
|
9888d7c90e | ||
|
|
05f2a84400 | ||
|
|
9c31b567da | ||
|
|
30755b3ff7 | ||
|
|
f639ef986e | ||
|
|
ded9094f59 | ||
|
|
7c1ae73ead | ||
|
|
36cd258bd2 | ||
|
|
8957e193e9 | ||
|
|
820b163067 | ||
|
|
80e5506456 | ||
|
|
3a37fb6b7e | ||
|
|
abd8768b65 | ||
|
|
882f1cbdbc | ||
|
|
bb5da2036d | ||
|
|
5b5fd5eabe | ||
|
|
2693336155 | ||
|
|
f52c911959 | ||
|
|
0313e0128a | ||
|
|
5880086522 | ||
|
|
b7c505a389 | ||
|
|
987f49ac03 | ||
|
|
dd9111ba20 | ||
|
|
92724e02bb | ||
|
|
0b6ea6a287 | ||
|
|
29cc5f83bd | ||
|
|
c480b74a3f | ||
|
|
2fd32751cc | ||
|
|
78afd2fc45 | ||
|
|
b280f7755d | ||
|
|
84702f3810 | ||
|
|
be5413826c | ||
|
|
2109dfa03c | ||
|
|
e6f7648219 | ||
|
|
3aa8f4b8b1 | ||
|
|
4f56789f45 | ||
|
|
f2e2fcaba4 | ||
|
|
6971d55345 | ||
|
|
aed303e504 | ||
|
|
c7388bd1ed | ||
|
|
cac379b5ee | ||
|
|
7fd34fb359 | ||
|
|
5827d3b4c1 | ||
|
|
f6597e0259 | ||
|
|
f8a70063f7 | ||
|
|
601a20ecf6 | ||
|
|
ca0e1a300a | ||
|
|
ee721e26dd | ||
|
|
a1c92dc6c5 | ||
|
|
a774f01199 | ||
|
|
e56c62c260 | ||
|
|
e04ef2b4a8 | ||
|
|
6d7b32351f | ||
|
|
fc914a059c | ||
|
|
18e8bb0ded | ||
|
|
52bf97d983 | ||
|
|
aaba9febc2 | ||
|
|
0c510426af | ||
|
|
3ac39f9bb6 | ||
|
|
30b7eff487 | ||
|
|
be9bf48eb2 | ||
|
|
1b03ae95de | ||
|
|
a48a2d706a | ||
|
|
8789394de1 | ||
|
|
9b2ab3d2b8 | ||
|
|
8117d5b710 | ||
|
|
50667e925e | ||
|
|
284db01d5e | ||
|
|
8212c1a805 | ||
|
|
077682ed4c | ||
|
|
d86f7aed75 | ||
|
|
e126fad9a7 | ||
|
|
6d312e0722 | ||
|
|
b61898bba5 | ||
|
|
adcfa70b4a | ||
|
|
53972cf37c | ||
|
|
08b7b5eb15 | ||
|
|
3cd6a815ca | ||
|
|
fdfdb9e058 | ||
|
|
dc69e57600 | ||
|
|
502fb2ba73 | ||
|
|
0405fb73bf | ||
|
|
0b476e823d | ||
|
|
52a9a45f6a | ||
|
|
bc12031d70 | ||
|
|
4b474d77a9 | ||
|
|
756ca23d31 | ||
|
|
37d69f0449 | ||
|
|
8e83e8bb81 | ||
|
|
3610cd6b6f | ||
|
|
8bc3d3f110 | ||
|
|
a7833e094d | ||
|
|
ea98ebb615 | ||
|
|
85b994e7cd | ||
|
|
afd6d3a3eb | ||
|
|
a55b6cfa6f | ||
|
|
beb883d036 | ||
|
|
5f0a088870 | ||
|
|
013941e8e8 | ||
|
|
d28b4e0f54 | ||
|
|
13e06cace2 | ||
|
|
b27bd0519c | ||
|
|
b919e33217 | ||
|
|
0fea876101 | ||
|
|
283be17f93 |
Generated
+28
-28
@@ -885,7 +885,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -906,7 +906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -915,7 +915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -924,14 +924,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -941,14 +941,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -974,7 +974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -986,7 +986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -997,7 +997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1005,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1014,7 +1014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1023,21 +1023,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1047,7 +1047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1065,7 +1065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1074,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1086,15 +1086,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1112,7 +1112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1147,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1161,7 +1161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1183,7 +1183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1192,7 +1192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -5619,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+32
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -134,9 +134,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
@@ -145,7 +149,11 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
@@ -154,18 +162,33 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
|
||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-24 | Edit Points: hold Shift, Ctrl or Alt while dragging a point's handle to make it a smooth, straight or corner point, as in PowerPoint (Cmd and Option work as Ctrl and Alt on the Mac); the point keeps that type for the next drag |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
|
||||
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
|
||||
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
|
||||
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
|
||||
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
|
||||
| 2026-10-24 | Edit Points: Esc pressed before the mouse button is released cancels a point or handle drag, as in PowerPoint, and leaves the shape as it was with no undo step. Edit Points stays on after the cancel |
|
||||
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
|
||||
| 2026-10-24 | Edit Points: right-click a segment for Add Point (at the pointer), Delete Segment (a closed outline opens there; grayed out on an open one) and Open/Close Path, as in PowerPoint (`shape.deleteSegment`) |
|
||||
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
|
||||
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
|
||||
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
@@ -262,9 +285,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
|
||||
| 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 |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
@@ -276,6 +303,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
|
||||
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
|
||||
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
|
||||
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DeckCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -66,6 +66,13 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The longest a connection refused as busy is drained before it is closed.
|
||||
const REFUSED_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Refused connections drained at once, each on a thread of its own. Past this many, a refused
|
||||
/// connection gets the busy reply and is closed without a drain.
|
||||
const MAX_REFUSING: usize = 16;
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
@@ -75,12 +82,14 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let refusing = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"}));
|
||||
refuse(stream, &refusing, limits.write_timeout.min(REFUSED_GRACE));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&active));
|
||||
@@ -98,6 +107,27 @@ fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) ->
|
||||
rx
|
||||
}
|
||||
|
||||
/// Close a connection that was told it is refused. A peer that wrote a line before it read the
|
||||
/// reply would see only a reset, so the connection is drained for up to `grace` first, on a
|
||||
/// thread of its own: the accepting thread goes straight back to accepting. Past
|
||||
/// [`MAX_REFUSING`] drains at once, the connection is closed without one.
|
||||
fn refuse(stream: TcpStream, refusing: &Arc<AtomicUsize>, grace: Duration) {
|
||||
if refusing.fetch_add(1, Ordering::SeqCst) >= MAX_REFUSING {
|
||||
refusing.fetch_sub(1, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let slot = Slot(Arc::clone(refusing));
|
||||
let spawned = std::thread::Builder::new().name("control-refused".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
if let Ok(read) = stream.try_clone() {
|
||||
close_politely(&stream, &mut BufReader::new(read), grace);
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
log::warn!("control server could not start a thread to close a refused connection");
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
@@ -192,14 +222,16 @@ fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Dur
|
||||
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
|
||||
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
|
||||
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
|
||||
// for close to twice as long as the deadline says.
|
||||
// for close to twice as long as the deadline says. The timeout is set on the handle that
|
||||
// reads: on Windows, a read timeout set on one handle of a `try_clone` pair can fail to apply
|
||||
// to reads through the other.
|
||||
const MAX_DRAIN: usize = 64 << 10;
|
||||
let until = Instant::now() + grace;
|
||||
let mut sink = [0u8; 4 << 10];
|
||||
let mut drained = 0;
|
||||
while drained < MAX_DRAIN {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || out.set_read_timeout(Some(left)).is_err() {
|
||||
if left.is_zero() || reader.get_ref().set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut sink) {
|
||||
@@ -388,8 +420,11 @@ mod tests {
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
let (addr, rx) = server(small());
|
||||
for _ in 0..12 {
|
||||
// A slot comes back only when the thread serving a peer ends, which can be after the next
|
||||
// peer connects, so every peer has a slot of its own.
|
||||
const PEERS: usize = 12;
|
||||
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
|
||||
for _ in 0..PEERS {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
@@ -403,6 +438,69 @@ mod tests {
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
/// A peer refused because every connection is taken still reads why when it had already
|
||||
/// written a line: closing with that line unread resets the connection and discards the
|
||||
/// reply. Repeated because it depends on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
// Take the only slot, and make sure it is being served.
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
for _ in 0..50 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers refused as busy that stay connected without sending anything do not hold up the
|
||||
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
|
||||
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
|
||||
#[test]
|
||||
fn silent_refused_peers_do_not_delay_the_next_refusal() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
// Twenty drains of 250 ms each, one after another, would take five seconds.
|
||||
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
|
||||
let start = Instant::now();
|
||||
let (mut r, _w) = client(addr);
|
||||
let refused = reply(&mut r).unwrap();
|
||||
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
|
||||
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
|
||||
drop(silent);
|
||||
}
|
||||
|
||||
/// A peer that keeps its connection open after its line was rejected gives its slot back at
|
||||
/// the drain deadline (the write timeout), not at the idle timeout.
|
||||
#[test]
|
||||
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
assert_eq!(reply(&mut r).unwrap()["ok"], false);
|
||||
// `w` stays open, sending nothing.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
drop(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
@@ -427,7 +525,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn connection_limit_refuses_then_frees_slot() {
|
||||
let (addr, rx) = server(small());
|
||||
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
|
||||
// third peer connected, and that peer then took its slot instead of reading `busy`.
|
||||
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let a = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
@@ -454,17 +554,18 @@ mod tests {
|
||||
#[test]
|
||||
fn blocked_ui_times_out_and_full_queue_is_busy() {
|
||||
let (addr, rx) = server(small());
|
||||
// No UI: the first request fills the one-slot queue and times out; the second finds it full.
|
||||
// No UI: the request that reaches the one-slot queue first fills it and times out; the
|
||||
// other finds it full. Each connection has its own thread, so either request can be first.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert!(b["error"].as_str().unwrap().starts_with("busy"));
|
||||
assert_eq!(b["id"], 2);
|
||||
let a = reply(&mut ar).unwrap();
|
||||
assert_eq!(a["error"], "timeout");
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
|
||||
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
|
||||
errors.sort_unstable();
|
||||
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,3 +59,6 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
+19
-9
@@ -1,10 +1,10 @@
|
||||
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
||||
//! and automatic advance.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use deckcraft_model::{Presentation, SlideId};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
@@ -83,12 +83,25 @@ pub struct ShowState {
|
||||
pub zoom: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Index of each slide by id (the first slide wins a duplicate id), so resolving a custom show
|
||||
/// costs the sum of its two lists, not their product.
|
||||
fn slide_indices(pres: &Presentation) -> HashMap<SlideId, usize> {
|
||||
let mut map = HashMap::with_capacity(pres.slides.len());
|
||||
for (i, s) in pres.slides.iter().enumerate() {
|
||||
map.entry(s.id).or_insert(i);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
||||
/// hidden slides.
|
||||
pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
let n = pres.slides.len();
|
||||
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
|
||||
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
|
||||
Some(cs) => {
|
||||
let index = slide_indices(pres);
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).collect()
|
||||
}
|
||||
None => match pres.show.range {
|
||||
Some((a, b)) => {
|
||||
let a = (a.max(1) as usize).saturating_sub(1);
|
||||
@@ -104,12 +117,9 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
/// Visible slides of custom show `name`, in that show's order.
|
||||
fn custom_indices(pres: &Presentation, name: &str) -> Option<Vec<usize>> {
|
||||
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
|
||||
let order: Vec<usize> = cs
|
||||
.slides
|
||||
.iter()
|
||||
.filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
|
||||
.filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
|
||||
.collect();
|
||||
let index = slide_indices(pres);
|
||||
let order: Vec<usize> =
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect();
|
||||
if order.is_empty() { None } else { Some(order) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1082,3 +1082,53 @@ fn show_returns_to_the_zoom_slide() {
|
||||
s.goto(&p, 4);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_resolves_duplicate_ids_to_the_first_slide() {
|
||||
let mut p = pres(3);
|
||||
Arc::make_mut(&mut p.slides[2]).id = p.slides[0].id;
|
||||
p.custom_shows = vec![CustomShow { name: "Dup".into(), slides: vec![p.slides[0].id, SlideId(1001), SlideId(424_242)] }];
|
||||
p.show.custom_show = Some("Dup".into());
|
||||
assert_eq!(show_order(&p), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_with_many_entries_resolves_quickly() {
|
||||
let mut p = pres(20_000);
|
||||
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
|
||||
p.custom_shows = vec![CustomShow { name: "All".into(), slides: ids.iter().rev().copied().collect() }];
|
||||
p.show.custom_show = Some("All".into());
|
||||
let t = std::time::Instant::now();
|
||||
let order = show_order(&p);
|
||||
assert_eq!(order.len(), 20_000);
|
||||
assert_eq!(order[0], 19_999);
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_targets_the_first_shape_with_a_duplicate_id() {
|
||||
let mut s = slide_with(vec![anim(7, AnimClass::Entrance, "fade", AnimStart::OnClick)]);
|
||||
s.shapes[0] = shape(7, "first");
|
||||
s.shapes[1] = shape(7, "second");
|
||||
s.shapes[0].xfrm = Some(Xfrm::new(10.0, 10.0, 20.0, 20.0));
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 1);
|
||||
assert_eq!(tl.steps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
let mut s = slide_with(vec![]);
|
||||
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
|
||||
s.animations = (0..2_000)
|
||||
.map(|_| {
|
||||
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.path = Some("M 0 0 L 0.5 0.5 E".into());
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
+59
-27
@@ -1,10 +1,11 @@
|
||||
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
|
||||
|
||||
use crate::AnimState;
|
||||
use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
@@ -12,7 +13,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
|
||||
pub const PARAGRAPH_STAGGER: f64 = 0.5;
|
||||
const MAX_EFFECTS: usize = 100_000;
|
||||
const MAX_PARAGRAPHS: usize = 1_000;
|
||||
const MAX_ITERATIONS: f64 = 1.0e6;
|
||||
|
||||
/// Which sequence an effect belongs to.
|
||||
@@ -86,7 +86,9 @@ enum After {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Effect {
|
||||
timing: EffectTiming,
|
||||
spec: EffectSpec,
|
||||
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
|
||||
/// per paragraph.
|
||||
spec: Arc<EffectSpec>,
|
||||
after: After,
|
||||
}
|
||||
|
||||
@@ -163,6 +165,14 @@ impl Timeline {
|
||||
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
|
||||
let mut tl = Timeline::default();
|
||||
if slide.animations.is_empty() {
|
||||
return tl;
|
||||
}
|
||||
// Every shape by id, built once: resolving each animation's target by tree search would
|
||||
// cost shapes x animations.
|
||||
let shapes = shape_index(&slide.shapes);
|
||||
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
|
||||
let mut para_cache: HashMap<ShapeId, Arc<Vec<Option<usize>>>> = HashMap::new();
|
||||
// Group the animations by sequence in one pass, sequences in order of first appearance
|
||||
// (main first), so many distinct triggers cost linear work, not a scan per trigger.
|
||||
let mut seqs: Vec<(Sequence, Vec<usize>)> = vec![(Sequence::Main, Vec::new())];
|
||||
@@ -191,34 +201,38 @@ impl Timeline {
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
let shape = slide.shape(a.shape);
|
||||
let shape = shapes.get(&a.shape).copied();
|
||||
// Paragraph targets of this animation.
|
||||
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => vec![Some(p as usize)],
|
||||
let paras: Arc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => Arc::new(vec![Some(p as usize)]),
|
||||
(None, TextBuild::ByParagraph) => {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut owner: Option<usize> = None;
|
||||
for (i, p) in t.paragraphs.iter().enumerate() {
|
||||
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
|
||||
owner = Some(i);
|
||||
v.push(Some(i));
|
||||
} else if let Some(o) = owner
|
||||
&& grouped.insert((a.shape, i, o))
|
||||
{
|
||||
tl.groups.push((a.shape, i, o));
|
||||
let cached = para_cache.entry(a.shape).or_insert_with(|| {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
|
||||
match role {
|
||||
BuildRole::Top => v.push(Some(i)),
|
||||
BuildRole::Member(o) => {
|
||||
if grouped.insert((a.shape, i, o)) {
|
||||
tl.groups.push((a.shape, i, o));
|
||||
}
|
||||
}
|
||||
BuildRole::Unowned => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.is_empty() { vec![None] } else { v }
|
||||
if v.is_empty() {
|
||||
v.push(None)
|
||||
}
|
||||
Arc::new(v)
|
||||
});
|
||||
Arc::clone(cached)
|
||||
}
|
||||
_ => vec![None],
|
||||
_ => Arc::new(vec![None]),
|
||||
};
|
||||
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
|
||||
let xf = sanitize_xfrm(xf, sw, sh);
|
||||
let spec = build_spec(a, xf, sw, sh, &resolve);
|
||||
let spec = Arc::new(build_spec(a, xf, sw, sh, &resolve));
|
||||
let dur = secs(a.duration_ms);
|
||||
let delay = secs(a.delay_ms);
|
||||
let until_next_click = a.repeat == u32::MAX;
|
||||
@@ -287,7 +301,7 @@ impl Timeline {
|
||||
span,
|
||||
class: a.class,
|
||||
},
|
||||
spec: spec.clone(),
|
||||
spec: Arc::clone(&spec),
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
@@ -298,12 +312,13 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
// Base colours of animated shapes.
|
||||
let mut colored: HashSet<ShapeId> = HashSet::new();
|
||||
for e in &tl.effects {
|
||||
let id = e.timing.shape;
|
||||
if tl.colors.iter().any(|(s, _)| *s == id) {
|
||||
if !colored.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
|
||||
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color }) => resolve(color),
|
||||
_ => accent,
|
||||
};
|
||||
@@ -536,6 +551,23 @@ fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
|
||||
Status::Active(p.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
|
||||
/// 64 deep); the first shape with an id wins a duplicate.
|
||||
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
|
||||
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
out.entry(s.id).or_insert(s);
|
||||
walk(s.children(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
walk(shapes, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
|
||||
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
|
||||
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
|
||||
|
||||
@@ -515,6 +515,244 @@ pub fn preset(name: &str) -> Option<Rgba> {
|
||||
Some(Rgba::rgb(c.0, c.1, c.2))
|
||||
}
|
||||
|
||||
/// Every preset colour name a file may write (`ST_PresetColorVal`, with the later editions' aliases).
|
||||
pub const PRESET_NAMES: [&str; 190] = [
|
||||
"aliceBlue",
|
||||
"antiqueWhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedAlmond",
|
||||
"blue",
|
||||
"blueViolet",
|
||||
"brown",
|
||||
"burlyWood",
|
||||
"cadetBlue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerBlue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkBlue",
|
||||
"darkCyan",
|
||||
"darkGoldenrod",
|
||||
"darkGray",
|
||||
"darkGrey",
|
||||
"darkGreen",
|
||||
"darkKhaki",
|
||||
"darkMagenta",
|
||||
"darkOliveGreen",
|
||||
"darkOrange",
|
||||
"darkOrchid",
|
||||
"darkRed",
|
||||
"darkSalmon",
|
||||
"darkSeaGreen",
|
||||
"darkSlateBlue",
|
||||
"darkSlateGray",
|
||||
"darkSlateGrey",
|
||||
"darkTurquoise",
|
||||
"darkViolet",
|
||||
"dkBlue",
|
||||
"dkCyan",
|
||||
"dkGoldenrod",
|
||||
"dkGray",
|
||||
"dkGrey",
|
||||
"dkGreen",
|
||||
"dkKhaki",
|
||||
"dkMagenta",
|
||||
"dkOliveGreen",
|
||||
"dkOrange",
|
||||
"dkOrchid",
|
||||
"dkRed",
|
||||
"dkSalmon",
|
||||
"dkSeaGreen",
|
||||
"dkSlateBlue",
|
||||
"dkSlateGray",
|
||||
"dkSlateGrey",
|
||||
"dkTurquoise",
|
||||
"dkViolet",
|
||||
"deepPink",
|
||||
"deepSkyBlue",
|
||||
"dimGray",
|
||||
"dimGrey",
|
||||
"dodgerBlue",
|
||||
"firebrick",
|
||||
"floralWhite",
|
||||
"forestGreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostWhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"grey",
|
||||
"green",
|
||||
"greenYellow",
|
||||
"honeydew",
|
||||
"hotPink",
|
||||
"indianRed",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderBlush",
|
||||
"lawnGreen",
|
||||
"lemonChiffon",
|
||||
"lightBlue",
|
||||
"lightCoral",
|
||||
"lightCyan",
|
||||
"lightGoldenrodYellow",
|
||||
"lightGray",
|
||||
"lightGrey",
|
||||
"lightGreen",
|
||||
"lightPink",
|
||||
"lightSalmon",
|
||||
"lightSeaGreen",
|
||||
"lightSkyBlue",
|
||||
"lightSlateGray",
|
||||
"lightSlateGrey",
|
||||
"lightSteelBlue",
|
||||
"lightYellow",
|
||||
"ltBlue",
|
||||
"ltCoral",
|
||||
"ltCyan",
|
||||
"ltGoldenrodYellow",
|
||||
"ltGray",
|
||||
"ltGrey",
|
||||
"ltGreen",
|
||||
"ltPink",
|
||||
"ltSalmon",
|
||||
"ltSeaGreen",
|
||||
"ltSkyBlue",
|
||||
"ltSlateGray",
|
||||
"ltSlateGrey",
|
||||
"ltSteelBlue",
|
||||
"ltYellow",
|
||||
"lime",
|
||||
"limeGreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"medAquamarine",
|
||||
"medBlue",
|
||||
"medOrchid",
|
||||
"medPurple",
|
||||
"medSeaGreen",
|
||||
"medSlateBlue",
|
||||
"medSpringGreen",
|
||||
"medTurquoise",
|
||||
"medVioletRed",
|
||||
"mediumAquamarine",
|
||||
"mediumBlue",
|
||||
"mediumOrchid",
|
||||
"mediumPurple",
|
||||
"mediumSeaGreen",
|
||||
"mediumSlateBlue",
|
||||
"mediumSpringGreen",
|
||||
"mediumTurquoise",
|
||||
"mediumVioletRed",
|
||||
"midnightBlue",
|
||||
"mintCream",
|
||||
"mistyRose",
|
||||
"moccasin",
|
||||
"navajoWhite",
|
||||
"navy",
|
||||
"oldLace",
|
||||
"olive",
|
||||
"oliveDrab",
|
||||
"orange",
|
||||
"orangeRed",
|
||||
"orchid",
|
||||
"paleGoldenrod",
|
||||
"paleGreen",
|
||||
"paleTurquoise",
|
||||
"paleVioletRed",
|
||||
"papayaWhip",
|
||||
"peachPuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderBlue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosyBrown",
|
||||
"royalBlue",
|
||||
"saddleBrown",
|
||||
"salmon",
|
||||
"sandyBrown",
|
||||
"seaGreen",
|
||||
"seaShell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyBlue",
|
||||
"slateBlue",
|
||||
"slateGray",
|
||||
"slateGrey",
|
||||
"snow",
|
||||
"springGreen",
|
||||
"steelBlue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whiteSmoke",
|
||||
"yellow",
|
||||
"yellowGreen",
|
||||
];
|
||||
|
||||
/// Every system colour name a file may write (`ST_SystemColorVal`).
|
||||
pub const SYSTEM_NAMES: [&str; 30] = [
|
||||
"scrollBar",
|
||||
"background",
|
||||
"activeCaption",
|
||||
"inactiveCaption",
|
||||
"menu",
|
||||
"window",
|
||||
"windowFrame",
|
||||
"menuText",
|
||||
"windowText",
|
||||
"captionText",
|
||||
"activeBorder",
|
||||
"inactiveBorder",
|
||||
"appWorkspace",
|
||||
"highlight",
|
||||
"highlightText",
|
||||
"btnFace",
|
||||
"btnShadow",
|
||||
"grayText",
|
||||
"btnText",
|
||||
"inactiveCaptionText",
|
||||
"btnHighlight",
|
||||
"3dDkShadow",
|
||||
"3dLight",
|
||||
"infoText",
|
||||
"infoBk",
|
||||
"hotLight",
|
||||
"gradientActiveCaption",
|
||||
"gradientInactiveCaption",
|
||||
"menuHighlight",
|
||||
"menuBar",
|
||||
];
|
||||
|
||||
/// Whether `name` is a preset colour name a file may write.
|
||||
pub fn is_preset_name(name: &str) -> bool {
|
||||
PRESET_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
/// Whether `name` is a system colour name a file may write.
|
||||
pub fn is_system_name(name: &str) -> bool {
|
||||
SYSTEM_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -171,6 +171,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
Some(it.fold(first, |a, b| a.union(b)))
|
||||
}
|
||||
|
||||
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
|
||||
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
|
||||
/// the shape tree stop at 64.
|
||||
pub const MAX_GROUP_DEPTH: usize = 32;
|
||||
|
||||
/// How many groups deep `shapes` nest (0 without groups).
|
||||
fn group_depth(shapes: &[Shape]) -> usize {
|
||||
let mut d = 0;
|
||||
deckcraft_model::walk(shapes, &mut |sh, depth| {
|
||||
if sh.is_group() {
|
||||
d = d.max(depth + 1);
|
||||
}
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|doc, sel| {
|
||||
@@ -179,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if members.len() < 2 {
|
||||
return Err(bad("arrange.group", "select two or more objects on the slide"));
|
||||
}
|
||||
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
|
||||
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
|
||||
}
|
||||
for m in &mut members {
|
||||
if m.xfrm.is_none() {
|
||||
m.xfrm = Some(xfrm_of(doc, sel, m));
|
||||
@@ -322,9 +341,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (start, end) = if to_slide {
|
||||
if horiz { (0.0, size.width) } else { (0.0, size.height) }
|
||||
} else {
|
||||
let f = boxes.first().map(|b| b.1).unwrap_or_default();
|
||||
let l = boxes.last().map(|b| b.1).unwrap_or_default();
|
||||
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
|
||||
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
|
||||
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
|
||||
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
|
||||
};
|
||||
let n = boxes.len();
|
||||
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Browse for Themes…",
|
||||
["Design", "Themes"],
|
||||
None,
|
||||
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
|
||||
"{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
|
||||
has_doc,
|
||||
browse_theme
|
||||
),
|
||||
@@ -161,6 +161,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !deckcraft_pptx::sniff(&bytes) {
|
||||
return Err(bad("design.browseTheme", "not a theme or PowerPoint file"));
|
||||
}
|
||||
// A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx
|
||||
// (its main part is a theme, not a presentation) brings the theme alone.
|
||||
if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes)
|
||||
&& src.masters.first().is_some_and(|m| !m.layouts.is_empty())
|
||||
{
|
||||
return apply_master(s, &src);
|
||||
}
|
||||
let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?;
|
||||
let name = theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
@@ -180,6 +187,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"theme": name}))
|
||||
}
|
||||
|
||||
/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the
|
||||
/// master the current slide uses (a preserved master stays beside it), stretched to this deck's
|
||||
/// slide size. Each slide on it moves to the new layout with the same name, else the same kind,
|
||||
/// keeping its content as Layout does.
|
||||
fn apply_master(s: &mut Session, src: &Presentation) -> Result<Value> {
|
||||
let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let theme = from.theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let (mut media, mut layouts) = Default::default();
|
||||
let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?;
|
||||
let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0));
|
||||
if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 {
|
||||
scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
for l in &mut m.layouts {
|
||||
scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
let moves: Vec<(usize, deckcraft_model::LayoutId)> =
|
||||
doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect();
|
||||
let layouts = m.layouts.len();
|
||||
if old.preserve {
|
||||
doc.masters.insert(mi, Arc::new(m));
|
||||
} else if let Some(slot) = doc.masters.get_mut(mi) {
|
||||
*slot = Arc::new(m);
|
||||
}
|
||||
for (i, l) in &moves {
|
||||
super::slide::apply_layout(doc, *i, *l)?;
|
||||
}
|
||||
if let Target::Layout { master, .. } = sel.target {
|
||||
sel.target = Target::Master { master };
|
||||
}
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom
|
||||
/// layouts), else Title and Content, else the first.
|
||||
fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option<deckcraft_model::LayoutId> {
|
||||
use deckcraft_model::LayoutType;
|
||||
let ls = &m.layouts;
|
||||
ls.iter()
|
||||
.find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim()))
|
||||
.or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind))
|
||||
.or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent))
|
||||
.or_else(|| ls.first())
|
||||
.map(|l| l.id)
|
||||
}
|
||||
|
||||
/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills).
|
||||
fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
|
||||
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, depth: usize) {
|
||||
@@ -854,6 +913,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
@@ -869,6 +936,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -913,7 +981,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -934,9 +1006,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text);
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -948,15 +1020,33 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
@@ -1955,8 +2045,10 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod browse_theme_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
@@ -1982,6 +2074,108 @@ mod browse_theme_tests {
|
||||
assert!(s.execute("design.browseTheme", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
|
||||
/// can only find them by kind) and its content layout's title moved.
|
||||
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
|
||||
let mut logo = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
|
||||
let mut t = Session::with_new();
|
||||
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
|
||||
let title = t
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
|
||||
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
name: "Logo".into(),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
m.background =
|
||||
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
|
||||
for l in &mut m.layouts {
|
||||
l.name = format!("Brand {}", l.name);
|
||||
}
|
||||
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
|
||||
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
|
||||
title.xfrm = Some(x);
|
||||
Ok(x)
|
||||
})
|
||||
.unwrap();
|
||||
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
|
||||
let (data, logo, title) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
|
||||
assert_eq!(r["slides"], json!(2), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
|
||||
let m = &doc.masters[0];
|
||||
assert_ne!(m.id, before.masters[0].id);
|
||||
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
|
||||
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
|
||||
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
|
||||
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
|
||||
// The 4:3 master stretches to this deck's 16:9.
|
||||
let k = doc.slide_size.width / 720.0;
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
|
||||
|
||||
// Each slide moved to the brand layout of its kind, and kept its text.
|
||||
for (old, new) in before.slides.iter().zip(&doc.slides) {
|
||||
let (_, was) = before.layout(old.layout).unwrap();
|
||||
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
|
||||
assert_eq!(owner.id, m.id);
|
||||
assert_eq!(now.kind, was.kind);
|
||||
}
|
||||
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
|
||||
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
|
||||
let (_, lay) = doc.layout(agenda.layout).unwrap();
|
||||
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
|
||||
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
|
||||
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.masters, before.masters);
|
||||
assert_eq!(doc.slides, before.slides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preserved_master_stays_beside_the_template() {
|
||||
let (data, _, _) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
let old = s.doc().unwrap().doc.masters[0].id;
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.masters.len(), 2);
|
||||
assert_eq!(st.doc.masters[1].id, old);
|
||||
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
|
||||
let new = &st.doc.masters[0];
|
||||
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
|
||||
}
|
||||
|
||||
fn undo_len(s: &crate::Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
@@ -2132,3 +2326,90 @@ mod browse_theme_tests {
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
|
||||
/// The clock is read before and after the command, so a second ticking over between the
|
||||
/// command and the read cannot fail the comparison. Returns the run text.
|
||||
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
|
||||
let before = sample(kind);
|
||||
s.execute("design.headerFooter", ¶ms).unwrap();
|
||||
let after = sample(kind);
|
||||
let got = dates(s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some(kind));
|
||||
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
|
||||
got[0].0.clone()
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_ne!(time, sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_eq!(again, time);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
let got = dates(&s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, redo: bool) -> Result<Value> {
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
let st = s.doc_mut()?;
|
||||
let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
|
||||
let Some(e) = from.pop() else { return ok() };
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.interaction = None;
|
||||
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
|
||||
st.last_duplicate = None;
|
||||
st.revision += 1;
|
||||
// The point type Edit Points remembered with the restored document: handle drags follow it.
|
||||
s.tool.vertex_kind = e.vertex_kind;
|
||||
Ok(json!({"label": e.label}))
|
||||
}
|
||||
|
||||
@@ -69,30 +72,6 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|
||||
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
|
||||
deckcraft_model::walk(shapes, &mut |s, _| {
|
||||
match &s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
|
||||
deckcraft_model::ShapeKind::Media(m) => {
|
||||
out.push(m.media);
|
||||
if let Some(p) = m.poster {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Model3d { media, poster } => {
|
||||
out.push(*media);
|
||||
if let Some(p) = poster {
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill {
|
||||
out.push(pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let st = s.doc()?;
|
||||
// Text selection.
|
||||
@@ -113,16 +92,18 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();
|
||||
let mut mids = vec![];
|
||||
for sl in &slides {
|
||||
media_of_shapes(&sl.shapes, &mut mids);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {
|
||||
mids.push(pf.media);
|
||||
}
|
||||
mids.extend(crate::media_refs::slide_media(sl));
|
||||
}
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
|
||||
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
// Selected table cells copy as their text: a tab between cells and a paragraph per row.
|
||||
if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) {
|
||||
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
@@ -136,8 +117,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut mids = vec![];
|
||||
media_of_shapes(&shapes, &mut mids);
|
||||
let mids = crate::media_refs::shapes_media(&shapes);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
@@ -250,32 +230,13 @@ fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
|
||||
rec(shapes, f, 0);
|
||||
}
|
||||
|
||||
/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).
|
||||
fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {
|
||||
if map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let m = |id: &mut MediaId| {
|
||||
/// Rewrite a media id the clipboard used (`map`: clipboard id to destination id).
|
||||
fn remap_media(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
|
||||
move |id: &mut MediaId| {
|
||||
if let Some(n) = map.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
};
|
||||
walk_mut(shapes, &mut |s| {
|
||||
match &mut s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media),
|
||||
deckcraft_model::ShapeKind::Media(c) => {
|
||||
m(&mut c.media);
|
||||
if let Some(p) = c.poster.as_mut() {
|
||||
m(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill {
|
||||
m(&mut pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in
|
||||
@@ -284,7 +245,11 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck
|
||||
let mut map = HashMap::new();
|
||||
for m in media {
|
||||
match doc.media(m.id) {
|
||||
None => doc.media.push(m.clone()),
|
||||
None => {
|
||||
// Keeps the foreign id, so later ids must start past it.
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(m.id.0);
|
||||
}
|
||||
Some(x) if x.data == m.data => {}
|
||||
Some(_) => {
|
||||
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
|
||||
@@ -328,12 +293,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
|
||||
let map = reid("edit.paste", doc, &mut sl.shapes)?;
|
||||
remap_animations(&mut sl.animations, &map);
|
||||
remap_media(&mut sl.shapes, &media);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background
|
||||
&& let Some(n) = media.get(&pf.media)
|
||||
{
|
||||
pf.media = *n;
|
||||
}
|
||||
crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));
|
||||
if doc.layout(sl.layout).is_none() {
|
||||
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
}
|
||||
@@ -357,7 +317,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
remap_media(&mut shapes, &media);
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
for sh in &mut shapes {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
@@ -504,7 +464,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
let media = bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut clip.shapes, &mut remap_media(&media));
|
||||
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
|
||||
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
|
||||
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
|
||||
@@ -587,6 +548,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
|
||||
return s.execute("slide.delete", &json!({}));
|
||||
}
|
||||
// With table cells selected, Delete clears their text and keeps the table, as in PowerPoint.
|
||||
let sel = &s.doc()?.selection;
|
||||
if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) {
|
||||
return super::table::clear_selected_cells(s);
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -167,8 +167,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
|
||||
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
if lower.ends_with(".md") {
|
||||
deckcraft_format::markdown_to_slides(&mut p, &text);
|
||||
@@ -219,6 +221,12 @@ fn probe_clips(p: &mut Presentation) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
|
||||
pub fn outline_text(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
|
||||
}
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
@@ -334,6 +342,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
@@ -409,7 +418,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
@@ -442,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else if l.ends_with(".rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
"deckcraft"
|
||||
}
|
||||
@@ -622,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use deckcraft_model::edit as ed;
|
||||
use deckcraft_model::resolve;
|
||||
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
@@ -198,7 +198,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_shapes,
|
||||
direction
|
||||
),
|
||||
cmd!("format.columns", "Columns", ["Home", "Paragraph"], None, "{count, spacing?: pt}", has_text_or_shapes, columns),
|
||||
cmd!(
|
||||
"format.columns",
|
||||
"Columns",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{count?: 1-16, spacing?: pt between columns}; a key left out keeps the shape's value, and with neither the Columns dialog opens",
|
||||
has_text_or_shapes,
|
||||
columns
|
||||
),
|
||||
cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit),
|
||||
cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap),
|
||||
cmd!(
|
||||
@@ -340,6 +348,27 @@ fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
|
||||
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
|
||||
// which used to answer and change nothing.
|
||||
let st = s.doc()?;
|
||||
if st.selection.text.is_none() {
|
||||
let charts: Vec<ShapeId> =
|
||||
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
|
||||
let only_charts = charts.len() == st.selection.shapes.len();
|
||||
if !charts.is_empty() {
|
||||
let tc = c.clone();
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if only_charts {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
|
||||
}
|
||||
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
|
||||
@@ -692,7 +721,8 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
@@ -1089,12 +1119,45 @@ fn direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
format_body(s, &move |b| b.vert = Some(d))
|
||||
}
|
||||
/// The edited or first selected shape's columns as drawn: the count and the spacing in points.
|
||||
fn current_columns(s: &Session) -> (u32, f64) {
|
||||
let Ok(st) = s.doc() else { return (1, 0.0) };
|
||||
let id = match &st.selection.text {
|
||||
Some(t) if !t.notes => Some(t.shape),
|
||||
_ => st.selection.shapes.first().copied(),
|
||||
};
|
||||
let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) };
|
||||
let body = match super::text::ctx_for(&st.doc, &st.selection) {
|
||||
Some(c) => resolve::body(&c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
(body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0))
|
||||
}
|
||||
fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 16) as u32;
|
||||
let sp = f64_or(p, "spacing", 0.0).max(0.0);
|
||||
const CMD: &str = "format.columns";
|
||||
let count = match p.get("count") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n))
|
||||
.ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?;
|
||||
Some(n as u32)
|
||||
}
|
||||
};
|
||||
let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt);
|
||||
if count.is_none() && spacing.is_none() {
|
||||
let (count, spacing) = current_columns(s);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) });
|
||||
return ok();
|
||||
}
|
||||
format_body(s, &move |b| {
|
||||
b.columns = Some(n);
|
||||
b.col_spacing = Some(sp);
|
||||
if let Some(n) = count {
|
||||
b.columns = Some(n);
|
||||
}
|
||||
if let Some(sp) = spacing {
|
||||
b.col_spacing = Some(sp);
|
||||
}
|
||||
})
|
||||
}
|
||||
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1593,6 +1656,51 @@ mod tests {
|
||||
assert!(para(&s).indent.is_none());
|
||||
}
|
||||
|
||||
fn body(s: &Session) -> deckcraft_model::text::BodyProps {
|
||||
s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).map(|t| t.body.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_set_the_number_and_spacing_and_a_left_out_key_keeps_its_value() {
|
||||
let mut s = shape();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("format.columns", &json!({"count": 4, "spacing": 36.0})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(4), Some(36.0)));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step");
|
||||
s.execute("format.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(2), Some(36.0)), "the spacing stays");
|
||||
s.execute("format.columns", &json!({"spacing": 9.0})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(2), Some(9.0)), "the number stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_with_no_fields_open_the_dialog_with_the_shapes_values() {
|
||||
let mut s = shape();
|
||||
s.execute("format.columns", &json!({"count": 3, "spacing": 27.0})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.ui_requests.clear();
|
||||
s.execute("format.columns", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
|
||||
assert_eq!(id, "columns");
|
||||
assert_eq!(params["count"], 3);
|
||||
assert_eq!(params["spacing"], 27.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_reject_a_bad_number_or_spacing_without_editing() {
|
||||
let mut s = shape();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
for p in [json!({"count": 0}), json!({"count": 17}), json!({"count": 2.5}), json!({"count": "two"}), json!({"spacing": "wide"})] {
|
||||
let err = s.execute("format.columns", &p).unwrap_err().to_string();
|
||||
assert!(err.contains("format.columns"), "{p}: {err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
assert_eq!(body(&s).columns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() {
|
||||
let mut s = shape();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! slide. Everything is computed here, offline.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -57,9 +58,44 @@ fn roles(shapes: &[Shape]) -> Roles {
|
||||
r
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange).
|
||||
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
|
||||
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
|
||||
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
|
||||
match ctx {
|
||||
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
|
||||
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
|
||||
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
|
||||
/// the ideas do not know, so a slide holding one gets no ideas.
|
||||
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
match s.kind {
|
||||
ShapeKind::Picture { .. } => true,
|
||||
ShapeKind::Shape => {
|
||||
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
|
||||
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
|
||||
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
|
||||
// (filled or outlined) would stay where it is, over the rearranged slide.
|
||||
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
|
||||
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let ctx = Ctx::for_slide(doc, slide);
|
||||
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
|
||||
return vec![];
|
||||
}
|
||||
// The ideas move one picture; a second would stay put, over the rearranged text.
|
||||
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
|
||||
return vec![];
|
||||
}
|
||||
let r = roles(&slide.shapes);
|
||||
if r.picture.is_some() {
|
||||
PICTURE_IDEAS.to_vec()
|
||||
@@ -249,6 +285,7 @@ mod tests {
|
||||
};
|
||||
let body = Shape {
|
||||
id: ShapeId(501),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
|
||||
..Default::default()
|
||||
@@ -414,6 +451,91 @@ mod tests {
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_with_diagrams_or_other_graphics_get_no_ideas() {
|
||||
// A filled autoshape with text (a diagram box) is not text to restack.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(510),
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
|
||||
// A connector too, and a hidden one does not count.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(511),
|
||||
kind: ShapeKind::Connector { start: None, end: None },
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
|
||||
let mut s = deck(true);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(503),
|
||||
kind: ShapeKind::Picture { fill: PictureFill::default() },
|
||||
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
fill: Some(Fill::None),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_filled_through_its_style_blocks_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
|
||||
style.line_ref.0 = 0;
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
style: Some(style),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_crop_matches_the_box_aspect() {
|
||||
let c = cover_crop(2.0, 1.0);
|
||||
|
||||
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Elements",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}",
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -1804,7 +1804,19 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Waterfall total points.
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
|
||||
// default (tx1 at lumMod 65 % / lumOff 35 %).
|
||||
let text_color = match p.get("textColor") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(v) => {
|
||||
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
|
||||
}
|
||||
};
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
@@ -1818,6 +1830,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
// A series' own setting would otherwise keep overriding the chart-wide switch.
|
||||
for series in &mut c.series {
|
||||
series.data_labels = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
|
||||
@@ -371,6 +371,8 @@ pub(crate) fn ok() -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`.
|
||||
/// A preset or system colour keeps its base: `{"preset":"red"}`,
|
||||
/// `{"system":"windowText","last":"#000000"}`.
|
||||
pub(crate) fn color_param(p: &Value, key: &str) -> Option<deckcraft_model::ColorRef> {
|
||||
let v = p.get(key)?;
|
||||
color_value(v)
|
||||
@@ -391,6 +393,17 @@ pub(crate) fn color_value(v: &Value) -> Option<deckcraft_model::ColorRef> {
|
||||
Value::Object(o) => {
|
||||
let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) {
|
||||
ColorRef::scheme(deckcraft_color::SchemeSlot::from_xml(s)?)
|
||||
} else if let Some(name) = o.get("preset").and_then(Value::as_str) {
|
||||
if !deckcraft_color::is_preset_name(name) {
|
||||
return None;
|
||||
}
|
||||
ColorRef { base: deckcraft_model::ColorBase::Preset { name: name.to_string() }, mods: vec![] }
|
||||
} else if let Some(name) = o.get("system").and_then(Value::as_str) {
|
||||
if !deckcraft_color::is_system_name(name) {
|
||||
return None;
|
||||
}
|
||||
let last = deckcraft_color::Rgba::from_hex(o.get("last").and_then(Value::as_str)?)?;
|
||||
ColorRef { base: deckcraft_model::ColorBase::System { name: name.to_string(), last }, mods: vec![] }
|
||||
} else {
|
||||
ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?)
|
||||
};
|
||||
@@ -639,4 +652,22 @@ mod tests {
|
||||
assert!(!needs_arguments(save.params));
|
||||
assert!(needs_arguments(audio.params));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_and_system_colors_keep_their_base() {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::{ColorBase, ColorRef};
|
||||
use serde_json::json;
|
||||
let preset = color_value(&json!({"preset": "red", "lumMod": 50_000}));
|
||||
assert_eq!(preset, Some(ColorRef { base: ColorBase::Preset { name: "red".into() }, mods: vec![ColorTransform::LumMod(50_000)] }));
|
||||
let system = color_value(&json!({"system": "windowText", "last": "#123456"}));
|
||||
assert_eq!(system, Some(ColorRef { base: ColorBase::System { name: "windowText".into(), last: Rgba::rgb(0x12, 0x34, 0x56) }, mods: vec![] }));
|
||||
assert_eq!(color_value(&json!({"system": "windowText"})), None, "a system color needs its last value");
|
||||
assert_eq!(color_value(&json!({"preset": "notAColor"})), None);
|
||||
assert_eq!(color_value(&json!({"system": "notAColor", "last": "#000000"})), None);
|
||||
for name in ["aliceBlue", "darkBlue", "ltSlateGrey"] {
|
||||
assert!(color_value(&json!({"preset": name})).is_some(), "{name}");
|
||||
}
|
||||
assert!(color_value(&json!({"system": "3dDkShadow", "last": "#000000"})).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+261
-24
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
edit_points
|
||||
),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
|
||||
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
|
||||
cmd!(
|
||||
"shape.addPoint",
|
||||
@@ -54,6 +54,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
move_handle
|
||||
),
|
||||
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
|
||||
cmd!(
|
||||
"shape.deleteSegment",
|
||||
"Delete Segment",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, id?}: removes a segment of a closed outline, which opens there; it starts at the segment's end and ends at its start",
|
||||
has_selection,
|
||||
delete_segment
|
||||
),
|
||||
cmd!(
|
||||
"shape.segmentType",
|
||||
"Segment Type",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
|
||||
has_selection,
|
||||
segment_type
|
||||
),
|
||||
cmd!(
|
||||
"shape.openPath",
|
||||
"Open Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
|
||||
has_selection,
|
||||
open_path
|
||||
),
|
||||
cmd!(
|
||||
"shape.closePath",
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
|
||||
has_selection,
|
||||
close_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -281,6 +317,92 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
|
||||
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
|
||||
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
|
||||
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
|
||||
/// sub-path's start, none for an open one's.
|
||||
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
|
||||
if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether segment `j` is a straight line or a curve.
|
||||
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
|
||||
match segs.get(j)? {
|
||||
Seg::L(_) => Some("straight"),
|
||||
Seg::C(..) | Seg::Q(..) => Some("curved"),
|
||||
Seg::M(_) | Seg::Z => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
|
||||
/// thirds, so it keeps its shape until a handle is dragged.
|
||||
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
|
||||
if curved {
|
||||
to_cubic(segs, j);
|
||||
} else if let Some(s) = segs.get_mut(j)
|
||||
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
|
||||
{
|
||||
*s = Seg::L(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
|
||||
/// on that vertex, as two points. Returns false for an open sub-path.
|
||||
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
|
||||
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
|
||||
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
|
||||
let mut out = vec![Seg::M(v)];
|
||||
out.extend_from_slice(after);
|
||||
out.extend_from_slice(before);
|
||||
segs.splice(from..to, out);
|
||||
true
|
||||
}
|
||||
|
||||
/// Deletes the segment ending at the vertex ending segment `si` from its closed sub-path, which
|
||||
/// opens there: it now starts at the segment's end and ends at its start. Returns false for an
|
||||
/// open sub-path.
|
||||
fn delete_segment_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, _) = subpath(segs, si);
|
||||
if !open_at(segs, si) {
|
||||
return false;
|
||||
}
|
||||
// The deleted segment now ends the sub-path.
|
||||
let (_, to) = subpath(segs, from);
|
||||
if to > from + 1 {
|
||||
segs.remove(to - 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
|
||||
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
|
||||
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let last = to.saturating_sub(1);
|
||||
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
|
||||
let mut add = Vec::new();
|
||||
if (start - end).hypot() >= 1e-6 {
|
||||
add.push(Seg::L(start));
|
||||
}
|
||||
add.push(Seg::Z);
|
||||
segs.splice(to..to, add);
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom paths of a preset shape, in shape-local points.
|
||||
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
|
||||
let geo = deckcraft_render::shape_geometry(sh, w, h);
|
||||
@@ -402,6 +524,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
|
||||
s.tool.edit_vertex = None;
|
||||
s.tool.edit_segment = None;
|
||||
if !on {
|
||||
s.tool.edit_points = None;
|
||||
return Ok(json!({"editing": Value::Null}));
|
||||
@@ -436,17 +559,16 @@ fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
let paths: Vec<Vec<[f64; 2]>> =
|
||||
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
|
||||
let types: Vec<Vec<&str>> = match &sh.geom {
|
||||
Geom::Custom { paths } => paths
|
||||
.iter()
|
||||
.map(|cp| {
|
||||
let segs = parse(&cp.d);
|
||||
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
|
||||
})
|
||||
.collect(),
|
||||
Geom::Preset { .. } => vec![],
|
||||
};
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types}))
|
||||
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
|
||||
if let Geom::Custom { paths } = &sh.geom {
|
||||
for cp in paths {
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
|
||||
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
|
||||
}
|
||||
|
||||
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
|
||||
@@ -502,26 +624,26 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.addPoint")?;
|
||||
let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99);
|
||||
edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
|
||||
let r = edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?;
|
||||
// The closing edge of a closed outline ends at the start: split it, not the start.
|
||||
let si = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?
|
||||
} else {
|
||||
si
|
||||
};
|
||||
let si = ending_segment(segs, si).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?;
|
||||
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
|
||||
})
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deletePoint")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.deletePoint")?;
|
||||
edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
|
||||
let r = edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?;
|
||||
if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) }
|
||||
})
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The vertex of `sh` near slide point `p`: (path, index).
|
||||
@@ -568,15 +690,35 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
|
||||
vs.iter().position(|v| *v == si)
|
||||
};
|
||||
let Some(ev) = end_vertex else { continue };
|
||||
let mut near: Option<(f64, f64)> = None;
|
||||
for k in 1..32 {
|
||||
let t = k as f64 / 32.0;
|
||||
if let Some(q) = at(t) {
|
||||
let d = (q - local).hypot();
|
||||
if d <= tol && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
if d <= tol && near.is_none_or(|n| d < n.0) {
|
||||
near = Some((d, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((mut d, mut t)) = near else { continue };
|
||||
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
|
||||
let mut step = 1.0 / 32.0;
|
||||
for _ in 0..4 {
|
||||
step /= 8.0;
|
||||
let c = t;
|
||||
for k in -8..=8 {
|
||||
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
|
||||
if let Some(q) = at(u) {
|
||||
let e = (q - local).hypot();
|
||||
if e < d {
|
||||
(d, t) = (e, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, pi, ev, t)| (pi, ev, t))
|
||||
@@ -740,7 +882,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")),
|
||||
};
|
||||
// A straight point's handles can be as long as each other; remember the choice for drags.
|
||||
s.tool.vertex_kind = Some((path, index, kind.clone()));
|
||||
s.tool.vertex_kind = Some((id, path, index, kind.clone()));
|
||||
edit_path(s, id, path, "shape.pointType", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?;
|
||||
set_type(segs, si, &kind);
|
||||
@@ -784,3 +926,98 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_handle(s, p)
|
||||
}
|
||||
|
||||
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.segmentType")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.segmentType")?;
|
||||
let curved = match str_param(p, "type") {
|
||||
Some("straight") => false,
|
||||
Some("curved") => true,
|
||||
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
|
||||
};
|
||||
// Already that type: nothing to change, so no undo step.
|
||||
let want = if curved { "curved" } else { "straight" };
|
||||
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
|
||||
return ok();
|
||||
}
|
||||
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
|
||||
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
|
||||
set_segment(segs, j, curved);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.openPath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.openPath")?;
|
||||
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
|
||||
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.closePath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.closePath")?;
|
||||
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
|
||||
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deleteSegment")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.deleteSegment")?;
|
||||
let r = edit_path(s, id, path, "shape.deleteSegment", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.deleteSegment", "no such segment"))?;
|
||||
if ending_segment(segs, si).is_none() {
|
||||
return Err(bad("shape.deleteSegment", "the first point of an open outline ends no segment"));
|
||||
}
|
||||
if delete_segment_at(segs, si) { Ok(true) } else { Err(bad("shape.deleteSegment", "only a segment of a closed outline can be deleted")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The point where the segment ending at vertex `index` of path `path` of `sh` starts.
|
||||
pub fn segment_start_of(sh: &Shape, path: usize, index: usize) -> Option<usize> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let vs = vertices(&segs);
|
||||
let j = ending_segment(&segs, *vs.get(index)?)?;
|
||||
vs.iter().position(|v| Some(*v) == j.checked_sub(1))
|
||||
}
|
||||
|
||||
/// Adding, deleting, opening or closing renumbers the points of `path`: a point type remembered
|
||||
/// for one of them would follow the wrong point.
|
||||
fn forget_kinds(s: &mut Session, path: usize) {
|
||||
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.1 == path) {
|
||||
s.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
|
||||
pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
segment_kind(&segs, ending_segment(&segs, si)?)
|
||||
}
|
||||
|
||||
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
|
||||
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
let (_, to) = subpath(&segs, si);
|
||||
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
|
||||
}
|
||||
|
||||
@@ -10,22 +10,41 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
query "slide.reuseList",
|
||||
"List Slides to Reuse",
|
||||
[],
|
||||
None,
|
||||
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
|
||||
has_doc,
|
||||
reuse_list
|
||||
),
|
||||
cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let slides: Vec<Value> =
|
||||
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
|
||||
Ok(json!({"name": name, "slides": slides}))
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
@@ -114,9 +133,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid("slide.reuse", doc, shapes)?;
|
||||
super::edit::reid(cmd, doc, shapes)?;
|
||||
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
|
||||
remap_connectors(shapes, &map, 0);
|
||||
Ok(map)
|
||||
@@ -171,19 +190,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(doc, &src, &used, &mut media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
renumber(doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
layout_map.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(doc, &mut l.shapes)?;
|
||||
}
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -192,11 +199,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber(doc, &mut sl.shapes)?;
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
for a in &mut sl.animations {
|
||||
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
|
||||
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
|
||||
@@ -227,9 +234,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
|
||||
/// shape ids. `layouts` gains each source layout id → its copy's.
|
||||
pub(crate) fn import_master(
|
||||
cmd: &str,
|
||||
doc: &mut Presentation,
|
||||
src: &Presentation,
|
||||
m: &Master,
|
||||
media_map: &mut HashMap<MediaId, MediaId>,
|
||||
layouts: &mut HashMap<LayoutId, LayoutId>,
|
||||
) -> Result<Master> {
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(cmd, doc, src, &used, media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
renumber(cmd, doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
layouts.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(cmd, doc, &mut l.shapes)?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
|
||||
/// `doc` are shared; a linked item keeps its link).
|
||||
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
for &id in used {
|
||||
if map.contains_key(&id) {
|
||||
continue;
|
||||
@@ -237,12 +270,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map:
|
||||
let Some(m) = src.media(id) else { continue };
|
||||
let new = if m.link.is_some() {
|
||||
let mut item = m.clone();
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
let new = item.id;
|
||||
doc.media.push(item);
|
||||
new
|
||||
} else {
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
|
||||
};
|
||||
map.insert(id, new);
|
||||
}
|
||||
@@ -397,4 +430,34 @@ mod tests {
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
|
||||
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
|
||||
/// exactly those.
|
||||
#[test]
|
||||
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
|
||||
let mut src = Session::with_new();
|
||||
for t in ["Alpha", "Beta", "Gamma"] {
|
||||
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
|
||||
}
|
||||
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
|
||||
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
|
||||
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
|
||||
let slides = listed["slides"].as_array().expect("slides");
|
||||
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(slides.len() >= 3);
|
||||
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
|
||||
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
|
||||
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
|
||||
|
||||
let last = slides.len() - 1;
|
||||
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
|
||||
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
|
||||
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
|
||||
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
|
||||
comparing,
|
||||
reject
|
||||
),
|
||||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
@@ -655,7 +664,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -663,7 +672,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
@@ -673,7 +682,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
@@ -684,7 +694,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||||
for _ in 0..limit {
|
||||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||||
let changes = crate::compare::pending(doc, &cmp);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
@@ -707,6 +717,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.reject", "give `index` or `all`"));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
|
||||
let pending = crate::compare::pending(&doc, c);
|
||||
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
|
||||
if picked.is_empty() {
|
||||
return Err(bad("review.reject", "no such revision"));
|
||||
}
|
||||
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
|
||||
Ok(json!({"rejected": picked.len()}))
|
||||
}
|
||||
|
||||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.doc_mut()?.compare = None;
|
||||
ok()
|
||||
|
||||
@@ -337,7 +337,12 @@ fn decode_text(raw: &str) -> String {
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
|
||||
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
|
||||
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
@@ -356,6 +361,13 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
|
||||
@@ -1646,7 +1646,7 @@ mod tests {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind: None });
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -442,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
|
||||
|
||||
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -363,10 +363,12 @@ fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize,
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
let st = s.doc()?;
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c)));
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
(None, Some((a, b))) => {
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
@@ -585,6 +587,110 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The cell range from `a` to `b` in `t`, grown until every merged cell it touches lies inside
|
||||
/// it, as (the corner on `a`'s side, the corner on `b`'s side). Cells past the table's edge are
|
||||
/// clamped to it.
|
||||
pub fn cell_block(t: &Table, a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
|
||||
let (nr, nc) = (t.n_rows().max(1), t.n_cols().max(1));
|
||||
let (a, b) = ((a.0.min(nr - 1), a.1.min(nc - 1)), (b.0.min(nr - 1), b.1.min(nc - 1)));
|
||||
let (mut r0, mut r1, mut c0, mut c1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
|
||||
// Each pass takes in the merged cells that overlap the block; stop when one adds nothing.
|
||||
for _ in 0..nr + nc {
|
||||
let mut grown = false;
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if cell.is_covered() {
|
||||
continue;
|
||||
}
|
||||
// Spans come from the file; the additions saturate so that they cannot overflow on 32-bit targets.
|
||||
let end = |at: usize, span: u32, n: usize| at.saturating_add((span.max(1) - 1) as usize).min(n - 1);
|
||||
let (er, ec) = (end(r, cell.row_span, nr), end(c, cell.grid_span, nc));
|
||||
let overlaps = r <= r1 && er >= r0 && c <= c1 && ec >= c0;
|
||||
if overlaps && (r < r0 || er > r1 || c < c0 || ec > c1) {
|
||||
(r0, r1, c0, c1) = (r0.min(r), r1.max(er), c0.min(c), c1.max(ec));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let pick = |lo: usize, hi: usize, first: bool| if first { lo } else { hi };
|
||||
let from = (pick(r0, r1, a.0 <= b.0), pick(c0, c1, a.1 <= b.1));
|
||||
let to = (pick(r0, r1, a.0 > b.0), pick(c0, c1, a.1 > b.1));
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The selected cell range of table `id` (`t`), grown over merged cells, as its top-left and
|
||||
/// bottom-right cells. `None` when no range of that table is selected.
|
||||
pub fn selected_range(
|
||||
cells: Option<(deckcraft_model::ShapeId, (usize, usize), (usize, usize))>,
|
||||
id: deckcraft_model::ShapeId,
|
||||
t: &Table,
|
||||
) -> Option<((usize, usize), (usize, usize))> {
|
||||
let (_, a, b) = cells.filter(|c| c.0 == id)?;
|
||||
let (x, y) = cell_block(t, a, b);
|
||||
Some(((x.0.min(y.0), x.1.min(y.1)), (x.0.max(y.0), x.1.max(y.1))))
|
||||
}
|
||||
|
||||
/// Whether cell (`r`, `c`) lies in `range` (from [`selected_range`]); without a range every cell does.
|
||||
pub fn in_range(range: Option<((usize, usize), (usize, usize))>, r: usize, c: usize) -> bool {
|
||||
range.is_none_or(|((r0, c0), (r1, c1))| (r0..=r1).contains(&r) && (c0..=c1).contains(&c))
|
||||
}
|
||||
|
||||
/// Shift+arrow with a cell range selected: the cell the range was extended to moves by (`dr`,
|
||||
/// `dc`), on past a merged cell until the range changes. The cell it started from stays. Returns
|
||||
/// false when no cell range of the selected table is selected.
|
||||
pub(crate) fn extend_selected_cells(s: &mut Session, dr: isize, dc: isize) -> Result<bool> {
|
||||
let st = s.doc()?;
|
||||
let Some((id, from, to)) = st.selection.cells.filter(|c| st.selection.shapes == [c.0]) else { return Ok(false) };
|
||||
let Some(ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { return Ok(false) };
|
||||
let (nr, nc) = (t.n_rows(), t.n_cols());
|
||||
let old = cell_block(t, from, to);
|
||||
let mut end = to;
|
||||
while cell_block(t, from, end) == old {
|
||||
let r = end.0.checked_add_signed(dr).filter(|r| *r < nr);
|
||||
let c = end.1.checked_add_signed(dc).filter(|c| *c < nc);
|
||||
let (Some(r), Some(c)) = (r, c) else { break };
|
||||
end = (r, c);
|
||||
}
|
||||
s.select(|_, sel| sel.cells = Some((id, from, end)))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The text of the selected range of cells, for Copy and Cut: a tab between the cells of a row
|
||||
/// and a line per row (a merged cell once, where it starts). `None` without a cell range of the
|
||||
/// selected table.
|
||||
pub(crate) fn selected_cells_text(st: &DocState) -> Option<String> {
|
||||
let id = st.selection.cells.filter(|c| st.selection.shapes == [c.0])?.0;
|
||||
let ShapeKind::Table(t) = &st.shape(id)?.kind else { return None };
|
||||
let ((r0, c0), (r1, c1)) = selected_range(st.selection.cells, id, t)?;
|
||||
let rows = (r0..=r1).map(|r| {
|
||||
let cells = (c0..=c1).filter_map(|c| t.cell(r, c).filter(|x| !x.is_covered()).map(|x| x.text.text()));
|
||||
cells.collect::<Vec<_>>().join("\t")
|
||||
});
|
||||
Some(rows.collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
/// Delete with a cell range selected: the text in those cells goes and the cells stay, each
|
||||
/// keeping its first paragraph's formatting.
|
||||
pub(crate) fn clear_selected_cells(s: &mut Session) -> Result<Value> {
|
||||
let Some((id, ..)) = s.doc()?.selection.cells else { return ok() };
|
||||
let cells = s.doc()?.selection.cells;
|
||||
with_table(s, &json!({"id": id}), "edit.delete", move |t| {
|
||||
let range = selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if in_range(range, r, c) {
|
||||
deckcraft_model::edit::delete(&mut cell.text, (0, 0), (usize::MAX, usize::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> {
|
||||
let a = p.get(key)?;
|
||||
let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?);
|
||||
|
||||
+141
-13
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoCorrect Options",
|
||||
["Tools"],
|
||||
None,
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?, lists?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
always,
|
||||
autocorrect_options
|
||||
),
|
||||
@@ -339,6 +339,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.text = Some(TextSel { notes: true, anchor: end, caret: end, ..Default::default() });
|
||||
sel.shapes.clear();
|
||||
sel.cells = None;
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
@@ -369,6 +370,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
sel.cells = None;
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -417,7 +419,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -425,6 +428,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
c
|
||||
})?;
|
||||
autocorrect(s, text.chars().count() == 1)?;
|
||||
// AutoCorrect can move the caret: a replaced word, or a list lead it removed.
|
||||
let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret);
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
@@ -631,6 +636,13 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) {
|
||||
return Ok(());
|
||||
}
|
||||
if typing && s.prefs.ac_lists {
|
||||
// A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list.
|
||||
let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let mut word_end = end - 1;
|
||||
let mut start = word_end;
|
||||
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
|
||||
@@ -679,6 +691,83 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A list AutoFormat started: the text being edited right after it, the typed lead with its
|
||||
/// space, the paragraph's own formatting before, and the revision the change left.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutoList {
|
||||
sel: TextSel,
|
||||
typed: String,
|
||||
props: deckcraft_model::text::ParaProps,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and
|
||||
/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering
|
||||
/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`.
|
||||
fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> {
|
||||
let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c})));
|
||||
let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme})));
|
||||
match lead {
|
||||
"*" => bullet("•"),
|
||||
"-" | "–" => bullet("–"),
|
||||
"--" | "—" => bullet("■"),
|
||||
">" => bullet("➢"),
|
||||
"<>" => bullet("◆"),
|
||||
"1." => number("arabicPeriod"),
|
||||
"1)" => number("arabicParenR"),
|
||||
"I." => number("romanUcPeriod"),
|
||||
"i." => number("romanLcPeriod"),
|
||||
"A." => number("alphaUcPeriod"),
|
||||
"a." => number("alphaLcPeriod"),
|
||||
"a)" => number("alphaLcParenR"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a
|
||||
/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed
|
||||
/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed.
|
||||
fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result<bool> {
|
||||
if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') {
|
||||
return Ok(false);
|
||||
}
|
||||
let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) };
|
||||
let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let para = t.caret.0;
|
||||
let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default();
|
||||
edit_text(s, |body, tt| {
|
||||
ed::delete(body, (para, 0), (para, end));
|
||||
tt.anchor = (para, 0);
|
||||
tt.caret = (para, 0);
|
||||
})?;
|
||||
s.execute(id, ¶ms)?;
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(sel) = st.selection.text.clone() {
|
||||
st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision });
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and
|
||||
/// space and the formatting it had. `false` when the last change was anything else.
|
||||
fn undo_auto_list(s: &mut Session) -> Result<bool> {
|
||||
let st = s.doc_mut()?;
|
||||
let Some(a) = st.auto_list.take() else { return Ok(false) };
|
||||
if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_text(s, |body, t| {
|
||||
if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) {
|
||||
p.props = a.props.clone();
|
||||
}
|
||||
let c = ed::insert(body, a.sel.caret, &a.typed, None);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
|
||||
/// superscripts it.
|
||||
fn is_ordinal(word: &str) -> bool {
|
||||
@@ -726,6 +815,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
("capsLock", &mut pr.ac_caps_lock),
|
||||
("fractions", &mut pr.ac_fractions),
|
||||
("ordinals", &mut pr.ac_ordinals),
|
||||
("lists", &mut pr.ac_lists),
|
||||
] {
|
||||
if let Some(v) = p.get(key).and_then(Value::as_bool) {
|
||||
*slot = v;
|
||||
@@ -772,7 +862,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
|
||||
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
|
||||
"ordinals": pr.ac_ordinals, "entries": list,
|
||||
"ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -784,7 +874,8 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
|
||||
let c = ed::insert_body(body, at, src);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -1026,6 +1117,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
if dir == "backward" && undo_auto_list(s)? {
|
||||
return ok();
|
||||
}
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
@@ -1124,7 +1218,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => match to.as_str() {
|
||||
"up" => (caret.0.saturating_sub(1), caret.1),
|
||||
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"lineStart" => (caret.0, 0),
|
||||
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
},
|
||||
@@ -1189,9 +1283,11 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t);
|
||||
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
||||
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
@@ -1293,12 +1389,18 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
for row in &mut t.rows {
|
||||
for c in &mut row.cells {
|
||||
ed::format_all(&mut c.text, f);
|
||||
// Only the selected cells, when a range of them is selected.
|
||||
let range = super::table::selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if super::table::in_range(range, r, c) {
|
||||
ed::format_all(&mut cell.text, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1371,7 +1473,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
f(p);
|
||||
}
|
||||
@@ -1383,12 +1486,17 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
for row in &mut t.rows {
|
||||
for c in &mut row.cells {
|
||||
c.text.paragraphs.iter_mut().for_each(f);
|
||||
let range = super::table::selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if super::table::in_range(range, r, c) {
|
||||
cell.text.paragraphs.iter_mut().for_each(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1519,3 +1627,23 @@ mod replace_font_tests {
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_paragraph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn select_paragraph_huge_at() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
||||
s.execute("text.edit", &json!({})).unwrap();
|
||||
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
// Clamped to the last (only) paragraph, never past the end of the body.
|
||||
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
+215
-28
@@ -1,5 +1,5 @@
|
||||
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
|
||||
//! by slide, and accepting a revised slide into the open one.
|
||||
//! by slide, and accepting a revised slide into the open one or rejecting it.
|
||||
//!
|
||||
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
|
||||
//! slide id at all, by position. Shapes match by id, then by name.
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde::Serialize;
|
||||
|
||||
/// The revised presentation a document is being compared with.
|
||||
@@ -18,6 +18,54 @@ pub struct Compare {
|
||||
/// Slides match by position (the decks shared no slide id when the compare started); fixed
|
||||
/// for the session so accepting slides doesn't switch the matching half way.
|
||||
pub by_position: bool,
|
||||
/// Revisions rejected: no longer listed and never accepted, the open deck left as it is.
|
||||
pub rejected: HashSet<ChangeKey>,
|
||||
}
|
||||
|
||||
/// What a revision is about, stable while slides move: the open slide (for changed and deleted)
|
||||
/// and the revised slide (for changed and inserted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeKey {
|
||||
kind: ChangeKind,
|
||||
open: Option<SlideId>,
|
||||
revised: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChangeKey {
|
||||
pub fn of(doc: &Presentation, change: &Change) -> ChangeKey {
|
||||
let open = match change.kind {
|
||||
ChangeKind::Inserted => None,
|
||||
ChangeKind::Changed | ChangeKind::Deleted => doc.slides.get(change.slide).map(|s| s.id),
|
||||
};
|
||||
ChangeKey { kind: change.kind, open, revised: change.revised }
|
||||
}
|
||||
}
|
||||
|
||||
/// The revisions still to review: [`diff`] without the rejected ones.
|
||||
pub fn pending(doc: &Presentation, c: &Compare) -> Vec<Change> {
|
||||
let mut out = diff(doc, &c.revised, c.by_position);
|
||||
out.retain(|ch| !is_rejected(doc, c, ch));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `ch` was rejected. Positional matching re-pairs slides by index when the open deck gains
|
||||
/// or loses a slide, which changes the open half of the key; the revised index stays put, so any
|
||||
/// rejected key for the same revised slide counts. An open slide pushed past the end of the revised
|
||||
/// deck turns into a deletion; it stays kept when a rejected key names it.
|
||||
fn is_rejected(doc: &Presentation, c: &Compare, ch: &Change) -> bool {
|
||||
if c.rejected.contains(&ChangeKey::of(doc, ch)) {
|
||||
return true;
|
||||
}
|
||||
if !c.by_position {
|
||||
return false;
|
||||
}
|
||||
match ch.kind {
|
||||
ChangeKind::Deleted => {
|
||||
let open = doc.slides.get(ch.slide).map(|s| s.id);
|
||||
open.is_some() && c.rejected.iter().any(|k| k.open == open)
|
||||
}
|
||||
_ => ch.revised.is_some() && c.rejected.iter().any(|k| k.revised == ch.revised),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `cur` and `rev` share no slide id (then slides match by position).
|
||||
@@ -26,7 +74,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
|
||||
!cur.slides.iter().any(|s| ids.contains(&s.id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeKind {
|
||||
/// Only in the revised deck.
|
||||
@@ -233,14 +281,16 @@ fn max_shape_id(shapes: &[Shape], m: &mut u32) {
|
||||
pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option<SlideId>) -> crate::Result<Slide> {
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut mids = vec![];
|
||||
crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids);
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
for id in mids {
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
None => doc.media.push(m.clone()),
|
||||
None => {
|
||||
// Keeps the revision's id, so later ids must start past it.
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(id.0);
|
||||
}
|
||||
Some(_) => {
|
||||
let new = doc
|
||||
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
|
||||
@@ -262,7 +312,6 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
shapes: &mut [Shape],
|
||||
taken: &HashSet<u32>,
|
||||
ids: &mut HashMap<ShapeId, ShapeId>,
|
||||
media: &HashMap<MediaId, MediaId>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
@@ -274,31 +323,18 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
ids.insert(s.id, new);
|
||||
s.id = new;
|
||||
}
|
||||
let remap = |m: &mut MediaId| {
|
||||
if let Some(n) = media.get(m) {
|
||||
*m = *n;
|
||||
}
|
||||
};
|
||||
match &mut s.kind {
|
||||
ShapeKind::Picture { fill } => remap(&mut fill.media),
|
||||
ShapeKind::Media(m) => {
|
||||
remap(&mut m.media);
|
||||
if let Some(p) = m.poster.as_mut() {
|
||||
remap(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(Fill::Picture(pf)) = &mut s.fill {
|
||||
remap(&mut pf.media);
|
||||
}
|
||||
if let Some(ch) = s.children_mut() {
|
||||
rec(doc, ch, taken, ids, media, depth + 1)?;
|
||||
rec(doc, ch, taken, ids, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0)?;
|
||||
rec(doc, &mut sl.shapes, &taken, &mut id_map, 0)?;
|
||||
crate::media_refs::visit_mut::slide(&mut sl, &mut |m: &mut MediaId| {
|
||||
if let Some(n) = media_map.get(m) {
|
||||
*m = *n;
|
||||
}
|
||||
});
|
||||
for a in &mut sl.animations {
|
||||
if let Some(n) = id_map.get(&a.shape) {
|
||||
a.shape = *n;
|
||||
@@ -412,6 +448,52 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Background, Fill, Shape, ShapeId, ShapeKind, Table, Transition, defaults};
|
||||
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let cell = p.add_media("cell.png", "image/png", format!("{tag} cell").into_bytes()).expect("media");
|
||||
let back = p.add_media("back.png", "image/png", format!("{tag} background").into_bytes()).expect("media");
|
||||
let sound = p.add_media("sound.wav", "audio/wav", format!("{tag} sound").into_bytes()).expect("media");
|
||||
let mut t = Table::new(1, 1, 100.0, 40.0);
|
||||
t.rows[0].cells[0].fill = Some(Fill::Picture(PictureFill { media: cell, ..Default::default() }));
|
||||
let id = ShapeId(p.alloc_id().expect("id"));
|
||||
let mut s = (*p.slides.first().cloned().unwrap_or_default()).clone();
|
||||
s.shapes.push(Shape { id, kind: ShapeKind::Table(t), ..Default::default() });
|
||||
s.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: back, ..Default::default() }) });
|
||||
s.transition = Some(Transition { kind: "fade".into(), sound: Some(sound), ..Default::default() });
|
||||
p.slides = vec![Arc::new(s)];
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accepted_slide_keeps_its_cell_picture_background_and_sound() {
|
||||
use deckcraft_model::{Background, Fill, MediaId, ShapeKind};
|
||||
|
||||
// Both decks hold the same media ids with different bytes.
|
||||
let mut doc = media_deck("open");
|
||||
let rev = media_deck("revised");
|
||||
let sl = super::adopt(&mut doc, &rev, &rev.slides[0], None).unwrap();
|
||||
let bytes = |id: MediaId| doc.media(id).unwrap().data.as_ref().clone();
|
||||
let table = sl.shapes.iter().find_map(|x| if let ShapeKind::Table(t) = &x.kind { Some(t) } else { None }).unwrap();
|
||||
let Some(Fill::Picture(cell)) = &table.rows[0].cells[0].fill else { panic!("no cell picture") };
|
||||
let Some(Background::Fill { fill: Fill::Picture(back) }) = &sl.background else { panic!("no background picture") };
|
||||
let sound = sl.transition.as_ref().and_then(|t| t.sound).unwrap();
|
||||
assert_eq!(
|
||||
[bytes(cell.media), bytes(back.media), bytes(sound)],
|
||||
["revised cell", "revised background", "revised sound"].map(|x| x.as_bytes().to_vec())
|
||||
);
|
||||
// The open deck's own slide is untouched.
|
||||
let own = &doc.slides[0];
|
||||
let Some(Background::Fill { fill: Fill::Picture(back) }) = &own.background else { panic!("no background picture") };
|
||||
assert_eq!(bytes(back.media), b"open background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
|
||||
let (mut s, rev, n) = decks();
|
||||
@@ -434,6 +516,50 @@ mod tests {
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_keeps_the_open_deck_and_accept_all_skips_rejected_revisions() {
|
||||
let (mut s, rev, n) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
let ins = before.iter().position(|c| c["kind"] == "inserted").unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("review.reject", &json!({"index": ins})).unwrap()["rejected"], 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "rejecting leaves the open deck as it is");
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert_eq!(kinds(&left).len(), before.len() - 1);
|
||||
assert!(!kinds(&left).contains(&"inserted".into()), "{left}");
|
||||
// Accept All takes the rest, never the rejected slide.
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.slides.len(), n - 1, "the deleted slide goes, the rejected insertion never comes");
|
||||
assert!(!st.doc.slides.iter().any(|x| super::slide_title(x) == "Brand new"));
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "rejecting is not an undo step");
|
||||
// Reject All: nothing left to review, the deck as it was; Accept All then has nothing to take.
|
||||
assert_eq!(s.execute("review.reject", &json!({"all": true})).unwrap()["rejected"], before.len() - 1);
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert!(s.execute("review.reject", &json!({"all": true})).is_err(), "nothing left to reject");
|
||||
assert!(s.execute("review.reject", &json!({})).is_err());
|
||||
s.execute("review.endCompare", &json!({})).unwrap();
|
||||
assert!(s.execute("review.reject", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_one_revision_keeps_the_others_in_order() {
|
||||
let (mut s, rev, _) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert!(s.execute("review.reject", &json!({"index": before.len()})).is_err());
|
||||
s.execute("review.reject", &json!({"index": 0})).unwrap();
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert_eq!(left.as_slice(), &before[1..], "the rejected one goes, the others stay in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_decks_match_by_position_and_bad_input_fails() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -452,4 +578,65 @@ mod tests {
|
||||
assert!(s.execute("review.compare", &json!({})).is_err());
|
||||
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
|
||||
}
|
||||
|
||||
fn slide_texts(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.slides.iter().map(|sl| sl.shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
/// By position, a rejected revision stays rejected when a slide is inserted into the open deck
|
||||
/// and the same revised slide now pairs with a different open slide.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_stays_rejected_after_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
|
||||
let mut other = Session::with_new();
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changed = |v: &Value| -> Vec<u64> {
|
||||
v["changes"].as_array().unwrap().iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).collect()
|
||||
};
|
||||
let revised = changed(&v);
|
||||
let last = *revised.last().expect("a changed slide");
|
||||
let at = v["changes"].as_array().unwrap().iter().position(|c| c["revised"] == last).unwrap();
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
// A new slide at the front shifts every open slide down by one.
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
assert_eq!(slide_texts(&s).iter().position(|t| t.contains('N')), Some(0));
|
||||
let now = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(!changed(&now).contains(&last), "{now}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let texts = slide_texts(&s);
|
||||
assert!(!texts.iter().any(|t| t.contains('B')), "{texts:?}");
|
||||
}
|
||||
|
||||
/// By position, inserting a slide before a rejected pair pushes the pair's open slide past the
|
||||
/// end of the revised deck; it must not turn into a deletion that Accept All carries out.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_does_not_become_a_deletion_when_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
let mut other = Session::with_new();
|
||||
for t in ["A", "X"] {
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
for t in ["B", "Y"] {
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changes = v["changes"].as_array().unwrap();
|
||||
let last = changes.iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).max().expect("a changed slide");
|
||||
let at = changes.iter().position(|c| c["kind"] == "changed" && c["revised"] == last).unwrap();
|
||||
let open_id = s.doc().unwrap().doc.slides[last as usize].id;
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides.iter().any(|sl| sl.id == open_id), "the open slide whose revision was rejected was deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,15 @@ impl Session {
|
||||
/// Run command `id` (`f`) inside the guard.
|
||||
pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision));
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let depth = self.depth;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(self))) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
self.depth = depth;
|
||||
// The remembered point type names a shape of the document it was chosen in.
|
||||
let same = before.as_ref().is_some_and(|b| self.active().is_some_and(|st| st.uid == b.0));
|
||||
self.tool.vertex_kind = if same { vertex_kind } else { None };
|
||||
let msg = panic_message(payload.as_ref());
|
||||
// Keep the document as it was before the command.
|
||||
if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut())
|
||||
@@ -91,6 +95,29 @@ mod tests {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_command_keeps_the_remembered_point_type() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let kept = Some((deckcraft_model::ShapeId(1), 0, 0, "straight".to_string()));
|
||||
s.tool.vertex_kind = kept.clone();
|
||||
let r = s.guarded("test.panic", |s| {
|
||||
s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string()));
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.tool.vertex_kind, kept);
|
||||
// A command that opens another document and then panics leaves no point type behind: the
|
||||
// one from before names a shape of the other document.
|
||||
let r = s.guarded("test.panic", |s| {
|
||||
s.execute("file.new", &json!({}))?;
|
||||
s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string()));
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.tool.vertex_kind, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_fails_after_editing_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -54,12 +54,16 @@ impl Session {
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if matches!(k, "escape" | "esc") && self.cancel_point_drag() {
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {
|
||||
self.tool.edit_points = None;
|
||||
self.tool.edit_vertex = None;
|
||||
self.tool.edit_segment = None;
|
||||
return Ok(json!({"handled": true, "command": "shape.editPoints"}));
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
@@ -89,6 +93,21 @@ impl Session {
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
}
|
||||
if !editing && has_shapes && !mods.cmd && mods.shift && !mods.alt {
|
||||
// Shift+arrow grows or shrinks a selected range of table cells.
|
||||
let d = match k {
|
||||
"left" => Some((0, -1)),
|
||||
"right" => Some((0, 1)),
|
||||
"up" => Some((-1, 0)),
|
||||
"down" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((dr, dc)) = d
|
||||
&& crate::cmd::table::extend_selected_cells(self, dr, dc)?
|
||||
{
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
}
|
||||
if !editing && has_shapes && !mods.cmd {
|
||||
let step = if mods.alt { 1.0 } else { 6.0 };
|
||||
let d = match k {
|
||||
|
||||
@@ -163,6 +163,8 @@ pub struct HistoryEntry {
|
||||
pub label: String,
|
||||
pub doc: Arc<Presentation>,
|
||||
pub selection: Selection,
|
||||
/// The point type Edit Points remembered with `doc` (`ToolState::vertex_kind`).
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -184,6 +186,8 @@ pub struct Interaction {
|
||||
pub label: String,
|
||||
pub doc: Arc<Presentation>,
|
||||
pub selection: Selection,
|
||||
/// The point type Edit Points remembered when it began.
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
@@ -225,6 +229,9 @@ pub struct DocState {
|
||||
/// The last Duplicate: where it happened and each original with its copy, so duplicating the
|
||||
/// copies again repeats how far they were moved, as in PowerPoint (#966).
|
||||
pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>,
|
||||
/// The list AutoFormat just started from a typed `* ` or `1. `, so Backspace right after
|
||||
/// can put the typed text back.
|
||||
pub auto_list: Option<cmd::text::AutoList>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -253,6 +260,7 @@ impl DocState {
|
||||
composition: None,
|
||||
compare: None,
|
||||
last_duplicate: None,
|
||||
auto_list: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -328,13 +336,15 @@ pub struct Prefs {
|
||||
pub smart_quotes: bool,
|
||||
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
|
||||
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) and starts
|
||||
/// bulleted and numbered lists (`* `, `1. `) as you type.
|
||||
pub ac_two_initials: bool,
|
||||
pub ac_sentence_caps: bool,
|
||||
pub ac_days: bool,
|
||||
pub ac_caps_lock: bool,
|
||||
pub ac_fractions: bool,
|
||||
pub ac_ordinals: bool,
|
||||
pub ac_lists: bool,
|
||||
/// Your own "replace … with …" entries; they win over the built-in list.
|
||||
pub ac_entries: Vec<(String, String)>,
|
||||
/// Built-in entries you deleted.
|
||||
@@ -360,6 +370,7 @@ impl Default for Prefs {
|
||||
ac_caps_lock: true,
|
||||
ac_fractions: true,
|
||||
ac_ordinals: true,
|
||||
ac_lists: true,
|
||||
ac_entries: Vec::new(),
|
||||
ac_removed: Vec::new(),
|
||||
author: "Presenter".into(),
|
||||
@@ -557,16 +568,21 @@ impl Session {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
pub fn set_active(&mut self, i: usize) {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
if i < self.docs.len() {
|
||||
self.active = Some(i);
|
||||
}
|
||||
self.forget_vertex_kind_unless(was);
|
||||
}
|
||||
pub fn add_document(&mut self, d: DocState) -> usize {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
self.docs.push(d);
|
||||
self.active = Some(self.docs.len() - 1);
|
||||
self.forget_vertex_kind_unless(was);
|
||||
self.docs.len() - 1
|
||||
}
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
if i < self.docs.len() {
|
||||
let d = self.docs.remove(i);
|
||||
if let Some(dir) = &self.recovery_dir {
|
||||
@@ -574,6 +590,14 @@ impl Session {
|
||||
}
|
||||
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
|
||||
}
|
||||
self.forget_vertex_kind_unless(was);
|
||||
}
|
||||
/// The point type Edit Points remembers names a shape of the active document, and another
|
||||
/// document can have a shape with the same id: a change of active document forgets it.
|
||||
fn forget_vertex_kind_unless(&mut self, was: Option<u64>) {
|
||||
if self.active().map(|d| d.uid) != was {
|
||||
self.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
pub fn next_untitled(&mut self) -> String {
|
||||
self.untitled += 1;
|
||||
@@ -599,6 +623,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone()));
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let outer = self.depth == 0;
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
@@ -617,6 +642,9 @@ impl Session {
|
||||
st.selection = sel;
|
||||
st.revision += 1;
|
||||
}
|
||||
if outer {
|
||||
self.tool.vertex_kind = vertex_kind;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -630,7 +658,7 @@ impl Session {
|
||||
&& spec.undoable
|
||||
&& outer
|
||||
{
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel });
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel, vertex_kind });
|
||||
}
|
||||
if spec.journal && outer {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
@@ -661,10 +689,11 @@ impl Session {
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
pub fn end_composition(&mut self) {
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection, vertex_kind });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1004
-1
File diff suppressed because it is too large
Load Diff
+136
-15
@@ -196,6 +196,9 @@ enum Drag {
|
||||
shape: ShapeId,
|
||||
/// The text selection before the press, to tell whether the drag changed it.
|
||||
before: Option<TextTarget>,
|
||||
/// The text selection the press made. In a table, a drag that leaves its cell selects
|
||||
/// whole cells, and coming back to the cell restores this.
|
||||
press: Option<TextSel>,
|
||||
},
|
||||
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
|
||||
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
|
||||
@@ -266,8 +269,13 @@ pub struct ToolState {
|
||||
pub edit_points: Option<ShapeId>,
|
||||
/// The vertex (path, index) whose control handles show in Edit Points.
|
||||
pub edit_vertex: Option<(usize, usize)>,
|
||||
/// The point type last chosen for a vertex (path, index, type): handle drags follow it.
|
||||
pub vertex_kind: Option<(usize, usize, String)>,
|
||||
/// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for
|
||||
/// the segment menu.
|
||||
pub edit_segment: Option<(usize, usize)>,
|
||||
/// Where on that segment the right click was (0..1), where Add Point puts the new point.
|
||||
pub edit_segment_t: Option<f64>,
|
||||
/// The point type last chosen for a vertex (shape, path, index, type): handle drags follow it.
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
|
||||
pub guides_visible: bool,
|
||||
/// The guide being dragged and where it is now (slide points); `None` once dropped.
|
||||
@@ -519,10 +527,11 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self, label: &str) {
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.is_none()
|
||||
{
|
||||
st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,10 +540,33 @@ impl Session {
|
||||
&& let Some(i) = st.interaction.take()
|
||||
&& !Arc::ptr_eq(&i.doc, &st.doc)
|
||||
{
|
||||
crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection });
|
||||
crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind });
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the
|
||||
/// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled.
|
||||
pub(crate) fn cancel_point_drag(&mut self) -> bool {
|
||||
if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) {
|
||||
return false;
|
||||
}
|
||||
self.tool.drag = None;
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points")
|
||||
&& let Some(i) = st.interaction.take()
|
||||
{
|
||||
// A modifier held during the drag may have set a point type; the drag is undone, so is that.
|
||||
let kind = i.vertex_kind;
|
||||
if !Arc::ptr_eq(&i.doc, &st.doc) {
|
||||
st.doc = i.doc;
|
||||
st.selection = i.selection;
|
||||
st.revision += 1;
|
||||
}
|
||||
self.tool.vertex_kind = kind;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
|
||||
pub(crate) fn commit_named(&mut self, label: &str) {
|
||||
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
|
||||
@@ -586,7 +618,8 @@ impl Session {
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
let before = self.text_selection();
|
||||
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
||||
self.tool.drag = Some(Drag::TextSelect { shape, before });
|
||||
let press = self.doc()?.selection.text.clone();
|
||||
self.tool.drag = Some(Drag::TextSelect { shape, before, press });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
@@ -671,6 +704,21 @@ impl Session {
|
||||
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
|
||||
}
|
||||
}
|
||||
Some(Hit::Shape { id, .. })
|
||||
if !(mods.shift || mods.cmd)
|
||||
&& st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id])
|
||||
&& in_table_cell(st, id, p) =>
|
||||
{
|
||||
// A press in a table whose cells are selected starts again in the cell under the
|
||||
// pointer: a click puts the caret there and a drag selects another range. A press
|
||||
// on the table outside its cells selects and moves the table like any shape.
|
||||
let before = self.text_selection();
|
||||
if self.edit_text_at(id, p)? {
|
||||
self.select(|_, sel| sel.cells = None)?;
|
||||
let press = self.doc()?.selection.text.clone();
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });
|
||||
}
|
||||
}
|
||||
Some(Hit::Shape { id, text }) => {
|
||||
let selected = st.selection.shapes.contains(&id);
|
||||
let toggle = mods.shift || mods.cmd;
|
||||
@@ -691,10 +739,17 @@ impl Session {
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a cell of a
|
||||
// selected table puts the caret there; a click on its grab band keeps the table selected.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
// A click on the frame band of a text box selects the box whole, as PowerPoint
|
||||
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
|
||||
let on_frame = text
|
||||
&& is_text_first(st, id)
|
||||
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
|
||||
let enter_text =
|
||||
(!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p))))
|
||||
.then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -885,7 +940,18 @@ impl Session {
|
||||
Drag::Handle { id, path, index, out } => {
|
||||
self.tool.drag = Some(Drag::Handle { id, path, index, out });
|
||||
let side = if out { "out" } else { "in" };
|
||||
if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p) {
|
||||
// Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or
|
||||
// corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt.
|
||||
let held = if mods.shift {
|
||||
Some("smooth")
|
||||
} else if mods.cmd {
|
||||
Some("straight")
|
||||
} else if mods.alt {
|
||||
Some("corner")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) {
|
||||
log::debug!("edit handle: {e}");
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
@@ -942,8 +1008,29 @@ impl Session {
|
||||
};
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
Drag::TextSelect { shape, .. } => {
|
||||
// The selection stays in the shape or table cell where it started.
|
||||
Drag::TextSelect { shape, press, .. } => {
|
||||
// In a table, leaving the cell the drag started in selects whole cells, as in
|
||||
// PowerPoint; coming back to that cell selects text in it again.
|
||||
let st = self.doc()?;
|
||||
if let Some(start) = press.as_ref().and_then(|t| t.cell)
|
||||
&& let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh))
|
||||
&& let Some(cell) = f.cell_at(f.local(p))
|
||||
{
|
||||
if cell != start {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![shape];
|
||||
sel.cells = Some((shape, start, cell));
|
||||
})?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if st.selection.text.is_none() {
|
||||
self.select(|_, sel| {
|
||||
sel.cells = None;
|
||||
sel.text = press;
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
@@ -1004,9 +1091,19 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point) -> Result<Value> {
|
||||
let kind = self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2);
|
||||
cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))
|
||||
/// Moves a control handle during a drag. `held` is the point type set by the modifier held
|
||||
/// during the drag; the point keeps that type for later drags, as after `shape.pointType`.
|
||||
fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result<Value> {
|
||||
let kind = match held {
|
||||
Some(k) => Some(k.to_string()),
|
||||
None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3),
|
||||
};
|
||||
let r =
|
||||
cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?;
|
||||
if let Some(k) = held {
|
||||
self.tool.vertex_kind = Some((id, path, index, k.to_string()));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
||||
@@ -1243,6 +1340,12 @@ impl Session {
|
||||
) => Some(*id),
|
||||
None => None,
|
||||
};
|
||||
// Edit Points: the point or else the segment under the pointer picks the menu.
|
||||
let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit {
|
||||
Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None),
|
||||
Some(Hit::Handle { .. }) => (None, None),
|
||||
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)),
|
||||
});
|
||||
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
@@ -1250,6 +1353,14 @@ impl Session {
|
||||
sel.shapes = id.into_iter().collect();
|
||||
})?;
|
||||
}
|
||||
if let Some((vertex, segment)) = picked {
|
||||
// A control handle belongs to the picked point: its menu stays.
|
||||
if !matches!(hit, Some(Hit::Handle { .. })) {
|
||||
self.tool.edit_vertex = vertex;
|
||||
}
|
||||
self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg));
|
||||
self.tool.edit_segment_t = segment.map(|(_, _, t)| t);
|
||||
}
|
||||
let st = self.doc()?;
|
||||
Ok(json!({
|
||||
"hit": serde_json::to_value(&hit).unwrap_or_default(),
|
||||
@@ -1669,8 +1780,9 @@ impl Session {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
if let Some(st) = self.active_mut() {
|
||||
crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before });
|
||||
crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind });
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -1883,6 +1995,15 @@ pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool
|
||||
Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv }
|
||||
}
|
||||
|
||||
/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take
|
||||
/// the nearest cell).
|
||||
fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool {
|
||||
st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| {
|
||||
let q = f.local(p);
|
||||
(0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let inv = x.affine().inverse();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives
|
||||
//! it. The file lists one effect per paragraph, each with its own start (On Click or With
|
||||
//! Previous); the show must not ask for more clicks, or fewer, than those effects.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_anim::{ShowAction, ShowState};
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape {
|
||||
Shape {
|
||||
id: ShapeId(p.alloc_id().expect("id")),
|
||||
name: name.into(),
|
||||
xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)),
|
||||
text: Some(TextBody::from_text(text)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on
|
||||
/// the second, saved as PPTX.
|
||||
fn saved(body: &str, levels: &[u8]) -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let mut b = shape(&mut p, "Body", body, 50.0);
|
||||
if let Some(t) = b.text.as_mut() {
|
||||
for (para, lvl) in t.paragraphs.iter_mut().zip(levels) {
|
||||
para.level = *lvl;
|
||||
}
|
||||
}
|
||||
let pic = shape(&mut p, "Picture", "Picture", 300.0);
|
||||
let anims = vec![
|
||||
Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() },
|
||||
Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() },
|
||||
];
|
||||
for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] {
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes = shapes;
|
||||
s.animations = animations;
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn spid(xml: &str, name: &str) -> String {
|
||||
let at = xml.find(&format!(r#"name="{name}""#)).expect("shape");
|
||||
let head = &xml[..at];
|
||||
let id = head.rfind(r#"id=""#).expect("id") + 4;
|
||||
head[id..].split('"').next().expect("spid").to_string()
|
||||
}
|
||||
|
||||
/// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`).
|
||||
fn effect(ids: &mut u32, spid: &str, para: Option<u32>, node: &str) -> String {
|
||||
let mut id = || {
|
||||
*ids += 1;
|
||||
*ids
|
||||
};
|
||||
let tgt = match para {
|
||||
Some(p) => format!(r#"<p:spTgt spid="{spid}"><p:txEl><p:pRg st="{p}" end="{p}"/></p:txEl></p:spTgt>"#),
|
||||
None => format!(r#"<p:spTgt spid="{spid}"/>"#),
|
||||
};
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" presetID="10" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="{node}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl>{tgt}</p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="{}" dur="500"/><p:tgtEl>{tgt}</p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>"#,
|
||||
id(),
|
||||
id(),
|
||||
id()
|
||||
)
|
||||
}
|
||||
|
||||
/// A click group holding `effects`.
|
||||
fn click(ids: &mut u32, effects: &[String]) -> String {
|
||||
*ids += 2;
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>"#,
|
||||
*ids - 1,
|
||||
*ids,
|
||||
effects.concat()
|
||||
)
|
||||
}
|
||||
|
||||
/// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click`
|
||||
/// from the spids of Body and Picture), Body's build "By paragraph".
|
||||
fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec<String>) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture"));
|
||||
let mut ids = 2;
|
||||
let main = clicks(&mut ids, &body, &pic).concat();
|
||||
let timing = format!(
|
||||
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>{main}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst><p:bldP spid="{body}" grpId="0" build="p"/><p:bldP spid="{pic}" grpId="0" animBg="1"/></p:bldLst></p:timing>"#
|
||||
);
|
||||
let a = xml.find("<p:timing>").expect("timing");
|
||||
let b = xml.find("</p:timing>").expect("timing end") + "</p:timing>".len();
|
||||
data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes();
|
||||
}
|
||||
out.start_file(name, SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// Clicks (Next with nothing playing) from the start of the first slide to the second one.
|
||||
fn clicks_to_second_slide(pres: &Presentation) -> usize {
|
||||
let mut s = ShowState::new(pres);
|
||||
for n in 1..=50 {
|
||||
match s.next(pres) {
|
||||
ShowAction::PlayStep(_) => {
|
||||
// The step clock runs out before the next click.
|
||||
s.step_done(pres);
|
||||
}
|
||||
ShowAction::Slide(1) => return n,
|
||||
a => panic!("unexpected {a:?}"),
|
||||
}
|
||||
}
|
||||
usize::MAX
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() {
|
||||
// As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first
|
||||
// paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2.
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() {
|
||||
// "By 2nd level paragraphs": the heading and each sub-point have their own click.
|
||||
// Picture comes in with the last one, so nothing splits Body's build.
|
||||
let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
}
|
||||
|
||||
/// The first slide's XML in a saved PPTX.
|
||||
fn slide_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Paragraph and start of each animation on the first slide.
|
||||
fn effects(pres: &Presentation) -> Vec<(Option<u32>, AnimStart, TextBuild)> {
|
||||
pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect()
|
||||
}
|
||||
|
||||
/// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent.
|
||||
fn first_level_build() -> Vec<u8> {
|
||||
with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "withEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph));
|
||||
assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_with_sub_points_survives_saving() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let saved = deckcraft_pptx::export(&pres).expect("export");
|
||||
let xml = slide_xml(&saved);
|
||||
let body = spid(&xml, "Body");
|
||||
let blds: Vec<&str> = xml.split("<p:bldP ").skip(1).filter(|b| b.starts_with(&format!(r#"spid="{body}""#))).collect();
|
||||
// ¶0 is its own build (Picture sits between), ¶1 and ¶2 share one.
|
||||
assert_eq!(blds.len(), 2, "{blds:?}");
|
||||
assert!(blds.iter().all(|b| b.contains(r#"build="p""#) && !b.contains("animBg")), "{blds:?}");
|
||||
let again = deckcraft_pptx::import(&saved).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_on_blank_text_saves_as_a_whole_shape_effect() {
|
||||
let xml = slide_xml(&saved(" ", &[0]));
|
||||
assert!(!xml.contains(r#"build="p""#), "no paragraph build without a paragraph to animate");
|
||||
assert!(!xml.contains("<p:txEl>"), "the effect targets the whole shape");
|
||||
}
|
||||
|
||||
/// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then
|
||||
/// Picture with the last one.
|
||||
fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec<String>) -> Presentation {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let mut clicks: Vec<String> = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect();
|
||||
let ep = effect(ids, pic, None, "clickEffect");
|
||||
clicks.push(click(ids, &[ep]));
|
||||
clicks
|
||||
});
|
||||
deckcraft_pptx::import(&bytes).expect("import")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reversed_build_keeps_its_order() {
|
||||
let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect());
|
||||
let paras: Vec<Option<u32>> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect();
|
||||
assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_with_its_own_duration_keeps_its_own_effect() {
|
||||
let pres = three_paragraphs(|ids, body| {
|
||||
(0..3)
|
||||
.map(|p| {
|
||||
let e = effect(ids, body, Some(p), "clickEffect");
|
||||
if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e }
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let durs: Vec<(Option<u32>, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect();
|
||||
assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
pub mod rtf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
@@ -661,6 +662,7 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
for row in &t.rows {
|
||||
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
|
||||
out.push('\t');
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -1100,3 +1102,33 @@ mod tests {
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_table_rows_are_indented_and_do_not_become_slides() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
for sh in s.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| k.is_title())) {
|
||||
sh.text = Some(TextBody::from_text("Slide title"));
|
||||
}
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["A1", "B1"], ["A2", "B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
let mut q = defaults::new_presentation(None);
|
||||
q.slides.clear();
|
||||
assert_eq!(outline_to_slides(&mut q, &out), 1, "table rows became slides: {out:?}");
|
||||
assert_eq!(q.slides.len(), 1, "{out:?}");
|
||||
let body = q.slides[0].shapes.iter().filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join("\n");
|
||||
assert!(body.contains("A1\tB1"), "{body:?}");
|
||||
assert!(body.contains("A2\tB2"), "{body:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
|
||||
//!
|
||||
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
|
||||
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
|
||||
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
|
||||
//! the tab-indented outline [`crate::outline_to_slides`] understands.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Paragraph, RunKind};
|
||||
|
||||
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
|
||||
fn escape(out: &mut String, s: &str) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '{' | '}' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\t' => out.push_str("\\tab "),
|
||||
'\u{b}' | '\n' => out.push_str("\\line "),
|
||||
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
|
||||
c if c.is_ascii() => {}
|
||||
c => {
|
||||
let mut buf = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{}?", *u as i16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runs(out: &mut String, p: &Paragraph) {
|
||||
for r in &p.runs {
|
||||
if matches!(r.kind, RunKind::Break) {
|
||||
out.push_str("\\line ");
|
||||
continue;
|
||||
}
|
||||
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
|
||||
if !(b || i || u) {
|
||||
escape(out, &r.text);
|
||||
continue;
|
||||
}
|
||||
out.push('{');
|
||||
out.push_str(if b { "\\b" } else { "" });
|
||||
out.push_str(if i { "\\i" } else { "" });
|
||||
out.push_str(if u { "\\ul" } else { "" });
|
||||
out.push(' ');
|
||||
escape(out, &r.text);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
|
||||
/// The presentation's outline (slide titles and body text) as RTF.
|
||||
pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
|
||||
for s in &p.slides {
|
||||
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
|
||||
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
|
||||
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
|
||||
Some(tp) => runs(&mut out, tp),
|
||||
None => escape(&mut out, &s.title()),
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
|
||||
let lvl = para.level.min(8) as u32;
|
||||
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
|
||||
runs(&mut out, para);
|
||||
out.push_str("\\par\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `bytes` look like RTF.
|
||||
pub fn sniff_rtf(bytes: &[u8]) -> bool {
|
||||
bytes.trim_ascii_start().starts_with(b"{\\rtf")
|
||||
}
|
||||
|
||||
/// Destinations whose text is not document text.
|
||||
const SKIP: &[&str] = &[
|
||||
"fonttbl",
|
||||
"colortbl",
|
||||
"stylesheet",
|
||||
"info",
|
||||
"pict",
|
||||
"header",
|
||||
"footer",
|
||||
"footnote",
|
||||
"listtable",
|
||||
"listoverridetable",
|
||||
"generator",
|
||||
"xmlnstbl",
|
||||
"themedata",
|
||||
"datastore",
|
||||
"latentstyles",
|
||||
"rsidtbl",
|
||||
"object",
|
||||
"fldinst",
|
||||
];
|
||||
|
||||
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
|
||||
/// run after `\uN` or `\'hh` may be followed by non-ASCII text).
|
||||
fn advance(rtf: &str, i: usize, n: usize) -> usize {
|
||||
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
|
||||
}
|
||||
|
||||
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
|
||||
/// indent in 0.25" steps) becomes its number of tabs.
|
||||
pub fn rtf_to_outline(rtf: &str) -> String {
|
||||
#[derive(Clone, Copy)]
|
||||
struct St {
|
||||
skip: bool,
|
||||
uc: usize,
|
||||
}
|
||||
let b = rtf.as_bytes();
|
||||
let mut stack: Vec<St> = vec![];
|
||||
let mut st = St { skip: false, uc: 1 };
|
||||
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
|
||||
let mut line = String::new();
|
||||
let mut out = String::new();
|
||||
let mut pending_skip = 0usize;
|
||||
let mut i = 0;
|
||||
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
|
||||
let text = line.trim();
|
||||
if !text.is_empty() {
|
||||
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
|
||||
out.push_str(&"\t".repeat(n));
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
line.clear();
|
||||
};
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
match c {
|
||||
b'{' => {
|
||||
stack.push(st);
|
||||
i += 1;
|
||||
// `{\*\dest …}`: an ignorable destination.
|
||||
if b[i..].starts_with(b"\\*") {
|
||||
st.skip = true;
|
||||
}
|
||||
}
|
||||
b'}' => {
|
||||
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
|
||||
i += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
let Some(&n) = b.get(i) else { break };
|
||||
if n.is_ascii_alphabetic() {
|
||||
let s = i;
|
||||
while i < b.len() && b[i].is_ascii_alphabetic() {
|
||||
i += 1;
|
||||
}
|
||||
let word = &rtf[s..i];
|
||||
let ns = i;
|
||||
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
|
||||
i += 1;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let arg: Option<i64> = rtf[ns..i].parse().ok();
|
||||
if i < b.len() && b[i] == b' ' {
|
||||
i += 1;
|
||||
}
|
||||
if pending_skip > 0 {
|
||||
pending_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if SKIP.contains(&word) {
|
||||
st.skip = true;
|
||||
}
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match word {
|
||||
"par" => {
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
}
|
||||
"pard" => {
|
||||
level = None;
|
||||
indent = 0;
|
||||
}
|
||||
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
|
||||
"li" => indent = arg.unwrap_or(0),
|
||||
"tab" | "line" => line.push(' '),
|
||||
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
|
||||
"u" => {
|
||||
let v = arg.unwrap_or(0);
|
||||
// RTF writes UTF-16 units as signed 16-bit values.
|
||||
push_utf16(&mut line, (v & 0xffff) as u32);
|
||||
pending_skip = 0;
|
||||
// Skip the fallback characters.
|
||||
let mut k = st.uc;
|
||||
while k > 0 && i < b.len() {
|
||||
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
|
||||
i = advance(rtf, i, 4);
|
||||
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
|
||||
break;
|
||||
} else {
|
||||
i = advance(rtf, i, 1);
|
||||
}
|
||||
k -= 1;
|
||||
}
|
||||
}
|
||||
"emdash" => line.push('—'),
|
||||
"endash" => line.push('–'),
|
||||
"bullet" => line.push('•'),
|
||||
"lquote" => line.push('‘'),
|
||||
"rquote" => line.push('’'),
|
||||
"ldblquote" => line.push('“'),
|
||||
"rdblquote" => line.push('”'),
|
||||
_ => {}
|
||||
}
|
||||
} else if n == b'\'' {
|
||||
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
i = advance(rtf, i, 3);
|
||||
if !st.skip
|
||||
&& let Some(h) = hex
|
||||
&& h != 0
|
||||
{
|
||||
line.push(cp1252(h));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match n {
|
||||
b'\\' | b'{' | b'}' => line.push(n as char),
|
||||
b'~' => line.push('\u{a0}'),
|
||||
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => i += 1,
|
||||
_ => {
|
||||
let s = i;
|
||||
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
if !st.skip
|
||||
&& let Some(t) = rtf.get(s..i)
|
||||
{
|
||||
line.push_str(&t.replace('\u{0}', ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
finish_surrogates(out)
|
||||
}
|
||||
|
||||
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
|
||||
/// and joined afterwards. NUL itself is the marker, so a NUL unit is dropped.
|
||||
fn push_utf16(line: &mut String, v: u32) {
|
||||
if v == 0 {
|
||||
return;
|
||||
}
|
||||
match char::from_u32(v) {
|
||||
Some(c) => line.push(c),
|
||||
None => line.push_str(&format!("\u{0}{v:04x}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_surrogates(s: String) -> String {
|
||||
if !s.contains('\u{0}') {
|
||||
return s;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(p) = rest.find('\u{0}') {
|
||||
out.push_str(&rest[..p]);
|
||||
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match (hi, lo) {
|
||||
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
|
||||
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
|
||||
rest = &rest[p + 10..];
|
||||
}
|
||||
_ => {
|
||||
out.push('\u{fffd}');
|
||||
// Skip the NUL and, when four hex digits follow, the digits; otherwise
|
||||
// resume right after the NUL so no following character is lost.
|
||||
let skip = if hi.is_some() { p + 5 } else { p + 1 };
|
||||
rest = rest.get(skip..).unwrap_or("");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn cp1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9f => HIGH[(b - 0x80) as usize],
|
||||
_ => b as char,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
|
||||
#[test]
|
||||
fn non_ascii_text_after_escapes_does_not_panic() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}");
|
||||
assert!(out.contains('x'), "{out:?}");
|
||||
for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] {
|
||||
let _ = rtf_to_outline(rtf);
|
||||
}
|
||||
}
|
||||
|
||||
/// NUL is the surrogate marker, so NULs from `\u0`, `\'00` or raw bytes must not reach `finish_surrogates`.
|
||||
#[test]
|
||||
fn nul_escapes_do_not_drop_text_or_add_a_slide() {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 \u0?abcé\par}"), "abcé\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 a\'00bé\par}"), "abé\n");
|
||||
assert_eq!(rtf_to_outline("{\\rtf1 a\0b\\par}\0\0"), "ab\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
|
||||
#[test]
|
||||
fn huge_left_indent_gives_the_deepest_level() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard\li9223372036854775807 deep\par\pard\li-9223372036854775808 flat\par}");
|
||||
assert_eq!(out, format!("{}deep\nflat\n", "\t".repeat(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_reads_back_as_the_same_outline() {
|
||||
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
|
||||
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
|
||||
crate::outline_to_slides(&mut p, outline);
|
||||
let rtf = slides_to_rtf(&p);
|
||||
assert!(sniff_rtf(rtf.as_bytes()));
|
||||
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
|
||||
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
|
||||
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
|
||||
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ impl Frame {
|
||||
|
||||
/// Copy the w x h window of chroma component `c` at (x0, y0) (clamped) into `out`.
|
||||
#[inline]
|
||||
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), out: &mut [u8], os: usize) {
|
||||
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [u8], os: usize) {
|
||||
let (cw, ch) = (self.cwidth, self.cheight);
|
||||
for r in 0..h {
|
||||
let yy = (y0 + r as i32).clamp(0, ch as i32 - 1) as usize;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn predict_block(f: &Frame, mv: [i16; 2], px: usize, py: usize, bw: usize, bh: u
|
||||
let (fx, fy) = ((mv[0] & 7) as u32, (mv[1] & 7) as u32);
|
||||
for (comp, out) in c.into_iter().enumerate() {
|
||||
let mut w = [0u8; 16 * 9];
|
||||
f.chroma_window(comp, (cx, cy), (cw + 1, ch + 1), &mut w, 16);
|
||||
f.chroma_window(comp, cx, cy, cw + 1, ch + 1, &mut w, 16);
|
||||
inter::mc_chroma_win(&w, 16, fx, fy, cw, ch, out, cs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn mc_chroma(f: &Frame, c: usize, x: i32, y: i32, mv: [i16; 2], w: usize, h:
|
||||
let yi = y + (mv[1] as i32 >> 3);
|
||||
let shift1 = bd.min(12) - 8;
|
||||
let shift3 = 14 - bd;
|
||||
f.chroma_window(c, (xi - 1, yi - 1), (w + 3, h + 3), &mut sc.win, WIN);
|
||||
f.chroma_window(c, xi - 1, yi - 1, w + 3, h + 3, &mut sc.win, WIN);
|
||||
interpolate::<4>(&sc.win, &mut sc.tmp, fx, fy, &CHROMA_FILTER[fx], &CHROMA_FILTER[fy], w, h, shift1, shift3, out);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn put_bi(p0: &[i16], p1: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u1
|
||||
}
|
||||
|
||||
/// Explicit weighted prediction, one list (8-265). `o` is the offset already scaled to the bit depth.
|
||||
pub fn put_weighted_uni(p: &[i16], (w, h): (usize, usize), bd: u32, (log2wd, wt, o): (u32, i32, i32), dst: &mut [u16], ds: usize) {
|
||||
pub fn put_weighted_uni(p: &[i16], w: usize, h: usize, bd: u32, log2wd: u32, wt: i32, o: i32, dst: &mut [u16], ds: usize) {
|
||||
let max = (1i32 << bd) - 1;
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
@@ -173,9 +173,14 @@ pub fn put_weighted_uni(p: &[i16], (w, h): (usize, usize), bd: u32, (log2wd, wt,
|
||||
pub fn put_weighted_bi(
|
||||
p0: &[i16],
|
||||
p1: &[i16],
|
||||
(w, h): (usize, usize),
|
||||
w: usize,
|
||||
h: usize,
|
||||
bd: u32,
|
||||
(log2wd, w0, w1, o0, o1): (u32, i32, i32, i32, i32),
|
||||
log2wd: u32,
|
||||
w0: i32,
|
||||
w1: i32,
|
||||
o0: i32,
|
||||
o1: i32,
|
||||
dst: &mut [u16],
|
||||
ds: usize,
|
||||
) {
|
||||
|
||||
+34
-23
@@ -36,23 +36,9 @@ fn same_motion(a: &MvField, b: &MvField) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Coding block and prediction block geometry (6.4.2): coding block (xc, yc, ncb), prediction block (xp, yp, w, h, part_idx).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PbGeom {
|
||||
pub xc: i32,
|
||||
pub yc: i32,
|
||||
pub ncb: i32,
|
||||
pub xp: i32,
|
||||
pub yp: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
pub part_idx: usize,
|
||||
}
|
||||
|
||||
impl<'a> SliceDecoder<'a> {
|
||||
/// Prediction block availability (6.4.2).
|
||||
fn pb_avail(&self, g: &PbGeom, xn: i32, yn: i32) -> bool {
|
||||
let PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx } = *g;
|
||||
fn pb_avail(&self, xc: i32, yc: i32, ncb: i32, xp: i32, yp: i32, w: i32, h: i32, part_idx: usize, xn: i32, yn: i32) -> bool {
|
||||
let same_cb = xc <= xn && yc <= yn && xc + ncb > xn && yc + ncb > yn;
|
||||
let avail = if !same_cb {
|
||||
self.pic.zavail(xp, yp, xn, yn, self.slice_addr)
|
||||
@@ -73,13 +59,23 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
|
||||
/// Merge mode motion (8.5.3.2.2).
|
||||
pub fn derive_merge(&self, g: PbGeom, part: PartMode, merge_idx: usize) -> MvField {
|
||||
let PbGeom { xc, yc, ncb, xp: xp0, yp: yp0, w: w0, h: h0, part_idx: part_idx0 } = g;
|
||||
pub fn derive_merge(
|
||||
&self,
|
||||
xc: i32,
|
||||
yc: i32,
|
||||
ncb: i32,
|
||||
xp0: i32,
|
||||
yp0: i32,
|
||||
w0: i32,
|
||||
h0: i32,
|
||||
part_idx0: usize,
|
||||
part: PartMode,
|
||||
merge_idx: usize,
|
||||
) -> MvField {
|
||||
let pml = self.pps.log2_parallel_merge_level;
|
||||
let (xp, yp, w, h, part_idx) = if pml > 2 && ncb == 8 { (xc, yc, ncb, ncb, 0) } else { (xp0, yp0, w0, h0, part_idx0) };
|
||||
let g = PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx };
|
||||
let same_mer = |xn: i32, yn: i32| (xp >> pml) == (xn >> pml) && (yp >> pml) == (yn >> pml);
|
||||
let avail = |xn: i32, yn: i32| self.pb_avail(&g, xn, yn) && !same_mer(xn, yn);
|
||||
let avail = |xn: i32, yn: i32| self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, xn, yn) && !same_mer(xn, yn);
|
||||
let mut list: [MvField; 5] = [MvField::default(); 5];
|
||||
let mut n = 0;
|
||||
let max = self.sh.max_num_merge_cand as usize;
|
||||
@@ -241,8 +237,20 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
|
||||
/// AMVP predictor (8.5.3.2.6, 8.5.3.2.7).
|
||||
pub fn derive_amvp(&self, g: PbGeom, l: usize, ref_idx: usize, mvp_flag: usize) -> [i16; 2] {
|
||||
let PbGeom { xp, yp, w, h, .. } = g;
|
||||
pub fn derive_amvp(
|
||||
&self,
|
||||
xc: i32,
|
||||
yc: i32,
|
||||
ncb: i32,
|
||||
xp: i32,
|
||||
yp: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
part_idx: usize,
|
||||
l: usize,
|
||||
ref_idx: usize,
|
||||
mvp_flag: usize,
|
||||
) -> [i16; 2] {
|
||||
let target = &self.refs[l][ref_idx];
|
||||
let (tpoc, tlt) = (target.poc, target.long_term);
|
||||
let cur_poc = self.pic.poc;
|
||||
@@ -269,7 +277,10 @@ impl<'a> SliceDecoder<'a> {
|
||||
pick.map(|(mv, r)| if r.poc != tpoc && !r.long_term && !tlt { scale_mv(mv, cur_poc - r.poc, cur_poc - tpoc) } else { mv })
|
||||
};
|
||||
let pa = [(xp - 1, yp + h), (xp - 1, yp + h - 1)];
|
||||
let av_a = [self.pb_avail(&g, pa[0].0, pa[0].1), self.pb_avail(&g, pa[1].0, pa[1].1)];
|
||||
let av_a = [
|
||||
self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pa[0].0, pa[0].1),
|
||||
self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pa[1].0, pa[1].1),
|
||||
];
|
||||
let is_scaled = av_a[0] || av_a[1];
|
||||
let mut a = None;
|
||||
for k in 0..2 {
|
||||
@@ -285,7 +296,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
}
|
||||
let pb = [(xp + w, yp - 1), (xp + w - 1, yp - 1), (xp - 1, yp - 1)];
|
||||
let av_b = [0, 1, 2].map(|k| self.pb_avail(&g, pb[k].0, pb[k].1));
|
||||
let av_b = [0, 1, 2].map(|k| self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pb[k].0, pb[k].1));
|
||||
let mut b = None;
|
||||
for k in 0..3 {
|
||||
if av_b[k] && b.is_none() {
|
||||
|
||||
@@ -125,7 +125,7 @@ impl Frame {
|
||||
}
|
||||
|
||||
/// Copy a w x h window of chroma component `c` (0 = Cb, 1 = Cr) with edge replication.
|
||||
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), out: &mut [i16], os: usize) {
|
||||
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [i16], os: usize) {
|
||||
let (cw, ch) = (self.cwidth as i32, self.cheight as i32);
|
||||
let sh = self.log2_ctb - 1;
|
||||
let mask = (1i32 << sh) - 1;
|
||||
|
||||
+30
-29
@@ -7,7 +7,6 @@ use crate::cabac::{Cabac, Contexts, init_contexts};
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::inter;
|
||||
use crate::intra::{self, Refs};
|
||||
use crate::mvpred::PbGeom;
|
||||
use crate::params::{Layout, Pps, Sps};
|
||||
use crate::picture::{FrameRef, RefPic};
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
@@ -669,7 +668,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
self.mark_edges(x0, y0, n, n, E_TU_V | E_PU_V, E_TU_H | E_PU_H);
|
||||
if skip {
|
||||
self.cu_intra = false;
|
||||
self.prediction_unit(PbGeom { xc: x0, yc: y0, ncb: n, xp: x0, yp: y0, w: n, h: n, part_idx: 0 }, PartMode::P2Nx2N, true)?;
|
||||
self.prediction_unit(x0, y0, n, x0, y0, n, n, 0, PartMode::P2Nx2N, true)?;
|
||||
self.finish_cu(x0, y0, n);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -698,7 +697,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
self.parse_intra_modes(x0, y0, n, part)?;
|
||||
self.max_trafo_depth = self.sps.max_th_depth_intra + self.intra_split as u32;
|
||||
self.transform_tree((x0, y0), (x0, y0), log2, 0, 0, [true, true])?;
|
||||
self.transform_tree(x0, y0, x0, y0, log2, 0, 0, [true, true])?;
|
||||
} else {
|
||||
self.intra_split = false;
|
||||
let mut merge2nx2n = false;
|
||||
@@ -715,8 +714,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
PartMode::PNxN => &[(0, 0, h, h), (h, 0, h, h), (0, h, h, h), (h, h, h, h)],
|
||||
};
|
||||
for (i, &(px, py, pw, ph)) in parts.iter().enumerate() {
|
||||
let merge =
|
||||
self.prediction_unit(PbGeom { xc: x0, yc: y0, ncb: n, xp: x0 + px, yp: y0 + py, w: pw, h: ph, part_idx: i }, part, false)?;
|
||||
let merge = self.prediction_unit(x0, y0, n, x0 + px, y0 + py, pw, ph, i, part, false)?;
|
||||
if part == PartMode::P2Nx2N {
|
||||
merge2nx2n = merge;
|
||||
}
|
||||
@@ -730,7 +728,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let root_cbf = if !merge2nx2n { self.dd(RQT_ROOT_CBF) == 1 } else { true };
|
||||
if root_cbf {
|
||||
self.max_trafo_depth = self.sps.max_th_depth_inter;
|
||||
self.transform_tree((x0, y0), (x0, y0), log2, 0, 0, [true, true])?;
|
||||
self.transform_tree(x0, y0, x0, y0, log2, 0, 0, [true, true])?;
|
||||
}
|
||||
}
|
||||
self.finish_cu(x0, y0, n);
|
||||
@@ -900,7 +898,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transform_tree(&mut self, (x0, y0): (i32, i32), base: (i32, i32), log2: u32, depth: u32, blk_idx: u32, parent_cbf_c: [bool; 2]) -> Result<()> {
|
||||
fn transform_tree(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, depth: u32, blk_idx: u32, parent_cbf_c: [bool; 2]) -> Result<()> {
|
||||
let split =
|
||||
if log2 <= self.sps.log2_max_tb && log2 > self.sps.log2_min_tb && depth < self.max_trafo_depth && !(self.intra_split && depth == 0) {
|
||||
self.dd(SPLIT_TRANSFORM + (5 - log2 as usize)) == 1
|
||||
@@ -925,10 +923,10 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
if split {
|
||||
let h = 1 << (log2 - 1);
|
||||
self.transform_tree((x0, y0), (x0, y0), log2 - 1, depth + 1, 0, cbf_c)?;
|
||||
self.transform_tree((x0 + h, y0), (x0, y0), log2 - 1, depth + 1, 1, cbf_c)?;
|
||||
self.transform_tree((x0, y0 + h), (x0, y0), log2 - 1, depth + 1, 2, cbf_c)?;
|
||||
self.transform_tree((x0 + h, y0 + h), (x0, y0), log2 - 1, depth + 1, 3, cbf_c)?;
|
||||
self.transform_tree(x0, y0, x0, y0, log2 - 1, depth + 1, 0, cbf_c)?;
|
||||
self.transform_tree(x0 + h, y0, x0, y0, log2 - 1, depth + 1, 1, cbf_c)?;
|
||||
self.transform_tree(x0, y0 + h, x0, y0, log2 - 1, depth + 1, 2, cbf_c)?;
|
||||
self.transform_tree(x0 + h, y0 + h, x0, y0, log2 - 1, depth + 1, 3, cbf_c)?;
|
||||
return Ok(());
|
||||
}
|
||||
let cbf_luma =
|
||||
@@ -937,18 +935,10 @@ impl<'a> SliceDecoder<'a> {
|
||||
if depth > 0 {
|
||||
self.mark_edges(x0, y0, n, n, E_TU_V, E_TU_H);
|
||||
}
|
||||
self.transform_unit((x0, y0), base, log2, blk_idx, cbf_luma, cbf_c)
|
||||
self.transform_unit(x0, y0, xb, yb, log2, blk_idx, cbf_luma, cbf_c)
|
||||
}
|
||||
|
||||
fn transform_unit(
|
||||
&mut self,
|
||||
(x0, y0): (i32, i32),
|
||||
(xb, yb): (i32, i32),
|
||||
log2: u32,
|
||||
blk_idx: u32,
|
||||
cbf_luma: bool,
|
||||
cbf_c: [bool; 2],
|
||||
) -> Result<()> {
|
||||
fn transform_unit(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, blk_idx: u32, cbf_luma: bool, cbf_c: [bool; 2]) -> Result<()> {
|
||||
let n = 1i32 << log2;
|
||||
let chroma_here = log2 > 2 || blk_idx == 3;
|
||||
let cbf_chroma = cbf_c[0] || cbf_c[1];
|
||||
@@ -1274,7 +1264,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
max_y = max_y.max(yc);
|
||||
}
|
||||
}
|
||||
self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y))
|
||||
self.reconstruct(x0, y0, log2, c, qp, ts, max_x, max_y)
|
||||
}
|
||||
|
||||
fn coeff_abs_level_remaining(&mut self, rice: u32) -> Result<i32> {
|
||||
@@ -1293,7 +1283,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
|
||||
/// Scaling, transform and addition to the prediction.
|
||||
fn reconstruct(&mut self, (x0, y0): (i32, i32), log2: u32, c: usize, qp: i32, ts: bool, (max_x, max_y): (usize, usize)) -> Result<()> {
|
||||
fn reconstruct(&mut self, x0: i32, y0: i32, log2: u32, c: usize, qp: i32, ts: bool, max_x: usize, max_y: usize) -> Result<()> {
|
||||
let n = 1usize << log2;
|
||||
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
|
||||
let coeffs = &mut self.coeffs[..n * n];
|
||||
@@ -1345,8 +1335,19 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
|
||||
/// prediction_unit(): parse, derive motion, store it and predict. Returns merge_flag.
|
||||
fn prediction_unit(&mut self, g: PbGeom, part: PartMode, skip: bool) -> Result<bool> {
|
||||
let PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx } = g;
|
||||
fn prediction_unit(
|
||||
&mut self,
|
||||
xc: i32,
|
||||
yc: i32,
|
||||
ncb: i32,
|
||||
xp: i32,
|
||||
yp: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
part_idx: usize,
|
||||
part: PartMode,
|
||||
skip: bool,
|
||||
) -> Result<bool> {
|
||||
let merge = if skip { true } else { self.dd(MERGE_FLAG) == 1 };
|
||||
let mvf = if merge {
|
||||
let idx = if self.sh.max_num_merge_cand > 1 {
|
||||
@@ -1361,7 +1362,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.derive_merge(PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx }, part, idx as usize)
|
||||
self.derive_merge(xc, yc, ncb, xp, yp, w, h, part_idx, part, idx as usize)
|
||||
} else {
|
||||
let pred_idc = if self.sh.is_b() {
|
||||
if w + h != 12 {
|
||||
@@ -1405,7 +1406,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
if ref_idx[l] < 0 {
|
||||
continue;
|
||||
}
|
||||
let mvp = self.derive_amvp(PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx }, l, ref_idx[l] as usize, mvp_flag[l] as usize);
|
||||
let mvp = self.derive_amvp(xc, yc, ncb, xp, yp, w, h, part_idx, l, ref_idx[l] as usize, mvp_flag[l] as usize);
|
||||
// mvLX = (mvp + mvd) wrapped to 16 bits (8-94)
|
||||
f.mv[l] = [(mvp[0] as i32 + mvd[l][0]) as i16, (mvp[1] as i32 + mvd[l][1]) as i16];
|
||||
}
|
||||
@@ -1503,11 +1504,11 @@ impl<'a> SliceDecoder<'a> {
|
||||
if bi {
|
||||
let (w0, o0) = get(0);
|
||||
let (w1, o1) = get(1);
|
||||
inter::put_weighted_bi(&self.pred0, &self.pred1, (bw, bh), bd, (log2wd, w0, w1, o0, o1), dst, stride);
|
||||
inter::put_weighted_bi(&self.pred0, &self.pred1, bw, bh, bd, log2wd, w0, w1, o0, o1, dst, stride);
|
||||
} else {
|
||||
let l = if lists[0] { 0 } else { 1 };
|
||||
let (wt, o) = get(l);
|
||||
inter::put_weighted_uni(&self.pred0, (bw, bh), bd, (log2wd, wt, o), dst, stride);
|
||||
inter::put_weighted_uni(&self.pred0, bw, bh, bd, log2wd, wt, o, dst, stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
|
||||
@@ -390,6 +390,7 @@ impl Canvas {
|
||||
return;
|
||||
}
|
||||
raw.set_region(self.clip.clone());
|
||||
let region_cost = self.clip.as_ref().map_or(0, |c| c.len() * std::mem::size_of::<[f64; 4]>());
|
||||
let cost = match &raw {
|
||||
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
|
||||
// A tile shared with its brush is counted once (by the brush); one made for this fill
|
||||
@@ -400,7 +401,7 @@ impl Canvas {
|
||||
}
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
};
|
||||
} + region_cost;
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
|
||||
@@ -100,8 +100,14 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
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| {
|
||||
@@ -307,6 +313,10 @@ fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> {
|
||||
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.
|
||||
|
||||
@@ -222,6 +222,7 @@ pub(crate) struct Plus {
|
||||
/// Convex device clip polygon; None is unclipped.
|
||||
clip: Area,
|
||||
objects: Vec<Option<Obj>>,
|
||||
object_bytes: usize,
|
||||
stack: Vec<Saved>,
|
||||
partial: Option<Partial>,
|
||||
/// The picture frame in device units (`[x, y, w, h]`), what Clear paints.
|
||||
@@ -237,6 +238,7 @@ impl Plus {
|
||||
page: 1.0,
|
||||
clip: None,
|
||||
objects: vec![None; MAX_OBJECTS],
|
||||
object_bytes: 0,
|
||||
stack: Vec::new(),
|
||||
partial: None,
|
||||
frame: None,
|
||||
@@ -247,14 +249,16 @@ impl Plus {
|
||||
pub fn comment(&mut self, cv: &mut Canvas, data: Bytes) {
|
||||
let mut pos = 0usize;
|
||||
while !cv.full() {
|
||||
let (Some(typ), Some(flags), Some(size), Some(dsize)) = (data.u16(pos), data.u16(pos + 2), data.u32(pos + 4), data.u32(pos + 8)) else {
|
||||
let Some(tail) = data.0.get(pos..) else { break };
|
||||
let tail = Bytes(tail);
|
||||
let (Some(typ), Some(flags), Some(size), Some(dsize)) = (tail.u16(0), tail.u16(2), tail.u32(4), tail.u32(8)) else {
|
||||
break;
|
||||
};
|
||||
let (size, dsize) = (size as usize, dsize as usize);
|
||||
if size < 12 || dsize > size - 12 {
|
||||
break;
|
||||
}
|
||||
let Some(rec) = data.slice(pos + 12, dsize) else { break };
|
||||
let Some(rec) = tail.slice(12, dsize) else { break };
|
||||
let _ = self.record(cv, typ, flags, Bytes(rec));
|
||||
let Some(next) = pos.checked_add(size) else { break };
|
||||
pos = next;
|
||||
@@ -290,6 +294,9 @@ impl Plus {
|
||||
let fill = self.brush_of(flags, r.u32(0)?)?;
|
||||
let p = self.painter();
|
||||
for rc in rects(r, 8, r.u32(4)? as usize, compressed)? {
|
||||
if cv.full() {
|
||||
break;
|
||||
}
|
||||
p.fill(cv, &rect_ops(rc), &fill);
|
||||
}
|
||||
Some(())
|
||||
@@ -298,6 +305,9 @@ impl Plus {
|
||||
let pen = self.pen(id)?;
|
||||
let p = self.painter();
|
||||
for rc in rects(r, 4, r.u32(0)? as usize, compressed)? {
|
||||
if cv.full() {
|
||||
break;
|
||||
}
|
||||
p.stroke(cv, &rect_ops(rc), pen);
|
||||
}
|
||||
Some(())
|
||||
@@ -550,8 +560,28 @@ impl Plus {
|
||||
}
|
||||
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>) {
|
||||
if let Some(slot) = self.objects.get_mut(id) {
|
||||
*slot = obj;
|
||||
let Some(slot) = self.objects.get_mut(id) else { return };
|
||||
if let Some(old) = slot.take() {
|
||||
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
|
||||
}
|
||||
if let Some(new_obj) = obj {
|
||||
let cost = Self::obj_cost(&new_obj);
|
||||
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
|
||||
self.object_bytes += cost;
|
||||
*slot = Some(new_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obj_cost(obj: &Obj) -> usize {
|
||||
match obj {
|
||||
Obj::Image(img) => match img.as_ref() {
|
||||
ImageData::Bitmap(rgba) => rgba.px.len(),
|
||||
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
|
||||
},
|
||||
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
|
||||
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1282,11 +1312,18 @@ fn decode_compressed(bytes: &[u8]) -> Option<Rgba> {
|
||||
limits.max_image_height = Some(1 << 15);
|
||||
limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
if w.checked_mul(h)? > crate::MAX_PIXELS {
|
||||
let (w, h) = reader.into_dimensions().ok()?;
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(1 << 15);
|
||||
limits.max_image_height = Some(1 << 15);
|
||||
limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_rgba8();
|
||||
Some(Rgba { w, h, bottom_up: false, px: img.into_raw() })
|
||||
}
|
||||
|
||||
@@ -1406,31 +1443,61 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
|
||||
if em <= 0.0 || !width.is_finite() {
|
||||
return vec![line.to_vec()];
|
||||
}
|
||||
let fits = |l: &[u16]| l.iter().map(|u| char_em(*u) * em).sum::<f64>() <= width;
|
||||
let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
let measure = |v: &[u16]| v.iter().map(|u| char_em(*u) * em).sum::<f64>();
|
||||
|
||||
let mut out: Vec<Vec<u16>> = Vec::new();
|
||||
let mut cur: Vec<u16> = Vec::new();
|
||||
let mut cur_total_w = 0.0;
|
||||
|
||||
for word in line.split_inclusive(|u| *u == 0x20) {
|
||||
let mut cand = cur.clone();
|
||||
cand.extend_from_slice(word);
|
||||
let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
if fits(&cand[..trimmed(&cand)]) || cur.is_empty() && fits(&word[..trimmed(word)]) {
|
||||
cur = cand;
|
||||
let n_non_space = trimmed(word);
|
||||
let word_non_space_w = measure(&word[..n_non_space]);
|
||||
let word_total_w = measure(word);
|
||||
|
||||
if cur.is_empty() {
|
||||
if word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w = word_total_w;
|
||||
continue;
|
||||
}
|
||||
} else if cur_total_w + word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w += word_total_w;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !cur.is_empty() {
|
||||
let n = trimmed(&cur);
|
||||
cur.truncate(n);
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
|
||||
if word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w = word_total_w;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A word wider than the line on its own: break it between characters.
|
||||
let mut cur_w = 0.0;
|
||||
for &u in word {
|
||||
cur.push(u);
|
||||
if !fits(&cur[..trimmed(&cur)]) && cur.len() > 1 {
|
||||
cur.pop();
|
||||
let u_w = char_em(u) * em;
|
||||
if cur.is_empty() {
|
||||
cur.push(u);
|
||||
cur_w = u_w;
|
||||
} else if cur_w + u_w <= width {
|
||||
cur.push(u);
|
||||
cur_w += u_w;
|
||||
} else {
|
||||
let n = trimmed(&cur);
|
||||
cur.truncate(n);
|
||||
out.push(std::mem::take(&mut cur));
|
||||
cur.push(u);
|
||||
cur_w = u_w;
|
||||
}
|
||||
}
|
||||
cur_total_w = cur_w;
|
||||
}
|
||||
let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
cur.truncate(n);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! 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};
|
||||
@@ -11,20 +13,22 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000;
|
||||
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<BezPath> },
|
||||
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<BezPath> },
|
||||
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<BezPath> },
|
||||
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<BezPath> },
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<Arc<BezPath>> },
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
@@ -53,42 +57,65 @@ impl Picture {
|
||||
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 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);
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd, region } => {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = placed_region(&tf, region) else { continue };
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
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) = placed_region(&tf, region) else { continue };
|
||||
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 {
|
||||
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) = placed_region(&tf, &t.region) else { continue };
|
||||
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()) && clip.is_none_or(in_range) {
|
||||
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) = placed_region(&tf, region) else { continue };
|
||||
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 });
|
||||
}
|
||||
@@ -113,7 +140,7 @@ impl Item {
|
||||
|
||||
/// 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<BezPath>> {
|
||||
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 {
|
||||
@@ -122,7 +149,7 @@ fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<B
|
||||
path.extend(r.to_path(0.1));
|
||||
}
|
||||
}
|
||||
(!path.is_empty()).then_some(Some(path))
|
||||
(!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.
|
||||
|
||||
@@ -2022,3 +2022,27 @@ fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() {
|
||||
// No frame: nothing to clear.
|
||||
assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_wrap_line_linear_performance() {
|
||||
let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0));
|
||||
// Repeated words that wrap across lines
|
||||
let text = "word ".repeat(500);
|
||||
let s = prec(
|
||||
0x401C,
|
||||
0x8000,
|
||||
&cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 100.0, 1000.0]), u16s(&text)]),
|
||||
);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 1000.0])]));
|
||||
let p = plus_pic(vec![fill, font, s]);
|
||||
let text_items = p.items.iter().filter(|i| matches!(i, Item::Text(_))).count();
|
||||
assert!(text_items > 10, "wrapped into multiple lines: {text_items}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_work_bound() {
|
||||
let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
@@ -248,6 +248,46 @@ pub enum TextBuild {
|
||||
ByLetter,
|
||||
}
|
||||
|
||||
/// Most paragraphs a by-paragraph build animates separately; later ones follow the last of them.
|
||||
pub const MAX_BUILD_PARAGRAPHS: usize = 1_000;
|
||||
|
||||
/// What a paragraph does in a by-paragraph build.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BuildRole {
|
||||
/// Animated on its own: a filled paragraph at the shallowest filled level.
|
||||
Top,
|
||||
/// Follows the top paragraph with this index (deeper, empty or blank paragraphs after it).
|
||||
Member(usize),
|
||||
/// Before the first top paragraph: not animated.
|
||||
Unowned,
|
||||
}
|
||||
|
||||
/// Does the paragraph show any text? A by-paragraph build doesn't animate blank ones on their own.
|
||||
pub fn build_filled(p: &crate::text::Paragraph) -> bool {
|
||||
p.runs.iter().any(|r| !r.text.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The role of every paragraph of `text` in a by-paragraph build, by paragraph index: what the
|
||||
/// timeline plays, and what the PPTX reader and writer map to one file effect per paragraph.
|
||||
pub fn paragraph_build_roles(text: &crate::text::TextBody) -> Vec<BuildRole> {
|
||||
let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut tops = 0;
|
||||
let mut owner: Option<usize> = None;
|
||||
text.paragraphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS {
|
||||
tops += 1;
|
||||
owner = Some(i);
|
||||
BuildRole::Top
|
||||
} else {
|
||||
owner.map_or(BuildRole::Unowned, BuildRole::Member)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Animation {
|
||||
@@ -606,3 +646,19 @@ mod option_label_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_role_tests {
|
||||
use super::*;
|
||||
use crate::text::TextBody;
|
||||
|
||||
#[test]
|
||||
fn blank_and_deeper_paragraphs_follow_the_top_one_before_them() {
|
||||
let mut t = TextBody::from_text("Before\nLead\n \nSub\nNext");
|
||||
for (p, lvl) in t.paragraphs.iter_mut().zip([1, 0, 0, 1, 0]) {
|
||||
p.level = lvl;
|
||||
}
|
||||
use BuildRole::*;
|
||||
assert_eq!(paragraph_build_roles(&t), vec![Unowned, Top, Member(1), Member(1), Top]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +732,12 @@ impl Chart {
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an
|
||||
/// explicit `false` hides them even when the chart-wide setting is on (#868).
|
||||
pub fn series_data_labels(&self, s: &Series) -> bool {
|
||||
s.data_labels.unwrap_or(self.data_labels)
|
||||
}
|
||||
|
||||
fn waterfall_value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
@@ -819,7 +825,7 @@ pub fn axis_scale(
|
||||
// Office's automatic limits ("How chart axis limits are determined", as Jon Peltier
|
||||
// summarises it): 5 % of the data range past the data, and zero when the values don't
|
||||
// sit within the top sixth of their largest magnitude. A single value is measured from zero.
|
||||
let flat = hi - lo < 1e-12;
|
||||
let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs());
|
||||
let pad = if !headroom {
|
||||
0.0
|
||||
} else if flat {
|
||||
@@ -831,12 +837,15 @@ pub fn axis_scale(
|
||||
let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad };
|
||||
let mut lo = fmin.unwrap_or(auto_lo);
|
||||
let mut hi = fmax.unwrap_or(auto_hi);
|
||||
if hi - lo < 1e-12 {
|
||||
let mag = lo.abs().max(hi.abs());
|
||||
// Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1.
|
||||
let unit = if mag == 0.0 { 1.0 } else { mag };
|
||||
if hi - lo <= 1e-12 * mag {
|
||||
// A fixed limit on the far side of the data: the automatic end gives way.
|
||||
match (fmin, fmax) {
|
||||
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) },
|
||||
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) },
|
||||
(Some(_), None) if lo < 0.0 => hi = 0.0,
|
||||
_ => hi = lo + lo.abs().max(1.0),
|
||||
_ => hi = lo + unit,
|
||||
}
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
@@ -844,6 +853,10 @@ pub fn axis_scale(
|
||||
Some(m) => m,
|
||||
None => {
|
||||
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
|
||||
// Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit.
|
||||
if !(step > 0.0 && step.is_finite()) {
|
||||
step = unit;
|
||||
}
|
||||
let mut k = 0;
|
||||
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
|
||||
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
|
||||
@@ -861,7 +874,9 @@ pub fn axis_scale(
|
||||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||||
let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo };
|
||||
let data_mag = lo.abs().max(hi.abs());
|
||||
let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo };
|
||||
let span = if span == 0.0 { 1.0 } else { span };
|
||||
let raw = span / 5.0;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let norm = raw / mag;
|
||||
@@ -876,9 +891,11 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
} else {
|
||||
10.0
|
||||
} * mag;
|
||||
// Subnormal spans underflow to a zero or NaN step: use the span itself.
|
||||
let step = if step > 0.0 && step.is_finite() { step } else { span };
|
||||
let min = (lo / step).floor() * step;
|
||||
let max = (hi / step).ceil() * step;
|
||||
let max = if (max - min).abs() < 1e-12 { min + step } else { max };
|
||||
let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max };
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
@@ -886,6 +903,25 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tiny_data_keeps_its_own_scale() {
|
||||
let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true);
|
||||
assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}");
|
||||
assert!(step > 0.0 && step < 1e-11);
|
||||
let (min, max, step) = nice_scale(1e-13, 5e-13);
|
||||
assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}");
|
||||
assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnormal_data_gives_a_finite_scale() {
|
||||
let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true);
|
||||
assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}");
|
||||
assert!(step > 0.0 && max >= min, "{min} {max} {step}");
|
||||
let (min, max, step) = nice_scale(0.0, f64::from_bits(1));
|
||||
assert!(min.is_finite() && max.is_finite() && step > 0.0 && step.is_finite() && max > min, "{min} {max} {step}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_axes_run_over_whole_powers() {
|
||||
let mut c = Chart::sample(ChartType::Line);
|
||||
@@ -897,6 +933,20 @@ mod tests {
|
||||
assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0)));
|
||||
}
|
||||
|
||||
/// #868: a series' own label setting wins; an unset series takes the chart-wide one.
|
||||
#[test]
|
||||
fn series_data_labels_override_the_chart_wide_setting() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.data_labels = false;
|
||||
let mut s = c.series[0].clone();
|
||||
assert!(!c.series_data_labels(&s), "unset series follows the chart");
|
||||
s.data_labels = Some(true);
|
||||
assert!(c.series_data_labels(&s), "a series that asks for labels gets them");
|
||||
c.data_labels = true;
|
||||
s.data_labels = Some(false);
|
||||
assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_bar_ranges() {
|
||||
let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)];
|
||||
|
||||
+28
-11
@@ -39,6 +39,7 @@ pub const RT_CORE: &str = "http://schemas.openxmlformats.org/package/2006/relati
|
||||
/// The files of a package, keyed by normalised part name (no leading slash, lowercase).
|
||||
pub struct Package {
|
||||
parts: HashMap<String, (String, Vec<u8>)>,
|
||||
ct_cache: std::sync::OnceLock<(HashMap<String, String>, HashMap<String, String>)>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -94,7 +95,7 @@ impl Package {
|
||||
total = total.saturating_add(data.len() as u64);
|
||||
parts.insert(name.to_ascii_lowercase(), (name, data));
|
||||
}
|
||||
Ok(Package { parts })
|
||||
Ok(Package { parts, ct_cache: std::sync::OnceLock::new() })
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
self.parts.get(&norm(name)).map(|(_, d)| d.as_slice())
|
||||
@@ -131,18 +132,29 @@ impl Package {
|
||||
}
|
||||
/// Content types: (extension defaults, part overrides).
|
||||
pub fn content_type(&self, part: &str) -> Option<String> {
|
||||
let doc = self.xml("[Content_Types].xml")?;
|
||||
let p = format!("/{}", norm(part));
|
||||
for o in doc.root.children_named("Override") {
|
||||
if o.attr("PartName").is_some_and(|n| n.to_ascii_lowercase() == p) {
|
||||
return o.attr("ContentType").map(String::from);
|
||||
let (overrides, defaults) = self.ct_cache.get_or_init(|| {
|
||||
let mut ov = HashMap::new();
|
||||
let mut def = HashMap::new();
|
||||
if let Some(doc) = self.xml("[Content_Types].xml") {
|
||||
for o in doc.root.children_named("Override") {
|
||||
if let (Some(n), Some(ct)) = (o.attr("PartName"), o.attr("ContentType")) {
|
||||
ov.insert(n.to_ascii_lowercase(), ct.to_string());
|
||||
}
|
||||
}
|
||||
for d in doc.root.children_named("Default") {
|
||||
if let (Some(e), Some(ct)) = (d.attr("Extension"), d.attr("ContentType")) {
|
||||
def.insert(e.to_ascii_lowercase(), ct.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(ov, def)
|
||||
});
|
||||
let p = format!("/{}", norm(part));
|
||||
if let Some(ct) = overrides.get(&p) {
|
||||
return Some(ct.clone());
|
||||
}
|
||||
let ext = part.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
doc.root
|
||||
.children_named("Default")
|
||||
.find(|d| d.attr("Extension").is_some_and(|e| e.eq_ignore_ascii_case(&ext)))
|
||||
.and_then(|d| d.attr("ContentType").map(String::from))
|
||||
defaults.get(&ext).cloned()
|
||||
}
|
||||
pub fn original_name(&self, name: &str) -> Option<&str> {
|
||||
self.parts.get(&norm(name)).map(|(n, _)| n.as_str())
|
||||
@@ -387,7 +399,12 @@ impl PackageOut {
|
||||
if rels.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.files.push((rels_path(part), rels.to_xml(), true));
|
||||
let path = rels_path(part);
|
||||
if let Some(existing) = self.files.iter_mut().find(|(name, ..)| *name == path) {
|
||||
existing.1 = rels.to_xml();
|
||||
} else {
|
||||
self.files.push((path, rels.to_xml(), true));
|
||||
}
|
||||
}
|
||||
/// Add a part with no content type of its own (a relationships part).
|
||||
pub fn raw(&mut self, name: &str, data: Vec<u8>) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use deckcraft_model::style::Fill;
|
||||
|
||||
use crate::xml::El;
|
||||
|
||||
/// Fills of chart parts never reference images we keep; read colour fills only.
|
||||
/// Fills of chart parts never reference images we keep; read colour and pattern fills only.
|
||||
fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
||||
let sp = sp?;
|
||||
for e in sp.elements() {
|
||||
@@ -13,6 +13,7 @@ fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
||||
"noFill" => return Some(Fill::None),
|
||||
"solidFill" => return super::dml::color(e).map(|color| Fill::Solid { color }),
|
||||
"gradFill" => return Some(Fill::Gradient(super::dml::gradient(e))),
|
||||
"pattFill" => return Some(Fill::Pattern(super::dml::pattern(e))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -126,23 +127,21 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
let mut kinds: Vec<ChartType> = vec![];
|
||||
// The value axis the first plot group uses is the primary one; a group on another value axis
|
||||
// (a combo chart's line on the right) is on the secondary axis.
|
||||
let val_ids: Vec<(&str, bool)> =
|
||||
let val_map: std::collections::HashMap<&str, bool> =
|
||||
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
||||
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
||||
let group_val_axis = |g: &El| {
|
||||
let ids: Vec<(&str, bool)> =
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect();
|
||||
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
||||
};
|
||||
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
||||
// sits on the right or top, or crosses the category axis at its maximum.
|
||||
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
||||
let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect();
|
||||
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
||||
let primary_val: Option<String> = used
|
||||
.iter()
|
||||
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
||||
.or(used.first())
|
||||
.cloned();
|
||||
let primary_val: Option<String> =
|
||||
used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned();
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
@@ -213,7 +212,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::<f64>().ok()) {
|
||||
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
|
||||
}
|
||||
if g.path(&["dLbls", "showVal"]).and_then(|v| v.bool("val")) == Some(true) {
|
||||
// `<c:showVal/>` without a val attribute means true, as in the schema's default.
|
||||
// The setting belongs to this plot group: a group that asks for no labels keeps them
|
||||
// off, even when another group of a combo chart asks for them (#868).
|
||||
let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true));
|
||||
if group_labels == Some(true) {
|
||||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
@@ -230,8 +233,12 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
// `<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)) {
|
||||
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.
|
||||
ser.data_labels = Some(false);
|
||||
}
|
||||
for t in s.children_named("trendline").take(16) {
|
||||
if let Some(t) = trendline(t) {
|
||||
@@ -410,15 +417,21 @@ fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
|
||||
|
||||
const MAX_KEPT_PARTS: usize = 256;
|
||||
const MAX_KEPT_DEPTH: usize = 8;
|
||||
pub const MAX_KEPT_TOTAL_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Relationships and parts a chart points at, so a later save can write them back.
|
||||
pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckcraft_model::chart::ChartKept {
|
||||
pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_model::chart::ChartKept {
|
||||
use deckcraft_model::chart::{ChartKept, ChartRel};
|
||||
|
||||
let key = crate::opc::norm(chart_part);
|
||||
if let Some(cached) = imp.kept_charts.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let mut kept = ChartKept::default();
|
||||
// The chart document itself is rewritten; don't store a second copy of it.
|
||||
let mut seen = std::collections::HashMap::from([(crate::opc::norm(chart_part), true)]);
|
||||
for r in pkg.rels(chart_part).items {
|
||||
let mut seen = std::collections::HashMap::from([(key.clone(), true)]);
|
||||
for r in imp.pkg.rels(chart_part).items {
|
||||
if !safe_rel(&r.id, &r.ty, &r.target_raw) {
|
||||
log::warn!("pptx: skipping chart relationship {}", r.id);
|
||||
continue;
|
||||
@@ -427,10 +440,11 @@ pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckc
|
||||
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: String::new(), external: true });
|
||||
continue;
|
||||
}
|
||||
if copy_part(pkg, &r.target, &mut kept.parts, &mut seen, 0) {
|
||||
if copy_part(imp.pkg, &r.target, &mut kept.parts, &mut seen, &mut imp.kept_sidecar_bytes, 0) {
|
||||
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false });
|
||||
}
|
||||
}
|
||||
imp.kept_charts.insert(key, kept.clone());
|
||||
kept
|
||||
}
|
||||
|
||||
@@ -444,6 +458,7 @@ fn copy_part(
|
||||
part: &str,
|
||||
out: &mut Vec<deckcraft_model::chart::ChartPart>,
|
||||
seen: &mut std::collections::HashMap<String, bool>,
|
||||
retained_bytes: &mut usize,
|
||||
depth: usize,
|
||||
) -> bool {
|
||||
use deckcraft_model::chart::ChartPart;
|
||||
@@ -473,7 +488,14 @@ fn copy_part(
|
||||
return false;
|
||||
}
|
||||
let content_type = pkg.content_type(part).unwrap_or_default();
|
||||
out.push(ChartPart { name, content_type, bytes: bytes.to_vec(), native_entry: None });
|
||||
let stored_bytes = if *retained_bytes + bytes.len() <= MAX_KEPT_TOTAL_BYTES {
|
||||
*retained_bytes += bytes.len();
|
||||
bytes.to_vec()
|
||||
} else {
|
||||
log::warn!("pptx: chart sidecar retained byte budget exceeded for {part}");
|
||||
Vec::new()
|
||||
};
|
||||
out.push(ChartPart { name, content_type, bytes: stored_bytes, native_entry: None });
|
||||
seen.insert(key, true);
|
||||
let rels_name = crate::opc::rels_path(part);
|
||||
if crate::opc::sidecar_part(&rels_name)
|
||||
@@ -482,15 +504,21 @@ fn copy_part(
|
||||
{
|
||||
seen.insert(crate::opc::norm(&rels_name), true);
|
||||
let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string();
|
||||
let stored_rels = if *retained_bytes + rels_bytes.len() <= MAX_KEPT_TOTAL_BYTES {
|
||||
*retained_bytes += rels_bytes.len();
|
||||
rels_bytes.to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.push(ChartPart {
|
||||
name,
|
||||
content_type: "application/vnd.openxmlformats-package.relationships+xml".into(),
|
||||
bytes: rels_bytes.to_vec(),
|
||||
bytes: stored_rels,
|
||||
native_entry: None,
|
||||
});
|
||||
let targets: Vec<String> = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect();
|
||||
for target in targets {
|
||||
copy_part(pkg, &target, out, seen, depth + 1);
|
||||
copy_part(pkg, &target, out, seen, retained_bytes, depth + 1);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -557,6 +585,29 @@ mod tests {
|
||||
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_pattern_fills_are_read_and_written_back() {
|
||||
// A pattern-filled series and an empty one with an outline, as PowerPoint writes them.
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:spPr><a:pattFill prst="openDmnd"><a:fgClr><a:schemeClr val="accent4"/></a:fgClr><a:bgClr><a:schemeClr val="bg1"/></a:bgClr></a:pattFill></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser><c:ser><c:idx val="1"/><c:spPr><a:noFill/><a:ln w="19050"><a:solidFill><a:schemeClr val="accent4"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>3</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
let accent4 = deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent4);
|
||||
let pattern = deckcraft_model::style::PatternFill {
|
||||
preset: "openDmnd".into(),
|
||||
fg: accent4.clone(),
|
||||
bg: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1),
|
||||
};
|
||||
assert_eq!(c.series[0].fill, Some(Fill::Pattern(pattern.clone())));
|
||||
assert_eq!(c.series[1].fill, Some(Fill::None));
|
||||
assert_eq!(c.series[1].line.as_ref().and_then(|l| l.fill.clone()), Some(Fill::solid(accent4)));
|
||||
// Written out from the model (an edited chart), the pattern survives.
|
||||
let mut edited = c.clone();
|
||||
edited.raw = None;
|
||||
edited.title = Some("Edited".into());
|
||||
let back = read_chart_xml(&crate::write::chart::chart_xml(&edited).0).unwrap();
|
||||
assert_eq!(back.series[0].fill, Some(Fill::Pattern(pattern)));
|
||||
assert_eq!(back.series[1].fill, Some(Fill::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_base_is_read() {
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
||||
|
||||
/// Points per level and levels per dimension we keep (guards against hostile parts).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
const MAX_CHARTEX_POINTS: usize = 1_000_000;
|
||||
|
||||
/// One `cx:data`: its categories (the innermost level) and named numeric dimensions.
|
||||
#[derive(Default)]
|
||||
@@ -20,38 +21,52 @@ struct Data {
|
||||
}
|
||||
|
||||
/// A level's points, by `idx`.
|
||||
fn points(lvl: &El) -> Vec<String> {
|
||||
let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize;
|
||||
let mut out = vec![String::new(); n];
|
||||
for pt in lvl.children_named("pt").take(MAX_POINTS) {
|
||||
fn points(lvl: &El, budget: &mut usize) -> Vec<String> {
|
||||
if *budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cap = MAX_POINTS.min(*budget);
|
||||
let mut pts = Vec::new();
|
||||
let mut max_idx = 0;
|
||||
for pt in lvl.children_named("pt").take(cap) {
|
||||
let i = pt.u32("idx").unwrap_or(0) as usize;
|
||||
if i >= MAX_POINTS {
|
||||
if i >= cap {
|
||||
continue;
|
||||
}
|
||||
if i >= out.len() {
|
||||
out.resize(i + 1, String::new());
|
||||
}
|
||||
if let Some(s) = out.get_mut(i) {
|
||||
*s = pt.text();
|
||||
max_idx = max_idx.max(i + 1);
|
||||
pts.push((i, pt.text()));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = max_idx.min(cap);
|
||||
let mut out = vec![String::new(); n];
|
||||
for (i, s) in pts {
|
||||
if let Some(target) = out.get_mut(i) {
|
||||
*target = s;
|
||||
}
|
||||
}
|
||||
*budget = budget.saturating_sub(out.len());
|
||||
out
|
||||
}
|
||||
|
||||
fn data(d: &El) -> Data {
|
||||
fn data(d: &El, budget: &mut usize) -> Data {
|
||||
let mut out = Data::default();
|
||||
for dim in d.elements() {
|
||||
for dim in d.elements().take(64) {
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
// The first level is the innermost: a treemap's leaves, a waterfall's categories.
|
||||
let Some(lvl) = dim.child("lvl") else { continue };
|
||||
let ty = dim.attr("type").unwrap_or("");
|
||||
if dim.is("strDim") || (dim.is("numDim") && ty == "cat") {
|
||||
if ty == "cat" || out.categories.is_empty() {
|
||||
out.categories = points(lvl);
|
||||
out.categories = points(lvl, budget);
|
||||
// Further levels are the hierarchy's parents, innermost first.
|
||||
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect();
|
||||
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(|l| points(l, budget)).collect();
|
||||
}
|
||||
} else if dim.is("numDim") {
|
||||
let vals = points(lvl).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
let vals = points(lvl, budget).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string)));
|
||||
}
|
||||
}
|
||||
@@ -85,15 +100,22 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
if !root.is("chartSpace") {
|
||||
return None;
|
||||
}
|
||||
let datas: HashMap<String, Data> =
|
||||
root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect();
|
||||
let mut decode_budget = MAX_CHARTEX_POINTS;
|
||||
let datas: HashMap<String, Data> = root
|
||||
.child("chartData")?
|
||||
.children_named("data")
|
||||
.take(64)
|
||||
.map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d, &mut decode_budget)))
|
||||
.collect();
|
||||
let ch = root.child("chart")?;
|
||||
let region = ch.child("plotArea")?.child("plotAreaRegion")?;
|
||||
let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() };
|
||||
c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into()));
|
||||
c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string());
|
||||
let mut kind = None;
|
||||
for s in region.children_named("series") {
|
||||
let mut output_budget = MAX_CHARTEX_POINTS;
|
||||
let mut categories_set = false;
|
||||
for s in region.children_named("series").take(1000) {
|
||||
let layout = s.attr("layoutId").unwrap_or("");
|
||||
if layout == "paretoLine" {
|
||||
// The cumulative line PowerPoint adds to a Pareto histogram.
|
||||
@@ -109,11 +131,23 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
_ => k,
|
||||
});
|
||||
let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?;
|
||||
let (values, format) =
|
||||
["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
|
||||
if c.categories.is_empty() {
|
||||
let (values_src, format) = ["val", "size", "colorVal"]
|
||||
.iter()
|
||||
.find_map(|t| d.numbers.get(*t))
|
||||
.or_else(|| d.numbers.values().next())
|
||||
.map(|(v, f)| (v.as_slice(), f.clone()))
|
||||
.unwrap_or((&[], None));
|
||||
let take_len = values_src.len().min(output_budget);
|
||||
let values = values_src[..take_len].to_vec();
|
||||
output_budget = output_budget.saturating_sub(values.len());
|
||||
if !categories_set && (!d.categories.is_empty() || !d.levels.is_empty()) {
|
||||
categories_set = true;
|
||||
c.categories = d.categories.clone();
|
||||
c.category_levels = d.levels.clone();
|
||||
output_budget = output_budget.saturating_sub(c.categories.len());
|
||||
for lvl in &c.category_levels {
|
||||
output_budget = output_budget.saturating_sub(lvl.len());
|
||||
}
|
||||
}
|
||||
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) {
|
||||
c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive");
|
||||
|
||||
+46
-22
@@ -86,16 +86,21 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
"pattFill" => Fill::Pattern(PatternFill {
|
||||
preset: f.attr("prst").unwrap_or("pct5").to_string(),
|
||||
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
|
||||
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
|
||||
}),
|
||||
"pattFill" => Fill::Pattern(pattern(f)),
|
||||
"grpFill" => Fill::Group,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// An `a:pattFill`: its preset (5 % dots when unset), foreground (black) and background (white).
|
||||
pub fn pattern(f: &El) -> PatternFill {
|
||||
PatternFill {
|
||||
preset: f.attr("prst").unwrap_or("pct5").to_string(),
|
||||
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
|
||||
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gradient(f: &El) -> Gradient {
|
||||
let mut stops: Vec<GradientStop> = f
|
||||
.child("gsLst")
|
||||
@@ -493,6 +498,11 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> {
|
||||
})
|
||||
}
|
||||
|
||||
fn typeface(e: &El) -> Option<String> {
|
||||
let t = e.attr("typeface").filter(|t| !t.is_empty())?;
|
||||
Some(t.chars().take(256).collect())
|
||||
}
|
||||
|
||||
pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
let mut p = RunProps {
|
||||
lang: r.attr("lang").map(String::from),
|
||||
@@ -530,10 +540,10 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
}
|
||||
"highlight" => p.highlight = color(e),
|
||||
"uFill" => p.underline_color = e.child("solidFill").and_then(color),
|
||||
"latin" => p.font = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"ea" => p.font_ea = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"cs" => p.font_cs = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"sym" => p.font_sym = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"latin" => p.font = typeface(e),
|
||||
"ea" => p.font_ea = typeface(e),
|
||||
"cs" => p.font_cs = typeface(e),
|
||||
"sym" => p.font_sym = typeface(e),
|
||||
"hlinkClick" => p.link = hyperlink(imp, part, e),
|
||||
"hlinkMouseOver" => p.hover = hyperlink(imp, part, e),
|
||||
_ => {}
|
||||
@@ -564,6 +574,8 @@ pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
||||
TextBody { body, list_style, paragraphs }
|
||||
}
|
||||
|
||||
const MAX_PARAGRAPH_RUNS: usize = 10_000;
|
||||
|
||||
pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
|
||||
let mut para = Paragraph::default();
|
||||
for e in p.elements() {
|
||||
@@ -581,33 +593,45 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
|
||||
let text: String = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
// A vertical tab or line feed inside text is a line break, as it lays out and as export writes it.
|
||||
for (i, piece) in text.split(['\u{b}', '\n']).enumerate() {
|
||||
if para.runs.len() >= MAX_PARAGRAPH_RUNS {
|
||||
break;
|
||||
}
|
||||
if i > 0 {
|
||||
para.runs.push(Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
}
|
||||
if !piece.is_empty() {
|
||||
if para.runs.len() >= MAX_PARAGRAPH_RUNS {
|
||||
break;
|
||||
}
|
||||
para.runs.push(Run { text: piece.to_string(), props: props.clone(), kind: RunKind::Text });
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
|
||||
}
|
||||
}
|
||||
"fld" => {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
|
||||
}
|
||||
}
|
||||
"m" => {
|
||||
// Office Math (a14:m) — keep the OMML and its plain text.
|
||||
let mut text = String::new();
|
||||
let mut ts = vec![];
|
||||
e.find_all("t", &mut ts);
|
||||
for t in ts {
|
||||
text.push_str(&t.text());
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
// Office Math (a14:m) — keep the OMML and its plain text.
|
||||
let mut text = String::new();
|
||||
let mut ts = vec![];
|
||||
e.find_all("t", &mut ts);
|
||||
for t in ts {
|
||||
text.push_str(&t.text());
|
||||
}
|
||||
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
|
||||
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
|
||||
}
|
||||
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
|
||||
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
|
||||
}
|
||||
"endParaRPr" => para.end_props = rpr(imp, part, e),
|
||||
_ => {}
|
||||
|
||||
+44
-16
@@ -36,6 +36,10 @@ pub struct Imp<'a> {
|
||||
pub table_style_ids: Vec<String>,
|
||||
/// Per legacy VML drawing part: shape id → preview picture part (see `shapes::vml_preview`).
|
||||
pub vml_previews: HashMap<String, HashMap<String, String>>,
|
||||
/// Aggregate byte count of retained chart sidecars across the presentation.
|
||||
pub kept_sidecar_bytes: usize,
|
||||
/// Cached kept sidecars by normalized chart part name.
|
||||
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -97,29 +101,32 @@ impl Part {
|
||||
|
||||
/// Prefixes declared below the root but not on it, skipping any bound to different namespaces.
|
||||
fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> {
|
||||
fn rec(e: &El, out: &mut Vec<(String, String)>, clash: &mut HashSet<String>, depth: usize) {
|
||||
fn rec(e: &El, out: &mut Vec<(String, String)>, out_map: &mut HashMap<String, String>, clash: &mut HashSet<String>, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in e.attrs.iter().filter(|(k, _)| k.starts_with("xmlns:")) {
|
||||
match out.iter().find(|(o, _)| o == k).map(|(_, u)| u != v) {
|
||||
Some(true) => {
|
||||
if let Some(existing) = out_map.get(k) {
|
||||
if existing != v {
|
||||
clash.insert(k.clone());
|
||||
}
|
||||
Some(false) => {}
|
||||
None => out.push((k.clone(), v.clone())),
|
||||
} else if out.len() < 1000 {
|
||||
out_map.insert(k.clone(), v.clone());
|
||||
out.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
for c in e.elements() {
|
||||
rec(c, out, clash, depth + 1);
|
||||
rec(c, out, out_map, clash, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut out_map = HashMap::new();
|
||||
let mut clash = HashSet::new();
|
||||
let root_ns: HashSet<&str> = ns.iter().map(|(r, _)| r.as_str()).collect();
|
||||
for c in root.elements() {
|
||||
rec(c, &mut out, &mut clash, 1);
|
||||
rec(c, &mut out, &mut out_map, &mut clash, 1);
|
||||
}
|
||||
out.retain(|(k, _)| !clash.contains(k) && !ns.iter().any(|(r, _)| r == k));
|
||||
out.retain(|(k, _)| !clash.contains(k) && !root_ns.contains(k.as_str()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -380,6 +387,8 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
};
|
||||
let part = Part::new(&pkg, &theme_path, &doc);
|
||||
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
||||
@@ -409,9 +418,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
.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())
|
||||
.unwrap_or_default();
|
||||
let master_paths: Vec<String> =
|
||||
let mut master_paths: Vec<String> =
|
||||
if master_paths.is_empty() { pres_part.rels.all_of("slideMaster").map(|r| r.target.clone()).collect() } else { master_paths };
|
||||
let slide_entries: Vec<(u32, String)> = pres_el
|
||||
let mut slide_entries: Vec<(u32, String)> = pres_el
|
||||
.child("sldIdLst")
|
||||
.map(|l| {
|
||||
l.children_named("sldId")
|
||||
@@ -423,10 +432,21 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
|
||||
let mut docs: HashMap<String, Doc> = HashMap::new();
|
||||
let mut max_id = 0u32;
|
||||
let mut seen_masters = HashSet::new();
|
||||
master_paths.retain(|m| seen_masters.insert(opc::norm(m)));
|
||||
|
||||
let mut all_parts: Vec<String> = master_paths.clone();
|
||||
let mut seen_layouts = HashSet::new();
|
||||
for m in &master_paths {
|
||||
all_parts.extend(pkg.rels(m).all_of("slideLayout").map(|r| r.target.clone()));
|
||||
for r in pkg.rels(m).all_of("slideLayout") {
|
||||
let key = opc::norm(&r.target);
|
||||
if seen_layouts.insert(key) {
|
||||
all_parts.push(r.target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen_slides = HashSet::new();
|
||||
slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p)));
|
||||
all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone()));
|
||||
for p in &all_parts {
|
||||
let key = opc::norm(p);
|
||||
@@ -448,6 +468,8 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
|
||||
@@ -516,17 +538,23 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
|
||||
// Slides.
|
||||
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
||||
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
|
||||
for (file_id, path, id) in &slide_ids {
|
||||
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
||||
let part = Part::new(&pkg, path, doc);
|
||||
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
||||
let mut slide = read_slide(&mut imp, &part, doc, *id, layout);
|
||||
// Notes.
|
||||
if let Some(n) = part.rels.first_of("notesSlide")
|
||||
&& let Some(nd) = load(&pkg, &n.target)
|
||||
{
|
||||
let np = Part::new(&pkg, &n.target, &nd);
|
||||
slide.notes = read_notes(&mut imp, &np, &nd.root);
|
||||
if let Some(n) = part.rels.first_of("notesSlide") {
|
||||
let n_key = opc::norm(&n.target);
|
||||
if let Some(cached_notes) = notes_by_target.get(&n_key) {
|
||||
slide.notes = cached_notes.clone();
|
||||
} else if let Some(nd) = load(&pkg, &n.target) {
|
||||
let np = Part::new(&pkg, &n.target, &nd);
|
||||
let notes = read_notes(&mut imp, &np, &nd.root);
|
||||
notes_by_target.insert(n_key, notes.clone());
|
||||
slide.notes = notes;
|
||||
}
|
||||
}
|
||||
// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
||||
// writes both): the modern one wins, its flattened legacy copy is skipped.
|
||||
|
||||
@@ -430,7 +430,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
|
||||
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
|
||||
{
|
||||
chart.kept = super::chart::capture_kept(imp.pkg, &rel.target);
|
||||
chart.kept = super::chart::capture_kept(imp, &rel.target);
|
||||
s.kind = ShapeKind::Chart(Box::new(chart));
|
||||
return s;
|
||||
}
|
||||
@@ -516,6 +516,10 @@ fn vml_index(pkg: &crate::opc::Package, vml: &str) -> std::collections::HashMap<
|
||||
/// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get
|
||||
/// here, and those are drawn from their data.
|
||||
fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId> {
|
||||
let norm_target = crate::opc::norm(target);
|
||||
if let Some(&id) = imp.media_by_part.get(&norm_target) {
|
||||
return Some(id);
|
||||
}
|
||||
fn strip(e: &mut El, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
@@ -537,11 +541,15 @@ fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId>
|
||||
data: std::sync::Arc::new(xml.into_bytes()),
|
||||
link: None,
|
||||
});
|
||||
imp.media_by_part.insert(norm_target, id);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
let dm = part.rels.get(rel_ids.attr("r:dm").unwrap_or(""))?;
|
||||
let data = imp.pkg.xml(&dm.target)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Slide transitions (`p:transition`) and animations (`p:timing`).
|
||||
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, TextBody, find_shape};
|
||||
|
||||
use super::shapes::IdCtx;
|
||||
use super::{Imp, Part, dml};
|
||||
@@ -95,7 +95,7 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// Flatten the timing tree into the Animation Pane list.
|
||||
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: &[Shape]) -> Vec<Animation> {
|
||||
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec<Animation> {
|
||||
let _ = (imp, part);
|
||||
let Some(tn) = timing.child("tnLst") else { return vec![] };
|
||||
let mut found = vec![];
|
||||
@@ -110,7 +110,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes:
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut out: Vec<(Animation, u32, String)> = vec![];
|
||||
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
|
||||
for f in found {
|
||||
let c = f.ctn;
|
||||
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
|
||||
@@ -172,15 +172,56 @@ 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();
|
||||
// Collapse a paragraph-by-paragraph build into one animation.
|
||||
if paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp) {
|
||||
if out.last().is_some_and(|(l, lf, lg)| *lf == file_id && *lg == grp && l.text_build == TextBuild::ByParagraph) {
|
||||
continue;
|
||||
}
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.paragraph = None;
|
||||
}
|
||||
out.push((a, file_id, grp));
|
||||
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
|
||||
out.push((a, file_id, grp, build));
|
||||
}
|
||||
out.into_iter().map(|(a, _, _)| a).collect()
|
||||
// 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 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 build = run.first().is_some_and(|r| r.3);
|
||||
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) => {
|
||||
anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() });
|
||||
}
|
||||
_ => anims.extend(effects.into_iter().cloned()),
|
||||
}
|
||||
}
|
||||
anims
|
||||
}
|
||||
|
||||
/// Are `effects` (paragraph effects of one shape, in file order) what a by-paragraph build of the
|
||||
/// 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 {
|
||||
let roles = paragraph_build_roles(text);
|
||||
let expected = roles.iter().enumerate().filter(|(i, r)| match r {
|
||||
BuildRole::Top => true,
|
||||
BuildRole::Member(_) => text.paragraphs.get(*i).is_some_and(build_filled),
|
||||
BuildRole::Unowned => false,
|
||||
});
|
||||
let Some(first) = effects.first() else { return false };
|
||||
let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() };
|
||||
let base = settings(first);
|
||||
let mut matched = 0;
|
||||
for (k, (i, role)) in expected.enumerate() {
|
||||
let Some(a) = effects.get(k) else { return false };
|
||||
let start_ok = match role {
|
||||
BuildRole::Top if k == 0 => true,
|
||||
BuildRole::Top if first.start == AnimStart::OnClick => a.start == AnimStart::OnClick,
|
||||
BuildRole::Top => a.start == AnimStart::AfterPrevious,
|
||||
_ => a.start == AnimStart::WithPrevious,
|
||||
};
|
||||
if a.paragraph != u32::try_from(i).ok() || !start_ok || settings(a) != base {
|
||||
return false;
|
||||
}
|
||||
matched = k + 1;
|
||||
}
|
||||
matched == effects.len()
|
||||
}
|
||||
|
||||
+130
-14
@@ -30,7 +30,17 @@ fn simple_fill(w: &mut W, f: &Fill) {
|
||||
match f {
|
||||
Fill::None => w.empty0("a:noFill"),
|
||||
Fill::Solid { color } => super::dml::solid(w, color),
|
||||
Fill::Gradient(_) | Fill::Picture(_) | Fill::Pattern(_) | Fill::Group | Fill::Background => {
|
||||
Fill::Pattern(p) => {
|
||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
||||
w.open0("a:fgClr");
|
||||
super::dml::color(w, &p.fg);
|
||||
w.close("a:fgClr");
|
||||
w.open0("a:bgClr");
|
||||
super::dml::color(w, &p.bg);
|
||||
w.close("a:bgClr");
|
||||
w.close("a:pattFill");
|
||||
}
|
||||
Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => {
|
||||
if let Fill::Gradient(g) = f
|
||||
&& let Some(s) = g.stops.first()
|
||||
{
|
||||
@@ -58,14 +68,54 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) {
|
||||
w.close("c:spPr");
|
||||
}
|
||||
|
||||
fn rich(w: &mut W, text: &str) {
|
||||
/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph
|
||||
/// defaults of a title's rich text and of every `c:txPr`.
|
||||
fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) {
|
||||
match color {
|
||||
Some(c) => {
|
||||
w.open0("a:defRPr");
|
||||
super::dml::solid(w, c);
|
||||
w.close("a:defRPr");
|
||||
}
|
||||
None => w.empty0("a:defRPr"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on
|
||||
/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing
|
||||
/// without a colour: the theme's default then applies on reading.
|
||||
fn tx_pr(w: &mut W, c: &Chart) {
|
||||
let Some(color) = &c.text_color else { return };
|
||||
w.open0("c:txPr");
|
||||
w.empty0("a:bodyPr");
|
||||
w.empty0("a:lstStyle");
|
||||
w.open0("a:p");
|
||||
w.open0("a:pPr");
|
||||
def_rpr(w, Some(color));
|
||||
w.close("a:pPr");
|
||||
w.empty("a:endParaRPr", A::new().a("lang", "en-US"));
|
||||
w.close("a:p");
|
||||
w.close("c:txPr");
|
||||
}
|
||||
|
||||
fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
|
||||
w.open0("c:tx");
|
||||
w.open0("c:rich");
|
||||
w.empty0("a:bodyPr");
|
||||
w.empty0("a:lstStyle");
|
||||
for line in text.split('\n') {
|
||||
w.open0("a:p");
|
||||
if color.is_some() {
|
||||
w.open0("a:pPr");
|
||||
def_rpr(w, color);
|
||||
w.close("a:pPr");
|
||||
}
|
||||
w.open0("a:r");
|
||||
if let Some(c) = color {
|
||||
w.open("a:rPr", A::new().a("lang", "en-US"));
|
||||
super::dml::solid(w, c);
|
||||
w.close("a:rPr");
|
||||
}
|
||||
w.elt("a:t", line);
|
||||
w.close("a:r");
|
||||
w.close("a:p");
|
||||
@@ -74,9 +124,10 @@ fn rich(w: &mut W, text: &str) {
|
||||
w.close("c:tx");
|
||||
}
|
||||
|
||||
fn title(w: &mut W, text: &str) {
|
||||
/// A title (the chart's or an axis's) in the chart's text colour.
|
||||
fn title(w: &mut W, text: &str, c: &Chart) {
|
||||
w.open0("c:title");
|
||||
rich(w, text);
|
||||
rich(w, text, c.text_color.as_ref());
|
||||
w.val("c:overlay", 0);
|
||||
w.close("c:title");
|
||||
}
|
||||
@@ -217,7 +268,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
|
||||
}
|
||||
}
|
||||
if let Some(d) = s.data_labels {
|
||||
data_labels(w, d);
|
||||
data_labels(w, c, d);
|
||||
}
|
||||
// After labels, before the category and value caches (ECMA-376 series content order).
|
||||
if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {
|
||||
@@ -325,8 +376,9 @@ fn trendline(w: &mut W, t: &Trendline) {
|
||||
w.close("c:trendline");
|
||||
}
|
||||
|
||||
fn data_labels(w: &mut W, show: bool) {
|
||||
fn data_labels(w: &mut W, c: &Chart, show: bool) {
|
||||
w.open0("c:dLbls");
|
||||
tx_pr(w, c);
|
||||
for (n, v) in [
|
||||
("c:showLegendKey", false),
|
||||
("c:showVal", show),
|
||||
@@ -355,12 +407,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:delete", 0);
|
||||
w.val("c:axPos", "b");
|
||||
if let Some(t) = &c.axis_titles.0 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
|
||||
w.val("c:majorTickMark", "out");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_VAL);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:crossBetween", "midCat");
|
||||
@@ -374,12 +427,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:delete", 0);
|
||||
w.val("c:axPos", cat_pos);
|
||||
if let Some(t) = &c.axis_titles.0 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
|
||||
w.val("c:majorTickMark", "none");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_VAL);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:auto", 1);
|
||||
@@ -411,7 +465,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.empty0("c:minorGridlines");
|
||||
}
|
||||
if let Some(t) = &c.axis_titles.1 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
match c.y_format.as_deref().filter(|f| !f.is_empty()) {
|
||||
Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)),
|
||||
@@ -420,6 +474,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:majorTickMark", "none");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_CAT);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:crossBetween", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" });
|
||||
@@ -468,7 +523,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
series(w, c, s, *i, kind, g);
|
||||
}
|
||||
if c.data_labels {
|
||||
data_labels(w, true);
|
||||
data_labels(w, c, true);
|
||||
}
|
||||
match g {
|
||||
Group::Bar => {
|
||||
@@ -576,7 +631,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
w.open0("c:chart");
|
||||
match &c.title {
|
||||
Some(t) => {
|
||||
title(&mut w, t);
|
||||
title(&mut w, t, c);
|
||||
w.val("c:autoTitleDeleted", 0);
|
||||
}
|
||||
None => w.val("c:autoTitleDeleted", 1),
|
||||
@@ -654,6 +709,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
w.open0("c:legend");
|
||||
w.val("c:legendPos", p);
|
||||
w.val("c:overlay", u8::from(c.legend_overlay));
|
||||
tx_pr(&mut w, c);
|
||||
w.close("c:legend");
|
||||
}
|
||||
w.val("c:plotVisOnly", 1);
|
||||
@@ -662,6 +718,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
if let Some(f) = &c.chart_fill {
|
||||
sp_pr(&mut w, Some(f), None);
|
||||
}
|
||||
tx_pr(&mut w, c);
|
||||
w.close("c:chartSpace");
|
||||
(w.finish(), false)
|
||||
}
|
||||
@@ -750,11 +807,20 @@ pub fn write_kept(pkg: &mut crate::opc::PackageOut, chart_name: &str, c: &Chart,
|
||||
}
|
||||
}
|
||||
pkg.rels(chart_name, &rels);
|
||||
let mut parts_by_norm = std::collections::HashMap::new();
|
||||
for p in &c.kept.parts {
|
||||
parts_by_norm.entry(norm(&p.name)).or_insert(p);
|
||||
}
|
||||
let mut seen_payloads = std::collections::HashSet::new();
|
||||
// The kept parts' own relationships, pointed at the new names.
|
||||
for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) {
|
||||
let Some(new_source) = names.get(&norm(&p.name)) else { continue };
|
||||
let norm_name = norm(&p.name);
|
||||
if !seen_payloads.insert(norm_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(new_source) = names.get(&norm_name) else { continue };
|
||||
let rp = norm(&rels_path(&p.name));
|
||||
let Some(rels_part) = c.kept.parts.iter().find(|q| norm(&q.name) == rp) else { continue };
|
||||
let Some(rels_part) = parts_by_norm.get(&rp) else { continue };
|
||||
let mut out = RelsOut::default();
|
||||
for r in crate::opc::rels_from_bytes(&rels_part.bytes, &p.name).items {
|
||||
if r.external {
|
||||
@@ -857,7 +923,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
|
||||
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
|
||||
cx_text(&mut w, &s.name);
|
||||
if s.data_labels.unwrap_or(c.data_labels) {
|
||||
if c.series_data_labels(s) {
|
||||
w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" }));
|
||||
w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1"));
|
||||
w.close("cx:dataLabels");
|
||||
@@ -971,8 +1037,58 @@ mod region_map_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
|
||||
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
|
||||
/// data labels and chart, the title's run defaults) and read back as the same colour.
|
||||
#[test]
|
||||
fn chart_text_colour_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = Some("Revenue".into());
|
||||
c.legend = Some("b".into());
|
||||
c.data_labels = true;
|
||||
c.axis_titles = (Some("Quarter".into()), Some("EUR".into()));
|
||||
let tinted = ColorRef::scheme(deckcraft_model::SchemeSlot::Accent2).with(ColorTransform::LumMod(75000));
|
||||
c.text_color = Some(tinted.clone());
|
||||
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
|
||||
// Axes (2), legend, data labels and the chart root; a title keeps it in its rich text.
|
||||
assert!(xml.matches("<c:txPr>").count() >= 5, "{xml}");
|
||||
assert!(xml.contains(r#"<a:schemeClr val="accent2"><a:lumMod val="75000"/></a:schemeClr>"#), "{xml}");
|
||||
assert!(xml.contains("<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr><a:solidFill>"), "{xml}");
|
||||
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, Some(tinted));
|
||||
// An RGB colour and no colour at all round-trip too.
|
||||
c.text_color = Some(ColorRef::rgb(Rgba::rgb(255, 255, 255)));
|
||||
let xml = super::chart_xml(&c).0;
|
||||
assert_eq!(crate::read::chart::read_chart_xml(&xml).unwrap().text_color, c.text_color);
|
||||
c.text_color = None;
|
||||
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
|
||||
assert!(!xml.contains("<c:txPr>"), "{xml}");
|
||||
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, None);
|
||||
}
|
||||
|
||||
/// An imported chart's `c:txPr` colour survives an edit that rebuilds the part from the model
|
||||
/// (the colour used to be read but never written, so a save after any edit lost it).
|
||||
#[test]
|
||||
fn imported_text_colour_survives_an_edit_and_a_save() {
|
||||
let xml = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:crossAx val="1"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let c = crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap();
|
||||
let grey = ColorRef::scheme(deckcraft_model::SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000));
|
||||
assert_eq!(c.text_color, Some(grey.clone()));
|
||||
// Unchanged: the part is written back as read.
|
||||
assert!(super::chart_xml(&c).1);
|
||||
// Edited (a title; the editor drops `raw` on any edit): rebuilt with the colour.
|
||||
let mut e = c.clone();
|
||||
e.title = Some("Units".into());
|
||||
e.raw = None;
|
||||
let (out, kept) = super::chart_xml(&e);
|
||||
assert!(!kept);
|
||||
let back = crate::read::chart::read_chart_xml(&out).unwrap();
|
||||
assert_eq!(back.text_color, Some(grey));
|
||||
assert_eq!(back.title.as_deref(), Some("Units"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_category_axis_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Bar);
|
||||
|
||||
@@ -36,9 +36,10 @@ pub fn color(w: &mut W, c: &ColorRef) {
|
||||
let (tag, a) = match &c.base {
|
||||
ColorBase::Rgb { rgb } => ("a:srgbClr", A::new().a("val", rgb.hex())),
|
||||
ColorBase::Scheme { slot } => ("a:schemeClr", A::new().a("val", slot.xml_name())),
|
||||
ColorBase::Preset { name } => ("a:prstClr", A::new().a("val", if name.is_empty() { "black" } else { name })),
|
||||
// The reader keeps any name a file has; only names the schema allows are written.
|
||||
ColorBase::Preset { name } => ("a:prstClr", A::new().a("val", if deckcraft_color::is_preset_name(name) { name } else { "black" })),
|
||||
ColorBase::System { name, last } => {
|
||||
("a:sysClr", A::new().a("val", if name.is_empty() { "windowText" } else { name }).a("lastClr", last.hex()))
|
||||
("a:sysClr", A::new().a("val", if deckcraft_color::is_system_name(name) { name } else { "windowText" }).a("lastClr", last.hex()))
|
||||
}
|
||||
};
|
||||
let rgb_alpha = match &c.base {
|
||||
@@ -788,3 +789,23 @@ pub fn paragraph(w: &mut W, x: &mut Exp, o: &mut Out, p: &Paragraph, math: bool,
|
||||
pub fn has_math(tb: &TextBody) -> bool {
|
||||
tb.paragraphs.iter().flat_map(|p| &p.runs).any(|r| matches!(&r.kind, RunKind::Math { omml } if !omml.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
#[test]
|
||||
fn unknown_preset_and_system_names_save_as_black_and_window_text() {
|
||||
let write = |base: ColorBase| {
|
||||
let mut w = W::frag();
|
||||
color(&mut w, &ColorRef { base, mods: vec![] });
|
||||
w.s
|
||||
};
|
||||
assert_eq!(write(ColorBase::Preset { name: "notAColor".into() }), r#"<a:prstClr val="black"/>"#);
|
||||
assert_eq!(write(ColorBase::Preset { name: "darkBlue".into() }), r#"<a:prstClr val="darkBlue"/>"#);
|
||||
let last = Rgba::rgb(0x12, 0x34, 0x56);
|
||||
assert_eq!(write(ColorBase::System { name: "notAColor".into(), last }), r#"<a:sysClr val="windowText" lastClr="123456"/>"#);
|
||||
assert_eq!(write(ColorBase::System { name: "3dLight".into(), last }), r#"<a:sysClr val="3dLight" lastClr="123456"/>"#);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
|
||||
// Comment authors.
|
||||
let mut authors: Vec<(String, String, u32)> = vec![];
|
||||
let mut author_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut n_comments = 0u32;
|
||||
|
||||
// Slides.
|
||||
@@ -662,13 +663,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
n_comments += 1;
|
||||
let cname = format!("ppt/comments/comment{n_comments}.xml");
|
||||
so.rels.add("comments", &opc::relative(&sname, &cname), false);
|
||||
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors));
|
||||
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors, &mut author_map));
|
||||
// The modern threaded part next to the flattened legacy one (issue #70):
|
||||
// PowerPoint 2021+ and DeckCraft read replies and resolved state from it,
|
||||
// older readers keep the legacy part.
|
||||
let mname = format!("ppt/comments/modernComment{n_comments}.xml");
|
||||
let rid = so.rels.add(RT_COMMENTS_MODERN, &opc::relative(&sname, &mname), false);
|
||||
x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors));
|
||||
x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors, &mut author_map));
|
||||
comment_rel = Some(rid);
|
||||
}
|
||||
let xml = slide_xml(&mut x, &mut so, s, comment_rel.as_deref());
|
||||
@@ -828,7 +829,7 @@ fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec<u8> {
|
||||
w.finish()
|
||||
}
|
||||
|
||||
fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
|
||||
fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>, author_map: &mut std::collections::HashMap<String, usize>) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:cmLst", A::new());
|
||||
let mut flat: Vec<&Comment> = vec![];
|
||||
@@ -839,7 +840,7 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V
|
||||
}
|
||||
}
|
||||
for c in flat {
|
||||
let ai = author_slot(c, authors);
|
||||
let ai = author_slot(c, authors, author_map);
|
||||
let idx = match authors.get_mut(ai) {
|
||||
Some(a) => {
|
||||
a.2 += 1;
|
||||
@@ -860,32 +861,45 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V
|
||||
|
||||
/// The author's slot in the shared bucket list, adding them on first use. Legacy parts index the
|
||||
/// list by number, the modern authors part gives every slot a GUID.
|
||||
fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>) -> usize {
|
||||
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
|
||||
match authors.iter().position(|(n, _, _)| *n == author) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
|
||||
authors.push((author.clone(), ini, 0));
|
||||
authors.len() - 1
|
||||
}
|
||||
fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>, map: &mut std::collections::HashMap<String, usize>) -> usize {
|
||||
let author = if c.author.is_empty() { "Author" } else { &c.author };
|
||||
if let Some(&i) = map.get(author) {
|
||||
return i;
|
||||
}
|
||||
let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
|
||||
let idx = authors.len();
|
||||
authors.push((author.to_string(), ini, 0));
|
||||
map.insert(author.to_string(), idx);
|
||||
idx
|
||||
}
|
||||
|
||||
/// The modern (p188) threaded-comment part: replies nest and the resolved state is kept, which
|
||||
/// the flattened legacy part cannot express (issue #70).
|
||||
fn modern_comments_xml(list: &[Comment], part: &str, authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
|
||||
fn modern_comments_xml(
|
||||
list: &[Comment],
|
||||
part: &str,
|
||||
authors: &mut Vec<(String, String, u32)>,
|
||||
author_map: &mut std::collections::HashMap<String, usize>,
|
||||
) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("p188:cmLst", A::new().a("xmlns:p188", NS_P188).a("xmlns:a", NS_A).a("xmlns:r", NS_R));
|
||||
for (ti, c) in list.iter().take(10_000).enumerate() {
|
||||
modern_comment(&mut w, c, part, ti, authors, 0);
|
||||
modern_comment(&mut w, c, part, ti, authors, author_map, 0);
|
||||
}
|
||||
w.close("p188:cmLst");
|
||||
w.finish()
|
||||
}
|
||||
|
||||
fn modern_comment(w: &mut W, c: &Comment, part: &str, ti: usize, authors: &mut Vec<(String, String, u32)>, depth: usize) {
|
||||
let ai = author_slot(c, authors);
|
||||
fn modern_comment(
|
||||
w: &mut W,
|
||||
c: &Comment,
|
||||
part: &str,
|
||||
ti: usize,
|
||||
authors: &mut Vec<(String, String, u32)>,
|
||||
author_map: &mut std::collections::HashMap<String, usize>,
|
||||
depth: usize,
|
||||
) {
|
||||
let ai = author_slot(c, authors, author_map);
|
||||
let author = authors.get(ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
|
||||
let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() };
|
||||
let mut a = A::new().a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}"))).a("authorId", author_guid(&author)).a("created", dt);
|
||||
@@ -899,7 +913,7 @@ fn modern_comment(w: &mut W, c: &Comment, part: &str, ti: usize, authors: &mut V
|
||||
if depth < 4 && !c.replies.is_empty() {
|
||||
w.open0("p188:replyLst");
|
||||
for (ri, r) in c.replies.iter().take(1000).enumerate() {
|
||||
let ri_ai = author_slot(r, authors);
|
||||
let ri_ai = author_slot(r, authors, author_map);
|
||||
let r_author = authors.get(ri_ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
|
||||
let rdt = if valid_date(&r.date) { r.date.clone() } else { "2024-01-01T00:00:00.000".into() };
|
||||
let mut ra = A::new()
|
||||
|
||||
@@ -852,21 +852,27 @@ fn opaque(
|
||||
// parts re-added under this slide's relationship ids.
|
||||
if let Ok(doc) = crate::xml::parse(xml.as_bytes())
|
||||
&& matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp")
|
||||
&& let Some(ids) = crate::read::rel_refs(&doc.root)
|
||||
{
|
||||
let mut part_map: std::collections::HashMap<&str, (&str, deckcraft_model::MediaId)> = std::collections::HashMap::new();
|
||||
for (id, kind, m) in parts {
|
||||
part_map.entry(id.as_str()).or_insert((kind.as_str(), *m));
|
||||
}
|
||||
if let Some(ids) = crate::read::rel_refs(&doc.root)
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (_, kind, m) = parts.iter().find(|(id, ..)| id == r)?;
|
||||
let (kind, m) = part_map.get(r.as_str())?;
|
||||
Some((r.clone(), x.media_rel(o, *m, kind)?))
|
||||
})
|
||||
.collect::<Option<std::collections::HashMap<_, _>>>()
|
||||
{
|
||||
let mut root = doc.root;
|
||||
set_rel_ids(&mut root, &ids, 0);
|
||||
ole_fallback(&mut root, x, o, preview);
|
||||
let id = cx.id_for(s);
|
||||
set_cnvpr_id(&mut root, id);
|
||||
w.raw(&root.to_xml());
|
||||
return;
|
||||
{
|
||||
let mut root = doc.root;
|
||||
set_rel_ids(&mut root, &ids, 0);
|
||||
ole_fallback(&mut root, x, o, preview);
|
||||
let id = cx.id_for(s);
|
||||
set_cnvpr_id(&mut root, id);
|
||||
w.raw(&root.to_xml());
|
||||
return;
|
||||
}
|
||||
}
|
||||
log::warn!("pptx: object '{label}' written as its preview picture");
|
||||
let id = cx.id_for(s);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Transitions and the animation timing tree.
|
||||
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::{Shape, ShapeKind, find_shape};
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
|
||||
|
||||
use super::shapes::IdMap;
|
||||
use super::{Exp, Out};
|
||||
@@ -565,53 +565,68 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
|
||||
}
|
||||
}
|
||||
|
||||
fn para_count(s: &Shape) -> u32 {
|
||||
s.text.as_ref().map(|t| t.paragraphs.iter().filter(|p| !p.is_empty()).count()).unwrap_or(0).min(10_000) as u32
|
||||
}
|
||||
|
||||
/// Write `p:timing` for a slide's animations.
|
||||
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
|
||||
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
|
||||
let mut main: Vec<Eff> = vec![];
|
||||
let mut triggered: Vec<(u32, Vec<Eff>)> = vec![];
|
||||
let mut bld: Vec<(u32, u32, bool)> = vec![];
|
||||
// The last paragraph effect written: (shape, class, trigger, grpId).
|
||||
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
|
||||
for a in anims.iter().take(10_000) {
|
||||
let Some(spid) = idmap.get(a.shape) else {
|
||||
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
|
||||
continue;
|
||||
};
|
||||
let Some(shape) = find_shape(shapes, a.shape) else { continue };
|
||||
let grp = {
|
||||
let c = grp_count.entry(spid).or_insert(0);
|
||||
let g = *c;
|
||||
*c += 1;
|
||||
g
|
||||
};
|
||||
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
|
||||
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
|
||||
let by_para = a.text_build == TextBuild::ByParagraph && is_text_sp && para_count(shape) > 0;
|
||||
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
|
||||
let roles = text.map(paragraph_build_roles).unwrap_or_default();
|
||||
let by_para = roles.contains(&BuildRole::Top);
|
||||
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
|
||||
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
|
||||
// the same way.
|
||||
let para_effect = !by_para && text_sp && a.paragraph.is_some();
|
||||
let joins = para_effect && prev_para.is_some_and(|(s, c, t, _)| s == a.shape && c == a.class && t == a.trigger);
|
||||
let grp = match prev_para {
|
||||
Some((.., g)) if joins => g,
|
||||
_ => {
|
||||
let c = grp_count.entry(spid).or_insert(0);
|
||||
let g = *c;
|
||||
*c += 1;
|
||||
g
|
||||
}
|
||||
};
|
||||
prev_para = para_effect.then_some((a.shape, a.class, a.trigger, grp));
|
||||
let mut effs = vec![];
|
||||
if by_para {
|
||||
// Paragraph indices in the file count empty paragraphs too.
|
||||
let idxs: Vec<u32> = shape
|
||||
.text
|
||||
.as_ref()
|
||||
.map(|t| t.paragraphs.iter().enumerate().filter(|(_, p)| !p.is_empty()).map(|(i, _)| i as u32).collect())
|
||||
.unwrap_or_default();
|
||||
for (k, pi) in idxs.into_iter().enumerate() {
|
||||
let start = if k == 0 {
|
||||
a.start
|
||||
} else if a.start == AnimStart::OnClick {
|
||||
AnimStart::OnClick
|
||||
} else {
|
||||
AnimStart::AfterPrevious
|
||||
// One effect per paragraph the build animates on its own, deeper ones with their
|
||||
// parent; indices in the file count empty paragraphs too.
|
||||
let mut tops = 0;
|
||||
for (i, role) in roles.iter().enumerate() {
|
||||
let start = match role {
|
||||
BuildRole::Top => {
|
||||
tops += 1;
|
||||
if tops == 1 {
|
||||
a.start
|
||||
} else if a.start == AnimStart::OnClick {
|
||||
AnimStart::OnClick
|
||||
} else {
|
||||
AnimStart::AfterPrevious
|
||||
}
|
||||
}
|
||||
BuildRole::Member(_) if text.and_then(|t| t.paragraphs.get(i)).is_some_and(build_filled) => AnimStart::WithPrevious,
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(pi) = u32::try_from(i) else { break };
|
||||
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
|
||||
}
|
||||
} else {
|
||||
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
|
||||
}
|
||||
if matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some() {
|
||||
bld.push((spid, grp, by_para));
|
||||
if text_sp && !joins {
|
||||
bld.push((spid, grp, by_para || para_effect));
|
||||
}
|
||||
match a.trigger.and_then(|t| idmap.get(t)) {
|
||||
Some(t) => match triggered.iter_mut().find(|(s, _)| *s == t) {
|
||||
@@ -698,8 +713,8 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
|
||||
w.close("p:tnLst");
|
||||
if !bld.is_empty() {
|
||||
w.open0("p:bldLst");
|
||||
for (spid, grp, by_para) in bld {
|
||||
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", by_para.then_some("p")).t("animBg", !by_para));
|
||||
for (spid, grp, build_p) in bld {
|
||||
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", build_p.then_some("p")).t("animBg", !build_p));
|
||||
}
|
||||
w.close("p:bldLst");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Series-level data labels: `c:dLbls/c:showVal` on a series overrides the chart-wide setting,
|
||||
//! and a bare `<c:showVal/>` (no `val` attribute) means true, as the schema's default (#868).
|
||||
|
||||
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};
|
||||
|
||||
/// A one-slide deck whose bar chart shows no labels anywhere.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Rewrite the chart part's XML through `f` and re-import the deck.
|
||||
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");
|
||||
}
|
||||
|
||||
/// `<c:showVal/>` on the first series: that series shows labels although the chart-wide
|
||||
/// setting is off; the other series stay quiet.
|
||||
#[test]
|
||||
fn bare_series_showval_shows_labels() {
|
||||
let p = reimport(&bar_chart_pptx(), |xml| xml.replacen("<c:cat>", r#"<c:dLbls><c:showVal/></c:dLbls><c:cat>"#, 1));
|
||||
let c = chart_of(&p);
|
||||
assert!(!c.data_labels, "the chart-wide setting stays off");
|
||||
assert_eq!(c.series[0].data_labels, Some(true), "a bare showVal means true");
|
||||
assert!(c.series_data_labels(&c.series[0]), "the first series' labels show");
|
||||
for s in c.series.iter().skip(1) {
|
||||
assert_eq!(s.data_labels, None, "other series keep the chart's setting");
|
||||
assert!(!c.series_data_labels(s), "and stay quiet");
|
||||
}
|
||||
}
|
||||
|
||||
/// `val="0"` on the series hides its labels although the chart-wide setting is on.
|
||||
#[test]
|
||||
fn series_showval_false_hides_labels() {
|
||||
let p = reimport(&bar_chart_pptx(), |xml| {
|
||||
xml.replacen("<c:cat>", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:cat>"#, 1).replacen(
|
||||
"<c:gapWidth",
|
||||
r#"<c:dLbls><c:showVal/></c:dLbls><c:gapWidth"#,
|
||||
1,
|
||||
)
|
||||
});
|
||||
let c = chart_of(&p);
|
||||
assert!(c.data_labels, "the bare chart-wide showVal turns labels on");
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the explicit series false is read");
|
||||
assert!(!c.series_data_labels(&c.series[0]), "and hides that series' labels");
|
||||
assert!(c.series_data_labels(&c.series[1]), "the next series takes the chart-wide on");
|
||||
}
|
||||
|
||||
/// The series' label setting survives a save and load.
|
||||
#[test]
|
||||
fn series_label_settings_round_trip() {
|
||||
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 chart = Chart::sample(ChartType::Column);
|
||||
chart.data_labels = true;
|
||||
chart.series[0].data_labels = Some(false);
|
||||
chart.series[1].data_labels = Some(true);
|
||||
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() };
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let c = chart_of(&back);
|
||||
assert!(c.data_labels);
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the explicit off survives");
|
||||
assert_eq!(c.series[1].data_labels, Some(true), "the explicit on survives");
|
||||
assert!(!c.series_data_labels(&c.series[0]));
|
||||
assert!(c.series_data_labels(&c.series[1]));
|
||||
}
|
||||
|
||||
/// A one-slide deck with a combo chart: a bar group and a line group.
|
||||
fn combo_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 chart = Chart::sample(ChartType::Combo);
|
||||
chart.series.truncate(2);
|
||||
chart.series[1].kind = Some(ChartType::Line);
|
||||
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() };
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
/// A combo chart's groups keep their own label setting: the group that asks for no labels
|
||||
/// keeps them off, even when the other group asks for them (#868).
|
||||
#[test]
|
||||
fn a_combo_chart_keeps_each_groups_own_label_setting() {
|
||||
let p = reimport(&combo_chart_pptx(), |xml| {
|
||||
// The bar group asks for no labels, the line group for labels.
|
||||
xml.replacen("<c:gapWidth", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:gapWidth"#, 1).replacen(
|
||||
"<c:marker val=\"1\"/>",
|
||||
r#"<c:dLbls><c:showVal/></c:dLbls><c:marker val="1"/>"#,
|
||||
1,
|
||||
)
|
||||
});
|
||||
let c = chart_of(&p);
|
||||
assert!(c.data_labels, "the line group's ask turns the chart-wide setting on");
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the bar group asks for no labels");
|
||||
assert!(!c.series_data_labels(&c.series[0]), "so the bars stay quiet");
|
||||
assert!(c.series_data_labels(&c.series[1]), "the line takes the chart-wide on");
|
||||
}
|
||||
|
||||
/// Turning Data Labels on through `chart.options` shows the labels of series that the file
|
||||
/// switched off one by one.
|
||||
#[test]
|
||||
fn chart_options_data_labels_on_overrides_series_off() {
|
||||
use deckcraft_engine::Session;
|
||||
let p = reimport(&bar_chart_pptx(), |xml| xml.replace("<c:cat>", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:cat>"#));
|
||||
assert!(chart_of(&p).series.iter().all(|s| !chart_of(&p).series_data_labels(s)), "every series starts quiet");
|
||||
let path = std::env::temp_dir().join("deckcraft_series_labels_off.pptx");
|
||||
std::fs::write(&path, deckcraft_pptx::export(&p).expect("export")).expect("write");
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.open", &serde_json::json!({"path": path.to_string_lossy()})).expect("open");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let id = doc.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Chart(_))).expect("chart").id.0;
|
||||
assert!(chart_of(&doc).series.iter().all(|x| !chart_of(&doc).series_data_labels(x)));
|
||||
s.execute("chart.options", &serde_json::json!({"id": id, "dataLabels": true})).expect("options");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let c = chart_of(&doc);
|
||||
assert!(c.series.iter().all(|x| c.series_data_labels(x)), "every series shows its labels");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
+126
-41
@@ -29,31 +29,70 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
|
||||
}
|
||||
}
|
||||
|
||||
/// A series' one colour (lines, areas, markers): its solid fill, a pattern's foreground, else the
|
||||
/// palette's.
|
||||
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
|
||||
match s.and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
Some(Fill::Pattern(p)) => ctx.color(&p.fg, None),
|
||||
_ => series_color(ctx, c, i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
|
||||
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
|
||||
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
/// How a bar, or its legend key, is painted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Paint {
|
||||
Color(Rgba),
|
||||
/// A preset pattern: name, foreground, background.
|
||||
Pattern(String, Rgba, Rgba),
|
||||
/// `a:noFill`: only the outline, if any, shows.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// The paint of fill `f` as a bar or legend key draws it; the palette's `fallback` when the file
|
||||
/// gives none (or one a bar can't show).
|
||||
fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint {
|
||||
match f {
|
||||
Some(Fill::Solid { color: cc }) => Paint::Color(ctx.color(cc, None)),
|
||||
Some(Fill::Pattern(p)) => Paint::Pattern(p.preset.clone(), ctx.color(&p.fg, None), ctx.color(&p.bg, None)),
|
||||
Some(Fill::None) => Paint::Nothing,
|
||||
_ => Paint::Color(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`).
|
||||
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
if s.point_fills.iter().any(|(k, _)| *k == pi) {
|
||||
return point_fill(ctx, c, si, s, pi);
|
||||
/// Fill `r` (in the space `m` maps to the output) with `p`.
|
||||
fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) {
|
||||
match p {
|
||||
Paint::Color(c) => {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*c, 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m),
|
||||
Paint::Nothing => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bar series' outline (`c:spPr/a:ln`) where the file gives it a solid colour: colour and width.
|
||||
fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f64)> {
|
||||
let l = s.line.as_ref()?;
|
||||
match l.fill.as_ref()? {
|
||||
Fill::Solid { color: cc } => Some((ctx.color(cc, None), l.width.unwrap_or(0.75).clamp(0.0, 20.0))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
|
||||
/// `a:noFill` leaves the bar empty, as in PowerPoint.
|
||||
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
|
||||
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
|
||||
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return series_color(ctx, c, pi);
|
||||
return Paint::Color(series_color(ctx, c, pi));
|
||||
}
|
||||
series_fill(ctx, c, si, Some(s))
|
||||
paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si))
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
@@ -193,6 +232,17 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo
|
||||
(origin, if overlay { area } else { plot })
|
||||
}
|
||||
|
||||
/// An axis span to divide by: the span itself when positive, else 1 (no absolute floor, so tiny data keeps its scale).
|
||||
pub(crate) fn positive_span(s: f64) -> f64 {
|
||||
if s > 0.0 && s.is_finite() { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The tick values of an axis (min, max, major step), one per step (at most 51).
|
||||
fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator<Item = f64> {
|
||||
let n = if st > 0.0 { (((hi - lo) / st).round() as i64).clamp(1, 50) } else { 1 };
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||||
match c.kind {
|
||||
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
|
||||
@@ -205,9 +255,19 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which
|
||||
/// is 595959 on a light theme and a light grey on a dark one.
|
||||
pub fn default_text_color() -> ColorRef {
|
||||
use deckcraft_color::ColorTransform;
|
||||
ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) {
|
||||
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||||
// Text without a colour of its own takes PowerPoint's default, the theme's text colour at
|
||||
// lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart
|
||||
// on a dark theme is readable (a fixed grey used to vanish there).
|
||||
let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&default_text_color()), None);
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
if let Some(f) = &c.chart_fill {
|
||||
crate::paint::fill_path(
|
||||
@@ -235,16 +295,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend placed by `c:legendPos`.
|
||||
let waterfall = c.kind == ChartType::Waterfall;
|
||||
let legend_items: Vec<(String, Rgba)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect()
|
||||
// Each legend entry: its name, its key's paint and the key's outline.
|
||||
let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
// A hierarchy's legend names its top-level groups.
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect()
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) {
|
||||
// A map draws its own colour scale.
|
||||
vec![]
|
||||
} else if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else {
|
||||
c.series
|
||||
.iter()
|
||||
@@ -252,13 +313,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.map(|(i, s)| {
|
||||
let k = c.series_kind(s);
|
||||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
if line {
|
||||
(s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None)
|
||||
} else if k.is_bar_like() {
|
||||
let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() {
|
||||
Paint::Color(series_color(rctx, c, i))
|
||||
} else {
|
||||
paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i))
|
||||
};
|
||||
(s.name.clone(), paint, bar_outline(rctx, s))
|
||||
} else {
|
||||
(s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let size = if vertical {
|
||||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||||
} else {
|
||||
@@ -267,10 +339,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||||
area = plot_area;
|
||||
let (mut x, mut y) = (origin.x, origin.y);
|
||||
for (name, col) in &legend_items {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*col, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||||
for (name, paint, outline) in &legend_items {
|
||||
let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base);
|
||||
fill_with(ctx, paint, key, m);
|
||||
if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2)));
|
||||
ctx.stroke_path(&key.to_path(0.1));
|
||||
}
|
||||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
if vertical {
|
||||
@@ -310,7 +387,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
ctx.stroke_path(&p);
|
||||
if c.data_labels {
|
||||
if c.series_data_labels(s) {
|
||||
let mid = a0 + sweep / 2.0;
|
||||
let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin());
|
||||
label(
|
||||
@@ -322,7 +399,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
Rgba::WHITE,
|
||||
// On the slices themselves: white, unless the chart's text was given a colour.
|
||||
if c.text_color.is_some() { text_col } else { Rgba::WHITE },
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -379,10 +457,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||||
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
|
||||
let ticks = |(lo, hi, st): (f64, f64, f64)| {
|
||||
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
};
|
||||
let ticks = axis_ticks;
|
||||
// Label gutters as wide as the longest value label.
|
||||
let primary_ticks: Vec<f64> = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect();
|
||||
let label_w = |sec: bool, sc: (f64, f64, f64)| {
|
||||
@@ -405,7 +480,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
if let Some(f) = &c.plot_fill {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
let span = (max - min).max(1e-9);
|
||||
let span = positive_span(max - min);
|
||||
// Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at
|
||||
// the plot's edge.
|
||||
let to_v = |v: f64| {
|
||||
@@ -414,13 +489,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
// A series' value as a position on its own axis.
|
||||
let to_axis = |sec: bool, v: f64| match (sec, second) {
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(),
|
||||
_ => to_v(v),
|
||||
};
|
||||
// Minor gridlines, under the major ones: five to a major unit.
|
||||
if c.minor_gridlines {
|
||||
let minor = step / 5.0;
|
||||
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
|
||||
let n = if minor > 0.0 { (((max - min) / minor).round() as i64).clamp(1, 500) } else { 1 };
|
||||
let mut g = BezPath::new();
|
||||
for k in (0..=n).filter(|k| k % 5 != 0) {
|
||||
// On a logarithmic axis `min`, `max` and `step` are exponents of the base.
|
||||
@@ -514,10 +589,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
// Scatter and bubble charts have a value x axis: one scale over every series' x values, with its
|
||||
// own tick labels in place of category names.
|
||||
let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) };
|
||||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
|
||||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width());
|
||||
if let Some(sc) = xscale {
|
||||
for v in ticks(sc) {
|
||||
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
|
||||
let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width();
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
@@ -625,7 +700,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.abs();
|
||||
// A waterfall colours increases, decreases and totals, and joins each bar's end
|
||||
// to the next bar's start.
|
||||
let fill = if waterfall {
|
||||
let paint = if waterfall {
|
||||
let slot = if c.subtotals.contains(&i) {
|
||||
2
|
||||
} else if b < a {
|
||||
@@ -633,13 +708,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
} else {
|
||||
0
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
Paint::Color(series_color(rctx, c, slot))
|
||||
} else {
|
||||
bar_fill(rctx, c, *si, i, s)
|
||||
bar_paint(rctx, c, *si, i, s)
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
fill_with(ctx, &paint, r.abs(), m);
|
||||
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow));
|
||||
ctx.stroke_path(&r.abs().to_path(0.1));
|
||||
}
|
||||
if waterfall && i + 1 < ncat && !horiz {
|
||||
let y = to_v(b);
|
||||
let mut j = BezPath::new();
|
||||
@@ -656,7 +735,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap));
|
||||
}
|
||||
}
|
||||
if c.data_labels {
|
||||
if c.series_data_labels(s) {
|
||||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let lr = if horiz {
|
||||
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
|
||||
@@ -1055,6 +1134,12 @@ mod tests {
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[test]
|
||||
fn tiny_data_gets_several_ticks() {
|
||||
let sc = deckcraft_model::chart::axis_scale(1e-13, 5e-13, None, None, None, 8, true);
|
||||
assert!(axis_ticks(sc).count() >= 3, "{sc:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_x_is_a_value_scale_over_all_series() {
|
||||
let mut c = Chart::sample(ChartType::Scatter);
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
return;
|
||||
}
|
||||
let value_label = |v: f64| c.data_label(s, v);
|
||||
let labels = c.data_labels || c.kind == ChartType::Funnel;
|
||||
let labels = c.series_data_labels(s) || c.kind == ChartType::Funnel;
|
||||
match c.kind {
|
||||
ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label),
|
||||
ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)),
|
||||
@@ -107,7 +107,7 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area:
|
||||
ctx.fill_rect(&t);
|
||||
let dark = v.is_some_and(|v| (v - min) / span > 0.55);
|
||||
let mut text = name.clone();
|
||||
if c.data_labels
|
||||
if c.series_data_labels(s)
|
||||
&& let Some(v) = v
|
||||
{
|
||||
text = format!(
|
||||
|
||||
@@ -53,6 +53,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
|
||||
pub use table::cell_body as table_cell_body;
|
||||
pub use table::cell_rects as table_cell_rects;
|
||||
@@ -1152,7 +1153,7 @@ impl Renderer {
|
||||
}
|
||||
_ => {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none())
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
|
||||
{
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
|
||||
@@ -51,7 +51,7 @@ 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_ref(), |ctx| {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd, region } => clipped(&mut ctx, region.as_deref(), |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 });
|
||||
@@ -64,7 +64,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}),
|
||||
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_deref(), |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 {
|
||||
@@ -75,7 +75,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}),
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_deref(), |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 +102,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
}
|
||||
}
|
||||
}),
|
||||
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_deref(), |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));
|
||||
@@ -281,7 +281,14 @@ fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {
|
||||
let s = em / g.face.upem.max(1.0);
|
||||
let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s);
|
||||
let sc = scale_of(gm);
|
||||
if !sc.is_finite() || sc > 1e5 {
|
||||
continue;
|
||||
}
|
||||
let p: BezPath = gm * path.as_ref().clone();
|
||||
if p.elements().len() > 10_000 || p.bounding_box().width() > 1e6 || p.bounding_box().height() > 1e6 {
|
||||
continue;
|
||||
}
|
||||
ctx.fill_path(&p);
|
||||
if bold && g.face.weight < 600.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round));
|
||||
|
||||
@@ -71,6 +71,11 @@ const TILES: &[(&str, [u8; 8])] = &[
|
||||
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
|
||||
];
|
||||
|
||||
/// Every preset name, in the order of the tiles.
|
||||
pub fn presets() -> impl Iterator<Item = &'static str> {
|
||||
TILES.iter().map(|(n, _)| *n)
|
||||
}
|
||||
|
||||
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
|
||||
pub fn tile(name: &str) -> [u8; 8] {
|
||||
TILES
|
||||
|
||||
@@ -28,8 +28,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base);
|
||||
let cen = area.center();
|
||||
let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2));
|
||||
let span = (max - min).max(1e-9);
|
||||
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
|
||||
let span = crate::chart::positive_span(max - min);
|
||||
let ticks = if step > 0.0 { (((max - min) / step).round() as i64).clamp(1, 20) } else { 1 };
|
||||
ctx.set_transform(m);
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.set_paint(color(grid_col, 1.0));
|
||||
@@ -120,7 +120,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.data_labels || s.data_labels == Some(true) {
|
||||
if c.series_data_labels(s) {
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if let Some(v) = s.values.get(i).copied().flatten() {
|
||||
let t = c.data_label(s, v);
|
||||
|
||||
+44
-27
@@ -153,18 +153,28 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let band_c = t.band_col && c.is_multiple_of(2);
|
||||
let tint = |k: i32| deckcraft_color::apply(a, &[ColorTransform::Tint(k)]);
|
||||
let white = Rgba::WHITE;
|
||||
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
let text_color = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
// Text on a tinted cell never inherits the text colour, which a dark theme makes light and
|
||||
// unreadable on the light tint: PowerPoint's medium styles set it to the theme's dark colour
|
||||
// (dk1). A dark theme may keep that in dk1 and map tx1 away from it, or (Nocturne) put it in
|
||||
// lt1, so of the two the one contrasting more with the cell's fill is used.
|
||||
let dk1 = ctx.color(&ColorRef::scheme(SchemeSlot::Dk1), None);
|
||||
let lt1 = ctx.color(&ColorRef::scheme(SchemeSlot::Lt1), None);
|
||||
let on = |fill: Rgba| if dk1.contrast(fill) >= lt1.contrast(fill) { dk1 } else { lt1 };
|
||||
match kind {
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => CellLook {
|
||||
fill: if band || band_c { Some(tint(20000)) } else { None },
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((text_color, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => {
|
||||
let fill = (band || band_c).then(|| tint(20000));
|
||||
CellLook {
|
||||
fill,
|
||||
text: fill.map(on),
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
"light2" => CellLook {
|
||||
fill: if header { Some(a) } else { None },
|
||||
text: if header { Some(white) } else { None },
|
||||
@@ -187,29 +197,36 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"medium4" => CellLook {
|
||||
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
_ => CellLook {
|
||||
"medium4" => {
|
||||
let fill = if header || band || band_c { tint(40000) } else { tint(20000) };
|
||||
CellLook {
|
||||
fill: Some(fill),
|
||||
text: Some(on(fill)),
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// medium2: strong header, banded light tints, white grid.
|
||||
fill: Some(if header || total || first_col || last_col {
|
||||
let strong = header || total || first_col || last_col;
|
||||
let fill = if strong {
|
||||
a
|
||||
} else if band || band_c {
|
||||
tint(40000)
|
||||
} else {
|
||||
tint(20000)
|
||||
}),
|
||||
text: if header || total || first_col || last_col { Some(white) } else { None },
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
};
|
||||
CellLook {
|
||||
fill: Some(fill),
|
||||
text: Some(if strong { white } else { on(fill) }),
|
||||
bold: strong,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+158
-1
@@ -2,7 +2,7 @@ use super::*;
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
use deckcraft_model::{Chart, Geom, PhType, Placeholder, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
|
||||
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
@@ -35,6 +35,33 @@ fn shape_fill_uses_theme_accent() {
|
||||
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
/// #832: a quick style on an empty placeholder paints its fill. The style reference
|
||||
/// (`s.style`) carries the fill, but the empty-placeholder gate skipped the fill whenever the
|
||||
/// shape had no local fill, so the quick style only appeared once text was added. A
|
||||
/// placeholder with no style at all still shows nothing but its dashed outline.
|
||||
#[test]
|
||||
fn a_quick_style_fills_an_empty_placeholder() {
|
||||
let placeholder = || Shape {
|
||||
id: ShapeId(910),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
ph: Some(Placeholder { kind: PhType::Body, idx: 1, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![Shape { style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), ..placeholder() }]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() });
|
||||
let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
// Lower right of the shape: the prompt text sits at the top of the text rect.
|
||||
let px = img.pixel(280, 185);
|
||||
assert!(
|
||||
(px[0] as i32 - accent.r as i32).abs() <= 2 && (px[1] as i32 - accent.g as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2,
|
||||
"the quick style's fill is drawn on the empty placeholder: {px:?} vs {accent:?}"
|
||||
);
|
||||
// An unformatted empty placeholder stays empty: the gate is not simply gone.
|
||||
let p = deck_with(vec![placeholder()]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() });
|
||||
assert_eq!(img.pixel(280, 185), [255, 255, 255, 255], "an unformatted placeholder has no fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shapes_draws_the_selection_on_a_transparent_canvas() {
|
||||
let sh = Shape {
|
||||
@@ -340,6 +367,43 @@ fn chart_axis_titles_are_drawn() {
|
||||
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_series_draw_their_pattern_fill_and_no_fill_with_their_outline() {
|
||||
// Three column series, as PowerPoint draws them: a red-on-yellow pattern, no fill with a blue
|
||||
// outline, and no fill at all. None takes a palette colour; the legend keys match the bars.
|
||||
use deckcraft_model::style::{Fill, PatternFill};
|
||||
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = None;
|
||||
c.series.truncate(3);
|
||||
c.categories = vec!["A".into()];
|
||||
for s in &mut c.series {
|
||||
s.values = vec![Some(10.0)];
|
||||
}
|
||||
c.series[0].fill = Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: rgb(255, 0, 0), bg: rgb(255, 255, 0) }));
|
||||
c.series[1].fill = Some(Fill::None);
|
||||
c.series[1].line = Some(deckcraft_model::style::Line { width: Some(3.0), fill: Some(Fill::solid(rgb(0, 0, 255))), ..Default::default() });
|
||||
c.series[2].fill = Some(Fill::None);
|
||||
let sh =
|
||||
Shape { id: ShapeId(625), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c.clone())), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let count =
|
||||
|f: &dyn Fn([u8; 4]) -> bool| (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count();
|
||||
let red = count(&|p| p[0] > 220 && p[1] < 40 && p[2] < 40);
|
||||
let yellow = count(&|p| p[0] > 220 && p[1] > 220 && p[2] < 40);
|
||||
let blue = count(&|p| p[0] < 40 && p[1] < 40 && p[2] > 220);
|
||||
assert!(red > 1000 && yellow > 1000, "the pattern's two colours: {red} red, {yellow} yellow");
|
||||
assert!(blue > 300, "the empty series' outline: {blue}");
|
||||
// No palette colour: the theme's accents would show for any series drawn the old way.
|
||||
let p = deck_with(vec![]);
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
for i in 0..3 {
|
||||
let a = crate::chart::series_color(&ctx, &c, i);
|
||||
let n = count(&|q| q[0].abs_diff(a.r) < 6 && q[1].abs_diff(a.g) < 6 && q[2].abs_diff(a.b) < 6);
|
||||
assert!(n < 50, "palette colour {i} drawn on {n} pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
|
||||
// One column series where only the middle point is given a red c:dPt fill.
|
||||
@@ -1336,6 +1400,41 @@ fn a_table_fill_shows_behind_cells_without_their_own() {
|
||||
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_body_text_is_dark_on_the_tinted_cells_of_a_dark_theme() {
|
||||
// The default style (medium2) tints body cells towards white, so their text must be the
|
||||
// theme's dark colour, as PowerPoint's medium styles set it (dk1): inheriting the text colour
|
||||
// leaves it light, and unreadable, on a dark theme such as Nocturne.
|
||||
let fill_and_darkest = |theme: &str| {
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
for (r, c) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
t.cell_mut(r, c).unwrap().text = TextBody::from_text("Body");
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(796), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 80.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
let mut p = deck_with(vec![tbl]);
|
||||
let th = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == theme).unwrap();
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).theme = th;
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let lum = |x, y| {
|
||||
let px = img.pixel(x, y);
|
||||
deckcraft_color::Rgba::rgb(px[0], px[1], px[2]).luminance()
|
||||
};
|
||||
// Body cell (1, 1) covers (200, 140)–(300, 180); its top-left corner is clear of the text
|
||||
// (cell margins) and of the white grid line.
|
||||
let fill = lum(203, 143);
|
||||
let darkest = (143..177).flat_map(|y| (203..297).map(move |x| (x, y))).map(|(x, y)| lum(x, y)).fold(1.0_f64, f64::min);
|
||||
(fill, darkest)
|
||||
};
|
||||
for theme in ["Nocturne", "Harbor"] {
|
||||
let (fill, darkest) = fill_and_darkest(theme);
|
||||
assert!(fill > 0.4, "{theme}: the body cell's fill is a light tint, luminance {fill}");
|
||||
assert!(
|
||||
darkest < 0.15 && darkest < fill / 3.0,
|
||||
"{theme}: darkest pixel in the body cell has luminance {darkest} on a fill of {fill}: its text is not dark"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
|
||||
fn effect_box(effects: deckcraft_model::Effects) -> Image {
|
||||
use deckcraft_model::style::{Fill, Line};
|
||||
@@ -2717,3 +2816,61 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
|
||||
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
|
||||
/// there), 595959 grey on Harbor's light ones.
|
||||
#[test]
|
||||
fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() {
|
||||
let draw = |theme: &str, title: Option<&str>| {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = title.map(String::from);
|
||||
c.legend = None;
|
||||
c.data_labels = false;
|
||||
let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let mut p = deck_with(vec![sh]);
|
||||
let t = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == theme).expect("theme");
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).theme = t;
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let lum = |px: [u8; 4]| (px[0] as u32 * 299 + px[1] as u32 * 587 + px[2] as u32 * 114) / 1000;
|
||||
// Pixels in the title band that pass `f`.
|
||||
let count = |img: &Image, f: &dyn Fn(u32) -> bool| {
|
||||
(200..760u32).flat_map(|x| (4..70u32).map(move |y| (x, y))).filter(|(x, y)| f(lum(img.pixel(*x, *y)))).count()
|
||||
};
|
||||
let (dark_bare, dark_titled) = (draw("Nocturne", None), draw("Nocturne", Some("Quarterly revenue")));
|
||||
assert!(lum(dark_bare.pixel(5, 5)) < 60, "Nocturne's slide is dark: {:?}", dark_bare.pixel(5, 5));
|
||||
let light_ink = count(&dark_titled, &|l| l > 200) as i64 - count(&dark_bare, &|l| l > 200) as i64;
|
||||
assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})");
|
||||
let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue")));
|
||||
assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5));
|
||||
// 595959 has luminance 89: dark, but not black.
|
||||
let dark_ink = count(&light_titled, &|l| l < 120) as i64 - count(&light_bare, &|l| l < 120) as i64;
|
||||
assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})");
|
||||
let black_ink = count(&light_titled, &|l| l < 40) as i64 - count(&light_bare, &|l| l < 40) as i64;
|
||||
assert!(black_ink < 20, "the title is PowerPoint's 595959 grey, not black (black pixels added: {black_ink})");
|
||||
}
|
||||
|
||||
/// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options
|
||||
/// textColor, Font Color on the chart) colours them too, as it does the title and legend.
|
||||
#[test]
|
||||
fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() {
|
||||
let draw = |colour: Option<ColorRef>| {
|
||||
let mut c = Chart::sample(ChartType::Pie);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.data_labels = true;
|
||||
c.text_color = colour;
|
||||
let sh = Shape { id: ShapeId(632), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 400.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let count = |img: &Image, f: &dyn Fn([u8; 4]) -> bool| {
|
||||
(0..400u32).flat_map(|x| (0..400u32).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count()
|
||||
};
|
||||
let red = |p: [u8; 4]| p[0] > 200 && p[1] < 60 && p[2] < 60;
|
||||
let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245;
|
||||
let (plain, coloured) = (draw(None), draw(Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))));
|
||||
assert_eq!(count(&plain, &red), 0, "no red without a text colour");
|
||||
assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red));
|
||||
assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before");
|
||||
}
|
||||
|
||||
@@ -64,6 +64,25 @@ pub fn is_date_field(kind: &str) -> bool {
|
||||
kind.starts_with("datetime")
|
||||
}
|
||||
|
||||
/// The date and time field types PowerPoint's Header and Footer and Insert ▸ Date & Time offer,
|
||||
/// in the order the dialogs list them. `datetime` and `datetimeFigureOut` are read as `datetime1`
|
||||
/// (see [`field_text`]) but are not offered as a choice.
|
||||
pub const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
|
||||
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
|
||||
/// the format needs names in a language other than English: the field keeps its saved text.
|
||||
|
||||
@@ -192,6 +192,7 @@ fn preview_rejects_a_from_that_is_not_an_effect_index() {
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut h = harness();
|
||||
h.run_steps(2);
|
||||
let img = h.render().unwrap();
|
||||
|
||||
@@ -214,11 +214,12 @@ impl Backstage {
|
||||
}
|
||||
|
||||
/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail).
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 6] = [
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 7] = [
|
||||
("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"),
|
||||
("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"),
|
||||
("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"),
|
||||
("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"),
|
||||
("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"),
|
||||
("png", "PNG Picture (*.png)", "The current slide as a picture"),
|
||||
("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"),
|
||||
];
|
||||
@@ -388,6 +389,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(page_bg)).show(ui, |ui| {
|
||||
// Fields, boxes and buttons sit on the page's own colour: outline them, as PowerPoint does.
|
||||
ui.visuals_mut().text_edit_bg_color = Some(t.field_bg);
|
||||
let w = &mut ui.visuals_mut().widgets;
|
||||
w.inactive.bg_fill = t.field_bg;
|
||||
w.inactive.weak_bg_fill = t.field_bg;
|
||||
|
||||
@@ -561,3 +561,20 @@ fn print_slides_follow_the_settings() {
|
||||
bs.print_layout = "slides";
|
||||
assert_eq!(print_page_size(&bs, &doc), (doc.slide_size.width as f32, doc.slide_size.height as f32), "slides print slide-shaped");
|
||||
}
|
||||
|
||||
/// Options in the File tab opens the Preferences dialog, and Cancel closes it (#782, #752).
|
||||
#[test]
|
||||
fn options_in_backstage_opens_preferences_and_cancel_closes_it() {
|
||||
let mut h = harness(new_app());
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().backstage_open());
|
||||
h.get_by_label("Options").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some(), "Options opens Preferences dialog");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
|
||||
h.query_all_by_role_and_label(Role::Button, "Cancel").next().expect("Cancel button").click_accesskit();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog");
|
||||
assert!(h.state().backstage_open(), "Backstage remains open");
|
||||
}
|
||||
|
||||
+333
-27
@@ -889,34 +889,35 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
if let Err(e) = r {
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
// Cursor.
|
||||
let cursor = match (&app.session.tool.kind, &app.session.tool.hover) {
|
||||
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
||||
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(_, Some(Hit::Resize { handle, .. })) => match handle {
|
||||
0 | 4 => egui::CursorIcon::ResizeNwSe,
|
||||
2 | 6 => egui::CursorIcon::ResizeNeSw,
|
||||
1 | 5 => egui::CursorIcon::ResizeVertical,
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
egui::CursorIcon::Text
|
||||
} else {
|
||||
egui::CursorIcon::Move
|
||||
// Cursor. The Format and Animation Painters override everything: their whole point is to
|
||||
// tell the user the tool is armed and waiting for a click (#830).
|
||||
let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some();
|
||||
let cursor = if painter_armed {
|
||||
egui::CursorIcon::Copy
|
||||
} else {
|
||||
match (&app.session.tool.kind, &app.session.tool.hover) {
|
||||
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
||||
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle),
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
egui::CursorIcon::Text
|
||||
} else {
|
||||
egui::CursorIcon::Move
|
||||
}
|
||||
}
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
None => egui::CursorIcon::Default,
|
||||
},
|
||||
}
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
None => egui::CursorIcon::Default,
|
||||
},
|
||||
};
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
@@ -1060,6 +1061,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
.map(|q| xf.to_screen(f.affine * q));
|
||||
poly(painter, pts, Stroke::new(2.0, t.accent));
|
||||
}
|
||||
// Shade the selected table cells in the text-selection color, each merged cell as it is drawn.
|
||||
if let Some((id, ..)) = st.selection.cells
|
||||
&& st.selection.text.is_none()
|
||||
&& let Some(sh) = st.shape(id)
|
||||
&& let deckcraft_model::ShapeKind::Table(table) = &sh.kind
|
||||
&& let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh)
|
||||
{
|
||||
use deckcraft_engine::cmd::table::{in_range, selected_range};
|
||||
let range = selected_range(st.selection.cells, id, table);
|
||||
for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) {
|
||||
let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)];
|
||||
painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
// Smart guides, marquee, previews.
|
||||
for g in &app.session.tool.guides {
|
||||
let (a, b) =
|
||||
@@ -1218,7 +1233,39 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
resp.context_menu(|ui| {
|
||||
let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned()));
|
||||
if let Some(sh) = editing {
|
||||
if let Some((path, index)) = app.session.tool.edit_vertex {
|
||||
if let Some((path, index)) = app.session.tool.edit_segment {
|
||||
let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index);
|
||||
for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] {
|
||||
if ui.radio(cur == Some(k), l).clicked() {
|
||||
let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
// Each of these renumbers the points, so the picked segment is dropped.
|
||||
if ui.button("Add Point").clicked() {
|
||||
let t = app.session.tool.edit_segment_t.unwrap_or(0.5);
|
||||
let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t}));
|
||||
app.session.tool.edit_segment = None;
|
||||
ui.close();
|
||||
}
|
||||
let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index);
|
||||
if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() {
|
||||
let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index}));
|
||||
app.session.tool.edit_segment = None;
|
||||
ui.close();
|
||||
}
|
||||
// Open Path opens the outline at the segment's start point.
|
||||
let (label, id, at) = match closed {
|
||||
Some(false) => ("Close Path", "shape.closePath", index),
|
||||
_ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)),
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(id, json!({"path": path, "index": at}));
|
||||
app.session.tool.edit_segment = None;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
} else if let Some((path, index)) = app.session.tool.edit_vertex {
|
||||
let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index);
|
||||
for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] {
|
||||
if ui.radio(cur == Some(k), l).clicked() {
|
||||
@@ -1231,6 +1278,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
app.session.tool.edit_vertex = None;
|
||||
ui.close();
|
||||
}
|
||||
let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) {
|
||||
Some(false) => ("Close Path", "shape.closePath"),
|
||||
_ => ("Open Path", "shape.openPath"),
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(id, json!({"path": path, "index": index}));
|
||||
// The points are numbered from the new start now.
|
||||
app.session.tool.edit_vertex = None;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Exit Edit Points").clicked() {
|
||||
@@ -1483,17 +1540,157 @@ fn grid_fits(span: f32, step: f32) -> bool {
|
||||
span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0
|
||||
}
|
||||
|
||||
/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the
|
||||
/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the
|
||||
/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping
|
||||
/// reduces to the classic NW/NE/E/SE/S/SW/W assignment.
|
||||
fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon {
|
||||
// The resize handles are drawn from the shape's effective box (a placeholder inherits it
|
||||
// from its layout), so the cursor follows the same one.
|
||||
let Some(d) = session.active() else { return egui::CursorIcon::Default };
|
||||
let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default };
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh);
|
||||
// The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward
|
||||
// the handle. Corners resize along the diagonal, edge handles along their edge. The
|
||||
// direction is normalised - the diagonal at 45 degrees whatever the shape's proportions -
|
||||
// so a wide rectangle still shows a diagonal cursor on its corners.
|
||||
let (dx, dy): (f64, f64) = match handle {
|
||||
0 => (-1.0, -1.0),
|
||||
1 => (0.0, -1.0),
|
||||
2 => (1.0, -1.0),
|
||||
3 => (1.0, 0.0),
|
||||
4 => (1.0, 1.0),
|
||||
5 => (0.0, 1.0),
|
||||
6 => (-1.0, 1.0),
|
||||
_ => (-1.0, 0.0),
|
||||
};
|
||||
// Map through the linear part of the transform (rotation, flips): two points along the
|
||||
// axis, the difference is the slide-space direction.
|
||||
let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
let a = x.affine();
|
||||
let p0 = a * centre;
|
||||
let p1 = a * Point::new(centre.x + dx, centre.y + dy);
|
||||
let v = p1 - p0;
|
||||
if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) {
|
||||
return egui::CursorIcon::Default;
|
||||
}
|
||||
// Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down
|
||||
// (vertical), 135° = down-left (NE-SW). Pick the closest of the four.
|
||||
let mut deg = v.y.atan2(v.x).to_degrees();
|
||||
if deg < 0.0 {
|
||||
deg += 180.0;
|
||||
}
|
||||
if (22.5..67.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else if (67.5..112.5).contains(°) {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if (112.5..157.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
} else {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's
|
||||
/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old
|
||||
/// fixed handle-to-cursor mapping showed NW-SE there.
|
||||
#[test]
|
||||
fn resize_cursor_tracks_rotation() {
|
||||
let mut s = Session::with_new();
|
||||
// A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape
|
||||
// rotation cleanly moves the cursor to the next of the four resize directions.
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
// Unrotated: the NW handle (0) drags the NW-SE diagonal.
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical);
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal);
|
||||
// 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 45.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally");
|
||||
// 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 90.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal");
|
||||
}
|
||||
|
||||
/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The
|
||||
/// corner's direction is the diagonal, not the line to the box's centre, so a wide
|
||||
/// rectangle's NW corner still drags along the NW-SE diagonal.
|
||||
#[test]
|
||||
fn a_wide_shape_keeps_its_corner_cursors_diagonal() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW");
|
||||
// The edges keep their own cursors.
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally");
|
||||
}
|
||||
|
||||
/// #745: a placeholder that inherits its box from the layout still shows a resize cursor.
|
||||
/// Its handles are drawn from the inherited box, so the cursor follows that one.
|
||||
#[test]
|
||||
fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() {
|
||||
let s = Session::with_new();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = st
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.xfrm.is_none() && sh.ph.is_some())
|
||||
.expect("the new slide has a placeholder without a box of its own")
|
||||
.id;
|
||||
assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
@@ -1843,6 +2040,84 @@ mod tests {
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) {
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking
|
||||
/// a point offers Open Path, then Close Path once the outline is open.
|
||||
#[test]
|
||||
fn edit_points_menu_sets_segment_types_and_opens_the_path() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// The middle of the right edge: the segment ending at point 2.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Curved Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"]));
|
||||
// The bottom-right corner: Open Path breaks the outline there.
|
||||
let p = screen(h.state(), 500.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5));
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4));
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on
|
||||
/// a closed outline, and Open Path at the segment's start point.
|
||||
#[test]
|
||||
fn edit_points_segment_menu_adds_points_and_deletes_segments() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// A quarter of the way along the top edge: the new point goes there.
|
||||
let p = screen(h.state(), 275.0, 150.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Add Point").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]]));
|
||||
// The right edge: Open Path opens the outline at its top end.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0]));
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
// Delete Segment removes the right edge: the outline now runs from its bottom end to its top end.
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Delete Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]]));
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!(null));
|
||||
// On the open outline Delete Segment is grayed out; Close Path joins the ends again.
|
||||
let p = screen(h.state(), 350.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled());
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!("straight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
@@ -1854,6 +2129,37 @@ mod tests {
|
||||
assert!(super::grid_line_step(6.0, 0.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point");
|
||||
}
|
||||
|
||||
/// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the
|
||||
/// user can see the tool is waiting for a click.
|
||||
#[test]
|
||||
fn format_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
// Hover over the canvas so the pointer function runs and sets the cursor.
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
// Arming the format painter overrides the cursor to Copy (#830).
|
||||
h.state_mut().session.painter = Some((Default::default(), false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor");
|
||||
}
|
||||
|
||||
/// #830: the Animation Painter arms the same cursor.
|
||||
#[test]
|
||||
fn animation_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
h.state_mut().session.anim_painter = Some((vec![], false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! The Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… open it
|
||||
//! with the text box's values, and OK sets the number and spacing in one undo step.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 160.0], "text": "Hello"})).unwrap();
|
||||
let _ = s.execute("text.exit", &json!({}));
|
||||
harness_of(s)
|
||||
}
|
||||
|
||||
/// Two text boxes with different columns, both selected: 2 columns 9 pt apart, and 3 columns
|
||||
/// 36 pt apart.
|
||||
fn two_boxes() -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = Vec::new();
|
||||
for (y, count, spacing) in [(80.0, 2, 9.0), (300.0, 3, 36.0)] {
|
||||
ids.push(s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, y, 320.0, 160.0], "text": "Hello"})).unwrap()["id"].clone());
|
||||
let _ = s.execute("text.exit", &json!({}));
|
||||
s.execute("format.columns", &json!({"count": count, "spacing": spacing})).unwrap();
|
||||
}
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
harness_of(s)
|
||||
}
|
||||
|
||||
fn harness_of(s: Session) -> Harness<'static, SlideApp> {
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn columns_of(h: &Harness<'_, SlideApp>) -> (Option<u32>, Option<f64>) {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let body = st.selected_shapes().first().and_then(|sh| sh.text.as_ref()).map(|t| t.body.clone()).unwrap_or_default();
|
||||
(body.columns, body.col_spacing)
|
||||
}
|
||||
|
||||
fn columns_of_each(h: &Harness<'_, SlideApp>) -> Vec<(Option<u32>, Option<f64>)> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.selected_shapes().iter().map(|sh| sh.text.as_ref().map(|t| (t.body.columns, t.body.col_spacing)).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// Opens the dialog on `h`'s selection, types `fields` into it and presses OK.
|
||||
fn ok_with(h: &mut Harness<'_, SlideApp>, fields: &[(&str, &str)]) {
|
||||
h.state_mut().run("format.columns", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
for (key, value) in fields {
|
||||
d.fields.insert((*key).into(), (*value).into());
|
||||
}
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none(), "status {:?}", h.state().status);
|
||||
}
|
||||
|
||||
/// With text boxes whose columns differ, the dialog shows the first one's values, and a field
|
||||
/// left as shown does not overwrite the others' own values.
|
||||
#[test]
|
||||
fn a_field_left_as_shown_keeps_each_text_box_s_own_value() {
|
||||
let mut h = two_boxes();
|
||||
assert_eq!(columns_of_each(&h), [(Some(2), Some(9.0)), (Some(3), Some(36.0))]);
|
||||
ok_with(&mut h, &[("count", "4")]);
|
||||
assert_eq!(columns_of_each(&h), [(Some(4), Some(9.0)), (Some(4), Some(36.0))], "Number only");
|
||||
|
||||
let mut h = two_boxes();
|
||||
ok_with(&mut h, &[("spacing", "0.5")]);
|
||||
assert_eq!(columns_of_each(&h), [(Some(2), Some(36.0)), (Some(3), Some(36.0))], "Spacing only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_with_nothing_changed_closes_without_an_edit() {
|
||||
let mut h = two_boxes();
|
||||
let n = h.state().session.active().unwrap().history.undo.len();
|
||||
ok_with(&mut h, &[]);
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo.len(), n);
|
||||
assert_eq!(columns_of_each(&h), [(Some(2), Some(9.0)), (Some(3), Some(36.0))]);
|
||||
}
|
||||
|
||||
fn field(h: &Harness<'_, SlideApp>, key: &str) -> Option<String> {
|
||||
h.state().dialog.as_ref().and_then(|d| d.fields.get(key).cloned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_columns_sets_the_number_and_spacing_in_one_undo() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("format.columns", &json!({"count": 3, "spacing": 9.0})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Columns").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("More Columns…").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("columns"), "status {:?}", h.state().status);
|
||||
assert_eq!(field(&h, "count").as_deref(), Some("3"));
|
||||
assert_eq!(field(&h, "spacing").as_deref(), Some("0.12"));
|
||||
{
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
d.fields.insert("count".into(), "2".into());
|
||||
d.fields.insert("spacing".into(), "0.5".into());
|
||||
}
|
||||
let n = h.state().session.active().unwrap().history.undo.len();
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none(), "status {:?}", h.state().status);
|
||||
assert_eq!(columns_of(&h), (Some(2), Some(36.0)));
|
||||
let undo = &h.state().session.active().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), n + 1);
|
||||
assert_eq!(undo.last().unwrap().label, "Columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_with_the_spacing_unchanged_keeps_the_exact_spacing() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("format.columns", &json!({"count": 3, "spacing": 9.0})).unwrap();
|
||||
h.state_mut().run("format.columns", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(field(&h, "spacing").as_deref(), Some("0.12"), "shown rounded to two places");
|
||||
h.state_mut().dialog.as_mut().unwrap().fields.insert("count".into(), "4".into());
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none(), "status {:?}", h.state().status);
|
||||
assert_eq!(columns_of(&h), (Some(4), Some(9.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_shape_columns_button_opens_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.pane = Some("format".into());
|
||||
h.state_mut().ui.format_tab = "text".into();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Columns…").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("columns"), "status {:?}", h.state().status);
|
||||
assert_eq!(field(&h, "count").as_deref(), Some("1"));
|
||||
assert_eq!(field(&h, "spacing").as_deref(), Some("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_number_keeps_the_dialog_open_and_changes_nothing() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("format.columns", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
for (count, spacing) in [("0", "0.25"), ("17", "0.25"), ("two", "0.25"), ("2", "-1"), ("2", "wide")] {
|
||||
{
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
d.fields.insert("count".into(), count.into());
|
||||
d.fields.insert("spacing".into(), spacing.into());
|
||||
}
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("columns"), "{count} / {spacing}");
|
||||
assert!(field(&h, "error").is_some_and(|e| !e.is_empty()), "{count} / {spacing}");
|
||||
assert_eq!(columns_of(&h), (None, None), "{count} / {spacing}");
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,22 @@ fn pane_opens_lists_changes_and_accept_all_applies_them() {
|
||||
assert!(h.state().session.active().unwrap().compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_empties_the_pane_and_keeps_the_deck() {
|
||||
let (mut h, n) = harness();
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
h.get_by_label_contains("Reject All").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "nothing applied");
|
||||
assert_eq!(st.doc.slides.len(), n);
|
||||
assert!(h.query_by_label("No differences left.").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let (mut h, _) = harness();
|
||||
// Review tab.
|
||||
h.state_mut().ui.tab = "Review".into();
|
||||
|
||||
+213
-22
@@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str {
|
||||
"altText" => "Alt Text",
|
||||
"outline" => "Slides from Outline",
|
||||
"renameSection" => "Rename Section",
|
||||
"reuseSlides" => "Reuse Slides",
|
||||
"renameShape" => "Rename",
|
||||
"renameLayout" => "Rename Layout",
|
||||
"paragraph" => "Paragraph",
|
||||
@@ -51,6 +52,7 @@ fn title(id: &str) -> &'static str {
|
||||
"preferences" => "Preferences",
|
||||
"autocorrect" => "AutoCorrect",
|
||||
"gridOptions" => "Grid Options",
|
||||
"columns" => "Columns",
|
||||
"export" => "Export",
|
||||
"symbol" => "Symbol",
|
||||
"photoAlbum" => "Photo Album",
|
||||
@@ -1091,6 +1093,25 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
|
||||
let date = check(ui, d, "Date and time", "date", hf.date);
|
||||
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
|
||||
// The format applies to an automatic date; a fixed one is the text as typed.
|
||||
let automatic = d.get("dateText", &hf.date_text).is_empty();
|
||||
let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() };
|
||||
// Not `d.get`: that stores the default as if the user had picked it.
|
||||
let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone());
|
||||
let rows = date_rows("en-US");
|
||||
let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone());
|
||||
ui.add_enabled_ui(date && automatic, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(" Format:");
|
||||
egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| {
|
||||
for (kind, sample) in &rows {
|
||||
if ui.selectable_label(fmt == *kind, sample).clicked() {
|
||||
d.fields.insert("dateFormat".into(), (*kind).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
|
||||
let foot = check(ui, d, "Footer", "footer", hf.footer);
|
||||
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
|
||||
@@ -1108,11 +1129,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if apply || apply_all {
|
||||
let dt = d.get("dateText", "");
|
||||
let ft = d.get("footerText", "");
|
||||
run(
|
||||
app,
|
||||
"design.headerFooter",
|
||||
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
|
||||
);
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("date".into(), json!(date));
|
||||
params.insert("dateText".into(), json!(dt));
|
||||
// Only a format the user picked: sending the default would rewrite every slide's
|
||||
// existing date field (an imported datetime4, say) to datetime1.
|
||||
if d.fields.contains_key("dateFormat") {
|
||||
params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now)));
|
||||
}
|
||||
params.insert("slideNumber".into(), json!(num_on));
|
||||
params.insert("footer".into(), json!(foot));
|
||||
params.insert("footerText".into(), json!(ft));
|
||||
params.insert("hideOnTitle".into(), json!(hide));
|
||||
params.insert("all".into(), json!(apply_all));
|
||||
run(app, "design.headerFooter", serde_json::Value::Object(params));
|
||||
}
|
||||
apply || apply_all || d.fields.contains_key("cancel")
|
||||
}
|
||||
@@ -1537,6 +1567,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"reuseSlides" => {
|
||||
let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let slides: Vec<(usize, String)> = d
|
||||
.params
|
||||
.get("slides")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| {
|
||||
Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
ui.label(format!("Insert slides from {name} after the current slide:"));
|
||||
// Unticked slides are remembered by index; everything starts ticked.
|
||||
let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Select All").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
}
|
||||
}
|
||||
if ui.small_button("Select None").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (i, title) in &slides {
|
||||
let mut on = !off(d, *i);
|
||||
let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) };
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if on {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
} else {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut keep, "Keep source formatting").changed()
|
||||
&& let Some(o) = d.params.as_object_mut()
|
||||
{
|
||||
o.insert("keep".into(), json!(keep));
|
||||
}
|
||||
let picked: Vec<usize> = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect();
|
||||
let (ok, cancel) = buttons(ui, "Insert");
|
||||
if ok {
|
||||
if picked.is_empty() {
|
||||
app.set_status("Tick at least one slide to insert".to_string());
|
||||
return false;
|
||||
}
|
||||
app.reuse_slides(&path, keep, &picked);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"renameSection" | "renameShape" | "renameLayout" => {
|
||||
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
field(ui, d, "Name:", "name", &cur);
|
||||
@@ -1565,6 +1655,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("PowerPoint Presentation (.pptx)", "pptx"),
|
||||
("Outline (.txt)", "outline"),
|
||||
("Animated GIF (.gif)", "gif"),
|
||||
("Outline/RTF (.rtf)", "rtf"),
|
||||
] {
|
||||
if ui.radio(fmt == f, l).clicked() {
|
||||
d.fields.insert("fmt".into(), f.into());
|
||||
@@ -2110,8 +2201,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
_ => about(app, ui),
|
||||
}
|
||||
let (ok, _) = buttons(ui, "Close");
|
||||
ok
|
||||
let (ok, cancel) = buttons(ui, "Close");
|
||||
ok || cancel
|
||||
}
|
||||
"autocorrect" => {
|
||||
let pr = &mut app.session.prefs;
|
||||
@@ -2121,6 +2212,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key");
|
||||
ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)");
|
||||
ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript");
|
||||
ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists");
|
||||
ui.checkbox(&mut pr.autocorrect, "Replace text as you type");
|
||||
ui.add_space(4.0);
|
||||
let mut from = d.get("from", "");
|
||||
@@ -2157,6 +2249,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
"gridOptions" => grid_options(app, ui, d),
|
||||
"columns" => columns_dialog(app, ui, d),
|
||||
"preferences" => {
|
||||
// The controls apply as they change, so the dialog remembers what it opened with and
|
||||
// Cancel (or Esc, or the close button: see `show`) puts that back.
|
||||
@@ -2466,21 +2559,7 @@ const DATE_LANGS: &[(&str, &str)] = &[
|
||||
("sv-SE", "Swedish"),
|
||||
];
|
||||
|
||||
const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
use deckcraft_text::datetime::DATE_KINDS;
|
||||
|
||||
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
|
||||
/// other languages keep the numeric date and the 24-hour times.
|
||||
@@ -2586,6 +2665,61 @@ fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
cancel
|
||||
}
|
||||
|
||||
/// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches,
|
||||
/// sent in points). A field left as the dialog showed it is left out, so each selected text box
|
||||
/// keeps its own value; `None` when neither field changed.
|
||||
fn columns_value(d: &Dialog, shown_count: &str, shown_spacing: &str) -> Result<Option<Value>, String> {
|
||||
let mut p = serde_json::Map::new();
|
||||
let count = field_of(d, "count");
|
||||
if count != shown_count {
|
||||
let n = count.trim().parse::<u32>().ok().filter(|n| (1..=16).contains(n)).ok_or("Number must be a whole number from 1 to 16")?;
|
||||
p.insert("count".into(), json!(n));
|
||||
}
|
||||
let spacing = field_of(d, "spacing");
|
||||
if spacing != shown_spacing {
|
||||
let inches = spacing
|
||||
.trim()
|
||||
.trim_end_matches("in")
|
||||
.trim_end_matches('"')
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite() && (0.0..=22.0).contains(v))
|
||||
.ok_or("Spacing must be from 0 to 22 inches")?;
|
||||
p.insert("spacing".into(), json!(inches * 72.0));
|
||||
}
|
||||
Ok((!p.is_empty()).then_some(Value::Object(p)))
|
||||
}
|
||||
|
||||
/// Columns: the number of columns in the text box and the space between them. OK applies one
|
||||
/// `format.columns` call with the fields that changed, or none when neither did; Cancel leaves
|
||||
/// the text box as it was.
|
||||
fn columns_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let count = d.params.get("count").and_then(Value::as_u64).unwrap_or(1).to_string();
|
||||
let spacing = num_text(d.params.get("spacing").and_then(Value::as_f64).unwrap_or(0.0) / 72.0);
|
||||
field(ui, d, "Number:", "count", &count);
|
||||
field(ui, d, "Spacing (in):", "spacing", &spacing);
|
||||
if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30)));
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let applied = columns_value(d, &count, &spacing).and_then(|p| match p {
|
||||
Some(p) => app.run("format.columns", p).map(|_| ()),
|
||||
None => Ok(()),
|
||||
});
|
||||
match applied {
|
||||
Ok(()) => return true,
|
||||
Err(e) => {
|
||||
d.fields.insert("error".into(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
fn pretty_equation(s: &str) -> String {
|
||||
let sup = |c: char| match c {
|
||||
'0' => '⁰',
|
||||
@@ -3302,6 +3436,10 @@ mod font_dialog_tests;
|
||||
#[path = "size_position_tests.rs"]
|
||||
mod size_position_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "columns_dialog_tests.rs"]
|
||||
mod columns_dialog_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "photo_album_tests.rs"]
|
||||
mod photo_album_tests;
|
||||
@@ -3406,6 +3544,55 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// Header & Footer ▸ Apply without picking a format leaves a slide's existing date field in
|
||||
/// its own format instead of rewriting it to the default one.
|
||||
#[test]
|
||||
fn header_footer_apply_keeps_an_existing_date_format() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime4", "all": true})).unwrap();
|
||||
// A file whose document-level format is unset, as an imported deck's is.
|
||||
Arc::make_mut(&mut session.doc_mut().unwrap().doc).header_footer.date_format = String::new();
|
||||
let date_fields = |app: &SlideApp| -> Vec<String> {
|
||||
app.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.doc
|
||||
.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(deckcraft_model::PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.filter_map(|r| match &r.kind {
|
||||
deckcraft_model::text::RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, crate::Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()]);
|
||||
harness.state_mut().run("app.dialog", json!({"id": "headerFooter"})).unwrap();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Apply").click_accesskit();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()], "the dialog left the field's format alone");
|
||||
}
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
@@ -3762,3 +3949,7 @@ mod about_credits_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "preferences_tests.rs"]
|
||||
mod preferences_tests;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Text fields must show where they are: in the light theme the field was as white as the dialog,
|
||||
//! pane or ribbon it sits on and had no outline, so only its label (or hint) was visible (#1204).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::Role;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Color32, Rect, Stroke};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp, theme};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The largest per-channel difference between two colours.
|
||||
fn distance(a: Color32, b: Color32) -> u8 {
|
||||
a.r().abs_diff(b.r()).max(a.g().abs_diff(b.g())).max(a.b().abs_diff(b.b()))
|
||||
}
|
||||
|
||||
/// The frame egui painted for the text field at `field` (its fill and outline).
|
||||
fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, Stroke) {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|c| match &c.shape {
|
||||
Shape::Rect(r) if r.rect.min.distance(field.min) < 2.0 && r.rect.max.distance(field.max) < 2.0 => Some((r.fill, r.stroke)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no frame painted for the field at {field:?}"))
|
||||
}
|
||||
|
||||
/// What `c` shows when painted over the opaque `surface`. `Color32` is premultiplied, so each
|
||||
/// channel is `c + surface * (255 - alpha) / 255`.
|
||||
fn over(c: Color32, surface: Color32) -> Color32 {
|
||||
let behind = |s: u8| (u16::from(s) * u16::from(255 - c.a()) / 255) as u8;
|
||||
Color32::from_rgb(c.r().saturating_add(behind(surface.r())), c.g().saturating_add(behind(surface.g())), c.b().saturating_add(behind(surface.b())))
|
||||
}
|
||||
|
||||
/// A frame with this `fill` and `stroke` stands apart from the opaque `surface`: it paints a fill
|
||||
/// or an outline (at least 1 px wide) that, as actually shown over the surface, differs from it.
|
||||
fn stands_apart(fill: Color32, stroke: Stroke, surface: Color32) -> bool {
|
||||
let outlined = stroke.width >= 1.0 && distance(over(stroke.color, surface), surface) >= 24;
|
||||
outlined || distance(over(fill, surface), surface) >= 16
|
||||
}
|
||||
|
||||
/// The field stands apart from `surface` by its fill or by its outline.
|
||||
fn assert_visible(h: &Harness<'_, SlideApp>, what: &str, surface: Color32) {
|
||||
let fields: Vec<Rect> = h.query_all_by_role(Role::TextInput).map(|n| n.rect()).collect();
|
||||
assert!(!fields.is_empty(), "{what}: no text field");
|
||||
for field in fields {
|
||||
let (fill, stroke) = frame_at(h, field);
|
||||
assert!(
|
||||
stands_apart(fill, stroke, surface),
|
||||
"{what}: the field at {field:?} (fill {fill:?}, outline {stroke:?}) can't be told from the {surface:?} behind it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_painted_fill_or_outline_stands_apart() {
|
||||
let surface = Color32::WHITE;
|
||||
// Stroke::NONE is transparent black: nothing is painted, whatever its RGB.
|
||||
assert!(!stands_apart(surface, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::WHITE, Stroke::NONE, Color32::from_rgb(250, 250, 250)));
|
||||
// A fully transparent fill shows the surface.
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::new(0.5, Color32::BLACK), surface));
|
||||
// A transparent outline paints nothing either.
|
||||
assert!(!stands_apart(surface, Stroke::new(1.0, Color32::TRANSPARENT), surface));
|
||||
// A real 1 px dark outline, or a clearly different opaque fill, does.
|
||||
assert!(stands_apart(surface, Stroke::new(1.0, Color32::from_gray(120)), surface));
|
||||
assert!(stands_apart(Color32::from_gray(200), Stroke::NONE, surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() {
|
||||
let light = theme::Tokens::light();
|
||||
|
||||
// Home › Find: "Find what:" in the dialog card.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().run("app.find", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Find and Replace", h.ctx.global_style().visuals.window_fill);
|
||||
// Still visible with the keyboard in it.
|
||||
let field = h.get_by_role(Role::TextInput).rect();
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.focused().is_some()), "the click puts the keyboard in the field");
|
||||
let (fill, stroke) = frame_at(&h, field);
|
||||
// Same surface as the dialog check above: the field sits on the dialog's window fill.
|
||||
let surface = h.ctx.global_style().visuals.window_fill;
|
||||
assert!(stands_apart(fill, stroke, surface), "focused field: fill {fill:?}, outline {stroke:?}");
|
||||
|
||||
// Review › Thesaurus: the search box on the task pane.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().ui.pane = Some("thesaurus".into());
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Thesaurus pane", light.card);
|
||||
|
||||
// Transitions › Duration: the spinner on the ribbon.
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.tab = "Transitions".into();
|
||||
let h = harness(app);
|
||||
assert_visible(&h, "Transitions ribbon", light.card);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! The gradient editor of the Format pane (shape fill and slide background): preset variations,
|
||||
//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops).
|
||||
//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops); and
|
||||
//! the pattern editor: a gallery of the preset patterns with foreground and background colors.
|
||||
|
||||
use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop, PatternFill};
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -321,3 +322,186 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||
ui.data_mut(|d| d.insert_temp(sel_id, sel));
|
||||
out
|
||||
}
|
||||
|
||||
/// The gallery names of the DrawingML preset patterns.
|
||||
const PATTERN_LABELS: [(&str, &str); 54] = [
|
||||
("pct5", "5%"),
|
||||
("pct10", "10%"),
|
||||
("pct20", "20%"),
|
||||
("pct25", "25%"),
|
||||
("pct30", "30%"),
|
||||
("pct40", "40%"),
|
||||
("pct50", "50%"),
|
||||
("pct60", "60%"),
|
||||
("pct70", "70%"),
|
||||
("pct75", "75%"),
|
||||
("pct80", "80%"),
|
||||
("pct90", "90%"),
|
||||
("horz", "Horizontal"),
|
||||
("vert", "Vertical"),
|
||||
("ltHorz", "Light horizontal"),
|
||||
("ltVert", "Light vertical"),
|
||||
("dkHorz", "Dark horizontal"),
|
||||
("dkVert", "Dark vertical"),
|
||||
("narHorz", "Narrow horizontal"),
|
||||
("narVert", "Narrow vertical"),
|
||||
("dashHorz", "Dashed horizontal"),
|
||||
("dashVert", "Dashed vertical"),
|
||||
("cross", "Cross"),
|
||||
("dnDiag", "Downward diagonal"),
|
||||
("upDiag", "Upward diagonal"),
|
||||
("ltDnDiag", "Light downward diagonal"),
|
||||
("ltUpDiag", "Light upward diagonal"),
|
||||
("dkDnDiag", "Dark downward diagonal"),
|
||||
("dkUpDiag", "Dark upward diagonal"),
|
||||
("wdDnDiag", "Wide downward diagonal"),
|
||||
("wdUpDiag", "Wide upward diagonal"),
|
||||
("dashDnDiag", "Dashed downward diagonal"),
|
||||
("dashUpDiag", "Dashed upward diagonal"),
|
||||
("diagCross", "Diagonal cross"),
|
||||
("smCheck", "Small checkerboard"),
|
||||
("lgCheck", "Large checkerboard"),
|
||||
("smGrid", "Small grid"),
|
||||
("lgGrid", "Large grid"),
|
||||
("dotGrid", "Dotted grid"),
|
||||
("smConfetti", "Small confetti"),
|
||||
("lgConfetti", "Large confetti"),
|
||||
("horzBrick", "Horizontal brick"),
|
||||
("diagBrick", "Diagonal brick"),
|
||||
("solidDmnd", "Solid diamond"),
|
||||
("openDmnd", "Outlined diamond"),
|
||||
("dotDmnd", "Dotted diamond"),
|
||||
("plaid", "Plaid"),
|
||||
("sphere", "Sphere"),
|
||||
("weave", "Weave"),
|
||||
("divot", "Divot"),
|
||||
("shingle", "Shingle"),
|
||||
("wave", "Wave"),
|
||||
("trellis", "Trellis"),
|
||||
("zigZag", "Zig zag"),
|
||||
];
|
||||
|
||||
/// The presets the gallery offers, in its 6 rows of 8. `horz`, `vert`, `cross`, `dnDiag`, `upDiag`
|
||||
/// and `diagCross` are left out, as in PowerPoint's gallery; a fill using one still draws.
|
||||
const PATTERN_GALLERY: [&str; 48] = [
|
||||
"pct5",
|
||||
"pct10",
|
||||
"pct20",
|
||||
"pct25",
|
||||
"pct30",
|
||||
"pct40",
|
||||
"pct50",
|
||||
"pct60",
|
||||
"pct70",
|
||||
"pct75",
|
||||
"pct80",
|
||||
"pct90",
|
||||
"ltDnDiag",
|
||||
"ltUpDiag",
|
||||
"dkDnDiag",
|
||||
"dkUpDiag",
|
||||
"wdDnDiag",
|
||||
"wdUpDiag",
|
||||
"ltVert",
|
||||
"ltHorz",
|
||||
"narVert",
|
||||
"narHorz",
|
||||
"dkVert",
|
||||
"dkHorz",
|
||||
"dashDnDiag",
|
||||
"dashUpDiag",
|
||||
"dashHorz",
|
||||
"dashVert",
|
||||
"smConfetti",
|
||||
"lgConfetti",
|
||||
"zigZag",
|
||||
"wave",
|
||||
"diagBrick",
|
||||
"horzBrick",
|
||||
"weave",
|
||||
"plaid",
|
||||
"divot",
|
||||
"dotGrid",
|
||||
"dotDmnd",
|
||||
"shingle",
|
||||
"trellis",
|
||||
"sphere",
|
||||
"smGrid",
|
||||
"lgGrid",
|
||||
"smCheck",
|
||||
"lgCheck",
|
||||
"openDmnd",
|
||||
"solidDmnd",
|
||||
];
|
||||
|
||||
/// The presets the pattern gallery offers, in gallery order.
|
||||
pub fn pattern_gallery() -> impl Iterator<Item = &'static str> {
|
||||
PATTERN_GALLERY.into_iter()
|
||||
}
|
||||
|
||||
/// The name the gallery shows for preset `name` (the preset itself when it has none).
|
||||
pub fn pattern_label(name: &str) -> &str {
|
||||
PATTERN_LABELS.iter().find(|(n, _)| *n == name).map_or(name, |(_, l)| l)
|
||||
}
|
||||
|
||||
/// The command parameter for `pt` (`shape.fill {pattern}` / `design.background {pattern}`).
|
||||
pub fn pattern_json(pt: &PatternFill) -> Value {
|
||||
json!({"preset": pt.preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)})
|
||||
}
|
||||
|
||||
/// Paint preset `name` into `r`, two points per pattern cell.
|
||||
fn paint_pattern(p: &egui::Painter, r: Rect, name: &str, fg: Color32, bg: Color32) {
|
||||
const CELL: f32 = 2.0;
|
||||
p.rect_filled(r, CornerRadius::ZERO, bg);
|
||||
let tile = deckcraft_render::pattern_tile(name);
|
||||
let (nx, ny) = ((r.width() / CELL).floor().max(0.0) as usize, (r.height() / CELL).floor().max(0.0) as usize);
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
if deckcraft_render::pattern_is_fg(&tile, x, y) {
|
||||
let min = r.min + vec2(x as f32 * CELL, y as f32 * CELL);
|
||||
p.rect_filled(Rect::from_min_size(min, vec2(CELL, CELL)), CornerRadius::ZERO, fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor for `pt`: a gallery of the presets drawn in the current colors, then Foreground and
|
||||
/// Background color menus. Returns the new pattern when the user changed something.
|
||||
pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option<PatternFill> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut out: Option<PatternFill> = None;
|
||||
let (fg, bg) = (c32(&pt.fg, sc), c32(&pt.bg, sc));
|
||||
ui.label("Pattern");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
for name in pattern_gallery() {
|
||||
let on = pt.preset == name;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
paint_pattern(ui.painter(), r.shrink(2.0), name, fg, bg);
|
||||
let stroke = if on || resp.hovered() || resp.has_focus() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
|
||||
ui.painter().rect_stroke(r.shrink(1.0), CornerRadius::same(2), stroke, egui::StrokeKind::Inside);
|
||||
let label = pattern_label(name);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label));
|
||||
if resp.on_hover_text(label).clicked() && !on {
|
||||
out = Some(PatternFill { preset: name.to_string(), ..pt.clone() });
|
||||
}
|
||||
}
|
||||
});
|
||||
for (label, foreground) in [("Foreground", true), ("Background", false)] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(80.0, 20.0), egui::Label::new(label));
|
||||
let cur = if foreground { &pt.fg } else { &pt.bg };
|
||||
let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
ui.painter().rect(sw, CornerRadius::same(3), c32(cur, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true);
|
||||
let name = format!("{label} color");
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, sc, None) {
|
||||
out = Some(if foreground { PatternFill { fg: c, ..pt.clone() } } else { PatternFill { bg: c, ..pt.clone() } });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Tests that render a harness run one at a time in each test process.
|
||||
//!
|
||||
//! `Harness::render` creates a wgpu device on its first call, and egui_kittest prefers a CPU
|
||||
//! adapter. When `cargo test` runs several of these tests on parallel threads, the test binary can
|
||||
//! exit with `0xc0000005` (`STATUS_ACCESS_VIOLATION`). Under cargo-nextest each test is its own
|
||||
//! process and the lock has no effect.
|
||||
//!
|
||||
//! Every test that calls `render()` on a harness takes [`gpu_lock`] on its first line
|
||||
//! (`let _gpu = crate::gpu_lock::gpu_lock();`) and holds the guard for its whole run.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Returns a guard that the test holds for its whole run, so tests that render run one at a time.
|
||||
/// A test that failed while holding it does not block the tests after it.
|
||||
pub(crate) fn gpu_lock() -> MutexGuard<'static, ()> {
|
||||
GPU_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panicked_holder_does_not_block_the_next_test() {
|
||||
let _ = std::thread::spawn(|| {
|
||||
let _gpu = gpu_lock();
|
||||
panic!("a GPU test failed");
|
||||
})
|
||||
.join();
|
||||
drop(gpu_lock());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holders_take_turns() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
static INSIDE: AtomicUsize = AtomicUsize::new(0);
|
||||
static MAX: AtomicUsize = AtomicUsize::new(0);
|
||||
let threads: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
std::thread::spawn(|| {
|
||||
let _gpu = gpu_lock();
|
||||
let now = INSIDE.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
MAX.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
INSIDE.fetch_sub(1, Ordering::SeqCst);
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for t in threads {
|
||||
assert!(t.join().is_ok());
|
||||
}
|
||||
assert_eq!(MAX.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn turn_guides_on_drag_one_and_drop_it_off_the_slide() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn pen_a_rectangle_then_ink_to_shape() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
|
||||
+110
-5
@@ -154,7 +154,7 @@ fn open_command(name: &str) -> Option<&'static str> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if is_presentation_name(&lower) {
|
||||
Some("file.openBytes")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
@@ -175,7 +175,7 @@ fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> {
|
||||
Some("insert.audio")
|
||||
} else if ct.starts_with("video/") {
|
||||
Some("insert.video")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
@@ -244,6 +244,10 @@ pub struct UiState {
|
||||
pub notes: bool,
|
||||
pub notes_height: f32,
|
||||
pub thumbs_width: f32,
|
||||
/// Vertical scroll offset of the thumbnail pane, recorded each frame for the control
|
||||
/// channel and tests; not persisted.
|
||||
#[serde(skip)]
|
||||
pub thumbs_scroll: f32,
|
||||
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`,
|
||||
/// `smartart` (Text Pane).
|
||||
pub pane: Option<String>,
|
||||
@@ -286,6 +290,7 @@ impl Default for UiState {
|
||||
notes: true,
|
||||
notes_height: 52.0,
|
||||
thumbs_width: 236.0,
|
||||
thumbs_scroll: 0.0,
|
||||
pane: None,
|
||||
pane_width: 300.0,
|
||||
ruler: false,
|
||||
@@ -752,6 +757,7 @@ impl SlideApp {
|
||||
"jpeg" | "jpg" => ("jpg", "jpeg", false),
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"rtf" => ("rtf", "rtf", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
"gif" => ("gif", "gif", false),
|
||||
other => {
|
||||
@@ -1264,7 +1270,20 @@ impl SlideApp {
|
||||
return;
|
||||
};
|
||||
let Some(path) = pick("open") else { return };
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) {
|
||||
// Offer the file's slides to choose from; all of them are ticked to begin with.
|
||||
match self.session.execute("slide.reuseList", &json!({"path": path})) {
|
||||
Ok(list) => {
|
||||
let mut d = dialogs::Dialog::new("reuseSlides");
|
||||
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
|
||||
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
|
||||
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
@@ -1474,7 +1493,7 @@ impl SlideApp {
|
||||
}
|
||||
};
|
||||
let params = if cmd == "slide.fromOutline" {
|
||||
json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
|
||||
json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
|
||||
} else {
|
||||
json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)})
|
||||
};
|
||||
@@ -1714,7 +1733,20 @@ impl SlideApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
|
||||
let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some();
|
||||
// While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow
|
||||
// keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon
|
||||
// buttons), and that stray focus would swallow every later key and end text entry. Let it
|
||||
// go; a real text field or an open menu keeps its keys.
|
||||
if !modal
|
||||
&& self.session.active().is_some_and(|d| d.selection.text.is_some())
|
||||
&& !ctx.text_edit_focused()
|
||||
&& !ctx.any_popup_open()
|
||||
&& let Some(id) = ctx.memory(|m| m.focused())
|
||||
{
|
||||
ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || modal {
|
||||
return;
|
||||
}
|
||||
if self.backstage.is_some() {
|
||||
@@ -2084,8 +2116,12 @@ mod compare_tests;
|
||||
#[cfg(test)]
|
||||
mod eyedropper_tests;
|
||||
#[cfg(test)]
|
||||
mod field_frame_tests;
|
||||
#[cfg(test)]
|
||||
mod find_dialog_tests;
|
||||
#[cfg(test)]
|
||||
mod gpu_lock;
|
||||
#[cfg(test)]
|
||||
mod huge_deck_tests;
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
@@ -2555,6 +2591,39 @@ mod tests {
|
||||
assert_eq!(level(&harness), 1);
|
||||
}
|
||||
|
||||
/// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus
|
||||
/// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the
|
||||
/// list could neither be typed into nor continued with Enter.
|
||||
#[test]
|
||||
fn tab_in_an_edited_list_keeps_the_keyboard() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("one".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Tab); // Increase List Level
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("sub".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter); // continues the list
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("two".into()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text");
|
||||
let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap();
|
||||
let levels: Vec<u8> = body.paragraphs.iter().map(|p| p.level).collect();
|
||||
assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
@@ -2735,6 +2804,41 @@ mod tests {
|
||||
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
|
||||
}
|
||||
|
||||
/// About dialog: Close and Cancel each close the dialog.
|
||||
#[test]
|
||||
fn about_dialog_close_and_cancel_close_it() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Cancel button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Cancel closes About dialog");
|
||||
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Close")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Close button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Close closes About dialog");
|
||||
}
|
||||
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
@@ -2765,6 +2869,7 @@ mod tests {
|
||||
/// they can be selected and moved.
|
||||
#[test]
|
||||
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut harness = canvas_harness();
|
||||
let off =
|
||||
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
|
||||
|
||||
+118
-30
@@ -156,8 +156,8 @@ fn run(app: &mut SlideApp, id: &str, p: Value) {
|
||||
}
|
||||
|
||||
/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s
|
||||
/// `transparency` parameter writes as an alpha transform. Anything else — no fill, a
|
||||
/// gradient, a picture, a pattern — is 0, because that command only changes a solid.
|
||||
/// `transparency` parameter writes as an alpha transform. Anything else (no fill, a
|
||||
/// gradient, a picture, a pattern) is 0.
|
||||
fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 {
|
||||
let Some(Fill::Solid { color }) = fill else {
|
||||
return 0.0;
|
||||
@@ -415,6 +415,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "format.columns", json!({"count": n, "spacing": 18}));
|
||||
}
|
||||
}
|
||||
if ui.small_button("Columns…").clicked() {
|
||||
run(app, "format.columns", json!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
"picture" => {
|
||||
@@ -475,15 +478,29 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
color_button(app, ui, "Color", Some("No Fill"), |c| match c {
|
||||
Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})),
|
||||
None => ("shape.fill".into(), json!({"none": true})),
|
||||
});
|
||||
// The Color button sets a solid fill, so it shows only for a solid or automatic fill. A
|
||||
// gradient takes its colors from its stops, and a pattern takes them from its Foreground
|
||||
// and Background menus below.
|
||||
if matches!(kind, "solid" | "auto") {
|
||||
color_button(app, ui, "Color", Some("No Fill"), |c| match c {
|
||||
Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})),
|
||||
None => ("shape.fill".into(), json!({"none": true})),
|
||||
});
|
||||
}
|
||||
// `shape.fill {transparency}` changes a solid fill (an automatic one through its
|
||||
// effective color) and a picture fill; a gradient has a transparency per stop.
|
||||
let transparency = matches!(sh.fill, None | Some(Fill::Solid { .. } | Fill::Picture(_)));
|
||||
ui.horizontal(|ui| {
|
||||
if !transparency {
|
||||
return;
|
||||
}
|
||||
ui.label("Transparency");
|
||||
// Seed from this shape. A shared temp defaulted to 0, so a shape that was
|
||||
// already 40% transparent showed as opaque, and leaving the slider wrote 0.
|
||||
let seed = solid_fill_transparency_percent(sh.fill.as_ref());
|
||||
let seed = match &sh.fill {
|
||||
Some(Fill::Picture(pf)) => ((1.0 - pf.alpha.unwrap_or(1.0)) * 100.0) as f32,
|
||||
fill => solid_fill_transparency_percent(fill.as_ref()),
|
||||
};
|
||||
let id = ui.id().with(("ftr", sh.id.0));
|
||||
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed));
|
||||
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%"));
|
||||
@@ -510,6 +527,11 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)}));
|
||||
}
|
||||
}
|
||||
if let Some(Fill::Pattern(pt)) = &sh.fill
|
||||
&& let Some(np) = crate::fillui::pattern_editor(ui, pt, &scheme(app))
|
||||
{
|
||||
run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Line").font(theme::bold(13.0)));
|
||||
let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None));
|
||||
@@ -871,25 +893,9 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
Some(Fill::Pattern(pt)) => {
|
||||
current = Some(json!({"pattern": {"preset": pt.preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}}));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for preset in ["pct5", "pct20", "pct50", "ltHorz", "ltVert", "smGrid", "dkDnDiag", "wave", "dotGrid", "weave"] {
|
||||
if ui.selectable_label(pt.preset == preset, preset).clicked() {
|
||||
run(app, "design.background", json!({"pattern": {"preset": preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
for (label, fg) in [("Foreground", true), ("Background", false)] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) };
|
||||
run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}}));
|
||||
}
|
||||
});
|
||||
});
|
||||
current = Some(json!({"pattern": crate::fillui::pattern_json(pt)}));
|
||||
if let Some(np) = crate::fillui::pattern_editor(ui, pt, &sc) {
|
||||
run(app, "design.background", json!({"pattern": crate::fillui::pattern_json(&np)}));
|
||||
}
|
||||
}
|
||||
Some(Fill::Picture(pf)) => {
|
||||
@@ -1189,7 +1195,8 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let _ = paint_anim_tile;
|
||||
}
|
||||
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it.
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it,
|
||||
/// Reject to drop it.
|
||||
fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Ok(v) = app.session.execute("review.revisions", &json!({})) else {
|
||||
@@ -1210,9 +1217,14 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
if ui.button(format!("Reject All ({})", changes.len())).clicked() {
|
||||
run(app, "review.reject", json!({"all": true}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
let slide = c["slide"].as_u64().unwrap_or(0);
|
||||
@@ -1231,6 +1243,9 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "slide.go", json!({"index": (slide as usize).min(max)}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Reject").clicked() {
|
||||
run(app, "review.reject", json!({"index": i}));
|
||||
}
|
||||
if ui.small_button("Accept").clicked() {
|
||||
run(app, "review.accept", json!({"index": i}));
|
||||
}
|
||||
@@ -1470,6 +1485,10 @@ mod smartart_tests;
|
||||
#[path = "background_tests.rs"]
|
||||
mod background_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pattern_fill_tests.rs"]
|
||||
mod pattern_fill_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
@@ -1557,6 +1576,75 @@ mod tests {
|
||||
h.run_steps(2);
|
||||
assert_eq!(solid_alpha(h.state(), b), Some(60_000));
|
||||
}
|
||||
|
||||
/// A blank slide with one selected rectangle, its fill set by `fill` (none for an automatic fill),
|
||||
/// in Format Shape ▸ Fill.
|
||||
fn fill_pane(fill: Option<serde_json::Value>) -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
if let Some(p) = fill {
|
||||
s.execute("shape.fill", &p).unwrap();
|
||||
}
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.pane = Some("format".into());
|
||||
app.ui.format_tab = "fill".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn picture_fill(transparency: f64) -> serde_json::Value {
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
|
||||
json!({"picture": deckcraft_engine::cmd::base64_encode(&png), "transparency": transparency})
|
||||
}
|
||||
|
||||
/// The "Color" drop buttons in the pane: the fill's, when it shows, and the line's.
|
||||
fn color_buttons(h: &Harness<'_, SlideApp>) -> usize {
|
||||
h.query_all_by_role(egui::accesskit::Role::Button).filter(|n| n.accesskit_node().label().is_some_and(|l| l.trim() == "Color")).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_color_and_transparency_show_only_where_they_change_the_fill() {
|
||||
let gradient = json!({"gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 90}});
|
||||
for (name, fill, color, transparency) in [
|
||||
("automatic", None, true, 1),
|
||||
("solid", Some(json!({"color": "accent2"})), true, 1),
|
||||
("no fill", Some(json!({"none": true})), false, 0),
|
||||
// The one Transparency row left is the selected gradient stop's.
|
||||
("gradient", Some(gradient), false, 1),
|
||||
("picture", Some(picture_fill(0.0)), false, 1),
|
||||
] {
|
||||
let h = fill_pane(fill);
|
||||
let fill_color = color_buttons(&h) == 2;
|
||||
assert_eq!(fill_color, color, "{name}: fill Color button");
|
||||
assert_eq!(h.query_all_by_label("Transparency").count(), transparency, "{name}: Transparency rows");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_fill_transparency_slider_shows_the_picture_transparency() {
|
||||
let mut h = fill_pane(Some(picture_fill(0.4)));
|
||||
let alpha = |h: &Harness<'_, SlideApp>| {
|
||||
let doc = h.state().session.active().unwrap();
|
||||
match &doc.selected_shapes()[0].fill {
|
||||
Some(Fill::Picture(pf)) => pf.alpha,
|
||||
other => panic!("expected a picture fill, got {other:?}"),
|
||||
}
|
||||
};
|
||||
let shown = slider_values(&h);
|
||||
assert!(shown.iter().any(|v| (*v - 40.0).abs() < 0.5), "a 40% transparent picture should show 40%, got {shown:?}");
|
||||
h.get_by_label("Transparency").click();
|
||||
h.run_steps(2);
|
||||
assert!(alpha(&h).is_some_and(|a| (a - 0.6).abs() < 1e-9), "{:?}", alpha(&h));
|
||||
}
|
||||
}
|
||||
|
||||
/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Format Shape and Format Background ▸ Pattern fill: the preset gallery and the Foreground and
|
||||
//! Background color menus.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::style::PatternFill;
|
||||
use deckcraft_model::{Background, ColorBase, ColorRef, Fill, ShapeId};
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness(mut app: SlideApp, pane: &str) -> Harness<'static, SlideApp> {
|
||||
app.ui.pane = Some(pane.into());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// A new deck with one rectangle selected and filled with `pct25` in accent 1 on background 1.
|
||||
fn patterned_shape() -> (Session, ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 120.0]})).unwrap();
|
||||
let id = r.get("id").and_then(|v| v.as_u64()).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})).unwrap();
|
||||
(s, ShapeId(u32::try_from(id).unwrap()))
|
||||
}
|
||||
|
||||
fn shape_pattern(h: &Harness<'_, SlideApp>, id: ShapeId) -> PatternFill {
|
||||
match &h.state().session.active().unwrap().shape(id).unwrap().fill {
|
||||
Some(Fill::Pattern(pt)) => pt.clone(),
|
||||
other => panic!("expected a pattern fill, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn background_pattern(h: &Harness<'_, SlideApp>) -> PatternFill {
|
||||
match &h.state().session.active().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Pattern(pt) }) => pt.clone(),
|
||||
other => panic!("expected a pattern background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the color menu named `button` and pick `hex` from its More Colors field.
|
||||
fn pick_hex(h: &mut Harness<'_, SlideApp>, button: &str, hex: &str) {
|
||||
h.get_by_label(button).click();
|
||||
h.run_steps(2);
|
||||
// The hex field is the text input on the "More Colors… #" label's row, to its right.
|
||||
let label = h.get_by_label("More Colors… #").rect();
|
||||
let field = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.find(|n| n.rect().min.x >= label.max.x - 1.0 && (n.rect().center().y - label.center().y).abs() < 6.0)
|
||||
.map(|n| n.rect().center())
|
||||
.expect("the More Colors hex field");
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: field, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.input_mut().events.push(Event::Text(hex.into()));
|
||||
h.run_steps(2);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pattern_fill_picks_a_preset_from_the_gallery() {
|
||||
let (s, id) = patterned_shape();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "format");
|
||||
let _ = h.get_by_label("Pattern fill");
|
||||
assert!(h.query_by_label("Transparency").is_none(), "a pattern has no solid-fill transparency");
|
||||
assert_eq!(crate::fillui::pattern_gallery().count(), 48);
|
||||
for name in crate::fillui::pattern_gallery() {
|
||||
assert!(deckcraft_render::pattern_presets().any(|p| p == name), "{name}");
|
||||
let _ = h.get_by_label(crate::fillui::pattern_label(name));
|
||||
}
|
||||
for label in ["Horizontal", "Vertical", "Cross", "Downward diagonal", "Upward diagonal", "Diagonal cross"] {
|
||||
assert!(h.query_by_label(label).is_none(), "{label} is not in the gallery");
|
||||
}
|
||||
let undo = h.state().session.active().unwrap().history.undo.len();
|
||||
h.get_by_label("Dark downward diagonal").click();
|
||||
h.run_steps(2);
|
||||
let pt = shape_pattern(&h, id);
|
||||
assert_eq!(pt.preset, "dkDnDiag");
|
||||
assert_eq!((pt.fg, pt.bg), (ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1), ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)));
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pattern_fill_keeps_preset_and_system_colors() {
|
||||
let (mut s, id) = patterned_shape();
|
||||
let fg = json!({"preset": "red", "lumMod": 50000});
|
||||
let bg = json!({"system": "window", "last": "#F0F0F0"});
|
||||
s.execute("shape.fill", &json!({"pattern": {"preset": "pct25", "fg": fg, "bg": bg}})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "format");
|
||||
let before = shape_pattern(&h, id);
|
||||
assert!(matches!(before.fg.base, ColorBase::Preset { .. }) && matches!(before.bg.base, ColorBase::System { .. }));
|
||||
h.get_by_label("Zig zag").click();
|
||||
h.run_steps(2);
|
||||
let pt = shape_pattern(&h, id);
|
||||
assert_eq!(pt.preset, "zigZag");
|
||||
assert_eq!((&pt.fg, &pt.bg), (&before.fg, &before.bg), "a preset change keeps both colors");
|
||||
pick_hex(&mut h, "Foreground color", "00FF00");
|
||||
assert_eq!(shape_pattern(&h, id).bg, before.bg, "a foreground change keeps the background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_gallery_shows_which_swatch_has_keyboard_focus() {
|
||||
let (s, _) = patterned_shape();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "format");
|
||||
let r = h.get_by_label("Zig zag").rect();
|
||||
let accent = crate::theme::Tokens::get(&h.ctx).accent;
|
||||
let outlined = |h: &Harness<'_, SlideApp>| {
|
||||
h.output().shapes.iter().any(|c| match &c.shape {
|
||||
egui::Shape::Rect(rs) => {
|
||||
rs.stroke.width == 2.0 && rs.stroke.color == accent && (rs.rect.center() - r.center()).length() < 0.5 && rs.rect.width() < r.width()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
assert!(!outlined(&h), "an unselected swatch has a plain border");
|
||||
h.get_by_label("Zig zag").focus();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Zig zag").is_focused());
|
||||
assert!(outlined(&h), "the focused swatch is outlined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() {
|
||||
let (mut s, _) = patterned_shape();
|
||||
s.execute("shape.fill", &json!({"color": "accent2"})).unwrap();
|
||||
let h = harness(SlideApp::new(s, Services::default()), "format");
|
||||
let _ = h.get_by_label("Transparency");
|
||||
assert!(h.query_by_label("Foreground color").is_none());
|
||||
assert!(h.query_by_label("Zig zag").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pattern_fill_sets_its_foreground_and_background_colors() {
|
||||
let (s, id) = patterned_shape();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "format");
|
||||
pick_hex(&mut h, "Foreground color", "00FF00");
|
||||
let pt = shape_pattern(&h, id);
|
||||
assert_eq!(pt.fg, ColorRef::rgb(Rgba::from_hex("00FF00").unwrap()));
|
||||
assert_eq!(pt.preset, "pct25", "the pattern stays");
|
||||
pick_hex(&mut h, "Background color", "112233");
|
||||
let pt = shape_pattern(&h, id);
|
||||
assert_eq!(pt.bg, ColorRef::rgb(Rgba::from_hex("112233").unwrap()));
|
||||
assert_eq!(pt.fg, ColorRef::rgb(Rgba::from_hex("00FF00").unwrap()), "the foreground stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_pattern_fill_offers_every_preset_and_both_colors() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": "bg1"}})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "background");
|
||||
h.get_by_label("Zig zag").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(background_pattern(&h).preset, "zigZag");
|
||||
pick_hex(&mut h, "Background color", "FFEE00");
|
||||
let pt = background_pattern(&h);
|
||||
assert_eq!((pt.preset.as_str(), pt.bg), ("zigZag", ColorRef::rgb(Rgba::from_hex("FFEE00").unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_pattern_fill_keeps_a_system_color() {
|
||||
let mut s = Session::with_new();
|
||||
let bg = json!({"system": "window", "last": "#F0F0F0"});
|
||||
s.execute("design.background", &json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": bg}})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()), "background");
|
||||
let before = background_pattern(&h).bg;
|
||||
assert!(matches!(before.base, ColorBase::System { .. }));
|
||||
h.get_by_label("Zig zag").click();
|
||||
h.run_steps(2);
|
||||
let pt = background_pattern(&h);
|
||||
assert_eq!((pt.preset.as_str(), pt.bg), ("zigZag", before));
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The Preferences (Options) dialog: controls apply as they change and Cancel, Esc and the
|
||||
//! window's close button put the settings back the way they were (#752, reported as #878).
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use egui_kittest::{Harness, Node};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Brightness;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// The slider itself, not the label drawn beside it.
|
||||
fn interface_size<'a>(h: &'a Harness<'_, SlideApp>) -> Node<'a> {
|
||||
h.get_all_by_label("Interface size").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Slider).expect("the slider is there")
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open_options(h: &mut Harness<'_, SlideApp>) {
|
||||
h.state_mut().run("app.preferences", json!({})).expect("open Options");
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn dark(app: &SlideApp) -> bool {
|
||||
app.ui.brightness == Brightness::Dark
|
||||
}
|
||||
|
||||
/// A checkbox applies as it is toggled and Cancel puts the old value back.
|
||||
#[test]
|
||||
fn options_cancel_undoes_the_changes() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "Cancel restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// Every Editing setting and the name field go back on Cancel, not only the first checkbox: a
|
||||
/// reporter who changed a different control and saw Cancel do nothing has the same answer.
|
||||
#[test]
|
||||
fn options_cancel_undoes_every_editing_setting() {
|
||||
let mut h = harness();
|
||||
let before = h.state().session.prefs.clone();
|
||||
open_options(&mut h);
|
||||
|
||||
for label in ["Snap objects to grid", "Replace straight quotes with smart quotes", "AutoCorrect as you type"] {
|
||||
h.query_by_label(label).unwrap_or_else(|| panic!("the {label} checkbox is there")).click();
|
||||
h.run_steps(1);
|
||||
}
|
||||
// The name field: it writes into the settings as it is typed.
|
||||
// The name field is the last text input of the dialog (the label is a separate widget).
|
||||
let name = h.get_all_by_role(egui::accesskit::Role::TextInput).last().expect("the name field is there");
|
||||
name.click();
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Text("Z".into()));
|
||||
h.run_steps(1);
|
||||
let changed = h.state().session.prefs.clone();
|
||||
assert_ne!(
|
||||
(changed.snap_to_grid, changed.smart_quotes, changed.autocorrect),
|
||||
(before.snap_to_grid, before.smart_quotes, before.autocorrect),
|
||||
"the settings apply as they are changed"
|
||||
);
|
||||
assert_ne!(changed.author, before.author, "the name applies as it is typed: {:?}", changed.author);
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
let after = h.state().session.prefs.clone();
|
||||
assert_eq!(after.snap_to_grid, before.snap_to_grid, "snap to grid is restored");
|
||||
assert_eq!(after.smart_quotes, before.smart_quotes, "smart quotes are restored");
|
||||
assert_eq!(after.autocorrect, before.autocorrect, "autocorrect is restored");
|
||||
assert_eq!(after.author, before.author, "the name is restored");
|
||||
}
|
||||
|
||||
/// The appearance toggle and the interface size go back on Cancel, too; Esc acts as Cancel.
|
||||
#[test]
|
||||
fn options_cancel_puts_the_appearance_and_scale_back() {
|
||||
let mut h = harness();
|
||||
let (dark_before, scale_before) = (dark(h.state()), h.state().ui.ui_scale);
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Dark appearance").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert!(dark(h.state()), "the appearance applies as it is toggled");
|
||||
|
||||
// Clicking the slider's middle sets it near the middle of its range, away from the default.
|
||||
interface_size(&h).click();
|
||||
h.run_steps(2);
|
||||
let scale_in_dialog = h.state().ui.ui_scale;
|
||||
assert!((scale_in_dialog - scale_before).abs() > 1e-3, "the slider applied a new size");
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(dark(h.state()), dark_before, "Esc restores the appearance");
|
||||
assert!((h.state().ui.ui_scale - scale_before).abs() < 1e-3, "Esc restores the size");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// The window's close button is the same as Cancel: the settings go back.
|
||||
#[test]
|
||||
fn options_close_button_restores_the_settings() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.get_all_by_label("Close window").next().expect("the window's close button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "the close button restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
@@ -15,7 +15,11 @@ fn harness() -> Harness<'static, SlideApp> {
|
||||
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
|
||||
s.execute("format.size", &json!({"size": 40})).unwrap();
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
// Home's ribbon is wider than it was when this test was written: at 1400 the Editing group
|
||||
// begins at x=1398, so the Replace Fonts button is two pixels wide on screen and a click at
|
||||
// its centre lands outside the window. The ribbon does not scroll sideways, so the window has
|
||||
// to be wide enough to hold it -- 1800 is what the other full-ribbon tests here use.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
@@ -63,6 +67,7 @@ fn ribbon_button_opens_the_dialog_and_replaces() {
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut h = harness();
|
||||
h.render().unwrap().save("/tmp/replacefonts0.png").unwrap();
|
||||
h.state_mut().dialog = Some(crate::dialogs::Dialog::new("replaceFonts"));
|
||||
|
||||
@@ -604,7 +604,27 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value {
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
_ => json!("#000000"),
|
||||
deckcraft_model::ColorBase::Preset { name } => {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("preset".into(), json!(name));
|
||||
for m in &c.mods {
|
||||
if let Some(v) = m.value() {
|
||||
o.insert(m.xml_name().into(), json!(v));
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
deckcraft_model::ColorBase::System { name, last } => {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("system".into(), json!(name));
|
||||
o.insert("last".into(), json!(format!("#{}", last.hex())));
|
||||
for m in &c.mods {
|
||||
if let Some(v) = m.value() {
|
||||
o.insert(m.xml_name().into(), json!(v));
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +787,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
run(app, "format.columns", json!({"count": n, "spacing": 18}));
|
||||
}
|
||||
}
|
||||
if ui.button("More Columns…").clicked() {
|
||||
run(app, "format.columns", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
|
||||
@@ -34,6 +34,7 @@ fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value {
|
||||
|
||||
#[test]
|
||||
fn drag_indents_add_move_and_remove_tabs_on_the_ruler() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap();
|
||||
@@ -105,6 +106,7 @@ fn editing_harness() -> Harness<'static, SlideApp> {
|
||||
|
||||
#[test]
|
||||
fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = editing_harness();
|
||||
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
@@ -127,6 +129,7 @@ fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() {
|
||||
|
||||
#[test]
|
||||
fn tabs_dialog_sets_clears_and_applies_on_ok() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = editing_harness();
|
||||
h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap();
|
||||
|
||||
+214
-9
@@ -316,6 +316,11 @@ impl Show {
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that.
|
||||
fn pointer_awake(&self, now: f64) -> bool {
|
||||
now - self.last_move < 2.5
|
||||
}
|
||||
|
||||
/// Pen and laser are different pointers: turning one on puts the other away.
|
||||
fn set_pen(&mut self, on: bool) {
|
||||
self.pen = on;
|
||||
@@ -707,8 +712,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
&& show.zoom.is_none()
|
||||
&& !show.zoom_arm
|
||||
&& !show.reading_bar_hit(&ctx)
|
||||
&& !pointer_on_media_bar(&ctx, ui, &doc, &show);
|
||||
let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None };
|
||||
&& !pointer_on_media_bar(&ctx, ui, &doc, &show, now);
|
||||
// The button is drawn only while the pointer is awake; a hidden button takes no click.
|
||||
let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) {
|
||||
gif_hit(&ctx, ui, &show, doc_uid, &doc, app)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None };
|
||||
let pointing = under.as_ref().is_some_and(|u| u.pick.is_some());
|
||||
@@ -745,7 +755,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
} else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) {
|
||||
show.dismiss_zoom();
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) {
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) {
|
||||
if show.ended {
|
||||
keep = false;
|
||||
} else if show.blank.is_some() {
|
||||
@@ -1041,7 +1051,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// Not while zoomed: the controls sit where the whole slide would be.
|
||||
if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() {
|
||||
let k = srect.width() / sw.max(1.0);
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) {
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) {
|
||||
for g in &gifs {
|
||||
let r = on_screen(srect, k, &g.rect);
|
||||
if r.contains(p) {
|
||||
@@ -1050,7 +1060,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) {
|
||||
if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
let action = {
|
||||
let status = app.media.status(id);
|
||||
crate::media::control_bar(ui, bar, status.as_ref(), &clip)
|
||||
@@ -1101,7 +1112,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
} else if show.zoom_arm || show.grid {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
} else if !show.pointer_awake(now) && !show.reading {
|
||||
// Idle first: a still pointer hides also over a clickable shape, such as a playing clip.
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
@@ -1161,7 +1172,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let a = show.state.prev(&doc);
|
||||
show.apply(a, &doc, now);
|
||||
}
|
||||
} else if !show.grid && (now - show.last_move < 2.5 || show.zoom_arm) {
|
||||
} else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) {
|
||||
let bar = tool_bar_rect(full);
|
||||
painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140));
|
||||
let mut child =
|
||||
@@ -1349,8 +1360,8 @@ fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect:
|
||||
}
|
||||
|
||||
/// True when the pointer is on a visible playback bar, so that click must not advance the show.
|
||||
fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool {
|
||||
if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() {
|
||||
fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool {
|
||||
if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false };
|
||||
@@ -2190,6 +2201,200 @@ mod tests {
|
||||
assert!(!harness.state().1.presenter_grid, "G again closes it");
|
||||
}
|
||||
|
||||
/// A silent mono 8 kHz WAV, so the clip has a real duration to seek in.
|
||||
fn silent_wav(secs: u32) -> Vec<u8> {
|
||||
let rate = 8_000u32;
|
||||
let data = vec![0u8; (rate * secs * 2) as usize];
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"RIFF");
|
||||
b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(b"WAVEfmt ");
|
||||
b.extend_from_slice(&16u32.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&rate.to_le_bytes());
|
||||
b.extend_from_slice(&(rate * 2).to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&16u16.to_le_bytes());
|
||||
b.extend_from_slice(b"data");
|
||||
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&data);
|
||||
b
|
||||
}
|
||||
|
||||
/// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the
|
||||
/// bar's seek track, and what `pointer_on_media_bar` said on the last frame.
|
||||
fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc<std::cell::Cell<bool>>) {
|
||||
let size = vec2(1000.0, 700.0);
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
let id = ShapeId(4000);
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap();
|
||||
let (w, h) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() };
|
||||
let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
slide.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
xfrm: Some(xfrm),
|
||||
kind: deckcraft_model::ShapeKind::Media(clip),
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let on_bar = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let seen = on_bar.clone();
|
||||
let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING));
|
||||
let slide_seen = slide.clone();
|
||||
// Presenter view opens an embedded window over the whole screen here, which takes the clicks.
|
||||
let mut app = SlideApp::new(session, crate::Services::default());
|
||||
app.ui.presenter_view = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state(
|
||||
move |ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) {
|
||||
let now = ui.input(|i| i.time);
|
||||
seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now));
|
||||
slide_seen.set(shown_slide_rect(ui, show, &d.doc));
|
||||
}
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(0, false);
|
||||
harness.run_steps(2);
|
||||
let doc = harness.state().session.active().unwrap().doc.clone();
|
||||
let bar = slide_media_bars(slide.get(), &doc, 0)[0].3;
|
||||
let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y);
|
||||
(harness, id, track, on_bar)
|
||||
}
|
||||
|
||||
/// Press and release the primary button at `p`.
|
||||
fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving
|
||||
/// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar.
|
||||
#[test]
|
||||
fn media_controls_hide_when_pointer_is_idle() {
|
||||
// Awake: the bar is there and a click on its timeline seeks, leaving the clip paused.
|
||||
let (mut harness, id, track, on_bar) = show_with_clip();
|
||||
harness.hover_at(track);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
assert!(on_bar.get(), "the bar is under a pointer that just moved");
|
||||
click_at(&mut harness, track);
|
||||
harness.run_steps(2);
|
||||
let status = harness.state().media.status(id).expect("the click on the bar cued the clip");
|
||||
assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play");
|
||||
assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position);
|
||||
|
||||
// Idle: same spot, no movement for 3 s.
|
||||
let (mut harness, id, track, on_bar) = show_with_clip();
|
||||
harness.hover_at(track);
|
||||
harness.run_steps(2);
|
||||
assert!(on_bar.get());
|
||||
harness.run_steps(12);
|
||||
assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
assert!(!on_bar.get(), "the bar is hidden once the pointer is idle");
|
||||
click_at(&mut harness, track);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().media.is_playing(id), "the click reached the clip");
|
||||
let at = harness.state().media.status(id).map_or(0.0, |s| s.position);
|
||||
assert!(at < 5.0, "the click did not seek along the timeline, position {at}");
|
||||
}
|
||||
|
||||
/// Two 1×1 frames (black, then white), 100 ms each.
|
||||
fn two_frame_gif() -> Vec<u8> {
|
||||
let mut b = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff".to_vec();
|
||||
for pixel in [b"\x44\x01", b"\x4c\x01"] {
|
||||
b.extend_from_slice(b"\x21\xf9\x04\x00\x0a\x00\x00\x00");
|
||||
b.extend_from_slice(b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02");
|
||||
b.extend_from_slice(pixel);
|
||||
b.push(0);
|
||||
}
|
||||
b.push(0x3b);
|
||||
b
|
||||
}
|
||||
|
||||
/// A running show on the first of two slides; slide 1 holds a large animated GIF. Returns the
|
||||
/// harness, the GIF, and its play/pause button's centre.
|
||||
fn show_with_gif() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2) {
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
// The GIF is on slide 1, the slide the editor has selected, with a slide after it.
|
||||
session.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
session.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
session.execute("slide.go", &json!({"index": 1})).unwrap();
|
||||
let id = ShapeId(4100);
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("a.gif", "image/gif", two_frame_gif()).unwrap();
|
||||
let (w, h) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[1]);
|
||||
slide.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6)),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING));
|
||||
let slide_seen = slide.clone();
|
||||
let mut app = SlideApp::new(session, crate::Services::default());
|
||||
app.ui.presenter_view = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state(
|
||||
move |ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) {
|
||||
slide_seen.set(shown_slide_rect(ui, show, &d.doc));
|
||||
}
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(1, false);
|
||||
harness.run_steps(2);
|
||||
let doc = harness.state().session.active().unwrap().doc.clone();
|
||||
let srect = slide.get();
|
||||
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
|
||||
let rect = doc.slides[1].shapes.last().unwrap().xfrm.unwrap();
|
||||
let button = crate::media::gif_button_rect(on_screen(srect, k, &rect)).center();
|
||||
(harness, id, button)
|
||||
}
|
||||
|
||||
/// The GIF's play/pause button is drawn only while the pointer is awake, so a click on its
|
||||
/// spot after 2.5 s without movement advances the show instead of pausing the GIF.
|
||||
#[test]
|
||||
fn gif_button_click_needs_an_awake_pointer() {
|
||||
let (mut harness, id, button) = show_with_gif();
|
||||
harness.hover_at(button);
|
||||
harness.run_steps(2);
|
||||
click_at(&mut harness, button);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().session.gif_paused.contains(&id), "an awake pointer's click pauses the GIF");
|
||||
assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1);
|
||||
|
||||
let (mut harness, id, button) = show_with_gif();
|
||||
harness.hover_at(button);
|
||||
harness.run_steps(2);
|
||||
harness.run_steps(12);
|
||||
assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
click_at(&mut harness, button);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().session.gif_paused.contains(&id), "the hidden button is not clickable");
|
||||
assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 2, "the click advanced the show");
|
||||
}
|
||||
|
||||
/// Send real egui key events to the presenter UI, without the audience window's handler.
|
||||
#[test]
|
||||
fn presenter_navigation_uses_its_own_keyboard_events() {
|
||||
|
||||
@@ -78,6 +78,7 @@ fn home_convert_to_smartart_gallery_converts_the_text() {
|
||||
|
||||
#[test]
|
||||
fn layout_gallery_shows_cached_pictures_and_previews_on_hover() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.smartArt", &json!({"kind": "process", "items": ["Plan", "Build", "Ship"]})).unwrap();
|
||||
|
||||
@@ -169,3 +169,13 @@ fn dragging_a_section_moves_it_and_keeps_it_collapsed() {
|
||||
let (beta, alpha) = (h.get_by_label("Beta").rect(), h.get_by_label("Alpha").rect());
|
||||
assert!(beta.top() < alpha.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_a_selects_every_slide() {
|
||||
let mut h = harness(false);
|
||||
h.key_combination_modifiers(egui::Modifiers::COMMAND, &[egui::Key::A]);
|
||||
h.run_steps(2);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let all = st.doc.slides.len();
|
||||
assert_eq!(st.selection.slides.len(), all, "Ctrl+A in the sorter selects every slide");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn spelling_dialog_changes_and_ignores_words() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n";
|
||||
let dic = "6\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\n";
|
||||
deckcraft_engine::spell::install("en-US", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes()));
|
||||
@@ -49,6 +50,7 @@ fn spelling_dialog_changes_and_ignores_words() {
|
||||
/// typed; the context menu's correction and Add to Dictionary clear them.
|
||||
#[test]
|
||||
fn squiggles_mark_misspelt_words_in_the_background() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n";
|
||||
let dic = "7\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\nreview\n";
|
||||
deckcraft_engine::spell::install("xx-SQ", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes()));
|
||||
@@ -115,6 +117,7 @@ fn squiggles_mark_misspelt_words_in_the_background() {
|
||||
/// gets there a few milliseconds per frame.
|
||||
#[test]
|
||||
fn squiggles_in_table_cells_and_notes_and_on_the_ui_thread() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n";
|
||||
let dic = "10\nthe/S\nplan/S\nsales\nregion/S\ntotal/S\nnorth\nsouth\nremember\nto\nshow\n";
|
||||
deckcraft_engine::spell::install("xx-TB", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes()));
|
||||
@@ -192,6 +195,7 @@ fn right_click(h: &mut Harness<'_, SlideApp>, pos: egui::Pos2) {
|
||||
/// dialog marks text and Do Not Check Spelling clears its squiggles.
|
||||
#[test]
|
||||
fn notes_menu_language_dialog_and_repeated_words() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
use egui::accesskit::Role;
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\n";
|
||||
let dic = "6\nthe\nplan\nremember\nis\ngood\nwork\n";
|
||||
|
||||
+162
-15
@@ -8,6 +8,8 @@ use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, ViewMode};
|
||||
|
||||
/// The status bar: slide counter, proofing language and status message on the left; notes,
|
||||
/// comments, views and zoom on the right.
|
||||
pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("status").exact_size(28.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
@@ -19,22 +21,37 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
_ => "Slide Master".to_string(),
|
||||
};
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// Left to right: slide counter, language, status message. Each starts at its usual column, or
|
||||
// after the one before it when that runs long ("Slide 100 of 120", "… (not checked)").
|
||||
let sr = p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
shown_text(ui, sr, "status_slide", &left);
|
||||
// Where the controls on the right begin; nothing on the left paints or clicks past it.
|
||||
let right_x = rect.max.x - 560.0;
|
||||
// The selection's proofing language; a click opens Review › Language.
|
||||
let (lang, no_proof) = crate::dialogs::selected_language(app);
|
||||
let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" };
|
||||
let lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0);
|
||||
let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y));
|
||||
let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
let hit = lr.expand(3.0).intersect(lclip);
|
||||
if hit.is_positive() {
|
||||
let resp = ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language");
|
||||
resp.widget_info(|| named_with_value("Language", &name));
|
||||
if resp.clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
}
|
||||
// Transient status message.
|
||||
// Transient status message, clipped before the controls on the right.
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
let mx = (rect.min.x + 290.0).max(lr.max.x + 16.0);
|
||||
let clip = Rect::from_min_max(pos2(mx, rect.min.y), pos2(right_x.max(mx), rect.max.y));
|
||||
let mr = p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
shown_text(ui, mr.intersect(clip), "status_message", &msg);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let mut right = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(rect.max.x - 560.0, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.max_rect(Rect::from_min_max(pos2(right_x, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
right.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -43,6 +60,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let pct = (app.canvas_scale * 100.0).round();
|
||||
let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click()));
|
||||
lbl.widget_info(|| named_with_value("Zoom level", &format!("{pct}%")));
|
||||
if lbl.clicked() {
|
||||
let _ = app.run("view.zoomDialog", json!({}));
|
||||
}
|
||||
@@ -51,10 +69,12 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
// Slider 10%–400% (log scale).
|
||||
let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0);
|
||||
let mut lz = z.ln();
|
||||
let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).show_value(false));
|
||||
let mut zp = z * 100.0;
|
||||
let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false));
|
||||
let shown = format!("{}%", zp.round());
|
||||
s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(zp), "Zoom") });
|
||||
if s.changed() {
|
||||
z = lz.exp();
|
||||
z = zp / 100.0;
|
||||
app.ui.zoom = Some(z);
|
||||
}
|
||||
if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() {
|
||||
@@ -62,17 +82,17 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
right.add_space(8.0);
|
||||
let view = app.ui.view;
|
||||
if small_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() {
|
||||
if view_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() {
|
||||
let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
|
||||
app.start_show(i, false);
|
||||
}
|
||||
if small_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() {
|
||||
if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", view == ViewMode::Reading).clicked() {
|
||||
let _ = app.run("view.reading", json!({}));
|
||||
}
|
||||
if small_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() {
|
||||
if view_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() {
|
||||
app.ui.view = ViewMode::Sorter;
|
||||
}
|
||||
if small_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() {
|
||||
if view_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() {
|
||||
app.ui.view = ViewMode::Normal;
|
||||
}
|
||||
right.add_space(8.0);
|
||||
@@ -89,19 +109,46 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response {
|
||||
let resp = icon_button(ui, icon, on);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// A view button: screen readers hear its name and whether its view is the one shown.
|
||||
fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response {
|
||||
let resp = icon_button(ui, icon, on);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
fn icon_button(ui: &mut Ui, icon: Icon, on: bool) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click());
|
||||
if on || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover });
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, t.text_dim, false);
|
||||
resp.on_hover_text(tip)
|
||||
resp
|
||||
}
|
||||
|
||||
/// A clickable status-bar text: screen readers hear `label` as its name and `text` as its value.
|
||||
fn named_with_value(label: &str, text: &str) -> egui::WidgetInfo {
|
||||
egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label) }
|
||||
}
|
||||
|
||||
/// Painted status-bar text: screen readers read `text` from a label over `rect`.
|
||||
fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) {
|
||||
if rect.is_positive() {
|
||||
let resp = ui.interact(rect, ui.id().with(id), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, label));
|
||||
if on || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover });
|
||||
}
|
||||
@@ -248,3 +295,103 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp, ViewMode};
|
||||
|
||||
/// A long proofing language in a narrow window must not take clicks meant for the controls on
|
||||
/// the right: Notes toggles the notes pane and the Language dialog stays closed.
|
||||
#[test]
|
||||
fn a_long_language_name_leaves_the_notes_button_clickable() {
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 400, 100], "text": "hello"})).unwrap();
|
||||
app.session.execute("format.language", &json!({"lang": "en-US-x-a-very-long-private-use-tag"})).unwrap();
|
||||
app.ui.notes = false;
|
||||
let size = egui::vec2(700.0, 800.0);
|
||||
let mut h = Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.notes);
|
||||
// The controls on the right start 560 points from the bar's right edge, which is 8 points
|
||||
// inside the window (x 132); the Notes button is the leftmost of them. Its position depends
|
||||
// on the interface font's widths, so the clicks use the position it was laid out at.
|
||||
let right_x = size.x - 8.0 - 560.0;
|
||||
let notes = h.get_by_label("Notes").rect();
|
||||
assert!(notes.left() > right_x + 2.0, "the Notes button ({notes:?}) starts after x {right_x}");
|
||||
let click = |h: &mut Harness<'_, SlideApp>, at: egui::Pos2| {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
};
|
||||
// The gap before the controls is outside the language name's clickable area.
|
||||
click(&mut h, egui::pos2((right_x + notes.left()) / 2.0, notes.center().y));
|
||||
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
|
||||
assert!(!h.state().ui.notes, "the click in the gap did not reach the Notes button");
|
||||
click(&mut h, notes.center());
|
||||
assert!(h.state().ui.notes, "the click reached the Notes button");
|
||||
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
|
||||
}
|
||||
|
||||
/// Screen readers find each status-bar control by name, hear the language and zoom it shows,
|
||||
/// hear which view button is on, and read the slide counter and the status message.
|
||||
#[test]
|
||||
fn status_bar_controls_have_names() {
|
||||
use egui_kittest::kittest::NodeT;
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
for name in ["Fit slide to current window", "Zoom In", "Zoom Out", "Slide Show", "Reading View"] {
|
||||
h.get_by_role_and_label(Role::Button, name);
|
||||
}
|
||||
let pct = format!("{}%", (h.state().canvas_scale * 100.0).round());
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, "Zoom level").value().as_deref(), Some(pct.as_str()));
|
||||
let zoom = h.get_by_role_and_label(Role::Slider, "Zoom");
|
||||
assert_eq!(zoom.value().as_deref(), Some(pct.as_str()));
|
||||
// Its number and range are in percent, as the value text is.
|
||||
let node = zoom.accesskit_node();
|
||||
assert_eq!(node.numeric_value().map(f64::round), Some((h.state().canvas_scale * 100.0).round().into()));
|
||||
assert_eq!((node.min_numeric_value(), node.max_numeric_value()), (Some(10.0), Some(400.0)));
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, "Language").value().as_deref(), Some("English (United States)"));
|
||||
let toggled = |h: &Harness<'_, SlideApp>, name: &str| h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled();
|
||||
assert_eq!(toggled(&h, "Normal"), Some(Toggled::True));
|
||||
assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::False));
|
||||
h.get_by_role_and_label(Role::Button, "Slide Sorter").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.view, ViewMode::Sorter, "the click by name reached the Slide Sorter button");
|
||||
assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::True));
|
||||
assert_eq!(toggled(&h, "Normal"), Some(Toggled::False));
|
||||
assert_eq!(toggled(&h, "Reading View"), Some(Toggled::False));
|
||||
h.state_mut().ui.view = ViewMode::Reading;
|
||||
h.run_steps(3);
|
||||
assert_eq!(toggled(&h, "Reading View"), Some(Toggled::True));
|
||||
assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::False));
|
||||
assert_eq!(h.get_by_value("Slide 1 of 1").accesskit_node().role(), Role::Label);
|
||||
h.state_mut().set_status("Saved a copy");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_by_value("Saved a copy").accesskit_node().role(), Role::Label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn a_very_tall_slide_in_the_editor_views_and_presenter_view() {
|
||||
/// notes and past the window.
|
||||
#[test]
|
||||
fn presenter_view_keeps_a_portrait_next_slide_in_its_column() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0));
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("show.presenter", json!({"on": true})).unwrap();
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Tokens {
|
||||
/// The ribbon card and popovers.
|
||||
pub card: Color32,
|
||||
pub card_border: Color32,
|
||||
/// Text fields. They have no outline, so the fill alone must set them apart from the card.
|
||||
pub text_field: Color32,
|
||||
/// Canvas around the slide.
|
||||
pub canvas: Color32,
|
||||
pub text: Color32,
|
||||
@@ -41,6 +43,12 @@ pub struct Tokens {
|
||||
pub caret: Color32,
|
||||
pub text_selection: Color32,
|
||||
pub field_bg: Color32,
|
||||
/// Unchecked checkbox and radio boxes and slider rails, drawn without an outline, so they
|
||||
/// must stand apart from the card and the chrome.
|
||||
pub control: Color32,
|
||||
/// [`Tokens::control`] under the pointer and pressed: a step darker each in light mode.
|
||||
pub control_hover: Color32,
|
||||
pub control_pressed: Color32,
|
||||
pub shadow: Color32,
|
||||
}
|
||||
|
||||
@@ -51,6 +59,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5),
|
||||
card: Color32::WHITE,
|
||||
card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
|
||||
text_field: Color32::from_rgb(0xEB, 0xEB, 0xEB),
|
||||
canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||
text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C),
|
||||
@@ -70,6 +79,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::WHITE,
|
||||
control: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
control_hover: Color32::from_rgb(0xC8, 0xC8, 0xC8),
|
||||
control_pressed: Color32::from_rgb(0xBC, 0xBC, 0xBC),
|
||||
shadow: Color32::from_black_alpha(28),
|
||||
}
|
||||
}
|
||||
@@ -79,6 +91,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0x26, 0x26, 0x26),
|
||||
card: Color32::from_rgb(0x33, 0x33, 0x33),
|
||||
card_border: Color32::from_rgb(0x44, 0x44, 0x44),
|
||||
text_field: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E),
|
||||
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
||||
text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB),
|
||||
@@ -98,6 +111,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control_hover: Color32::from_rgb(0x40, 0x40, 0x40),
|
||||
control_pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
}
|
||||
}
|
||||
@@ -118,6 +134,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
v.panel_fill = t.chrome;
|
||||
v.window_fill = t.card;
|
||||
v.extreme_bg_color = t.field_bg;
|
||||
v.text_edit_bg_color = Some(t.text_field);
|
||||
v.faint_bg_color = t.hover;
|
||||
v.window_stroke = Stroke::new(1.0, t.card_border);
|
||||
v.window_corner_radius = CornerRadius::same(10);
|
||||
@@ -131,13 +148,13 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
|
||||
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
v.widgets.inactive.bg_fill = t.field_bg;
|
||||
v.widgets.inactive.bg_fill = t.control;
|
||||
v.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.control_hover;
|
||||
v.widgets.hovered.bg_stroke = Stroke::NONE;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.control_pressed;
|
||||
v.widgets.open.weak_bg_fill = t.pressed;
|
||||
v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow };
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow };
|
||||
@@ -257,6 +274,24 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_checkboxes_and_slider_rails_stand_out_from_their_background() {
|
||||
// An unchecked checkbox and a slider rail are filled with the inactive widget fill and
|
||||
// have no outline, so in light mode they vanished into the white dialog (#766).
|
||||
let ctx = egui::Context::default();
|
||||
super::apply(&ctx, &super::Tokens::light());
|
||||
let v = ctx.global_style().visuals.clone();
|
||||
assert_eq!(v.widgets.inactive.bg_stroke, egui::Stroke::NONE, "the test assumes no outline");
|
||||
let fill = v.widgets.inactive.bg_fill;
|
||||
for (name, bg) in [("dialog", v.window_fill), ("panel", v.panel_fill)] {
|
||||
let diff = [fill.r().abs_diff(bg.r()), fill.g().abs_diff(bg.g()), fill.b().abs_diff(bg.b())];
|
||||
assert!(diff.iter().all(|d| *d >= 24), "control fill {fill:?} is too close to the {name} background {bg:?}");
|
||||
}
|
||||
// Hovering and pressing darken the control a step each, as they do buttons.
|
||||
let (hovered, active) = (v.widgets.hovered.bg_fill, v.widgets.active.bg_fill);
|
||||
assert!(fill.r() > hovered.r() && hovered.r() > active.r(), "idle {fill:?}, hovered {hovered:?}, active {active:?} don't darken in turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn thesaurus_pane_swaps_the_word_at_the_caret() {
|
||||
let _gpu = crate::gpu_lock::gpu_lock();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
|
||||
@@ -26,11 +26,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
master_list(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let out = egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
list(app, ui);
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
// Where the pane is scrolled to, for the control channel and tests (#917).
|
||||
app.ui.thumbs_scroll = out.state.offset.y;
|
||||
});
|
||||
// Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it.
|
||||
if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) {
|
||||
@@ -47,6 +49,16 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let width = ui.available_width();
|
||||
let tw = (width - 40.0).max(60.0);
|
||||
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
|
||||
// Scroll the current slide into view when it changes, so arrow-key navigation follows
|
||||
// along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves
|
||||
// the view, as in PowerPoint, so the user's own scrolling stands; the first frame only
|
||||
// records the slide.
|
||||
let scroll_key = ui.id().with("thumb_scrolled_to");
|
||||
let slide_changed = ui.data_mut(|d| {
|
||||
let last: Option<usize> = d.get_temp(scroll_key);
|
||||
d.insert_temp(scroll_key, cur);
|
||||
last.is_some_and(|last| last != cur)
|
||||
});
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 3u32;
|
||||
let mut rects = vec![];
|
||||
@@ -170,6 +182,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if need_more {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Scroll the current slide's thumbnail into view when the slide changes (#917).
|
||||
if slide_changed && let Some((_, row_rect)) = rects.iter().find(|(i, _)| *i == cur) {
|
||||
ui.scroll_to_rect(*row_rect, None);
|
||||
}
|
||||
// Drag reorder: insertion line.
|
||||
if let Some((from, _)) = app.thumb_drag {
|
||||
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
|
||||
@@ -327,3 +343,58 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
|
||||
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
|
||||
h.state().ui.thumbs_scroll
|
||||
}
|
||||
|
||||
/// #917: navigating to a slide below the fold scrolls the pane to reveal it; a neighbour
|
||||
/// that is already on screen does not move the pane.
|
||||
#[test]
|
||||
fn navigating_slides_keeps_the_current_thumbnail_visible() {
|
||||
let mut h = harness();
|
||||
for _ in 0..30 {
|
||||
h.state_mut().session.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
// slide.new selects each new slide; back to the first so the pane starts at the top.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
h.run_steps(3);
|
||||
let last = h.state().session.active().unwrap().doc.slides.len() - 1;
|
||||
assert_eq!(thumbs_scroll(&h), 0.0, "the pane starts at the top");
|
||||
|
||||
// Jump to the last slide: the pane follows and reveals it.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last})).unwrap();
|
||||
h.run_steps(120); // the scroll eases in over several frames
|
||||
let jumped = thumbs_scroll(&h);
|
||||
assert!(jumped > 100.0, "the pane scrolled to the last slide, offset {jumped}");
|
||||
|
||||
// One slide up was already on screen after that scroll: the pane holds still.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last - 1})).unwrap();
|
||||
h.run_steps(120);
|
||||
let held = thumbs_scroll(&h);
|
||||
assert!((held - jumped).abs() < 1.0, "no re-scroll for a visible slide: {jumped} -> {held}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Start On Click / With Previous / After Previous, duration, delay | yes | yes |
|
||||
| Effect Options (direction, sequence by paragraph) | yes | yes |
|
||||
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) |
|
||||
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
|
||||
| Triggers on a media bookmark | yes | no |
|
||||
| Animation Painter | yes | yes (#54) |
|
||||
@@ -96,6 +96,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h |
|
||||
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
|
||||
| 2026-10-16 | trivial | PPTX paragraph builds edited in PowerPoint keep their clicks; sub-points save With Previous of their parent (#844) |
|
||||
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
|
||||
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user