slidemind 0.3.16 → 0.3.18
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +4 -1
- package/src/builders/build.mjs +37 -2
- package/src/builders/draft-check.mjs +47 -3
- package/src/lib/adopt.mjs +30 -24
- package/src/lib/reconcile.mjs +27 -1
- package/src/pptx/app-props.mjs +0 -4
- package/src/pptx/slide-swap.mjs +0 -3
- package/src/pptx/image-extensions.mjs +0 -190
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"description": "Connect Claude Code to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design, from the terminal.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "statworx",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"description": "Connect Codex to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design. Requires the slidemind CLI (npm install -g slidemind); the skills explain the install when it is missing.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "statworx",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -652,7 +652,9 @@ From here:
|
|
|
652
652
|
the gate will open). Two things come back, and both bind:
|
|
653
653
|
- **Measured lines** under each slide — `[fill:warn]` (a stretched region whose organisms pool
|
|
654
654
|
at its top, with the region and the dead band) and `[inventory:warn]` (connectors listed in
|
|
655
|
-
`asset-plan.md` but not drawn
|
|
655
|
+
`asset-plan.md` but not drawn — counted per route kind too, so five `curved` rows drawn as
|
|
656
|
+
elbows warn even when the total matches; an empty inventory, a fetched asset no slide
|
|
657
|
+
references) —
|
|
656
658
|
and a closing `DRAFT CHECK: N measured defects` line. Fix every one in the JSX and re-run;
|
|
657
659
|
the review gate refuses to open while they stand for an unchanged slide. A fill band is
|
|
658
660
|
fixed by distributing the organisms (`grid-template-rows` with one track per organism,
|
|
@@ -621,7 +621,10 @@ export async function viewDraft(
|
|
|
621
621
|
|
|
622
622
|
let captured;
|
|
623
623
|
try {
|
|
624
|
-
|
|
624
|
+
// Converted slides never reach the local engine — it would lint each one
|
|
625
|
+
// as an unregistered layout and the noise would ride the translate gate.
|
|
626
|
+
const capturable = all.filter((f) => f.endsWith(".jsx") && !conversions[f]);
|
|
627
|
+
captured = await captureDeckLocally({ deckDir, bundleDir, onProgress, only: capturable });
|
|
625
628
|
} catch (err) {
|
|
626
629
|
throw new Error(
|
|
627
630
|
`local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
|
package/src/builders/build.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { loadChartPreview, loadConnectorPreview } from "../bridge/capture.mjs";
|
|
|
38
38
|
import { FAVICON_LINK } from "./gate-server.mjs";
|
|
39
39
|
import { lintSlidesOrThrow } from "./lint.mjs";
|
|
40
40
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
41
|
+
import { loadConversions } from "../lib/conversions.mjs";
|
|
41
42
|
import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
|
|
42
43
|
|
|
43
44
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -87,6 +88,29 @@ function inlineLayout(file) {
|
|
|
87
88
|
);
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// Preview stand-ins for the ConvertedHost layouts converted slides invoke. The
|
|
92
|
+
// host's name carries its canvas (`ConvertedHost<cx>x<cy>`, EMU), so the stub
|
|
93
|
+
// sizes itself the way the minted host does (EMU / 9525, 3 decimals) instead of
|
|
94
|
+
// assuming 16:9 — a 4:3 source previews at 960x720, not stretched to 1280.
|
|
95
|
+
// Real geometry still comes from the server render.
|
|
96
|
+
const HOST_NAME_RE = /<(ConvertedHost(\d+)x(\d+))/;
|
|
97
|
+
const emuToCssPx = (emu) => `${Math.round((Number(emu) / 9525) * 1000) / 1000}px`;
|
|
98
|
+
|
|
99
|
+
function convertedHostStubs(slideFiles, conversions) {
|
|
100
|
+
const hosts = new Map();
|
|
101
|
+
for (const file of slideFiles) {
|
|
102
|
+
if (!conversions[path.basename(file)]) continue;
|
|
103
|
+
const m = HOST_NAME_RE.exec(fs.readFileSync(file, "utf8"));
|
|
104
|
+
if (m) hosts.set(m[1], { w: emuToCssPx(m[2]), h: emuToCssPx(m[3]) });
|
|
105
|
+
}
|
|
106
|
+
return [...hosts].map(
|
|
107
|
+
([name, { w, h }]) =>
|
|
108
|
+
`<script type="text/jsx-template" data-kind="layout" data-name="${name}" data-binding="${name}">\n` +
|
|
109
|
+
`export default function ${name}({ background, children }) { return <div style={{ position: "relative", width: "${w}", height: "${h}", overflow: "hidden", background: background || "#FFFFFF" }}>{children}</div>; }\n` +
|
|
110
|
+
`</script>`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
function layoutBindingNameForStem(stem) {
|
|
91
115
|
return "Slide" + stem.split("-").map((p) => (
|
|
92
116
|
p ? p[0].toUpperCase() + p.slice(1) : ""
|
|
@@ -354,7 +378,15 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
354
378
|
fs.readFileSync(file, "utf8"),
|
|
355
379
|
]),
|
|
356
380
|
);
|
|
357
|
-
|
|
381
|
+
// Converted slides (`slidemind adopt`) invoke a synthetic ConvertedHost
|
|
382
|
+
// layout the server mints per source canvas; no bundle carries it, so the
|
|
383
|
+
// local engine would fail every one of them as an unregistered layout. They
|
|
384
|
+
// are linted server-side on render — skip them here, lint the authored rest.
|
|
385
|
+
const conversions = loadConversions(deckDir, { includeSource: false });
|
|
386
|
+
const authoredFiles = slideFiles.filter((f) => !conversions[path.basename(f)]);
|
|
387
|
+
if (authoredFiles.length > 0) {
|
|
388
|
+
await lintSlidesOrThrow({ deckDir, slideFiles: authoredFiles, layoutSources });
|
|
389
|
+
}
|
|
358
390
|
|
|
359
391
|
// The <Chart> stand-in runtime rides in the same bundle cache (see
|
|
360
392
|
// loadChartPreview) — resolved after lint, which already proved the bundle
|
|
@@ -364,7 +396,10 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
364
396
|
|
|
365
397
|
copyDesignSystem(deckDir, skill);
|
|
366
398
|
|
|
367
|
-
const layoutsBlock = layoutFiles
|
|
399
|
+
const layoutsBlock = layoutFiles
|
|
400
|
+
.map(inlineLayout)
|
|
401
|
+
.concat(convertedHostStubs(slideFiles, conversions))
|
|
402
|
+
.join("\n");
|
|
368
403
|
const slidesBlock = slideFiles
|
|
369
404
|
.map((file, i) => inlineSlide(file, i))
|
|
370
405
|
.join("\n");
|
|
@@ -30,6 +30,20 @@ const NONE_RE = /^\(?\s*(none|n\/a|no\b)/i;
|
|
|
30
30
|
|
|
31
31
|
const pad2 = (n) => String(n).padStart(2, "0");
|
|
32
32
|
|
|
33
|
+
// Route kind per connector row / directive — part of the mockup contract: a
|
|
34
|
+
// loop the concept draws as curves that lands as elbows is a different figure,
|
|
35
|
+
// and the count alone (5 = 5) never fires. Port of inventory.ts (gotcha 84).
|
|
36
|
+
const ROUTE_KINDS = ["straight", "elbow", "curved"];
|
|
37
|
+
const emptyRoutes = () => ({ straight: 0, elbow: 0, curved: 0 });
|
|
38
|
+
function routeKindOf(text) {
|
|
39
|
+
for (const token of text.split(",")) {
|
|
40
|
+
const t = token.trim().toLowerCase();
|
|
41
|
+
if (ROUTE_KINDS.includes(t)) return t;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const describeRoutes = (r) => ROUTE_KINDS.map((k) => `${r[k]} ${k}`).join(", ");
|
|
46
|
+
|
|
33
47
|
/** Seed the inventory form once (never overwrites a filled one). `images`: [{slideNumber,label}]. */
|
|
34
48
|
export function seedAssetPlan(deckDir, images) {
|
|
35
49
|
const file = path.join(deckDir, ASSET_PLAN_FILE);
|
|
@@ -91,7 +105,7 @@ function parseAssetPlan(text) {
|
|
|
91
105
|
const line = raw.trim();
|
|
92
106
|
const heading = SLIDE_HEADING_RE.exec(line);
|
|
93
107
|
if (heading) {
|
|
94
|
-
current = { filled: false, connectors: 0, organisms: 0, icons: 0, iconsResolved: 0, verdict: null, ticked: 0, unticked: 0 };
|
|
108
|
+
current = { filled: false, connectors: 0, routes: emptyRoutes(), organisms: 0, icons: 0, iconsResolved: 0, verdict: null, ticked: 0, unticked: 0 };
|
|
95
109
|
out.set(Number(heading[1]), current);
|
|
96
110
|
section = "";
|
|
97
111
|
continue;
|
|
@@ -119,7 +133,12 @@ function parseAssetPlan(text) {
|
|
|
119
133
|
if (inline && !NONE_RE.test(inline)) {
|
|
120
134
|
if (section === "organisms" || section === "regions") current.filled = true;
|
|
121
135
|
if (section === "connectors") {
|
|
122
|
-
|
|
136
|
+
for (const row of inline.split(";")) {
|
|
137
|
+
if (!row.trim()) continue;
|
|
138
|
+
current.connectors += 1;
|
|
139
|
+
const kind = routeKindOf(row);
|
|
140
|
+
if (kind) current.routes[kind] += 1;
|
|
141
|
+
}
|
|
123
142
|
}
|
|
124
143
|
}
|
|
125
144
|
continue;
|
|
@@ -129,7 +148,11 @@ function parseAssetPlan(text) {
|
|
|
129
148
|
if (!body || NONE_RE.test(body)) continue;
|
|
130
149
|
if (section === "organisms" || section === "regions") current.filled = true;
|
|
131
150
|
if (section === "organisms") current.organisms += 1;
|
|
132
|
-
if (section === "connectors")
|
|
151
|
+
if (section === "connectors") {
|
|
152
|
+
current.connectors += 1;
|
|
153
|
+
const kind = routeKindOf(body);
|
|
154
|
+
if (kind) current.routes[kind] += 1;
|
|
155
|
+
}
|
|
133
156
|
if (section === "icons") {
|
|
134
157
|
current.icons += 1;
|
|
135
158
|
// Resolved = a fetched file appended, or an explicit "(no result)".
|
|
@@ -167,6 +190,19 @@ function countConnectors(jsx) {
|
|
|
167
190
|
return attached + markers;
|
|
168
191
|
}
|
|
169
192
|
|
|
193
|
+
// Route kind per `data-connector` directive; a missing data-route is straight.
|
|
194
|
+
// Imported native connectors carry their own preset and are not authored
|
|
195
|
+
// against the mockup, so they are left out of the kind tally.
|
|
196
|
+
function countRoutes(jsx) {
|
|
197
|
+
const routes = emptyRoutes();
|
|
198
|
+
for (const tag of jsx.match(/<[^<>]*\bdata-connector\b[^<>]*>/g) ?? []) {
|
|
199
|
+
if (/\bdata-native-connector\b/.test(tag)) continue;
|
|
200
|
+
const kind = /\bdata-route\s*=\s*["']([a-z]+)["']/.exec(tag)?.[1] ?? "straight";
|
|
201
|
+
if (ROUTE_KINDS.includes(kind)) routes[kind] += 1;
|
|
202
|
+
}
|
|
203
|
+
return routes;
|
|
204
|
+
}
|
|
205
|
+
|
|
170
206
|
/**
|
|
171
207
|
* `[inventory:warn]` lines for the deck: per authored slide (inventory empty,
|
|
172
208
|
* connectors listed > drawn) plus fetched assets no slide references. Empty
|
|
@@ -212,6 +248,14 @@ export function inventoryWarnings(deckDir, jsxFiles = null) {
|
|
|
212
248
|
`[inventory:warn] ${jsxPath} — asset-plan.md lists ${inv.connectors} connector${inv.connectors === 1 ? "" : "s"} for this slide, the JSX draws ${drawn} (data-connector / marker-end). Draw the missing ones per workflow/authoring.md → Arrow contract; a mockup connector is never dropped.`,
|
|
213
249
|
);
|
|
214
250
|
}
|
|
251
|
+
const drawnRoutes = countRoutes(jsx);
|
|
252
|
+
for (const kind of ROUTE_KINDS) {
|
|
253
|
+
if (inv.routes[kind] > drawnRoutes[kind]) {
|
|
254
|
+
lines.push(
|
|
255
|
+
`[inventory:warn] ${jsxPath} — asset-plan.md lists ${inv.routes[kind]} ${kind} connector${inv.routes[kind] === 1 ? "" : "s"} for this slide, the JSX draws ${drawnRoutes[kind]} (${describeRoutes(drawnRoutes)}). The mockup's route shape is the contract: keep data-route="${kind}" and fix the endpoint sides or node positions instead of changing the route kind.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
215
259
|
}
|
|
216
260
|
// Fetched assets nobody references: the mockup staged them, the deck lost them.
|
|
217
261
|
const referenced = allJsx
|
package/src/lib/adopt.mjs
CHANGED
|
@@ -30,7 +30,14 @@ import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs
|
|
|
30
30
|
import { foldPickedSlides } from "../pptx/slide-swap.mjs";
|
|
31
31
|
import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
|
|
32
32
|
import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { writeConversionRecord } from "./conversions.mjs";
|
|
34
|
+
|
|
35
|
+
// Converted JSX needs a fresh baseline against the imported PPTX. Source tags
|
|
36
|
+
// alone cannot recover the original conversion evidence or preserved natives.
|
|
37
|
+
export function needsConversion(decoded) {
|
|
38
|
+
return !decoded || /<ConvertedHost\d+x\d+\b/.test(decoded.jsx) ||
|
|
39
|
+
/^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m.test(decoded.jsx);
|
|
40
|
+
}
|
|
34
41
|
|
|
35
42
|
export const LIVE_DECK_UNSUPPORTED =
|
|
36
43
|
"conversion of live presentations requires the add-in path in v1 — " +
|
|
@@ -174,21 +181,13 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
174
181
|
const native = [];
|
|
175
182
|
let bundle = null;
|
|
176
183
|
|
|
177
|
-
// Fold
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// that stays native, a CONVERTED slide whose preserved natives (OLE /
|
|
181
|
-
// SmartArt / unsupported chart kinds) need their verbatim source package kept
|
|
182
|
-
// for the render-time splice (design D4/D6), and a converted slide carrying
|
|
183
|
-
// the deck's own chart as `<Chart data-sm-source-id>`, which the render
|
|
184
|
-
// patches against that same package (native-charts 5.4a).
|
|
184
|
+
// Fold every converted slide in one pass. The package supplies preserved
|
|
185
|
+
// natives AND the original envelope (layouts, passive third-party tags,
|
|
186
|
+
// relationships). Tagged converted JSX alone cannot restore either safely.
|
|
185
187
|
const picks = [];
|
|
186
188
|
decodedByIndex.forEach((decoded, index) => {
|
|
187
|
-
if (decoded) return;
|
|
188
|
-
|
|
189
|
-
if (!conv || conv.preserved?.length > 0 || hasSourcedChart(conv.jsx)) {
|
|
190
|
-
picks.push(index + 1);
|
|
191
|
-
}
|
|
189
|
+
if (!needsConversion(decoded)) return;
|
|
190
|
+
picks.push(index + 1);
|
|
192
191
|
});
|
|
193
192
|
const foldByPick = new Map(
|
|
194
193
|
(picks.length
|
|
@@ -229,7 +228,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
229
228
|
}
|
|
230
229
|
|
|
231
230
|
decodedByIndex.forEach((decoded, index) => {
|
|
232
|
-
if (decoded) {
|
|
231
|
+
if (decoded && !needsConversion(decoded)) {
|
|
233
232
|
const stem = stemFor(decoded.meta.name, index, taken);
|
|
234
233
|
let jsx = decoded.jsx;
|
|
235
234
|
for (const asset of decoded.assets) {
|
|
@@ -251,11 +250,14 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
251
250
|
if (conv) {
|
|
252
251
|
// Deterministically converted: editable jsx in the converted dialect,
|
|
253
252
|
// plus the identity/evidence record push threads back to the server.
|
|
254
|
-
const stem = stemFor(conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
|
|
253
|
+
const stem = stemFor(decoded?.meta.name ?? conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
|
|
255
254
|
let jsx = conv.jsx;
|
|
256
255
|
for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
|
|
257
256
|
fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
|
|
258
|
-
const
|
|
257
|
+
const candidateGuid = decoded?.guid ?? conv.guid;
|
|
258
|
+
const owns = !decoded || ownerIndexByGuid.get(decoded.guid)?.index === index;
|
|
259
|
+
const guid = owns && candidateGuid && !seenGuids.has(candidateGuid)
|
|
260
|
+
? candidateGuid : mintSlideGuid();
|
|
259
261
|
seenGuids.add(guid);
|
|
260
262
|
guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
|
|
261
263
|
writeConversionRecord(
|
|
@@ -277,6 +279,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
277
279
|
},
|
|
278
280
|
fold?.bytes ?? null,
|
|
279
281
|
);
|
|
282
|
+
bundle ??= decoded?.meta.bundle ?? null;
|
|
280
283
|
convertedFiles.push(`${stem}.jsx`);
|
|
281
284
|
return;
|
|
282
285
|
}
|
|
@@ -329,7 +332,10 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
/**
|
|
332
|
-
*
|
|
335
|
+
* Convert foreign slides and re-convert embedded converted slides against the
|
|
336
|
+
* CURRENT imported PPTX. This recovers a fresh baseline/evidence pair without
|
|
337
|
+
* pretending the original conversion was reviewed. Ordinary authored JSX is
|
|
338
|
+
* restored verbatim. Requests go through the add-in's
|
|
333
339
|
* `POST /api/cli/brands/:brandId/convert-pptx` route. Returns the route result
|
|
334
340
|
* (`{ slides, assets, skipped, warnings }` + the `brandId` it ran against) to
|
|
335
341
|
* hand to {@link adoptDeckFromPptx}, or null when conversion is not available
|
|
@@ -349,7 +355,7 @@ export async function convertUntaggedSlides({
|
|
|
349
355
|
const { buffer, decodedByIndex } = source ?? readSourceDeck(pptxPath);
|
|
350
356
|
const slides = [];
|
|
351
357
|
decodedByIndex.forEach((decoded, i) => {
|
|
352
|
-
if (
|
|
358
|
+
if (needsConversion(decoded)) slides.push(i + 1);
|
|
353
359
|
});
|
|
354
360
|
if (slides.length === 0) return null;
|
|
355
361
|
|
|
@@ -372,7 +378,7 @@ export async function convertUntaggedSlides({
|
|
|
372
378
|
if (brands.length !== 1) {
|
|
373
379
|
note(
|
|
374
380
|
brands.length === 0
|
|
375
|
-
? "no corporate design available to convert against —
|
|
381
|
+
? "no corporate design available to convert against — slides requiring conversion stay native"
|
|
376
382
|
: "several corporate designs available — pass `--brand <presFP>` to convert the untagged slides",
|
|
377
383
|
);
|
|
378
384
|
return null;
|
|
@@ -383,7 +389,7 @@ export async function convertUntaggedSlides({
|
|
|
383
389
|
// capability probe — do it here, so a whole .pptx is never shipped to a
|
|
384
390
|
// backend that will just 404 the route.
|
|
385
391
|
if (backendURL && !(await client.backendSupportsConvertPptx(backendURL))) {
|
|
386
|
-
note("this add-in does not convert foreign slides —
|
|
392
|
+
note("this add-in does not convert foreign slides — slides requiring conversion stay native");
|
|
387
393
|
return null;
|
|
388
394
|
}
|
|
389
395
|
}
|
|
@@ -396,15 +402,15 @@ export async function convertUntaggedSlides({
|
|
|
396
402
|
backendURL,
|
|
397
403
|
});
|
|
398
404
|
if (result?.ok === false) {
|
|
399
|
-
note("the add-in declined to convert this file —
|
|
405
|
+
note("the add-in declined to convert this file — slides requiring conversion stay native");
|
|
400
406
|
return null;
|
|
401
407
|
}
|
|
402
408
|
return { ...result, brandId: resolved };
|
|
403
409
|
} catch (err) {
|
|
404
410
|
note(
|
|
405
411
|
err?.code === "convert_route_unavailable"
|
|
406
|
-
? "this add-in does not convert foreign slides —
|
|
407
|
-
: `slide conversion unavailable (${err?.message ?? err}) —
|
|
412
|
+
? "this add-in does not convert foreign slides — slides requiring conversion stay native"
|
|
413
|
+
: `slide conversion unavailable (${err?.message ?? err}) — slides requiring conversion stay native`,
|
|
408
414
|
);
|
|
409
415
|
return null;
|
|
410
416
|
}
|
package/src/lib/reconcile.mjs
CHANGED
|
@@ -166,8 +166,34 @@ export async function collectLiveReconcileInputs({
|
|
|
166
166
|
const before = Object.keys(exported).length;
|
|
167
167
|
const ack = await exportRound(exportSlides, pending);
|
|
168
168
|
deck ??= ack?.deck ?? null;
|
|
169
|
+
// PowerPoint web (package fallback on) may hand back the SAVED file's bytes
|
|
170
|
+
// (`provenance.source` graph/saved) when the host export fails. Saved bytes
|
|
171
|
+
// cannot show unsaved manual edits, so projecting them would read "no
|
|
172
|
+
// drift" for a slide the user is editing right now — treat them as not
|
|
173
|
+
// exported, same failure mode as a slide that never arrived.
|
|
174
|
+
const stale = [];
|
|
169
175
|
for (const [guid, slide] of Object.entries(ack?.slides ?? {})) {
|
|
170
|
-
if (slide?.base64
|
|
176
|
+
if (!slide?.base64 || !slide?.tags) continue;
|
|
177
|
+
if (slide.provenance && slide.provenance.source !== "host") stale.push(guid);
|
|
178
|
+
else exported[guid] = slide;
|
|
179
|
+
}
|
|
180
|
+
// `failed` names slides neither the host nor the stored file could supply
|
|
181
|
+
// (web fallback); they never come back, so they fail closed at once.
|
|
182
|
+
const failed = Object.keys(ack?.failed ?? {}).filter(
|
|
183
|
+
(guid) => !exported[guid] && requested.includes(guid),
|
|
184
|
+
);
|
|
185
|
+
const unverifiable = [...new Set([...failed, ...stale])];
|
|
186
|
+
if (unverifiable.length > 0) {
|
|
187
|
+
const err = new Error(
|
|
188
|
+
`the pane could not export ${unverifiable.length} live slide(s) from the ` +
|
|
189
|
+
`open presentation (${failed.length} unavailable, ${stale.length} only as ` +
|
|
190
|
+
`the saved file), so their manual edits cannot be checked. Save the ` +
|
|
191
|
+
`presentation and retry, or re-run with --force to push anyway and ` +
|
|
192
|
+
`discard any manual edits.`,
|
|
193
|
+
);
|
|
194
|
+
err.code = "export_incomplete";
|
|
195
|
+
err.failed = ack?.failed ?? {};
|
|
196
|
+
throw err;
|
|
171
197
|
}
|
|
172
198
|
const next = (Array.isArray(ack?.truncated) ? ack.truncated : []).filter(
|
|
173
199
|
(guid) => !exported[guid] && requested.includes(guid),
|
package/src/pptx/app-props.mjs
CHANGED
|
@@ -20,7 +20,6 @@ import { unzip, zip, readText } from "./zip.mjs";
|
|
|
20
20
|
import { slideOrderPaths, decodeXmlEntities } from "./ooxml.mjs";
|
|
21
21
|
import { slideText } from "./read-local.mjs";
|
|
22
22
|
import { stripHiddenOleGraphicFrames } from "./hidden-ole.mjs";
|
|
23
|
-
import { stripRiskyImageExtensions } from "./image-extensions.mjs";
|
|
24
23
|
|
|
25
24
|
const APP_PROPS = "docProps/app.xml";
|
|
26
25
|
|
|
@@ -61,9 +60,6 @@ export function normalizePptxAppProperties(buffer) {
|
|
|
61
60
|
if (stripHiddenOleGraphicFrames(entries)) {
|
|
62
61
|
changed = true;
|
|
63
62
|
}
|
|
64
|
-
if (stripRiskyImageExtensions(entries)) {
|
|
65
|
-
changed = true;
|
|
66
|
-
}
|
|
67
63
|
|
|
68
64
|
return {
|
|
69
65
|
bytes: changed ? zip(entries) : buffer,
|
package/src/pptx/slide-swap.mjs
CHANGED
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
resolvePartPath,
|
|
31
31
|
} from "./ooxml.mjs";
|
|
32
32
|
import { stripHiddenOleGraphicFrames } from "./hidden-ole.mjs";
|
|
33
|
-
import { stripRiskyImageExtensions } from "./image-extensions.mjs";
|
|
34
33
|
import {
|
|
35
34
|
slideHasForeignTags,
|
|
36
35
|
slideReferencesEmbeddedWorkbook,
|
|
@@ -112,7 +111,6 @@ export function foldPickedSlides(buffer, picks, { sourceName = "reused" } = {})
|
|
|
112
111
|
part === lowest ? picked : part === picked ? lowest : part,
|
|
113
112
|
) ?? false,
|
|
114
113
|
});
|
|
115
|
-
const riskyImagesStripped = stripRiskyImageExtensions(copy);
|
|
116
114
|
|
|
117
115
|
// Prune to the lowest slide's dependency closure + trimmed skeleton.
|
|
118
116
|
const { entries: pruned, changed } = pruneToLowest(copy);
|
|
@@ -123,7 +121,6 @@ export function foldPickedSlides(buffer, picks, { sourceName = "reused" } = {})
|
|
|
123
121
|
!swapped &&
|
|
124
122
|
!notesStripped &&
|
|
125
123
|
!hiddenOleStripped &&
|
|
126
|
-
!riskyImagesStripped &&
|
|
127
124
|
!changed
|
|
128
125
|
) {
|
|
129
126
|
return { selection: n, slideId, bytes: buffer };
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { readText } from "./zip.mjs";
|
|
2
|
-
import { relsFor, relsPathFor, resolvePartPath } from "./ooxml.mjs";
|
|
3
|
-
|
|
4
|
-
const CONTENT_TYPES = "[Content_Types].xml";
|
|
5
|
-
|
|
6
|
-
export function stripRiskyImageExtensions(files) {
|
|
7
|
-
let changed = false;
|
|
8
|
-
const removedTargets = new Set();
|
|
9
|
-
const candidates = Object.keys(files).filter((part) =>
|
|
10
|
-
/^ppt\/(?:slides|slideMasters|slideLayouts)\/[^/]+\.xml$/.test(part),
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
for (const part of candidates) {
|
|
14
|
-
const before = readText(files, part);
|
|
15
|
-
if (!before) continue;
|
|
16
|
-
|
|
17
|
-
const removedIds = new Set();
|
|
18
|
-
const relsById = new Map(relsFor(files, part).map((rel) => [rel.id, rel]));
|
|
19
|
-
const partDir = part.includes("/") ? part.slice(0, part.lastIndexOf("/")) : "";
|
|
20
|
-
let after = before.replace(
|
|
21
|
-
/<a:ext\b[\s\S]*?<\/a:ext>/g,
|
|
22
|
-
(ext) => {
|
|
23
|
-
if (!/<(?:asvg:svgBlip|a14:imgLayer)\b/.test(ext)) return ext;
|
|
24
|
-
for (const id of relationshipIdsIn(ext)) removedIds.add(id);
|
|
25
|
-
return "";
|
|
26
|
-
},
|
|
27
|
-
);
|
|
28
|
-
after = after.replace(/<a:extLst\b[^>]*>\s*<\/a:extLst>/g, "");
|
|
29
|
-
after = after.replace(
|
|
30
|
-
/<p:pic\b[\s\S]*?<\/p:pic>/g,
|
|
31
|
-
(pic) => {
|
|
32
|
-
if (!/<a:blip\b(?![^>]*\br:(?:embed|link)=)[^>]*>\s*<\/a:blip>/.test(pic)) {
|
|
33
|
-
return pic;
|
|
34
|
-
}
|
|
35
|
-
for (const id of relationshipIdsIn(pic)) removedIds.add(id);
|
|
36
|
-
return "";
|
|
37
|
-
},
|
|
38
|
-
);
|
|
39
|
-
after = after.replace(
|
|
40
|
-
/<p:pic\b[\s\S]*?<\/p:pic>/g,
|
|
41
|
-
(pic) => {
|
|
42
|
-
for (const id of relationshipIdsIn(pic)) {
|
|
43
|
-
const rel = relsById.get(id);
|
|
44
|
-
if (
|
|
45
|
-
rel &&
|
|
46
|
-
rel.targetMode !== "External" &&
|
|
47
|
-
/\.svg(?:$|[?#])/i.test(rel.target ?? "")
|
|
48
|
-
) {
|
|
49
|
-
removedIds.add(id);
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return pic;
|
|
54
|
-
},
|
|
55
|
-
);
|
|
56
|
-
const relsPath = relsPathFor(part);
|
|
57
|
-
const relsXml = readText(files, relsPath);
|
|
58
|
-
const remainingIds = relationshipIdsIn(after);
|
|
59
|
-
if (relsXml) {
|
|
60
|
-
for (const rel of relsById.values()) {
|
|
61
|
-
if (
|
|
62
|
-
rel.targetMode !== "External" &&
|
|
63
|
-
!remainingIds.has(rel.id) &&
|
|
64
|
-
/\.(?:svg|wdp)(?:$|[?#])/i.test(rel.target ?? "")
|
|
65
|
-
) {
|
|
66
|
-
removedIds.add(rel.id);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (after === before && removedIds.size === 0) continue;
|
|
72
|
-
|
|
73
|
-
if (after !== before) {
|
|
74
|
-
files[part] = new TextEncoder().encode(after);
|
|
75
|
-
changed = true;
|
|
76
|
-
}
|
|
77
|
-
if (!relsXml) continue;
|
|
78
|
-
|
|
79
|
-
let nextRels = relsXml;
|
|
80
|
-
for (const id of removedIds) {
|
|
81
|
-
if (remainingIds.has(id)) continue;
|
|
82
|
-
nextRels = removeRelationshipById(nextRels, id);
|
|
83
|
-
const rel = relsById.get(id);
|
|
84
|
-
if (rel && rel.targetMode !== "External") {
|
|
85
|
-
removedTargets.add(resolvePartPath(partDir, rel.target));
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (nextRels !== relsXml) {
|
|
89
|
-
files[relsPath] = new TextEncoder().encode(nextRels);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const deleted = deleteUnreferencedTargets(files, removedTargets);
|
|
94
|
-
if (deleted.size > 0) {
|
|
95
|
-
removeContentTypeOverrides(files, deleted);
|
|
96
|
-
changed = true;
|
|
97
|
-
}
|
|
98
|
-
if (removeUnusedMediaDefaults(files, ["svg", "wdp"])) {
|
|
99
|
-
changed = true;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return changed;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function deleteUnreferencedTargets(files, removedTargets) {
|
|
106
|
-
const remainingRefs = referencedInternalTargets(files);
|
|
107
|
-
const deleted = new Set();
|
|
108
|
-
for (const part of removedTargets) {
|
|
109
|
-
if (!part || remainingRefs.has(part) || files[part] === undefined) continue;
|
|
110
|
-
delete files[part];
|
|
111
|
-
const sidecar = relsPathFor(part);
|
|
112
|
-
if (files[sidecar] !== undefined) delete files[sidecar];
|
|
113
|
-
deleted.add(part);
|
|
114
|
-
}
|
|
115
|
-
return deleted;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function referencedInternalTargets(files) {
|
|
119
|
-
const refs = new Set();
|
|
120
|
-
for (const relsPath of Object.keys(files)) {
|
|
121
|
-
if (!relsPath.endsWith(".rels")) continue;
|
|
122
|
-
const dir = relsPath.replace(/\/?_rels\/[^/]+$/, "");
|
|
123
|
-
const owner =
|
|
124
|
-
dir === ""
|
|
125
|
-
? relsPath.replace(/^_rels\/(.*)\.rels$/, "$1")
|
|
126
|
-
: `${dir}/${relsPath.replace(/^.*_rels\/(.*)\.rels$/, "$1")}`;
|
|
127
|
-
for (const rel of relsFor(files, owner)) {
|
|
128
|
-
if (rel.targetMode === "External") continue;
|
|
129
|
-
refs.add(resolvePartPath(dir, rel.target));
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return refs;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function removeContentTypeOverrides(files, deletedParts) {
|
|
136
|
-
const before = readText(files, CONTENT_TYPES);
|
|
137
|
-
if (!before) return;
|
|
138
|
-
let after = before;
|
|
139
|
-
for (const part of deletedParts) {
|
|
140
|
-
after = after.replace(
|
|
141
|
-
new RegExp(
|
|
142
|
-
`<Override\\b(?=[^>]*\\bPartName="/${escapeRe(part)}")[^>]*?/>`,
|
|
143
|
-
"g",
|
|
144
|
-
),
|
|
145
|
-
"",
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
if (after !== before) files[CONTENT_TYPES] = new TextEncoder().encode(after);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function removeUnusedMediaDefaults(files, exts) {
|
|
152
|
-
const before = readText(files, CONTENT_TYPES);
|
|
153
|
-
if (!before) return false;
|
|
154
|
-
let after = before;
|
|
155
|
-
for (const ext of exts) {
|
|
156
|
-
const hasPart = Object.keys(files).some((part) =>
|
|
157
|
-
part.toLowerCase().endsWith(`.${ext}`),
|
|
158
|
-
);
|
|
159
|
-
if (hasPart) continue;
|
|
160
|
-
after = after.replace(
|
|
161
|
-
new RegExp(`<Default\\b(?=[^>]*\\bExtension="${escapeRe(ext)}")[^>]*?/>`, "g"),
|
|
162
|
-
"",
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (after !== before) {
|
|
166
|
-
files[CONTENT_TYPES] = new TextEncoder().encode(after);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function relationshipIdsIn(xml) {
|
|
173
|
-
const ids = new Set();
|
|
174
|
-
for (const m of String(xml).matchAll(/\br:(?:id|embed|link)\s*=\s*"([^"]+)"/g)) {
|
|
175
|
-
ids.add(m[1]);
|
|
176
|
-
}
|
|
177
|
-
return ids;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function removeRelationshipById(xml, id) {
|
|
181
|
-
return xml.replace(
|
|
182
|
-
new RegExp(
|
|
183
|
-
`<Relationship\\b(?=[^>]*\\bId="${escapeRe(id)}")[^>]*?(?:/>|>[\\s\\S]*?</Relationship>)`,
|
|
184
|
-
"g",
|
|
185
|
-
),
|
|
186
|
-
"",
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|