mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 12:07:02 -06:00
Compare commits
173
Commits
20d819908a
...
release
| 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 | ||
|
|
c955407373 | ||
|
|
54962c6389 | ||
|
|
6189fa8bf6 | ||
|
|
713f605566 | ||
|
|
9641817a15 | ||
|
|
1e097e3edd | ||
|
|
9888d7c90e | ||
|
|
d94790d97a | ||
|
|
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 | ||
|
|
5fadda5ecf | ||
|
|
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 |
|
||||
|
||||
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
|
||||
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
|
||||
for t in ts {
|
||||
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -41,8 +41,9 @@ pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
log::error!("{p}: {e}");
|
||||
app.set_status(format!("Couldn't open {p}: {e}"));
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
|
||||
+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 };
|
||||
|
||||
@@ -9,7 +9,10 @@ use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::ease;
|
||||
use crate::transition::{FULL, Layer, R, Source};
|
||||
use crate::transition_geom::*;
|
||||
use crate::transition_geom::{
|
||||
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
|
||||
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
|
||||
};
|
||||
|
||||
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
@@ -17,7 +17,11 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::FrameBuf;
|
||||
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
|
||||
use crate::par::Pool;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
|
||||
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
|
||||
REFMVS_LIMIT, REFS_PER_FRAME,
|
||||
};
|
||||
use crate::state::{FrameShared, FrameState, TileState};
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
@@ -61,7 +65,7 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn set(&self, v: Result<T>) {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = Some(v);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
@@ -71,12 +75,12 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
@@ -52,8 +52,8 @@ impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
|
||||
|
||||
use crate::bits::BitReader;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
|
||||
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
|
||||
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
|
||||
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
|
||||
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
|
||||
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const EIGHTTAP: u8 = 0;
|
||||
|
||||
+12
-3
@@ -5,7 +5,16 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
@@ -678,7 +687,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
@@ -699,7 +708,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+23
-5
@@ -6,7 +6,27 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::{MiInfo, Mv};
|
||||
use crate::header::{FrameHeader, NONE, SequenceHeader};
|
||||
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
|
||||
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
|
||||
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
|
||||
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
|
||||
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
|
||||
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
|
||||
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
|
||||
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
|
||||
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
|
||||
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
|
||||
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
|
||||
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
|
||||
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
|
||||
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
|
||||
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
|
||||
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
|
||||
V_DCT, V_FLIPADST, V_PRED,
|
||||
};
|
||||
use crate::state::{FrameShared, TileState};
|
||||
use crate::symbol::SymbolDecoder;
|
||||
use crate::transform::inverse_transform_2d;
|
||||
@@ -2222,18 +2242,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
|
||||
if diff <= 2 * r {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
diff
|
||||
} else {
|
||||
if diff <= 2 * (max - r - 1) {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
max - (diff + 1)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -577,7 +577,7 @@ fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
@@ -770,7 +770,7 @@ fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
@@ -1771,34 +1804,21 @@ 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(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
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() {
|
||||
c.raw = None;
|
||||
}
|
||||
@@ -1810,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()
|
||||
|
||||
@@ -107,14 +107,16 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
@@ -130,7 +132,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') {
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
@@ -302,9 +304,11 @@ fn decode_text(raw: &str) -> String {
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&collapse_space(rest));
|
||||
break;
|
||||
let search_window = rest.len().min(32);
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
@@ -333,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')
|
||||
}
|
||||
|
||||
@@ -352,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>";
|
||||
@@ -391,6 +407,10 @@ mod tests {
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
|
||||
// Verify many bare ampersands do not cause quadratic scan freeze
|
||||
let hostile = format!("<p>{}</p>", "&".repeat(2000));
|
||||
assert_eq!(text(&hostile), "&".repeat(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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()?;
|
||||
@@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn parsed() -> &'static HashMap<&'static str, BezPath> {
|
||||
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
|
||||
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for icon in ICONS {
|
||||
|
||||
+59
-5
@@ -8,6 +8,14 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a regular file"));
|
||||
}
|
||||
const MAX_FILE_SIZE: u64 = 2 << 30; // 2 GiB
|
||||
if meta.len() > MAX_FILE_SIZE {
|
||||
return Err(format!("{path}: file size {} exceeds 2 GiB limit", meta.len()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -28,21 +36,29 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
crate::file_access::check_write(path)?;
|
||||
let err = |e: std::io::Error| format!("{path}: {e}");
|
||||
// A uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), is renamed over the destination: concurrent saves never share
|
||||
// temporary bytes.
|
||||
|
||||
let target_path = match std::fs::canonicalize(path) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
if let Some(perms) = existing_perms {
|
||||
let _ = file.set_permissions(perms);
|
||||
}
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, &target_path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
@@ -67,3 +83,41 @@ pub fn file_len(path: &str) -> Option<u64> {
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_file_rejects_directory() {
|
||||
let dir = std::env::temp_dir();
|
||||
let res = read_file(dir.to_str().unwrap());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_preserves_permissions_and_writes_content() {
|
||||
let tmp_dir = std::env::temp_dir().join("deckcraft_io_test");
|
||||
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||
let test_file = tmp_dir.join("test_write.txt");
|
||||
let path_str = test_file.to_str().unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"hello world").is_ok());
|
||||
let read = read_file(path_str).unwrap();
|
||||
assert_eq!(read, b"hello world");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o640);
|
||||
std::fs::set_permissions(&test_file, perms).unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"updated world").is_ok());
|
||||
let new_meta = std::fs::metadata(&test_file).unwrap();
|
||||
assert_eq!(new_meta.permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,8 +85,20 @@ fn owner_alive(dir: &Path, owner: u64) -> bool {
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or("")));
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut file = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{}: {e}", tmp.display())))?;
|
||||
file.write_all(bytes).and_then(|()| file.sync_all()).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{}: {e}", path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
@@ -152,13 +164,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
|
||||
+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)]);
|
||||
}
|
||||
+28
-27
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -807,7 +807,7 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
@@ -822,14 +822,14 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
@@ -885,7 +885,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
// Checked again under the lock: another thread may have added the same face.
|
||||
@@ -923,7 +923,7 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -959,7 +959,7 @@ impl FontDb {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
|
||||
n
|
||||
}
|
||||
|
||||
@@ -983,7 +983,7 @@ impl FontDb {
|
||||
/// it is loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.load_family(family, &loading)
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ impl FontDb {
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
@@ -1112,7 +1112,7 @@ impl FontDb {
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -1214,20 +1214,20 @@ impl FontDb {
|
||||
}
|
||||
let seen = self.read_faces().len();
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
// there is none, while this one waited.
|
||||
if self.read_faces().len() != seen && covered(self) {
|
||||
return true;
|
||||
}
|
||||
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
|
||||
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
@@ -1238,7 +1238,8 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
@@ -1253,7 +1254,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1326,10 +1327,10 @@ impl FontDb {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1347,7 +1348,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1359,12 +1360,12 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1382,14 +1383,14 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
@@ -1399,7 +1400,7 @@ impl FontDb {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
+43
-11
@@ -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');
|
||||
}
|
||||
@@ -1070,23 +1072,49 @@ mod tests {
|
||||
assert_eq!(md_list_item(not), None, "{not:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
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");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell 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);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
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");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
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 [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
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 });
|
||||
}
|
||||
@@ -1096,7 +1124,11 @@ fn outline_includes_table_text() {
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::cabac::Cabac;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::mbtypes::*;
|
||||
use crate::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::picture::MbKind;
|
||||
use crate::slice::SliceType;
|
||||
use crate::slicedec::{MbCur, SliceDecoder};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! CAVLC residual block parsing (9.2).
|
||||
|
||||
use crate::cavlc_tables::*;
|
||||
use crate::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
|
||||
use crate::error::{Result, ensure};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
|
||||
let mut w = [0u64; N];
|
||||
for (i, w) in w.iter_mut().enumerate() {
|
||||
let o = (y + i) * stride + x - 4;
|
||||
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
|
||||
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
|
||||
return;
|
||||
};
|
||||
*w = u64::from_le_bytes(b);
|
||||
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
let base = (y - 4) * stride + x;
|
||||
let mut s = [[0i16; N]; 8];
|
||||
for (k, row) in s.iter_mut().enumerate() {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
filter_lanes(&mut s, ep, chroma);
|
||||
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
|
||||
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
|
||||
|
||||
use crate::error::{Result, ensure, unsupported};
|
||||
use crate::tables::*;
|
||||
use crate::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
|
||||
@@ -301,7 +301,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
|
||||
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
|
||||
if w <= N
|
||||
&& x0 >= 0
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
|
||||
&& let Some(dst) = out.first_chunk_mut::<N>()
|
||||
{
|
||||
*dst = *src;
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::deblock;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::inter::{self, Weight};
|
||||
use crate::intra::{self, Avail};
|
||||
use crate::mbtypes::*;
|
||||
use crate::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::params::{Pps, Sps};
|
||||
use crate::picture::*;
|
||||
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::tables::*;
|
||||
use crate::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
|
||||
use crate::transform::{self, LevelScale};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
@@ -907,7 +907,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (raster & 3) * 4;
|
||||
let py = self.mb_y * 16 + (raster >> 2) * 4;
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
return;
|
||||
};
|
||||
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (b8 & 1) * 8;
|
||||
let py = self.mb_y * 16 + (b8 >> 1) * 8;
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else {
|
||||
return;
|
||||
};
|
||||
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -940,7 +940,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
let px = self.mb_x * 8 + (b & 1) * 4;
|
||||
let py = self.mb_y * 8 + (b >> 1) * 4;
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
continue;
|
||||
};
|
||||
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
|
||||
|
||||
@@ -107,7 +107,7 @@ struct Pic {
|
||||
|
||||
impl Pic {
|
||||
fn is_p(&self) -> bool {
|
||||
!self.idr && !self.b && self.mbs.iter().any(|m| m.is_none())
|
||||
!self.idr && !self.b && self.mbs.iter().any(Option::is_none)
|
||||
}
|
||||
fn inter(&self) -> bool {
|
||||
self.b || self.is_p()
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
|
||||
o[3][j] = e - h;
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
@@ -173,7 +173,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
|
||||
}
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
|
||||
@@ -25,7 +25,7 @@ fn filt_w<const T: usize, const W: usize>(src: &[i16], step: usize, f: &[i8; T],
|
||||
let mut acc = [0i32; W];
|
||||
for k in 0..T {
|
||||
let c = f[k] as i32;
|
||||
let Some(p) = src.get(k * step..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = src.get(k * step..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -10,7 +10,11 @@ use crate::intra::{self, Refs};
|
||||
use crate::params::{Layout, Pps, Sps};
|
||||
use crate::picture::{FrameRef, RefPic};
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ABS_MVD_GT0, ABS_MVD_GT1, CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CU_QP_DELTA_ABS, CU_SKIP, CU_TRANSQUANT_BYPASS, GT1, GT2, INTER_PRED_IDC,
|
||||
INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, REF_IDX, RQT_ROOT_CBF,
|
||||
SAO_MERGE, SAO_TYPE, SIG_COEFF, SPLIT_CU, SPLIT_TRANSFORM, TRANSFORM_SKIP,
|
||||
};
|
||||
use crate::tables::{CTX_IDX_MAP, LEVEL_SCALE, SCAN_4, qpc_420, scan_order};
|
||||
use crate::transform;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::Decoder;
|
||||
use crate::cabac::{Contexts, NEXT_STATE, RANGE_TAB_LPS, init_contexts};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{CU_SKIP, NUM_CTX, PRED_MODE, SPLIT_CU};
|
||||
use deckcraft_bitstream::{BitWriter, escape_rbsp};
|
||||
|
||||
const W: usize = 96;
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -254,7 +254,7 @@ impl Player {
|
||||
}
|
||||
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
|
||||
let mut g = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.mixer.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(&mut g)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+6
-4
@@ -28,11 +28,13 @@ for f in range(6):
|
||||
sys.stdout.buffer.write(bytes(v for y in range(h) for x in range(w) for v in ((x * 4 + f * 20) % 256, (y * 5) % 256, (f * 40) % 256)))
|
||||
" > "$out"
|
||||
}
|
||||
frames 64 48 /tmp/deckcraft-fixture-64x48.rgb
|
||||
frames 64 64 /tmp/deckcraft-fixture-64x64.rgb
|
||||
VID="-f rawvideo -pix_fmt rgb24 -s 64x48 -r 10 -i /tmp/deckcraft-fixture-64x48.rgb"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deckcraft-fixture.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
frames 64 48 "$TMP_DIR/deckcraft-fixture-64x48.rgb"
|
||||
frames 64 64 "$TMP_DIR/deckcraft-fixture-64x64.rgb"
|
||||
VID="-f rawvideo -pix_fmt rgb24 -s 64x48 -r 10 -i $TMP_DIR/deckcraft-fixture-64x48.rgb"
|
||||
$FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -shortest $META clip-h264.mp4
|
||||
$FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov
|
||||
$FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4
|
||||
$FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm
|
||||
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i /tmp/deckcraft-fixture-64x64.rgb -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
|
||||
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,7 +1217,7 @@ fn image(r: Bytes) -> Option<ImageData> {
|
||||
fn metafile(r: Bytes) -> Option<Picture> {
|
||||
let size = r.u32(12)? as usize;
|
||||
let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?;
|
||||
let depth = NESTING.with(|n| n.get());
|
||||
let depth = NESTING.with(std::cell::Cell::get);
|
||||
if depth >= MAX_NESTING {
|
||||
return None;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1217,7 +1217,7 @@ fn savedc_restores_text_colour_and_font() {
|
||||
fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec<u8> {
|
||||
let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]);
|
||||
v.extend([italic, 0, 0, charset, 0, 0, 0, 0]);
|
||||
let mut name: Vec<u8> = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
let mut name: Vec<u8> = face.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
name.resize(64, 0);
|
||||
v.extend(name);
|
||||
erec(82, &v)
|
||||
@@ -1716,7 +1716,7 @@ fn plus_pic(recs: Vec<Vec<u8>>) -> Picture {
|
||||
}
|
||||
|
||||
fn u16s(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
|
||||
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white.
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
+174
-77
@@ -491,50 +491,66 @@ impl Default for Chart {
|
||||
}
|
||||
|
||||
impl Chart {
|
||||
fn sample_treemap_sunburst(kind: ChartType) -> Self {
|
||||
let mut c = Chart {
|
||||
kind,
|
||||
categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(),
|
||||
category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()],
|
||||
vary_colors: true,
|
||||
..Default::default()
|
||||
};
|
||||
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
|
||||
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
c
|
||||
}
|
||||
|
||||
fn sample_observation_chart(kind: ChartType) -> Self {
|
||||
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
|
||||
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
|
||||
let categories = if kind == ChartType::BoxWhisker {
|
||||
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut c =
|
||||
Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() };
|
||||
for s in 0..n_series {
|
||||
c.series.push(Series {
|
||||
name: format!("Series {}", s + 1),
|
||||
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn sample_stock() -> Self {
|
||||
let mut c = Chart {
|
||||
kind: ChartType::Stock,
|
||||
categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
|
||||
for (name, v) in hlc {
|
||||
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The sample data a new chart starts with (our own numbers).
|
||||
pub fn sample(kind: ChartType) -> Self {
|
||||
let cats = ["Q1", "Q2", "Q3", "Q4"];
|
||||
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
|
||||
let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() };
|
||||
if matches!(kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
// A two-level hierarchy: months inside quarters.
|
||||
c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect();
|
||||
c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()];
|
||||
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
|
||||
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
c.vary_colors = true;
|
||||
return c;
|
||||
return Self::sample_treemap_sunburst(kind);
|
||||
}
|
||||
if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
|
||||
// These plot raw observations: a histogram bins one series, a box chart summarises
|
||||
// each category's points.
|
||||
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
|
||||
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
|
||||
c.categories = if kind == ChartType::BoxWhisker {
|
||||
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
for s in 0..n_series {
|
||||
c.series.push(Series {
|
||||
name: format!("Series {}", s + 1),
|
||||
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
c.gridlines = true;
|
||||
c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string());
|
||||
return c;
|
||||
return Self::sample_observation_chart(kind);
|
||||
}
|
||||
if kind == ChartType::Stock {
|
||||
// High-low-close over five days, the series in the order PowerPoint expects.
|
||||
c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect();
|
||||
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
|
||||
for (name, v) in hlc {
|
||||
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
}
|
||||
return c;
|
||||
return Self::sample_stock();
|
||||
}
|
||||
let cats = ["Q1", "Q2", "Q3", "Q4"];
|
||||
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
|
||||
let mut c = Chart { kind, categories: cats.iter().map(ToString::to_string).collect(), ..Default::default() };
|
||||
let n_series =
|
||||
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
|
||||
for s in 0..n_series {
|
||||
@@ -593,27 +609,44 @@ impl Chart {
|
||||
return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0));
|
||||
}
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let has_stacked_or_percent = ser.iter().any(|s| {
|
||||
let k = self.series_kind(s);
|
||||
k.stacked() || k.percent()
|
||||
});
|
||||
|
||||
let mut percent = false;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m, mut stacked) = (0.0, 0.0, false);
|
||||
if has_stacked_or_percent {
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let budget = (ser.len() * n).min(10_000_000);
|
||||
let n_clamped = if ser.is_empty() { 0 } else { n.min(budget / ser.len()) };
|
||||
|
||||
for i in 0..n_clamped {
|
||||
let (mut p, mut m, mut stacked) = (0.0, 0.0, false);
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
stacked = true;
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
if stacked {
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
stacked = true;
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
for v in s.values.iter().flatten().copied().filter(|v| v.is_finite()) {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
if stacked {
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
}
|
||||
if !percent {
|
||||
for s in &ser {
|
||||
@@ -699,38 +732,58 @@ impl Chart {
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
/// 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;
|
||||
if self.kind == ChartType::Waterfall {
|
||||
for (a, b) in self.waterfall_bars() {
|
||||
lo = lo.min(a.min(b));
|
||||
hi = hi.max(a.max(b));
|
||||
for (a, b) in self.waterfall_bars() {
|
||||
lo = lo.min(a.min(b));
|
||||
hi = hi.max(a.max(b));
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &self.series {
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
}
|
||||
return (lo, hi);
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
if self.kind == ChartType::Waterfall {
|
||||
return self.waterfall_value_range();
|
||||
}
|
||||
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
if self.kind.stacked() {
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &self.series {
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
let (mut lo, mut hi) = if self.kind.stacked() {
|
||||
self.stacked_value_range(n)
|
||||
} else {
|
||||
let mut l: f64 = 0.0;
|
||||
let mut h: f64 = 0.0;
|
||||
for s in &self.series {
|
||||
for v in s.values.iter().flatten() {
|
||||
if v.is_finite() {
|
||||
hi = hi.max(*v);
|
||||
lo = lo.min(*v);
|
||||
h = h.max(*v);
|
||||
l = l.min(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(l, h)
|
||||
};
|
||||
if self.kind.percent() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
@@ -772,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 {
|
||||
@@ -784,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;
|
||||
@@ -797,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 {
|
||||
@@ -814,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;
|
||||
@@ -829,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)
|
||||
}
|
||||
|
||||
@@ -839,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);
|
||||
@@ -850,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)];
|
||||
|
||||
@@ -1066,13 +1066,13 @@ impl Presentation {
|
||||
self.slides.iter().position(|s| s.id == id)
|
||||
}
|
||||
pub fn slide(&self, id: SlideId) -> Option<&Slide> {
|
||||
self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref())
|
||||
self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref)
|
||||
}
|
||||
pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> {
|
||||
self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut)
|
||||
}
|
||||
pub fn slide_at(&self, i: usize) -> Option<&Slide> {
|
||||
self.slides.get(i).map(|s| s.as_ref())
|
||||
self.slides.get(i).map(AsRef::as_ref)
|
||||
}
|
||||
/// Master and layout for a layout id.
|
||||
pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> {
|
||||
@@ -1086,7 +1086,7 @@ impl Presentation {
|
||||
self.masters.first().map(|m| (m.as_ref(), m.layouts.first()))
|
||||
}
|
||||
pub fn first_master(&self) -> Option<&Master> {
|
||||
self.masters.first().map(|m| m.as_ref())
|
||||
self.masters.first().map(AsRef::as_ref)
|
||||
}
|
||||
pub fn media(&self, id: MediaId) -> Option<&MediaItem> {
|
||||
self.media.iter().find(|m| m.id == id)
|
||||
|
||||
@@ -275,7 +275,7 @@ pub enum Dash {
|
||||
}
|
||||
|
||||
impl Dash {
|
||||
pub const PRESETS: [&'static str; 11] =
|
||||
pub const PRESETS: [&str; 11] =
|
||||
["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"];
|
||||
/// Dash/space lengths in multiples of the line width.
|
||||
pub fn pattern(&self) -> Vec<f64> {
|
||||
|
||||
@@ -530,7 +530,7 @@ impl Paragraph {
|
||||
true
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.content()).collect()
|
||||
self.runs.iter().map(Run::content).collect()
|
||||
}
|
||||
pub fn char_len(&self) -> usize {
|
||||
self.runs.iter().map(Run::char_len).sum()
|
||||
@@ -576,7 +576,7 @@ pub struct ListStyle {
|
||||
|
||||
impl ListStyle {
|
||||
pub fn level(&self, l: u8) -> Option<&LevelStyle> {
|
||||
self.levels.get(l as usize).and_then(|x| x.as_ref())
|
||||
self.levels.get(l as usize).and_then(Option::as_ref)
|
||||
}
|
||||
pub fn set(&mut self, l: u8, s: LevelStyle) {
|
||||
let i = (l as usize).min(8);
|
||||
|
||||
@@ -57,22 +57,7 @@ impl Trendline {
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| poly_y(&coeff, *x)).collect();
|
||||
(Model::Poly(coeff), ys, yhat)
|
||||
}
|
||||
TrendlineType::Exp => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
|
||||
(Model::Exp { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Exp => fit_exp(&pairs, intercept)?,
|
||||
TrendlineType::Log => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
@@ -83,43 +68,10 @@ impl Trendline {
|
||||
let yhat: Vec<f64> = ln_x.iter().map(|u| b + c * u).collect();
|
||||
(Model::Log { b, c }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Power => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
|
||||
(Model::Power { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Power => fit_power(&pairs, intercept)?,
|
||||
TrendlineType::MovingAvg => {
|
||||
let period = self.period.unwrap_or(2).clamp(2, 255) as usize;
|
||||
if pairs.len() <= period {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
let mut yhat = Vec::new();
|
||||
for i in period - 1..pairs.len() {
|
||||
let window = &pairs[i + 1 - period..=i];
|
||||
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
|
||||
if avg.is_finite() {
|
||||
pts.push((pairs[i].0, avg));
|
||||
ys.push(pairs[i].1);
|
||||
yhat.push(avg);
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
(Model::Moving(pts), ys, yhat)
|
||||
fit_moving_avg(&pairs, period)?
|
||||
}
|
||||
};
|
||||
if yhat.iter().any(|v| !v.is_finite()) {
|
||||
@@ -325,6 +277,89 @@ fn poly_y(coeff: &[f64], x: f64) -> f64 {
|
||||
acc
|
||||
}
|
||||
|
||||
fn fit_exp(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
|
||||
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
|
||||
Some((Model::Exp { c, b }, ys, yhat))
|
||||
}
|
||||
|
||||
fn fit_power(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
|
||||
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
|
||||
Some((Model::Power { c, b }, ys, yhat))
|
||||
}
|
||||
|
||||
fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
if pairs.len() <= period {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
let mut yhat = Vec::new();
|
||||
for i in period - 1..pairs.len() {
|
||||
let window = &pairs[i + 1 - period..=i];
|
||||
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
|
||||
if avg.is_finite() {
|
||||
pts.push((pairs[i].0, avg));
|
||||
ys.push(pairs[i].1);
|
||||
yhat.push(avg);
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some((Model::Moving(pts), ys, yhat))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
|
||||
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
|
||||
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
|
||||
@@ -334,29 +369,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
return None;
|
||||
}
|
||||
if let Some(b0) = intercept {
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
return coeff.iter().all(|v| v.is_finite()).then_some(coeff);
|
||||
return polyfit_with_intercept(xs, ys, order, b0);
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (RFC 6716 §4.3.4 – §4.3.6).
|
||||
|
||||
use super::rate::{Mode, QTHETA_OFFSET, QTHETA_OFFSET_TWOPHASE, get_pulses};
|
||||
use super::tables::*;
|
||||
use super::tables::{E_MEANS, EBANDS, NB_EBANDS, ORDERY, SHORT_MDCT};
|
||||
use crate::range::{BITRES, RangeDecoder, ilog};
|
||||
|
||||
pub const SPREAD_NONE: usize = 0;
|
||||
@@ -71,7 +71,7 @@ fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) {
|
||||
x[i + stride] = c * x2 + s * x1;
|
||||
x[i] = c * x1 + ms * x2;
|
||||
}
|
||||
if len >= 2 * stride + 1 {
|
||||
if len > 2 * stride {
|
||||
for i in (0..=len - 2 * stride - 1).rev() {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (RFC 6716 §4.3.2).
|
||||
|
||||
use super::rate::MAX_FINE_BITS;
|
||||
use super::tables::*;
|
||||
use super::tables::{BETA_COEF, BETA_INTRA, E_PROB_MODEL, NB_EBANDS, PRED_COEF, SMALL_ENERGY_ICDF};
|
||||
use crate::range::RangeDecoder;
|
||||
|
||||
const LAPLACE_MINP: u32 = 1;
|
||||
|
||||
@@ -12,7 +12,7 @@ use bands::{anti_collapse, denormalise_bands, quant_all_bands, renormalise_vecto
|
||||
use energy::{unquant_coarse_energy, unquant_energy_finalise, unquant_fine_energy};
|
||||
use mdct::ImdctScratch;
|
||||
use rate::{Mode, compute_allocation};
|
||||
use tables::*;
|
||||
use tables::{COMB_GAINS, EBANDS, MAX_LM, NB_EBANDS, OVERLAP, PREEMPH, SHORT_MDCT, SPREAD_ICDF, TAPSET_ICDF, TF_SELECT_TABLE, TRIM_ICDF};
|
||||
|
||||
use crate::range::{BITRES, RangeDecoder};
|
||||
|
||||
@@ -578,7 +578,7 @@ fn tf_decode(start: usize, end: usize, is_transient: bool, tf_res: &mut [i32; NB
|
||||
let mut budget = dec.storage() as i32 * 8;
|
||||
let mut tell = dec.tell();
|
||||
let mut logp: u32 = if is_transient { 2 } else { 4 };
|
||||
let tf_select_rsv = lm > 0 && tell + logp as i32 + 1 <= budget;
|
||||
let tf_select_rsv = lm > 0 && tell + (logp as i32) < budget;
|
||||
budget -= tf_select_rsv as i32;
|
||||
let mut tf_changed = 0;
|
||||
let mut curr = 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pulse cache and bit allocation (RFC 6716 §4.3.3, §4.3.4.1).
|
||||
|
||||
use super::tables::*;
|
||||
use super::tables::{ALLOC, EBANDS, LOG2_FRAC_TABLE, MAX_LM, NB_EBANDS, OVERLAP};
|
||||
use crate::range::{BITRES, RangeDecoder};
|
||||
|
||||
const MAX_PSEUDO: i32 = 40;
|
||||
|
||||
@@ -217,7 +217,7 @@ impl StreamDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
let data_len = data.map_or(0, |d| d.len());
|
||||
let data_len = data.map_or(0, <[_]>::len);
|
||||
let mut dec_storage;
|
||||
let mut dec: Option<&mut RangeDecoder> = match data {
|
||||
Some(d) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Fixed-point NLSF decoding, NLSF→LPC conversion and filter stabilisation
|
||||
//! (RFC 6716 §4.2.7.5, with the RFC 8251 §6/§7 overflow fixes).
|
||||
|
||||
use super::tables::*;
|
||||
use super::tables::{
|
||||
COS_Q12, LSF_ORDER_NBMB, LSF_ORDER_WB, NLSF_CB_NBMB, NLSF_CB_WB, NLSF_MIN_SPACING_NBMB, NLSF_MIN_SPACING_WB, NLSF_PRED_NBMB, NLSF_PRED_WB,
|
||||
NLSF_PSEL_NBMB, NLSF_PSEL_WB,
|
||||
};
|
||||
|
||||
pub const MAX_LPC_ORDER: usize = 16;
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@ mod resampler;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod tables;
|
||||
|
||||
use lpc::*;
|
||||
use lpc::{MAX_LPC_ORDER, bwexpander, div32_varq, inverse32_varq, nlsf_decode, nlsf2a, rshift_round, sat16, smlawb, smulwb, smulww};
|
||||
use resampler::Resampler;
|
||||
use tables::*;
|
||||
use tables::{
|
||||
CB_NB_10, CB_NB_20, CB_WB_10, CB_WB_20, DELTA_GAIN, EXC_LSB, EXC_SIGN, GAIN_MSB, LBRR_FLAGS, LTP_FILTER, LTP_PERIODICITY, LTP_SCALE, LTP_TAPS_0,
|
||||
LTP_TAPS_1, LTP_TAPS_2, MID_ONLY, NLSF_EXT, NLSF_INTERP, NLSF_SEL_NBMB, NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, PITCH_CONTOUR, PITCH_DELTA,
|
||||
PITCH_HIGH, PITCH_LOW, PULSE_COUNT, RATE_LEVEL, SHELL_2, SHELL_4, SHELL_8, SHELL_16, STEREO_PRED_JOINT, STEREO_WEIGHTS_Q13, TYPE_ACTIVE,
|
||||
TYPE_INACTIVE, UNIFORM3, UNIFORM4, UNIFORM5, UNIFORM8,
|
||||
};
|
||||
|
||||
use crate::range::RangeDecoder;
|
||||
use crate::{Error, Result};
|
||||
|
||||
+29
-21
@@ -131,6 +131,33 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
write::guard(|| export_inner(pres, opts))
|
||||
}
|
||||
|
||||
fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec<Vec<(usize, Rect)>> {
|
||||
match opts.layout {
|
||||
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
|
||||
PageLayout::Notes => {
|
||||
let b = notes_frames(pres, opts.paper).0;
|
||||
slides.iter().map(|&i| vec![(i, b)]).collect()
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
|
||||
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline {
|
||||
let mut outline = Outline::new();
|
||||
for (p, v) in pages.iter().enumerate() {
|
||||
for (i, _) in v {
|
||||
let t = pres.slides[*i].title();
|
||||
let t = t.lines().next().unwrap_or("").trim().to_string();
|
||||
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
|
||||
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
|
||||
}
|
||||
}
|
||||
outline
|
||||
}
|
||||
|
||||
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let slides: Vec<usize> = match &opts.slides {
|
||||
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
|
||||
@@ -153,29 +180,10 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
|
||||
// Page plan: per page, the slides on it with their boxes (page points).
|
||||
let pages: Vec<Vec<(usize, Rect)>> = match opts.layout {
|
||||
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
|
||||
PageLayout::Notes => {
|
||||
let b = notes_frames(pres, opts.paper).0;
|
||||
slides.iter().map(|&i| vec![(i, b)]).collect()
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
|
||||
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
|
||||
}
|
||||
};
|
||||
let pages = plan_pages(pres, opts, &slides, sw, sh);
|
||||
// Bookmarks and slide → page lookup for internal links.
|
||||
let page_of: HashMap<usize, usize> = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect();
|
||||
let mut outline = Outline::new();
|
||||
for (p, v) in pages.iter().enumerate() {
|
||||
for (i, _) in v {
|
||||
let t = pres.slides[*i].title();
|
||||
let t = t.lines().next().unwrap_or("").trim().to_string();
|
||||
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
|
||||
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
|
||||
}
|
||||
}
|
||||
pdf.set_outline(outline);
|
||||
pdf.set_outline(build_outline(pres, &pages));
|
||||
|
||||
for v in &pages {
|
||||
let (pw, ph) = match opts.layout {
|
||||
|
||||
+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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user