@shimabell06/cloud-arch-icon-browser 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +56 -78
- package/cli/powerpoint-bridge.js +406 -0
- package/cli/server.js +75 -12
- package/dist/assets/index-CF_evZHB.js +9 -0
- package/dist/assets/index-jxRCnk3-.css +2 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/dist/assets/index-CE34ADfR.css +0 -2
- package/dist/assets/index-wz5PJNXz.js +0 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @shimabell06/cloud-arch-icon-browser
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 46753f3: Add an Experimental Windows PowerPoint Copy all workflow for Tray items through a capability-scoped localhost bridge, with bounded transient PNG handling and perceived-size normalization. Physical Windows/PowerPoint paste validation remains tracked by #57 and is not yet a formal compatibility claim.
|
|
8
|
+
- 9aeba15: Use a stable canonical localhost origin for packaged runs, reuse an already-running matching local instance, and let supported browsers reopen a previously validated local ZIP through a persisted File System Access handle without storing package bytes.
|
|
9
|
+
- 2e1045e: Add a session-only Tray with quantity/order controls, multi-select and drag collection, reusable locally persisted Saved Sets, recently-used history, and frequently-used shortcuts.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2666a3e: Reduce initial and scroll-time icon preview latency with shared prefetch observation and bounded viewport-prioritized preview work.
|
|
14
|
+
|
|
15
|
+
## 0.2.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 593c7b5: Deduplicate identical icon filenames in the global All icons results and search suggestions while preserving category-scoped entries.
|
|
20
|
+
|
|
3
21
|
## 0.2.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,129 +1,107 @@
|
|
|
1
1
|
# Cloud Arch Icon Browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find Microsoft Azure Architecture Icons quickly and copy them into PowerPoint, Excel, or other compatible apps.
|
|
4
4
|
|
|
5
|
-
Cloud Arch Icon Browser
|
|
5
|
+

