Skip to content

Commit 58e1254

Browse files
committed
refactor(pptx): handle missing animations and depths without fallbacks
1 parent ff23595 commit 58e1254

5 files changed

Lines changed: 33 additions & 25 deletions

File tree

‎packages/pptx/src/deck.ts‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -318,16 +318,17 @@ const PATH_ARGUMENTS: Record<string, number> = { M: 2, L: 2, C: 6, Z: 0, E: 0 };
318318
const MotionPath = type("string").narrow((path, ctx) => {
319319
const tokens = path.trim().split(/[\s,]+/);
320320
if (tokens[0] !== "M") return ctx.mustBe("a path starting with M");
321-
for (let at = 0; at < tokens.length;) {
322-
const count = PATH_ARGUMENTS[tokens[at] ?? ""];
321+
let at = 0;
322+
for (let command = tokens[at]; command !== undefined; command = tokens[at]) {
323+
const count = PATH_ARGUMENTS[command];
323324
if (count === undefined)
324325
return ctx.mustBe("a path of M, L, C, Z and E commands with numbers");
325326
const numbers = tokens.slice(at + 1, at + 1 + count);
326327
if (
327328
numbers.length < count ||
328329
numbers.some((n) => !Number.isFinite(Number(n)))
329330
)
330-
return ctx.mustBe(`a path whose ${tokens[at]} has ${count} numbers`);
331+
return ctx.mustBe(`a path whose ${command} has ${count} numbers`);
331332
at += 1 + count;
332333
}
333334
return true;
@@ -499,14 +500,16 @@ const NESTING = [
499500
},
500501
] as const;
501502

503+
type Depths = Record<(typeof NESTING)[number]["key"], number>;
504+
502505
/** Lists and groups nested past their limits, found without recursion before the schema's own recursion can overflow. */
503506
function deepNesting(input: unknown): DeckIssue[] {
504507
const issues: DeckIssue[] = [];
505508
const pending = [
506509
{
507510
value: isObject(input) ? input.slides : undefined,
508511
path: ["slides"] as Path,
509-
depths: NESTING.map(() => 0),
512+
depths: { items: 0, children: 0 } satisfies Depths,
510513
},
511514
];
512515
for (let next = pending.pop(); next; next = pending.pop()) {
@@ -515,22 +518,18 @@ function deepNesting(input: unknown): DeckIssue[] {
515518
const entries = Object.entries(value).reverse();
516519
for (const [key, child] of entries) {
517520
const childPath = [...path, Array.isArray(value) ? Number(key) : key];
518-
const level = NESTING.findIndex(
519-
(nesting, index) =>
520-
key === nesting.key &&
521+
const nesting = NESTING.find(
522+
(each) =>
523+
key === each.key &&
521524
Array.isArray(child) &&
522-
nesting.opens(value, depths[index] ?? 0),
525+
each.opens(value, depths[each.key]),
523526
);
524-
const nesting = NESTING[level];
525-
const depth = depths[level] ?? 0;
526527
if (!nesting) pending.push({ value: child, path: childPath, depths });
527-
else if (depth < nesting.limit)
528+
else if (depths[nesting.key] < nesting.limit)
528529
pending.push({
529530
value: child,
530531
path: childPath,
531-
depths: depths.map((each, index) =>
532-
index === level ? each + 1 : each,
533-
),
532+
depths: { ...depths, [nesting.key]: depths[nesting.key] + 1 },
534533
});
535534
else
536535
issues.push(
@@ -654,8 +653,9 @@ function freeSlideIssues(slide: Slide, index: number): DeckIssue[] {
654653
),
655654
);
656655
}
656+
if (!slide.animations) return issues;
657657
let endless = false;
658-
for (const [position, animation] of (slide.animations ?? []).entries()) {
658+
for (const [position, animation] of slide.animations.entries()) {
659659
const path = ["slides", index, "animations", position];
660660
if (!ids.has(animation.target))
661661
issues.push(

‎packages/pptx/src/image.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function isSvg(bytes: Uint8Array) {
5555
for (let at = 0; at < bytes.length; at += CHUNK) {
5656
const end = at + CHUNK >= bytes.length;
5757
text += decoder.decode(bytes.subarray(at, at + CHUNK), { stream: !end });
58-
const rest = text.slice(PROLOG.exec(text)?.[0].length ?? 0);
58+
const rest = text.replace(PROLOG, "");
5959
// An unfinished comment, declaration or tag name needs the next chunk to decide.
6060
if (end || (rest.length > 4 && !/^<[?!]/.test(rest)))
6161
return /^<svg[\s/>]/.test(rest);

‎packages/pptx/src/motion.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ const TOWARD: Record<Side, string> = {
1717
bottom: "up",
1818
};
1919

20-
const speed = (duration: number) =>
21-
duration <= 500 ? "fast" : duration <= 750 ? "med" : "slow";
20+
function speed(duration: number) {
21+
if (duration <= 500) return "fast";
22+
if (duration <= 750) return "med";
23+
return "slow";
24+
}
2225

2326
/** An exact duration needs PowerPoint 2010 and Morph needs 2019; older viewers take the fallback, with Morph as a fade. */
2427
export function transitionXml(transition: Transition) {

‎packages/pptx/src/write.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -441,10 +441,11 @@ function freeSlide(slide: Slide & { layout: "free" }, part: SlidePart) {
441441
part.shapes.push(elementXml(element, part, targets));
442442
return {
443443
background: slide.background ? backgroundXml(slide.background) : "",
444-
timing: timingXml(slide.animations ?? [], targets),
444+
timing: slide.animations ? timingXml(slide.animations, targets) : "",
445445
};
446446
}
447447

448+
/** A placed slide takes its background from the master and has no animations. */
448449
function placedSlide(
449450
deck: Deck,
450451
slide: Exclude<Slide, { layout: "free" }>,
@@ -472,6 +473,7 @@ function placedSlide(
472473
else if (text !== undefined)
473474
part.shapes.push(placeholderShape(part.nextId(), placeholder, text));
474475
}
476+
return { background: "", timing: "" };
475477
}
476478

477479
// Keynote drops an unused Picture with Caption layout and warns about removed media on open.
@@ -495,16 +497,18 @@ function slideXml(
495497
"slideLayout",
496498
`../slideLayouts/slideLayout${layouts.indexOf(layout) + 1}.xml`,
497499
);
498-
const free = slide.layout === "free" ? freeSlide(slide, part) : undefined;
499-
if (slide.layout !== "free") placedSlide(deck, slide, index, part);
500+
const { background, timing } =
501+
slide.layout === "free"
502+
? freeSlide(slide, part)
503+
: placedSlide(deck, slide, index, part);
500504
if (slide.notes !== undefined)
501505
part.rels.add("notesSlide", `../notesSlides/notesSlide${index + 1}.xml`);
502506
const transition = slide.transition ? transitionXml(slide.transition) : "";
503507
const masterShapes = slide.layout === "free" ? ' showMasterSp="0"' : "";
504508
return {
505509
notes: slide.notes,
506510
rels: part.rels,
507-
xml: `${XML_DECLARATION}<p:sld ${NAMESPACES}${masterShapes}><p:cSld>${free?.background ?? ""}<p:spTree>${GROUP_HEADER}${part.shapes.join("")}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>${transition}${free?.timing ?? ""}</p:sld>`,
511+
xml: `${XML_DECLARATION}<p:sld ${NAMESPACES}${masterShapes}><p:cSld>${background}<p:spTree>${GROUP_HEADER}${part.shapes.join("")}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>${transition}${timing}</p:sld>`,
508512
};
509513
}
510514

‎packages/pptx/test/pptx.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,20 +305,21 @@ test("each effect on a shape is its own build, and wipes travel away from their
305305
const timed = parts
306306
.map(({ data }) => new TextDecoder().decode(data))
307307
.find((xml) => xml.includes("<p:timing>"));
308+
if (timed === undefined) throw new Error("no slide has a timing tree");
308309
const effects = [
309-
...(timed ?? "").matchAll(
310+
...timed.matchAll(
310311
/grpId="(\d+)" nodeType="\w+"><p:stCondLst>.*?<p:spTgt spid="(\d+)"\/>/g,
311312
),
312313
].map(([, grpId, spid]) => `${spid}:${grpId}`);
313314
expect(effects.length).toBeGreaterThan(
314315
new Set(effects.map((each) => each.split(":")[0])).size,
315316
);
316317
expect(new Set(effects).size).toBe(effects.length);
317-
for (const [, spid, grpId] of (timed ?? "").matchAll(
318+
for (const [, spid, grpId] of timed.matchAll(
318319
/<p:bldP spid="(\d+)" grpId="(\d+)"/g,
319320
))
320321
expect(effects).toContain(`${spid}:${grpId}`);
321-
const wipes = [...(timed ?? "").matchAll(/filter="(wipe\(\w+\))"/g)].map(
322+
const wipes = [...timed.matchAll(/filter="(wipe\(\w+\))"/g)].map(
322323
([, filter]) => filter,
323324
);
324325
expect(wipes).toEqual(["wipe(right)", "wipe(left)"]);

0 commit comments

Comments
 (0)