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 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.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user