feat(article-export): enhance Word document generation with async image handling and brand ID support
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user