Skip to content

Commit

Permalink
docxHtml: fix icon used in third level of lists and don't break when …
Browse files Browse the repository at this point in the history
…rendering list with level greater than max level (9)

fix #1133
  • Loading branch information
bjrmatos committed May 2, 2024
1 parent 9ec3ad8 commit b579aea
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,19 @@ module.exports = async function convertDocxMetaToNodes (reporter, docxMeta, html
}

if (currentDocxMeta.list != null) {
const pPrEl = findOrCreateChildNode(doc, 'w:pPr', containerEl)
const pStyleEl = findOrCreateChildNode(doc, 'w:pStyle', pPrEl)
const listParagraphStyleId = addOrGetListParagraphStyle(stylesDoc, listParagraphStyleIdCache)
const numId = await addOrGetNumbering(files, currentDocxMeta.list, numberingListsCache)
pStyleEl.setAttribute('w:val', listParagraphStyleId)
const numPrEl = findOrCreateChildNode(doc, 'w:numPr', pPrEl)
const iLvlEl = findOrCreateChildNode(doc, 'w:ilvl', numPrEl)
iLvlEl.setAttribute('w:val', currentDocxMeta.list.level - 1)
const numIdEl = findOrCreateChildNode(doc, 'w:numId', numPrEl)
numIdEl.setAttribute('w:val', numId)

if (numId != null) {
const pPrEl = findOrCreateChildNode(doc, 'w:pPr', containerEl)
const pStyleEl = findOrCreateChildNode(doc, 'w:pStyle', pPrEl)
const listParagraphStyleId = addOrGetListParagraphStyle(stylesDoc, listParagraphStyleIdCache)
pStyleEl.setAttribute('w:val', listParagraphStyleId)
const numPrEl = findOrCreateChildNode(doc, 'w:numPr', pPrEl)
const iLvlEl = findOrCreateChildNode(doc, 'w:ilvl', numPrEl)
iLvlEl.setAttribute('w:val', currentDocxMeta.list.level - 1)
const numIdEl = findOrCreateChildNode(doc, 'w:numId', numPrEl)
numIdEl.setAttribute('w:val', numId)
}
}

