docgen-utils 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Export docs command - converts HTML to DOCX
3
3
  *
4
- * Handles images in a general way:
5
- * - External <img> tags: fetched via HTTP and embedded
6
- * - SVG charts: rendered to PNG using Playwright's browser canvas
7
- * - Data URLs: passed through directly
4
+ * Uses Playwright to:
5
+ * - Fetch external images via browser canvas (accurate naturalWidth/naturalHeight)
6
+ * - Render SVG charts by screenshotting live DOM elements (preserves CSS context)
8
7
  */
9
8
  export interface ExportDocsOptions {
10
9
  name?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"export-docs.d.ts","sourceRoot":"","sources":["../../../../packages/cli/commands/export-docs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA+YH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCjH"}
1
+ {"version":3,"file":"export-docs.d.ts","sourceRoot":"","sources":["../../../../packages/cli/commands/export-docs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAuTH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDjH"}
@@ -1,338 +1,270 @@
1
1
  /**
2
2
  * Export docs command - converts HTML to DOCX
3
3
  *
4
- * Handles images in a general way:
5
- * - External <img> tags: fetched via HTTP and embedded
6
- * - SVG charts: rendered to PNG using Playwright's browser canvas
7
- * - Data URLs: passed through directly
4
+ * Uses Playwright to:
5
+ * - Fetch external images via browser canvas (accurate naturalWidth/naturalHeight)
6
+ * - Render SVG charts by screenshotting live DOM elements (preserves CSS context)
8
7
  */
9
8
  import * as fs from "node:fs";
10
9
  import * as path from "node:path";
11
10
  import { chromium } from "playwright";
12
11
  import { createDocxBuffer } from "../../docs/create-document";
13
12
  import { parseHtmlContent } from "../../docs/parse";
14
- import { fetchWithProxy } from "./common";
13
+ // Maximum content width in DOCX (6.5 inches at 96 DPI = 624 pixels)
14
+ // docx library ImageRun uses pixels: width * 9525 EMU/px
15
+ const MAX_IMAGE_WIDTH = 624;
15
16
  /**
16
- * Recursively find all image elements from parsed elements.
17
- * Searches top-level elements and also nested structures like blockquotes and two-column layouts.
17
+ * Fetch external images from the HTML page using Playwright browser canvas.
18
+ * Opens the actual HTML file so images load naturally, then extracts pixel data
19
+ * via canvas with accurate naturalWidth/naturalHeight dimensions.
18
20
  */
19
- function findAllImageElements(elements) {
20
- const images = [];
21
- for (const el of elements) {
22
- if (el.type === "image") {
23
- images.push(el);
24
- }
25
- // Recursively search blockquote/callout content
26
- if (el.type === "blockquote" && el.content) {
27
- images.push(...findAllImageElements(el.content));
28
- }
29
- // Recursively search two-column-layout content
30
- if (el.type === "two-column-layout") {
31
- images.push(...findAllImageElements(el.sidebar.content));
32
- images.push(...findAllImageElements(el.main.content));
33
- }
34
- }
35
- return images;
36
- }
37
- /**
38
- * Fetch external images from parsed elements and convert to image data.
39
- * Uses Node.js native fetch API to download images.
40
- * Returns a Map of image src URL to ChartImageData.
41
- */
42
- async function fetchExternalImages(elements) {
21
+ async function fetchExternalImages(browser, htmlPath) {
43
22
  const imageMap = new Map();
44
- // Find all image elements (including nested ones)
45
- const imageElements = findAllImageElements(elements);
46
- // Filter to only external HTTP/HTTPS images (skip data URLs)
47
- const externalImages = imageElements.filter((el) => el.src.startsWith("http://") || el.src.startsWith("https://"));
48
- if (externalImages.length === 0) {
23
+ const absoluteHtmlPath = path.resolve(htmlPath);
24
+ if (!fs.existsSync(absoluteHtmlPath)) {
25
+ return imageMap;
26
+ }
27
+ const page = await browser.newPage({
28
+ viewport: { width: 1200, height: 800 },
29
+ });
30
+ // Navigate to the HTML page to have proper context for relative URLs
31
+ await page.goto(`file://${absoluteHtmlPath}`, {
32
+ waitUntil: "networkidle",
33
+ });
34
+ // Wait for images to load
35
+ await page.waitForTimeout(1000);
36
+ // Find all unique image URLs in the page
37
+ const uniqueSrcs = await page.evaluate(() => {
38
+ const images = Array.from(document.querySelectorAll("img"));
39
+ const srcs = images.map(img => img.src).filter(src => src && !src.startsWith("data:"));
40
+ return [...new Set(srcs)];
41
+ });
42
+ if (uniqueSrcs.length === 0) {
43
+ await page.close();
49
44
  return imageMap;
50
45
  }
51
- console.log(`Fetching ${externalImages.length} external image(s)...`);
52
- // Maximum content width in DOCX (6.5 inches at 96 DPI = 624 pixels, but we use 468 for some margin)
53
- const maxWidth = 468;
54
- for (const imgEl of externalImages) {
46
+ console.log(`Fetching ${uniqueSrcs.length} external image(s)...`);
47
+ for (const src of uniqueSrcs) {
55
48
  try {
56
- const src = imgEl.src;
57
- // Fetch the image
58
- const response = await fetchWithProxy(src);
59
- if (!response.ok) {
60
- console.warn(`Failed to fetch image: ${src} (${response.status})`);
61
- continue;
62
- }
63
- const arrayBuffer = await response.arrayBuffer();
64
- const data = Buffer.from(arrayBuffer);
65
- // Determine image dimensions
66
- // For now, use a reasonable default size that fits the document
67
- // The actual image will be scaled by the DOCX renderer
68
- let width = imgEl.width || 468;
69
- let height = imgEl.height || 300;
70
- // Try to get actual dimensions from the image data
71
- const dimensions = getImageDimensions(data);
72
- if (dimensions) {
73
- width = dimensions.width;
74
- height = dimensions.height;
49
+ // Use Playwright to fetch and render the image via browser canvas
50
+ // This handles CORS and gives accurate naturalWidth/naturalHeight
51
+ const imageData = await page.evaluate(async ({ src, maxWidth }) => {
52
+ return new Promise((resolve) => {
53
+ const img = new Image();
54
+ img.crossOrigin = "anonymous";
55
+ img.onload = () => {
56
+ try {
57
+ // Scale to fit within document width
58
+ let width = img.naturalWidth;
59
+ let height = img.naturalHeight;
60
+ if (width > maxWidth) {
61
+ const scale = maxWidth / width;
62
+ width = maxWidth;
63
+ height = Math.round(height * scale);
64
+ }
65
+ // Draw to canvas and get PNG data
66
+ const canvas = document.createElement("canvas");
67
+ canvas.width = width;
68
+ canvas.height = height;
69
+ const ctx = canvas.getContext("2d");
70
+ if (!ctx) {
71
+ resolve(null);
72
+ return;
73
+ }
74
+ ctx.drawImage(img, 0, 0, width, height);
75
+ // Get image data as base64
76
+ const dataUrl = canvas.toDataURL("image/png");
77
+ const base64 = dataUrl.split(",")[1];
78
+ if (!base64) {
79
+ resolve(null);
80
+ return;
81
+ }
82
+ // Convert base64 to byte array
83
+ const binaryString = atob(base64);
84
+ const bytes = new Array(binaryString.length);
85
+ for (let i = 0; i < binaryString.length; i++) {
86
+ bytes[i] = binaryString.charCodeAt(i);
87
+ }
88
+ resolve({ data: bytes, width, height });
89
+ }
90
+ catch (err) {
91
+ console.warn("Error processing image:", err);
92
+ resolve(null);
93
+ }
94
+ };
95
+ img.onerror = () => {
96
+ console.warn("Failed to load image:", src);
97
+ resolve(null);
98
+ };
99
+ // Set timeout for slow images
100
+ setTimeout(() => {
101
+ console.warn("Image load timeout:", src);
102
+ resolve(null);
103
+ }, 10000);
104
+ img.src = src;
105
+ });
106
+ }, { src, maxWidth: MAX_IMAGE_WIDTH });
107
+ if (imageData) {
108
+ imageMap.set(src, {
109
+ data: Buffer.from(imageData.data),
110
+ width: imageData.width,
111
+ height: imageData.height,
112
+ });
75
113
  }
76
- // Scale to fit within document width
77
- if (width > maxWidth) {
78
- const scale = maxWidth / width;
79
- height = Math.round(height * scale);
80
- width = maxWidth;
81
- }
82
- imageMap.set(src, { data, width, height });
83
114
  }
84
115
  catch (err) {
85
- console.warn(`Error fetching image ${imgEl.src}:`, err);
116
+ console.warn(`Failed to fetch image ${src}:`, err);
86
117
  continue;
87
118
  }
88
119
  }
120
+ await page.close();
89
121
  return imageMap;
90
122
  }
91
123
  /**
92
- * Extract image dimensions from binary data.
93
- * Supports PNG, JPEG, GIF, and WebP formats.
124
+ * Render SVG chart elements to PNG by screenshotting them from the live HTML page.
125
+ *
126
+ * Opens the actual HTML file in Playwright, locates SVG elements using multiple
127
+ * matching criteria (viewBox, gradient/pattern IDs, aria-labels), and screenshots
128
+ * each matched element directly. This preserves full CSS context, fonts, and styling
129
+ * that would be lost when rendering SVG strings in isolation.
94
130
  */
95
- function getImageDimensions(data) {
96
- // PNG: Check for PNG signature and read IHDR chunk
97
- if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) {
98
- // PNG signature found, IHDR chunk starts at byte 8
99
- // Width is at bytes 16-19, Height at bytes 20-23 (big-endian)
100
- const width = data.readUInt32BE(16);
101
- const height = data.readUInt32BE(20);
102
- return { width, height };
131
+ async function renderChartImages(browser, htmlPath) {
132
+ const chartImages = new Map();
133
+ const absoluteHtmlPath = path.resolve(htmlPath);
134
+ if (!fs.existsSync(absoluteHtmlPath)) {
135
+ return chartImages;
103
136
  }
104
- // JPEG: Look for SOF0 marker (0xFF 0xC0) or SOF2 (0xFF 0xC2)
105
- if (data[0] === 0xFF && data[1] === 0xD8) {
106
- let offset = 2;
107
- while (offset < data.length - 8) {
108
- if (data[offset] !== 0xFF) {
109
- offset++;
110
- continue;
137
+ // Parse the HTML using the library to get svg-chart elements
138
+ const htmlContent = fs.readFileSync(absoluteHtmlPath, "utf-8");
139
+ const elements = parseHtmlContent(htmlContent);
140
+ // Get svg-chart elements the library found, including those nested inside blockquotes and layouts
141
+ const svgChartElements = [];
142
+ function collectSvgCharts(els) {
143
+ for (const el of els) {
144
+ if (el.type === "svg-chart") {
145
+ svgChartElements.push(el);
111
146
  }
112
- const marker = data[offset + 1];
113
- // SOF0, SOF1, SOF2 markers contain dimensions
114
- if (marker >= 0xC0 && marker <= 0xC2) {
115
- const height = data.readUInt16BE(offset + 5);
116
- const width = data.readUInt16BE(offset + 7);
117
- return { width, height };
147
+ else if (el.type === "blockquote" && el.content) {
148
+ collectSvgCharts(el.content);
149
+ }
150
+ else if (el.type === "two-column-layout") {
151
+ collectSvgCharts(el.sidebar.content);
152
+ collectSvgCharts(el.main.content);
118
153
  }
119
- // Skip to next marker
120
- const length = data.readUInt16BE(offset + 2);
121
- offset += 2 + length;
122
- }
123
- }
124
- // GIF: Check for GIF87a or GIF89a signature
125
- if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
126
- const width = data.readUInt16LE(6);
127
- const height = data.readUInt16LE(8);
128
- return { width, height };
129
- }
130
- // WebP: Check for RIFF....WEBP signature
131
- if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
132
- data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
133
- // VP8 format (lossy)
134
- if (data[12] === 0x56 && data[13] === 0x50 && data[14] === 0x38 && data[15] === 0x20) {
135
- const width = data.readUInt16LE(26) & 0x3FFF;
136
- const height = data.readUInt16LE(28) & 0x3FFF;
137
- return { width, height };
138
- }
139
- // VP8L format (lossless)
140
- if (data[12] === 0x56 && data[13] === 0x50 && data[14] === 0x38 && data[15] === 0x4C) {
141
- const bits = data.readUInt32LE(21);
142
- const width = (bits & 0x3FFF) + 1;
143
- const height = ((bits >> 14) & 0x3FFF) + 1;
144
- return { width, height };
145
154
  }
146
155
  }
147
- return null;
148
- }
149
- /**
150
- * Render SVG chart elements to PNG images using Playwright browser canvas.
151
- * This allows Node.js CLI to render SVGs the same way the browser export does.
152
- */
153
- async function renderSvgChartsWithPlaywright(elements) {
154
- const chartImages = new Map();
155
- // Find all svg-chart elements
156
- const svgCharts = elements.filter((el) => el.type === "svg-chart");
157
- if (svgCharts.length === 0) {
156
+ collectSvgCharts(elements);
157
+ if (svgChartElements.length === 0) {
158
158
  return chartImages;
159
159
  }
160
- // Launch browser
161
- const launchOptions = { headless: true };
162
- const proxyServer = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
163
- if (proxyServer) {
164
- launchOptions.proxy = { server: proxyServer };
165
- }
166
- const browser = await chromium.launch(launchOptions);
167
- try {
168
- const context = await browser.newContext({
169
- bypassCSP: true,
170
- viewport: { width: 1280, height: 800 },
171
- });
172
- const page = await context.newPage();
173
- // Process each SVG chart
174
- let svgIndex = 0;
175
- for (const chart of svgCharts) {
176
- try {
177
- const imageData = await renderSvgInBrowser(page, chart.svgContent, chart.width, chart.height, chart.backgroundColor);
178
- if (imageData) {
179
- chartImages.set(`svg-chart-${svgIndex}`, imageData);
160
+ console.log(`Rendering ${svgChartElements.length} SVG chart(s)...`);
161
+ const page = await browser.newPage({
162
+ viewport: { width: 1200, height: 800 },
163
+ });
164
+ await page.goto(`file://${absoluteHtmlPath}`, {
165
+ waitUntil: "networkidle",
166
+ });
167
+ // Wait for fonts/styles to settle
168
+ await page.waitForTimeout(500);
169
+ // For each svg-chart element from the library, find the matching SVG in the DOM
170
+ // by extracting unique identifiers from the svgContent
171
+ for (let i = 0; i < svgChartElements.length; i++) {
172
+ const svgChartEl = svgChartElements[i];
173
+ const svgContent = svgChartEl.svgContent;
174
+ // Extract multiple identifiers for robust matching
175
+ const viewBoxMatch = svgContent.match(/viewBox\s*=\s*["']([^"']+)["']/);
176
+ const viewBox = viewBoxMatch ? viewBoxMatch[1] : null;
177
+ // Extract first gradient/pattern ID as a unique identifier
178
+ const gradientIdMatch = svgContent.match(/id\s*=\s*["']([^"']+)["']/);
179
+ const firstId = gradientIdMatch ? gradientIdMatch[1] : null;
180
+ // Extract aria-label as another identifier
181
+ // Use separate patterns for double-quoted and single-quoted values to handle
182
+ // aria-labels that contain the other quote character (e.g., "Korea's cat culture")
183
+ const ariaLabelMatch = svgContent.match(/aria-label\s*=\s*"([^"]+)"/) || svgContent.match(/aria-label\s*=\s*'([^']+)'/);
184
+ const ariaLabel = ariaLabelMatch ? ariaLabelMatch[1] : null;
185
+ // Find the SVG with matching identifiers and mark IT directly (not its parent)
186
+ // This ensures we only screenshot the SVG, not surrounding DOM elements
187
+ const found = await page.evaluate(({ viewBox, firstId, ariaLabel, index }) => {
188
+ const svgs = Array.from(document.querySelectorAll("svg"));
189
+ for (const svg of svgs) {
190
+ // Skip already marked SVGs
191
+ if (svg.hasAttribute("data-svg-chart-index"))
192
+ continue;
193
+ // Match by multiple criteria for robustness
194
+ const svgViewBox = svg.getAttribute("viewBox");
195
+ const svgAriaLabel = svg.getAttribute("aria-label");
196
+ // Check for first ID match (gradient/pattern IDs are often unique per SVG)
197
+ let hasMatchingId = false;
198
+ if (firstId) {
199
+ const idEl = svg.querySelector(`#${CSS.escape(firstId)}`);
200
+ hasMatchingId = !!idEl;
201
+ }
202
+ // Match: viewBox + (firstId OR ariaLabel)
203
+ if (viewBox && svgViewBox === viewBox) {
204
+ if (hasMatchingId || (ariaLabel && svgAriaLabel === ariaLabel) || (!firstId && !ariaLabel)) {
205
+ svg.setAttribute("data-svg-chart-index", String(index));
206
+ return true;
207
+ }
180
208
  }
181
209
  }
182
- catch (err) {
183
- console.warn(`Failed to render SVG chart ${svgIndex}:`, err);
210
+ return false;
211
+ }, { viewBox, firstId, ariaLabel, index: i });
212
+ if (!found) {
213
+ // Fallback: try to match by width/height if viewBox matching failed
214
+ const widthMatch = svgContent.match(/width\s*=\s*["']([^"']+)["']/);
215
+ const heightMatch = svgContent.match(/height\s*=\s*["']([^"']+)["']/);
216
+ const svgWidth = widthMatch ? widthMatch[1] : null;
217
+ const svgHeight = heightMatch ? heightMatch[1] : null;
218
+ if (svgWidth && svgHeight) {
219
+ await page.evaluate(({ w, h, firstId, index }) => {
220
+ const svgs = Array.from(document.querySelectorAll("svg"));
221
+ for (const svg of svgs) {
222
+ if (svg.getAttribute("width") === w && svg.getAttribute("height") === h) {
223
+ if (!svg.hasAttribute("data-svg-chart-index")) {
224
+ // Additional check: if we have firstId, verify it exists in this SVG
225
+ if (firstId) {
226
+ const idEl = svg.querySelector(`#${CSS.escape(firstId)}`);
227
+ if (!idEl)
228
+ continue;
229
+ }
230
+ svg.setAttribute("data-svg-chart-index", String(index));
231
+ return true;
232
+ }
233
+ }
234
+ }
235
+ return false;
236
+ }, { w: svgWidth, h: svgHeight, firstId, index: i });
184
237
  }
185
- svgIndex++;
186
238
  }
187
- await page.close();
188
- await context.close();
189
239
  }
190
- finally {
191
- await browser.close();
192
- }
193
- return chartImages;
194
- }
195
- /**
196
- * Render a single SVG to PNG in the browser using Canvas.
197
- */
198
- async function renderSvgInBrowser(page, svgContent, width, height, backgroundColor) {
199
- // Create HTML page with the SVG rendering logic
200
- const renderPage = `<!DOCTYPE html>
201
- <html>
202
- <head>
203
- <style>
204
- body { margin: 0; padding: 0; }
205
- #svg-container { display: inline-block; }
206
- </style>
207
- </head>
208
- <body>
209
- <div id="svg-container"></div>
210
- <canvas id="canvas"></canvas>
211
- <script>
212
- window.renderSvgToPng = async function(svgContent, width, height, backgroundColor) {
213
- // Parse SVG to get dimensions if not provided
214
- const parser = new DOMParser();
215
- const svgDoc = parser.parseFromString(svgContent, "image/svg+xml");
216
- const svgElement = svgDoc.querySelector("svg");
217
-
218
- if (!svgElement) {
219
- return null;
220
- }
221
-
222
- // Get dimensions from SVG
223
- let svgWidth = width;
224
- let svgHeight = height;
225
-
226
- if (!svgWidth || !svgHeight) {
227
- const viewBox = svgElement.getAttribute("viewBox");
228
- if (viewBox) {
229
- const parts = viewBox.split(/\\s+/).map(Number);
230
- if (parts.length >= 4) {
231
- svgWidth = svgWidth || parts[2];
232
- svgHeight = svgHeight || parts[3];
233
- }
234
- }
235
- if (!svgWidth) {
236
- const widthAttr = svgElement.getAttribute("width");
237
- if (widthAttr && !widthAttr.includes("%")) {
238
- svgWidth = parseFloat(widthAttr);
239
- }
240
+ // Screenshot each marked SVG element directly
241
+ for (let i = 0; i < svgChartElements.length; i++) {
242
+ const svgEl = await page.$(`svg[data-svg-chart-index="${i}"]`);
243
+ if (!svgEl) {
244
+ continue;
240
245
  }
241
- if (!svgHeight) {
242
- const heightAttr = svgElement.getAttribute("height");
243
- if (heightAttr && !heightAttr.includes("%")) {
244
- svgHeight = parseFloat(heightAttr);
245
- }
246
+ // Get bounding box
247
+ const box = await svgEl.boundingBox();
248
+ if (!box) {
249
+ continue;
246
250
  }
247
- }
248
-
249
- // Default dimensions if still not found
250
- svgWidth = svgWidth || 624;
251
- svgHeight = svgHeight || 468;
252
-
253
- // Ensure SVG has explicit dimensions
254
- svgElement.setAttribute("width", String(svgWidth));
255
- svgElement.setAttribute("height", String(svgHeight));
256
-
257
- // Ensure SVG has proper XML namespace
258
- if (!svgElement.getAttribute("xmlns")) {
259
- svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg");
260
- }
261
-
262
- // Serialize the modified SVG
263
- const serializer = new XMLSerializer();
264
- const svgString = serializer.serializeToString(svgElement);
265
-
266
- // Create a blob URL for the SVG
267
- const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
268
- const svgUrl = URL.createObjectURL(svgBlob);
269
-
270
- try {
271
- // Create an image from the SVG
272
- const img = new Image();
273
- await new Promise((resolve, reject) => {
274
- img.onload = resolve;
275
- img.onerror = reject;
276
- img.src = svgUrl;
251
+ // Take screenshot of just this SVG element
252
+ const screenshot = await svgEl.screenshot({
253
+ type: "png",
254
+ });
255
+ // Scale to fit within document content width
256
+ const scale = box.width > MAX_IMAGE_WIDTH ? MAX_IMAGE_WIDTH / box.width : 1;
257
+ const width = Math.round(box.width * scale);
258
+ const height = Math.round(box.height * scale);
259
+ // Use svg-chart-{index} as the key to match the library's indexing
260
+ chartImages.set(`svg-chart-${i}`, {
261
+ data: screenshot,
262
+ width,
263
+ height,
277
264
  });
278
-
279
- // Create canvas
280
- const scale = window.devicePixelRatio || 1;
281
- const canvas = document.getElementById("canvas");
282
- canvas.width = svgWidth * scale;
283
- canvas.height = svgHeight * scale;
284
-
285
- const ctx = canvas.getContext("2d");
286
- if (!ctx) return null;
287
-
288
- // Fill with background color
289
- ctx.fillStyle = backgroundColor ? "#" + backgroundColor : "white";
290
- ctx.fillRect(0, 0, canvas.width, canvas.height);
291
-
292
- // Scale for device pixel ratio
293
- ctx.scale(scale, scale);
294
-
295
- // Draw the SVG
296
- ctx.drawImage(img, 0, 0, svgWidth, svgHeight);
297
-
298
- // Convert to PNG data URL
299
- const dataUrl = canvas.toDataURL("image/png");
300
-
301
- // Scale dimensions to fit within DOCX content width
302
- const maxWidth = 624;
303
- const aspectRatio = svgHeight / svgWidth;
304
- const finalWidth = Math.min(svgWidth, maxWidth);
305
- const finalHeight = finalWidth * aspectRatio;
306
-
307
- return {
308
- dataUrl,
309
- width: Math.round(finalWidth),
310
- height: Math.round(finalHeight),
311
- };
312
- } finally {
313
- URL.revokeObjectURL(svgUrl);
314
- }
315
- };
316
- </script>
317
- </body>
318
- </html>`;
319
- await page.setContent(renderPage, { waitUntil: "domcontentloaded" });
320
- // Call the rendering function in the browser
321
- const result = await page.evaluate(async ({ svgContent, width, height, backgroundColor }) => {
322
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
323
- return window.renderSvgToPng(svgContent, width, height, backgroundColor);
324
- }, { svgContent, width, height, backgroundColor });
325
- if (!result || !result.dataUrl) {
326
- return null;
327
265
  }
328
- // Convert data URL to Buffer
329
- const base64Data = result.dataUrl.replace(/^data:image\/png;base64,/, "");
330
- const data = Buffer.from(base64Data, "base64");
331
- return {
332
- data,
333
- width: result.width,
334
- height: result.height,
335
- };
266
+ await page.close();
267
+ return chartImages;
336
268
  }
337
269
  export async function exportDocs(filePath, outDir, options = {}) {
338
270
  const { name: outputName, pageless } = options;
@@ -345,12 +277,26 @@ export async function exportDocs(filePath, outDir, options = {}) {
345
277
  const html = fs.readFileSync(absolutePath, "utf-8");
346
278
  // Get filename for title - use provided name or derive from input file
347
279
  const baseName = outputName || path.basename(filePath, ".html");
348
- // Parse HTML to get elements
280
+ // Launch browser for image fetching and SVG rendering
281
+ const launchOptions = { headless: true };
282
+ const proxyServer = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
283
+ if (proxyServer) {
284
+ launchOptions.proxy = { server: proxyServer };
285
+ }
286
+ const browser = await chromium.launch(launchOptions);
287
+ let imageMap;
288
+ let chartImages;
289
+ try {
290
+ // Fetch external images using browser canvas (accurate dimensions via naturalWidth/naturalHeight)
291
+ imageMap = await fetchExternalImages(browser, absolutePath);
292
+ // Render SVG charts by screenshotting live DOM elements (preserves CSS context)
293
+ chartImages = await renderChartImages(browser, absolutePath);
294
+ }
295
+ finally {
296
+ await browser.close();
297
+ }
298
+ // Parse HTML to get elements (pass pre-parsed to avoid double-parsing in createDocxBuffer)
349
299
  const elements = parseHtmlContent(html);
350
- // Fetch external images (for <img> tags with HTTP URLs)
351
- const imageMap = await fetchExternalImages(elements);
352
- // Render SVG charts to PNG using Playwright browser canvas
353
- const chartImages = await renderSvgChartsWithPlaywright(elements);
354
300
  // Create DOCX buffer with pre-parsed elements, external images, and rendered charts
355
301
  const buffer = await createDocxBuffer(html, {
356
302
  title: baseName,