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
+211 -146
View File
@@ -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<Blob> {
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<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(
@@ -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<Word
return new docx.TableCell({
children: [
new docx.Paragraph({
alignment: docx.AlignmentType.LEFT,
children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')],
spacing: { after: 0, line: 300 },
}),
@@ -680,7 +735,7 @@ async function buildWordTableCell(docx: DocxModule, cell: Element): Promise<Word
}
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) {
@@ -786,7 +841,7 @@ async function buildWordImageParagraph(
}
return new docx.Paragraph({
alignment: docx.AlignmentType.CENTER,
alignment: docx.AlignmentType.LEFT,
children: [imageRun],
spacing: { before: 100, after: 180 },
keepLines: true,
@@ -1024,7 +1079,17 @@ function fitImageSize(
}
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 {