@signiphi/pdf-compose 0.1.0-beta.6 → 0.1.0-beta.8

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/dist/index.mjs CHANGED
@@ -574,6 +574,11 @@ function replaceVariablesInContent(content, values) {
574
574
  }
575
575
  return textNode;
576
576
  }
577
+ if (node.type === "imageNode" && node.attrs?.varName) {
578
+ const varName = node.attrs.varName;
579
+ const resolved = values[varName] || node.attrs.src || "";
580
+ return { ...node, attrs: { ...node.attrs, src: resolved } };
581
+ }
577
582
  if (node.content) {
578
583
  return { ...node, content: node.content.map(walkNode) };
579
584
  }
@@ -702,7 +707,8 @@ function expandRepeatContent(content, values) {
702
707
  values: enrichedValues
703
708
  };
704
709
  }
705
- var TOKEN_REGEX = /\{\{(field|var)\|([^}]+)\}\}/g;
710
+ var TOKEN_REGEX = /\{\{(field|var|image)\|([^}]+)\}\}/g;
711
+ var BLOCK_IMAGE_REGEX = /^!\[([^\]]*)\]\(([^)\s]+)\)$/;
706
712
  var DIRECTIVE_OPEN = /^:::(panel|columns|col|watermark|repeat|subtotals)(?:\{([^}]*)\})?$/;
707
713
  var DIRECTIVE_CLOSE = /^:::$/;
708
714
  function parseDirectiveAttrs(str) {
@@ -847,6 +853,44 @@ function parseVariableToken(tokenBody) {
847
853
  }
848
854
  return attrs;
849
855
  }
856
+ function parseImageToken(tokenBody) {
857
+ const attrs = {
858
+ src: "",
859
+ varName: "",
860
+ alt: "",
861
+ width: "",
862
+ height: "",
863
+ align: ""
864
+ };
865
+ const pairs = tokenBody.split("|");
866
+ for (const pair of pairs) {
867
+ const colonIdx = pair.indexOf(":");
868
+ if (colonIdx === -1) continue;
869
+ const key = pair.slice(0, colonIdx).trim();
870
+ const value = pair.slice(colonIdx + 1).trim();
871
+ switch (key) {
872
+ case "src":
873
+ attrs.src = value;
874
+ break;
875
+ case "var":
876
+ attrs.varName = value;
877
+ break;
878
+ case "alt":
879
+ attrs.alt = value;
880
+ break;
881
+ case "width":
882
+ attrs.width = value;
883
+ break;
884
+ case "height":
885
+ attrs.height = value;
886
+ break;
887
+ case "align":
888
+ attrs.align = value;
889
+ break;
890
+ }
891
+ }
892
+ return attrs;
893
+ }
850
894
  function parseInline(text) {
851
895
  text = preprocessMarkedTokens(text);
852
896
  const nodes = [];
@@ -870,6 +914,8 @@ function parseInline(text) {
870
914
  const node = { type: "variableNode", attrs };
871
915
  if (marks.length > 0) node.marks = marks;
872
916
  nodes.push(node);
917
+ } else if (match[1] === "image") {
918
+ nodes.push({ type: "imageNode", attrs: parseImageToken(match[2]) });
873
919
  }
874
920
  lastIndex = match.index + match[0].length;
875
921
  }
@@ -1183,10 +1229,24 @@ function parseBlocks(lines, startIdx, stopCondition) {
1183
1229
  i++;
1184
1230
  continue;
1185
1231
  }
1186
- content.push({
1187
- type: "paragraph",
1188
- content: parseInline(line)
1189
- });
1232
+ const imageMatch = line.trim().match(BLOCK_IMAGE_REGEX);
1233
+ if (imageMatch) {
1234
+ content.push({
1235
+ type: "imageNode",
1236
+ attrs: { src: imageMatch[2], varName: "", alt: imageMatch[1], width: "", height: "", align: "" }
1237
+ });
1238
+ i++;
1239
+ continue;
1240
+ }
1241
+ const inlineNodes = parseInline(line);
1242
+ const nonWhitespace = inlineNodes.filter(
1243
+ (n) => !(n.type === "text" && !(n.text || "").trim())
1244
+ );
1245
+ if (nonWhitespace.length === 1 && nonWhitespace[0].type === "imageNode") {
1246
+ content.push(nonWhitespace[0]);
1247
+ } else {
1248
+ content.push({ type: "paragraph", content: inlineNodes });
1249
+ }
1190
1250
  i++;
1191
1251
  }
