ts-visio 1.0.2 → 1.2.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.
@@ -1,21 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ShapeModifier = void 0;
4
- const fast_xml_parser_1 = require("fast-xml-parser");
5
4
  const RelsManager_1 = require("./core/RelsManager");
6
5
  const StyleHelpers_1 = require("./utils/StyleHelpers");
7
6
  const VisioConstants_1 = require("./core/VisioConstants");
7
+ const VisioTypes_1 = require("./types/VisioTypes");
8
8
  const ForeignShapeBuilder_1 = require("./shapes/ForeignShapeBuilder");
9
9
  const ShapeBuilder_1 = require("./shapes/ShapeBuilder");
10
10
  const ConnectorBuilder_1 = require("./shapes/ConnectorBuilder");
11
11
  const ContainerBuilder_1 = require("./shapes/ContainerBuilder");
12
+ const XmlHelper_1 = require("./utils/XmlHelper");
12
13
  class ShapeModifier {
13
14
  // ...
14
15
  async addContainer(pageId, props) {
15
- const pagePath = this.getPagePath(pageId);
16
- let content = this.pkg.getFileText(pagePath);
17
- const parsed = this.parser.parse(content);
18
- // Ensure Shapes container...
16
+ const parsed = this.getParsed(pageId);
19
17
  if (!parsed.PageContents.Shapes)
20
18
  parsed.PageContents.Shapes = { Shape: [] };
21
19
  let topLevelShapes = parsed.PageContents.Shapes.Shape;
@@ -23,20 +21,15 @@ class ShapeModifier {
23
21
  topLevelShapes = topLevelShapes ? [topLevelShapes] : [];
24
22
  parsed.PageContents.Shapes.Shape = topLevelShapes;
25
23
  }
26
- let newId = props.id || this.getNextId(parsed);
24
+ const newId = props.id || this.getNextId(parsed);
27
25
  const containerShape = ContainerBuilder_1.ContainerBuilder.createContainerShape(newId, props);
28
26
  topLevelShapes.push(containerShape);
29
- const newXml = this.builder.build(parsed);
30
- this.pkg.updateFile(pagePath, newXml);
31
- return newId;
32
- this.pkg.updateFile(pagePath, newXml);
27
+ this.getShapeMap(parsed).set(newId, containerShape);
28
+ this.saveParsed(pageId, parsed);
33
29
  return newId;
34
30
  }
35
31
  async addList(pageId, props, direction = 'vertical') {
36
- const pagePath = this.getPagePath(pageId);
37
- let content = this.pkg.getFileText(pagePath);
38
- const parsed = this.parser.parse(content);
39
- // Ensure Shapes container...
32
+ const parsed = this.getParsed(pageId);
40
33
  if (!parsed.PageContents.Shapes)
41
34
  parsed.PageContents.Shapes = { Shape: [] };
42
35
  let topLevelShapes = parsed.PageContents.Shapes.Shape;
@@ -44,57 +37,66 @@ class ShapeModifier {
44
37
  topLevelShapes = topLevelShapes ? [topLevelShapes] : [];
45
38
  parsed.PageContents.Shapes.Shape = topLevelShapes;
46
39
  }
47
- let newId = props.id || this.getNextId(parsed);
40
+ const newId = props.id || this.getNextId(parsed);
48
41
  const listShape = ContainerBuilder_1.ContainerBuilder.createContainerShape(newId, props);
49
42
  ContainerBuilder_1.ContainerBuilder.makeList(listShape, direction);
50
43
  topLevelShapes.push(listShape);
51
- const newXml = this.builder.build(parsed);
52
- this.pkg.updateFile(pagePath, newXml);
44
+ this.getShapeMap(parsed).set(newId, listShape);
45
+ this.saveParsed(pageId, parsed);
53
46
  return newId;
54
47
  }
48
+ /**
49
+ * Register the resolved OPC part path for a page ID.
50
+ * Must be called before any operation on a loaded file to ensure the
51
+ * correct file is targeted rather than the ID-derived fallback name.
52
+ */
53
+ registerPage(pageId, xmlPath) {
54
+ this.pagePathRegistry.set(pageId, xmlPath);
55
+ }
55
56
  constructor(pkg) {
56
57
  this.pkg = pkg;
57
58
  this.pageCache = new Map();
58
59
  this.dirtyPages = new Set();
60
+ this.shapeCache = new WeakMap();
61
+ this.pagePathRegistry = new Map();
59
62
  this.autoSave = true;
60
- this.parser = new fast_xml_parser_1.XMLParser({
61
- ignoreAttributes: false,
62
- attributeNamePrefix: "@_"
63
- });
64
- this.builder = new fast_xml_parser_1.XMLBuilder({
65
- ignoreAttributes: false,
66
- attributeNamePrefix: "@_",
67
- format: true
68
- });
63
+ this.parser = (0, XmlHelper_1.createXmlParser)();
64
+ this.builder = (0, XmlHelper_1.createXmlBuilder)();
69
65
  this.relsManager = new RelsManager_1.RelsManager(pkg);
70
66
  }
71
67
  getPagePath(pageId) {
72
- return `visio/pages/page${pageId}.xml`;
68
+ return this.pagePathRegistry.get(pageId) ?? `visio/pages/page${pageId}.xml`;
73
69
  }
74
- getAllShapes(parsed) {
75
- let topLevelShapes = parsed.PageContents.Shapes ? parsed.PageContents.Shapes.Shape : [];
76
- if (!Array.isArray(topLevelShapes)) {
77
- topLevelShapes = topLevelShapes ? [topLevelShapes] : [];
78
- }
79
- const all = [];
80
- const gather = (shapeList) => {
81
- for (const s of shapeList) {
82
- all.push(s);
83
- if (s.Shapes && s.Shapes.Shape) {
84
- const children = Array.isArray(s.Shapes.Shape) ? s.Shapes.Shape : [s.Shapes.Shape];
85
- gather(children);
86
- }
70
+ getShapeMap(parsed) {
71
+ if (!this.shapeCache.has(parsed)) {
72
+ const map = new Map();
73
+ let topLevelShapes = parsed.PageContents.Shapes ? parsed.PageContents.Shapes.Shape : [];
74
+ if (!Array.isArray(topLevelShapes)) {
75
+ topLevelShapes = topLevelShapes ? [topLevelShapes] : [];
87
76
  }
88
- };
89
- gather(topLevelShapes);
90
- return all;
77
+ const gather = (shapeList) => {
78
+ for (const s of shapeList) {
79
+ map.set(s['@_ID'], s);
80
+ if (s.Shapes && s.Shapes.Shape) {
81
+ const children = Array.isArray(s.Shapes.Shape) ? s.Shapes.Shape : [s.Shapes.Shape];
82
+ gather(children);
83
+ }
84
+ }
85
+ };
86
+ gather(topLevelShapes);
87
+ this.shapeCache.set(parsed, map);
88
+ }
89
+ return this.shapeCache.get(parsed);
90
+ }
91
+ getAllShapes(parsed) {
92
+ return Array.from(this.getShapeMap(parsed).values());
91
93
  }
92
94
  getNextId(parsed) {
93
95
  // Updates PageSheet.NextShapeID to prevent ID conflicts.
94
96
  // Calculates the next ID from existing shapes and increments the counter.
95
- const allShapes = this.getAllShapes(parsed);
97
+ const shapeMap = this.getShapeMap(parsed);
96
98
  let maxId = 0;
97
- for (const s of allShapes) {
99
+ for (const s of shapeMap.values()) {
98
100
  const id = parseInt(s['@_ID']);
99
101
  if (!isNaN(id) && id > maxId)
100
102
  maxId = id;
@@ -169,7 +171,7 @@ class ShapeModifier {
169
171
  this.performSave(pagePath, parsed);
170
172
  }
171
173
  performSave(pagePath, parsed) {
172
- const newXml = this.builder.build(parsed);
174
+ const newXml = (0, XmlHelper_1.buildXml)(this.builder, parsed);
173
175
  this.pkg.updateFile(pagePath, newXml);
174
176
  this.pageCache.set(pagePath, { content: newXml, parsed });
175
177
  }
@@ -189,7 +191,7 @@ class ShapeModifier {
189
191
  parsed.PageContents.Shapes = { Shape: [] };
190
192
  }
191
193
  if (!Array.isArray(parsed.PageContents.Shapes.Shape)) {
192
- parsed.PageContents.Shapes.Shape = [parsed.PageContents.Shapes.Shape];
194
+ parsed.PageContents.Shapes.Shape = parsed.PageContents.Shapes.Shape ? [parsed.PageContents.Shapes.Shape] : [];
193
195
  }
194
196
  const newId = this.getNextId(parsed);
195
197
  const shapeHierarchy = ConnectorBuilder_1.ConnectorBuilder.buildShapeHierarchy(parsed);
@@ -206,6 +208,7 @@ class ShapeModifier {
206
208
  const connectorShape = ConnectorBuilder_1.ConnectorBuilder.createConnectorShapeObject(newId, layout, validateArrow(beginArrow), validateArrow(endArrow));
207
209
  const topLevelShapes = parsed.PageContents.Shapes.Shape;
208
210
  topLevelShapes.push(connectorShape);
211
+ this.getShapeMap(parsed).set(newId, connectorShape);
209
212
  ConnectorBuilder_1.ConnectorBuilder.addConnectorToConnects(parsed, newId, fromShapeId, toShapeId);
210
213
  this.saveParsed(pageId, parsed);
211
214
  return newId;
@@ -221,7 +224,6 @@ class ShapeModifier {
221
224
  topLevelShapes = topLevelShapes ? [topLevelShapes] : [];
222
225
  parsed.PageContents.Shapes.Shape = topLevelShapes;
223
226
  }
224
- const allShapes = this.getAllShapes(parsed);
225
227
  // Auto-generate ID if not provided
226
228
  let newId = props.id;
227
229
  if (!newId) {
@@ -245,7 +247,7 @@ class ShapeModifier {
245
247
  }
246
248
  if (parentId) {
247
249
  // Add to Parent Group
248
- const parent = allShapes.find((s) => s['@_ID'] == parentId);
250
+ const parent = this.getShapeMap(parsed).get(parentId);
249
251
  if (!parent) {
250
252
  throw new Error(`Parent shape ${parentId} not found`);
251
253
  }
@@ -266,83 +268,120 @@ class ShapeModifier {
266
268
  // Add to Page
267
269
  topLevelShapes.push(newShape);
268
270
  }
271
+ this.getShapeMap(parsed).set(newId, newShape);
269
272
  this.saveParsed(pageId, parsed);
270
273
  return newId;
271
274
  }
272
- async updateShapeText(pageId, shapeId, newText) {
275
+ async deleteShape(pageId, shapeId) {
273
276
  const parsed = this.getParsed(pageId);
274
- let found = false;
275
- // Helper to recursively find and update shape
276
- const findAndUpdate = (shapes) => {
277
- for (const shape of shapes) {
278
- if (shape['@_ID'] == shapeId) {
279
- shape.Text = {
280
- '#text': newText
281
- };
282
- found = true;
283
- return;
284
- }
285
- // If shapes can be nested (Groups), check for sub-shapes - future improvement
286
- // But typically basic Text is on top level or group level.
277
+ // 1. Remove shape from the shape tree (handles top-level and nested groups)
278
+ const removed = this.removeShapeFromTree(parsed.PageContents.Shapes, shapeId);
279
+ if (!removed) {
280
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
281
+ }
282
+ // 2. Remove any Connect elements referencing this shape
283
+ if (parsed.PageContents.Connects?.Connect) {
284
+ let connects = parsed.PageContents.Connects.Connect;
285
+ if (!Array.isArray(connects))
286
+ connects = [connects];
287
+ const filtered = connects.filter((c) => c['@_FromSheet'] !== shapeId && c['@_ToSheet'] !== shapeId);
288
+ parsed.PageContents.Connects.Connect = filtered;
289
+ }
290
+ // 3. Remove any Relationship elements referencing this shape (container membership, etc.)
291
+ if (parsed.PageContents.Relationships?.Relationship) {
292
+ let rels = parsed.PageContents.Relationships.Relationship;
293
+ if (!Array.isArray(rels))
294
+ rels = [rels];
295
+ parsed.PageContents.Relationships.Relationship = rels.filter((r) => r['@_ShapeID'] !== shapeId && r['@_RelatedShapeID'] !== shapeId);
296
+ }
297
+ // 4. Invalidate the shape cache so the map is rebuilt on next access
298
+ this.shapeCache.delete(parsed);
299
+ this.saveParsed(pageId, parsed);
300
+ }
301
+ removeShapeFromTree(shapesContainer, shapeId) {
302
+ if (!shapesContainer?.Shape)
303
+ return false;
304
+ let shapes = shapesContainer.Shape;
305
+ if (!Array.isArray(shapes))
306
+ shapes = [shapes];
307
+ const idx = shapes.findIndex((s) => s['@_ID'] === shapeId);
308
+ if (idx !== -1) {
309
+ shapes.splice(idx, 1);
310
+ shapesContainer.Shape = shapes;
311
+ return true;
312
+ }
313
+ // Recurse into nested group children
314
+ for (const shape of shapes) {
315
+ if (shape.Shapes && this.removeShapeFromTree(shape.Shapes, shapeId)) {
316
+ return true;
287
317
  }
288
- };
289
- const shapesData = parsed.PageContents?.Shapes?.Shape;
290
- if (shapesData) {
291
- const shapesArray = Array.isArray(shapesData) ? shapesData : [shapesData];
292
- findAndUpdate(shapesArray);
293
318
  }
294
- if (!found) {
319
+ return false;
320
+ }
321
+ async updateShapeText(pageId, shapeId, newText) {
322
+ const parsed = this.getParsed(pageId);
323
+ const shape = this.getShapeMap(parsed).get(shapeId);
324
+ if (!shape) {
295
325
  throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
296
326
  }
327
+ shape.Text = {
328
+ '#text': newText
329
+ };
297
330
  this.saveParsed(pageId, parsed);
298
331
  }
299
332
  async updateShapeStyle(pageId, shapeId, style) {
300
333
  const parsed = this.getParsed(pageId);
301
- let found = false;
302
- const findAndUpdate = (shapes) => {
303
- for (const shape of shapes) {
304
- if (shape['@_ID'] == shapeId) {
305
- found = true;
306
- // Ensure Section array exists
307
- if (!shape.Section) {
308
- shape.Section = [];
309
- }
310
- else if (!Array.isArray(shape.Section)) {
311
- shape.Section = [shape.Section];
312
- }
313
- // Update/Add Fill
314
- if (style.fillColor) {
315
- // Remove existing Fill section if any (simplified: assuming IX=0)
316
- shape.Section = shape.Section.filter((s) => s['@_N'] !== 'Fill');
317
- shape.Section.push((0, StyleHelpers_1.createFillSection)(style.fillColor));
318
- }
319
- // Update/Add Character (Font/Text Style)
320
- if (style.fontColor || style.bold !== undefined) {
321
- // Remove existing Character section if any
322
- shape.Section = shape.Section.filter((s) => s['@_N'] !== 'Character');
323
- shape.Section.push((0, StyleHelpers_1.createCharacterSection)({
324
- bold: style.bold,
325
- color: style.fontColor
326
- }));
327
- }
328
- return;
329
- }
330
- }
331
- };
332
- const shapesData = parsed.PageContents?.Shapes?.Shape;
333
- if (shapesData) {
334
- const shapesArray = Array.isArray(shapesData) ? shapesData : [shapesData];
335
- findAndUpdate(shapesArray);
336
- }
337
- if (!found) {
334
+ const shape = this.getShapeMap(parsed).get(shapeId);
335
+ if (!shape) {
338
336
  throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
339
337
  }
338
+ // Ensure Section array exists
339
+ if (!shape.Section) {
340
+ shape.Section = [];
341
+ }
342
+ else if (!Array.isArray(shape.Section)) {
343
+ shape.Section = [shape.Section];
344
+ }
345
+ // Update/Add Fill
346
+ if (style.fillColor) {
347
+ // Remove existing Fill section if any (simplified: assuming IX=0)
348
+ shape.Section = shape.Section.filter((s) => s['@_N'] !== 'Fill');
349
+ shape.Section.push((0, StyleHelpers_1.createFillSection)(style.fillColor));
350
+ }
351
+ // Update/Add Character (Font/Text Style)
352
+ if (style.fontColor || style.bold !== undefined || style.fontSize !== undefined || style.fontFamily !== undefined) {
353
+ shape.Section = shape.Section.filter((s) => s['@_N'] !== 'Character');
354
+ shape.Section.push((0, StyleHelpers_1.createCharacterSection)({
355
+ bold: style.bold,
356
+ color: style.fontColor,
357
+ fontSize: style.fontSize,
358
+ fontFamily: style.fontFamily,
359
+ }));
360
+ }
361
+ // Update/Add Paragraph (Horizontal Alignment)
362
+ if (style.horzAlign !== undefined) {
363
+ shape.Section = shape.Section.filter((s) => s['@_N'] !== 'Paragraph');
364
+ shape.Section.push((0, StyleHelpers_1.createParagraphSection)(style.horzAlign));
365
+ }
366
+ // Update/Add VerticalAlign (top-level shape Cell)
367
+ if (style.verticalAlign !== undefined) {
368
+ if (!shape.Cell)
369
+ shape.Cell = [];
370
+ if (!Array.isArray(shape.Cell))
371
+ shape.Cell = [shape.Cell];
372
+ const existingCell = shape.Cell.find((c) => c['@_N'] === 'VerticalAlign');
373
+ if (existingCell) {
374
+ existingCell['@_V'] = (0, StyleHelpers_1.vertAlignValue)(style.verticalAlign);
375
+ }
376
+ else {
377
+ shape.Cell.push({ '@_N': 'VerticalAlign', '@_V': (0, StyleHelpers_1.vertAlignValue)(style.verticalAlign) });
378
+ }
379
+ }
340
380
  this.saveParsed(pageId, parsed);
341
381
  }
342
382
  async updateShapeDimensions(pageId, shapeId, w, h) {
343
383
  const parsed = this.getParsed(pageId);
344
- const shapes = this.getAllShapes(parsed);
345
- const shape = shapes.find((s) => s['@_ID'] == shapeId);
384
+ const shape = this.getShapeMap(parsed).get(shapeId);
346
385
  if (!shape)
347
386
  throw new Error(`Shape ${shapeId} not found`);
348
387
  // Ensure Cell array
@@ -361,115 +400,175 @@ class ShapeModifier {
361
400
  updateCell('Height', h.toString());
362
401
  this.saveParsed(pageId, parsed);
363
402
  }
364
- async updateShapePosition(pageId, shapeId, x, y) {
403
+ /**
404
+ * Set the rotation angle of a shape. Degrees are converted to radians
405
+ * for storage in the Angle cell (Visio's native unit).
406
+ */
407
+ async rotateShape(pageId, shapeId, degrees) {
365
408
  const parsed = this.getParsed(pageId);
366
- let found = false;
367
- const findAndUpdate = (shapes) => {
368
- for (const shape of shapes) {
369
- if (shape['@_ID'] == shapeId) {
370
- found = true;
371
- // Ensure Cell array exists
372
- if (!shape.Cell) {
373
- shape.Cell = [];
374
- }
375
- else if (!Array.isArray(shape.Cell)) {
376
- shape.Cell = [shape.Cell];
409
+ const shape = this.getShapeMap(parsed).get(shapeId);
410
+ if (!shape)
411
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
412
+ if (!shape.Cell)
413
+ shape.Cell = [];
414
+ if (!Array.isArray(shape.Cell))
415
+ shape.Cell = [shape.Cell];
416
+ const radians = (degrees * Math.PI) / 180;
417
+ const existing = shape.Cell.find((c) => c['@_N'] === 'Angle');
418
+ if (existing)
419
+ existing['@_V'] = radians.toString();
420
+ else
421
+ shape.Cell.push({ '@_N': 'Angle', '@_V': radians.toString() });
422
+ this.saveParsed(pageId, parsed);
423
+ }
424
+ /**
425
+ * Set the flip state for a shape along the X or Y axis.
426
+ * FlipX mirrors left-to-right; FlipY mirrors top-to-bottom.
427
+ */
428
+ setShapeFlip(pageId, shapeId, axis, enabled) {
429
+ const parsed = this.getParsed(pageId);
430
+ const shape = this.getShapeMap(parsed).get(shapeId);
431
+ if (!shape)
432
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
433
+ if (!shape.Cell)
434
+ shape.Cell = [];
435
+ if (!Array.isArray(shape.Cell))
436
+ shape.Cell = [shape.Cell];
437
+ const cellName = axis === 'x' ? 'FlipX' : 'FlipY';
438
+ const value = enabled ? '1' : '0';
439
+ const existing = shape.Cell.find((c) => c['@_N'] === cellName);
440
+ if (existing)
441
+ existing['@_V'] = value;
442
+ else
443
+ shape.Cell.push({ '@_N': cellName, '@_V': value });
444
+ this.saveParsed(pageId, parsed);
445
+ }
446
+ /**
447
+ * Resize a shape, keeping it centred on its current PinX/PinY.
448
+ * Updates Width, Height, LocPinX, LocPinY, and the cached @_V on any
449
+ * Geometry cells whose @_F formula references Width or Height, so that
450
+ * non-Visio renderers see consistent values.
451
+ */
452
+ async resizeShape(pageId, shapeId, width, height) {
453
+ const parsed = this.getParsed(pageId);
454
+ const shape = this.getShapeMap(parsed).get(shapeId);
455
+ if (!shape)
456
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
457
+ if (!shape.Cell)
458
+ shape.Cell = [];
459
+ if (!Array.isArray(shape.Cell))
460
+ shape.Cell = [shape.Cell];
461
+ const upsert = (name, val) => {
462
+ const cell = shape.Cell.find((c) => c['@_N'] === name);
463
+ if (cell)
464
+ cell['@_V'] = val;
465
+ else
466
+ shape.Cell.push({ '@_N': name, '@_V': val });
467
+ };
468
+ upsert('Width', width.toString());
469
+ upsert('Height', height.toString());
470
+ upsert('LocPinX', (width / 2).toString());
471
+ upsert('LocPinY', (height / 2).toString());
472
+ // Keep cached @_V consistent for renderers that don't evaluate formulas
473
+ if (shape.Section) {
474
+ const sections = Array.isArray(shape.Section) ? shape.Section : [shape.Section];
475
+ for (const section of sections) {
476
+ if (section['@_N'] !== 'Geometry' || !section.Row)
477
+ continue;
478
+ const rows = Array.isArray(section.Row) ? section.Row : [section.Row];
479
+ for (const row of rows) {
480
+ if (!row.Cell)
481
+ continue;
482
+ const cells = Array.isArray(row.Cell) ? row.Cell : [row.Cell];
483
+ for (const cell of cells) {
484
+ if (cell['@_F'] === 'Width')
485
+ cell['@_V'] = width.toString();
486
+ if (cell['@_F'] === 'Height')
487
+ cell['@_V'] = height.toString();
377
488
  }
378
- // Helper to update specific cell
379
- const updateCell = (name, value) => {
380
- const cell = shape.Cell.find((c) => c['@_N'] === name);
381
- if (cell) {
382
- cell['@_V'] = value;
383
- }
384
- else {
385
- shape.Cell.push({ '@_N': name, '@_V': value });
386
- }
387
- };
388
- updateCell('PinX', x.toString());
389
- updateCell('PinY', y.toString());
390
- return;
391
489
  }
392
490
  }
393
- };
394
- const shapesData = parsed.PageContents?.Shapes?.Shape;
395
- if (shapesData) {
396
- const shapesArray = Array.isArray(shapesData) ? shapesData : [shapesData];
397
- findAndUpdate(shapesArray);
398
491
  }
399
- if (!found) {
492
+ this.saveParsed(pageId, parsed);
493
+ }
494
+ async updateShapePosition(pageId, shapeId, x, y) {
495
+ const parsed = this.getParsed(pageId);
496
+ const shape = this.getShapeMap(parsed).get(shapeId);
497
+ if (!shape) {
400
498
  throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
401
499
  }
500
+ // Ensure Cell array exists
501
+ if (!shape.Cell) {
502
+ shape.Cell = [];
503
+ }
504
+ else if (!Array.isArray(shape.Cell)) {
505
+ shape.Cell = [shape.Cell];
506
+ }
507
+ // Helper to update specific cell
508
+ const updateCell = (name, value) => {
509
+ const cell = shape.Cell.find((c) => c['@_N'] === name);
510
+ if (cell) {
511
+ cell['@_V'] = value;
512
+ }
513
+ else {
514
+ shape.Cell.push({ '@_N': name, '@_V': value });
515
+ }
516
+ };
517
+ updateCell('PinX', x.toString());
518
+ updateCell('PinY', y.toString());
402
519
  this.saveParsed(pageId, parsed);
403
520
  }
404
521
  addPropertyDefinition(pageId, shapeId, name, type, options = {}) {
405
522
  const parsed = this.getParsed(pageId);
406
- let found = false;
407
- const findAndUpdate = (shapes) => {
408
- for (const shape of shapes) {
409
- if (shape['@_ID'] == shapeId) {
410
- found = true;
411
- // Ensure Section array exists
412
- if (!shape.Section)
413
- shape.Section = [];
414
- if (!Array.isArray(shape.Section))
415
- shape.Section = [shape.Section];
416
- // Find or Create Property Section
417
- let propSection = shape.Section.find((s) => s['@_N'] === 'Property');
418
- if (!propSection) {
419
- propSection = { '@_N': 'Property', Row: [] };
420
- shape.Section.push(propSection);
421
- }
422
- // Ensure Row array exists
423
- if (!propSection.Row)
424
- propSection.Row = [];
425
- if (!Array.isArray(propSection.Row))
426
- propSection.Row = [propSection.Row];
427
- // Check if property already exists
428
- const existingRow = propSection.Row.find((r) => r['@_N'] === `Prop.${name}`);
429
- if (existingRow) {
430
- // Update existing Definition
431
- const updateCell = (n, v) => {
432
- let c = existingRow.Cell.find((x) => x['@_N'] === n);
433
- if (c)
434
- c['@_V'] = v;
435
- else
436
- existingRow.Cell.push({ '@_N': n, '@_V': v });
437
- };
438
- if (options.label !== undefined)
439
- updateCell('Label', options.label);
440
- updateCell('Type', type.toString());
441
- if (options.invisible !== undefined)
442
- updateCell('Invisible', options.invisible ? '1' : '0');
443
- }
444
- else {
445
- // Create New Row
446
- propSection.Row.push({
447
- '@_N': `Prop.${name}`,
448
- Cell: [
449
- { '@_N': 'Label', '@_V': options.label || name }, // Default label to name
450
- { '@_N': 'Type', '@_V': type.toString() },
451
- { '@_N': 'Invisible', '@_V': options.invisible ? '1' : '0' },
452
- { '@_N': 'Value', '@_V': '0' } // Initialize with default
453
- ]
454
- });
455
- }
456
- return;
457
- }
458
- // Recurse into nested shapes (groups/containers)
459
- if (shape.Shapes?.Shape) {
460
- const children = Array.isArray(shape.Shapes.Shape) ? shape.Shapes.Shape : [shape.Shapes.Shape];
461
- findAndUpdate(children);
462
- }
463
- }
464
- };
465
- const shapesData = parsed.PageContents?.Shapes?.Shape;
466
- if (shapesData) {
467
- const shapesArray = Array.isArray(shapesData) ? shapesData : [shapesData];
468
- findAndUpdate(shapesArray);
469
- }
470
- if (!found) {
523
+ const shape = this.getShapeMap(parsed).get(shapeId);
524
+ if (!shape) {
471
525
  throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
472
526
  }
527
+ // Ensure Section array exists
528
+ if (!shape.Section)
529
+ shape.Section = [];
530
+ if (!Array.isArray(shape.Section))
531
+ shape.Section = [shape.Section];
532
+ // Find or Create Property Section
533
+ let propSection = shape.Section.find((s) => s['@_N'] === 'Property');
534
+ if (!propSection) {
535
+ propSection = { '@_N': 'Property', Row: [] };
536
+ shape.Section.push(propSection);
537
+ }
538
+ // Ensure Row array exists
539
+ if (!propSection.Row)
540
+ propSection.Row = [];
541
+ if (!Array.isArray(propSection.Row))
542
+ propSection.Row = [propSection.Row];
543
+ // Check if property already exists
544
+ const existingRow = propSection.Row.find((r) => r['@_N'] === `Prop.${name}`);
545
+ if (existingRow) {
546
+ // Update existing Definition
547
+ const updateCell = (n, v) => {
548
+ let c = existingRow.Cell.find((x) => x['@_N'] === n);
549
+ if (c)
550
+ c['@_V'] = v;
551
+ else
552
+ existingRow.Cell.push({ '@_N': n, '@_V': v });
553
+ };
554
+ if (options.label !== undefined)
555
+ updateCell('Label', options.label);
556
+ updateCell('Type', type.toString());
557
+ if (options.invisible !== undefined)
558
+ updateCell('Invisible', options.invisible ? '1' : '0');
559
+ }
560
+ else {
561
+ // Create New Row
562
+ propSection.Row.push({
563
+ '@_N': `Prop.${name}`,
564
+ Cell: [
565
+ { '@_N': 'Label', '@_V': options.label || name }, // Default label to name
566
+ { '@_N': 'Type', '@_V': type.toString() },
567
+ { '@_N': 'Invisible', '@_V': options.invisible ? '1' : '0' },
568
+ { '@_N': 'Value', '@_V': '0' } // Initialize with default
569
+ ]
570
+ });
571
+ }
473
572
  this.saveParsed(pageId, parsed);
474
573
  }
475
574
  dateToVisioString(date) {
@@ -479,69 +578,50 @@ class ShapeModifier {
479
578
  }
480
579
  setPropertyValue(pageId, shapeId, name, value) {
481
580
  const parsed = this.getParsed(pageId);
482
- let found = false;
483
- const findAndUpdate = (shapes) => {
484
- for (const shape of shapes) {
485
- if (shape['@_ID'] == shapeId) {
486
- found = true;
487
- // Ensure Section array exists
488
- const sections = shape.Section ? (Array.isArray(shape.Section) ? shape.Section : [shape.Section]) : [];
489
- const propSection = sections.find((s) => s['@_N'] === 'Property');
490
- if (!propSection) {
491
- throw new Error(`Property definition 'Prop.${name}' does not exist on shape ${shapeId}. Call addPropertyDefinition first.`);
492
- }
493
- const rows = propSection.Row ? (Array.isArray(propSection.Row) ? propSection.Row : [propSection.Row]) : [];
494
- const row = rows.find((r) => r['@_N'] === `Prop.${name}`);
495
- if (!row) {
496
- throw new Error(`Property definition 'Prop.${name}' does not exist on shape ${shapeId}. Call addPropertyDefinition first.`);
497
- }
498
- // Determine Visio Value String
499
- let visioValue = '';
500
- if (value instanceof Date) {
501
- visioValue = this.dateToVisioString(value);
502
- }
503
- else if (typeof value === 'boolean') {
504
- visioValue = value ? '1' : '0'; // Should boolean be V='TRUE' or 1? Standard practice is often 1/0 or TRUE/FALSE. Cells are formulaic.
505
- // However, if the Type is 3 (Boolean), Visio often expects 0/1 or TRUE/FALSE.
506
- // Let's stick to '1'/'0' for safety in formulas if generic.
507
- }
508
- else {
509
- visioValue = value.toString();
510
- }
511
- // Update or Add Value Cell
512
- // Note: If Type is String (0), V="String". If Number (2), V="123".
513
- // Visio often puts string values in formulae as "String", but in XML V attribute it's raw text?
514
- // Actually, for String props, V usually contains the string.
515
- let valCell = row.Cell.find((c) => c['@_N'] === 'Value');
516
- if (valCell) {
517
- valCell['@_V'] = visioValue;
518
- }
519
- else {
520
- row.Cell.push({ '@_N': 'Value', '@_V': visioValue });
521
- }
522
- return;
523
- }
524
- // Recurse into nested shapes (groups/containers)
525
- if (shape.Shapes?.Shape) {
526
- const children = Array.isArray(shape.Shapes.Shape) ? shape.Shapes.Shape : [shape.Shapes.Shape];
527
- findAndUpdate(children);
528
- }
529
- }
530
- };
531
- const shapesData = parsed.PageContents?.Shapes?.Shape;
532
- if (shapesData) {
533
- const shapesArray = Array.isArray(shapesData) ? shapesData : [shapesData];
534
- findAndUpdate(shapesArray);
535
- }
536
- if (!found) {
581
+ const shape = this.getShapeMap(parsed).get(shapeId);
582
+ if (!shape) {
537
583
  throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
538
584
  }
585
+ // Ensure Section array exists
586
+ const sections = shape.Section ? (Array.isArray(shape.Section) ? shape.Section : [shape.Section]) : [];
587
+ const propSection = sections.find((s) => s['@_N'] === 'Property');
588
+ if (!propSection) {
589
+ throw new Error(`Property definition 'Prop.${name}' does not exist on shape ${shapeId}. Call addPropertyDefinition first.`);
590
+ }
591
+ const rows = propSection.Row ? (Array.isArray(propSection.Row) ? propSection.Row : [propSection.Row]) : [];
592
+ const row = rows.find((r) => r['@_N'] === `Prop.${name}`);
593
+ if (!row) {
594
+ throw new Error(`Property definition 'Prop.${name}' does not exist on shape ${shapeId}. Call addPropertyDefinition first.`);
595
+ }
596
+ // Determine Visio Value String
597
+ let visioValue = '';
598
+ if (value instanceof Date) {
599
+ visioValue = this.dateToVisioString(value);
600
+ }
601
+ else if (typeof value === 'boolean') {
602
+ visioValue = value ? '1' : '0'; // Should boolean be V='TRUE' or 1? Standard practice is often 1/0 or TRUE/FALSE. Cells are formulaic.
603
+ // However, if the Type is 3 (Boolean), Visio often expects 0/1 or TRUE/FALSE.
604
+ // Let's stick to '1'/'0' for safety in formulas if generic.
605
+ }
606
+ else {
607
+ visioValue = value.toString();
608
+ }
609
+ // Update or Add Value Cell
610
+ // Note: If Type is String (0), V="String". If Number (2), V="123".
611
+ // Visio often puts string values in formulae as "String", but in XML V attribute it's raw text?
612
+ // Actually, for String props, V usually contains the string.
613
+ let valCell = row.Cell.find((c) => c['@_N'] === 'Value');
614
+ if (valCell) {
615
+ valCell['@_V'] = visioValue;
616
+ }
617
+ else {
618
+ row.Cell.push({ '@_N': 'Value', '@_V': visioValue });
619
+ }
539
620
  this.saveParsed(pageId, parsed);
540
621
  }
541
622
  getShapeGeometry(pageId, shapeId) {
542
623
  const parsed = this.getParsed(pageId);
543
- const shapes = this.getAllShapes(parsed);
544
- const shape = shapes.find((s) => s['@_ID'] == shapeId);
624
+ const shape = this.getShapeMap(parsed).get(shapeId);
545
625
  if (!shape)
546
626
  throw new Error(`Shape ${shapeId} not found`);
547
627
  const getCellVal = (name) => {
@@ -622,8 +702,7 @@ class ShapeModifier {
622
702
  async addListItem(pageId, listId, itemId) {
623
703
  // 1. Get List Properties (Direction, Spacing)
624
704
  const parsed = this.getParsed(pageId);
625
- const shapes = this.getAllShapes(parsed);
626
- const listShape = shapes.find((s) => s['@_ID'] == listId);
705
+ const listShape = this.getShapeMap(parsed).get(listId);
627
706
  if (!listShape)
628
707
  throw new Error(`List ${listId} not found`);
629
708
  const getUserVal = (name, def) => {
@@ -718,8 +797,7 @@ class ShapeModifier {
718
797
  }
719
798
  async addHyperlink(pageId, shapeId, details) {
720
799
  const parsed = this.getParsed(pageId);
721
- const shapes = this.getAllShapes(parsed);
722
- const shape = shapes.find((s) => s['@_ID'] == shapeId);
800
+ const shape = this.getShapeMap(parsed).get(shapeId);
723
801
  if (!shape)
724
802
  throw new Error(`Shape ${shapeId} not found`);
725
803
  // Ensure Section array
@@ -806,8 +884,7 @@ class ShapeModifier {
806
884
  }
807
885
  async assignLayer(pageId, shapeId, layerIndex) {
808
886
  const parsed = this.getParsed(pageId);
809
- const shapes = this.getAllShapes(parsed);
810
- const shape = shapes.find((s) => s['@_ID'] == shapeId);
887
+ const shape = this.getShapeMap(parsed).get(shapeId);
811
888
  if (!shape)
812
889
  throw new Error(`Shape ${shapeId} not found`);
813
890
  // Ensure Section array
@@ -885,5 +962,112 @@ class ShapeModifier {
885
962
  }
886
963
  this.saveParsed(pageId, parsed);
887
964
  }
965
+ /**
966
+ * Read back all custom property (shape data) entries for a shape.
967
+ * Returns a map of property key → ShapeData, with values coerced to
968
+ * the declared type (Number, Boolean, Date, or String).
969
+ */
970
+ getShapeProperties(pageId, shapeId) {
971
+ const parsed = this.getParsed(pageId);
972
+ const shape = this.getShapeMap(parsed).get(shapeId);
973
+ if (!shape)
974
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
975
+ const result = {};
976
+ if (!shape.Section)
977
+ return result;
978
+ const sections = Array.isArray(shape.Section) ? shape.Section : [shape.Section];
979
+ const propSection = sections.find((s) => s['@_N'] === 'Property');
980
+ if (!propSection?.Row)
981
+ return result;
982
+ const rows = Array.isArray(propSection.Row) ? propSection.Row : [propSection.Row];
983
+ for (const row of rows) {
984
+ // Row names are "Prop.KeyName" — strip the prefix to recover the user-facing key
985
+ const rawKey = row['@_N'] ?? '';
986
+ const key = rawKey.startsWith('Prop.') ? rawKey.slice(5) : rawKey;
987
+ if (!key)
988
+ continue;
989
+ const cells = Array.isArray(row.Cell) ? row.Cell : (row.Cell ? [row.Cell] : []);
990
+ const getCell = (name) => cells.find((c) => c['@_N'] === name)?.['@_V'];
991
+ const rawValue = getCell('Value') ?? '';
992
+ const type = parseInt(getCell('Type') ?? '0');
993
+ const label = getCell('Label');
994
+ const hidden = getCell('Invisible') === '1';
995
+ let value;
996
+ switch (type) {
997
+ case VisioTypes_1.VisioPropType.Number:
998
+ case VisioTypes_1.VisioPropType.Currency:
999
+ case VisioTypes_1.VisioPropType.Duration:
1000
+ value = parseFloat(rawValue) || 0;
1001
+ break;
1002
+ case VisioTypes_1.VisioPropType.Boolean:
1003
+ value = rawValue === '1' || rawValue.toLowerCase() === 'true';
1004
+ break;
1005
+ case VisioTypes_1.VisioPropType.Date:
1006
+ value = new Date(rawValue);
1007
+ break;
1008
+ default:
1009
+ value = rawValue;
1010
+ }
1011
+ result[key] = { value, label, hidden, type };
1012
+ }
1013
+ return result;
1014
+ }
1015
+ /**
1016
+ * Read back all hyperlinks attached to a shape.
1017
+ */
1018
+ getShapeHyperlinks(pageId, shapeId) {
1019
+ const parsed = this.getParsed(pageId);
1020
+ const shape = this.getShapeMap(parsed).get(shapeId);
1021
+ if (!shape)
1022
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
1023
+ const result = [];
1024
+ if (!shape.Section)
1025
+ return result;
1026
+ const sections = Array.isArray(shape.Section) ? shape.Section : [shape.Section];
1027
+ const linkSection = sections.find((s) => s['@_N'] === 'Hyperlink');
1028
+ if (!linkSection?.Row)
1029
+ return result;
1030
+ const rows = Array.isArray(linkSection.Row) ? linkSection.Row : [linkSection.Row];
1031
+ for (const row of rows) {
1032
+ const cells = Array.isArray(row.Cell) ? row.Cell : (row.Cell ? [row.Cell] : []);
1033
+ const getCell = (name) => cells.find((c) => c['@_N'] === name)?.['@_V'];
1034
+ result.push({
1035
+ address: getCell('Address'),
1036
+ subAddress: getCell('SubAddress'),
1037
+ description: getCell('Description'),
1038
+ newWindow: getCell('NewWindow') === '1',
1039
+ });
1040
+ }
1041
+ return result;
1042
+ }
1043
+ /**
1044
+ * Read back the layer indices a shape is assigned to.
1045
+ * Returns an empty array if the shape has no layer assignment.
1046
+ */
1047
+ getShapeLayerIndices(pageId, shapeId) {
1048
+ const parsed = this.getParsed(pageId);
1049
+ const shape = this.getShapeMap(parsed).get(shapeId);
1050
+ if (!shape)
1051
+ throw new Error(`Shape ${shapeId} not found on page ${pageId}`);
1052
+ if (!shape.Section)
1053
+ return [];
1054
+ const sections = Array.isArray(shape.Section) ? shape.Section : [shape.Section];
1055
+ const memSection = sections.find((s) => s['@_N'] === 'LayerMem');
1056
+ if (!memSection?.Row)
1057
+ return [];
1058
+ const rows = Array.isArray(memSection.Row) ? memSection.Row : [memSection.Row];
1059
+ const row = rows[0];
1060
+ if (!row)
1061
+ return [];
1062
+ const cells = Array.isArray(row.Cell) ? row.Cell : (row.Cell ? [row.Cell] : []);
1063
+ const memberCell = cells.find((c) => c['@_N'] === 'LayerMember');
1064
+ if (!memberCell?.['@_V'])
1065
+ return [];
1066
+ return memberCell['@_V']
1067
+ .split(';')
1068
+ .filter((s) => s.length > 0)
1069
+ .map((s) => parseInt(s))
1070
+ .filter((n) => !isNaN(n));
1071
+ }
888
1072
  }
889
1073
  exports.ShapeModifier = ShapeModifier;