node-pptx-templater 1.1.0 → 1.1.2

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 CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.3] - 2026-06-22
9
+
10
+ ### Fixed
11
+
12
+ - **Layout Preservation in `addCellShape()`**: Fixed a critical layout bug where querying cell bounds or adding cell shapes dynamically (via `addCellShape()` or `updateCellShape()`) mutated table row heights inside the XML. The sizing/positioning logic now runs in a layout-only mode, keeping the table XML completely untouched and respecting original template custom row heights.
13
+
14
+ ## [1.1.2] - 2026-06-22
15
+
16
+ ### Fixed
17
+
18
+ - **Slide Link Relationships Target Paths**: Fixed PowerPoint "Repair Mode" errors when adding slide-to-slide hyperlinks (via `addSlideLink` or `addImageLink`). The relative target path resolves correctly to the sibling filename `slideX.xml` instead of using the redundant parent prefix `../slides/slideX.xml` (which exited and re-entered the same directory, violating PowerPoint's relative path resolution rules).
19
+
20
+ ### Added
21
+
22
+ - **Redundant Traversal Validation**: Added automated check in `ValidationEngine` to identify and error on redundant relative path traversals inside relationship files (e.g. referencing `../slides/slideX.xml` from a source slide file already located in `ppt/slides/`).
23
+
8
24
  ## [1.1.0] - 2026-06-12
9
25
 
10
26
  ### Added
package/README.md CHANGED
@@ -172,27 +172,51 @@ const meta = await ppt.getTableRows('SalesTable', { includeMetadata: true });
172
172
  ```
173
173
 
174
174
  ### Table Cell Shapes
175
- Cell shapes are overlay graphics anchored within table cells. They are positioned absolutely based on cell bounds, and **never** modify row heights, column widths, cell margins, or trigger table reflow. Offsets (`x`, `y`) are relative to the cell's top-left corner. Oversized shapes are scaled down proportionally to fit inside the cell.
175
+ Cell shapes are overlay graphics anchored within table cells. They are positioned absolutely based on cell bounds, and **never** modify row heights, column widths, cell margins, or trigger table reflow.
176
+
177
+ #### Merged Cell Support
178
+ When targeting a merged cell (spanned using `rowSpan` or `colSpan`), `addCellShape()` dynamically resolves the **actual rendered bounds** of the entire merged cell region (summing the width and height of the spanned rows/columns). The shape is positioned and aligned relative to this final merged boundary. Any offset `x` and `y` specified is interpreted relative to the top-left corner of the resolved merged cell region.
179
+
180
+ #### Alignment & Presets
181
+ Shapes can be aligned dynamically within the cell (or merged region) using preset positions or explicit alignments:
182
+ * **Presets**: Use `position` values like `'top-left'`, `'center'`, `'bottom-right'`, etc.
183
+ * **Alignment Options**: Set `alignX: 'left' | 'center' | 'right'` and `alignY: 'top' | 'middle' | 'bottom'`. If no alignments or offsets are provided, shapes default to true centering (`'center'`/`'middle'`).
184
+ * **Offsets & Padding**: Offsets (`x`, `y`) are applied relative to the resolved alignment. For instance, when `alignX: 'right'` is used, the shape is placed at `cellRight - shapeWidth - x` (defaulting to a `5px` padding if `x` is omitted).
176
185
 
177
186
  ```javascript
178
- // Add a simple indicator
179
- await ppt.addCellShape('SalesTable', 2, 1, {
187
+ // Add a status icon centered in a merged cell (rowSpan=2, colSpan=2)
188
+ await ppt.addCellShape('StatusTable', 1, 1, {
180
189
  type: 'circle',
181
190
  width: 12,
182
191
  height: 12,
183
- fill: '#10B981' // Green status dot
192
+ fill: '#10B981', // Green status dot
193
+ alignX: 'center',
194
+ alignY: 'middle' // Visually centered in the merged cell
184
195
  });
185
196
 
186
- // Add a badge with text and custom offsets
187
- await ppt.addCellShape('SalesTable', 1, 2, {
197
+ // Add a badge with text and explicit offsets inside a merged cell
198
+ await ppt.addCellShape('StatusTable', 1, 1, {
188
199
  type: 'badge',
189
- text: 'Active',
190
- fill: '#3B82F6',
191
- x: 4,
192
- y: 2,
200
+ text: 'Urgent',
201
+ fill: '#EF4444',
202
+ alignX: 'left',
203
+ alignY: 'top',
204
+ x: 5,
205
+ y: 3,
193
206
  width: 50,
194
207
  height: 16
195
208
  });
209
+
210
+ // Add an indicator with bottom-right alignment and custom padding
211
+ await ppt.addCellShape('StatusTable', 1, 1, {
212
+ type: 'icon',
213
+ icon: 'up',
214
+ size: 14,
215
+ alignX: 'right',
216
+ alignY: 'bottom',
217
+ x: 4,
218
+ y: 2
219
+ });
196
220
  ```
197
221
 
198
222
  ### XML Folder Workflow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "High-performance, low-level PowerPoint (PPTX) OpenXML template engine for Node.js. Dynamically replace text, insert images, update charts (with Excel workbook data caching), and merge table cells without PowerPoint corruption or Repair Mode prompts.",
5
5
  "main": "./src/index.js",
6
6
  "type": "commonjs",
@@ -191,4 +191,4 @@
191
191
  "LICENSE",
192
192
  "CHANGELOG.md"
193
193
  ]
194
- }
194
+ }
@@ -2556,7 +2556,11 @@ class PPTXTemplater {
2556
2556
  this.#assertLoaded()
2557
2557
  const targetIndices = this.#getTargetSlideIndices()
2558
2558
  for (const idx of targetIndices) {
2559
- this.#shapeManager.updateShapePosition(idx, shapeId, options, this.#slideManager)
2559
+ let resolvedOptions = options
2560
+ if (options.alignToCell) {
2561
+ resolvedOptions = this.#resolveAlignToCell(idx, options, shapeId, true)
2562
+ }
2563
+ this.#shapeManager.updateShapePosition(idx, shapeId, resolvedOptions, this.#slideManager)
2560
2564
  }
2561
2565
  return this
2562
2566
  }
@@ -2650,17 +2654,22 @@ class PPTXTemplater {
2650
2654
  return this.#shapeManager.validateShape(options)
2651
2655
  }
2652
2656
 
2653
- /**
2654
- * Adds a new shape dynamically to the targeted slide(s).
2655
- *
2656
- * @param {Object} options Shape configuration options.
2657
- * @returns {this} The chainable presentation templater instance.
2658
- */
2659
- async addShape(options) {
2657
+ async addShape(typeOrOptions, options = {}) {
2660
2658
  this.#assertLoaded()
2659
+ let resolvedOptions = {}
2660
+ if (typeof typeOrOptions === 'string') {
2661
+ resolvedOptions = { ...options, type: typeOrOptions }
2662
+ } else {
2663
+ resolvedOptions = { ...typeOrOptions }
2664
+ }
2665
+
2661
2666
  const targetIndices = this.#getTargetSlideIndices()
2662
2667
  for (const idx of targetIndices) {
2663
- this.#shapeManager.addShape(idx, options, this.#slideManager)
2668
+ let finalOptions = resolvedOptions
2669
+ if (resolvedOptions.alignToCell) {
2670
+ finalOptions = this.#resolveAlignToCell(idx, resolvedOptions)
2671
+ }
2672
+ this.#shapeManager.addShape(idx, finalOptions, this.#slideManager)
2664
2673
  }
2665
2674
  return this
2666
2675
  }
@@ -2676,7 +2685,11 @@ class PPTXTemplater {
2676
2685
  this.#assertLoaded()
2677
2686
  const targetIndices = this.#getTargetSlideIndices()
2678
2687
  for (const idx of targetIndices) {
2679
- this.#shapeManager.updateShape(idx, shapeId, options, this.#slideManager)
2688
+ let resolvedOptions = options
2689
+ if (options.alignToCell) {
2690
+ resolvedOptions = this.#resolveAlignToCell(idx, options, shapeId)
2691
+ }
2692
+ this.#shapeManager.updateShape(idx, shapeId, resolvedOptions, this.#slideManager)
2680
2693
  }
2681
2694
  return this
2682
2695
  }
@@ -2714,6 +2727,8 @@ class PPTXTemplater {
2714
2727
 
2715
2728
  /**
2716
2729
  * Dynamically adds a shape inside a table cell based on cell coordinates.
2730
+ * Cell shapes are overlay graphics anchored independently of the table layout,
2731
+ * and adding a cell shape never modifies row heights, column widths, or table dimensions.
2717
2732
  *
2718
2733
  * @param {string} tableId - Table name or shape ID.
2719
2734
  * @param {number} rowIndex - 0-based row index.
@@ -2822,13 +2837,17 @@ class PPTXTemplater {
2822
2837
  /**
2823
2838
  * Retrieves final rendered bounds of a table cell in pixels.
2824
2839
  *
2825
- * @param {string} tableId - Table name or shape ID.
2840
+ * @param {string|Object} tableIdOrObj - Table name, shape ID, or table object.
2826
2841
  * @param {number} rowIndex - 0-based row index.
2827
2842
  * @param {number} colIndex - 0-based column index.
2828
2843
  * @returns {Object|null} Cell bounds { x, y, width, height } in pixels, or null.
2829
2844
  */
2830
- getCellBounds(tableId, rowIndex, colIndex) {
2845
+ getCellBounds(tableIdOrObj, rowIndex, colIndex) {
2831
2846
  this.#assertLoaded()
2847
+ const tableId =
2848
+ typeof tableIdOrObj === 'object'
2849
+ ? tableIdOrObj.id || tableIdOrObj.name || tableIdOrObj.tableId
2850
+ : tableIdOrObj
2832
2851
  const targetIndices = this.#getTargetSlideIndices()
2833
2852
  for (const idx of targetIndices) {
2834
2853
  try {
@@ -2851,14 +2870,21 @@ class PPTXTemplater {
2851
2870
 
2852
2871
  /**
2853
2872
  * Retrieves final rendered position of a table cell in pixels.
2873
+ * Optionally calculates centered top-left coordinates for a shape of given dimensions.
2854
2874
  *
2855
- * @param {string} tableId - Table name or shape ID.
2875
+ * @param {string|Object} tableIdOrObj - Table name, shape ID, or table object.
2856
2876
  * @param {number} rowIndex - 0-based row index.
2857
2877
  * @param {number} colIndex - 0-based column index.
2858
- * @returns {Object|null} Cell position { row, column, x, y } in pixels, or null.
2878
+ * @param {number|Object} [shapeWidthOrOptions] - Width of the shape in pixels, or options object.
2879
+ * @param {number} [shapeHeight] - Height of the shape in pixels.
2880
+ * @returns {Object|null} Cell position { row, column, x, y, width, height } in pixels, or null.
2859
2881
  */
2860
- getCellPosition(tableId, rowIndex, colIndex) {
2882
+ getCellPosition(tableIdOrObj, rowIndex, colIndex, shapeWidthOrOptions, shapeHeight) {
2861
2883
  this.#assertLoaded()
2884
+ const tableId =
2885
+ typeof tableIdOrObj === 'object'
2886
+ ? tableIdOrObj.id || tableIdOrObj.name || tableIdOrObj.tableId
2887
+ : tableIdOrObj
2862
2888
  const targetIndices = this.#getTargetSlideIndices()
2863
2889
  for (const idx of targetIndices) {
2864
2890
  try {
@@ -2867,7 +2893,9 @@ class PPTXTemplater {
2867
2893
  tableId,
2868
2894
  rowIndex,
2869
2895
  colIndex,
2870
- this.#slideManager
2896
+ this.#slideManager,
2897
+ shapeWidthOrOptions,
2898
+ shapeHeight
2871
2899
  )
2872
2900
  if (pos) return pos
2873
2901
  } catch (err) {
@@ -3548,6 +3576,126 @@ class PPTXTemplater {
3548
3576
  this.#zOrderManager.normalizeZOrder(targetIdx, this.#slideManager)
3549
3577
  return this
3550
3578
  }
3579
+
3580
+ /**
3581
+ * Aligns an existing shape to a table cell's position.
3582
+ *
3583
+ * @param {string} shapeId - Unique shape name/id in the template.
3584
+ * @param {string|Object} tableIdOrObj - Table ID string, or table object.
3585
+ * @param {number} rowIndex - 0-based row index.
3586
+ * @param {number} colIndex - 0-based column index.
3587
+ * @param {Object} [options] - Alignment options.
3588
+ * @param {'left'|'center'|'right'} [options.horizontal='center'] - Horizontal alignment.
3589
+ * @param {'top'|'middle'|'bottom'} [options.vertical='middle'] - Vertical alignment.
3590
+ * @returns {this} The chainable presentation templater instance.
3591
+ */
3592
+ alignShapeToCell(shapeId, tableIdOrObj, rowIndex, colIndex, options = {}) {
3593
+ const tableId =
3594
+ typeof tableIdOrObj === 'object'
3595
+ ? tableIdOrObj.id || tableIdOrObj.name || tableIdOrObj.tableId
3596
+ : tableIdOrObj
3597
+ this.updateShapePosition(shapeId, {
3598
+ alignToCell: {
3599
+ table: tableId,
3600
+ row: rowIndex,
3601
+ col: colIndex,
3602
+ horizontal: options.horizontal || 'center',
3603
+ vertical: options.vertical || 'middle',
3604
+ },
3605
+ })
3606
+ return this
3607
+ }
3608
+
3609
+ #resolveAlignToCell(slideIndex, options, shapeId, convertToEmus = false) {
3610
+ const align = options.alignToCell
3611
+ if (!align || !align.table) return options
3612
+
3613
+ const tableId =
3614
+ typeof align.table === 'object'
3615
+ ? align.table.id || align.table.name || align.table.tableId
3616
+ : align.table
3617
+ const row = align.row !== undefined ? align.row : 0
3618
+ const col = align.col !== undefined ? align.col : 0
3619
+
3620
+ // Get cell bounds
3621
+ const bounds = this.#tableManager.getCellBounds(
3622
+ slideIndex,
3623
+ tableId,
3624
+ row,
3625
+ col,
3626
+ this.#slideManager
3627
+ )
3628
+
3629
+ if (!bounds) return options
3630
+
3631
+ // Determine shape dimensions
3632
+ let shapeWidth = options.width
3633
+ let shapeHeight = options.height
3634
+
3635
+ if (convertToEmus) {
3636
+ if (shapeWidth !== undefined) shapeWidth = Math.round(shapeWidth / 9525)
3637
+ if (shapeHeight !== undefined) shapeHeight = Math.round(shapeHeight / 9525)
3638
+ }
3639
+
3640
+ if (shapeWidth === undefined || shapeHeight === undefined) {
3641
+ if (options.type === 'square' && options.size !== undefined) {
3642
+ shapeWidth = options.size
3643
+ shapeHeight = options.size
3644
+ } else if (options.type === 'circle' && options.radius !== undefined) {
3645
+ shapeWidth = options.radius * 2
3646
+ shapeHeight = options.radius * 2
3647
+ } else if (shapeId) {
3648
+ // Try getting existing shape dimensions
3649
+ const existing = this.#shapeManager.getShape(slideIndex, shapeId, this.#slideManager)
3650
+ if (existing) {
3651
+ shapeWidth = existing.width
3652
+ shapeHeight = existing.height
3653
+ }
3654
+ }
3655
+ }
3656
+
3657
+ // Default to fallback dimensions if still undefined
3658
+ if (shapeWidth === undefined) shapeWidth = 100
3659
+ if (shapeHeight === undefined) shapeHeight = 100
3660
+
3661
+ // Align horizontally
3662
+ let horiz = align.horizontal || align.alignX || 'center'
3663
+ horiz = String(horiz).toLowerCase()
3664
+ if (horiz === 'middle') horiz = 'center'
3665
+
3666
+ let x = bounds.x
3667
+ if (horiz === 'center') {
3668
+ x = bounds.x + (bounds.width - shapeWidth) / 2
3669
+ } else if (horiz === 'right') {
3670
+ x = bounds.x + bounds.width - shapeWidth
3671
+ }
3672
+
3673
+ // Align vertically
3674
+ let vert = align.vertical || align.alignY || 'middle'
3675
+ vert = String(vert).toLowerCase()
3676
+ if (vert === 'center') vert = 'middle'
3677
+
3678
+ let y = bounds.y
3679
+ if (vert === 'middle') {
3680
+ y = bounds.y + (bounds.height - shapeHeight) / 2
3681
+ } else if (vert === 'bottom') {
3682
+ y = bounds.y + bounds.height - shapeHeight
3683
+ }
3684
+
3685
+ const resolved = { ...options }
3686
+ if (convertToEmus) {
3687
+ resolved.x = Math.round(x * 9525)
3688
+ resolved.y = Math.round(y * 9525)
3689
+ } else {
3690
+ resolved.x = Math.round(x)
3691
+ resolved.y = Math.round(y)
3692
+ }
3693
+
3694
+ // Remove alignToCell to prevent it polluting lower levels
3695
+ delete resolved.alignToCell
3696
+
3697
+ return resolved
3698
+ }
3551
3699
  }
3552
3700
 
3553
3701
  module.exports = { PPTXTemplater }
@@ -237,6 +237,18 @@ class ValidationEngine {
237
237
  if (!ppt.zipManager.hasFile(resolved)) {
238
238
  errors.push(`Relationship ${rel.id} points to non-existent file: ${resolved}`)
239
239
  }
240
+
241
+ // Catch redundant relative path traversals (e.g., '../slides/slide1.xml' from 'ppt/slides/slide2.xml')
242
+ const sourceDir = partPath.split('/').slice(0, -1).join('/')
243
+ const resolvedDir = resolved.split('/').slice(0, -1).join('/')
244
+ if (sourceDir === resolvedDir) {
245
+ const expectedTarget = resolved.split('/').pop()
246
+ if (rel.target !== expectedTarget) {
247
+ errors.push(
248
+ `Relationship "${rel.id}" in ${relsPath} uses redundant relative target "${rel.target}" (expected "${expectedTarget}")`
249
+ )
250
+ }
251
+ }
240
252
  }
241
253
  }
242
254
 
@@ -100,7 +100,7 @@ class HyperlinkManager {
100
100
  const targetInfo = slideManager.getSlideInfo(targetSlideIndex)
101
101
 
102
102
  // Build relative target path from source to target slide
103
- const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`
103
+ const relativePath = targetInfo.zipPath.split('/').pop()
104
104
 
105
105
  // Add relationship pointing to the target slide
106
106
  const rId = relationshipManager.addRelationship(
@@ -130,7 +130,7 @@ class HyperlinkManager {
130
130
  const sourceInfo = slideManager.getSlideInfo(sourceSlideIndex)
131
131
  const targetInfo = slideManager.getSlideInfo(targetSlideIndex)
132
132
 
133
- const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`
133
+ const relativePath = targetInfo.zipPath.split('/').pop()
134
134
  const rId = relationshipManager.addRelationship(
135
135
  sourceInfo.zipPath,
136
136
  REL_TYPES.SLIDE,
@@ -167,7 +167,7 @@ class HyperlinkManager {
167
167
  const sourceInfo = slideManager.getSlideInfo(sourceSlideIndex)
168
168
  const targetInfo = slideManager.getSlideInfo(targetSlideIndex)
169
169
 
170
- const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`
170
+ const relativePath = targetInfo.zipPath.split('/').pop()
171
171
  const rId = relationshipManager.addRelationship(
172
172
  sourceInfo.zipPath,
173
173
  REL_TYPES.SLIDE,
@@ -223,7 +223,7 @@ class TableManager {
223
223
  )
224
224
  }
225
225
 
226
- this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj)
226
+ this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, true)
227
227
 
228
228
  if (cellShapes) {
229
229
  this.#processCellShapes(
@@ -805,7 +805,7 @@ class TableManager {
805
805
  const colWidths = gridColsArr.map(col => parseInt(col['@_w'] || 0, 10))
806
806
 
807
807
  const trsArr = tblObj['a:tr'] || []
808
- const rowHeights = trsArr.map(row => parseInt(row['@_h'] || 0, 10))
808
+ const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, false)
809
809
 
810
810
  const parent = this.getMergeParent(slideIndex, tableId, rowIndex, colIndex, slideManager)
811
811
  const pr = parent.row
@@ -843,13 +843,50 @@ class TableManager {
843
843
  }
844
844
  }
845
845
 
846
- getCellPosition(slideIndex, tableId, rowIndex, colIndex, slideManager) {
846
+ getCellPosition(
847
+ slideIndex,
848
+ tableId,
849
+ rowIndex,
850
+ colIndex,
851
+ slideManager,
852
+ shapeWidthOrOptions,
853
+ shapeHeight
854
+ ) {
847
855
  const bounds = this.getCellBounds(slideIndex, tableId, rowIndex, colIndex, slideManager)
856
+ if (!bounds) return null
857
+
858
+ let shapeWidth
859
+ let shapeHeightVal
860
+
861
+ if (shapeWidthOrOptions && typeof shapeWidthOrOptions === 'object') {
862
+ shapeWidth =
863
+ shapeWidthOrOptions.width !== undefined
864
+ ? shapeWidthOrOptions.width
865
+ : shapeWidthOrOptions.shapeWidth
866
+ shapeHeightVal =
867
+ shapeWidthOrOptions.height !== undefined
868
+ ? shapeWidthOrOptions.height
869
+ : shapeWidthOrOptions.shapeHeight
870
+ } else {
871
+ shapeWidth = shapeWidthOrOptions
872
+ shapeHeightVal = shapeHeight
873
+ }
874
+
875
+ let x = bounds.x
876
+ let y = bounds.y
877
+
878
+ if (shapeWidth !== undefined && shapeHeightVal !== undefined) {
879
+ x = Math.round(bounds.x + (bounds.width - shapeWidth) / 2)
880
+ y = Math.round(bounds.y + (bounds.height - shapeHeightVal) / 2)
881
+ }
882
+
848
883
  return {
849
884
  row: rowIndex,
850
885
  column: colIndex,
851
- x: bounds.x,
852
- y: bounds.y,
886
+ x,
887
+ y,
888
+ width: bounds.width,
889
+ height: bounds.height,
853
890
  }
854
891
  }
855
892
 
@@ -1352,8 +1389,17 @@ class TableManager {
1352
1389
  }
1353
1390
 
1354
1391
  // 2. Determine alignment settings
1355
- let alignX = config.alignX
1356
- let alignY = config.alignY
1392
+ let alignX = config.alignX || config.horizontal
1393
+ let alignY = config.alignY || config.vertical
1394
+
1395
+ if (alignX) {
1396
+ alignX = String(alignX).toLowerCase()
1397
+ if (alignX === 'middle') alignX = 'center'
1398
+ }
1399
+ if (alignY) {
1400
+ alignY = String(alignY).toLowerCase()
1401
+ if (alignY === 'center') alignY = 'middle'
1402
+ }
1357
1403
 
1358
1404
  if (config.position) {
1359
1405
  switch (config.position) {
@@ -1444,7 +1490,13 @@ class TableManager {
1444
1490
 
1445
1491
  // 4. Boundary Constraints Validation/Enforcement
1446
1492
  if (shapeWidth > cellWidth_px) {
1447
- shapeLeft = cellLeft_px
1493
+ if (alignX === 'center') {
1494
+ shapeLeft = cellLeft_px + (cellWidth_px - shapeWidth) / 2
1495
+ } else if (alignX === 'right') {
1496
+ shapeLeft = cellLeft_px + cellWidth_px - shapeWidth
1497
+ } else {
1498
+ shapeLeft = cellLeft_px
1499
+ }
1448
1500
  } else {
1449
1501
  shapeLeft = Math.max(
1450
1502
  cellLeft_px,
@@ -1453,7 +1505,13 @@ class TableManager {
1453
1505
  }
1454
1506
 
1455
1507
  if (shapeHeight > cellHeight_px) {
1456
- shapeTop = cellTop_px
1508
+ if (alignY === 'middle') {
1509
+ shapeTop = cellTop_px + (cellHeight_px - shapeHeight) / 2
1510
+ } else if (alignY === 'bottom') {
1511
+ shapeTop = cellTop_px + cellHeight_px - shapeHeight
1512
+ } else {
1513
+ shapeTop = cellTop_px
1514
+ }
1457
1515
  } else {
1458
1516
  shapeTop = Math.max(
1459
1517
  cellTop_px,
@@ -1885,7 +1943,7 @@ class TableManager {
1885
1943
  const colWidths = gridColsArr.map(col => parseInt(col['@_w'] || 0, 10))
1886
1944
 
1887
1945
  const trsArr = tblObj['a:tr'] || []
1888
- const rowHeights = trsArr.map(row => parseInt(row['@_h'] || 0, 10))
1946
+ const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, false)
1889
1947
 
1890
1948
  const parent = this.getMergeParent(slideIndex, tableId, rowIndex, colIndex, slideManager)
1891
1949
  const pr = parent.row
@@ -1982,7 +2040,7 @@ class TableManager {
1982
2040
  const colWidths = gridColsArr.map(col => parseInt(col['@_w'] || 0, 10))
1983
2041
 
1984
2042
  const trsArr = tblObj['a:tr'] || []
1985
- const rowHeights = trsArr.map(row => parseInt(row['@_h'] || 0, 10))
2043
+ const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, false)
1986
2044
 
1987
2045
  const parent = this.getMergeParent(slideIndex, tableId, rowIndex, colIndex, slideManager)
1988
2046
  const pr = parent.row
@@ -2094,7 +2152,7 @@ class TableManager {
2094
2152
  }
2095
2153
  }
2096
2154
 
2097
- #calculateRowHeights(slideIndex, tableId, slideManager, tblObj) {
2155
+ #calculateRowHeights(slideIndex, tableId, slideManager, tblObj, writeToXml = true) {
2098
2156
  const trsArr = tblObj['a:tr'] || []
2099
2157
  if (trsArr.length === 0) return []
2100
2158
 
@@ -2105,8 +2163,11 @@ class TableManager {
2105
2163
  const numRows = trsArr.length
2106
2164
  const numCols = colWidths.length
2107
2165
 
2108
- // Initialize rowHeights with original height or 0
2109
- const rowHeights = trsArr.map(row => parseInt(row['@_h'] || 0, 10))
2166
+ // Initialize rowHeights with original height or a safe minimum floor of 228600 EMUs (~24px/pt)
2167
+ const rowHeights = trsArr.map(row => {
2168
+ const h = parseInt(row['@_h'] || 0, 10)
2169
+ return h > 0 ? h : 228600
2170
+ })
2110
2171
 
2111
2172
  // Helper to get paragraph font size
2112
2173
  const getParagraphFontSize = p => {
@@ -2228,7 +2289,9 @@ class TableManager {
2228
2289
  }
2229
2290
 
2230
2291
  const totalCellHeight_emu = marT + marB + textHeight_emu
2231
- cellHeights[r][c] = totalCellHeight_emu
2292
+ const rowTemplateHeight = parseInt(row['@_h'] || 0, 10)
2293
+ const minFloor = rowTemplateHeight > 0 ? rowTemplateHeight : 228600
2294
+ cellHeights[r][c] = Math.max(totalCellHeight_emu, minFloor)
2232
2295
  }
2233
2296
  }
2234
2297
 
@@ -2279,8 +2342,10 @@ class TableManager {
2279
2342
  }
2280
2343
 
2281
2344
  // Update row heights in XML
2282
- for (let r = 0; r < numRows; r++) {
2283
- trsArr[r]['@_h'] = String(rowHeights[r])
2345
+ if (writeToXml) {
2346
+ for (let r = 0; r < numRows; r++) {
2347
+ trsArr[r]['@_h'] = String(rowHeights[r])
2348
+ }
2284
2349
  }
2285
2350
 
2286
2351
  return rowHeights