@cj-tech-master/excelts 9.5.9 → 9.6.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.
Files changed (60) hide show
  1. package/dist/browser/modules/excel/stream/workbook-writer.browser.d.ts +17 -0
  2. package/dist/browser/modules/excel/stream/workbook-writer.browser.js +23 -1
  3. package/dist/browser/modules/excel/stream/workbook-writer.js +6 -0
  4. package/dist/browser/modules/excel/stream/worksheet-writer.d.ts +6 -0
  5. package/dist/browser/modules/excel/stream/worksheet-writer.js +29 -3
  6. package/dist/browser/modules/excel/types.d.ts +17 -0
  7. package/dist/browser/modules/excel/utils/drawing-utils.d.ts +46 -4
  8. package/dist/browser/modules/excel/utils/drawing-utils.js +64 -6
  9. package/dist/browser/modules/excel/workbook.browser.d.ts +38 -1
  10. package/dist/browser/modules/excel/workbook.browser.js +36 -1
  11. package/dist/browser/modules/excel/worksheet.d.ts +13 -1
  12. package/dist/browser/modules/excel/worksheet.js +35 -4
  13. package/dist/browser/modules/excel/xlsx/xform/core/content-types-xform.js +6 -0
  14. package/dist/browser/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.d.ts +6 -0
  15. package/dist/browser/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +32 -0
  16. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +5 -0
  17. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.js +14 -6
  18. package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +2 -0
  19. package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.js +2 -1
  20. package/dist/browser/modules/excel/xlsx/xform/sheet/worksheet-xform.js +14 -10
  21. package/dist/browser/modules/excel/xlsx/xlsx.browser.d.ts +2 -0
  22. package/dist/browser/modules/excel/xlsx/xlsx.browser.js +7 -1
  23. package/dist/cjs/modules/excel/stream/workbook-writer.browser.js +22 -0
  24. package/dist/cjs/modules/excel/stream/workbook-writer.js +6 -0
  25. package/dist/cjs/modules/excel/stream/worksheet-writer.js +27 -1
  26. package/dist/cjs/modules/excel/utils/drawing-utils.js +67 -6
  27. package/dist/cjs/modules/excel/workbook.browser.js +36 -1
  28. package/dist/cjs/modules/excel/worksheet.js +34 -3
  29. package/dist/cjs/modules/excel/xlsx/xform/core/content-types-xform.js +6 -0
  30. package/dist/cjs/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +32 -0
  31. package/dist/cjs/modules/excel/xlsx/xform/drawing/blip-xform.js +14 -6
  32. package/dist/cjs/modules/excel/xlsx/xform/drawing/pic-xform.js +2 -1
  33. package/dist/cjs/modules/excel/xlsx/xform/sheet/worksheet-xform.js +13 -9
  34. package/dist/cjs/modules/excel/xlsx/xlsx.browser.js +6 -0
  35. package/dist/esm/modules/excel/stream/workbook-writer.browser.js +23 -1
  36. package/dist/esm/modules/excel/stream/workbook-writer.js +6 -0
  37. package/dist/esm/modules/excel/stream/worksheet-writer.js +29 -3
  38. package/dist/esm/modules/excel/utils/drawing-utils.js +64 -6
  39. package/dist/esm/modules/excel/workbook.browser.js +36 -1
  40. package/dist/esm/modules/excel/worksheet.js +35 -4
  41. package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +6 -0
  42. package/dist/esm/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +32 -0
  43. package/dist/esm/modules/excel/xlsx/xform/drawing/blip-xform.js +14 -6
  44. package/dist/esm/modules/excel/xlsx/xform/drawing/pic-xform.js +2 -1
  45. package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +14 -10
  46. package/dist/esm/modules/excel/xlsx/xlsx.browser.js +7 -1
  47. package/dist/iife/excelts.iife.js +195 -26
  48. package/dist/iife/excelts.iife.js.map +1 -1
  49. package/dist/iife/excelts.iife.min.js +35 -35
  50. package/dist/types/modules/excel/stream/workbook-writer.browser.d.ts +17 -0
  51. package/dist/types/modules/excel/stream/worksheet-writer.d.ts +6 -0
  52. package/dist/types/modules/excel/types.d.ts +17 -0
  53. package/dist/types/modules/excel/utils/drawing-utils.d.ts +46 -4
  54. package/dist/types/modules/excel/workbook.browser.d.ts +38 -1
  55. package/dist/types/modules/excel/worksheet.d.ts +13 -1
  56. package/dist/types/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.d.ts +6 -0
  57. package/dist/types/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +5 -0
  58. package/dist/types/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +2 -0
  59. package/dist/types/modules/excel/xlsx/xlsx.browser.d.ts +2 -0
  60. package/package.json +7 -7
@@ -410,17 +410,23 @@ class WorkSheetXform extends base_xform_1.BaseXform {
410
410
  headerImageMedia.push(medium);
411
411
  }
412
412
  });
