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 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,160 +262,201 @@ async function buildWordDocumentBlob(
|
|||||||
markdown: string,
|
markdown: string,
|
||||||
untitledLabel: string,
|
untitledLabel: string,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const docx = await import('docx')
|
return withWordExportScheduler(async () => {
|
||||||
const documentTitle = title || untitledLabel
|
const docx = await import('docx')
|
||||||
const children = await buildWordChildren(docx, title, markdown)
|
const documentTitle = title || untitledLabel
|
||||||
const document = new docx.Document({
|
const children = await buildWordChildren(docx, title, markdown)
|
||||||
creator: '省心推',
|
const document = new docx.Document({
|
||||||
title: documentTitle,
|
creator: '省心推',
|
||||||
numbering: {
|
title: documentTitle,
|
||||||
config: [
|
numbering: {
|
||||||
{
|
config: [
|
||||||
reference: 'article-bullet',
|
{
|
||||||
levels: [
|
reference: 'article-bullet',
|
||||||
{
|
levels: [
|
||||||
level: 0,
|
{
|
||||||
format: docx.LevelFormat.BULLET,
|
level: 0,
|
||||||
text: '•',
|
format: docx.LevelFormat.BULLET,
|
||||||
style: {
|
text: '•',
|
||||||
paragraph: {
|
style: {
|
||||||
indent: { left: 480, hanging: 240 },
|
paragraph: {
|
||||||
|
indent: { left: 480, hanging: 240 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
level: 1,
|
||||||
level: 1,
|
format: docx.LevelFormat.BULLET,
|
||||||
format: docx.LevelFormat.BULLET,
|
text: '◦',
|
||||||
text: '◦',
|
style: {
|
||||||
style: {
|
paragraph: {
|
||||||
paragraph: {
|
indent: { left: 840, hanging: 240 },
|
||||||
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',
|
properties: {
|
||||||
levels: [
|
page: {
|
||||||
{
|
size: {
|
||||||
level: 0,
|
width: DOCX_PAGE_WIDTH_DXA,
|
||||||
format: docx.LevelFormat.DECIMAL,
|
height: DOCX_PAGE_HEIGHT_DXA,
|
||||||
text: '%1.',
|
},
|
||||||
style: {
|
margin: {
|
||||||
paragraph: {
|
top: DOCX_PAGE_MARGIN_DXA,
|
||||||
indent: { left: 520, hanging: 280 },
|
right: DOCX_PAGE_MARGIN_DXA,
|
||||||
},
|
bottom: DOCX_PAGE_MARGIN_DXA,
|
||||||
|
left: DOCX_PAGE_MARGIN_DXA,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
level: 1,
|
children,
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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(
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user