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 = ` +

Test 1: Simple SVG with xmlns

+ + + + `; + + // Test 2: Simple SVG without xmlns (should be added automatically) + const test2 = ` +

Test 2: Simple SVG without xmlns

+ + + + `; + + // Test 3: Complex nested SVG + const test3 = ` +

Test 3: Complex nested SVG

+ + + + + + + + + + `; + + // Test 4: SVG with text elements + const test4 = ` +

Test 4: SVG with text

+ + Hello SVG + + `; + + // Test 5: Radar chart style SVG (like from test.html) + const test5 = ` +

Test 5: Radar chart style SVG

+ + + + + + + + + `; + + const allTests = test1 + test2 + test3 + test4 + test5; + + try { + console.log('📄 Generating comprehensive test document with verbose logging...\n'); + const docx = await HTMLtoDOCX(allTests, null, { + orientation: 'portrait', + title: 'SVG Test Suite', + imageProcessing: { + svgHandling: 'native', + verboseLogging: true, + }, + }); + + fs.writeFileSync('./comprehensive-svg-test.docx', docx); + console.log('\n✅ Created: comprehensive-svg-test.docx'); + console.log(' → Document with 5 different SVG test cases\n'); + + console.log('✨ Success! Open the file to verify all SVGs render correctly.'); + } catch (error) { + console.error('❌ Error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +testVariousSVGs(); diff --git a/example/example-inline-svg.js b/example/example-inline-svg.js new file mode 100644 index 0000000..0761515 --- /dev/null +++ b/example/example-inline-svg.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +// Use the built version, or install via: npm install @turbodocx/html-to-docx +// const HTMLtoDOCX = require('@turbodocx/html-to-docx'); +const HTMLtoDOCX = require('../dist/html-to-docx.umd'); + +// Simple HTML with inline SVG element +const htmlContent = ` + + + + + Inline SVG Test + + +

Testing Inline SVG Elements

+ +

This document tests inline SVG elements (not img tags with SVG sources).

+ +

1. Simple Inline SVG Circle

+

A basic SVG shape:

+ + + + +

2. Inline SVG with Multiple Elements

+

A more complex SVG:

+ + + + + + +

3. SVG from test.html

+

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:

Test image with mixed styles - +

