feat(article-export): enhance Word document generation with async image handling and brand ID support

This commit is contained in:
2026-05-22 11:11:36 +08:00
parent 56400805f4
commit 9cc6537e68
+101 -77
View File
@@ -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<HTMLImageElement>(':scope > img')).map((image) =>
buildWordImageParagraph(docx, image),
),
)
).filter((paragraph): paragraph is InstanceType<DocxModule['Paragraph']> => 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<WordFileChild[]> {
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<DocxModule['Table']> {
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<InstanceType<DocxModule['Table']>> {
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<DocxMo
})
}
function buildWordTableCell(docx: DocxModule, cell: Element): WordTableCell {
async function buildWordTableCell(docx: DocxModule, cell: Element): Promise<WordTableCell> {
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<WordParagraphChild[]> {
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<InstanceType<DocxModule['Paragraph']> | 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<WordParagraphChild | null> {
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<WordIm
}
const resolvedSrc = resolveBrowserURL(src)
const fetchSrc = buildWordImageFetchURL(resolvedSrc)
const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? ''
const fallbackWidth = Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX
const fallbackHeight = Number.parseInt(image.getAttribute('height') ?? '', 10) || 360
try {
const response = await fetch(resolvedSrc, buildWordImageFetchInit(resolvedSrc))
const response = await fetch(fetchSrc, buildWordImageFetchInit(fetchSrc))
if (!response.ok) {
return null
}
const contentType = response.headers.get('content-type')
const data = await response.arrayBuffer()
const dimensions = await readBrowserImageSize(resolvedSrc)
const type = inferWordImageType(resolvedSrc, contentType)
const dimensions = await readBrowserImageSize(fetchSrc)
const type = inferWordImageType(fetchSrc, contentType)
if (!type) {
const converted = await convertImageBytesToPng(data, contentType)
return converted
@@ -851,15 +857,33 @@ async function loadWordImageData(image: HTMLImageElement | null): Promise<WordIm
}
}
function buildWordImageFetchURL(src: string): string {
try {
const url = new URL(src, window.location.href)
if (url.pathname.startsWith('/api/public/assets/') && !url.searchParams.has('format')) {
url.searchParams.set('format', 'png')
return url.toString()
}
} catch {
// Keep the original URL; fetch will surface the actual failure.
}
return src
}
function buildWordImageFetchInit(src: string): RequestInit {
const headers = new Headers()
try {
const url = new URL(src)
const token = getStoredAccessToken()
if (token && url.pathname.startsWith('/api/')) {
if (token && url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/public/')) {
headers.set('Authorization', `Bearer ${token}`)
}
const brandId = readStoredCurrentBrandId()
if (brandId && url.pathname.startsWith('/api/tenant/')) {
headers.set('X-Brand-ID', String(brandId))
}
} catch {
// ignore malformed image URLs; fetch will surface the actual failure.
}