1192
1252
  return { blocks: content, nextIdx: i };
@@ -1337,6 +1397,34 @@ var CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
1337
1397
  var CONTENT_HEIGHT = PAGE_HEIGHT - 2 * MARGIN;
1338
1398
  var LINE_HEIGHT_FACTOR = 1.4;
1339
1399
  var BODY_FONT_SIZE = 10;
1400
+ function hexToRgbColor(hex) {
1401
+ if (!hex) return null;
1402
+ const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
1403
+ if (!m) return null;
1404
+ const n = parseInt(m[1], 16);
1405
+ return rgb((n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255);
1406
+ }
1407
+ function contrastTextColor(fill) {
1408
+ const c = fill;
1409
+ const luminance = 0.299 * c.red + 0.587 * c.green + 0.114 * c.blue;
1410
+ return luminance > 0.6 ? rgb(0, 0, 0) : rgb(1, 1, 1);
1411
+ }
1412
+ function resolveTheme(theme) {
1413
+ const primary = hexToRgbColor(theme?.primaryColor);
1414
+ const accent = hexToRgbColor(theme?.accentColor);
1415
+ const border = hexToRgbColor(theme?.borderColor);
1416
+ return {
1417
+ tableHeaderBg: primary ?? rgb(0.3, 0.3, 0.3),
1418
+ tableHeaderText: primary ? contrastTextColor(primary) : rgb(1, 1, 1),
1419
+ tableBorder: border ?? rgb(0.75, 0.75, 0.75),
1420
+ panelDarkHeaderBg: primary ?? rgb(0.25, 0.25, 0.25),
1421
+ panelDarkHeaderText: primary ? contrastTextColor(primary) : rgb(1, 1, 1),
1422
+ panelBorder: border ?? rgb(0.6, 0.6, 0.6),
1423
+ rule: primary ?? rgb(0, 0, 0),
1424
+ pageHeaderText: primary ?? rgb(0, 0, 0),
1425
+ pageHeaderRule: accent ?? primary ?? rgb(0.75, 0.75, 0.75)
1426
+ };
1427
+ }
1340
1428
  var DEFAULT_REGION = { leftX: MARGIN, width: CONTENT_WIDTH };
1341
1429
  var FIELD_DISPLAY = {
1342
1430
  ["text" /* TEXT */]: { label: "Text", width: 120, height: 30 },
@@ -1368,6 +1456,53 @@ function getFieldDimensions(fieldType, attrs, font, fontSize) {
1368
1456
  const height = textLineHeight + 4;
1369
1457
  return { width, height };
1370
1458
  }
1459
+ async function loadImageBytes(src) {
1460
+ if (src.startsWith("data:")) {
1461
+ const comma = src.indexOf(",");
1462
+ if (comma === -1) throw new Error("malformed data URL");
1463
+ const meta = src.slice(0, comma);
1464
+ if (!/;base64$/i.test(meta)) throw new Error("data URL must be base64-encoded");
1465
+ const bin = atob(src.slice(comma + 1));
1466
+ const bytes = new Uint8Array(bin.length);
1467
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1468
+ return bytes;
1469
+ }
1470
+ const res = await fetch(src);
1471
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1472
+ return new Uint8Array(await res.arrayBuffer());
1473
+ }
1474
+ function describeImageSrc(src) {
1475
+ return src.length > 80 ? `${src.slice(0, 77)}...` : src;
1476
+ }
1477
+ async function preloadImages(pdfDoc, content) {
1478
+ const srcs = /* @__PURE__ */ new Set();
1479
+ (function walk(node) {
1480
+ if (node.type === "imageNode") {
1481
+ const src = node.attrs?.src || "";
1482
+ if (src) srcs.add(src);
1483
+ }
1484
+ for (const child of node.content || []) walk(child);
1485
+ })(content);
1486
+ const images = /* @__PURE__ */ new Map();
1487
+ const warnings = [];
1488
+ for (const src of srcs) {
1489
+ try {
1490
+ const bytes = await loadImageBytes(src);
1491
+ const isPng = bytes.length > 4 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
1492
+ const isJpeg = bytes.length > 2 && bytes[0] === 255 && bytes[1] === 216;
1493
+ if (isPng) {
1494
+ images.set(src, await pdfDoc.embedPng(bytes));
1495
+ } else if (isJpeg) {
1496
+ images.set(src, await pdfDoc.embedJpg(bytes));
1497
+ } else {
1498
+ warnings.push(`Image skipped (unsupported format \u2014 only PNG/JPEG embed): ${describeImageSrc(src)}`);
1499
+ }
1500
+ } catch (err) {
1501
+ warnings.push(`Image skipped (${getErrorMessage(err)}): ${describeImageSrc(src)}`);
1502
+ }
1503
+ }
1504
+ return { images, warnings };
1505
+ }
1371
1506
  function ensureSpace(state, neededHeight) {
1372
1507
  if (state.y + neededHeight > CONTENT_HEIGHT) {
1373
1508
  const newPage = state.pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
@@ -1715,9 +1850,9 @@ function renderTable(state, node, region = DEFAULT_REGION) {
1715
1850
  const cellPadding = borderless ? 2 : 4;
1716
1851
  const lineHeight = fontSize * LINE_HEIGHT_FACTOR;
1717
1852
  const rowPadding = cellPadding * 2;
1718
- const borderColor = rgb(0.75, 0.75, 0.75);
1719
- const headerBg = rgb(0.3, 0.3, 0.3);
1720
- const headerTextColor = rgb(1, 1, 1);
1853
+ const borderColor = state.theme.tableBorder;
1854
+ const headerBg = state.theme.tableHeaderBg;
1855
+ const headerTextColor = state.theme.tableHeaderText;
1721
1856
  const firstRow = tableRows[0];
1722
1857
  const colCount = firstRow.content?.length || 0;
1723
1858
  if (colCount === 0) return;
@@ -1965,7 +2100,7 @@ function processBlock(state, node, region = DEFAULT_REGION) {
1965
2100
  start: { x: region.leftX, y: ruleY },
1966
2101
  end: { x: region.leftX + region.width, y: ruleY },
1967
2102
  thickness: 2.5,
1968
- color: rgb(0, 0, 0)
2103
+ color: state.theme.rule
1969
2104
  });
1970
2105
  state.y += 4;
1971
2106
  break;
@@ -1998,6 +2133,39 @@ function processBlock(state, node, region = DEFAULT_REGION) {
1998
2133
  renderTable(state, node, region);
1999
2134
  break;
2000
2135
  }
2136
+ case "imageNode": {
2137
+ const src = node.attrs?.src || "";
2138
+ const image = src ? state.images.get(src) : void 0;
2139
+ if (!image) break;
2140
+ const natW = image.width;
2141
+ const natH = image.height;
2142
+ let w = parseFloat(node.attrs?.width || "") || 0;
2143
+ let h = parseFloat(node.attrs?.height || "") || 0;
2144
+ if (w && !h) h = natH / natW * w;
2145
+ else if (h && !w) w = natW / natH * h;
2146
+ else if (!w && !h) {
2147
+ w = natW;
2148
+ h = natH;
2149
+ }
2150
+ if (w > region.width) {
2151
+ h = h * (region.width / w);
2152
+ w = region.width;
2153
+ }
2154
+ const maxH = CONTENT_HEIGHT * 0.6;
2155
+ if (h > maxH) {
2156
+ w = w * (maxH / h);
2157
+ h = maxH;
2158
+ }
2159
+ ensureSpace(state, h + 4);
2160
+ const align = node.attrs?.align || "left";
2161
+ let x = region.leftX;
2162
+ if (align === "center") x = region.leftX + (region.width - w) / 2;
2163
+ else if (align === "right") x = region.leftX + region.width - w;
2164
+ const pdfY = PAGE_HEIGHT - MARGIN - state.y - h;
2165
+ state.currentPage.drawImage(image, { x, y: pdfY, width: w, height: h });
2166
+ state.y += h + 4;
2167
+ break;
2168
+ }
2001
2169
  // ─── Layout directives ─────────────────────────────────────────
2002
2170
  case "panel": {
2003
2171
  const title = node.attrs?.title || "";
@@ -2034,8 +2202,8 @@ function processBlock(state, node, region = DEFAULT_REGION) {
2034
2202
  y: titleBarY,
2035
2203
  width: region.width,
2036
2204
  height: titleHeight,
2037
- color: isDarkHeader ? rgb(0.25, 0.25, 0.25) : rgb(0.93, 0.93, 0.93),
2038
- borderColor: rgb(0.6, 0.6, 0.6),
2205
+ color: isDarkHeader ? state.theme.panelDarkHeaderBg : rgb(0.93, 0.93, 0.93),
2206
+ borderColor: state.theme.panelBorder,
2039
2207
  borderWidth: 0.75
2040
2208
  });
2041
2209
  startPage.drawText(title, {
@@ -2043,7 +2211,7 @@ function processBlock(state, node, region = DEFAULT_REGION) {
2043
2211
  y: titleBarY + 4,
2044
2212
  size: titleFontSize,
2045
2213
  font: state.fonts.bold,
2046
- color: isDarkHeader ? rgb(1, 1, 1) : rgb(0, 0, 0)
2214
+ color: isDarkHeader ? state.theme.panelDarkHeaderText : rgb(0, 0, 0)
2047
2215
  });
2048
2216
  }
2049
2217
  if (border !== "none") {
@@ -2056,7 +2224,7 @@ function processBlock(state, node, region = DEFAULT_REGION) {
2056
2224
  y: borderY,
2057
2225
  width: region.width,
2058
2226
  height: panelHeight,
2059
- borderColor: rgb(0.6, 0.6, 0.6),
2227
+ borderColor: state.theme.panelBorder,
2060
2228
  borderWidth: 0.75,
2061
2229
  borderDashArray
2062
2230
  });
@@ -2068,7 +2236,7 @@ function processBlock(state, node, region = DEFAULT_REGION) {
2068
2236
  y: startBorderBottom,
2069
2237
  width: region.width,
2070
2238
  height: startPanelHeight,
2071
- borderColor: rgb(0.6, 0.6, 0.6),
2239
+ borderColor: state.theme.panelBorder,
2072
2240
  borderWidth: 0.75,
2073
2241
  borderDashArray
2074
2242
  });
@@ -2080,7 +2248,7 @@ function processBlock(state, node, region = DEFAULT_REGION) {
2080
2248
  y: contBorderY,
2081
2249
  width: region.width,
2082
2250
  height: contHeight,
2083
- borderColor: rgb(0.6, 0.6, 0.6),
2251
+ borderColor: state.theme.panelBorder,
2084
2252
  borderWidth: 0.75,
2085
2253
  borderDashArray
2086
2254
  });
@@ -2384,6 +2552,24 @@ async function addFormFields(pdfDoc, fieldPositions, fields) {
2384
2552
  PDFString$1.of(field.label)
2385
2553
  );
2386
2554
  }
2555
+ const initialsFontSize = Math.min(
2556
+ 72,
2557
+ Math.max(8, Math.round(position.height * 0.55))
2558
+ );
2559
+ try {
2560
+ initialsField.setFontSize(initialsFontSize);
2561
+ const acro = initialsField;
2562
+ const daRef = acro.acroField.dict.get(PDFName$1.of("DA"));
2563
+ if (daRef) {
2564
+ const daStr = daRef.value;
2565
+ if (typeof daStr === "string") {
2566
+ const newDa = daStr.replace(/\/Helv\b/, "/TiBo").replace(/\/HeBo\b/, "/TiBo");
2567
+ acro.acroField.dict.set(PDFName$1.of("DA"), PDFString$1.of(newDa));
2568
+ }
2569
+ }
2570
+ } catch (fontError) {
2571
+ console.warn(`Failed to set initials font:`, fontError);
2572
+ }
2387
2573
  initialsField.enableReadOnly();
2388
2574
  if (field.required) initialsField.enableRequired();
2389
2575
  break;
@@ -2494,7 +2680,8 @@ async function addFormFields(pdfDoc, fieldPositions, fields) {
2494
2680
  return warnings;
2495
2681
  }
2496
2682
  async function generatePdfFromContent(content, options = {}) {
2497
- const { drawFieldPlaceholders = false, embedFormFields = false, fields = [] } = options;
2683
+ const { drawFieldPlaceholders = false, embedFormFields = false, fields = [], header, footer } = options;
2684
+ const theme = resolveTheme(options.theme);
2498
2685
  const pdfDoc = await PDFDocument.create();
2499
2686
  const firstPage = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
2500
2687
  const fonts = {
@@ -2504,6 +2691,7 @@ async function generatePdfFromContent(content, options = {}) {
2504
2691
  boldItalic: await pdfDoc.embedFont(StandardFonts.HelveticaBoldOblique)
2505
2692
  };
2506
2693
  const watermarkNodes = collectWatermarkNodes(content);
2694
+ const { images, warnings: imageWarnings } = await preloadImages(pdfDoc, content);
2507
2695
  const state = {
2508
2696
  currentPage: firstPage,
2509
2697
  pageIndex: 0,
@@ -2512,7 +2700,9 @@ async function generatePdfFromContent(content, options = {}) {
2512
2700
  pdfDoc,
2513
2701
  fieldPositions: /* @__PURE__ */ new Map(),
2514
2702
  drawFieldPlaceholders,
2515
- watermarkNodes
2703
+ watermarkNodes,
2704
+ theme,
2705
+ images
2516
2706
  };
2517
2707
  renderWatermarksOnPage(firstPage, fonts, watermarkNodes);
2518
2708
  if (content.content) {
@@ -2523,6 +2713,8 @@ async function generatePdfFromContent(content, options = {}) {
2523
2713
  const allPages = pdfDoc.getPages();
2524
2714
  const totalPages = allPages.length;
2525
2715
  const pageNumFontSize = 9;
2716
+ const hfFontSize = 8;
2717
+ const hfColor = rgb(0.45, 0.45, 0.45);
2526
2718
  for (let i = 0; i < totalPages; i++) {
2527
2719
  const page = allPages[i];
2528
2720
  const label = `Page ${i + 1} of ${totalPages}`;
@@ -2534,35 +2726,77 @@ async function generatePdfFromContent(content, options = {}) {
2534
2726
  font: fonts.bold,
2535
2727
  color: rgb(0, 0, 0)
2536
2728
  });
2729
+ if (header && (header.left || header.right) && !(header.skipFirstPage && i === 0)) {
2730
+ const headerBaseY = PAGE_HEIGHT - MARGIN + 10;
2731
+ if (header.left) {
2732
+ page.drawText(header.left, {
2733
+ x: MARGIN,
2734
+ y: headerBaseY,
2735
+ size: pageNumFontSize,
2736
+ font: fonts.bold,
2737
+ color: theme.pageHeaderText
2738
+ });
2739
+ }
2740
+ if (header.right) {
2741
+ const rightWidth = fonts.regular.widthOfTextAtSize(header.right, hfFontSize);
2742
+ page.drawText(header.right, {
2743
+ x: PAGE_WIDTH - MARGIN - rightWidth,
2744
+ y: headerBaseY,
2745
+ size: hfFontSize,
2746
+ font: fonts.regular,
2747
+ color: hfColor
2748
+ });
2749
+ }
2750
+ page.drawLine({
2751
+ start: { x: MARGIN, y: headerBaseY - 6 },
2752
+ end: { x: PAGE_WIDTH - MARGIN, y: headerBaseY - 6 },
2753
+ thickness: 0.75,
2754
+ color: theme.pageHeaderRule
2755
+ });
2756
+ }
2757
+ if (footer?.left) {
2758
+ page.drawText(footer.left, {
2759
+ x: MARGIN,
2760
+ y: MARGIN / 2 - pageNumFontSize / 2,
2761
+ size: hfFontSize,
2762
+ font: fonts.regular,
2763
+ color: hfColor
2764
+ });
2765
+ }
2537
2766
  }
2538
2767
  let fieldWarnings;
2539
2768
  if (embedFormFields && fields.length > 0) {
2540
2769
  const warnings = await addFormFields(pdfDoc, state.fieldPositions, fields);
2541
2770
  if (warnings.length > 0) fieldWarnings = warnings;
2542
2771
  }
2543
- const pdfBytes = await pdfDoc.save({ useObjectStreams: false });
2772
+ const pdfBytes = new Uint8Array(await pdfDoc.save({ useObjectStreams: false }));
2544
2773
  return {
2545
2774
  pdfBytes,
2546
2775
  fieldPositions: state.fieldPositions,
2547
- fieldWarnings
2776
+ fieldWarnings,
2777
+ imageWarnings: imageWarnings.length > 0 ? imageWarnings : void 0
2548
2778
  };
2549
2779
  }
2550
2780
 
2551
2781
  // src/utils/template-pipeline.ts
2552
- async function generatePdfFromTiptap(content, values) {
2782
+ async function generatePdfFromTiptap(content, values, options = {}) {
2553
2783
  const { content: expanded, values: enrichedValues } = expandRepeatContent(content, values);
2554
2784
  const suppressed = suppressZeroContent(expanded, enrichedValues);
2555
2785
  const replaced = replaceVariablesInContent(suppressed, enrichedValues);
2556
2786
  const fields = extractFieldsFromContent(replaced);
2557
2787
  const result = await generatePdfFromContent(replaced, {
2558
2788
  embedFormFields: fields.length > 0,
2559
- fields
2789
+ fields,
2790
+ theme: options.theme,
2791
+ header: options.header,
2792
+ footer: options.footer,
2793
+ drawFieldPlaceholders: options.drawFieldPlaceholders
2560
2794
  });
2561
- return { pdfBytes: result.pdfBytes };
2795
+ return { pdfBytes: result.pdfBytes, imageWarnings: result.imageWarnings };
2562
2796
  }
2563
- async function generatePdfFromMarkdown(markdown, values) {
2797
+ async function generatePdfFromMarkdown(markdown, values, options = {}) {
2564
2798
  const content = markdownToTiptap(markdown);
2565
- return generatePdfFromTiptap(content, values);
2799
+ return generatePdfFromTiptap(content, values, options);
2566
2800
  }
2567
2801
 
2568
2802
  // src/utils/markdown-writer.ts
@@ -2582,6 +2816,20 @@ function serializeFieldToken(attrs) {
2582
2816
  if (attrs.acknowledgements) parts.push(`acks:${btoa(attrs.acknowledgements)}`);
2583
2817
  return `{{${parts.join("|")}}}`;
2584
2818
  }
2819
+ function serializeImageNode(attrs) {
2820
+ const hasExtras = attrs.varName || attrs.width || attrs.height || attrs.align;
2821
+ if (!hasExtras && attrs.src) {
2822
+ return `![${attrs.alt || ""}](${attrs.src})`;
2823
+ }
2824
+ const parts = ["image"];
2825
+ if (attrs.varName) parts.push(`var:${attrs.varName}`);
2826
+ else if (attrs.src) parts.push(`src:${attrs.src}`);
2827
+ if (attrs.alt) parts.push(`alt:${attrs.alt}`);
2828
+ if (attrs.width) parts.push(`width:${attrs.width}`);
2829
+ if (attrs.height) parts.push(`height:${attrs.height}`);
2830
+ if (attrs.align) parts.push(`align:${attrs.align}`);
2831
+ return `{{${parts.join("|")}}}`;
2832
+ }
2585
2833
  function serializeVariableToken(attrs) {
2586
2834
  const parts = ["var"];
2587
2835
  if (attrs.varName) parts.push(`name:${attrs.varName}`);
@@ -2599,6 +2847,10 @@ function serializeInline(content) {
2599
2847
  result += serializeFieldToken(node.attrs || {});
2600
2848
  continue;
2601
2849
  }
2850
+ if (node.type === "imageNode") {
2851
+ result += serializeImageNode(node.attrs || {});
2852
+ continue;
2853
+ }
2602
2854
  if (node.type === "variableNode") {
2603
2855
  let token = serializeVariableToken(node.attrs || {});
2604
2856
  const varMarks = node.marks || [];
@@ -2705,6 +2957,9 @@ ${text}
2705
2957
  case "tableCell": {
2706
2958
  return (node.content || []).map(serializeBlock).join("");
2707
2959
  }
2960
+ case "imageNode": {
2961
+ return serializeImageNode(node.attrs || {});
2962
+ }
2708
2963
  case "watermark": {
2709
2964
  const attrs = node.attrs || {};
2710
2965
  const parts = [];
@@ -4768,8 +5023,8 @@ function validateMarkdown(markdown) {
4768
5023
  errors.push(`Malformed token at line ${lineNum}: missing | delimiter`);
4769
5024
  } else {
4770
5025
  const tokenType = tokenContent.slice(0, pipeIdx);
4771
- if (tokenType !== "field" && tokenType !== "var") {
4772
- errors.push(`Unknown token type "${tokenType}" at line ${lineNum} (expected "field" or "var")`);
5026
+ if (tokenType !== "field" && tokenType !== "var" && tokenType !== "image") {
5027
+ errors.push(`Unknown token type "${tokenType}" at line ${lineNum} (expected "field", "var" or "image")`);
4773
5028
  } else {
4774
5029
  const body = tokenContent.slice(pipeIdx + 1);
4775
5030
  const pairs = body.split("|");
@@ -4783,6 +5038,11 @@ function validateMarkdown(markdown) {
4783
5038
  if (!hasName) {
4784
5039
  errors.push(`Variable token at line ${lineNum} is missing required "name" attribute`);
4785
5040
  }
5041
+ } else if (tokenType === "image") {
5042
+ const hasSource = pairs.some((p) => p.startsWith("src:") || p.startsWith("var:"));
5043
+ if (!hasSource) {
5044
+ errors.push(`Image token at line ${lineNum} needs a "src" or "var" attribute`);
5045
+ }
4786
5046
  }
4787
5047
  }
4788
5048
  }