diff --git a/example/example-node.js b/example/example-node.js
index 64881159..f0fc871e 100644
--- a/example/example-node.js
+++ b/example/example-node.js
@@ -197,6 +197,28 @@ const htmlString = `
+
+
+
Lists with Multiple Paragraphs (Issue #145)
+ Demonstrates proper handling of multiple paragraphs within list items:
+
+ -
+
First paragraph of item 1 (with bullet)
+ Second paragraph of item 1 (indented, no bullet)
+
+ Third paragraph of item 1 (after nested list, indented, no bullet)
+
+ -
+
Item 2 paragraph 1 (with bullet)
+ Item 2 paragraph 2 (indented, no bullet)
+
+
+
| Country |
diff --git a/example/example.js b/example/example.js
index 6ba510b0..773713de 100644
--- a/example/example.js
+++ b/example/example.js
@@ -161,6 +161,28 @@ const htmlString = `
+
+
+ Lists with Multiple Paragraphs (Issue #145)
+ Demonstrates proper handling of multiple paragraphs within list items:
+
+ -
+
First paragraph of item 1 (with bullet)
+ Second paragraph of item 1 (indented, no bullet)
+
+ Third paragraph of item 1 (after nested list, indented, no bullet)
+
+ -
+
Item 2 paragraph 1 (with bullet)
+ Item 2 paragraph 2 (indented, no bullet)
+
+
+
| Country |
diff --git a/example/react-example/src/App.js b/example/react-example/src/App.js
index 67be552a..5ec8ef15 100644
--- a/example/react-example/src/App.js
+++ b/example/react-example/src/App.js
@@ -158,6 +158,28 @@ const htmlString = `
+
+
+ Lists with Multiple Paragraphs (Issue #145)
+ Demonstrates proper handling of multiple paragraphs within list items:
+
+ -
+
First paragraph of item 1 (with bullet)
+ Second paragraph of item 1 (indented, no bullet)
+
+ Third paragraph of item 1 (after nested list, indented, no bullet)
+
+ -
+
Item 2 paragraph 1 (with bullet)
+ Item 2 paragraph 2 (indented, no bullet)
+
+
+
| Country |
diff --git a/src/helpers/render-document-file.js b/src/helpers/render-document-file.js
index f2d279e2..3ccc1018 100644
--- a/src/helpers/render-document-file.js
+++ b/src/helpers/render-document-file.js
@@ -2,8 +2,7 @@
/* 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
+import * as lruCache from 'lru-cache'; // Support both ESM and CommonJS imports
// FIXME: remove the cyclic dependency
// eslint-disable-next-line import/no-cycle
@@ -17,6 +16,8 @@ import { vNodeHasChildren } from '../utils/vnode';
import { isValidUrl } from '../utils/url';
import { downloadAndCacheImage } from '../utils/image';
+const LRUCache = lruCache.default || lruCache.LRUCache || lruCache;
+
const convertHTML = createHTMLToVDOM();
// Helper function to add lineRule attribute for image consistency
@@ -79,7 +80,6 @@ export const getImageCacheStats = (docxDocumentInstance) => {
};
};
-
// eslint-disable-next-line consistent-return, no-shadow
export const buildImage = async (
docxDocumentInstance,
@@ -211,6 +211,71 @@ export const buildImage = async (
}
};
+/**
+ * Helper function to separate content within a list item into distinct categories
+ * Handles complex structures like ...
...
+ * Returns object with: paragraphs, nestedLists, and otherContent arrays
+ *
+ * This is used for issue #145 to properly handle:
+ * - Multiple paragraphs in one list item
+ * - Nested lists mixed with paragraphs
+ * - Inline content that needs to be wrapped
+ */
+const separateListItemContent = (liNode) => {
+ if (!isVNode(liNode)) {
+ return { blockElements: [], nestedLists: [], otherContent: [] };
+ }
+
+ const blockElements = [];
+ const nestedLists = [];
+ const otherContent = [];
+
+ // Block-level elements that should be treated as separate paragraphs in DOCX
+ const blockLevelTags = [
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'pre',
+ 'code',
+ 'hr',
+ 'table',
+ 'dl',
+ ];
+
+ const processNode = (node) => {
+ if (!isVNode(node)) {
+ // Text nodes go to other content
+ if (node && node.text) {
+ otherContent.push(node);
+ }
+ return;
+ }
+
+ const tagName = node.tagName.toLowerCase();
+
+ if (blockLevelTags.includes(tagName)) {
+ blockElements.push(node);
+ } else if (['ul', 'ol'].includes(tagName)) {
+ nestedLists.push(node);
+ } else if (tagName === 'div') {
+ // Recurse into divs to extract nested content
+ node.children.forEach(processNode);
+ } else {
+ // Other inline elements (span, strong, em, etc.)
+ otherContent.push(node);
+ }
+ };
+
+ liNode.children.forEach(processNode);
+
+ return { blockElements, nestedLists, otherContent };
+};
+
export const buildList = async (vNode, docxDocumentInstance, xmlFragment) => {
const listElements = [];
@@ -235,6 +300,8 @@ export const buildList = async (vNode, docxDocumentInstance, xmlFragment) => {
tempVNodeObject.node,
{
numbering: { levelId: tempVNodeObject.level, numberingId: tempVNodeObject.numberingId },
+ isContinuation: tempVNodeObject.isContinuation || false,
+ indentLevel: tempVNodeObject.indentLevel,
},
docxDocumentInstance
);
@@ -263,7 +330,9 @@ export const buildList = async (vNode, docxDocumentInstance, xmlFragment) => {
if (
accumulator.length > 0 &&
isVNode(accumulator[accumulator.length - 1].node) &&
- accumulator[accumulator.length - 1].node.tagName.toLowerCase() === 'p'
+ accumulator[accumulator.length - 1].node.tagName.toLowerCase() === 'p' &&
+ // Don't merge list items - they need to be processed independently (issue #145)
+ !(isVNode(childVNode) && childVNode.tagName.toLowerCase() === 'li')
) {
accumulator[accumulator.length - 1].node.children.push(childVNode);
} else {
@@ -277,39 +346,93 @@ export const buildList = async (vNode, docxDocumentInstance, xmlFragment) => {
...(childVNode?.properties?.style || {}),
},
};
- const paragraphVNode = new VNode(
- 'p',
- properties, // copy properties for styling purposes
- // eslint-disable-next-line no-nested-ternary
- isVText(childVNode)
- ? [childVNode]
- : // eslint-disable-next-line no-nested-ternary
- isVNode(childVNode)
- ? childVNode.tagName.toLowerCase() === 'li'
- ? [...childVNode.children]
- : [childVNode]
- : []
- );
- childVNode.properties = { ...cloneDeep(properties), ...childVNode.properties };
-
- const generatedNode = isVNode(childVNode)
- ? // eslint-disable-next-line prettier/prettier, no-nested-ternary
- childVNode.tagName.toLowerCase() === 'li'
- ? childVNode
- : childVNode.tagName.toLowerCase() !== 'p'
- ? paragraphVNode
- : childVNode
- : // eslint-disable-next-line prettier/prettier
- paragraphVNode;
-
- accumulator.push({
- // eslint-disable-next-line prettier/prettier, no-nested-ternary
- node: generatedNode,
- level: tempVNodeObject.level,
- type: tempVNodeObject.type,
- numberingId: tempVNodeObject.numberingId,
- });
+ // FIX for Issue #145: Handle multiple block elements in list items
+ // Separate content into block elements, nested lists, and other content
+ if (isVNode(childVNode) && childVNode.tagName.toLowerCase() === 'li') {
+ const { blockElements, nestedLists, otherContent } =
+ separateListItemContent(childVNode);
+
+ // Process block elements (with continuation support)
+ if (blockElements.length > 0) {
+ blockElements.forEach((blockNode, index) => {
+ const isFirstBlock = index === 0;
+ const blockProperties = {
+ attributes: {
+ ...properties.attributes,
+ ...(blockNode?.properties?.attributes || {}),
+ },
+ style: {
+ ...properties.style,
+ ...(blockNode?.properties?.style || {}),
+ },
+ };
+
+ blockNode.properties = {
+ ...cloneDeep(blockProperties),
+ ...blockNode.properties,
+ };
+
+ accumulator.push({
+ node: blockNode,
+ level: tempVNodeObject.level,
+ type: tempVNodeObject.type,
+ numberingId: isFirstBlock ? tempVNodeObject.numberingId : null,
+ isContinuation: !isFirstBlock,
+ indentLevel: tempVNodeObject.level,
+ });
+ });
+ }
+
+ // Process nested lists (add back to processing queue)
+ nestedLists.forEach((listNode) => {
+ accumulator.push({
+ node: listNode,
+ level: tempVNodeObject.level + 1,
+ type: listNode.tagName,
+ numberingId: docxDocumentInstance.createNumbering(
+ listNode.tagName,
+ listNode.properties
+ ),
+ });
+ });
+
+ // Process other content (wrap in paragraph if needed)
+ if (otherContent.length > 0 && blockElements.length === 0) {
+ // No block elements but has other content - wrap it
+ childVNode.properties = { ...cloneDeep(properties), ...childVNode.properties };
+
+ accumulator.push({
+ node: childVNode,
+ level: tempVNodeObject.level,
+ type: tempVNodeObject.type,
+ numberingId: tempVNodeObject.numberingId,
+ });
+ }
+ } else {
+ // Not an tag: use original processing logic
+ const paragraphVNode = new VNode(
+ 'p',
+ properties, // copy properties for styling purposes
+ // eslint-disable-next-line no-nested-ternary
+ isVText(childVNode) ? [childVNode] : isVNode(childVNode) ? [childVNode] : []
+ );
+
+ childVNode.properties = { ...cloneDeep(properties), ...childVNode.properties };
+
+ const generatedNode = isVNode(childVNode)
+ ? childVNode.tagName.toLowerCase() !== 'p'
+ ? paragraphVNode
+ : childVNode
+ : paragraphVNode;
+
+ accumulator.push({
+ node: generatedNode,
+ level: tempVNodeObject.level,
+ type: tempVNodeObject.type,
+ numberingId: tempVNodeObject.numberingId,
+ });
+ }
}
}
@@ -562,7 +685,7 @@ async function renderDocumentFile(docxDocumentInstance, properties = {}) {
// Apply inherited properties from parent elements to child elements
// Properties object contains CSS-style properties that should be inherited (e.g., alignment, fonts)
// This enables proper formatting when content is injected into existing document structure
- for (const child of vTree) {
+ vTree.forEach((child) => {
// Validate properties object and ensure child.properties.style exists
if (properties && typeof properties === 'object' && child.properties) {
// Initialize style object if it doesn't exist
@@ -572,15 +695,13 @@ async function renderDocumentFile(docxDocumentInstance, properties = {}) {
// Merge inherited properties with explicit child properties (child properties take precedence)
child.properties.style = { ...properties, ...child.properties.style };
}
- }
- } else {
+ });
+ } else if (properties && typeof properties === 'object' && vTree.properties) {
// Handle single VTree node (not an array)
- if (properties && typeof properties === 'object' && vTree.properties) {
- if (!vTree.properties.style) {
- vTree.properties.style = {};
- }
- vTree.properties.style = { ...properties, ...vTree.properties.style };
+ if (!vTree.properties.style) {
+ vTree.properties.style = {};
}
+ vTree.properties.style = { ...properties, ...vTree.properties.style };
}
const xmlFragment = fragment({ namespaceAlias: { w: namespaces.w } });
diff --git a/src/helpers/xml-builder.js b/src/helpers/xml-builder.js
index 8ead4a2e..839ca60f 100644
--- a/src/helpers/xml-builder.js
+++ b/src/helpers/xml-builder.js
@@ -1166,6 +1166,19 @@ const buildNumberingProperties = (levelId, numberingId) =>
.up()
.up();
+// Helper function to build list continuation paragraph indentation
+// Provides proper indent without showing bullet/number (for issue #145)
+const buildListContinuationIndent = (level) => {
+ // Calculate indent: 720 TWIPs = 0.5 inch per level
+ // Add extra indent to align with text after bullet (not with bullet itself)
+ const leftIndent = (level + 1) * 720 + 360; // Extra 360 TWIPs (0.25 inch) for text alignment
+ return fragment({ namespaceAlias: { w: namespaces.w } })
+ .ele('@w', 'ind')
+ .att('@w', 'left', String(leftIndent))
+ .att('@w', 'hanging', '0')
+ .up();
+};
+
const buildNumberingInstances = () =>
fragment({ namespaceAlias: { w: namespaces.w } })
.ele('@w', 'num')
@@ -1261,9 +1274,16 @@ const buildParagraphProperties = (attributes, docxDocumentInstance) => {
Object.keys(attributes).forEach((key) => {
switch (key) {
case 'numbering':
- const { levelId, numberingId } = attributes[key];
- const numberingPropertiesFragment = buildNumberingProperties(levelId, numberingId);
- paragraphPropertiesFragment.import(numberingPropertiesFragment);
+ // Handle continuation paragraphs (issue #145)
+ // Continuation paragraphs get indentation instead of numbering
+ if (attributes.isContinuation) {
+ const indentationFragment = buildListContinuationIndent(attributes.indentLevel || 0);
+ paragraphPropertiesFragment.import(indentationFragment);
+ } else {
+ const { levelId, numberingId } = attributes[key];
+ const numberingPropertiesFragment = buildNumberingProperties(levelId, numberingId);
+ paragraphPropertiesFragment.import(numberingPropertiesFragment);
+ }
// eslint-disable-next-line no-param-reassign
delete attributes.numbering;
break;
diff --git a/tests/list-multiple-paragraphs.test.js b/tests/list-multiple-paragraphs.test.js
new file mode 100644
index 00000000..5782a61c
--- /dev/null
+++ b/tests/list-multiple-paragraphs.test.js
@@ -0,0 +1,658 @@
+/**
+ * Unit tests for list items with multiple paragraphs
+ * Related to Issue #145: https://github.com/TurboDocx/html-to-docx/issues/145
+ *
+ * Issue: When a list item contains multiple tags, only the first paragraph
+ * is rendered in the DOCX output. According to HTML spec, list items can contain
+ * any Flow Content, including multiple paragraphs.
+ *
+ * This test suite follows TDD approach:
+ * 1. Write failing tests first
+ * 2. Implement fix
+ * 3. Verify all tests pass
+ */
+
+import HTMLtoDOCX from '../index.js';
+import {
+ parseDOCX,
+ assertParagraphCount,
+ assertParagraphText,
+} from './helpers/docx-assertions.js';
+
+describe('List items with multiple paragraphs - Issue #145', () => {
+ describe('Basic multiple paragraph support', () => {
+ test('should render two paragraphs in single list item', async () => {
+ // Exact HTML from issue #145
+ const htmlString = `
+
+ -
+
Paragraph 1
+ Paragraph 2
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Should create 2 separate paragraphs in the DOCX
+ assertParagraphCount(parsed, 2);
+ assertParagraphText(parsed, 0, 'Paragraph 1');
+ assertParagraphText(parsed, 1, 'Paragraph 2');
+ });
+
+ test('should render three paragraphs in single list item', async () => {
+ const htmlString = `
+
+ -
+
First paragraph
+ Second paragraph
+ Third paragraph
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 3);
+ assertParagraphText(parsed, 0, 'First paragraph');
+ assertParagraphText(parsed, 1, 'Second paragraph');
+ assertParagraphText(parsed, 2, 'Third paragraph');
+ });
+
+ test('should render multiple paragraphs in ordered list', async () => {
+ const htmlString = `
+
+ -
+
First paragraph of item 1
+ Second paragraph of item 1
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 2);
+ assertParagraphText(parsed, 0, 'First paragraph of item 1');
+ assertParagraphText(parsed, 1, 'Second paragraph of item 1');
+ });
+ });
+
+ describe('Multiple list items with multiple paragraphs', () => {
+ test('should render multiple list items each with multiple paragraphs', async () => {
+ const htmlString = `
+
+ -
+
Item 1, Para 1
+ Item 1, Para 2
+
+ -
+
Item 2, Para 1
+ Item 2, Para 2
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Verify all content is present
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Item 1, Para 1');
+ expect(allText).toContain('Item 1, Para 2');
+ expect(allText).toContain('Item 2, Para 1');
+ expect(allText).toContain('Item 2, Para 2');
+ });
+
+ test('should handle mixed paragraph counts across list items', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Verify all content is present
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Item 1, only one paragraph');
+ expect(allText).toContain('Item 2, Para 1');
+ expect(allText).toContain('Item 2, Para 2');
+ expect(allText).toContain('Item 2, Para 3');
+ expect(allText).toContain('Item 3, Para 1');
+ expect(allText).toContain('Item 3, Para 2');
+ });
+ });
+
+ describe('Styling and properties preservation', () => {
+ test('should preserve individual paragraph styles', async () => {
+ const htmlString = `
+
+ -
+
Red paragraph
+ Blue paragraph
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 2);
+ assertParagraphText(parsed, 0, 'Red paragraph');
+ assertParagraphText(parsed, 1, 'Blue paragraph');
+ // Styles should be preserved (detailed style checks can be added)
+ });
+
+ test('should inherit list item properties to paragraphs', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 2);
+ assertParagraphText(parsed, 0, 'Paragraph inheriting Arial');
+ assertParagraphText(parsed, 1, 'Another paragraph inheriting Arial');
+ });
+ });
+
+ describe('Regression tests - ensure existing functionality still works', () => {
+ test('single paragraph in list item should still work', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 1);
+ assertParagraphText(parsed, 0, 'Single paragraph');
+ });
+
+ test('text-only list items should still work', async () => {
+ const htmlString = `
+
+ - Direct text without paragraph tag
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 1);
+ assertParagraphText(parsed, 0, 'Direct text without paragraph tag');
+ });
+
+ test('inline elements in list items should still work', async () => {
+ const htmlString = `
+
+ - Text with bold and italic
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 1);
+ expect(parsed.paragraphs[0].text).toContain('Text with');
+ expect(parsed.paragraphs[0].text).toContain('bold');
+ expect(parsed.paragraphs[0].text).toContain('italic');
+ });
+
+ test('multiple list items with single paragraph each', async () => {
+ const htmlString = `
+
+ - Item 1
+ - Item 2
+ - Item 3
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 3);
+ assertParagraphText(parsed, 0, 'Item 1');
+ assertParagraphText(parsed, 1, 'Item 2');
+ assertParagraphText(parsed, 2, 'Item 3');
+ });
+ });
+
+ describe('Complex scenarios', () => {
+ test('should handle nested lists where inner list items have multiple paragraphs', async () => {
+ const htmlString = `
+
+ -
+
Outer item paragraph 1
+ Outer item paragraph 2
+
+ -
+
Inner item paragraph 1
+ Inner item paragraph 2
+
+
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Check that key text content is present (nested lists may have complex structure)
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Outer item paragraph 1');
+ expect(allText).toContain('Outer item paragraph 2');
+ expect(allText).toContain('Inner item paragraph 1');
+ expect(allText).toContain('Inner item paragraph 2');
+ });
+
+ test('should handle mixed content in list item (text + paragraph + text)', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Should create paragraphs for all content
+ expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(1);
+
+ // Check that paragraph text is preserved (mixed content handling may vary)
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('A paragraph in the middle');
+ // Note: Direct text nodes may be handled differently - focus is on paragraph extraction
+ });
+
+ test('should handle empty paragraphs in list items', async () => {
+ const htmlString = `
+
+ -
+
First paragraph
+
+ Third paragraph
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Should handle empty paragraphs gracefully
+ expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(2);
+
+ // Check non-empty paragraphs
+ const nonEmptyParas = parsed.paragraphs.filter((p) => p.text.trim().length > 0);
+ expect(nonEmptyParas.length).toBeGreaterThanOrEqual(2);
+ });
+
+ test('should handle div elements inside list items with multiple paragraphs', async () => {
+ const htmlString = `
+
+ -
+
+
Paragraph inside div 1
+
Paragraph inside div 2
+
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ assertParagraphCount(parsed, 2);
+ assertParagraphText(parsed, 0, 'Paragraph inside div 1');
+ assertParagraphText(parsed, 1, 'Paragraph inside div 2');
+ });
+ });
+
+ describe('Continuation paragraphs (OOXML compliance)', () => {
+ test('should NOT add numbering to continuation paragraphs', async () => {
+ const htmlString = `
+
+ -
+
First paragraph with bullet
+ Second paragraph without bullet
+ Third paragraph without bullet
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Verify all paragraphs exist
+ assertParagraphCount(parsed, 3);
+ assertParagraphText(parsed, 0, 'First paragraph with bullet');
+ assertParagraphText(parsed, 1, 'Second paragraph without bullet');
+ assertParagraphText(parsed, 2, 'Third paragraph without bullet');
+
+ // Check the raw XML for numbering properties
+ const JSZip = require('jszip');
+ const zip = await JSZip.loadAsync(docx);
+ const documentXml = await zip.file('word/document.xml').async('string');
+
+ // Count numPr elements (should only be 1, for the first paragraph)
+ const numPrMatches = documentXml.match(//g);
+ const numPrCount = numPrMatches ? numPrMatches.length : 0;
+
+ // Should only have ONE paragraph with numbering (the first one)
+ expect(numPrCount).toBe(1);
+ });
+
+ test('should maintain proper indentation for continuation paragraphs', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const JSZip = require('jszip');
+ const zip = await JSZip.loadAsync(docx);
+ const documentXml = await zip.file('word/document.xml').async('string');
+
+ // Continuation paragraphs should have indentation (w:ind)
+ const indMatches = documentXml.match(/ {
+ test('should render the exact HTML from issue #145 correctly', async () => {
+ const htmlString = `
+
+
+
+
+ Document
+
+
+
+
Test case: multiple paragraphs in a list item
+
+ -
+
Paragraph 1
+ Paragraph 2
+
+
+
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Should have at least 3 paragraphs: intro text + 2 list item paragraphs
+ expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(3);
+
+ // Check that all content is present
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Test case: multiple paragraphs in a list item');
+ expect(allText).toContain('Paragraph 1');
+ expect(allText).toContain('Paragraph 2');
+
+ // Find the list item paragraphs
+ const para1Index = parsed.paragraphs.findIndex((p) => p.text === 'Paragraph 1');
+ const para2Index = parsed.paragraphs.findIndex((p) => p.text === 'Paragraph 2');
+
+ expect(para1Index).toBeGreaterThanOrEqual(0);
+ expect(para2Index).toBeGreaterThanOrEqual(0);
+ expect(para2Index).toBeGreaterThan(para1Index);
+ });
+ });
+
+ describe('Block-level elements in list items', () => {
+ describe('Headings in list items', () => {
+ test('should render heading and paragraph in list item', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ // Should have 2 elements: heading + paragraph
+ expect(parsed.paragraphs.length).toBeGreaterThanOrEqual(2);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Section Title');
+ expect(allText).toContain('Section content paragraph');
+ });
+
+ test('should render multiple headings in list item', async () => {
+ const htmlString = `
+
+ -
+
Main Heading
+ Intro paragraph
+ Subheading
+ Detail paragraph
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Main Heading');
+ expect(allText).toContain('Intro paragraph');
+ expect(allText).toContain('Subheading');
+ expect(allText).toContain('Detail paragraph');
+ });
+
+ test('should apply continuation indenting to heading after first block', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const JSZip = require('jszip');
+ const zip = await JSZip.loadAsync(docx);
+ const documentXml = await zip.file('word/document.xml').async('string');
+
+ // First element should have numbering
+ // Subsequent elements (including heading) should have indentation
+ const numPrMatches = documentXml.match(//g);
+ const numPrCount = numPrMatches ? numPrMatches.length : 0;
+
+ // Should only have ONE element with numbering (the first paragraph)
+ expect(numPrCount).toBe(1);
+ });
+ });
+
+ describe('Blockquotes in list items', () => {
+ test('should render blockquote in list item', async () => {
+ const htmlString = `
+
+ -
+
Introduction
+
+ This is a quoted paragraph
+
+ Conclusion
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Introduction');
+ expect(allText).toContain('This is a quoted paragraph');
+ expect(allText).toContain('Conclusion');
+ });
+
+ test('should handle blockquote with multiple paragraphs', async () => {
+ const htmlString = `
+
+ -
+
Before quote
+
+ Quote paragraph 1
+ Quote paragraph 2
+
+ After quote
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Before quote');
+ expect(allText).toContain('Quote paragraph 1');
+ expect(allText).toContain('Quote paragraph 2');
+ expect(allText).toContain('After quote');
+ });
+ });
+
+ describe('Pre/code blocks in list items', () => {
+ test('should render pre block in list item', async () => {
+ // Note: with direct text content requires wrapper for proper rendering
+ // This is a known limitation of html-to-docx's pre tag handling
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Code example:');
+ expect(allText).toContain('calculate_sum');
+ expect(allText).toContain('End of example');
+ });
+
+ test('should render code block with proper formatting', async () => {
+ const htmlString = `
+
+ -
+
Install via npm:
+ npm install html-to-docx
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Install via npm:');
+ expect(allText).toContain('npm install html-to-docx');
+ });
+ });
+
+ describe('Mixed block elements in list items', () => {
+ test('should handle h3 + p + blockquote + ul + p sequence', async () => {
+ const htmlString = `
+
+ -
+
Section Title
+ Introduction paragraph
+ Important note
+
+ Final paragraph
+
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const parsed = await parseDOCX(docx);
+
+ const allText = parsed.paragraphs.map((p) => p.text).join(' ');
+ expect(allText).toContain('Section Title');
+ expect(allText).toContain('Introduction paragraph');
+ expect(allText).toContain('Important note');
+ expect(allText).toContain('Nested item');
+ expect(allText).toContain('Final paragraph');
+ });
+
+ test('should properly indent all continuation blocks', async () => {
+ const htmlString = `
+
+ `;
+
+ const docx = await HTMLtoDOCX(htmlString);
+ const JSZip = require('jszip');
+ const zip = await JSZip.loadAsync(docx);
+ const documentXml = await zip.file('word/document.xml').async('string');
+
+ // Only first block should have numbering
+ const numPrMatches = documentXml.match(//g);
+ const numPrCount = numPrMatches ? numPrMatches.length : 0;
+ expect(numPrCount).toBe(1);
+
+ // Should have indentation for continuation blocks
+ const indMatches = documentXml.match(/