|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Cloud Arch Icon Browser is an independent open-source project. It is not affiliated with, endorsed by, or sponsored by Microsoft. Microsoft Azure Architecture Icons are not included in this repository or npm package.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Quick start
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
The command runs the latest published npm release, starts a temporary HTTP server bound to `127.0.0.1` on an available port, and opens the local web UI in the default browser. The ZIP selected by the user is processed locally and is not uploaded or persisted by the application.
|
|
16
|
-
|
|
17
|
-
## Features
|
|
11
|
+
1. Download the official Azure Architecture Icons ZIP from [Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/icons/).
|
|
12
|
+
2. Run:
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
```bash
|
|
15
|
+
npx @shimabell06/cloud-arch-icon-browser
|
|
16
|
+
```
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
- Preserve and browse the ZIP folder hierarchy as categories.
|
|
23
|
-
- Search display names, original filenames, and category paths with deterministic exact → prefix → substring ranking ahead of fuzzy matches.
|
|
24
|
-
- Keep category scope explicit through a removable filter chip while preserving the active query.
|
|
25
|
-
- Use keyboard-operable search autocomplete with icon previews, recent searches, and favorite shortcuts.
|
|
26
|
-
- Save Favorites, recently opened icons, recent searches, theme, Grid/Compact preference, and sidebar state as local UI metadata without persisting the ZIP or SVG contents.
|
|
27
|
-
- Switch between Grid and Compact icon layouts.
|
|
28
|
-
- Copy an icon as a transparent 512×512 PNG for compatible clipboard workflows such as Windows PowerPoint and Excel.
|
|
29
|
-
- Open a centered details dialog with real package metadata, Favorite, Copy image, Copy SVG where supported, and original SVG download actions.
|
|
30
|
-
- Preserve original SVG bytes and filenames for download.
|
|
31
|
-
- Explicitly select System / Light / Dark theme behavior.
|
|
32
|
-
- Replace the active package without reloading the application.
|
|
33
|
-
- Use responsive desktop and mobile navigation with keyboard and accessibility support.
|
|
34
|
-
- Run locally through `npx` with no automatic runtime network access.
|
|
18
|
+
3. Choose the downloaded ZIP in the browser.
|
|
35
19
|
|
|
36
|
-
|
|
20
|
+
The command serves the app from the stable local origin `http://127.0.0.1:41731/` and opens it in your default browser. A second invocation reuses the same running app; if another process occupies that port, the CLI reports an error rather than switching origins. The selected ZIP is processed locally and its bytes are never uploaded or copied into application storage.
|
|
37
21
|
|
|
38
|
-
|
|
22
|
+
On browsers that support the File System Access API, a successfully validated selection can remember only the local file handle in IndexedDB. A later launch can use `Open previous ZIP` to request/read that file under normal browser permission rules, and `Forget previous ZIP reference` removes the handle. The app does not trigger a file-permission prompt automatically on page load. Unsupported browsers continue to use the normal ZIP picker.
|
|
39
23
|
|
|
40
|
-
|
|
24
|
+
## Features
|
|
41
25
|
|
|
42
|
-
|
|
26
|
+
- Fast search across icon names, original filenames, and category paths.
|
|
27
|
+
- Category browsing with explicit search filter chips.
|
|
28
|
+
- Favorites, recently used icons, recent searches, Frequently used shortcuts, Tray, Saved Sets, and Grid / Compact views.
|
|
29
|
+
- Centered icon details with real package metadata.
|
|
30
|
+
- Copy icons as transparent 512×512 PNG images for compatible clipboard workflows such as Windows PowerPoint and Excel.
|
|
31
|
+
- Copy original SVG source text where supported or download the original SVG with its original filename.
|
|
32
|
+
- Experimental Windows PowerPoint `Copy all` from the Tray in the packaged `npx` runtime, preserving Tray order and quantities without a flattened-image fallback.
|
|
33
|
+
- System, Light, and Dark themes with responsive keyboard-accessible navigation.
|
|
34
|
+
- Local-only package processing with no automatic runtime network access; supported browsers can remember only a local file reference for the previous ZIP.
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
`Copy image` depends on browser Clipboard API support and permission. If image clipboard writes are unavailable or denied, the app reports the failure and the original SVG remains available for download.
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
### Experimental PowerPoint Copy all
|
|
47
39
|
|
|
48
|
-
|
|
40
|
+
On Windows, the packaged `npx` runtime exposes an explicitly Experimental `Copy all` action in the Tray. It prepares up to 36 transient 512×512 PNG representations, preserves Tray order and quantities, asks desktop PowerPoint to copy them as a multi-shape selection, and then lets you paste once in PowerPoint. Generated PNGs and temporary Office files stay local and are deleted after the operation. There is no cloud service and no flattened combined-image fallback.
|
|
49
41
|
|
|
50
|
-
|
|
42
|
+
This workflow is **not yet formally supported** because real-machine Windows 11 + current Chromium + Microsoft 365 PowerPoint validation is still pending. The UI shows an Experimental warning on first use. If the later validation fails, the feature will be disabled or removed rather than replaced by a flattened image.
|
|
51
43
|
|
|
52
|
-
The
|
|
44
|
+
The feature is enabled by default for evaluation. To disable it locally, run this in the app's browser developer console and reload:
|
|
53
45
|
|
|
54
|
-
|
|
46
|
+
```js
|
|
47
|
+
localStorage.setItem(
|
|
48
|
+
"cloud-arch-icon-browser:feature:powerpoint-copy-all",
|
|
49
|
+
"off",
|
|
50
|
+
);
|
|
51
|
+
```
|
|
55
52
|
|
|
56
|
-
|
|
53
|
+
Delete that key (or set it to `on`) to return to the built-in default. The existing single-icon `Copy image` workflow remains the stable cross-platform baseline.
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
True vector clipboard copy and direct browser-to-PowerPoint drag are not part of this Experimental release. The `Copy SVG source` action copies SVG markup text; it is not a vector-image clipboard operation.
|
|
59
56
|
|
|
60
|
-
##
|
|
57
|
+
## Official Azure icons
|
|
61
58
|
|
|
62
|
-
|
|
59
|
+
Download the current package from [Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/icons/) and use the icons in accordance with Microsoft's terms.
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
^22.22.2 || ^24.15.0 || >=26.0.0
|
|
66
|
-
```
|
|
61
|
+
The latest package explicitly verified by this project is recorded in [`COMPATIBILITY.md`](./COMPATIBILITY.md). The runtime validates package structure rather than hard-coding a specific Microsoft ZIP version.
|
|
67
62
|
|
|
68
|
-
|
|
63
|
+
## Development
|
|
69
64
|
|
|
70
|
-
|
|
65
|
+
Use the Node.js version pinned in [`.node-version`](./.node-version):
|
|
71
66
|
|
|
72
67
|
```bash
|
|
73
|
-
nvm install
|
|
74
|
-
nvm use
|
|
68
|
+
nvm install
|
|
69
|
+
nvm use
|
|
75
70
|
npm ci
|
|
76
71
|
npm run dev
|
|
77
72
|
```
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Core validation commands:
|
|
74
|
+
Common validation commands:
|
|
82
75
|
|
|
83
76
|
```bash
|
|
84
77
|
npm run check
|
|
85
78
|
npm run typecheck
|
|
86
79
|
npm test
|
|
87
80
|
npm run build
|
|
88
|
-
npm run test:
|
|
89
|
-
npm run test:cli-package-smoke
|
|
90
|
-
npm run verify:package
|
|
91
|
-
npm run verify:release-ready
|
|
81
|
+
npm run test:e2e
|
|
92
82
|
```
|
|
93
83
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
python3 scripts/ui-review/create_fixture.py /tmp/cloud-arch-icon-browser-e2e-fixture.zip
|
|
98
|
-
npx playwright install chromium
|
|
99
|
-
E2E_FIXTURE=/tmp/cloud-arch-icon-browser-e2e-fixture.zip npm run test:e2e
|
|
100
|
-
```
|
|
84
|
+
### Regenerate the README demo
|
|
101
85
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
To verify a maintainer-downloaded current official ZIP without committing it:
|
|
86
|
+
Install Playwright Chromium and `ffmpeg`, then run the recorder with a maintainer-downloaded official Azure Architecture Icons ZIP:
|
|
105
87
|
|
|
106
88
|
```bash
|
|
107
|
-
|
|
89
|
+
npx playwright install chromium
|
|
90
|
+
AZURE_ICON_ZIP=/path/to/Azure_Public_Service_Icons.zip npm run docs:demo
|
|
108
91
|
```
|
|
109
92
|
|
|
110
|
-
|
|
93
|
+
The recorder starts the local app, performs the search → details → Copy image flow with Playwright, and writes `docs/assets/demo.gif`. The source ZIP and SVG files are never copied into the repository.
|
|
111
94
|
|
|
112
|
-
## Project
|
|
95
|
+
## Project docs
|
|
113
96
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
- [`
|
|
117
|
-
- [`
|
|
118
|
-
- [`COMPATIBILITY.md`](./COMPATIBILITY.md) — official package compatibility and verification status.
|
|
119
|
-
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — contribution workflow.
|
|
120
|
-
- [`docs/RELEASE.md`](./docs/RELEASE.md) — release, Trusted Publishing, compatibility, and watcher runbook.
|
|
97
|
+
- [`DESIGN.md`](./DESIGN.md) — product and architecture design contract.
|
|
98
|
+
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — contribution workflow and development details.
|
|
99
|
+
- [`COMPATIBILITY.md`](./COMPATIBILITY.md) — official package compatibility status.
|
|
100
|
+
- [`docs/RELEASE.md`](./docs/RELEASE.md) — release and maintenance runbook.
|
|
121
101
|
- [`SECURITY.md`](./SECURITY.md) — vulnerability reporting policy.
|
|
122
102
|
|
|
123
103
|
## License
|
|
124
104
|
|
|
125
|
-
The source code
|
|
126
|
-
|
|
127
|
-
Third-party software and assets retain their own licenses. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
|
|
105
|
+
The project source code is licensed under the MIT License. See [`LICENSE`](./LICENSE).
|
|
128
106
|
|
|
129
|
-
Microsoft Azure Architecture Icons are Microsoft assets and are not covered by this project's MIT License.
|
|
107
|
+
Third-party software and assets retain their own licenses. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). Microsoft Azure Architecture Icons are Microsoft assets and are not covered by this project's MIT License.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
export const POWERPOINT_CAPABILITY_PATH = "/__bridge/powerpoint/capability";
|
|
7
|
+
export const POWERPOINT_COPY_ALL_PATH = "/__bridge/powerpoint/copy-all";
|
|
8
|
+
export const POWERPOINT_CAPABILITY_HEADER = "x-cloud-arch-capability";
|
|
9
|
+
export const MAX_POWERPOINT_OBJECTS = 36;
|
|
10
|
+
const MAX_UNIQUE_IMAGES = 36;
|
|
11
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const MAX_REQUEST_BYTES = 12 * 1024 * 1024;
|
|
13
|
+
const POWERPOINT_TIMEOUT_MS = 20_000;
|
|
14
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
15
|
+
class PowerPointAutomationError extends Error {
|
|
16
|
+
kind;
|
|
17
|
+
constructor(message, kind) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.name = "PowerPointAutomationError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createPowerPointBridge(options = {}) {
|
|
24
|
+
const platform = options.platform ?? process.platform;
|
|
25
|
+
const capabilityToken = options.capabilityToken ?? randomBytes(32).toString("base64url");
|
|
26
|
+
const runner = options.runner ?? runPowerPointCopy;
|
|
27
|
+
let busy = false;
|
|
28
|
+
return {
|
|
29
|
+
matches: (rawUrl) => {
|
|
30
|
+
const pathname = requestPathname(rawUrl);
|
|
31
|
+
return (pathname === POWERPOINT_CAPABILITY_PATH ||
|
|
32
|
+
pathname === POWERPOINT_COPY_ALL_PATH);
|
|
33
|
+
},
|
|
34
|
+
handle: async (request, response, port) => {
|
|
35
|
+
const pathname = requestPathname(request.url);
|
|
36
|
+
if (pathname === POWERPOINT_CAPABILITY_PATH) {
|
|
37
|
+
if (request.method !== "GET") {
|
|
38
|
+
response.setHeader("Allow", "GET");
|
|
39
|
+
sendJson(response, 405, { error: "Method Not Allowed" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const available = platform === "win32";
|
|
43
|
+
sendJson(response, 200, {
|
|
44
|
+
available,
|
|
45
|
+
experimental: true,
|
|
46
|
+
maxObjects: MAX_POWERPOINT_OBJECTS,
|
|
47
|
+
capability: available ? capabilityToken : null,
|
|
48
|
+
reason: available ? null : "windows-npx-only",
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (pathname !== POWERPOINT_COPY_ALL_PATH) {
|
|
53
|
+
sendJson(response, 404, { error: "Not Found" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (request.method !== "POST") {
|
|
57
|
+
response.setHeader("Allow", "POST");
|
|
58
|
+
sendJson(response, 405, { error: "Method Not Allowed" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (platform !== "win32") {
|
|
62
|
+
sendJson(response, 501, {
|
|
63
|
+
error: "PowerPoint Copy all is available only in the Windows npx runtime.",
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const expectedOrigin = `http://127.0.0.1:${port}`;
|
|
68
|
+
if (request.headers.origin !== expectedOrigin) {
|
|
69
|
+
sendJson(response, 403, { error: "Forbidden" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (request.headers[POWERPOINT_CAPABILITY_HEADER] !== capabilityToken) {
|
|
73
|
+
sendJson(response, 403, { error: "Forbidden" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!isJsonContentType(request.headers["content-type"])) {
|
|
77
|
+
sendJson(response, 415, { error: "Expected application/json." });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (busy) {
|
|
81
|
+
sendJson(response, 409, {
|
|
82
|
+
error: "A PowerPoint copy operation is already in progress.",
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let items;
|
|
87
|
+
try {
|
|
88
|
+
const raw = await readBoundedBody(request);
|
|
89
|
+
items = parseCopyPayload(JSON.parse(raw));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof RequestTooLargeError) {
|
|
93
|
+
sendJson(response, 413, { error: error.message });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
sendJson(response, 400, {
|
|
97
|
+
error: error instanceof Error ? error.message : "Invalid Copy all payload.",
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
busy = true;
|
|
102
|
+
try {
|
|
103
|
+
await runner(items);
|
|
104
|
+
sendJson(response, 200, {
|
|
105
|
+
ok: true,
|
|
106
|
+
objectCount: items.reduce((sum, item) => sum + item.quantity, 0),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const message = error instanceof PowerPointAutomationError
|
|
111
|
+
? error.message
|
|
112
|
+
: "PowerPoint automation failed. Try again with desktop PowerPoint available.";
|
|
113
|
+
sendJson(response, 503, { error: message });
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
busy = false;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function parseCopyPayload(value) {
|
|
122
|
+
if (!isRecord(value) || !Array.isArray(value.items)) {
|
|
123
|
+
throw new Error("Copy all payload must contain an items array.");
|
|
124
|
+
}
|
|
125
|
+
if (value.items.length === 0 || value.items.length > MAX_UNIQUE_IMAGES) {
|
|
126
|
+
throw new Error(`Copy all supports 1-${MAX_UNIQUE_IMAGES} unique images.`);
|
|
127
|
+
}
|
|
128
|
+
let total = 0;
|
|
129
|
+
const parsed = [];
|
|
130
|
+
for (const item of value.items) {
|
|
131
|
+
if (!isRecord(item))
|
|
132
|
+
throw new Error("Each Copy all item must be an object.");
|
|
133
|
+
const keys = Object.keys(item).sort();
|
|
134
|
+
if (keys.length !== 2 || keys[0] !== "pngBase64" || keys[1] !== "quantity") {
|
|
135
|
+
throw new Error("Copy all items may contain only pngBase64 and quantity.");
|
|
136
|
+
}
|
|
137
|
+
if (typeof item.quantity !== "number" ||
|
|
138
|
+
!Number.isSafeInteger(item.quantity) ||
|
|
139
|
+
item.quantity < 1 ||
|
|
140
|
+
item.quantity > MAX_POWERPOINT_OBJECTS) {
|
|
141
|
+
throw new Error("Copy all item quantity is invalid.");
|
|
142
|
+
}
|
|
143
|
+
if (typeof item.pngBase64 !== "string") {
|
|
144
|
+
throw new Error("Copy all image must be base64 PNG data.");
|
|
145
|
+
}
|
|
146
|
+
const png = decodePng(item.pngBase64);
|
|
147
|
+
total += item.quantity;
|
|
148
|
+
if (total > MAX_POWERPOINT_OBJECTS) {
|
|
149
|
+
throw new Error(`Copy all supports at most ${MAX_POWERPOINT_OBJECTS} objects.`);
|
|
150
|
+
}
|
|
151
|
+
parsed.push({ png, quantity: item.quantity });
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
async function runPowerPointCopy(items) {
|
|
156
|
+
const directory = await mkdtemp(join(tmpdir(), "cloud-arch-icon-browser-powerpoint-"));
|
|
157
|
+
try {
|
|
158
|
+
const manifestItems = [];
|
|
159
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
160
|
+
const item = items[index];
|
|
161
|
+
if (item === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
const path = join(directory, `${String(index).padStart(2, "0")}.png`);
|
|
164
|
+
await writeFile(path, item.png, { flag: "wx" });
|
|
165
|
+
manifestItems.push({ path, quantity: item.quantity });
|
|
166
|
+
}
|
|
167
|
+
const manifestPath = join(directory, "manifest.json");
|
|
168
|
+
await writeFile(manifestPath, JSON.stringify({
|
|
169
|
+
total: items.reduce((sum, item) => sum + item.quantity, 0),
|
|
170
|
+
items: manifestItems,
|
|
171
|
+
}), { encoding: "utf8", flag: "wx" });
|
|
172
|
+
await invokePowerPointAutomation(manifestPath);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await rm(directory, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function invokePowerPointAutomation(manifestPath) {
|
|
179
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
180
|
+
const encodedCommand = Buffer.from(POWERPOINT_COPY_SCRIPT, "utf16le").toString("base64");
|
|
181
|
+
const child = spawn("powershell.exe", [
|
|
182
|
+
"-NoLogo",
|
|
183
|
+
"-NoProfile",
|
|
184
|
+
"-NonInteractive",
|
|
185
|
+
"-ExecutionPolicy",
|
|
186
|
+
"Bypass",
|
|
187
|
+
"-EncodedCommand",
|
|
188
|
+
encodedCommand,
|
|
189
|
+
], {
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
192
|
+
env: { ...process.env, CAB_POWERPOINT_MANIFEST: manifestPath },
|
|
193
|
+
});
|
|
194
|
+
let stderr = "";
|
|
195
|
+
let settled = false;
|
|
196
|
+
const finish = (error) => {
|
|
197
|
+
if (settled)
|
|
198
|
+
return;
|
|
199
|
+
settled = true;
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
if (error)
|
|
202
|
+
rejectPromise(error);
|
|
203
|
+
else
|
|
204
|
+
resolvePromise();
|
|
205
|
+
};
|
|
206
|
+
child.stderr.on("data", (chunk) => {
|
|
207
|
+
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8192);
|
|
208
|
+
});
|
|
209
|
+
child.once("error", (error) => {
|
|
210
|
+
if ("code" in error && error.code === "ENOENT") {
|
|
211
|
+
finish(new PowerPointAutomationError("Windows PowerShell is unavailable, so PowerPoint Copy all cannot run.", "not-installed"));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
finish(new PowerPointAutomationError("PowerPoint automation could not be started.", "automation-failed"));
|
|
215
|
+
});
|
|
216
|
+
child.once("close", (code) => {
|
|
217
|
+
if (code === 0) {
|
|
218
|
+
finish();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (stderr.includes("CAB_POWERPOINT_NOT_INSTALLED")) {
|
|
222
|
+
finish(new PowerPointAutomationError("Desktop Microsoft PowerPoint was not found. Install/open desktop PowerPoint and try again.", "not-installed"));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
finish(new PowerPointAutomationError("PowerPoint could not prepare the multi-object clipboard. Close modal dialogs in PowerPoint and try again.", "automation-failed"));
|
|
226
|
+
});
|
|
227
|
+
const timeout = setTimeout(() => {
|
|
228
|
+
child.kill();
|
|
229
|
+
finish(new PowerPointAutomationError("PowerPoint Copy all timed out. Close PowerPoint dialogs and try again.", "timeout"));
|
|
230
|
+
}, POWERPOINT_TIMEOUT_MS);
|
|
231
|
+
timeout.unref();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function decodePng(base64) {
|
|
235
|
+
if (base64.length === 0 ||
|
|
236
|
+
base64.length % 4 !== 0 ||
|
|
237
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
|
|
238
|
+
throw new Error("Copy all image contains invalid base64 data.");
|
|
239
|
+
}
|
|
240
|
+
const png = Buffer.from(base64, "base64");
|
|
241
|
+
if (png.length === 0 || png.length > MAX_IMAGE_BYTES) {
|
|
242
|
+
throw new Error("Copy all PNG size is outside the allowed range.");
|
|
243
|
+
}
|
|
244
|
+
if (png.length < PNG_SIGNATURE.length || !png.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
245
|
+
throw new Error("Copy all accepts PNG images only.");
|
|
246
|
+
}
|
|
247
|
+
return png;
|
|
248
|
+
}
|
|
249
|
+
async function readBoundedBody(request) {
|
|
250
|
+
const declared = Number(request.headers["content-length"] ?? 0);
|
|
251
|
+
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
|
|
252
|
+
throw new RequestTooLargeError();
|
|
253
|
+
}
|
|
254
|
+
const chunks = [];
|
|
255
|
+
let total = 0;
|
|
256
|
+
for await (const chunk of request) {
|
|
257
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
258
|
+
total += buffer.length;
|
|
259
|
+
if (total > MAX_REQUEST_BYTES)
|
|
260
|
+
throw new RequestTooLargeError();
|
|
261
|
+
chunks.push(buffer);
|
|
262
|
+
}
|
|
263
|
+
if (total === 0)
|
|
264
|
+
throw new Error("Copy all payload is empty.");
|
|
265
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
266
|
+
}
|
|
267
|
+
class RequestTooLargeError extends Error {
|
|
268
|
+
constructor() {
|
|
269
|
+
super("Copy all payload is too large.");
|
|
270
|
+
this.name = "RequestTooLargeError";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function requestPathname(rawUrl) {
|
|
274
|
+
if (!rawUrl?.startsWith("/") || rawUrl.startsWith("//"))
|
|
275
|
+
return null;
|
|
276
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
277
|
+
return queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
|
278
|
+
}
|
|
279
|
+
function isJsonContentType(value) {
|
|
280
|
+
return value?.toLowerCase().split(";", 1)[0]?.trim() === "application/json";
|
|
281
|
+
}
|
|
282
|
+
function sendJson(response, statusCode, value) {
|
|
283
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
284
|
+
response.statusCode = statusCode;
|
|
285
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
286
|
+
response.setHeader("Content-Length", String(body.length));
|
|
287
|
+
response.setHeader("Cache-Control", "no-store");
|
|
288
|
+
response.end(body);
|
|
289
|
+
}
|
|
290
|
+
function isRecord(value) {
|
|
291
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
292
|
+
}
|
|
293
|
+
const POWERPOINT_COPY_SCRIPT = String.raw `
|
|
294
|
+
$ErrorActionPreference = "Stop"
|
|
295
|
+
Set-StrictMode -Version Latest
|
|
296
|
+
$MsoFalse = 0
|
|
297
|
+
$MsoTrue = -1
|
|
298
|
+
$PpLayoutBlank = 12
|
|
299
|
+
$manifestPath = $env:CAB_POWERPOINT_MANIFEST
|
|
300
|
+
$application = $null
|
|
301
|
+
$presentation = $null
|
|
302
|
+
$slide = $null
|
|
303
|
+
$shapeRange = $null
|
|
304
|
+
$ownedApplication = $false
|
|
305
|
+
|
|
306
|
+
function Release-ComObject {
|
|
307
|
+
param($Object)
|
|
308
|
+
if ($null -ne $Object -and [Runtime.InteropServices.Marshal]::IsComObject($Object)) {
|
|
309
|
+
try { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($Object) } catch {}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if ([string]::IsNullOrWhiteSpace($manifestPath) -or -not (Test-Path -LiteralPath $manifestPath)) {
|
|
315
|
+
throw "Missing internal Copy all manifest."
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
$application = [Runtime.InteropServices.Marshal]::GetActiveObject("PowerPoint.Application")
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
try {
|
|
323
|
+
$application = New-Object -ComObject PowerPoint.Application
|
|
324
|
+
$ownedApplication = $true
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
[Console]::Error.WriteLine("CAB_POWERPOINT_NOT_INSTALLED")
|
|
328
|
+
exit 21
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
333
|
+
$presentation = $application.Presentations.Add($MsoFalse)
|
|
334
|
+
$slide = $presentation.Slides.Add(1, $PpLayoutBlank)
|
|
335
|
+
$shapeNames = New-Object System.Collections.Generic.List[object]
|
|
336
|
+
$columns = [Math]::Ceiling([Math]::Sqrt([double]$manifest.total))
|
|
337
|
+
$index = 0
|
|
338
|
+
|
|
339
|
+
foreach ($item in $manifest.items) {
|
|
340
|
+
for ($copy = 0; $copy -lt [int]$item.quantity; $copy++) {
|
|
341
|
+
$column = $index % $columns
|
|
342
|
+
$row = [Math]::Floor($index / $columns)
|
|
343
|
+
$left = 10 + ($column * 86)
|
|
344
|
+
$top = 10 + ($row * 86)
|
|
345
|
+
$shape = $slide.Shapes.AddPicture(
|
|
346
|
+
[string]$item.path,
|
|
347
|
+
$MsoFalse,
|
|
348
|
+
$MsoTrue,
|
|
349
|
+
[single]$left,
|
|
350
|
+
[single]$top,
|
|
351
|
+
[single]72,
|
|
352
|
+
[single]72
|
|
353
|
+
)
|
|
354
|
+
try {
|
|
355
|
+
$null = $shapeNames.Add([string]$shape.Name)
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
Release-ComObject $shape
|
|
359
|
+
}
|
|
360
|
+
$index++
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
$shapeRange = $slide.Shapes.Range([object[]]$shapeNames.ToArray())
|
|
365
|
+
$shapeRange.Copy()
|
|
366
|
+
Start-Sleep -Milliseconds 350
|
|
367
|
+
$presentation.Saved = $MsoTrue
|
|
368
|
+
$presentation.Close()
|
|
369
|
+
Release-ComObject $shapeRange
|
|
370
|
+
$shapeRange = $null
|
|
371
|
+
Release-ComObject $slide
|
|
372
|
+
$slide = $null
|
|
373
|
+
Release-ComObject $presentation
|
|
374
|
+
$presentation = $null
|
|
375
|
+
|
|
376
|
+
if ($ownedApplication) {
|
|
377
|
+
$application.Quit()
|
|
378
|
+
}
|
|
379
|
+
Release-ComObject $application
|
|
380
|
+
$application = $null
|
|
381
|
+
[GC]::Collect()
|
|
382
|
+
[GC]::WaitForPendingFinalizers()
|
|
383
|
+
exit 0
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
[Console]::Error.WriteLine("CAB_POWERPOINT_AUTOMATION_FAILED")
|
|
387
|
+
exit 22
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
if ($null -ne $shapeRange) { Release-ComObject $shapeRange }
|
|
391
|
+
if ($null -ne $slide) { Release-ComObject $slide }
|
|
392
|
+
if ($null -ne $presentation) {
|
|
393
|
+
try {
|
|
394
|
+
$presentation.Saved = $MsoTrue
|
|
395
|
+
$presentation.Close()
|
|
396
|
+
} catch {}
|
|
397
|
+
Release-ComObject $presentation
|
|
398
|
+
}
|
|
399
|
+
if ($null -ne $application) {
|
|
400
|
+
if ($ownedApplication) { try { $application.Quit() } catch {} }
|
|
401
|
+
Release-ComObject $application
|
|
402
|
+
}
|
|
403
|
+
[GC]::Collect()
|
|
404
|
+
[GC]::WaitForPendingFinalizers()
|
|
405
|
+
}
|
|
406
|
+
`;
|