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 DOMPurify from 'dompurify'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { readStoredCurrentBrandId } from './current-brand'
import { getStoredAccessToken } from './session' import { getStoredAccessToken } from './session'
import type { import type {
FileChild as WordFileChild, FileChild as WordFileChild,
@@ -443,7 +444,7 @@ async function buildWordBlock(
return [ return [
new docx.Paragraph({ new docx.Paragraph({
style: 'ArticleTitle', style: 'ArticleTitle',
children: buildWordInlineChildren(docx, element), children: await buildWordInlineChildren(docx, element),
}), }),
] ]
} }
@@ -457,7 +458,7 @@ async function buildWordBlock(
: tagName === 'h2' : tagName === 'h2'
? docx.HeadingLevel.HEADING_2 ? docx.HeadingLevel.HEADING_2
: docx.HeadingLevel.HEADING_3, : docx.HeadingLevel.HEADING_3,
children: buildWordInlineChildren(docx, element), children: await buildWordInlineChildren(docx, element),
}), }),
] ]
} }
@@ -466,7 +467,7 @@ async function buildWordBlock(
return [ return [
new docx.Paragraph({ new docx.Paragraph({
heading: docx.HeadingLevel.HEADING_3, heading: docx.HeadingLevel.HEADING_3,
children: buildWordInlineChildren(docx, element), children: await buildWordInlineChildren(docx, element),
}), }),
] ]
} }
@@ -476,18 +477,14 @@ async function buildWordBlock(
} }
if (tagName === 'table') { if (tagName === 'table') {
return [buildWordTable(docx, element)] return [await buildWordTable(docx, element)]
} }
if (tagName === 'blockquote') { if (tagName === 'blockquote') {
const paragraphs = await buildWordContainerBlocks(docx, element) const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR })
return paragraphs.map((block) => { return [
if (!(block instanceof docx.Paragraph)) { new docx.Paragraph({
return block children: quoteChildren.length ? quoteChildren : [new docx.TextRun('')],
}
return new docx.Paragraph({
children: buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }),
border: { border: {
left: { left: {
color: DOCX_BORDER_COLOR, color: DOCX_BORDER_COLOR,
@@ -497,8 +494,8 @@ async function buildWordBlock(
}, },
}, },
spacing: { before: 80, after: 180 }, spacing: { before: 80, after: 180 },
}) }),
}) ]
} }
if (tagName === 'pre') { if (tagName === 'pre') {
@@ -535,14 +532,22 @@ async function buildWordBlock(
} }
if (tagName === 'p' && element.querySelector(':scope > img') && !getDirectText(element).trim()) { if (tagName === 'p' && element.querySelector(':scope > img') && !getDirectText(element).trim()) {
const imageParagraph = await buildWordImageParagraph(docx, element.querySelector('img')) const imageParagraphs = (
return imageParagraph ? [imageParagraph] : [] 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') { if (tagName === 'p' || tagName === 'div' || tagName === 'section' || tagName === 'article') {
const inlineChildren = await buildWordInlineChildren(docx, element)
return [ return [
new docx.Paragraph({ new docx.Paragraph({
children: buildWordInlineChildren(docx, element), children: inlineChildren.length ? inlineChildren : [new docx.TextRun('')],
numbering: listState numbering: listState
? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level } ? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level }
: undefined, : undefined,
@@ -551,7 +556,7 @@ async function buildWordBlock(
] ]
} }
const paragraphChildren = buildWordInlineChildren(docx, element) const paragraphChildren = await buildWordInlineChildren(docx, element)
return paragraphChildren.length return paragraphChildren.length
? [ ? [
new docx.Paragraph({ 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( async function buildWordList(
docx: DocxModule, docx: DocxModule,
element: Element, element: Element,
@@ -609,7 +593,7 @@ async function buildWordList(
const itemContainer = document.createElement('span') const itemContainer = document.createElement('span')
itemInlineChildren.forEach((child) => itemContainer.append(child.cloneNode(true))) itemInlineChildren.forEach((child) => itemContainer.append(child.cloneNode(true)))
const inlineRuns = buildWordInlineChildren(docx, itemContainer) const inlineRuns = await buildWordInlineChildren(docx, itemContainer)
children.push( children.push(
new docx.Paragraph({ new docx.Paragraph({
@@ -632,15 +616,22 @@ async function buildWordList(
return children return children
} }
function buildWordTable(docx: DocxModule, element: Element): InstanceType<DocxModule['Table']> { async function buildWordTable(
const rows = Array.from(element.querySelectorAll('tr')).map((row) => { docx: DocxModule,
const cells = Array.from(row.children).filter((cell) => ['td', 'th'].includes(cell.tagName.toLowerCase())) element: Element,
return new docx.TableRow({ ): Promise<InstanceType<DocxModule['Table']>> {
cantSplit: true, const rows = await Promise.all(
tableHeader: cells.some((cell) => cell.tagName.toLowerCase() === 'th'), Array.from(element.querySelectorAll('tr')).map(async (row) => {
children: cells.map((cell) => buildWordTableCell(docx, cell)), 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({ return new docx.Table({
rows: rows.length ? rows : [new docx.TableRow({ children: [emptyWordTableCell(docx)] })], 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 isHeader = cell.tagName.toLowerCase() === 'th'
const inlineRuns = buildWordInlineChildren(docx, cell, { bold: isHeader }) const inlineRuns = await buildWordInlineChildren(docx, cell, { bold: isHeader })
return new docx.TableCell({ return new docx.TableCell({
children: [ children: [
new docx.Paragraph({ new docx.Paragraph({
@@ -690,11 +681,11 @@ function wordTableBorder(docx: DocxModule) {
} }
} }
function buildWordInlineChildren( async function buildWordInlineChildren(
docx: DocxModule, docx: DocxModule,
element: Node, element: Node,
inheritedStyle: WordTextStyle = {}, inheritedStyle: WordTextStyle = {},
): WordParagraphChild[] { ): Promise<WordParagraphChild[]> {
const children: WordParagraphChild[] = [] const children: WordParagraphChild[] = []
for (const child of Array.from(element.childNodes)) { for (const child of Array.from(element.childNodes)) {
@@ -734,11 +725,15 @@ function buildWordInlineChildren(
continue continue
} }
if (tagName === 'img') { if (tagName === 'img') {
const imageRun = await buildWordImageRun(docx, childElement as HTMLImageElement)
if (imageRun) {
children.push(imageRun)
}
continue continue
} }
if (tagName === 'a') { if (tagName === 'a') {
const href = childElement.getAttribute('href')?.trim() const href = childElement.getAttribute('href')?.trim()
const linkChildren = buildWordInlineChildren(docx, childElement, { const linkChildren = await buildWordInlineChildren(docx, childElement, {
...nextStyle, ...nextStyle,
color: DOCX_LINK_COLOR, color: DOCX_LINK_COLOR,
}) })
@@ -751,7 +746,7 @@ function buildWordInlineChildren(
continue continue
} }
children.push(...buildWordInlineChildren(docx, childElement, nextStyle)) children.push(...(await buildWordInlineChildren(docx, childElement, nextStyle)))
} }
return children return children
@@ -775,30 +770,40 @@ async function buildWordImageParagraph(
docx: DocxModule, docx: DocxModule,
image: HTMLImageElement | null, image: HTMLImageElement | null,
): Promise<InstanceType<DocxModule['Paragraph']> | 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) const imageData = await loadWordImageData(image)
if (!imageData) { if (!imageData) {
return null return null
} }
const size = fitImageSize(imageData.width, imageData.height, DOCX_IMAGE_MAX_WIDTH_PX, DOCX_IMAGE_MAX_HEIGHT_PX) const size = fitImageSize(imageData.width, imageData.height, DOCX_IMAGE_MAX_WIDTH_PX, DOCX_IMAGE_MAX_HEIGHT_PX)
return new docx.Paragraph({ return new docx.ImageRun({
alignment: docx.AlignmentType.CENTER, type: imageData.type,
children: [ data: imageData.data,
new docx.ImageRun({ transformation: {
type: imageData.type, width: size.width,
data: imageData.data, height: size.height,
transformation: { },
width: size.width, altText: {
height: size.height, name: imageData.alt || 'article image',
}, title: imageData.alt || 'article image',
altText: { },
name: imageData.alt || 'article image',
title: imageData.alt || 'article image',
},
}),
],
spacing: { before: 100, after: 180 },
keepLines: true,
}) })
} }
@@ -809,20 +814,21 @@ async function loadWordImageData(image: HTMLImageElement | null): Promise<WordIm
} }
const resolvedSrc = resolveBrowserURL(src) const resolvedSrc = resolveBrowserURL(src)
const fetchSrc = buildWordImageFetchURL(resolvedSrc)
const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? '' const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? ''
const fallbackWidth = Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX const fallbackWidth = Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX
const fallbackHeight = Number.parseInt(image.getAttribute('height') ?? '', 10) || 360 const fallbackHeight = Number.parseInt(image.getAttribute('height') ?? '', 10) || 360
try { try {
const response = await fetch(resolvedSrc, buildWordImageFetchInit(resolvedSrc)) const response = await fetch(fetchSrc, buildWordImageFetchInit(fetchSrc))
if (!response.ok) { if (!response.ok) {
return null return null
} }
const contentType = response.headers.get('content-type') const contentType = response.headers.get('content-type')
const data = await response.arrayBuffer() const data = await response.arrayBuffer()
const dimensions = await readBrowserImageSize(resolvedSrc) const dimensions = await readBrowserImageSize(fetchSrc)
const type = inferWordImageType(resolvedSrc, contentType) const type = inferWordImageType(fetchSrc, contentType)
if (!type) { if (!type) {
const converted = await convertImageBytesToPng(data, contentType) const converted = await convertImageBytesToPng(data, contentType)
return converted 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 { function buildWordImageFetchInit(src: string): RequestInit {
const headers = new Headers() const headers = new Headers()
try { try {
const url = new URL(src) const url = new URL(src)
const token = getStoredAccessToken() 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}`) headers.set('Authorization', `Bearer ${token}`)
} }
const brandId = readStoredCurrentBrandId()
if (brandId && url.pathname.startsWith('/api/tenant/')) {
headers.set('X-Brand-ID', String(brandId))
}
} catch { } catch {
// ignore malformed image URLs; fetch will surface the actual failure. // ignore malformed image URLs; fetch will surface the actual failure.
} }