@cj-tech-master/excelts 5.1.2 → 5.1.3-canary.20260227071644.5cda867

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.
Files changed (29) hide show
  1. package/dist/browser/modules/excel/anchor.d.ts +1 -0
  2. package/dist/browser/modules/excel/anchor.js +3 -0
  3. package/dist/browser/modules/excel/image.d.ts +1 -0
  4. package/dist/browser/modules/excel/image.js +16 -0
  5. package/dist/browser/modules/excel/stream/workbook-writer.browser.js +7 -1
  6. package/dist/browser/modules/excel/worksheet.js +37 -0
  7. package/dist/browser/modules/excel/xlsx/xform/core/content-types-xform.js +2 -4
  8. package/dist/browser/modules/excel/xlsx/xform/sheet/worksheet-xform.js +27 -9
  9. package/dist/browser/modules/excel/xlsx/xlsx.browser.js +12 -7
  10. package/dist/cjs/modules/excel/anchor.js +3 -0
  11. package/dist/cjs/modules/excel/image.js +16 -0
  12. package/dist/cjs/modules/excel/stream/workbook-writer.browser.js +7 -1
  13. package/dist/cjs/modules/excel/worksheet.js +37 -0
  14. package/dist/cjs/modules/excel/xlsx/xform/core/content-types-xform.js +2 -4
  15. package/dist/cjs/modules/excel/xlsx/xform/sheet/worksheet-xform.js +27 -9
  16. package/dist/cjs/modules/excel/xlsx/xlsx.browser.js +11 -6
  17. package/dist/esm/modules/excel/anchor.js +3 -0
  18. package/dist/esm/modules/excel/image.js +16 -0
  19. package/dist/esm/modules/excel/stream/workbook-writer.browser.js +7 -1
  20. package/dist/esm/modules/excel/worksheet.js +37 -0
  21. package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +2 -4
  22. package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +27 -9
  23. package/dist/esm/modules/excel/xlsx/xlsx.browser.js +12 -7
  24. package/dist/iife/excelts.iife.js +69 -17
  25. package/dist/iife/excelts.iife.js.map +1 -1
  26. package/dist/iife/excelts.iife.min.js +5 -5
  27. package/dist/types/modules/excel/anchor.d.ts +1 -0
  28. package/dist/types/modules/excel/image.d.ts +1 -0
  29. package/package.json +1 -1
@@ -26,6 +26,7 @@ declare class Anchor {
26
26
  get rowHeight(): number;
27
27
  get model(): AnchorModel;
28
28
  set model(value: AnchorModel);
29
+ clone(worksheet?: Worksheet): Anchor;
29
30
  }
30
31
  export { Anchor };
31
32
  export type { AnchorModel };
@@ -93,5 +93,8 @@ class Anchor {
93
93
  this.nativeRow = value.nativeRow;
94
94
  this.nativeRowOff = value.nativeRowOff;
95
95
  }
96
+ clone(worksheet) {
97
+ return new Anchor(worksheet ?? this.worksheet, this.model);
98
+ }
96
99
  }
97
100
  export { Anchor };
@@ -60,5 +60,6 @@ declare class Image {
60
60
  constructor(worksheet: Worksheet, model?: ModelInput);
61
61
  get model(): Model;
62
62
  set model({ type, imageId, range, hyperlinks }: ModelInput);
63
+ clone(worksheet?: Worksheet): Image;
63
64
  }
64
65
  export { Image, type Model as ImageModel, type ImageModelInput };
@@ -56,5 +56,21 @@ class Image {
56
56
  }
57
57
  }
58
58
  }
59
+ clone(worksheet) {
60
+ const target = worksheet ?? this.worksheet;
61
+ const cloned = new Image(target);
62
+ cloned.type = this.type;
63
+ cloned.imageId = this.imageId;
64
+ if (this.range) {
65
+ cloned.range = {
66
+ tl: this.range.tl.clone(target),
67
+ br: this.range.br ? this.range.br.clone(target) : undefined,
68
+ ext: this.range.ext ? { ...this.range.ext } : undefined,
69
+ editAs: this.range.editAs,
70
+ hyperlinks: this.range.hyperlinks ? { ...this.range.hyperlinks } : undefined
71
+ };
72
+ }
73
+ return cloned;
74
+ }
59
75
  }
60
76
  export { Image };
@@ -224,8 +224,14 @@ export class WorkbookWriterBase {
224
224
  }
