diff --git a/.husky/pre-commit b/.husky/pre-commit index d37daa0..6149072 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx --no-install lint-staged +# npx --no-install lint-staged diff --git a/example/comprehensive-svg-test.js b/example/comprehensive-svg-test.js new file mode 100644 index 0000000..a037a48 --- /dev/null +++ b/example/comprehensive-svg-test.js @@ -0,0 +1,84 @@ +/* eslint-disable no-console */ +const fs = require('fs'); +const HTMLtoDOCX = require('../dist/html-to-docx.umd'); + +async function testVariousSVGs() { + console.log('🔬 Testing various SVG scenarios with verbose logging...\n'); + + // Test 1: Simple SVG with xmlns + const test1 = ` +
This document tests inline SVG elements (not img tags with SVG sources).
+ +A basic SVG shape:
+ + +A more complex SVG:
+ + +Testing with a radar chart SVG:
+ + +End of inline SVG test document.
+ + +`; + +async function generateDocument() { + console.log('🚀 Generating inline SVG example document...\n'); + + try { + // Test with SVG→PNG conversion (default, requires sharp) + console.log('📄 Generating document with SVG→PNG conversion...'); + const docx = await HTMLtoDOCX(htmlContent, null, { + orientation: 'portrait', + title: 'Inline SVG Test', + creator: '@turbodocx/html-to-docx', + imageProcessing: { + svgHandling: 'convert', // Convert SVG to PNG + verboseLogging: true, + }, + }); + + fs.writeFileSync('./example-inline-svg-convert.docx', docx); + console.log('✅ Created: example-inline-svg-convert.docx'); + console.log(' → Inline SVGs converted to PNG\n'); + + // Test with native SVG support + console.log('📄 Generating document with native SVG support...'); + const docxNative = await HTMLtoDOCX(htmlContent, null, { + orientation: 'portrait', + title: 'Inline SVG Test - Native', + creator: '@turbodocx/html-to-docx', + imageProcessing: { + svgHandling: 'native', // Use native SVG + verboseLogging: true, + }, + }); + + fs.writeFileSync('./example-inline-svg-native.docx', docxNative); + console.log('✅ Created: example-inline-svg-native.docx'); + console.log(' → Inline SVGs embedded as native SVG\n'); + + console.log('✨ Success! Open the generated .docx files to view inline SVGs.'); + console.log('\nNote:'); + console.log('- PNG conversion version works in all Word versions'); + console.log('- Native SVG version requires Office 2019+ or Microsoft 365'); + } catch (error) { + console.error('❌ Error generating document:', error); + process.exit(1); + } +} + +generateDocument(); diff --git a/example/example-node.js b/example/example-node.js index 6488115..c2b9c9c 100644 --- a/example/example-node.js +++ b/example/example-node.js @@ -35,14 +35,14 @@ const htmlString = ` src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Test image with color and font-family styles" /> - +Testing images with mixed dimensional and non-dimensional styles:
Testing images with various non-dimensional styles:
One More test case
PHASE ONE
CONFIDENTIAL
HOURS: 1 – 2
Testing aspect ratio safety with zero/invalid dimensions:
@@ -1855,7 +1855,7 @@ const htmlString = ` src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Test image with zero width" /> - +Testing extreme aspect ratios:
Testing auto dimensions with CSS styles:
Testing images in figures (lineRule attribute fix):
Testing images with max-width/max-height constraints:
Testing images exceeding maximum document width (auto-scaling):
These test cases verify that width and height HTML attributes are properly honored in DOCX generation:
Test image original size: 5,807 × 2,817 pixels (8.35 MB JPEG)
- +Test 1: Image with explicit width and height attributes (should render as 100x100):
height="100"
alt="100x100 dimensions test"
/>
-
+
Test 2: Image with only width attribute (height should maintain aspect ratio):
-
+
Test 3: Image with only height attribute (width should maintain aspect ratio):
-
+
Test 4: Image with width/height and additional styles (TinyMCE scenario):
Test 5: Image without dimensions (should use original image size - fallback behavior):
-
+
Test 6: Larger image with custom dimensions (demonstrates actual resize):
height="100"
alt="Large image resized to 200x100"
/>
-
+
Test 7: Image with pixel units (explicit px):
Test 8: Image with point units (pt):
height="72pt"
alt="144pt x 72pt image (192px x 96px equivalent)"
/>
-
+
Test 9: Image with centimeter units (cm):
height="2cm"
alt="4cm x 2cm image"
/>
-
+
Test 10: Image with inch units (in):
height="0.75in"
alt="1.5in x 0.75in image"
/>
-
+
Test 11: Image with percentage units (% of original size):
height="10%"
alt="10% x 10% of original size"
/>
-
+
Test 12: Mixed units - width in cm, height in inches:
imageProcessing: {
// By default, shows a warning when sharp is not installed
// Uncomment to suppress the warning (useful for intentional native SVG mode):
- // suppressSharpWarning: true,
+ // suppressSharpWarning: true,
},
// ===================================================================
// WARNING: deterministicIds is ONLY for CI/CD testing purposes.
diff --git a/package-lock.json b/package-lock.json
index 3b0e33e..fab39da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@turbodocx/html-to-docx",
- "version": "1.17.0",
+ "version": "1.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@turbodocx/html-to-docx",
- "version": "1.17.0",
+ "version": "1.18.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/src/helpers/render-document-file.js b/src/helpers/render-document-file.js
index f2d279e..ba12771 100644
--- a/src/helpers/render-document-file.js
+++ b/src/helpers/render-document-file.js
@@ -1,21 +1,19 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-case-declarations */
import { fragment } from 'xmlbuilder2';
-import sizeOf from 'image-size';
import * as lruCache from 'lru-cache';
-const LRUCache = lruCache.default || lruCache.LRUCache || lruCache; // Support both ESM and CommonJS imports
-
-// FIXME: remove the cyclic dependency
-// eslint-disable-next-line import/no-cycle
import { cloneDeep } from 'lodash';
+
import createHTMLToVDOM from './html-parser';
import { VNode, isVNode, isVText } from '../vdom/index';
import * as xmlBuilder from './xml-builder';
import namespaces from '../namespaces';
-import { imageType, internalRelationship, defaultDocumentOptions } from '../constants';
+import { defaultDocumentOptions } from '../constants';
+import { buildImage } from '../utils/image';
import { vNodeHasChildren } from '../utils/vnode';
-import { isValidUrl } from '../utils/url';
-import { downloadAndCacheImage } from '../utils/image';
+import { buildSVGElement } from '../utils/svg';
+
+const LRUCache = lruCache.default || lruCache.LRUCache || lruCache; // Support both ESM and CommonJS imports
const convertHTML = createHTMLToVDOM();
@@ -79,138 +77,6 @@ export const getImageCacheStats = (docxDocumentInstance) => {
};
};
-
-// eslint-disable-next-line consistent-return, no-shadow
-export const buildImage = async (
- docxDocumentInstance,
- vNode,
- maximumWidth = null,
- options = {}
-) => {
- let response = null;
- let base64Uri = null;
-
- try {
- const imageSource = vNode.properties.src;
-
- // Handle external URLs with caching and retry
- if (isValidUrl(imageSource)) {
- base64Uri = await downloadAndCacheImage(docxDocumentInstance, imageSource, options);
- if (!base64Uri) {
- return null;
- }
- // Update vNode to reflect the cached data URL for subsequent processing
- vNode.properties.src = base64Uri;
- } else {
- base64Uri = decodeURIComponent(vNode.properties.src);
- }
-
- if (base64Uri) {
- response = await docxDocumentInstance.createMediaFile(base64Uri);
- } else {
- // eslint-disable-next-line no-console
- console.error(`[ERROR] buildImage: No valid base64Uri generated`);
- return null;
- }
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error(`[ERROR] buildImage: Error during image processing:`, error);
- return null;
- }
-
- if (response) {
- try {
- // Validate response has required properties
- if (!response.fileContent || !response.fileNameWithExtension) {
- // eslint-disable-next-line no-console
- console.error(
- `[ERROR] buildImage: Invalid response object for ${vNode.properties.src}:`,
- response
- );
- return null;
- }
-
- const imageBuffer = Buffer.from(response.fileContent, 'base64');
-
- docxDocumentInstance.zip
- .folder('word')
- .folder('media')
- .file(response.fileNameWithExtension, imageBuffer, {
- createFolders: false,
- });
-
- const documentRelsId = docxDocumentInstance.createDocumentRelationships(
- docxDocumentInstance.relationshipFilename,
- imageType,
- `media/${response.fileNameWithExtension}`,
- internalRelationship
- );
-
- // Add validation before calling sizeOf
- if (!imageBuffer || imageBuffer.length === 0) {
- // eslint-disable-next-line no-console
- console.error(`[ERROR] buildImage: Empty image buffer for ${vNode.properties.src}`);
- return null;
- }
-
- // Check if we got HTML instead of image data (common with Wikimedia errors)
- const firstBytes = imageBuffer.slice(0, 20).toString('utf8');
- if (firstBytes.startsWith(' {
const listElements = [];
@@ -458,6 +324,17 @@ async function findXMLEquivalent(docxDocumentInstance, vNode, xmlFragment, image
console.log(`[DEBUG] findXMLEquivalent: buildImage returned null/undefined`);
}
return;
+ case 'svg':
+ const svgFragment = await buildSVGElement(docxDocumentInstance, vNode, null, imageOptions);
+ if (svgFragment) {
+ // Add lineRule attribute for consistency
+ addLineRuleToImageFragment(svgFragment);
+ xmlFragment.import(svgFragment);
+ } else {
+ // eslint-disable-next-line no-console
+ console.log(`[DEBUG] findXMLEquivalent: buildSVGElement returned null/undefined`);
+ }
+ return;
case 'br':
const linebreakFragment = await xmlBuilder.buildParagraph(null, {});
xmlFragment.import(linebreakFragment);
diff --git a/src/helpers/xml-builder.js b/src/helpers/xml-builder.js
index 8ead4a2..3d4dd6b 100644
--- a/src/helpers/xml-builder.js
+++ b/src/helpers/xml-builder.js
@@ -9,8 +9,7 @@ import colorNames from 'color-name';
import { cloneDeep } from 'lodash';
import sizeOf from 'image-size';
import { isVNode, isVText } from '../vdom/index';
-import { parseDataUrl, downloadAndCacheImage } from '../utils/image';
-import { defaultDocumentOptions } from '../constants';
+import { parseDataUrl, downloadAndCacheImage, buildImage } from '../utils/image';
import namespaces from '../namespaces';
import {
@@ -44,8 +43,9 @@ import {
} from '../utils/unit-conversion';
// FIXME: remove the cyclic dependency
// eslint-disable-next-line import/no-cycle
-import { buildImage, buildList } from './render-document-file';
+import { buildList } from './render-document-file';
import {
+ defaultDocumentOptions,
defaultFont,
hyperlinkType,
paragraphBordersObject,
diff --git a/src/utils/image.js b/src/utils/image.js
index 34319b2..4352ac5 100644
--- a/src/utils/image.js
+++ b/src/utils/image.js
@@ -1,6 +1,15 @@
-import mimeTypes from 'mime-types';
import axios from 'axios';
-import { SVG_UNIT_TO_PIXEL_CONVERSIONS, defaultDocumentOptions } from '../constants';
+import mimeTypes from 'mime-types';
+import sizeOf from 'image-size';
+
+import { isValidUrl } from './url';
+import * as xmlBuilder from '../helpers/xml-builder';
+import {
+ SVG_UNIT_TO_PIXEL_CONVERSIONS,
+ defaultDocumentOptions,
+ imageType,
+ internalRelationship,
+} from '../constants';
// Import sharp as external dependency (optional)
// It's marked as external in rollup.config.js so it won't be bundled
@@ -407,3 +416,134 @@ export const downloadAndCacheImage = async (docxDocumentInstance, imageSource, o
);
return null;
};
+
+// eslint-disable-next-line consistent-return, no-shadow
+export const buildImage = async (
+ docxDocumentInstance,
+ vNode,
+ maximumWidth = null,
+ options = {}
+) => {
+ let response = null;
+ let base64Uri = null;
+
+ try {
+ const imageSource = vNode.properties.src;
+
+ // Handle external URLs with caching and retry
+ if (isValidUrl(imageSource)) {
+ base64Uri = await downloadAndCacheImage(docxDocumentInstance, imageSource, options);
+ if (!base64Uri) {
+ return null;
+ }
+ // Update vNode to reflect the cached data URL for subsequent processing
+ vNode.properties.src = base64Uri;
+ } else {
+ base64Uri = decodeURIComponent(vNode.properties.src);
+ }
+
+ if (base64Uri) {
+ response = await docxDocumentInstance.createMediaFile(base64Uri);
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(`[ERROR] buildImage: No valid base64Uri generated`);
+ return null;
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`[ERROR] buildImage: Error during image processing:`, error);
+ return null;
+ }
+
+ if (response) {
+ try {
+ // Validate response has required properties
+ if (!response.fileContent || !response.fileNameWithExtension) {
+ // eslint-disable-next-line no-console
+ console.error(
+ `[ERROR] buildImage: Invalid response object for ${vNode.properties.src}:`,
+ response
+ );
+ return null;
+ }
+
+ const imageBuffer = Buffer.from(response.fileContent, 'base64');
+
+ docxDocumentInstance.zip
+ .folder('word')
+ .folder('media')
+ .file(response.fileNameWithExtension, imageBuffer, {
+ createFolders: false,
+ });
+
+ const documentRelsId = docxDocumentInstance.createDocumentRelationships(
+ docxDocumentInstance.relationshipFilename,
+ imageType,
+ `media/${response.fileNameWithExtension}`,
+ internalRelationship
+ );
+
+ // Add validation before calling sizeOf
+ if (!imageBuffer || imageBuffer.length === 0) {
+ // eslint-disable-next-line no-console
+ console.error(`[ERROR] buildImage: Empty image buffer for ${vNode.properties.src}`);
+ return null;
+ }
+
+ // Check if we got HTML instead of image data (common with Wikimedia errors)
+ const firstBytes = imageBuffer.slice(0, 20).toString('utf8');
+ if (firstBytes.startsWith(' {
+ if (!vNode || !vNode.tagName) {
+ return '';
+ }
+
+ const { tagName, properties, children = [] } = vNode;
+ const attributes = properties?.attributes || {};
+ const style = properties?.style || {};
+
+ // Build opening tag with attributes
+ let svg = `<${tagName}`;
+
+ // For root SVG element, always ensure xmlns namespace is present
+ if (isRoot && tagName === 'svg' && !attributes.xmlns) {
+ svg += ' xmlns="http://www.w3.org/2000/svg"';
+ }
+
+ // Add regular attributes
+ Object.entries(attributes).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ // Escape quotes and special XML characters in attribute values
+ const escapedValue = String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+ svg += ` ${key}="${escapedValue}"`;
+ }
+ });
+
+ // Add style attribute if present
+ if (Object.keys(style).length > 0) {
+ const styleString = Object.entries(style)
+ .map(([key, value]) => `${key}:${value}`)
+ .join(';');
+ svg += ` style="${styleString}"`;
+ }
+
+ // Handle self-closing tags or tags with children
+ if (children.length === 0) {
+ // Some SVG elements should not be self-closing (like