feat(word-export): enhance Word document generation with alignment options and normalization for CJK text
Frontend CI / Frontend (push) Has been cancelled

This commit is contained in:
2026-05-24 02:08:52 +08:00
parent 4a2df0435a
commit 520cde5637
+69 -4
View File
@@ -60,6 +60,10 @@ type MarkdownTokenLike = {
type DocxModule = typeof import('docx') type DocxModule = typeof import('docx')
type BrowserSetImmediate = (handler: (...args: unknown[]) => void, ...args: unknown[]) => number
type BrowserClearImmediate = (handle?: number) => void
type WordTextStyle = { type WordTextStyle = {
bold?: boolean bold?: boolean
italics?: boolean italics?: boolean
@@ -258,6 +262,7 @@ async function buildWordDocumentBlob(
markdown: string, markdown: string,
untitledLabel: string, untitledLabel: string,
): Promise<Blob> { ): Promise<Blob> {
return withWordExportScheduler(async () => {
const docx = await import('docx') const docx = await import('docx')
const documentTitle = title || untitledLabel const documentTitle = title || untitledLabel
const children = await buildWordChildren(docx, title, markdown) const children = await buildWordChildren(docx, title, markdown)
@@ -328,6 +333,7 @@ async function buildWordDocumentBlob(
}, },
paragraph: { paragraph: {
spacing: { after: 160, line: 360 }, spacing: { after: 160, line: 360 },
alignment: docx.AlignmentType.LEFT,
}, },
}, },
heading1: { heading1: {
@@ -340,6 +346,7 @@ async function buildWordDocumentBlob(
paragraph: { paragraph: {
spacing: { before: 260, after: 180 }, spacing: { before: 260, after: 180 },
keepNext: true, keepNext: true,
alignment: docx.AlignmentType.LEFT,
}, },
}, },
heading2: { heading2: {
@@ -352,6 +359,7 @@ async function buildWordDocumentBlob(
paragraph: { paragraph: {
spacing: { before: 240, after: 140 }, spacing: { before: 240, after: 140 },
keepNext: true, keepNext: true,
alignment: docx.AlignmentType.LEFT,
}, },
}, },
heading3: { heading3: {
@@ -364,6 +372,7 @@ async function buildWordDocumentBlob(
paragraph: { paragraph: {
spacing: { before: 200, after: 120 }, spacing: { before: 200, after: 120 },
keepNext: true, keepNext: true,
alignment: docx.AlignmentType.LEFT,
}, },
}, },
hyperlink: { hyperlink: {
@@ -386,6 +395,7 @@ async function buildWordDocumentBlob(
paragraph: { paragraph: {
spacing: { after: 260 }, spacing: { after: 260 },
keepNext: true, keepNext: true,
alignment: docx.AlignmentType.LEFT,
}, },
}, },
], ],
@@ -412,6 +422,41 @@ async function buildWordDocumentBlob(
}) })
return docx.Packer.toBlob(document) return docx.Packer.toBlob(document)
})
}
async function withWordExportScheduler<T>(work: () => Promise<T>): Promise<T> {
const scope = globalThis as typeof globalThis & {
setImmediate?: BrowserSetImmediate
clearImmediate?: BrowserClearImmediate
}
const hadSetImmediate = Object.prototype.hasOwnProperty.call(scope, 'setImmediate')
const hadClearImmediate = Object.prototype.hasOwnProperty.call(scope, 'clearImmediate')
const previousSetImmediate = scope.setImmediate
const previousClearImmediate = scope.clearImmediate
scope.setImmediate = (handler: (...args: unknown[]) => void, ...args: unknown[]) =>
window.setTimeout(() => handler(...args), 0)
scope.clearImmediate = (handle?: number) => {
if (typeof handle === 'number') {
window.clearTimeout(handle)
}
}
try {
return await work()
} finally {
if (hadSetImmediate) {
scope.setImmediate = previousSetImmediate
} else {
delete scope.setImmediate
}
if (hadClearImmediate) {
scope.clearImmediate = previousClearImmediate
} else {
delete scope.clearImmediate
}
}
} }
async function buildWordChildren( async function buildWordChildren(
@@ -436,7 +481,7 @@ async function buildWordChildren(
} }
if (!children.length) { if (!children.length) {
children.push(new docx.Paragraph('')) children.push(new docx.Paragraph({ alignment: docx.AlignmentType.LEFT }))
} }
return children return children
@@ -454,6 +499,7 @@ async function buildWordBlock(
return [ return [
new docx.Paragraph({ new docx.Paragraph({
style: 'ArticleTitle', style: 'ArticleTitle',
alignment: docx.AlignmentType.LEFT,
children: await buildWordInlineChildren(docx, element), children: await buildWordInlineChildren(docx, element),
}), }),
] ]
@@ -462,6 +508,7 @@ async function buildWordBlock(
if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') { if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') {
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
heading: heading:
tagName === 'h1' tagName === 'h1'
? docx.HeadingLevel.HEADING_1 ? docx.HeadingLevel.HEADING_1
@@ -476,6 +523,7 @@ async function buildWordBlock(
if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') { if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') {
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
heading: docx.HeadingLevel.HEADING_3, heading: docx.HeadingLevel.HEADING_3,
children: await buildWordInlineChildren(docx, element), children: await buildWordInlineChildren(docx, element),
}), }),
@@ -494,6 +542,7 @@ async function buildWordBlock(
const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }) const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR })
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: quoteChildren.length ? quoteChildren : [new docx.TextRun('')], children: quoteChildren.length ? quoteChildren : [new docx.TextRun('')],
border: { border: {
left: { left: {
@@ -511,6 +560,7 @@ async function buildWordBlock(
if (tagName === 'pre') { if (tagName === 'pre') {
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: [ children: [
new docx.TextRun({ new docx.TextRun({
text: element.textContent ?? '', text: element.textContent ?? '',
@@ -528,6 +578,7 @@ async function buildWordBlock(
if (tagName === 'hr') { if (tagName === 'hr') {
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
border: { border: {
bottom: { bottom: {
color: 'E5E7EB', color: 'E5E7EB',
@@ -557,6 +608,7 @@ async function buildWordBlock(
const inlineChildren = await buildWordInlineChildren(docx, element) const inlineChildren = await buildWordInlineChildren(docx, element)
return [ return [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: inlineChildren.length ? inlineChildren : [new docx.TextRun('')], 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 }
@@ -570,6 +622,7 @@ async function buildWordBlock(
return paragraphChildren.length return paragraphChildren.length
? [ ? [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: paragraphChildren, children: paragraphChildren,
spacing: { after: 160, line: 360 }, spacing: { after: 160, line: 360 },
}), }),
@@ -607,6 +660,7 @@ async function buildWordList(
children.push( children.push(
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')], children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')],
numbering: { numbering: {
reference: ordered ? 'article-number' : 'article-bullet', reference: ordered ? 'article-number' : 'article-bullet',
@@ -670,6 +724,7 @@ async function buildWordTableCell(docx: DocxModule, cell: Element): Promise<Word
return new docx.TableCell({ return new docx.TableCell({
children: [ children: [
new docx.Paragraph({ new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')], children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')],
spacing: { after: 0, line: 300 }, spacing: { after: 0, line: 300 },
}), }),
@@ -680,7 +735,7 @@ async function buildWordTableCell(docx: DocxModule, cell: Element): Promise<Word
} }
function emptyWordTableCell(docx: DocxModule): WordTableCell { function emptyWordTableCell(docx: DocxModule): WordTableCell {
return new docx.TableCell({ children: [new docx.Paragraph('')] }) return new docx.TableCell({ children: [new docx.Paragraph({ alignment: docx.AlignmentType.LEFT })] })
} }
function wordTableBorder(docx: DocxModule) { function wordTableBorder(docx: DocxModule) {
@@ -786,7 +841,7 @@ async function buildWordImageParagraph(
} }
return new docx.Paragraph({ return new docx.Paragraph({
alignment: docx.AlignmentType.CENTER, alignment: docx.AlignmentType.LEFT,
children: [imageRun], children: [imageRun],
spacing: { before: 100, after: 180 }, spacing: { before: 100, after: 180 },
keepLines: true, keepLines: true,
@@ -1024,7 +1079,17 @@ function fitImageSize(
} }
function normalizeWordText(text: string): string { function normalizeWordText(text: string): string {
return text.replace(/\s+/g, ' ') return normalizeCjkWordText(text.replace(/\s+/g, ' '))
}
function normalizeCjkWordText(text: string): string {
return text
.replace(/(?<=[\p{Script=Han}]) (?=[\p{Script=Han}])/gu, '')
.replace(/(?<=[\p{Script=Han}]) (?=[])/gu, '')
.replace(/(?<=[]) (?=[\p{Script=Han}])/gu, '')
.replace(/(?<=[]) (?=[\p{Script=Han}])/gu, '')
.replace(/(?<=[\p{Script=Han}]) (?=[a-zA-Z] [\p{Script=Han}])/gu, '')
.replace(/(?<=[\p{Script=Han}] [a-zA-Z]) (?=[\p{Script=Han}])/gu, '')
} }
function getDirectText(element: Element): string { function getDirectText(element: Element): string {