docgen-utils 1.0.11 → 1.0.12

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 +1 @@
1
- {"version":3,"file":"import-docx.d.ts","sourceRoot":"","sources":["../../../packages/docs/import-docx.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAu6EH;;;;GAIG;AACH,wBAA8B,UAAU,CACtC,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,MAAM,CAAC,CAoMjB"}
1
+ {"version":3,"file":"import-docx.d.ts","sourceRoot":"","sources":["../../../packages/docs/import-docx.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA43FH;;;;GAIG;AACH,wBAA8B,UAAU,CACtC,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,MAAM,CAAC,CA+MjB"}
@@ -5,6 +5,8 @@
5
5
  * Usage: const html = await importDocx(arrayBuffer);
6
6
  */
7
7
  import JSZip from "jszip";
8
+ import { EMFJS } from "rtf.js";
9
+ import { parseHTML } from "linkedom";
8
10
  // ============================================================================
9
11
  // Constants
10
12
  // ============================================================================
@@ -133,6 +135,332 @@ function getBorderStyleCss(style) {
133
135
  return styleMap[style] ?? "solid";
134
136
  }
135
137
  // ============================================================================
138
+ // EMF to SVG Conversion
139
+ // ============================================================================
140
+ // Setup DOM environment for EMFJS (required for SVG creation)
141
+ let emfDomSetup = false;
142
+ function setupEmfDom() {
143
+ if (emfDomSetup)
144
+ return;
145
+ // Create a minimal DOM environment using linkedom
146
+ const { document, window } = parseHTML('<!DOCTYPE html><html><body></body></html>');
147
+ // EMFJS requires document.createElementNS
148
+ if (typeof globalThis.document === 'undefined') {
149
+ globalThis.document = document;
150
+ }
151
+ if (typeof globalThis.window === 'undefined') {
152
+ globalThis.window = window;
153
+ }
154
+ emfDomSetup = true;
155
+ }
156
+ /**
157
+ * Read EMF header to get bounds and frame dimensions
158
+ */
159
+ function readEmfHeader(buffer) {
160
+ const view = new DataView(buffer);
161
+ // Check if this is a valid EMF (record type should be 0x00000001)
162
+ const recordType = view.getUint32(0, true);
163
+ if (recordType !== 0x00000001) {
164
+ throw new Error('Not a valid EMF file');
165
+ }
166
+ // Bounds rectangle at offset 8-23: left, top, right, bottom (in device units)
167
+ const boundsRight = view.getInt32(16, true);
168
+ const boundsBottom = view.getInt32(20, true);
169
+ // Frame rectangle at offset 24-39: left, top, right, bottom (in 0.01mm units)
170
+ const frameRight = view.getInt32(32, true);
171
+ const frameBottom = view.getInt32(36, true);
172
+ return {
173
+ boundsWidth: Math.max(boundsRight, 1),
174
+ boundsHeight: Math.max(boundsBottom, 1),
175
+ frameWidth: frameRight,
176
+ frameHeight: frameBottom
177
+ };
178
+ }
179
+ // For backward compatibility
180
+ function readEmfBounds(buffer) {
181
+ const header = readEmfHeader(buffer);
182
+ return { width: header.boundsWidth, height: header.boundsHeight };
183
+ }
184
+ function extractEmfText(buffer) {
185
+ const view = new DataView(buffer);
186
+ const EMR_EXTTEXTOUTW = 0x54;
187
+ const EMR_SETTEXTCOLOR = 0x18;
188
+ const EMR_EXTCREATEFONTINDIRECTW = 0x52;
189
+ const EMR_SELECTOBJECT = 0x25;
190
+ const EMR_DELETEOBJECT = 0x28;
191
+ const EMR_SETTEXTALIGN = 0x16;
192
+ const textRecords = [];
193
+ let currentTextColor = '#000000';
194
+ let currentFontRotation = 0; // in degrees
195
+ let currentFontHeight = 40; // default font height in logical units
196
+ let currentTextAlign = 0; // EMF text alignment flags
197
+ // Font objects map: handle -> { rotation, height }
198
+ const fontObjects = new Map();
199
+ let pos = 0;
200
+ while (pos < buffer.byteLength - 8) {
201
+ const recordType = view.getUint32(pos, true);
202
+ const recordSize = view.getUint32(pos + 4, true);
203
+ if (recordSize < 8 || pos + recordSize > buffer.byteLength)
204
+ break;
205
+ if (recordType === EMR_SETTEXTCOLOR) {
206
+ // Color is at offset 8 (after type and size)
207
+ const r = view.getUint8(pos + 8);
208
+ const g = view.getUint8(pos + 9);
209
+ const b = view.getUint8(pos + 10);
210
+ currentTextColor = '#' + r.toString(16).padStart(2, '0') +
211
+ g.toString(16).padStart(2, '0') +
212
+ b.toString(16).padStart(2, '0');
213
+ }
214
+ // EMR_SETTEXTALIGN - sets text alignment mode
215
+ if (recordType === EMR_SETTEXTALIGN) {
216
+ currentTextAlign = view.getUint32(pos + 8, true);
217
+ }
218
+ // EMR_EXTCREATEFONTINDIRECTW - creates a font with rotation info and height
219
+ if (recordType === EMR_EXTCREATEFONTINDIRECTW) {
220
+ // EMR_EXTCREATEFONTINDIRECTW structure:
221
+ // 0-3: record type
222
+ // 4-7: record size
223
+ // 8-11: ihFonts (handle/index of font object)
224
+ // 12-15: elfw.elfLogFont.lfHeight
225
+ // 16-19: elfw.elfLogFont.lfWidth
226
+ // 20-23: elfw.elfLogFont.lfEscapement (rotation in tenths of a degree)
227
+ // 24-27: elfw.elfLogFont.lfOrientation (also in tenths of a degree)
228
+ const fontHandle = view.getUint32(pos + 8, true);
229
+ const lfHeight = view.getInt32(pos + 12, true);
230
+ const lfEscapement = view.getInt32(pos + 20, true); // Escapement is text rotation
231
+ // Convert from tenths of degrees to degrees, negate for SVG (SVG rotates clockwise, EMF counter-clockwise)
232
+ const rotationDegrees = -lfEscapement / 10;
233
+ // Font height is negative in EMF (cell height), use absolute value
234
+ fontObjects.set(fontHandle, { rotation: rotationDegrees, height: Math.abs(lfHeight) });
235
+ }
236
+ // EMR_SELECTOBJECT - selects a font (and its rotation/height)
237
+ if (recordType === EMR_SELECTOBJECT) {
238
+ const objectHandle = view.getUint32(pos + 8, true);
239
+ // Check if it's a font object we've created
240
+ if (fontObjects.has(objectHandle)) {
241
+ const fontInfo = fontObjects.get(objectHandle);
242
+ currentFontRotation = fontInfo.rotation;
243
+ currentFontHeight = fontInfo.height;
244
+ }
245
+ }
246
+ // EMR_DELETEOBJECT - remove font object
247
+ if (recordType === EMR_DELETEOBJECT) {
248
+ const objectHandle = view.getUint32(pos + 8, true);
249
+ fontObjects.delete(objectHandle);
250
+ }
251
+ if (recordType === EMR_EXTTEXTOUTW) {
252
+ // EMR_EXTTEXTOUTW structure:
253
+ // 0-3: record type
254
+ // 4-7: record size
255
+ // 8-23: bounds (16 bytes)
256
+ // 24-27: iGraphicsMode
257
+ // 28-31: exScale
258
+ // 32-35: eyScale
259
+ // 36-39: emrtext.ptlReference.x
260
+ // 40-43: emrtext.ptlReference.y
261
+ // 44-47: emrtext.nChars
262
+ // 48-51: emrtext.offString (offset to string from start of record)
263
+ const x = view.getInt32(pos + 36, true);
264
+ const y = view.getInt32(pos + 40, true);
265
+ const nChars = view.getUint32(pos + 44, true);
266
+ const offString = view.getUint32(pos + 48, true);
267
+ if (nChars > 0 && offString > 0 && pos + offString + nChars * 2 <= buffer.byteLength) {
268
+ // Read UTF-16LE string
269
+ let text = '';
270
+ for (let i = 0; i < nChars; i++) {
271
+ const charCode = view.getUint16(pos + offString + i * 2, true);
272
+ if (charCode === 0)
273
+ break;
274
+ text += String.fromCharCode(charCode);
275
+ }
276
+ if (text.trim()) {
277
+ // Determine SVG text-anchor from EMF text align flags
278
+ // TA_LEFT = 0, TA_RIGHT = 2, TA_CENTER = 6 (bits 1-2)
279
+ const hAlignBits = currentTextAlign & 6;
280
+ let textAnchor = 'start';
281
+ if (hAlignBits === 2) {
282
+ textAnchor = 'end'; // TA_RIGHT
283
+ }
284
+ else if (hAlignBits === 6) {
285
+ textAnchor = 'middle'; // TA_CENTER
286
+ }
287
+ textRecords.push({
288
+ x,
289
+ y,
290
+ text: text.trim(),
291
+ color: currentTextColor,
292
+ rotation: currentFontRotation,
293
+ textAnchor,
294
+ fontHeight: currentFontHeight
295
+ });
296
+ }
297
+ }
298
+ }
299
+ pos += recordSize;
300
+ }
301
+ return textRecords;
302
+ }
303
+ /**
304
+ * Escape text for SVG
305
+ */
306
+ function escapeSvgText(text) {
307
+ return text
308
+ .replace(/&/g, '&amp;')
309
+ .replace(/</g, '&lt;')
310
+ .replace(/>/g, '&gt;')
311
+ .replace(/"/g, '&quot;')
312
+ .replace(/'/g, '&apos;');
313
+ }
314
+ /**
315
+ * Convert EMF buffer to SVG for inline rendering
316
+ */
317
+ function convertEmfToSvg(buffer) {
318
+ try {
319
+ setupEmfDom();
320
+ const header = readEmfHeader(buffer);
321
+ const { boundsWidth, boundsHeight, frameWidth, frameHeight } = header;
322
+ // Disable EMFJS logging by temporarily overriding console
323
+ const originalLog = console.log;
324
+ console.log = () => { }; // Silence EMFJS debug logs
325
+ try {
326
+ const renderer = new EMFJS.Renderer(buffer);
327
+ const svg = renderer.render({
328
+ width: boundsWidth + 'px',
329
+ height: boundsHeight + 'px',
330
+ wExt: boundsWidth,
331
+ hExt: boundsHeight,
332
+ xExt: boundsWidth,
333
+ yExt: boundsHeight,
334
+ mapMode: 8 // MM_ANISOTROPIC - allows independent scaling of X and Y
335
+ });
336
+ // Get the SVG markup
337
+ let svgMarkup = svg.outerHTML;
338
+ // Extract text from EMF and add to SVG
339
+ const textRecords = extractEmfText(buffer);
340
+ if (textRecords.length > 0) {
341
+ // Calculate scaling from EMF logical units to SVG viewport
342
+ // EMF text coordinates are in the window extent coordinate system
343
+ // EMFJS renders graphics to the viewport extent coordinate system
344
+ // We need to scale text from window extent -> viewport extent (same as graphics)
345
+ const windowExt = readEmfWindowExtent(buffer);
346
+ const viewportExt = readEmfViewportExtent(buffer);
347
+ const scaleX = viewportExt.width / windowExt.width;
348
+ const scaleY = viewportExt.height / windowExt.height;
349
+ // Create text elements
350
+ const textElements = [];
351
+ for (const record of textRecords) {
352
+ const svgX = Math.round(record.x * scaleX);
353
+ const svgY = Math.round(record.y * scaleY);
354
+ // Scale font height from logical units to SVG pixels
355
+ const fontSize = Math.round(record.fontHeight * scaleY);
356
+ // Apply rotation transform if needed
357
+ const transform = record.rotation !== 0
358
+ ? ` transform="rotate(${record.rotation}, ${svgX}, ${svgY})"`
359
+ : '';
360
+ // Apply text-anchor for horizontal alignment
361
+ const textAnchorAttr = record.textAnchor !== 'start'
362
+ ? ` text-anchor="${record.textAnchor}"`
363
+ : '';
364
+ // SVG uses 'dominant-baseline' for vertical alignment. EMF uses baseline alignment.
365
+ // SVG's default is 'auto' which is similar to baseline, so we don't need to change it.
366
+ textElements.push(`<text x="${svgX}" y="${svgY}" fill="${record.color}" font-size="${fontSize}px" font-family="Arial, sans-serif"${textAnchorAttr}${transform}>${escapeSvgText(record.text)}</text>`);
367
+ }
368
+ // Insert text elements inside the INNER SVG (where graphics are rendered)
369
+ // EMFJS creates a nested SVG structure: outer SVG > inner SVG with graphics
370
+ // We want text in the inner SVG to match the graphics coordinate system
371
+ // Find the first closing </svg> tag (inner SVG)
372
+ const firstSvgClose = svgMarkup.indexOf('</svg>');
373
+ if (firstSvgClose > 0) {
374
+ svgMarkup = svgMarkup.slice(0, firstSvgClose) +
375
+ textElements.join('') +
376
+ svgMarkup.slice(firstSvgClose);
377
+ }
378
+ }
379
+ // Add xmlns attribute if missing (required for inline SVG)
380
+ if (!svgMarkup.includes('xmlns=')) {
381
+ svgMarkup = svgMarkup.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
382
+ }
383
+ // Return raw SVG markup for inline rendering
384
+ return { type: "inlineSvg", svgMarkup };
385
+ }
386
+ finally {
387
+ console.log = originalLog; // Restore console.log
388
+ }
389
+ }
390
+ catch (err) {
391
+ // If conversion fails, return null (image won't display)
392
+ console.error('EMF to SVG conversion failed:', err);
393
+ return null;
394
+ }
395
+ }
396
+ /**
397
+ * Read the window extent from EMF (EMR_SETWINDOWEXTEX record)
398
+ * Returns the last SETWINDOWEXTEX before text rendering starts
399
+ */
400
+ function readEmfWindowExtent(buffer) {
401
+ const view = new DataView(buffer);
402
+ const EMR_SETWINDOWEXTEX = 0x09;
403
+ let pos = 0;
404
+ let lastWindowExt = { width: 0, height: 0 };
405
+ while (pos < buffer.byteLength - 8) {
406
+ const recordType = view.getUint32(pos, true);
407
+ const recordSize = view.getUint32(pos + 4, true);
408
+ if (recordSize < 8 || pos + recordSize > buffer.byteLength)
409
+ break;
410
+ if (recordType === EMR_SETWINDOWEXTEX) {
411
+ // EMR_SETWINDOWEXTEX:
412
+ // 0-3: record type
413
+ // 4-7: record size
414
+ // 8-11: cx (width)
415
+ // 12-15: cy (height)
416
+ const cx = view.getInt32(pos + 8, true);
417
+ const cy = view.getInt32(pos + 12, true);
418
+ if (cx > 0 && cy > 0) {
419
+ lastWindowExt = { width: cx, height: cy };
420
+ }
421
+ }
422
+ pos += recordSize;
423
+ }
424
+ // If we found a valid window extent, use it
425
+ if (lastWindowExt.width > 0 && lastWindowExt.height > 0) {
426
+ return lastWindowExt;
427
+ }
428
+ // Fallback to bounds
429
+ const header = readEmfHeader(buffer);
430
+ return { width: header.boundsWidth, height: header.boundsHeight };
431
+ }
432
+ /**
433
+ * Read the viewport extent from EMF (EMR_SETVIEWPORTEXTEX record)
434
+ * This is used to scale graphics and text to the SVG coordinate space
435
+ */
436
+ function readEmfViewportExtent(buffer) {
437
+ const view = new DataView(buffer);
438
+ const EMR_SETVIEWPORTEXTEX = 0x0b;
439
+ let pos = 0;
440
+ let lastViewportExt = { width: 0, height: 0 };
441
+ while (pos < buffer.byteLength - 8) {
442
+ const recordType = view.getUint32(pos, true);
443
+ const recordSize = view.getUint32(pos + 4, true);
444
+ if (recordSize < 8 || pos + recordSize > buffer.byteLength)
445
+ break;
446
+ if (recordType === EMR_SETVIEWPORTEXTEX) {
447
+ const cx = view.getInt32(pos + 8, true);
448
+ const cy = view.getInt32(pos + 12, true);
449
+ if (cx > 0 && cy > 0) {
450
+ lastViewportExt = { width: cx, height: cy };
451
+ }
452
+ }
453
+ pos += recordSize;
454
+ }
455
+ // If we found a valid viewport extent, use it
456
+ if (lastViewportExt.width > 0 && lastViewportExt.height > 0) {
457
+ return lastViewportExt;
458
+ }
459
+ // Fallback to bounds
460
+ const header = readEmfHeader(buffer);
461
+ return { width: header.boundsWidth, height: header.boundsHeight };
462
+ }
463
+ // ============================================================================
136
464
  // Parsing Functions
137
465
  // ============================================================================
138
466
  function parseThemeColors(themeDoc) {
@@ -1687,11 +2015,56 @@ function renderTableToHtml(table, styleMap, numberingMap, themeColors, imageMap,
1687
2015
  return html;
1688
2016
  }
1689
2017
  function renderImageToHtml(img, imageMap) {
1690
- const dataUri = imageMap.get(img.rId);
1691
- if (!dataUri)
2018
+ const imageData = imageMap.get(img.rId);
2019
+ if (!imageData)
1692
2020
  return "";
1693
2021
  const width = Math.round(emuToPx(img.width));
1694
2022
  const height = Math.round(emuToPx(img.height));
2023
+ // Check if this is an inline SVG (from EMF conversion)
2024
+ if (imageData.type === "inlineSvg") {
2025
+ // Build container styles
2026
+ const containerStyles = [
2027
+ "display:inline-block",
2028
+ `width:${width}px`,
2029
+ `height:${height}px`,
2030
+ "margin:8px 0",
2031
+ ];
2032
+ // Build CSS transform for rotation and flip
2033
+ const transforms = [];
2034
+ if (img.rotation) {
2035
+ transforms.push(`rotate(${img.rotation}deg)`);
2036
+ }
2037
+ if (img.flipH) {
2038
+ transforms.push("scaleX(-1)");
2039
+ }
2040
+ if (img.flipV) {
2041
+ transforms.push("scaleY(-1)");
2042
+ }
2043
+ if (transforms.length > 0) {
2044
+ containerStyles.push(`transform:${transforms.join(" ")}`);
2045
+ }
2046
+ // Apply shadow
2047
+ if (img.shadow) {
2048
+ const blurPx = Math.round(emuToPx(img.shadow.blurRadius));
2049
+ const offsetXPx = img.shadow.offsetX ? Math.round(emuToPx(img.shadow.offsetX)) : 0;
2050
+ const offsetYPx = img.shadow.offsetY ? Math.round(emuToPx(img.shadow.offsetY)) : 0;
2051
+ const alpha = img.shadow.alpha / 100;
2052
+ const r = parseInt(img.shadow.color.substring(0, 2), 16);
2053
+ const g = parseInt(img.shadow.color.substring(2, 4), 16);
2054
+ const b = parseInt(img.shadow.color.substring(4, 6), 16);
2055
+ containerStyles.push(`box-shadow:${offsetXPx}px ${offsetYPx}px ${blurPx}px rgba(${r},${g},${b},${alpha})`);
2056
+ }
2057
+ // Apply border
2058
+ if (img.border) {
2059
+ const borderWidthPx = Math.round(emuToPx(img.border.width));
2060
+ containerStyles.push(`border:${borderWidthPx}px solid #${img.border.color}`);
2061
+ }
2062
+ // Inject width/height into the SVG element to ensure proper sizing
2063
+ let styledSvg = imageData.svgMarkup.replace(/<svg /, `<svg style="width:100%;height:100%;display:block;" `);
2064
+ return `<div style="${containerStyles.join(";")}">${styledSvg}</div>`;
2065
+ }
2066
+ // Regular image (dataUri type)
2067
+ const dataUri = imageData.dataUri;
1695
2068
  const alt = img.alt ? ` alt="${escapeHtml(img.alt)}"` : "";
1696
2069
  const styles = [
1697
2070
  "max-width:100%",
@@ -1810,8 +2183,8 @@ function renderPositionedElements(positionedElements, sectionProps, styleMap, th
1810
2183
  }
1811
2184
  // Handle positioned images
1812
2185
  if (el.type === "image" && el.imageRId) {
1813
- const dataUri = imageMap.get(el.imageRId);
1814
- if (dataUri) {
2186
+ const imageData = imageMap.get(el.imageRId);
2187
+ if (imageData) {
1815
2188
  // Build CSS transform for rotation and flip (extracted from XML, not hardcoded)
1816
2189
  const transforms = [];
1817
2190
  if (el.imageRotation) {
@@ -1842,7 +2215,15 @@ function renderPositionedElements(positionedElements, sectionProps, styleMap, th
1842
2215
  const borderWidthPx = Math.round(emuToPx(el.imageBorder.width));
1843
2216
  styles.push(`border:${borderWidthPx}px solid #${el.imageBorder.color}`);
1844
2217
  }
1845
- innerContent = `<img src="${dataUri}" style="width:100%;height:100%;object-fit:cover">`;
2218
+ // Check if this is an inline SVG (from EMF conversion)
2219
+ if (imageData.type === "inlineSvg") {
2220
+ // Inject width/height into the SVG element to ensure proper sizing
2221
+ const styledSvg = imageData.svgMarkup.replace(/<svg /, `<svg style="width:100%;height:100%;display:block;" `);
2222
+ innerContent = styledSvg;
2223
+ }
2224
+ else {
2225
+ innerContent = `<img src="${imageData.dataUri}" style="width:100%;height:100%;object-fit:cover">`;
2226
+ }
1846
2227
  }
1847
2228
  }
1848
2229
  // Handle frame shape specially - render as border, not solid fill
@@ -2080,8 +2461,17 @@ export default async function importDocx(arrayBuffer) {
2080
2461
  const imgFile = zip.file(mediaPath);
2081
2462
  if (!imgFile)
2082
2463
  continue;
2083
- const imgData = await imgFile.async("base64");
2084
2464
  const ext = mediaPath.split(".").pop()?.toLowerCase() ?? "png";
2465
+ // Handle EMF files specially - convert to SVG for web display
2466
+ if (ext === "emf") {
2467
+ const imgBuffer = await imgFile.async("arraybuffer");
2468
+ const svgData = convertEmfToSvg(imgBuffer);
2469
+ if (svgData) {
2470
+ imageMap.set(rId, svgData);
2471
+ }
2472
+ continue;
2473
+ }
2474
+ const imgData = await imgFile.async("base64");
2085
2475
  const mime = ext === "jpg" || ext === "jpeg"
2086
2476
  ? "image/jpeg"
2087
2477
  : ext === "gif"
@@ -2089,7 +2479,7 @@ export default async function importDocx(arrayBuffer) {
2089
2479
  : ext === "svg"
2090
2480
  ? "image/svg+xml"
2091
2481
  : `image/${ext}`;
2092
- imageMap.set(rId, `data:${mime};base64,${imgData}`);
2482
+ imageMap.set(rId, { type: "dataUri", dataUri: `data:${mime};base64,${imgData}` });
2093
2483
  }
2094
2484
  }
2095
2485
  // Parse section properties from document