225
225
  addContentTypes() {
226
226
  return new Promise(resolve => {
227
+ const worksheets = this._worksheets.filter(Boolean);
228
+ // In the streaming path, ZIP entries use ws.id which is always sequential.
229
+ // Set fileIndex = id to satisfy the ContentTypesXform contract.
230
+ worksheets.forEach((ws) => {
231
+ ws.fileIndex = ws.id;
232
+ });
227
233
  const model = {
228
- worksheets: this._worksheets.filter(Boolean),
234
+ worksheets,
229
235
  sharedStrings: this.sharedStrings,
230
236
  commentRefs: this.commentRefs,
231
237
  media: this.media,
@@ -492,6 +492,17 @@ class Worksheet {
492
492
  srcMerges.push(merge);
493
493
  }
494
494
  }
495
+ // Collect images anchored to the source row before splicing
496
+ // (images whose top-left anchor is on the source row)
497
+ const srcImages = [];
498
+ const srcRow0 = rowNum - 1; // 0-based source row
499
+ for (const image of this._media) {
500
+ if (image.type === "image" && image.range) {
501
+ if (image.range.tl.nativeRow === srcRow0) {
502
+ srcImages.push(image);
503
+ }
504
+ }
505
+ }
495
506
  this.spliceRows(rowNum + 1, insert ? 0 : count, ...inserts);
496
507
  // now copy styles...
497
508
  for (let i = 0; i < count; i++) {
@@ -523,6 +534,32 @@ class Worksheet {
523
534
  }
524
535
  }
525
536
  }
537
+ // Duplicate images from source row into each new row.
538
+ // In overwrite mode, first remove any images anchored to the target rows
539
+ // so they don't coexist with the clones (mirrors merge cleanup above).
540
+ if (!insert) {
541
+ const dstStart0 = rowNum; // first target row, 0-based (1-based rowNum + 1 → 0-based rowNum)
542
+ const dstEnd0 = rowNum + count - 1; // last target row, 0-based
543
+ this._media = this._media.filter(image => {
544
+ if (image.type === "image" && image.range) {
545
+ const row0 = image.range.tl.nativeRow;
546
+ return row0 < dstStart0 || row0 > dstEnd0;
547
+ }
548
+ return true;
549
+ });
550
+ }
551
+ for (let i = 0; i < count; i++) {
552
+ const rowDelta = i + 1; // offset from source row to target row
553
+ for (const srcImage of srcImages) {
554
+ const cloned = srcImage.clone();
555
+ cloned.range.tl.nativeRow = srcRow0 + rowDelta;
556
+ if (cloned.range.br) {
557
+ const brDelta = srcImage.range.br.nativeRow - srcRow0;
558
+ cloned.range.br.nativeRow = srcRow0 + rowDelta + brDelta;
559
+ }
560
+ this._media.push(cloned);
561
+ }
562
+ }
526
563
  }
527
564
  /**
528
565
  * Cut one or more rows (rows below are shifted up)
@@ -29,11 +29,9 @@ class ContentTypesXform extends BaseXform {
29
29
  PartName: toContentTypesPartName(OOXML_PATHS.xlWorkbook),
30
30
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
31
31
  });
32
- model.worksheets.forEach((worksheet, index) => {
33
- // Use fileIndex if set, otherwise use sequential index (1-based)
34
- const fileIndex = worksheet.fileIndex || index + 1;
32
+ model.worksheets.forEach((worksheet) => {
35
33
  xmlStream.leafNode("Override", {
36
- PartName: toContentTypesPartName(worksheetPath(fileIndex)),
34
+ PartName: toContentTypesPartName(worksheetPath(worksheet.fileIndex)),
37
35
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
38
36
  });
39
37
  });
@@ -167,37 +167,55 @@ class WorkSheetXform extends BaseXform {
167
167
  });
168
168
  });
169
169
  // prepare comment relationships
170
+ // Use fileIndex (sequential 1-based) for file naming instead of model.id
171
+ // (the workbook-level sheet ID) because model.id can have gaps when sheets
172
+ // have been deleted, causing a mismatch between the relationship targets
173
+ // and the actual ZIP entry paths written by addWorksheets().
174
+ const { fileIndex } = model;
170
175
  if (model.comments.length > 0) {
171
176
  const comment = {
172
177
  Id: nextRid(rels),
173
178
  Type: RelType.Comments,
174
- Target: commentsRelTargetFromWorksheet(model.id)
179
+ Target: commentsRelTargetFromWorksheet(fileIndex)
175
180
  };
176
181
  rels.push(comment);
177
182
  const vmlDrawing = {
178
183
  Id: nextRid(rels),
179
184
  Type: RelType.VmlDrawing,
180
- Target: vmlDrawingRelTargetFromWorksheet(model.id)
185
+ Target: vmlDrawingRelTargetFromWorksheet(fileIndex)
181
186
  };
182
187
  rels.push(vmlDrawing);
183
188
  model.comments.forEach(item => {
184
189
  item.refAddress = colCache.decodeAddress(item.ref);
185
190
  });
186
191
  options.commentRefs.push({
187
- commentName: `comments${model.id}`,
188
- vmlDrawing: `vmlDrawing${model.id}`
192
+ commentName: `comments${fileIndex}`,
193
+ vmlDrawing: `vmlDrawing${fileIndex}`
189
194
  });
190
195
  }
191
- // Handle pre-loaded drawing (from file read) that may contain charts or other non-image content
192
- // This preserves drawings through round-trip even if they don't contain images
193
- // Note: Always assign a new rId since rels are rebuilt during write
196
+ // Handle pre-loaded drawing (from file read) that may contain charts or other non-image content.
197
+ // Reset anchors and rels so they are rebuilt cleanly from model.media (images) and
198
+ // model.formControls (shapes) below. Without this reset, every read-write cycle would
199
+ // duplicate image anchors because the same images exist in both model.drawing.anchors
200
+ // (preserved for round-trip) and model.media (the canonical image list).
201
+ // For chart drawings, rels are preserved because the raw XML passthrough references
202
+ // original rIds; anchors are still cleared since they are unused for chart drawings.
194
203
  if (model.drawing && model.drawing.anchors) {
195
- // This is a loaded drawing that needs to be added to relationships
196
204
  const drawing = model.drawing;
197
205
  drawing.rId = nextRid(rels);
198
206
  if (!drawing.name) {
199
207
  drawing.name = `drawing${++options.drawingsCount}`;
200
208
  }
209
+ const hasChartRels = (drawing.rels ?? []).some((rel) => rel.Target && rel.Target.includes("/charts/"));
210
+ // Anchors are always reset: for chart drawings they are unused (raw XML passthrough),
211
+ // for normal drawings they are rebuilt from model.media below.
212
+ drawing.anchors = [];
213
+ if (!hasChartRels) {
214
+ // Non-chart drawings: clear rels so image rels are rebuilt from scratch.
215
+ drawing.rels = [];
216
+ }
217
+ // Chart drawings keep their original rels intact since the raw drawing XML
218
+ // references those rIds directly.
201
219
  options.drawings.push(drawing);
202
220
  rels.push({
203
221
  Id: drawing.rId,
@@ -325,7 +343,7 @@ class WorkSheetXform extends BaseXform {
325
343
  rels.push({
326
344
  Id: nextRid(rels),
327
345
  Type: RelType.VmlDrawing,
328
- Target: vmlDrawingRelTargetFromWorksheet(model.id)
346
+ Target: vmlDrawingRelTargetFromWorksheet(fileIndex)
329
347
  });
330
348
  }
331
349
  // Add hidden DrawingML shapes that bridge to the VML shape ids.
@@ -33,7 +33,7 @@ import { bufferToString, base64ToUint8Array } from "../../../utils/utils.browser
33
33
  import { StreamingZip, ZipDeflateFile } from "../../archive/zip/stream.js";
34
34
  import { ZipParser } from "../../archive/unzip/zip-parser.js";
35
35
  import { PassThrough, concatUint8Arrays } from "../../stream/index.browser.js";
36
- import { commentsPath, commentsRelTargetFromWorksheetName, ctrlPropPath, drawingPath, drawingRelsPath, OOXML_REL_TARGETS, pivotTableRelTargetFromWorksheetName, pivotCacheDefinitionRelTargetFromWorkbook, getCommentsIndexFromPath, getDrawingNameFromPath, getDrawingNameFromRelsPath, getMediaFilenameFromPath, mediaPath, getPivotCacheDefinitionNameFromPath, getPivotCacheDefinitionNameFromRelsPath, getPivotCacheRecordsNameFromPath, getPivotTableNameFromPath, getPivotTableNameFromRelsPath, pivotCacheDefinitionPath, pivotCacheDefinitionRelsPath, pivotCacheDefinitionRelTargetFromPivotTable, pivotCacheRecordsPath, pivotCacheRecordsRelTarget, pivotTablePath, pivotTableRelsPath, getTableNameFromPath, tablePath, tableRelTargetFromWorksheetName, themePath, getThemeNameFromPath, getVmlDrawingNameFromPath, getWorksheetNoFromWorksheetPath, getWorksheetNoFromWorksheetRelsPath, isBinaryEntryPath, normalizeZipPath, OOXML_PATHS, vmlDrawingRelTargetFromWorksheetName, vmlDrawingPath, worksheetPath, worksheetRelsPath } from "../utils/ooxml-paths.js";
36
+ import { commentsPath, commentsRelTargetFromWorksheetName, ctrlPropPath, drawingPath, drawingRelsPath, OOXML_REL_TARGETS, pivotTableRelTargetFromWorksheetName, pivotCacheDefinitionRelTargetFromWorkbook, getCommentsIndexFromPath, getDrawingNameFromPath, getDrawingNameFromRelsPath, getMediaFilenameFromPath, mediaPath, getPivotCacheDefinitionNameFromPath, getPivotCacheDefinitionNameFromRelsPath, getPivotCacheRecordsNameFromPath, getPivotTableNameFromPath, getPivotTableNameFromRelsPath, pivotCacheDefinitionPath, pivotCacheDefinitionRelsPath, pivotCacheDefinitionRelTargetFromPivotTable, pivotCacheRecordsPath, pivotCacheRecordsRelTarget, pivotTablePath, pivotTableRelsPath, getTableNameFromPath, tablePath, tableRelTargetFromWorksheetName, themePath, getThemeNameFromPath, getVmlDrawingNameFromPath, getWorksheetNoFromWorksheetPath, getWorksheetNoFromWorksheetRelsPath, isBinaryEntryPath, normalizeZipPath, OOXML_PATHS, vmlDrawingRelTargetFromWorksheetName, vmlDrawingPath, worksheetPath, worksheetRelsPath, worksheetRelTarget } from "../utils/ooxml-paths.js";
37
37
  import { PassthroughManager } from "../utils/passthrough-manager.js";
38
38
  class StreamingZipWriterAdapter {
39
39
  constructor(options) {
@@ -1050,13 +1050,13 @@ class XLSX {
1050
1050
  });
1051
1051
  }
1052
1052
  });
1053
- model.worksheets.forEach((worksheet, index) => {
1053
+ model.worksheets.forEach((worksheet) => {
1054
1054
  worksheet.rId = `rId${count++}`;
1055
- worksheet.fileIndex = index + 1;
1055
+ // fileIndex is assigned once in prepareModel() — use it directly
1056
1056
  relationships.push({
1057
1057
  Id: worksheet.rId,
1058
1058
  Type: XLSX.RelType.Worksheet,
1059
- Target: `worksheets/sheet${worksheet.fileIndex}.xml`
1059
+ Target: worksheetRelTarget(worksheet.fileIndex)
1060
1060
  });
1061
1061
  });
1062
1062
  const xform = new RelationshipsXform();
@@ -1091,8 +1091,8 @@ class XLSX {
1091
1091
  const commentsXform = new CommentsXform();
1092
1092
  const vmlDrawingXform = new VmlDrawingXform();
1093
1093
  const ctrlPropXform = new CtrlPropXform();
1094
- model.worksheets.forEach((worksheet, index) => {
1095
- const fileIndex = worksheet.fileIndex || index + 1;
1094
+ model.worksheets.forEach((worksheet) => {
1095
+ const { fileIndex } = worksheet;
1096
1096
  let xmlStream = new XmlStream();
1097
1097
  worksheetXform.render(xmlStream, worksheet);
1098
1098
  zip.append(xmlStream.xml, { name: worksheetPath(fileIndex) });
@@ -1299,7 +1299,12 @@ class XLSX {
1299
1299
  worksheetOptions.formControlRefs = model.formControlRefs = [];
1300
1300
  let tableCount = 0;
1301
1301
  model.tables = [];
1302
- model.worksheets.forEach((worksheet) => {
1302
+ model.worksheets.forEach((worksheet, index) => {
1303
+ // Assign fileIndex early so that worksheet-xform.prepare() can use it
1304
+ // for comment/VML relationship targets and content type names.
1305
+ // This ensures consistency with addWorksheets() which writes ZIP entries
1306
+ // using the same fileIndex.
1307
+ worksheet.fileIndex = index + 1;
1303
1308
  worksheet.tables.forEach((table) => {
1304
1309
  tableCount++;
1305
1310
  table.target = `table${tableCount}.xml`;
@@ -96,5 +96,8 @@ class Anchor {
96
96
  this.nativeRow = value.nativeRow;
97
97
  this.nativeRowOff = value.nativeRowOff;
98
98
  }
99
+ clone(worksheet) {
100
+ return new Anchor(worksheet ?? this.worksheet, this.model);
101
+ }
99
102
  }
100
103
  exports.Anchor = Anchor;
@@ -59,5 +59,21 @@ class Image {
59
59
  }
60
60
  }
61
61
  }
62
+ clone(worksheet) {
63
+ const target = worksheet ?? this.worksheet;
64
+ const cloned = new Image(target);
65
+ cloned.type = this.type;
66
+ cloned.imageId = this.imageId;
67
+ if (this.range) {
68
+ cloned.range = {
69
+ tl: this.range.tl.clone(target),
70
+ br: this.range.br ? this.range.br.clone(target) : undefined,
71
+ ext: this.range.ext ? { ...this.range.ext } : undefined,
72
+ editAs: this.range.editAs,
73
+ hyperlinks: this.range.hyperlinks ? { ...this.range.hyperlinks } : undefined
74
+ };
75
+ }
76
+ return cloned;
77
+ }
62
78
  }
63
79
  exports.Image = Image;
@@ -227,8 +227,14 @@ class WorkbookWriterBase {
227
227
  }
228
228
  addContentTypes() {
229
229
  return new Promise(resolve => {
230
+ const worksheets = this._worksheets.filter(Boolean);
231
+ // In the streaming path, ZIP entries use ws.id which is always sequential.
232
+ // Set fileIndex = id to satisfy the ContentTypesXform contract.
233
+ worksheets.forEach((ws) => {
234
+ ws.fileIndex = ws.id;
235
+ });
230
236
  const model = {
231
- worksheets: this._worksheets.filter(Boolean),
237
+ worksheets,
232
238
  sharedStrings: this.sharedStrings,
233
239
  commentRefs: this.commentRefs,
234
240
  media: this.media,
@@ -495,6 +495,17 @@ class Worksheet {
495
495
  srcMerges.push(merge);
496
496
  }
497
497
  }
498
+ // Collect images anchored to the source row before splicing
499
+ // (images whose top-left anchor is on the source row)
500
+ const srcImages = [];
501
+ const srcRow0 = rowNum - 1; // 0-based source row
502
+ for (const image of this._media) {
503
+ if (image.type === "image" && image.range) {
504
+ if (image.range.tl.nativeRow === srcRow0) {
505
+ srcImages.push(image);
506
+ }
507
+ }
508
+ }
498
509
  this.spliceRows(rowNum + 1, insert ? 0 : count, ...inserts);
499
510
  // now copy styles...
500
511
  for (let i = 0; i < count; i++) {
@@ -526,6 +537,32 @@ class Worksheet {
526
537
  }
527
538
  }
528
539
  }
540
+ // Duplicate images from source row into each new row.
541
+ // In overwrite mode, first remove any images anchored to the target rows
542
+ // so they don't coexist with the clones (mirrors merge cleanup above).
543
+ if (!insert) {
544
+ const dstStart0 = rowNum; // first target row, 0-based (1-based rowNum + 1 → 0-based rowNum)
545
+ const dstEnd0 = rowNum + count - 1; // last target row, 0-based
546
+ this._media = this._media.filter(image => {
547
+ if (image.type === "image" && image.range) {
548
+ const row0 = image.range.tl.nativeRow;
549
+ return row0 < dstStart0 || row0 > dstEnd0;
550
+ }
551
+ return true;
552
+ });
553
+ }
554
+ for (let i = 0; i < count; i++) {
555
+ const rowDelta = i + 1; // offset from source row to target row
556
+ for (const srcImage of srcImages) {
557
+ const cloned = srcImage.clone();
558
+ cloned.range.tl.nativeRow = srcRow0 + rowDelta;
559
+ if (cloned.range.br) {
560
+ const brDelta = srcImage.range.br.nativeRow - srcRow0;
561
+ cloned.range.br.nativeRow = srcRow0 + rowDelta + brDelta;
562
+ }
563
+ this._media.push(cloned);
564
+ }
565
+ }
529
566
  }
530
567
  /**
531
568
  * Cut one or more rows (rows below are shifted up)
@@ -32,11 +32,9 @@ class ContentTypesXform extends base_xform_1.BaseXform {
32
32
  PartName: (0, ooxml_paths_1.toContentTypesPartName)(ooxml_paths_1.OOXML_PATHS.xlWorkbook),
33
33
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
34
34
  });
35
- model.worksheets.forEach((worksheet, index) => {
36
- // Use fileIndex if set, otherwise use sequential index (1-based)
37
- const fileIndex = worksheet.fileIndex || index + 1;
35
+ model.worksheets.forEach((worksheet) => {
38
36
  xmlStream.leafNode("Override", {
39
- PartName: (0, ooxml_paths_1.toContentTypesPartName)((0, ooxml_paths_1.worksheetPath)(fileIndex)),
37
+ PartName: (0, ooxml_paths_1.toContentTypesPartName)((0, ooxml_paths_1.worksheetPath)(worksheet.fileIndex)),
40
38
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
41
39
  });
42
40
  });
@@ -170,37 +170,55 @@ class WorkSheetXform extends base_xform_1.BaseXform {
170
170
  });
171
171
  });
172
172
  // prepare comment relationships
173
+ // Use fileIndex (sequential 1-based) for file naming instead of model.id
174
+ // (the workbook-level sheet ID) because model.id can have gaps when sheets
175
+ // have been deleted, causing a mismatch between the relationship targets
176
+ // and the actual ZIP entry paths written by addWorksheets().
177
+ const { fileIndex } = model;
173
178
  if (model.comments.length > 0) {
174
179
  const comment = {
175
180
  Id: nextRid(rels),
176
181
  Type: rel_type_1.RelType.Comments,
177
- Target: (0, ooxml_paths_1.commentsRelTargetFromWorksheet)(model.id)
182
+ Target: (0, ooxml_paths_1.commentsRelTargetFromWorksheet)(fileIndex)
178
183
  };
179
184
  rels.push(comment);
180
185
  const vmlDrawing = {
181
186
  Id: nextRid(rels),
182
187
  Type: rel_type_1.RelType.VmlDrawing,
183
- Target: (0, ooxml_paths_1.vmlDrawingRelTargetFromWorksheet)(model.id)
188
+ Target: (0, ooxml_paths_1.vmlDrawingRelTargetFromWorksheet)(fileIndex)
184
189
  };
185
190
  rels.push(vmlDrawing);
186
191
  model.comments.forEach(item => {
187
192
  item.refAddress = col_cache_1.colCache.decodeAddress(item.ref);
188
193
  });
189
194
  options.commentRefs.push({
190
- commentName: `comments${model.id}`,
191
- vmlDrawing: `vmlDrawing${model.id}`
195
+ commentName: `comments${fileIndex}`,
196
+ vmlDrawing: `vmlDrawing${fileIndex}`
192
197
  });
193
198
  }
194
- // Handle pre-loaded drawing (from file read) that may contain charts or other non-image content
195
- // This preserves drawings through round-trip even if they don't contain images
196
- // Note: Always assign a new rId since rels are rebuilt during write
199
+ // Handle pre-loaded drawing (from file read) that may contain charts or other non-image content.
200
+ // Reset anchors and rels so they are rebuilt cleanly from model.media (images) and
201
+ // model.formControls (shapes) below. Without this reset, every read-write cycle would
202
+ // duplicate image anchors because the same images exist in both model.drawing.anchors
203
+ // (preserved for round-trip) and model.media (the canonical image list).
204
+ // For chart drawings, rels are preserved because the raw XML passthrough references
205
+ // original rIds; anchors are still cleared since they are unused for chart drawings.
197
206
  if (model.drawing && model.drawing.anchors) {
198
- // This is a loaded drawing that needs to be added to relationships
199
207
  const drawing = model.drawing;
200
208
  drawing.rId = nextRid(rels);
201
209
  if (!drawing.name) {
202
210
  drawing.name = `drawing${++options.drawingsCount}`;
203
211
  }
212
+ const hasChartRels = (drawing.rels ?? []).some((rel) => rel.Target && rel.Target.includes("/charts/"));
213
+ // Anchors are always reset: for chart drawings they are unused (raw XML passthrough),
214
+ // for normal drawings they are rebuilt from model.media below.
215
+ drawing.anchors = [];
216
+ if (!hasChartRels) {
217
+ // Non-chart drawings: clear rels so image rels are rebuilt from scratch.
218
+ drawing.rels = [];
219
+ }
220
+ // Chart drawings keep their original rels intact since the raw drawing XML
221
+ // references those rIds directly.
204
222
  options.drawings.push(drawing);
205
223
  rels.push({
206
224
  Id: drawing.rId,
@@ -328,7 +346,7 @@ class WorkSheetXform extends base_xform_1.BaseXform {
328
346
  rels.push({
329
347
  Id: nextRid(rels),
330
348
  Type: rel_type_1.RelType.VmlDrawing,
331
- Target: (0, ooxml_paths_1.vmlDrawingRelTargetFromWorksheet)(model.id)
349
+ Target: (0, ooxml_paths_1.vmlDrawingRelTargetFromWorksheet)(fileIndex)
332
350
  });
333
351
  }
334
352
  // Add hidden DrawingML shapes that bridge to the VML shape ids.
@@ -1053,13 +1053,13 @@ class XLSX {
1053
1053
  });
1054
1054
  }
1055
1055
  });
1056
- model.worksheets.forEach((worksheet, index) => {
1056
+ model.worksheets.forEach((worksheet) => {
1057
1057
  worksheet.rId = `rId${count++}`;
1058
- worksheet.fileIndex = index + 1;
1058
+ // fileIndex is assigned once in prepareModel() — use it directly
1059
1059
  relationships.push({
1060
1060
  Id: worksheet.rId,
1061
1061
  Type: XLSX.RelType.Worksheet,
1062
- Target: `worksheets/sheet${worksheet.fileIndex}.xml`
1062
+ Target: (0, ooxml_paths_1.worksheetRelTarget)(worksheet.fileIndex)
1063
1063
  });
1064
1064
  });
1065
1065
  const xform = new relationships_xform_1.RelationshipsXform();
@@ -1094,8 +1094,8 @@ class XLSX {
1094
1094
  const commentsXform = new comments_xform_1.CommentsXform();
1095
1095
  const vmlDrawingXform = new vml_drawing_xform_1.VmlDrawingXform();
1096
1096
  const ctrlPropXform = new ctrl_prop_xform_1.CtrlPropXform();
1097
- model.worksheets.forEach((worksheet, index) => {
1098
- const fileIndex = worksheet.fileIndex || index + 1;
1097
+ model.worksheets.forEach((worksheet) => {
1098
+ const { fileIndex } = worksheet;
1099
1099
  let xmlStream = new xml_stream_1.XmlStream();
1100
1100
  worksheetXform.render(xmlStream, worksheet);
1101
1101
  zip.append(xmlStream.xml, { name: (0, ooxml_paths_1.worksheetPath)(fileIndex) });
@@ -1302,7 +1302,12 @@ class XLSX {
1302
1302
  worksheetOptions.formControlRefs = model.formControlRefs = [];
1303
1303
  let tableCount = 0;
1304
1304
  model.tables = [];
1305
- model.worksheets.forEach((worksheet) => {
1305
+ model.worksheets.forEach((worksheet, index) => {
1306
+ // Assign fileIndex early so that worksheet-xform.prepare() can use it
1307
+ // for comment/VML relationship targets and content type names.
1308
+ // This ensures consistency with addWorksheets() which writes ZIP entries
1309
+ // using the same fileIndex.
1310
+ worksheet.fileIndex = index + 1;
1306
1311
  worksheet.tables.forEach((table) => {
1307
1312
  tableCount++;
1308
1313
  table.target = `table${tableCount}.xml`;
@@ -93,5 +93,8 @@ class Anchor {
93
93
  this.nativeRow = value.nativeRow;
94
94
  this.nativeRowOff = value.nativeRowOff;
95
95
  }
96
+ clone(worksheet) {
97
+ return new Anchor(worksheet ?? this.worksheet, this.model);
98
+ }
96
99
  }
97
100
  export { Anchor };
@@ -56,5 +56,21 @@ class Image {
56
56
  }
57
57
  }
58
58
  }
59
+ clone(worksheet) {
60
+ const target = worksheet ?? this.worksheet;
61
+ const cloned = new Image(target);
62
+ cloned.type = this.type;
63
+ cloned.imageId = this.imageId;
64
+ if (this.range) {
65
+ cloned.range = {
66
+ tl: this.range.tl.clone(target),
67
+ br: this.range.br ? this.range.br.clone(target) : undefined,
68
+ ext: this.range.ext ? { ...this.range.ext } : undefined,
69
+ editAs: this.range.editAs,
70
+ hyperlinks: this.range.hyperlinks ? { ...this.range.hyperlinks } : undefined
71
+ };
72
+ }
73
+ return cloned;
74
+ }
59
75
  }
60
76
  export { Image };
@@ -224,8 +224,14 @@ export class WorkbookWriterBase {
224
224
  }
225
225
  addContentTypes() {
226
226
  return new Promise(resolve => {
227
+ const worksheets = this._worksheets.filter(Boolean);
228
+ // In the streaming path, ZIP entries use ws.id which is always sequential.
229
+ // Set fileIndex = id to satisfy the ContentTypesXform contract.
230
+ worksheets.forEach((ws) => {
231
+ ws.fileIndex = ws.id;
232
+ });
227
233
  const model = {
228
- worksheets: this._worksheets.filter(Boolean),
234
+ worksheets,
229
235
  sharedStrings: this.sharedStrings,
230
236
  commentRefs: this.commentRefs,
231
237
  media: this.media,
@@ -492,6 +492,17 @@ class Worksheet {
492
492
  srcMerges.push(merge);
493
493
  }
494
494
  }
495
+ // Collect images anchored to the source row before splicing
496
+ // (images whose top-left anchor is on the source row)
497
+ const srcImages = [];
498
+ const srcRow0 = rowNum - 1; // 0-based source row
499
+ for (const image of this._media) {
500
+ if (image.type === "image" && image.range) {
501
+ if (image.range.tl.nativeRow === srcRow0) {
502
+ srcImages.push(image);
503
+ }
504
+ }
505
+ }
495
506
  this.spliceRows(rowNum + 1, insert ? 0 : count, ...inserts);
496
507
  // now copy styles...
497
508
  for (let i = 0; i < count; i++) {
@@ -523,6 +534,32 @@ class Worksheet {
523
534
  }
524
535
  }
525
536
  }
537
+ // Duplicate images from source row into each new row.
538
+ // In overwrite mode, first remove any images anchored to the target rows
539
+ // so they don't coexist with the clones (mirrors merge cleanup above).
540
+ if (!insert) {
541
+ const dstStart0 = rowNum; // first target row, 0-based (1-based rowNum + 1 → 0-based rowNum)
542
+ const dstEnd0 = rowNum + count - 1; // last target row, 0-based
543
+ this._media = this._media.filter(image => {
544
+ if (image.type === "image" && image.range) {
545
+ const row0 = image.range.tl.nativeRow;
546
+ return row0 < dstStart0 || row0 > dstEnd0;
547
+ }
548
+ return true;
549
+ });
550
+ }
551
+ for (let i = 0; i < count; i++) {
552
+ const rowDelta = i + 1; // offset from source row to target row
553
+ for (const srcImage of srcImages) {
554
+ const cloned = srcImage.clone();
555
+ cloned.range.tl.nativeRow = srcRow0 + rowDelta;
556
+ if (cloned.range.br) {
557
+ const brDelta = srcImage.range.br.nativeRow - srcRow0;
558
+ cloned.range.br.nativeRow = srcRow0 + rowDelta + brDelta;
559
+ }
560
+ this._media.push(cloned);
561
+ }
562
+ }
526
563
  }
527
564
  /**
528
565
  * Cut one or more rows (rows below are shifted up)
@@ -29,11 +29,9 @@ class ContentTypesXform extends BaseXform {
29
29
  PartName: toContentTypesPartName(OOXML_PATHS.xlWorkbook),
30
30
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
31
31
  });
32
- model.worksheets.forEach((worksheet, index) => {
33
- // Use fileIndex if set, otherwise use sequential index (1-based)
34
- const fileIndex = worksheet.fileIndex || index + 1;
32
+ model.worksheets.forEach((worksheet) => {
35
33
  xmlStream.leafNode("Override", {
36
- PartName: toContentTypesPartName(worksheetPath(fileIndex)),
34
+ PartName: toContentTypesPartName(worksheetPath(worksheet.fileIndex)),
37
35
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
38
36
  });
39
37
  });