feat(word-export): enhance Word document generation with alignment options and normalization for CJK text
Frontend CI / Frontend (push) Has been cancelled
Frontend CI / Frontend (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user