Skip to content

Commit 7ef5d60

Browse files
authored
fix: restore shape ID counter before createPresentation in restorePre… (#113)
* fix: restore shape ID counter before createPresentation in restorePresentation When a handler is recompiled (new handler registered), ESM module-level variables reset to initial values. The shape ID counter in ooxml-core resets to 1. Previously, restorePresentation called createPresentation first, then set the counter — but any shapes created between those two calls would get IDs starting from 1, colliding with restored slides. Fix: set the shape ID counter FIRST, before createPresentation, so all subsequent shape creation uses the correct counter value. Also adds a fallback for older serialized presentations that lack shapeIdCounter: scans all restored slides for the maximum existing shape ID and sets the counter to that value, preventing collisions even without explicit counter tracking. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix: address PR #113 review feedback - Validate shapeIdCounter with Number.isFinite + Number.isInteger before passing to setShapeIdCounter (rejects NaN/Infinity/floats) - Extract shared SHAPE_ID_REGEX constant and _extractShapeIds helper used by both _validatePresentation and restorePresentation - Add 2 tests for shape ID continuity across serialize/restore: 1. Normal flow: serialize → restore → addSlideNumbers → buildZip 2. Legacy fallback: missing shapeIdCounter → scan slides for max ID Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> --------- Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 09d6567 commit 7ef5d60

3 files changed

Lines changed: 111 additions & 24 deletions

File tree

builtin-modules/pptx.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "PowerPoint PPTX presentation builder - slides, text, shapes, themes, layouts",
44
"author": "system",
55
"mutable": false,
6-
"sourceHash": "sha256:0139928e7286a4f7",
6+
"sourceHash": "sha256:b9ec89c9f86fc4b5",
77
"dtsHash": "sha256:0e08c6d6b51b36e8",
88
"importStyle": "named",
99
"hints": {

builtin-modules/src/pptx.ts

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3916,7 +3916,9 @@ function textFitsInBox(
39163916
* @param items - Array of shape XML strings or objects with toString()
39173917
* @returns Combined XML string
39183918
*/
3919-
export function shapes(items: Array<ShapeFragment | null | undefined>): ShapeFragment {
3919+
export function shapes(
3920+
items: Array<ShapeFragment | null | undefined>,
3921+
): ShapeFragment {
39203922
if (!Array.isArray(items)) {
39213923
throw new Error(
39223924
`shapes(): expected an array of ShapeFragment items, but got ${typeof items}. ` +
@@ -4389,6 +4391,25 @@ export interface ValidationResult {
43894391
* @returns ValidationResult with any issues found
43904392
* @internal
43914393
*/
4394+
4395+
/** Regex to extract shape IDs from OOXML slide shapes XML.
4396+
* Used by both validation (duplicate detection) and restore (max ID scan). */
4397+
const SHAPE_ID_REGEX = /<p:cNvPr\s+id="(\d+)"/g;
4398+
4399+
/**
4400+
* Scan slide shapes XML and return all numeric shape IDs found.
4401+
* @param shapes - Raw OOXML shapes XML string from a slide
4402+
* @returns Array of numeric shape IDs
4403+
*/
4404+
function _extractShapeIds(shapes: string): number[] {
4405+
const ids: number[] = [];
4406+
for (const m of shapes.matchAll(SHAPE_ID_REGEX)) {
4407+
const n = parseInt(m[1], 10);
4408+
if (Number.isFinite(n)) ids.push(n);
4409+
}
4410+
return ids;
4411+
}
4412+
43924413
function _validatePresentation(
43934414
slides: SlideData[],
43944415
charts: ChartEntry[],
@@ -4422,7 +4443,11 @@ function _validatePresentation(
44224443
if (topTags) {
44234444
for (const tag of topTags) {
44244445
const nodeName = tag.match(/<(p:\w+)/)?.[1];
4425-
if (nodeName && !ALLOWED_SHAPE_NODES.has(nodeName) && nodeName !== "p:txBody") {
4446+
if (
4447+
nodeName &&
4448+
!ALLOWED_SHAPE_NODES.has(nodeName) &&
4449+
nodeName !== "p:txBody"
4450+
) {
44264451
// Only warn for unexpected nodes (not errors, since some are legit internal use)
44274452
warnings.push({
44284453
code: "PPTX_UNEXPECTED_SHAPE_NODE",
@@ -4436,11 +4461,11 @@ function _validatePresentation(
44364461
}
44374462

44384463
// A3: Detect duplicate shape IDs within a slide
4439-
const idMatches = shapes.matchAll(/<p:cNvPr\s+id="(\d+)"/g);
4464+
const slideShapeIds = _extractShapeIds(shapes);
44404465
const slideIds = new Set<string>();
4441-
for (const m of idMatches) {
4442-
const id = m[1];
4443-
if (slideIds.has(id)) {
4466+
for (const id of slideShapeIds) {
4467+
const idStr = String(id);
4468+
if (slideIds.has(idStr)) {
44444469
errors.push({
44454470
code: "PPTX_DUPLICATE_SHAPE_ID",
44464471
severity: "error",
@@ -4449,15 +4474,16 @@ function _validatePresentation(
44494474
hint: "Each shape must have a unique ID. This usually means shapes were copy-pasted incorrectly.",
44504475
});
44514476
}
4452-
slideIds.add(id);
4477+
slideIds.add(idStr);
44534478
// Track globally too
4454-
if (!globalShapeIds.has(id)) globalShapeIds.set(id, []);
4455-
globalShapeIds.get(id)!.push(i);
4479+
if (!globalShapeIds.has(idStr)) globalShapeIds.set(idStr, []);
4480+
globalShapeIds.get(idStr)!.push(i);
44564481
}
44574482

44584483
// A4: Check balanced required tags
44594484
for (const tag of ["p:sp", "p:pic", "p:graphicFrame", "p:cxnSp"]) {
4460-
const opens = (shapes.match(new RegExp(`<${tag}[\\s>]`, "g")) || []).length;
4485+
const opens = (shapes.match(new RegExp(`<${tag}[\\s>]`, "g")) || [])
4486+
.length;
44614487
const closes = (shapes.match(new RegExp(`</${tag}>`, "g")) || []).length;
44624488
if (opens !== closes) {
44634489
errors.push({
@@ -4521,19 +4547,20 @@ function _validatePresentation(
45214547
errors.push({
45224548
code: "PPTX_CHART_MISSING_ROOT",
45234549
severity: "error",
4524-
message: "Chart XML missing required <c:chartSpace> or <c:chart> elements.",
4550+
message:
4551+
"Chart XML missing required <c:chartSpace> or <c:chart> elements.",
45254552
part: entry.name,
45264553
hint: "Regenerate the chart using barChart/pieChart/lineChart/comboChart.",
45274554
});
45284555
}
45294556

45304557
// B3b: Axis ID/crossAx consistency (for bar/line/combo charts)
4531-
const axIds = [...xml.matchAll(/<c:axId val="(\d+)"\/>/g)].map((m) =>
4532-
m[1],
4558+
const axIds = [...xml.matchAll(/<c:axId val="(\d+)"\/>/g)].map(
4559+
(m) => m[1],
4560+
);
4561+
const crossAxIds = [...xml.matchAll(/<c:crossAx val="(\d+)"\/>/g)].map(
4562+
(m) => m[1],
45334563
);
4534-
const crossAxIds = [
4535-
...xml.matchAll(/<c:crossAx val="(\d+)"\/>/g),
4536-
].map((m) => m[1]);
45374564
for (const crossId of crossAxIds) {
45384565
if (!axIds.includes(crossId)) {
45394566
errors.push({
@@ -4937,7 +4964,9 @@ export function createPresentation(opts?: CreatePresentationOptions) {
49374964
);
49384965
}
49394966
// Validate and convert ShapeFragment(s) to XML, then delegate to internal method
4940-
const shapesStr = fragmentsToXml(shapesInput as ShapeFragment | ShapeFragment[]);
4967+
const shapesStr = fragmentsToXml(
4968+
shapesInput as ShapeFragment | ShapeFragment[],
4969+
);
49414970
pres._addBodyRaw(shapesStr, slideOpts);
49424971
},
49434972

@@ -5869,6 +5898,29 @@ export function restorePresentation(state: SerializedPresentation): Pres {
58695898
);
58705899
}
58715900

5901+
// Restore shape ID counter FIRST — before createPresentation or any other
5902+
// function that might generate shapes. This prevents ID collisions when
5903+
// the module's global counter has been reset (e.g., handler recompilation
5904+
// resets ESM module-level variables to initial values).
5905+
if (
5906+
typeof state.shapeIdCounter === "number" &&
5907+
Number.isFinite(state.shapeIdCounter) &&
5908+
Number.isInteger(state.shapeIdCounter) &&
5909+
state.shapeIdCounter >= 1
5910+
) {
5911+
setShapeIdCounter(state.shapeIdCounter);
5912+
} else if (state.slides && Array.isArray(state.slides)) {
5913+
// No valid saved counter — scan existing slides to find the max shape ID
5914+
// so new shapes don't collide with restored ones.
5915+
let maxId = 1;
5916+
for (const slide of state.slides) {
5917+
for (const id of _extractShapeIds(slide.shapes)) {
5918+
if (id > maxId) maxId = id;
5919+
}
5920+
}
5921+
setShapeIdCounter(maxId);
5922+
}
5923+
58725924
// Recreate the presentation with the same options
58735925
const pres = createPresentation({
58745926
theme: state.themeName,
@@ -5899,12 +5951,6 @@ export function restorePresentation(state: SerializedPresentation): Pres {
58995951
pres._chartEntries = state.chartEntries;
59005952
}
59015953

5902-
// Restore shape ID counter to prevent duplicate IDs when adding new shapes
5903-
// This is critical for addSlideNumbers/addFooter called after restore
5904-
if (typeof state.shapeIdCounter === "number") {
5905-
setShapeIdCounter(state.shapeIdCounter);
5906-
}
5907-
59085954
return pres;
59095955
}
59105956

tests/pptx-validation.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2464,6 +2464,47 @@ describe("layout helpers", () => {
24642464
});
24652465
expect(pres2._images[0].relId).toBe("rIdImage1");
24662466
});
2467+
2468+
it("should preserve shape ID counter across serialize/restore", () => {
2469+
// Part 1: Create presentation with several shapes
2470+
const pres1 = pptx.createPresentation();
2471+
pptx.titleSlide(pres1, { title: "Slide 1" });
2472+
pptx.contentSlide(pres1, { title: "Slide 2", body: "Content" });
2473+
pptx.contentSlide(pres1, { title: "Slide 3", body: "More" });
2474+
2475+
const state = pres1.serialize();
2476+
// Counter should be > 1 after creating shapes
2477+
expect(state.shapeIdCounter).toBeGreaterThan(1);
2478+
2479+
// Part 2: Restore and add slide numbers (which create new shapes)
2480+
const pres2 = pptx.restorePresentation(state);
2481+
pptx.addSlideNumbers(pres2);
2482+
2483+
// Should NOT throw duplicate shape ID validation errors
2484+
const zip = pres2.buildZip();
2485+
expect(zip).toBeInstanceOf(Uint8Array);
2486+
expect(zip.length).toBeGreaterThan(0);
2487+
});
2488+
2489+
it("should scan slides for max ID when shapeIdCounter is missing", () => {
2490+
// Simulate a legacy serialized presentation without shapeIdCounter
2491+
const pres1 = pptx.createPresentation();
2492+
pptx.titleSlide(pres1, { title: "Title" });
2493+
pptx.contentSlide(pres1, { title: "Content", body: "Body" });
2494+
2495+
const state = pres1.serialize();
2496+
// Remove the counter to simulate legacy data
2497+
delete (state as Record<string, unknown>).shapeIdCounter;
2498+
2499+
// Restore should scan and find max ID from shapes
2500+
const pres2 = pptx.restorePresentation(state);
2501+
pptx.addSlideNumbers(pres2);
2502+
2503+
// Should NOT throw duplicate shape ID validation errors
2504+
const zip = pres2.buildZip();
2505+
expect(zip).toBeInstanceOf(Uint8Array);
2506+
expect(zip.length).toBeGreaterThan(0);
2507+
});
24672508
});
24682509
});
24692510

0 commit comments

Comments
 (0)