From 520cde5637ad377acf0e4d3c34e60557b1a6063c Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 24 May 2026 02:08:52 +0800 Subject: [PATCH] feat(word-export): enhance Word document generation with alignment options and normalization for CJK text --- apps/admin-web/src/lib/article-export.ts | 357 ++++++++++++++--------- 1 file changed, 211 insertions(+), 146 deletions(-) diff --git a/apps/admin-web/src/lib/article-export.ts b/apps/admin-web/src/lib/article-export.ts index da5e386..792849b 100644 --- a/apps/admin-web/src/lib/article-export.ts +++ b/apps/admin-web/src/lib/article-export.ts @@ -60,6 +60,10 @@ type MarkdownTokenLike = { type DocxModule = typeof import('docx') +type BrowserSetImmediate = (handler: (...args: unknown[]) => void, ...args: unknown[]) => number + +type BrowserClearImmediate = (handle?: number) => void + type WordTextStyle = { bold?: boolean italics?: boolean @@ -258,160 +262,201 @@ async function buildWordDocumentBlob( markdown: string, untitledLabel: string, ): Promise { - const docx = await import('docx') - const documentTitle = title || untitledLabel - const children = await buildWordChildren(docx, title, markdown) - const document = new docx.Document({ - creator: '省心推', - title: documentTitle, - numbering: { - config: [ - { - reference: 'article-bullet', - levels: [ - { - level: 0, - format: docx.LevelFormat.BULLET, - text: '•', - style: { - paragraph: { - indent: { left: 480, hanging: 240 }, + return withWordExportScheduler(async () => { + const docx = await import('docx') + const documentTitle = title || untitledLabel + const children = await buildWordChildren(docx, title, markdown) + const document = new docx.Document({ + creator: '省心推', + title: documentTitle, + numbering: { + config: [ + { + reference: 'article-bullet', + levels: [ + { + level: 0, + format: docx.LevelFormat.BULLET, + text: '•', + style: { + paragraph: { + indent: { left: 480, hanging: 240 }, + }, }, }, - }, - { - level: 1, - format: docx.LevelFormat.BULLET, - text: '◦', - style: { - paragraph: { - indent: { left: 840, hanging: 240 }, + { + level: 1, + format: docx.LevelFormat.BULLET, + text: '◦', + style: { + paragraph: { + indent: { left: 840, hanging: 240 }, + }, }, }, + ], + }, + { + reference: 'article-number', + levels: [ + { + level: 0, + format: docx.LevelFormat.DECIMAL, + text: '%1.', + style: { + paragraph: { + indent: { left: 520, hanging: 280 }, + }, + }, + }, + { + level: 1, + format: docx.LevelFormat.LOWER_LETTER, + text: '%2.', + style: { + paragraph: { + indent: { left: 900, hanging: 280 }, + }, + }, + }, + ], + }, + ], + }, + styles: { + default: { + document: { + run: { + font: 'Microsoft YaHei', + size: 22, + color: DOCX_TEXT_COLOR, }, - ], + paragraph: { + spacing: { after: 160, line: 360 }, + alignment: docx.AlignmentType.LEFT, + }, + }, + heading1: { + run: { + bold: true, + color: '101828', + size: 36, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 260, after: 180 }, + keepNext: true, + alignment: docx.AlignmentType.LEFT, + }, + }, + heading2: { + run: { + bold: true, + color: '101828', + size: 30, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 240, after: 140 }, + keepNext: true, + alignment: docx.AlignmentType.LEFT, + }, + }, + heading3: { + run: { + bold: true, + color: '101828', + size: 25, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 200, after: 120 }, + keepNext: true, + alignment: docx.AlignmentType.LEFT, + }, + }, + hyperlink: { + run: { + color: DOCX_LINK_COLOR, + underline: {}, + }, + }, }, + paragraphStyles: [ + { + id: 'ArticleTitle', + name: 'Article Title', + run: { + bold: true, + color: '101828', + size: 44, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { after: 260 }, + keepNext: true, + alignment: docx.AlignmentType.LEFT, + }, + }, + ], + }, + sections: [ { - reference: 'article-number', - levels: [ - { - level: 0, - format: docx.LevelFormat.DECIMAL, - text: '%1.', - style: { - paragraph: { - indent: { left: 520, hanging: 280 }, - }, + properties: { + page: { + size: { + width: DOCX_PAGE_WIDTH_DXA, + height: DOCX_PAGE_HEIGHT_DXA, + }, + margin: { + top: DOCX_PAGE_MARGIN_DXA, + right: DOCX_PAGE_MARGIN_DXA, + bottom: DOCX_PAGE_MARGIN_DXA, + left: DOCX_PAGE_MARGIN_DXA, }, }, - { - level: 1, - format: docx.LevelFormat.LOWER_LETTER, - text: '%2.', - style: { - paragraph: { - indent: { left: 900, hanging: 280 }, - }, - }, - }, - ], + }, + children, }, ], - }, - styles: { - default: { - document: { - run: { - font: 'Microsoft YaHei', - size: 22, - color: DOCX_TEXT_COLOR, - }, - paragraph: { - spacing: { after: 160, line: 360 }, - }, - }, - heading1: { - run: { - bold: true, - color: '101828', - size: 36, - font: 'Microsoft YaHei', - }, - paragraph: { - spacing: { before: 260, after: 180 }, - keepNext: true, - }, - }, - heading2: { - run: { - bold: true, - color: '101828', - size: 30, - font: 'Microsoft YaHei', - }, - paragraph: { - spacing: { before: 240, after: 140 }, - keepNext: true, - }, - }, - heading3: { - run: { - bold: true, - color: '101828', - size: 25, - font: 'Microsoft YaHei', - }, - paragraph: { - spacing: { before: 200, after: 120 }, - keepNext: true, - }, - }, - hyperlink: { - run: { - color: DOCX_LINK_COLOR, - underline: {}, - }, - }, - }, - paragraphStyles: [ - { - id: 'ArticleTitle', - name: 'Article Title', - run: { - bold: true, - color: '101828', - size: 44, - font: 'Microsoft YaHei', - }, - paragraph: { - spacing: { after: 260 }, - keepNext: true, - }, - }, - ], - }, - sections: [ - { - properties: { - page: { - size: { - width: DOCX_PAGE_WIDTH_DXA, - height: DOCX_PAGE_HEIGHT_DXA, - }, - margin: { - top: DOCX_PAGE_MARGIN_DXA, - right: DOCX_PAGE_MARGIN_DXA, - bottom: DOCX_PAGE_MARGIN_DXA, - left: DOCX_PAGE_MARGIN_DXA, - }, - }, - }, - children, - }, - ], - }) + }) - return docx.Packer.toBlob(document) + return docx.Packer.toBlob(document) + }) +} + +async function withWordExportScheduler(work: () => Promise): Promise { + 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( @@ -436,7 +481,7 @@ async function buildWordChildren( } if (!children.length) { - children.push(new docx.Paragraph('')) + children.push(new docx.Paragraph({ alignment: docx.AlignmentType.LEFT })) } return children @@ -454,6 +499,7 @@ async function buildWordBlock( return [ new docx.Paragraph({ style: 'ArticleTitle', + alignment: docx.AlignmentType.LEFT, children: await buildWordInlineChildren(docx, element), }), ] @@ -462,6 +508,7 @@ async function buildWordBlock( if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') { return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, heading: tagName === 'h1' ? docx.HeadingLevel.HEADING_1 @@ -476,6 +523,7 @@ async function buildWordBlock( if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') { return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, heading: docx.HeadingLevel.HEADING_3, children: await buildWordInlineChildren(docx, element), }), @@ -494,6 +542,7 @@ async function buildWordBlock( const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }) return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, children: quoteChildren.length ? quoteChildren : [new docx.TextRun('')], border: { left: { @@ -511,6 +560,7 @@ async function buildWordBlock( if (tagName === 'pre') { return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, children: [ new docx.TextRun({ text: element.textContent ?? '', @@ -528,6 +578,7 @@ async function buildWordBlock( if (tagName === 'hr') { return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, border: { bottom: { color: 'E5E7EB', @@ -557,6 +608,7 @@ async function buildWordBlock( const inlineChildren = await buildWordInlineChildren(docx, element) return [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, children: inlineChildren.length ? inlineChildren : [new docx.TextRun('')], numbering: listState ? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level } @@ -570,6 +622,7 @@ async function buildWordBlock( return paragraphChildren.length ? [ new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, children: paragraphChildren, spacing: { after: 160, line: 360 }, }), @@ -607,6 +660,7 @@ async function buildWordList( children.push( new docx.Paragraph({ + alignment: docx.AlignmentType.LEFT, children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')], numbering: { reference: ordered ? 'article-number' : 'article-bullet', @@ -670,6 +724,7 @@ async function buildWordTableCell(docx: DocxModule, cell: Element): Promise