Testing images with various non-dimensional styles:

  • Heading 3
  • - +

    One More test case

    PHASE ONE
    CONFIDENTIAL
    HOURS: 1 – 2

    @@ -1841,7 +1841,7 @@ const htmlString = `

    - +

    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:

    src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Very tall image" /> - +

    Testing auto dimensions with CSS styles:

    Auto dimensions with non-dimensional styles - +

    Testing images in figures (lineRule attribute fix):

    />
    Image with consistent lineRule processing
    - +

    Testing images with max-width/max-height constraints:

    Image with max constraints - +

    Testing images exceeding maximum document width (auto-scaling):

    - +
    @@ -1936,7 +1936,7 @@ const htmlString = `

    Testing Image Width/Height Attribute Handling (Fix for TinyMCE dimensions)

    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):

    Width only test - +

    Test 3: Image with only height attribute (width should maintain aspect ratio):

    Height only test - +

    Test 4: Image with width/height and additional styles (TinyMCE scenario):

    height="60" alt="TinyMCE style with dimensions" /> - +

    Test 5: Image without dimensions (should use original image size - fallback behavior):

    No dimensions - fallback test - +

    Test 6: Larger image with custom dimensions (demonstrates actual resize):

    height="100" alt="Large image resized to 200x100" /> - +

    Unit Support Tests

    Test 7: Image with pixel units (explicit px):

    height="90px" alt="180px x 90px image" /> - +

    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 , ) + const nonSelfClosingTags = ['g', 'title', 'desc', 'defs', 'text']; + if (nonSelfClosingTags.includes(tagName)) { + svg += `>`; + } else { + svg += ' />'; + } + } else { + svg += '>'; + + // Serialize children + children.forEach((child) => { + if (typeof child === 'string') { + // Text content - escape special XML characters + const escapedText = child + .replace(/&/g, '&') + .replace(//g, '>'); + svg += escapedText; + } else if (child.text) { + // VText node - escape special XML characters + const escapedText = child.text + .replace(/&/g, '&') + .replace(//g, '>'); + svg += escapedText; + } else if (child.tagName) { + // Recursive VNode (not root) + svg += serializeVNodeToSVG(child, false); + } + }); + + svg += ``; + } + + return svg; +}; + +/** + * Processes an inline SVG element by converting it to a data URI and handling it like an image + * @param {Object} docxDocumentInstance - The document instance + * @param {Object} vNode - The VNode representing the SVG element + * @param {number|null} maximumWidth - Maximum width for the image + * @param {Object} options - Processing options + * @returns {Promise} XML fragment or null + */ +// eslint-disable-next-line import/prefer-default-export +export const buildSVGElement = async ( + docxDocumentInstance, + vNode, + maximumWidth = null, + options = {} +) => { + try { + // Serialize the SVG VNode to an SVG string (isRoot=true for proper namespace handling) + const svgString = serializeVNodeToSVG(vNode, true); + + if (!svgString || svgString.trim().length === 0) { + // eslint-disable-next-line no-console + console.error('[ERROR] buildSVGElement: Failed to serialize SVG element'); + return null; + } + + // Log the serialized SVG for debugging (if verbose logging is enabled) + const verboseLogging = + options.verboseLogging || docxDocumentInstance.imageProcessing?.verboseLogging || false; + if (verboseLogging) { + // eslint-disable-next-line no-console + console.log( + `[DEBUG] Serialized SVG (${svgString.length} chars): ${svgString.substring(0, 200)}...` + ); + } + + // Convert SVG string to base64 data URI + const base64SVG = Buffer.from(svgString, 'utf-8').toString('base64'); + const dataUri = `data:image/svg+xml;base64,${base64SVG}`; + + // Extract dimensions from SVG attributes + // Width and height might be numbers, strings with units, or missing + let width; + let height; + + if (vNode.properties?.attributes?.width) { + const widthStr = String(vNode.properties.attributes.width); + // Remove units (px, pt, etc.) and parse as number + width = parseInt(widthStr.replace(/[^\d.]/g, ''), 10); + } + + if (vNode.properties?.attributes?.height) { + const heightStr = String(vNode.properties.attributes.height); + height = parseInt(heightStr.replace(/[^\d.]/g, ''), 10); + } + + // If dimensions are from style attribute, use those + if (vNode.properties?.style?.width) { + const styleWidth = parseInt(String(vNode.properties.style.width).replace(/[^\d.]/g, ''), 10); + if (!Number.isNaN(styleWidth)) { + width = styleWidth; + } + } + + if (vNode.properties?.style?.height) { + const styleHeight = parseInt( + String(vNode.properties.style.height).replace(/[^\d.]/g, ''), + 10 + ); + if (!Number.isNaN(styleHeight)) { + height = styleHeight; + } + } + + // Create a temporary vNode that looks like an img element with the SVG data URI + const imgVNode = { + tagName: 'img', + properties: { + src: dataUri, + alt: vNode.properties?.attributes?.title || 'SVG image', + // Add width/height if we extracted valid values + ...(width && !Number.isNaN(width) && { width }), + ...(height && !Number.isNaN(height) && { height }), + }, + }; + + // Use the existing buildImage function to process the SVG as an image + // eslint-disable-next-line no-use-before-define + return await buildImage(docxDocumentInstance, imgVNode, maximumWidth, options); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[ERROR] buildSVGElement: Error processing inline SVG:', error); + return null; + } +}; diff --git a/tests/inline-image-caching.test.js b/tests/inline-image-caching.test.js index 6e27cea..3f563c7 100644 --- a/tests/inline-image-caching.test.js +++ b/tests/inline-image-caching.test.js @@ -1,12 +1,11 @@ // Unit tests for inline image caching functionality -// Tests that inline images (via buildRun in xml-builder.js) use the same caching mechanism as block images - -import HTMLtoDOCX from '../index.js'; -import { parseDOCX, assertParagraphCount } from './helpers/docx-assertions.js'; -import { getImageCacheStats, clearImageCache } from '../src/helpers/render-document-file.js'; -import { PNG_1x1_BASE64, JPEG_1x1_BASE64 } from './fixtures/index.js'; +// Tests that inline images (via buildRun in xml-builder) use the same caching mechanism as block images import axios from 'axios'; +import HTMLtoDOCX from '../index'; +import { parseDOCX } from './helpers/docx-assertions'; +import { PNG_1x1_BASE64, JPEG_1x1_BASE64 } from './fixtures/index'; + // Mock axios for downloadImageToBase64 tests jest.mock('axios'); diff --git a/tests/inline-svg-element.test.js b/tests/inline-svg-element.test.js new file mode 100644 index 0000000..4fd7c25 --- /dev/null +++ b/tests/inline-svg-element.test.js @@ -0,0 +1,221 @@ +// Unit tests for inline SVG element handling +// Tests inline tags (not img tags with SVG sources) + +import HTMLtoDOCX from '../index'; +import { parseDOCX } from './helpers/docx-assertions'; + +describe('Inline SVG Element Handling', () => { + describe('Basic inline SVG support', () => { + test('should handle inline SVG element (not img tag)', async () => { + const htmlString = ` +

    Before SVG

    + + + +

    After SVG

    + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + + // Should create document with paragraphs and SVG as image + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(2); + // Check that SVG was processed (as either .svg or .png file) + expect(parsed.xml).toMatch(/image-.*\.(svg|png)/); + }); + + test('should handle inline SVG with convert mode', async () => { + const htmlString = ` + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'convert', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1); + }); + + test('should handle multiple inline SVG elements', async () => { + const htmlString = ` + + + + + + + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(3); + }); + + test('should handle inline SVG with complex nested elements', async () => { + const htmlString = ` + + + + + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1); + expect(parsed.xml).toMatch(/image-.*\.svg/); + }); + + test('should handle inline SVG with viewBox and no width/height', async () => { + const htmlString = ` + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Inline SVG in different contexts', () => { + test('should handle inline SVG in paragraph', async () => { + const htmlString = ` +

    + Text before + + + + text after +

    + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1); + }); + + test('should handle inline SVG in div', async () => { + const htmlString = ` +
    +

    Chart Title

    + + + +
    + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Mixed SVG types', () => { + test('should handle both inline SVG and img tags with SVG sources', async () => { + const svgBase64 = Buffer.from( + '', + 'utf-8' + ).toString('base64'); + const svgDataUrl = `data:image/svg+xml;base64,${svgBase64}`; + + const htmlString = ` +

    IMG tag with SVG:

    + SVG via img +

    Inline SVG element:

    + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(4); + // Should have SVG files for both types + expect(parsed.xml).toMatch(/image-.*\.svg/); + }); + }); + + describe('Error handling', () => { + test('should handle empty inline SVG gracefully', async () => { + const htmlString = ` +

    Before

    + +

    After

    + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + // Should still create document with text paragraphs + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(2); + }); + + test('should handle inline SVG without xmlns', async () => { + const htmlString = ` + + + + `; + + const docx = await HTMLtoDOCX(htmlString, null, { + imageProcessing: { + svgHandling: 'native', + }, + }); + + const parsed = await parseDOCX(docx); + expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1); + }); + }); +});