if (currentDocxMeta.backgroundColor != null) {
Expand Down Expand Up @@ -1140,6 +1143,14 @@ async function addOrGetNumbering (files, listInfo, cache) {
let numberingDoc
let numId

// NOTE: Word does not accept more than 9 levels, when this happens we skip the list style
const MAX_LEVEL = 9
const currentLvl = listInfo.level - 1

if (currentLvl >= MAX_LEVEL) {
return
}

const numberingFile = files.find(f => f.path === 'word/numbering.xml')

if (cache.has(listInfo.id)) {
Expand Down Expand Up @@ -1260,8 +1271,14 @@ async function addOrGetNumbering (files, listInfo, cache) {
numFmt = 'decimal'
}

if (listInfo.type === 'ul' && [1, 4, 7].includes(cLvl)) {
text = 'o'
if (listInfo.type === 'ul') {
if ([1, 4, 7].includes(cLvl)) {
text = 'o'
} else if ([2, 5, 8].includes(cLvl)) {
// NOTE: be aware that this is a different symbol than the default
// they may look the same rendered in the editor but they are different
text = ''
}
} else if (listInfo.type === 'ol') {
text = `%${cLvl + 1}.`
}
Expand Down Expand Up @@ -1324,7 +1341,7 @@ async function addOrGetNumbering (files, listInfo, cache) {
n.getAttribute('w:abstractNumId') === currentAbstractNumIdEl.getAttribute('w:val')
), numberingDoc.documentElement)

const targetLvl = (listInfo.level - 1).toString()
const targetLvl = currentLvl.toString()

const currentLvlEl = findChildNode((n) => (
n.nodeName === 'w:lvl' &&
Expand Down Expand Up @@ -1374,14 +1391,14 @@ function createLvl (numberingDoc, listLevel, opts) {
fontAttrs['w:hAnsi'] = opts.fontHansi
}

if (opts.fontHint != null) {
fontAttrs['w:hint'] = opts.fontHansi
}

if (opts.fontCs != null) {
fontAttrs['w:cs'] = opts.fontCs
}

if (opts.fontHint != null) {
fontAttrs['w:hint'] = opts.fontHint
}

if (Object.keys(fontAttrs).length > 0) {
lvlEl.appendChild(createNode(numberingDoc, 'w:rPr', {
children: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,10 @@ function applyListDataIfNeeded (data, node) {
node.tagName === 'ul' ||
node.tagName === 'ol'
) {
// we create a new id always and don't care about re-using same id for nested lists
// because we want to match output of html which allows a list to have both ordered
// and unordered lists at different levels,
// in docx this is not possible if you re-use the same id
data.listContainerId = `list_${generateRandomId()}`
} else if (node.tagName === 'li') {
if (
Expand Down
257 changes: 257 additions & 0 deletions packages/jsreport-docx/test/htmlTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -12834,6 +12834,263 @@ describe('docx html embed', () => {
}
})

const templateNestedWithBothListsStr = `<${listTag}><li>...<${listTag === 'ol' ? 'ul' : 'ol'}><li>...</li></${listTag === 'ol' ? 'ul' : 'ol'}></li><li>...</li><li>...<${listTag}><li>...</li></${listTag}></li></${listTag}>`

it(`${mode} mode - <${listTag}> with nested different list ${templateNestedWithBothListsStr}`, async () => {
const docxTemplateBuf = fs.readFileSync(path.join(docxDirPath, `${mode === 'block' ? 'html-embed-block' : 'html-embed-inline'}.docx`))

const result = await reporter.render({
template: {
engine: 'handlebars',
recipe: 'docx',
docx: {
templateAsset: {
content: docxTemplateBuf
}
}
},
data: {
html: createHtml(templateNestedWithBothListsStr, ['item1', 'item2', 'item3', 'item4', 'item5'])
}
})

// Write document for easier debugging
fs.writeFileSync(outputPath, result.content)

const [templateDoc] = await getDocumentsFromDocxBuf(docxTemplateBuf, ['word/document.xml'])
const templateTextNodesForDocxHtml = getTextNodesMatching(templateDoc, `{{docxHtml content=html${mode === 'block' ? '' : ' inline=true'}}}`)
const [doc, ...restOfDocuments] = await getDocumentsFromDocxBuf(result.content, ['word/document.xml', ...outputDocuments])

const assertExtra = {
mode,
outputDocuments: restOfDocuments
}

const numberingDoc = restOfDocuments[1]

const paragraphNodes = nodeListToArray(doc.getElementsByTagName('w:p'))

should(paragraphNodes.length).eql(mode === 'block' ? 5 : 1)

if (mode === 'block') {
paragraphAssert(paragraphNodes[0], templateTextNodesForDocxHtml[0], assertExtra)
should(paragraphNodes[1].getElementsByTagName('w:numId')?.[0]?.getAttribute('w:val')).be.eql('2')
paragraphAssert(paragraphNodes[2], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[3], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[4], templateTextNodesForDocxHtml[0], assertExtra)

const numberingNumNodes = findChildNode((n) => (
n.nodeName === 'w:num'
), numberingDoc.documentElement, true)

should(numberingNumNodes.length).eql(3)

const expectedFmt = listTag === 'ol' ? 'decimal' : 'bullet'

const abstractNumIdNodeForN1 = findChildNode('w:abstractNumId', numberingNumNodes[0])

const numFmtNodeForN1 = findChildNode('w:numFmt', findChildNode((n) => (
n.nodeName === 'w:lvl' &&
n.getAttribute('w:ilvl') === '0'
), findChildNode((n) => (
n.nodeName === 'w:abstractNum' &&
n.getAttribute('w:abstractNumId') === abstractNumIdNodeForN1.getAttribute('w:val')
), numberingDoc.documentElement)))

should(numFmtNodeForN1).be.ok()
should(numFmtNodeForN1.getAttribute('w:val')).eql(expectedFmt)

const expectedFmt2 = listTag === 'ol' ? 'bullet' : 'decimal'

const abstractNumIdNodeForN2 = findChildNode('w:abstractNumId', numberingNumNodes[1])

const numFmtNodeForN2 = findChildNode('w:numFmt', findChildNode((n) => (
n.nodeName === 'w:lvl' &&
n.getAttribute('w:ilvl') === '0'
), findChildNode((n) => (
n.nodeName === 'w:abstractNum' &&
n.getAttribute('w:abstractNumId') === abstractNumIdNodeForN2.getAttribute('w:val')
), numberingDoc.documentElement)))

should(numFmtNodeForN2).be.ok()
should(numFmtNodeForN2.getAttribute('w:val')).eql(expectedFmt2)

const expectedFmt3 = listTag === 'ol' ? 'decimal' : 'bullet'

const abstractNumIdNodeForN3 = findChildNode('w:abstractNumId', numberingNumNodes[2])

const numFmtNodeForN3 = findChildNode('w:numFmt', findChildNode((n) => (
n.nodeName === 'w:lvl' &&
n.getAttribute('w:ilvl') === '0'
), findChildNode((n) => (
n.nodeName === 'w:abstractNum' &&
n.getAttribute('w:abstractNumId') === abstractNumIdNodeForN3.getAttribute('w:val')
), numberingDoc.documentElement)))

should(numFmtNodeForN3).be.ok()
should(numFmtNodeForN3.getAttribute('w:val')).eql(expectedFmt3)

should(findChildNode((n) => (
n.nodeName === 'w:ilvl' &&
n.getAttribute('w:val') === '0'
), findChildNode('w:numPr', findChildNode('w:pPr', paragraphNodes[0])))).be.ok()

should(findChildNode((n) => (
n.nodeName === 'w:ilvl' &&
n.getAttribute('w:val') === '1'
), findChildNode('w:numPr', findChildNode('w:pPr', paragraphNodes[1])))).be.ok()

should(findChildNode((n) => (
n.nodeName === 'w:ilvl' &&
n.getAttribute('w:val') === '0'
), findChildNode('w:numPr', findChildNode('w:pPr', paragraphNodes[2])))).be.ok()

should(findChildNode((n) => (
n.nodeName === 'w:ilvl' &&
n.getAttribute('w:val') === '0'
), findChildNode('w:numPr', findChildNode('w:pPr', paragraphNodes[3])))).be.ok()

should(findChildNode((n) => (
n.nodeName === 'w:ilvl' &&
n.getAttribute('w:val') === '1'
), findChildNode('w:numPr', findChildNode('w:pPr', paragraphNodes[4])))).be.ok()

const textNodesInParagraph1 = nodeListToArray(paragraphNodes[0].getElementsByTagName('w:t'))
should(textNodesInParagraph1.length).eql(1)
should(textNodesInParagraph1[0].textContent).eql('item1')
const textNodesInParagraph2 = nodeListToArray(paragraphNodes[1].getElementsByTagName('w:t'))
should(textNodesInParagraph2.length).eql(1)
should(textNodesInParagraph2[0].textContent).eql('item2')
const textNodesInParagraph3 = nodeListToArray(paragraphNodes[2].getElementsByTagName('w:t'))
should(textNodesInParagraph3.length).eql(1)
should(textNodesInParagraph3[0].textContent).eql('item3')
const textNodesInParagraph4 = nodeListToArray(paragraphNodes[3].getElementsByTagName('w:t'))
should(textNodesInParagraph4.length).eql(1)
should(textNodesInParagraph4[0].textContent).eql('item4')
const textNodesInParagraph5 = nodeListToArray(paragraphNodes[4].getElementsByTagName('w:t'))
should(textNodesInParagraph5.length).eql(1)
should(textNodesInParagraph5[0].textContent).eql('item5')
} else {
paragraphAssert(paragraphNodes[0], templateTextNodesForDocxHtml[0], assertExtra)
const textNodes = nodeListToArray(paragraphNodes[0].getElementsByTagName('w:t'))
should(textNodes.length).eql(5)
should(textNodes[0].textContent).eql('item1')
should(textNodes[1].textContent).eql('item2')
should(textNodes[2].textContent).eql('item3')
should(textNodes[3].textContent).eql('item4')
should(textNodes[4].textContent).eql('item5')
}
})

const templateGreaterThanMaxDepthStr = `<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...<${listTag}><li>...</li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}></li></${listTag}>`

it(`${mode} mode - <${listTag}> with nested level greater than max depth ${templateGreaterThanMaxDepthStr}`, async () => {
const docxTemplateBuf = fs.readFileSync(path.join(docxDirPath, `${mode === 'block' ? 'html-embed-block' : 'html-embed-inline'}.docx`))

const result = await reporter.render({
template: {
engine: 'handlebars',
recipe: 'docx',
docx: {
templateAsset: {
content: docxTemplateBuf
}
}
},
data: {
html: createHtml(templateGreaterThanMaxDepthStr, [
'item1', 'item2', 'item3',
'item4', 'item5', 'item6',
'item7', 'item8', 'item9',
'item10'
])
}
})

// Write document for easier debugging
fs.writeFileSync(outputPath, result.content)

const [templateDoc] = await getDocumentsFromDocxBuf(docxTemplateBuf, ['word/document.xml'])
const templateTextNodesForDocxHtml = getTextNodesMatching(templateDoc, `{{docxHtml content=html${mode === 'block' ? '' : ' inline=true'}}}`)
const [doc, ...restOfDocuments] = await getDocumentsFromDocxBuf(result.content, ['word/document.xml', ...outputDocuments])

const assertExtra = {
mode,
outputDocuments: restOfDocuments
}

const numberingDoc = restOfDocuments[1]

const paragraphNodes = nodeListToArray(doc.getElementsByTagName('w:p'))

should(paragraphNodes.length).eql(mode === 'block' ? 10 : 1)

if (mode === 'block') {
paragraphAssert(paragraphNodes[0], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[1], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[2], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[3], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[4], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[5], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[6], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[7], templateTextNodesForDocxHtml[0], assertExtra)
paragraphAssert(paragraphNodes[8], templateTextNodesForDocxHtml[0], assertExtra)

should(nodeListToArray(paragraphNodes[9].getElementsByTagName('w:nuPr')).length).be.eql(0)

const numberingNumNodes = findChildNode((n) => (
n.nodeName === 'w:num'
), numberingDoc.documentElement, true)

should(numberingNumNodes.length).eql(9)

const textNodesInParagraph1 = nodeListToArray(paragraphNodes[0].getElementsByTagName('w:t'))
should(textNodesInParagraph1.length).eql(1)
should(textNodesInParagraph1[0].textContent).eql('item1')
const textNodesInParagraph2 = nodeListToArray(paragraphNodes[1].getElementsByTagName('w:t'))
should(textNodesInParagraph2.length).eql(1)
should(textNodesInParagraph2[0].textContent).eql('item2')
const textNodesInParagraph3 = nodeListToArray(paragraphNodes[2].getElementsByTagName('w:t'))
should(textNodesInParagraph3.length).eql(1)
should(textNodesInParagraph3[0].textContent).eql('item3')
const textNodesInParagraph4 = nodeListToArray(paragraphNodes[3].getElementsByTagName('w:t'))
should(textNodesInParagraph4.length).eql(1)
should(textNodesInParagraph4[0].textContent).eql('item4')
const textNodesInParagraph5 = nodeListToArray(paragraphNodes[4].getElementsByTagName('w:t'))
should(textNodesInParagraph5.length).eql(1)
should(textNodesInParagraph5[0].textContent).eql('item5')
const textNodesInParagraph6 = nodeListToArray(paragraphNodes[5].getElementsByTagName('w:t'))
should(textNodesInParagraph6.length).eql(1)
should(textNodesInParagraph6[0].textContent).eql('item6')
const textNodesInParagraph7 = nodeListToArray(paragraphNodes[6].getElementsByTagName('w:t'))
should(textNodesInParagraph7.length).eql(1)
should(textNodesInParagraph7[0].textContent).eql('item7')
const textNodesInParagraph8 = nodeListToArray(paragraphNodes[7].getElementsByTagName('w:t'))
should(textNodesInParagraph8.length).eql(1)
should(textNodesInParagraph8[0].textContent).eql('item8')
const textNodesInParagraph9 = nodeListToArray(paragraphNodes[8].getElementsByTagName('w:t'))
should(textNodesInParagraph9.length).eql(1)
should(textNodesInParagraph9[0].textContent).eql('item9')
const textNodesInParagraph10 = nodeListToArray(paragraphNodes[9].getElementsByTagName('w:t'))
should(textNodesInParagraph10.length).eql(1)
should(textNodesInParagraph10[0].textContent).eql('item10')
} else {
paragraphAssert(paragraphNodes[0], templateTextNodesForDocxHtml[0], assertExtra)
const textNodes = nodeListToArray(paragraphNodes[0].getElementsByTagName('w:t'))
should(textNodes.length).eql(10)
should(textNodes[0].textContent).eql('item1')
should(textNodes[1].textContent).eql('item2')
should(textNodes[2].textContent).eql('item3')
should(textNodes[3].textContent).eql('item4')
should(textNodes[4].textContent).eql('item5')
should(textNodes[5].textContent).eql('item6')
should(textNodes[6].textContent).eql('item7')
should(textNodes[7].textContent).eql('item8')
should(textNodes[8].textContent).eql('item9')
should(textNodes[9].textContent).eql('item10')
}
})

const templateTextChildStr = `<${listTag}>...<li>...</li><li>...</li></${listTag}>`

it(`${mode} mode - <${listTag}> with text child directly in <${listTag}> ${templateTextChildStr}`, async () => {
Expand Down

0 comments on commit b579aea

Please sign in to comment.