413
- // Handle background images
413
+ // Handle background images. Background pictures are always embedded —
414
+ // external (linked) images are rejected in addBackgroundImage because Excel
415
+ // drops a background whose relationship uses TargetMode="External".
414
416
  backgroundMedia.forEach(medium => {
415
- const rId = nextRid(rels);
416
417
  const bookImage = options.media[medium.imageId];
418
+ // Guard against an invalid imageId — same as the image/watermark paths.
419
+ if (!bookImage) {
420
+ return;
421
+ }
422
+ const rId = nextRid(rels);
417
423
  rels.push({
418
424
  Id: rId,
419
425
  Type: rel_type_1.RelType.Image,
420
426
  Target: (0, drawing_utils_1.resolveMediaTarget)(bookImage)
421
427
  });
422
428
  model.background = { rId };
423
- model.image = options.media[medium.imageId];
429
+ model.image = bookImage;
424
430
  });
425
431
  // Handle embedded images — create drawing model using shared utility
426
432
  if (imageMedia.length > 0) {
@@ -468,12 +474,9 @@ class WorkSheetXform extends base_xform_1.BaseXform {
468
474
  if (!bookImage) {
469
475
  continue;
470
476
  }
477
+ const isExternal = (0, drawing_utils_1.isExternalImage)(bookImage);
471
478
  const rIdImage = nextRid(drawing.rels);
472
- drawing.rels.push({
473
- Id: rIdImage,
474
- Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
475
- Target: (0, drawing_utils_1.resolveMediaTarget)(bookImage)
476
- });
479
+ drawing.rels.push((0, drawing_utils_1.buildImageRel)(rIdImage, bookImage));
477
480
  // Convert opacity (0-1) to OOXML percentage (0-100000), clamped
478
481
  const rawOpacity = medium.opacity !== undefined ? medium.opacity : 0.15;
479
482
  const clampedOpacity = Math.max(0, Math.min(1, rawOpacity));
@@ -486,7 +489,8 @@ class WorkSheetXform extends base_xform_1.BaseXform {
486
489
  drawing.anchors.push({
487
490
  picture: {
488
491
  rId: rIdImage,
489
- alphaModFix
492
+ alphaModFix,
493
+ ...(isExternal ? { external: true } : {})
490
494
  },
491
495
  // Cover the full data area with extra margin
492
496
  range: {
@@ -4287,6 +4287,12 @@ class XLSX {
4287
4287
  if (medium.type !== "image") {
4288
4288
  throw new errors_1.ImageError("Unsupported media");
4289
4289
  }
4290
+ // External (linked) images carry only a `link` target — no bytes are
4291
+ // written into the package; the relationship (TargetMode="External")
4292
+ // references the image in place.
4293
+ if ((0, drawing_utils_1.isExternalImage)(medium)) {
4294
+ return;
4295
+ }
4290
4296
  // Preserve legacy behavior: `${undefined}` becomes "undefined" in template strings
4291
4297
  const mediaName = medium.name ?? "undefined";
4292
4298
  const filename = (0, ooxml_paths_1.mediaPath)(`${mediaName}.${medium.extension}`);
@@ -11,7 +11,7 @@ import { Zip, ZipDeflate } from "../../archive/zip/stream.js";
11
11
  import { DefinedNames } from "../defined-names.js";
12
12
  import { ExcelNotSupportedError, ImageError } from "../errors.js";
13
13
  import { WorksheetWriter } from "./worksheet-writer.js";
14
- import { filterDrawingAnchors } from "../utils/drawing-utils.js";
14
+ import { filterDrawingAnchors, isExternalImage } from "../utils/drawing-utils.js";
15
15
  import { drawingPath, drawingRelsPath, mediaPath, OOXML_PATHS, OOXML_REL_TARGETS, worksheetRelTarget } from "../utils/ooxml-paths.js";
16
16
  import { SharedStrings } from "../utils/shared-strings.js";
17
17
  import { StreamBuf } from "../utils/stream-buf.js";
@@ -369,6 +369,23 @@ export class WorkbookWriterBase {
369
369
  }
370
370
  return this._worksheets.length || 1;
371
371
  }
372
+ /**
373
+ * Register an image with the workbook and return its numeric id.
374
+ *
375
+ * Supply `buffer`/`base64`/`filename` to **embed** the bytes, or only `link`
376
+ * (a URL or local file path) to reference it **externally** — in which case
377
+ * no bytes are written into the package and the relationship is emitted with
378
+ * `TargetMode="External"`. If both are provided, embedding wins.
379
+ *
380
+ * Linked images work with cell pictures and overlay watermarks; worksheet
381
+ * background images and header/footer (VML) watermarks cannot be linked.
382
+ *
383
+ * @example
384
+ * ```typescript
385
+ * const id = wb.addImage({ extension: "png", link: "https://example.com/logo.png" });
386
+ * ws.addImage(id, "B2:D6");
387
+ * ```
388
+ */
372
389
  addImage(image) {
373
390
  const id = this.media.length;
374
391
  const medium = {
@@ -486,6 +503,11 @@ export class WorkbookWriterBase {
486
503
  addMedia() {
487
504
  return Promise.all(this.media.map(async (medium) => {
488
505
  if (medium.type === "image") {
506
+ // External (linked) images carry only a `link` target — no bytes
507
+ // are written into the package.
508
+ if (isExternalImage(medium)) {
509
+ return;
510
+ }
489
511
  const filename = mediaPath(medium.name);
490
512
  if (medium.buffer) {
491
513
  this._addFile(medium.buffer, filename);
@@ -6,6 +6,7 @@
6
6
  import { ImageError } from "../errors.js";
7
7
  import { WorkbookWriterBase } from "./workbook-writer.browser.js";
8
8
  import { WorksheetWriter } from "./worksheet-writer.js";
9
+ import { isExternalImage } from "../utils/drawing-utils.js";
9
10
  import { mediaPath } from "../utils/ooxml-paths.js";
10
11
  import { readFileBytes, createWriteStream } from "../../../utils/fs.js";
11
12
  class WorkbookWriter extends WorkbookWriterBase {
@@ -27,6 +28,11 @@ class WorkbookWriter extends WorkbookWriterBase {
27
28
  addMedia() {
28
29
  return Promise.all(this.media.map(async (medium) => {
29
30
  if (medium.type === "image") {
31
+ // External (linked) images carry only a `link` target — no bytes
32
+ // are written into the package.
33
+ if (isExternalImage(medium)) {
34
+ return;
35
+ }
30
36
  const filename = mediaPath(medium.name);
31
37
  // Node.js: support loading from file
32
38
  if (medium.filename) {
@@ -1,13 +1,13 @@
1
1
  import { Anchor } from "../anchor.js";
2
2
  import { Column } from "../column.js";
3
3
  import { DataValidations } from "../data-validations.js";
4
- import { ExcelStreamStateError, MergeConflictError, RowOutOfBoundsError } from "../errors.js";
4
+ import { ExcelStreamStateError, ImageError, MergeConflictError, RowOutOfBoundsError } from "../errors.js";
5
5
  import { Dimensions } from "../range.js";
6
6
  import { Row } from "../row.js";
7
7
  import { SheetCommentsWriter } from "./sheet-comments-writer.js";
8
8
  import { SheetRelsWriter } from "./sheet-rels-writer.js";
9
9
  import { colCache } from "../utils/col-cache.js";
10
- import { buildDrawingAnchorsAndRels } from "../utils/drawing-utils.js";
10
+ import { buildDrawingAnchorsAndRels, isExternalImage } from "../utils/drawing-utils.js";
11
11
  import { applyMergeBorders, collectMergeBorders } from "../utils/merge-borders.js";
12
12
  import { drawingRelTargetFromWorksheet, mediaRelTargetFromRels, worksheetPath } from "../utils/ooxml-paths.js";
13
13
  import { buildSheetProtection } from "../utils/sheet-protection.js";
@@ -440,6 +440,12 @@ class WorksheetWriter {
440
440
  }
441
441
  // =========================================================================
442
442
  addBackgroundImage(imageId) {
443
+ const bookImage = this._workbook.getImage(Number(imageId));
444
+ if (bookImage && isExternalImage(bookImage)) {
445
+ throw new ImageError("Background images cannot be external (linked) images. " +
446
+ "Use an embedded image (buffer/base64/filename). " +
447
+ "External images are only supported for cell pictures and overlay watermarks.");
448
+ }
443
449
  this._background = {
444
450
  imageId: Number(imageId)
445
451
  };
@@ -469,14 +475,32 @@ class WorksheetWriter {
469
475
  /**
470
476
  * Add a watermark to the worksheet using an image from `WorkbookWriter.addImage()`.
471
477
  * Supports overlay mode (DrawingML with transparency) and header mode (VML behind content).
478
+ *
479
+ * `mode: "overlay"` supports external (linked) images; `mode: "header"` does
480
+ * not — VML header/footer images require embedded media, so a linked image
481
+ * with `mode: "header"` throws an `ImageError`.
482
+ *
483
+ * @throws {ImageError} If `mode: "header"` is used with an external (linked) image.
472
484
  */
473
485
  addWatermark(options) {
486
+ const mode = options.mode ?? "overlay";
487
+ // Validate BEFORE mutating any state: VML header/footer images use
488
+ // embedded media; external (linked) images are not representable here.
489
+ // Reject them up front so a failed call leaves existing watermark media
490
+ // untouched (no partial mutation).
491
+ if (mode === "header") {
492
+ const bookImage = this._workbook.getImage(Number(options.imageId));
493
+ if (bookImage && isExternalImage(bookImage)) {
494
+ throw new ImageError("Header watermark images cannot be external (linked) images. " +
495
+ "Use an embedded image (buffer/base64/filename), or use overlay mode for linked images.");
496
+ }
497
+ }
474
498
  // Remove existing watermark entries (both stored type tags)
475
499
  this._media = this._media.filter(m => m._watermarkTag !== true);
476
500
  const opacity = options.opacity !== undefined ? Math.max(0, Math.min(1, options.opacity)) : 0.15;
477
501
  this._watermark = {
478
502
  imageId: String(options.imageId),
479
- mode: options.mode ?? "overlay",
503
+ mode,
480
504
  opacity,
481
505
  headerWidth: options.headerWidth,
482
506
  headerHeight: options.headerHeight,
@@ -794,6 +818,8 @@ class WorksheetWriter {
794
818
  if (!image) {
795
819
  return;
796
820
  }
821
+ // Background images are always embedded — external (linked) images are
822
+ // rejected up front in addBackgroundImage (Excel drops them).
797
823
  const pictureId = this._sheetRelsWriter.addMedia({
798
824
  Target: mediaRelTargetFromRels(image.name),
799
825
  Type: RelType.Image
@@ -26,6 +26,65 @@ export function resolveMediaTarget(medium) {
26
26
  : `${medium.name}.${medium.extension}`;
27
27
  return mediaRelTargetFromRels(filename);
28
28
  }
29
+ /**
30
+ * Determine whether a media entry is an **external (linked) image** rather than
31
+ * an embedded one. An external image carries a `link` target and supplies no
32
+ * embedded bytes (`buffer`/`base64`/`filename`). Embedding always takes
33
+ * precedence: if any byte source is present the image is embedded even if a
34
+ * `link` was also provided.
35
+ */
36
+ export function isExternalImage(medium) {
37
+ return !!medium.link && medium.buffer == null && medium.base64 == null && medium.filename == null;
38
+ }
39
+ /**
40
+ * Best-effort image extension inference from an external link's path.
41
+ *
42
+ * Normalises to the extension vocabulary used by `ImageData`
43
+ * (`"jpeg" | "png" | "gif"`); unknown extensions fall back to `"png"`.
44
+ * The extension is advisory only for linked images — the relationship
45
+ * Target carries the real reference — but keeping it within the documented
46
+ * set avoids surprising consumers that branch on `medium.extension`.
47
+ */
48
+ export function inferExternalImageExtension(link) {
49
+ const match = /\.([a-zA-Z0-9]{2,5})(?:[?#].*)?$/.exec(link);
50
+ const ext = match ? match[1].toLowerCase() : "";
51
+ switch (ext) {
52
+ case "jpg":
53
+ case "jpeg":
54
+ return "jpeg";
55
+ case "gif":
56
+ return "gif";
57
+ case "png":
58
+ default:
59
+ return "png";
60
+ }
61
+ }
62
+ // =============================================================================
63
+ // Anchor / Rel Building
64
+ // =============================================================================
65
+ /**
66
+ * Build an image relationship for the given rId, choosing between an embedded
67
+ * package target (`../media/imageN.ext`) and an external link target
68
+ * (`TargetMode="External"`) based on whether the image is external.
69
+ *
70
+ * Shared by the drawing, background, and watermark write paths so the
71
+ * embed-vs-link decision lives in exactly one place.
72
+ */
73
+ export function buildImageRel(rId, bookImage) {
74
+ if (isExternalImage(bookImage)) {
75
+ return {
76
+ Id: rId,
77
+ Type: RelType.Image,
78
+ Target: bookImage.link,
79
+ TargetMode: "External"
80
+ };
81
+ }
82
+ return {
83
+ Id: rId,
84
+ Type: RelType.Image,
85
+ Target: resolveMediaTarget(bookImage)
86
+ };
87
+ }
29
88
  /**
30
89
  * Build the drawing anchors and relationships from a list of image media entries.
31
90
  *
@@ -47,20 +106,19 @@ export function buildDrawingAnchorsAndRels(media, existingRels, options) {
47
106
  if (!bookImage) {
48
107
  continue;
49
108
  }
109
+ // An external (linked) image has a `link` target and no embedded bytes.
110
+ const isExternal = isExternalImage(bookImage);
50
111
  // Deduplicate: reuse rId if same imageId already has a drawing rel
51
112
  let rIdImage = imageRIdMap[imageId];
52
113
  if (!rIdImage) {
53
114
  rIdImage = options.nextRId(rels);
54
115
  imageRIdMap[imageId] = rIdImage;
55
- rels.push({
56
- Id: rIdImage,
57
- Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
58
- Target: resolveMediaTarget(bookImage)
59
- });
116
+ rels.push(buildImageRel(rIdImage, bookImage));
60
117
  }
61
118
  const anchor = {
62
119
  picture: {
63
- rId: rIdImage
120
+ rId: rIdImage,
121
+ ...(isExternal ? { external: true } : {})
64
122
  },
65
123
  range: medium.range
66
124
  };
@@ -1697,7 +1697,42 @@ class Workbook {
1697
1697
  // Images
1698
1698
  // ===========================================================================
1699
1699
  /**
1700
- * Add Image to Workbook and return the id
1700
+ * Register an image with the workbook and return its numeric id. Pass the id
1701
+ * to {@link Worksheet.addImage}, {@link Worksheet.addBackgroundImage}, or
1702
+ * {@link Worksheet.addWatermark} to place it.
1703
+ *
1704
+ * The image is either **embedded** or **linked (external)**:
1705
+ *
1706
+ * - **Embedded** — supply `buffer`, `base64`, or `filename`. The bytes are
1707
+ * written into the `.xlsx` package (`xl/media/imageN.ext`). Self-contained,
1708
+ * but inflates file size.
1709
+ * - **Linked (external)** — supply only `link` (a URL or local file path).
1710
+ * No bytes are stored; the package keeps a relationship with
1711
+ * `TargetMode="External"` and the picture is rendered via `<a:blip r:link>`.
1712
+ * Keeps the file small, but the image is resolved by Excel at open time.
1713
+ *
1714
+ * If both bytes and a `link` are provided, **embedding wins**.
1715
+ *
1716
+ * Linked images work with **cell pictures** ({@link Worksheet.addImage}) and
1717
+ * **overlay watermarks** ({@link Worksheet.addWatermark} with `mode:
1718
+ * "overlay"`). Worksheet background images and header/footer (VML) watermarks
1719
+ * cannot be linked — they require an embedded image.
1720
+ *
1721
+ * Note: Excel treats linked images as volatile — a moved/missing target
1722
+ * shows a broken-image placeholder, and modern Excel may not auto-load
1723
+ * remote URLs for security reasons. Prefer embedding for self-contained files.
1724
+ *
1725
+ * @example Embedded image
1726
+ * ```typescript
1727
+ * const id = workbook.addImage({ buffer: pngBytes, extension: "png" });
1728
+ * worksheet.addImage(id, "B2:D6");
1729
+ * ```
1730
+ *
1731
+ * @example Linked (external) image — no bytes stored
1732
+ * ```typescript
1733
+ * const id = workbook.addImage({ extension: "png", link: "https://example.com/logo.png" });
1734
+ * worksheet.addImage(id, "B2:D6");
1735
+ * ```
1701
1736
  */
1702
1737
  addImage(image) {
1703
1738
  const id = this.media.length;
@@ -7,7 +7,7 @@ import { getChartSupport, tryGetChartSupport } from "./chart-host-registry.js";
7
7
  import { Column } from "./column.js";
8
8
  import { DataValidations } from "./data-validations.js";
9
9
  import { Enums } from "./enums.js";
10
- import { MergeConflictError, TableError } from "./errors.js";
10
+ import { ImageError, MergeConflictError, TableError } from "./errors.js";
11
11
  import { FormCheckbox } from "./form-control.js";
12
12
  import { Image } from "./image.js";
13
13
  import { withPivotChartSource } from "./pivot-chart.js";
@@ -20,6 +20,7 @@ import { decodeCell, decodeRange, encodeCol } from "./utils/address.js";
20
20
  import { getCellDisplayText } from "./utils/cell-format.js";
21
21
  import { colCache } from "./utils/col-cache.js";
22
22
  import { copyStyle } from "./utils/copy-style.js";
23
+ import { isExternalImage } from "./utils/drawing-utils.js";
23
24
  import { applyMergeBorders, collectMergeBorders } from "./utils/merge-borders.js";
24
25
  import { buildSheetProtection } from "./utils/sheet-protection.js";
25
26
  import { calculateAutoFitWidth, getMaxDigitWidth, getColumnContentWidthPx, getCellTextWidthPx, getCellHeightPt } from "./utils/text-metrics.js";
@@ -1306,9 +1307,20 @@ class Worksheet {
1306
1307
  return true;
1307
1308
  }
1308
1309
  /**
1309
- * Using the image id from `Workbook.addImage`, set the background to the worksheet
1310
+ * Using the image id from `Workbook.addImage`, set the background to the worksheet.
1311
+ *
1312
+ * The image must be **embedded** (`buffer`/`base64`/`filename`). Worksheet
1313
+ * background pictures (`<picture r:id>`) do not support external (linked)
1314
+ * images — Excel silently drops a background whose relationship uses
1315
+ * `TargetMode="External"`, so this rejects linked images up front.
1310
1316
  */
1311
1317
  addBackgroundImage(imageId) {
1318
+ const bookImage = this._workbook.getImage(imageId);
1319
+ if (bookImage && isExternalImage(bookImage)) {
1320
+ throw new ImageError("Background images cannot be external (linked) images. " +
1321
+ "Use an embedded image (buffer/base64/filename). " +
1322
+ "External images are only supported for cell pictures and overlay watermarks.");
1323
+ }
1312
1324
  const model = {
1313
1325
  type: "background",
1314
1326
  imageId: String(imageId)
@@ -1333,7 +1345,14 @@ class Worksheet {
1333
1345
  * Visible in Page Layout view and when printed. Renders behind cell content.
1334
1346
  * Transparency must be baked into the image (PNG with alpha channel).
1335
1347
  *
1348
+ * **External (linked) images:** `mode: "overlay"` supports external images
1349
+ * (registered via `workbook.addImage({ link })`). `mode: "header"` does
1350
+ * **not** — VML header/footer images require embedded media, so passing a
1351
+ * linked image with `mode: "header"` throws an `ImageError`. Use an embedded
1352
+ * image (`buffer`/`base64`/`filename`) or switch to `mode: "overlay"`.
1353
+ *
1336
1354
  * @param options - Watermark configuration
1355
+ * @throws {ImageError} If `mode: "header"` is used with an external (linked) image.
1337
1356
  *
1338
1357
  * @example Overlay watermark with transparency:
1339
1358
  * ```typescript
@@ -1348,11 +1367,23 @@ class Worksheet {
1348
1367
  * ```
1349
1368
  */
1350
1369
  addWatermark(options) {
1370
+ const mode = options.mode ?? "overlay";
1371
+ // Validate BEFORE mutating any state: VML header/footer images use
1372
+ // embedded media (`<v:imagedata o:relid>`); external (linked) images are
1373
+ // not representable here. Reject them up front so a failed call leaves the
1374
+ // existing watermark untouched (no partial mutation).
1375
+ if (mode === "header") {
1376
+ const bookImage = this._workbook.getImage(options.imageId);
1377
+ if (bookImage && isExternalImage(bookImage)) {
1378
+ throw new ImageError("Header watermark images cannot be external (linked) images. " +
1379
+ "Use an embedded image (buffer/base64/filename), or use overlay mode for linked images.");
1380
+ }
1381
+ }
1351
1382
  // Remove any existing watermark media entries first
1352
1383
  this._media = this._media.filter(m => m.type !== "watermark" && m.type !== "headerImage");
1353
1384
  this._watermark = {
1354
1385
  imageId: String(options.imageId),
1355
- mode: options.mode ?? "overlay",
1386
+ mode,
1356
1387
  opacity: options.opacity,
1357
1388
  headerWidth: options.headerWidth,
1358
1389
  headerHeight: options.headerHeight,
@@ -1368,7 +1399,7 @@ class Worksheet {
1368
1399
  this._media.push(new Image(this, model));
1369
1400
  }
1370
1401
  else {
1371
- // Header mode: add as a "headerImage" media entry for the VML pipeline
1402
+ // Header mode: add as a "headerImage" media entry for the VML pipeline.
1372
1403
  const model = {
1373
1404
  type: "headerImage",
1374
1405
  imageId: String(options.imageId),
@@ -1,3 +1,4 @@
1
+ import { isExternalImage } from "../../../utils/drawing-utils.js";
1
2
  import { OOXML_PATHS, chartsheetPath, commentsPathFromName, ctrlPropPath, drawingPath, externalLinkPath, chartPath, chartUserShapesPath, chartExPath, chartStylePath, chartColorsPath, chartExStylePath, chartExColorsPath, pivotCacheDefinitionPath, pivotCacheRecordsPath, pivotTablePath, tablePath, toContentTypesPartName, worksheetPath } from "../../../utils/ooxml-paths.js";
2
3
  import { BaseXform } from "../base-xform.js";
3
4
  import { StdDocAttributes } from "../../../../xml/writer.js";
@@ -10,6 +11,11 @@ class ContentTypesXform extends BaseXform {
10
11
  const mediaHash = {};
11
12
  (model.media ?? []).forEach((medium) => {
12
13
  if (medium.type === "image") {
14
+ // External (linked) images add no part to the package, so they need
15
+ // no Default content-type registration.
16
+ if (isExternalImage(medium)) {
17
+ return;
18
+ }
13
19
  const imageType = medium.extension;
14
20
  if (!mediaHash[imageType]) {
15
21
  mediaHash[imageType] = true;
@@ -1,3 +1,4 @@
1
+ import { inferExternalImageExtension } from "../../../utils/drawing-utils.js";
1
2
  import { BaseXform } from "../base-xform.js";
2
3
  class BaseCellAnchorXform extends BaseXform {
3
4
  parseOpen(node) {
@@ -34,6 +35,13 @@ class BaseCellAnchorXform extends BaseXform {
34
35
  if (!rel) {
35
36
  return undefined;
36
37
  }
38
+ // External (linked) image: the relationship uses TargetMode="External"
39
+ // and there is no media part in the package. Synthesize a media entry
40
+ // carrying the `link` target so it round-trips and surfaces on the
41
+ // worksheet as an external image. Entries are deduplicated by link.
42
+ if (rel.TargetMode === "External" || model.external) {
43
+ return this.reconcileExternalPicture(rel.Target, options);
44
+ }
37
45
  const match = rel.Target.match(/.*\/media\/(.+[.][a-zA-Z]{3,4})/);
38
46
  if (match) {
39
47
  const name = match[1];
@@ -48,5 +56,29 @@ class BaseCellAnchorXform extends BaseXform {
48
56
  }
49
57
  return undefined;
50
58
  }
59
+ /**
60
+ * Resolve (or create) the media entry for an external linked image. The
61
+ * synthesized entry is appended to `options.media` and indexed by its link
62
+ * so repeated references to the same external image share one entry.
63
+ */
64
+ reconcileExternalPicture(link, options) {
65
+ if (!link) {
66
+ return undefined;
67
+ }
68
+ const indexKey = `external:${link}`;
69
+ let mediaId = options.mediaIndex[indexKey];
70
+ if (mediaId === undefined) {
71
+ mediaId = options.media.length;
72
+ const medium = {
73
+ type: "image",
74
+ extension: inferExternalImageExtension(link),
75
+ link,
76
+ index: mediaId
77
+ };
78
+ options.media.push(medium);
79
+ options.mediaIndex[indexKey] = mediaId;
80
+ }
81
+ return options.media[mediaId];
82
+ }
51
83
  }
52
84
  export { BaseCellAnchorXform };
@@ -8,11 +8,13 @@ class BlipXform extends BaseXform {
8
8
  return "a:blip";
9
9
  }
10
10
  render(xmlStream, model) {
11
+ // External (linked) images use `r:link`; embedded images use `r:embed`.
12
+ const relAttr = model.external ? "r:link" : "r:embed";
11
13
  if (model.alphaModFix !== undefined && model.alphaModFix < 100000) {
12
14
  // Render as open/close node with a:alphaModFix child
13
15
  xmlStream.openNode(this.tag, {
14
16
  "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
15
- "r:embed": model.rId,
17
+ [relAttr]: model.rId,
16
18
  cstate: "print"
17
19
  });
18
20
  xmlStream.leafNode("a:alphaModFix", { amt: String(model.alphaModFix) });
@@ -21,18 +23,24 @@ class BlipXform extends BaseXform {
21
23
  else {
22
24
  xmlStream.leafNode(this.tag, {
23
25
  "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
24
- "r:embed": model.rId,
26
+ [relAttr]: model.rId,
25
27
  cstate: "print"
26
28
  });
27
29
  }
28
30
  }
29
31
  parseOpen(node) {
30
32
  switch (node.name) {
31
- case this.tag:
32
- this.model = {
33
- rId: node.attributes["r:embed"]
34
- };
33
+ case this.tag: {
34
+ // A blip may carry `r:embed` (embedded) or `r:link` (external linked).
35
+ const link = node.attributes["r:link"];
36
+ if (link !== undefined) {
37
+ this.model = { rId: link, external: true };
38
+ }
39
+ else {
40
+ this.model = { rId: node.attributes["r:embed"] };
41
+ }
35
42
  return true;
43
+ }
36
44
  case "a:alphaModFix":
37
45
  if (node.attributes.amt) {
38
46
  this.model.alphaModFix = parseInt(node.attributes.amt, 10);
@@ -24,7 +24,8 @@ class PicXform extends BaseXform {
24
24
  // Pass alphaModFix through to blipFill → blip
25
25
  this.map["xdr:blipFill"].render(xmlStream, {
26
26
  rId: model.rId,
27
- alphaModFix: model.alphaModFix
27
+ alphaModFix: model.alphaModFix,
28
+ external: model.external
28
29
  });
29
30
  this.map["xdr:spPr"].render(xmlStream, model);
30
31
  xmlStream.closeNode();
@@ -1,5 +1,5 @@
1
1
  import { colCache } from "../../../utils/col-cache.js";
2
- import { buildDrawingAnchorsAndRels, resolveMediaTarget } from "../../../utils/drawing-utils.js";
2
+ import { buildDrawingAnchorsAndRels, buildImageRel, isExternalImage, resolveMediaTarget } from "../../../utils/drawing-utils.js";
3
3
  import { chartRelTargetFromDrawing, chartExRelTargetFromDrawing, commentsRelTargetFromWorksheet, ctrlPropRelTargetFromWorksheet, drawingRelTargetFromWorksheet, pivotTableRelTargetFromWorksheet, resolveRelTarget, tableRelTargetFromWorksheet, vmlDrawingRelTargetFromWorksheet, vmlDrawingHFRelTargetFromWorksheet } from "../../../utils/ooxml-paths.js";
4
4
  import { RelType } from "../../rel-type.js";
5
5
  import { BaseXform } from "../base-xform.js";
@@ -407,17 +407,23 @@ class WorkSheetXform extends BaseXform {
407
407
  headerImageMedia.push(medium);
408
408
  }
409
409
  });
410
- // Handle background images
410
+ // Handle background images. Background pictures are always embedded —
411
+ // external (linked) images are rejected in addBackgroundImage because Excel
412
+ // drops a background whose relationship uses TargetMode="External".
411
413
  backgroundMedia.forEach(medium => {
412
- const rId = nextRid(rels);
413
414
  const bookImage = options.media[medium.imageId];
415
+ // Guard against an invalid imageId — same as the image/watermark paths.
416
+ if (!bookImage) {
417
+ return;
418
+ }
419
+ const rId = nextRid(rels);
414
420
  rels.push({
415
421
  Id: rId,
416
422
  Type: RelType.Image,
417
423
  Target: resolveMediaTarget(bookImage)
418
424
  });
419
425
  model.background = { rId };
420
- model.image = options.media[medium.imageId];
426
+ model.image = bookImage;
421
427
  });
422
428
  // Handle embedded images — create drawing model using shared utility
423
429
  if (imageMedia.length > 0) {
@@ -465,12 +471,9 @@ class WorkSheetXform extends BaseXform {
465
471
  if (!bookImage) {
466
472
  continue;
467
473
  }
474
+ const isExternal = isExternalImage(bookImage);
468
475
  const rIdImage = nextRid(drawing.rels);
469
- drawing.rels.push({
470
- Id: rIdImage,
471
- Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
472
- Target: resolveMediaTarget(bookImage)
473
- });
476
+ drawing.rels.push(buildImageRel(rIdImage, bookImage));
474
477
  // Convert opacity (0-1) to OOXML percentage (0-100000), clamped
475
478
  const rawOpacity = medium.opacity !== undefined ? medium.opacity : 0.15;
476
479
  const clampedOpacity = Math.max(0, Math.min(1, rawOpacity));
@@ -483,7 +486,8 @@ class WorkSheetXform extends BaseXform {
483
486
  drawing.anchors.push({
484
487
  picture: {
485
488
  rId: rIdImage,
486
- alphaModFix
489
+ alphaModFix,
490
+ ...(isExternal ? { external: true } : {})
487
491
  },
488
492
  // Cover the full data area with extra margin
489
493
  range: {
@@ -16,7 +16,7 @@ import { StreamingZip, ZipDeflateFile } from "../../archive/zip/stream.js";
16
16
  // erased at runtime; runtime entry points route through `getChartSupport()`.
17
17
  import { getChartSupport } from "../chart-host-registry.js";
18
18
  import { ExcelStreamStateError, ExcelFileError, ImageError, ExcelNotSupportedError, XmlParseError, TableError, ChartOptionsError } from "../errors.js";
19
- import { filterDrawingAnchors } from "../utils/drawing-utils.js";
19
+ import { filterDrawingAnchors, isExternalImage } from "../utils/drawing-utils.js";
20
20
  import { rewriteExternalRefs } from "../utils/external-link-formula.js";
21
21
  import { commentsPath, chartsheetPath, chartsheetRelsPath, getChartsheetNoFromPath, getChartsheetNoFromRelsPath, ctrlPropPath, drawingPath, drawingRelsPath, externalLinkPath, externalLinkRelsPath, externalLinkRelTargetFromWorkbook, OOXML_REL_TARGETS, pivotCacheDefinitionRelTargetFromWorkbook, pivotTablePathFromName, isCommentsPath, chartPath, chartRelsPath, chartStylePath, chartColorsPath, chartExStylePath, chartExColorsPath, chartStyleRelTarget, chartExStyleRelTarget, chartExPath, chartExRelsPath, getChartExNumberFromPath, getChartExNumberFromRelsPath, chartColorsRelTarget, chartExColorsRelTarget, chartRelTargetFromDrawing, chartExRelTargetFromDrawing, chartUserShapesPath, chartUserShapesRelTarget, getChartNumberFromPath, getChartNumberFromRelsPath, getChartStyleNumberFromPath, getChartColorsNumberFromPath, getChartExStyleNumberFromPath, getChartExColorsNumberFromPath, getDrawingNameFromPath, getChartUserShapesNameFromPath, getDrawingNameFromRelsPath, getExternalLinkIndexFromPath, getExternalLinkIndexFromRelsPath, getMediaFilenameFromPath, mediaPath, getPivotCacheDefinitionNameFromPath, getPivotCacheDefinitionNameFromRelsPath, getPivotCacheRecordsNameFromPath, getPivotTableNameFromPath, getPivotTableNameFromRelsPath, pivotCacheDefinitionPath, pivotCacheDefinitionRelsPath, pivotCacheDefinitionRelTargetFromPivotTable, pivotCacheRecordsPath, pivotCacheRecordsRelTarget, pivotTablePath, pivotTableRelsPath, getTableNameFromPath, tablePath, themePath, getThemeNameFromPath, getVmlDrawingNameFromPath, getVmlDrawingHFNameFromPath, getWorksheetNoFromWorksheetPath, getWorksheetNoFromWorksheetRelsPath, isBinaryEntryPath, normalizeZipPath, OOXML_PATHS, resolveRelTarget, vmlDrawingPath, vmlDrawingHFPath, vmlDrawingHFRelsPath, worksheetPath, worksheetRelsPath, worksheetRelTarget } from "../utils/ooxml-paths.js";
22
22
  import { validateXlsxBuffer } from "../utils/ooxml-validator/index.js";
@@ -4284,6 +4284,12 @@ class XLSX {
4284
4284
  if (medium.type !== "image") {
4285
4285
  throw new ImageError("Unsupported media");
4286
4286
  }
4287
+ // External (linked) images carry only a `link` target — no bytes are
4288
+ // written into the package; the relationship (TargetMode="External")
4289
+ // references the image in place.
4290
+ if (isExternalImage(medium)) {
4291
+ return;
4292
+ }
4287
4293
  // Preserve legacy behavior: `${undefined}` becomes "undefined" in template strings
4288
4294
  const mediaName = medium.name ?? "undefined";
4289
4295
  const filename = mediaPath(`${mediaName}.${medium.extension}`);