From 9cc6537e68c16df17d8772ec5d3fa8382c5d024d Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 22 May 2026 11:11:36 +0800 Subject: [PATCH] feat(article-export): enhance Word document generation with async image handling and brand ID support --- apps/admin-web/src/lib/article-export.ts | 178 +++++++++++++---------- 1 file changed, 101 insertions(+), 77 deletions(-) diff --git a/apps/admin-web/src/lib/article-export.ts b/apps/admin-web/src/lib/article-export.ts index 21ca298..43996d8 100644 --- a/apps/admin-web/src/lib/article-export.ts +++ b/apps/admin-web/src/lib/article-export.ts @@ -1,5 +1,6 @@ import DOMPurify from 'dompurify' import MarkdownIt from 'markdown-it' +import { readStoredCurrentBrandId } from './current-brand' import { getStoredAccessToken } from './session' import type { FileChild as WordFileChild, @@ -443,7 +444,7 @@ async function buildWordBlock( return [ new docx.Paragraph({ style: 'ArticleTitle', - children: buildWordInlineChildren(docx, element), + children: await buildWordInlineChildren(docx, element), }), ] } @@ -457,7 +458,7 @@ async function buildWordBlock( : tagName === 'h2' ? docx.HeadingLevel.HEADING_2 : docx.HeadingLevel.HEADING_3, - children: buildWordInlineChildren(docx, element), + children: await buildWordInlineChildren(docx, element), }), ] } @@ -466,7 +467,7 @@ async function buildWordBlock( return [ new docx.Paragraph({ heading: docx.HeadingLevel.HEADING_3, - children: buildWordInlineChildren(docx, element), + children: await buildWordInlineChildren(docx, element), }), ] } @@ -476,18 +477,14 @@ async function buildWordBlock( } if (tagName === 'table') { - return [buildWordTable(docx, element)] + return [await buildWordTable(docx, element)] } if (tagName === 'blockquote') { - const paragraphs = await buildWordContainerBlocks(docx, element) - return paragraphs.map((block) => { - if (!(block instanceof docx.Paragraph)) { - return block - } - - return new docx.Paragraph({ - children: buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }), + const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }) + return [ + new docx.Paragraph({ + children: quoteChildren.length ? quoteChildren : [new docx.TextRun('')], border: { left: { color: DOCX_BORDER_COLOR, @@ -497,8 +494,8 @@ async function buildWordBlock( }, }, spacing: { before: 80, after: 180 }, - }) - }) + }), + ] } if (tagName === 'pre') { @@ -535,14 +532,22 @@ async function buildWordBlock( } if (tagName === 'p' && element.querySelector(':scope > img') && !getDirectText(element).trim()) { - const imageParagraph = await buildWordImageParagraph(docx, element.querySelector('img')) - return imageParagraph ? [imageParagraph] : [] + const imageParagraphs = ( + await Promise.all( + Array.from(element.querySelectorAll(':scope > img')).map((image) => + buildWordImageParagraph(docx, image), + ), + ) + ).filter((paragraph): paragraph is InstanceType => Boolean(paragraph)) + + return imageParagraphs } if (tagName === 'p' || tagName === 'div' || tagName === 'section' || tagName === 'article') { + const inlineChildren = await buildWordInlineChildren(docx, element) return [ new docx.Paragraph({ - children: buildWordInlineChildren(docx, element), + children: inlineChildren.length ? inlineChildren : [new docx.TextRun('')], numbering: listState ? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level } : undefined, @@ -551,7 +556,7 @@ async function buildWordBlock( ] } - const paragraphChildren = buildWordInlineChildren(docx, element) + const paragraphChildren = await buildWordInlineChildren(docx, element) return paragraphChildren.length ? [ new docx.Paragraph({ @@ -562,27 +567,6 @@ async function buildWordBlock( : [] } -async function buildWordContainerBlocks( - docx: DocxModule, - element: Element, -): Promise { - const children: WordFileChild[] = [] - - if (element.children.length === 0) { - return [ - new docx.Paragraph({ - children: buildWordInlineChildren(docx, element), - }), - ] - } - - for (const child of Array.from(element.children)) { - children.push(...(await buildWordBlock(docx, child))) - } - - return children -} - async function buildWordList( docx: DocxModule, element: Element, @@ -609,7 +593,7 @@ async function buildWordList( const itemContainer = document.createElement('span') itemInlineChildren.forEach((child) => itemContainer.append(child.cloneNode(true))) - const inlineRuns = buildWordInlineChildren(docx, itemContainer) + const inlineRuns = await buildWordInlineChildren(docx, itemContainer) children.push( new docx.Paragraph({ @@ -632,15 +616,22 @@ async function buildWordList( return children } -function buildWordTable(docx: DocxModule, element: Element): InstanceType { - const rows = Array.from(element.querySelectorAll('tr')).map((row) => { - const cells = Array.from(row.children).filter((cell) => ['td', 'th'].includes(cell.tagName.toLowerCase())) - return new docx.TableRow({ - cantSplit: true, - tableHeader: cells.some((cell) => cell.tagName.toLowerCase() === 'th'), - children: cells.map((cell) => buildWordTableCell(docx, cell)), - }) - }) +async function buildWordTable( + docx: DocxModule, + element: Element, +): Promise> { + const rows = await Promise.all( + Array.from(element.querySelectorAll('tr')).map(async (row) => { + const cells = Array.from(row.children).filter((cell) => + ['td', 'th'].includes(cell.tagName.toLowerCase()), + ) + return new docx.TableRow({ + cantSplit: true, + tableHeader: cells.some((cell) => cell.tagName.toLowerCase() === 'th'), + children: await Promise.all(cells.map((cell) => buildWordTableCell(docx, cell))), + }) + }), + ) return new docx.Table({ rows: rows.length ? rows : [new docx.TableRow({ children: [emptyWordTableCell(docx)] })], @@ -663,9 +654,9 @@ function buildWordTable(docx: DocxModule, element: Element): InstanceType { const isHeader = cell.tagName.toLowerCase() === 'th' - const inlineRuns = buildWordInlineChildren(docx, cell, { bold: isHeader }) + const inlineRuns = await buildWordInlineChildren(docx, cell, { bold: isHeader }) return new docx.TableCell({ children: [ new docx.Paragraph({ @@ -690,11 +681,11 @@ function wordTableBorder(docx: DocxModule) { } } -function buildWordInlineChildren( +async function buildWordInlineChildren( docx: DocxModule, element: Node, inheritedStyle: WordTextStyle = {}, -): WordParagraphChild[] { +): Promise { const children: WordParagraphChild[] = [] for (const child of Array.from(element.childNodes)) { @@ -734,11 +725,15 @@ function buildWordInlineChildren( continue } if (tagName === 'img') { + const imageRun = await buildWordImageRun(docx, childElement as HTMLImageElement) + if (imageRun) { + children.push(imageRun) + } continue } if (tagName === 'a') { const href = childElement.getAttribute('href')?.trim() - const linkChildren = buildWordInlineChildren(docx, childElement, { + const linkChildren = await buildWordInlineChildren(docx, childElement, { ...nextStyle, color: DOCX_LINK_COLOR, }) @@ -751,7 +746,7 @@ function buildWordInlineChildren( continue } - children.push(...buildWordInlineChildren(docx, childElement, nextStyle)) + children.push(...(await buildWordInlineChildren(docx, childElement, nextStyle))) } return children @@ -775,30 +770,40 @@ async function buildWordImageParagraph( docx: DocxModule, image: HTMLImageElement | null, ): Promise | null> { + const imageRun = await buildWordImageRun(docx, image) + if (!imageRun) { + return null + } + + return new docx.Paragraph({ + alignment: docx.AlignmentType.CENTER, + children: [imageRun], + spacing: { before: 100, after: 180 }, + keepLines: true, + }) +} + +async function buildWordImageRun( + docx: DocxModule, + image: HTMLImageElement | null, +): Promise { const imageData = await loadWordImageData(image) if (!imageData) { return null } const size = fitImageSize(imageData.width, imageData.height, DOCX_IMAGE_MAX_WIDTH_PX, DOCX_IMAGE_MAX_HEIGHT_PX) - return new docx.Paragraph({ - alignment: docx.AlignmentType.CENTER, - children: [ - new docx.ImageRun({ - type: imageData.type, - data: imageData.data, - transformation: { - width: size.width, - height: size.height, - }, - altText: { - name: imageData.alt || 'article image', - title: imageData.alt || 'article image', - }, - }), - ], - spacing: { before: 100, after: 180 }, - keepLines: true, + return new docx.ImageRun({ + type: imageData.type, + data: imageData.data, + transformation: { + width: size.width, + height: size.height, + }, + altText: { + name: imageData.alt || 'article image', + title: imageData.alt || 'article image', + }, }) } @@ -809,20 +814,21 @@ async function loadWordImageData(image: HTMLImageElement | null): Promise