diff --git a/src/helpers/xml-builder.js b/src/helpers/xml-builder.js index 8ead4a2e..4db75ace 100644 --- a/src/helpers/xml-builder.js +++ b/src/helpers/xml-builder.js @@ -1763,10 +1763,10 @@ const buildTableCellBorders = (tableCellBorder) => { const { colors, strokes, ...borders } = tableCellBorder; Object.keys(borders).forEach((border) => { - if (tableCellBorder[border]) { + if (borders[border]) { const borderFragment = buildBorder( border, - tableCellBorder[border], + borders[border], 0, colors[border], strokes[border] @@ -2569,38 +2569,32 @@ const buildTableCell = async ( const columnIndexEquivalentFirst = columnIndexEquivalent.indexOf('first'); const columnIndexEquivalentLast = columnIndexEquivalent.indexOf('last'); - // if table cell styles will be given, then below 4 are overridden - if (rowIndexEquivalentFirst !== -1) { - // it means that the cell is in first row - // we set the top border of cells to table top border + // Apply table-level borders to all cells (edge and non-edge) + // This ensures that when borderOptions is set, all cells get borders creating a complete grid + // Individual cell styles (if present) can override these later in fixupTableCellBorder() + // IMPORTANT: Don't apply borders if table has border-style: hidden or border-style: none + if (modifiedAttributes.tableBorder.strokes.top !== 'hidden' && modifiedAttributes.tableBorder.strokes.top !== 'none') { modifiedAttributes.tableCellBorder.strokes.top = modifiedAttributes.tableBorder.strokes.top; modifiedAttributes.tableCellBorder.colors.top = modifiedAttributes.tableBorder.colors.top; modifiedAttributes.tableCellBorder.top = modifiedAttributes.tableBorder.top || docxDocumentInstance.tableBorders.size; } - if (rowIndexEquivalentLast !== -1) { - // it means that the cell is in last row - // we set the bottom border of cells to that of table - modifiedAttributes.tableCellBorder.strokes.bottom = - modifiedAttributes.tableBorder.strokes.bottom; + if (modifiedAttributes.tableBorder.strokes.bottom !== 'hidden' && modifiedAttributes.tableBorder.strokes.bottom !== 'none') { + modifiedAttributes.tableCellBorder.strokes.bottom = modifiedAttributes.tableBorder.strokes.bottom; modifiedAttributes.tableCellBorder.colors.bottom = modifiedAttributes.tableBorder.colors.bottom; modifiedAttributes.tableCellBorder.bottom = modifiedAttributes.tableBorder.bottom || docxDocumentInstance.tableBorders.size; } - if (columnIndexEquivalentFirst !== -1) { - // it means that the cell is in first column - // we set the left border of cells to that of table + if (modifiedAttributes.tableBorder.strokes.left !== 'hidden' && modifiedAttributes.tableBorder.strokes.left !== 'none') { modifiedAttributes.tableCellBorder.strokes.left = modifiedAttributes.tableBorder.strokes.left; modifiedAttributes.tableCellBorder.colors.left = modifiedAttributes.tableBorder.colors.left; modifiedAttributes.tableCellBorder.left = modifiedAttributes.tableBorder.left || docxDocumentInstance.tableBorders.size; } - if (columnIndexEquivalentLast !== -1) { - // it means that the cell is in last column - // we set the right border of cells to that of table + if (modifiedAttributes.tableBorder.strokes.right !== 'hidden' && modifiedAttributes.tableBorder.strokes.right !== 'none') { modifiedAttributes.tableCellBorder.strokes.right = modifiedAttributes.tableBorder.strokes.right; modifiedAttributes.tableCellBorder.colors.right = modifiedAttributes.tableBorder.colors.right; modifiedAttributes.tableCellBorder.right = diff --git a/tests/table-cell-borders.test.js b/tests/table-cell-borders.test.js new file mode 100644 index 00000000..116dc928 --- /dev/null +++ b/tests/table-cell-borders.test.js @@ -0,0 +1,727 @@ +/** + * Table Cell Borders Test Suite + * Tests for GitHub Issue #160: Bottom border missing in table cells + * + * This test file validates that table cell borders are correctly rendered + * in the generated DOCX files, specifically testing the fix for missing + * bottom borders. + */ + +import HTMLtoDOCX from '../index.js'; +import { parseDOCX } from './helpers/docx-assertions.js'; + +/** + * Helper function to check if XML contains a specific border element + * @param {string} xml - The document XML string + * @param {string} borderSide - The border side (top, bottom, left, right) + * @param {Object} expectedAttrs - Expected attributes { val, sz, color } + * @returns {boolean} True if border element exists with expected attributes + */ +function assertTableCellBorder(xml, borderSide, expectedAttrs = {}) { + // Find all elements (table cell borders) + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + expect(tcBordersMatches.length).toBeGreaterThan(0); + + // Check if any tcBorders contains the specified border element + let foundBorder = false; + for (const tcBordersBlock of tcBordersMatches) { + // Build regex for the specific border side + const borderRegex = new RegExp( + `]+)\\s*\\/>` + ); + const borderMatch = tcBordersBlock.match(borderRegex); + + if (borderMatch) { + foundBorder = true; + const attributes = borderMatch[1]; + + // Verify expected attributes if provided + if (expectedAttrs.val) { + expect(attributes).toContain(`w:val="${expectedAttrs.val}"`); + } + if (expectedAttrs.sz !== undefined) { + expect(attributes).toContain(`w:sz="${expectedAttrs.sz}"`); + } + if (expectedAttrs.color) { + expect(attributes).toContain(`w:color="${expectedAttrs.color}"`); + } + if (expectedAttrs.space !== undefined) { + expect(attributes).toContain(`w:space="${expectedAttrs.space}"`); + } + + break; // Found matching border + } + } + + expect(foundBorder).toBe(true); +} + +describe('Table Cell Borders - Issue #160 Regression Tests', () => { + describe('Exact bug from Issue #160', () => { + test('should render bottom border - exact example from issue reporter', async () => { + // EXACT code from Issue #160 report by dt-eric-lefevreardant + // Original issue: https://github.com/TurboDocx/html-to-docx/issues/160 + const htmlString = '
leftright
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // Before fix: only contained and + // After fix: should contain all four borders including + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + expect(tcBordersMatches.length).toBeGreaterThan(0); + + // Verify bottom border exists as reported missing in the issue + const bottomBorderRegex = //; + const hasBottomBorder = tcBordersMatches.every((tcBorders) => + bottomBorderRegex.test(tcBorders) + ); + expect(hasBottomBorder).toBe(true); + + // Also verify right border (also reported missing) + const rightBorderRegex = //; + const hasRightBorder = tcBordersMatches.some((tcBorders) => rightBorderRegex.test(tcBorders)); + expect(hasRightBorder).toBe(true); + }); + }); + + describe('Basic border rendering', () => { + test('should include bottom border when specified with borderOptions', async () => { + // This is the exact example from GitHub Issue #160 + const htmlString = '
leftright
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // The bug: bottom border is missing from the generated XML + // After fix: should find + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + sz: '1', + space: '0', + color: '000000', + }); + }); + + test('should include all four borders (top, bottom, left, right)', async () => { + const htmlString = '
cell content
'; + const options = { + table: { + borderOptions: { + size: 2, + stroke: 'single', + color: 'FF0000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // All four borders should be present + assertTableCellBorder(parsed.xml, 'top', { + val: 'single', + sz: '2', + color: 'FF0000', + }); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + sz: '2', + color: 'FF0000', + }); + + assertTableCellBorder(parsed.xml, 'left', { + val: 'single', + sz: '2', + color: 'FF0000', + }); + + assertTableCellBorder(parsed.xml, 'right', { + val: 'single', + sz: '2', + color: 'FF0000', + }); + }); + }); + + describe('Different stroke styles', () => { + test('should render bottom border with dashed stroke', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'dashed', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'dashed', + }); + }); + + test('should render bottom border with dotted stroke', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'dotted', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'dotted', + }); + }); + + test('should render bottom border with double stroke', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 2, + stroke: 'double', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'double', + }); + }); + }); + + describe('Custom colors', () => { + test('should render bottom border with custom color', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: 'FF0000', // Red + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + color: 'FF0000', + }); + }); + + test('should render bottom border with blue color', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: '0000FF', // Blue + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + color: '0000FF', + }); + }); + }); + + describe('Complex tables', () => { + test('should render borders in multi-row tables', async () => { + const htmlString = ` + + + + +
Row 1 Cell 1Row 1 Cell 2
Row 2 Cell 1Row 2 Cell 2
Row 3 Cell 1Row 3 Cell 2
+ `; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // Bottom borders should exist for all cells + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + sz: '1', + color: '000000', + }); + }); + + test('should render borders in tables with headers', async () => { + const htmlString = ` + + + + + + + +
Header 1Header 2
Data 1Data 2
+ `; + const options = { + table: { + borderOptions: { + size: 2, + stroke: 'single', + color: '333333', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // All borders including bottom should be present + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + sz: '2', + color: '333333', + }); + }); + }); + + describe('Edge cases', () => { + test('should handle table with single cell', async () => { + const htmlString = '
single cell
'; + const options = { + table: { + borderOptions: { + size: 1, + stroke: 'single', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'single', + }); + }); + + test('should handle zero-width borders (hidden)', async () => { + const htmlString = '
test
'; + const options = { + table: { + borderOptions: { + size: 0, + stroke: 'nil', + color: '000000', + }, + }, + }; + + const result = await HTMLtoDOCX(htmlString, undefined, options); + const parsed = await parseDOCX(result); + + // With size 0 and nil stroke, borders may not be generated at all + // This is expected behavior - no visible borders means no border elements needed + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + // Either no borders exist (most likely) or borders exist with nil/0 values + if (tcBordersMatches) { + // If borders do exist, they should have nil stroke and size 0 + assertTableCellBorder(parsed.xml, 'bottom', { + val: 'nil', + sz: '0', + }); + } else { + // No borders is also acceptable for zero-width/nil borders + expect(tcBordersMatches).toBeNull(); + } + }); + }); + + describe('Regression: Kushal bug report - border-style: hidden should suppress borders', () => { + test('should NOT render cell borders when table has border-style: hidden', async () => { + // Bug reported by Kushal: tables with border-style: hidden incorrectly show borders + // after the fix for issue #160 + const htmlString = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1,11,21,31,4
2,12,22,32,4
3,13,23,33,4
4,14,2 4,34,4
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // When border-style: hidden, there should be NO cell borders + // Check that either tcBorders doesn't exist OR all borders are 'nil' or 'none' + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + if (tcBordersMatches) { + // If borders exist, they should all be nil/none + for (const tcBorder of tcBordersMatches) { + // Should not have visible borders (single, double, etc. with size > 0) + expect(tcBorder).not.toMatch(/ + + + Row 1, Col 1 + Row 1, Col 2 + + + Row 2, Col 1 + Row 2, Col 2 + + +`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // Should have table-level borders, not cell-level overrides + // The expected behavior: table borders should take precedence + // Top border should be yellow (FFFF00), size 16 (2px * 8) + // Bottom border should be orange (FFA500), size 32 (4px * 8) + // Left border should be black (000000), size 8 (1px * 8) + // Right border should be brown (A52A2A), size 16 (2px * 8) + + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + + // Check that borders match table-level styles, not default cell borders + // Find cells with specific border colors matching table borders + const hasYellowTopBorder = parsed.xml.includes('w:color="FFFF00"') || parsed.xml.includes('w:color="ffff00"'); + const hasOrangeBottomBorder = parsed.xml.includes('w:color="FFA500"') || parsed.xml.includes('w:color="ffa500"'); + const hasBrownRightBorder = parsed.xml.includes('w:color="A52A2A"') || parsed.xml.includes('w:color="a52a2a"'); + + expect(hasYellowTopBorder).toBe(true); + expect(hasOrangeBottomBorder).toBe(true); + expect(hasBrownRightBorder).toBe(true); + }); + }); + + describe('Ported from example-node.js - Comprehensive border scenarios', () => { + test('should handle basic table without borders or styles', async () => { + // From example-node.js line 200 + const htmlString = ` + + + + + + + + + + + + +
CountryCapital
IndiaNew Delhi
USAWashington DC
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // Basic table without explicit borders should still be valid + expect(parsed.xml).toContain(''); + expect(parsed.xml).toContain(''); + expect(parsed.xml).toContain(''); + }); + + test('should handle border-style: none with border attribute (from example-node.js)', async () => { + // From example-node.js line 1072 + // Note: This has conflicting directives - border="1" adds borders, but border-style: none removes them + // Current behavior: border="1" attribute takes precedence + const htmlString = ` + + + + + + + + + + + + + + + + + + + + +
1,11,21,31,4
2,12,22,32,4
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // When border="1" is present, it overrides CSS border-style: none + // This is existing behavior - border attribute takes precedence + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + // Borders are present due to border="1" attribute + expect(tcBordersMatches.length).toBeGreaterThan(0); + }); + + test('should handle normal table with border attribute (from example-node.js)', async () => { + // From example-node.js line 1108 + const htmlString = ` + + + + + + + + + + + + + + +
1,11,21,31,4
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // Should have borders with border="1" + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + expect(tcBordersMatches.length).toBeGreaterThan(0); + }); + + test('should handle border-width: 0px with border-style: solid (from example-node.js)', async () => { + // From example-node.js line 1180 + const htmlString = ` + + + + + + + + + + + + + + +
1,11,21,31,4
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // border-width: 0 should result in size 0 borders + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + if (tcBordersMatches) { + // Should have borders but with size 0 + expect(parsed.xml).toMatch(/w:sz="0"/); + } + }); + + test('should handle complex border styles with multiple colors (from example-node.js)', async () => { + // From example-node.js line 1251 + const htmlString = ` + + + + + + + + + + + + + + + + + + + + +
1,11,21,31,4
2,12,22,32,4
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // Should have various border colors + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + // Check for custom colors in the output + expect(parsed.xml).toMatch(/w:color/); + }); + + test('should handle table with border attribute and border-collapse (from example-node.js)', async () => { + // From example-node.js line 743 + const htmlString = ` + + + + + + +
Cell with no left borderNormal cell
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + const tcBordersRegex = /(.*?)<\/w:tcBorders>/gs; + const tcBordersMatches = parsed.xml.match(tcBordersRegex); + + expect(tcBordersMatches).not.toBeNull(); + // Table should have border="1" so cells get borders + expect(tcBordersMatches.length).toBeGreaterThan(0); + // Note: Cell-level border-left:none override may or may not work depending on implementation + // The test just verifies the table renders with borders + }); + + test('should handle table with rowspan and colspan (from example-node.js)', async () => { + // From example-node.js line 1903 + const htmlString = ` + + + + + + + + + + + + + + + + +
+

Header spanning 2 columns

+
+

Single header

+
+

Cell spanning 2 rows

+
+

data2

+
+

data3

+
`; + + const result = await HTMLtoDOCX(htmlString, undefined, {}); + const parsed = await parseDOCX(result); + + // Should have gridSpan for colspan + expect(parsed.xml).toMatch(/