feat(write): 支持导出word时,图片下载插入
- 新增 ImageRun 导入和图片处理逻辑 - 解析 Markdown 图片语法并下载图片 - 保持图片宽高比插入文档 - 优化段落处理,支持纯图片段落居中显示
This commit is contained in:
parent
720b19f9f4
commit
8bfe8d468f
|
@ -0,0 +1,60 @@
|
||||||
|
type NestedObject = {
|
||||||
|
[key: string]: string | NestedObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlattenedObject = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function flattenObject(
|
||||||
|
obj: NestedObject,
|
||||||
|
parentKey: string = '',
|
||||||
|
): FlattenedObject {
|
||||||
|
const result: FlattenedObject = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
Object.assign(result, flattenObject(value as NestedObject, newKey));
|
||||||
|
} else {
|
||||||
|
result[newKey] = value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
type TranslationTableRow = {
|
||||||
|
key: string;
|
||||||
|
[language: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a translation table from multiple flattened language objects.
|
||||||
|
* @param langs - A list of flattened language objects.
|
||||||
|
* @param langKeys - A list of language identifiers (e.g., 'English', 'Vietnamese').
|
||||||
|
* @returns An array representing the translation table.
|
||||||
|
*/
|
||||||
|
export function createTranslationTable(
|
||||||
|
langs: FlattenedObject[],
|
||||||
|
langKeys: string[],
|
||||||
|
): TranslationTableRow[] {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
// Collect all unique keys from the language objects
|
||||||
|
langs.forEach((lang) => {
|
||||||
|
Object.keys(lang).forEach((key) => keys.add(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the table
|
||||||
|
return Array.from(keys).map((key) => {
|
||||||
|
const row: TranslationTableRow = { key };
|
||||||
|
|
||||||
|
langs.forEach((lang, index) => {
|
||||||
|
const langKey = langKeys[index];
|
||||||
|
row[langKey] = lang[key] || ''; // Use empty string if key is missing
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ import {
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
Document,
|
Document,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
|
ImageRun,
|
||||||
Packer,
|
Packer,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextRun,
|
TextRun,
|
||||||
|
@ -55,9 +56,9 @@ interface KnowledgeBaseItem {
|
||||||
|
|
||||||
type MarkedHeadingToken = Tokens.Heading;
|
type MarkedHeadingToken = Tokens.Heading;
|
||||||
type MarkedParagraphToken = Tokens.Paragraph;
|
type MarkedParagraphToken = Tokens.Paragraph;
|
||||||
|
type MarkedImageToken = Tokens.Image; // <-- 新增:定义图片 Token 类型
|
||||||
type MarkedListItem = Tokens.ListItem;
|
type MarkedListItem = Tokens.ListItem;
|
||||||
type MarkedListToken = Tokens.List;
|
type MarkedListToken = Tokens.List;
|
||||||
type MarkedSpaceToken = Tokens.Space;
|
|
||||||
|
|
||||||
// 定义插入点标记,以便在onChange时识别并移除
|
// 定义插入点标记,以便在onChange时识别并移除
|
||||||
// const INSERTION_MARKER = '【AI内容将插入此处】';
|
// const INSERTION_MARKER = '【AI内容将插入此处】';
|
||||||
|
@ -118,7 +119,7 @@ const Write = () => {
|
||||||
{
|
{
|
||||||
id: 'default_1_v4f',
|
id: 'default_1_v4f',
|
||||||
name: t('defaultTemplate'),
|
name: t('defaultTemplate'),
|
||||||
content: `# ${t('defaultTemplateTitle')}\n \n ## ${t('introduction')}\n \n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `,
|
content: `# ${t('defaultTemplateTitle')}\n \n ## ${t('introduction')}\n \n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `,
|
||||||
isCustom: false,
|
isCustom: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -540,15 +541,12 @@ const Write = () => {
|
||||||
} else {
|
} else {
|
||||||
message.error(t('aiRequestFailedError'));
|
message.error(t('aiRequestFailedError'));
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
// AI加载状态在 done 状态或错误处理中会更新,这里不主动设置为 false
|
|
||||||
// 只有当 isStreaming 状态完全结束时,才彻底清除临时状态
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出为Word
|
const handleSave = async () => {
|
||||||
const handleSave = () => {
|
// 将函数声明为 async
|
||||||
const selectedTemplateItem = templates.find(
|
const selectedTemplateItem = templates.find(
|
||||||
(item) => item.id === selectedTemplate,
|
(item) => item.id === selectedTemplate,
|
||||||
);
|
);
|
||||||
|
@ -556,7 +554,9 @@ const Write = () => {
|
||||||
? selectedTemplateItem.name
|
? selectedTemplateItem.name
|
||||||
: t('document', { defaultValue: '文档' });
|
: t('document', { defaultValue: '文档' });
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
|
const dateStr = `${today.getFullYear()}${String(
|
||||||
|
today.getMonth() + 1,
|
||||||
|
).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
|
||||||
const fileName = `${baseName}_${dateStr}.docx`;
|
const fileName = `${baseName}_${dateStr}.docx`;
|
||||||
|
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
|
@ -570,6 +570,27 @@ const Write = () => {
|
||||||
const tokens = marked.lexer(content) as Token[];
|
const tokens = marked.lexer(content) as Token[];
|
||||||
const paragraphs: Paragraph[] = [];
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
|
// 辅助函数,用于获取图片尺寸以保持宽高比
|
||||||
|
const getImageDimensions = (
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
): Promise<{ width: number; height: number }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const blob = new Blob([buffer]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
URL.revokeObjectURL(url); // 清理
|
||||||
|
};
|
||||||
|
img.onerror = (err) => {
|
||||||
|
reject(err);
|
||||||
|
URL.revokeObjectURL(url); // 清理
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:解析文本类型的内联元素
|
||||||
function parseTokensToRuns(
|
function parseTokensToRuns(
|
||||||
inlineTokens: Tokens.Generic[] | undefined,
|
inlineTokens: Tokens.Generic[] | undefined,
|
||||||
): TextRun[] {
|
): TextRun[] {
|
||||||
|
@ -577,40 +598,33 @@ const Write = () => {
|
||||||
if (!inlineTokens) return runs;
|
if (!inlineTokens) return runs;
|
||||||
|
|
||||||
inlineTokens.forEach((token) => {
|
inlineTokens.forEach((token) => {
|
||||||
|
// 跳过 image token,因为它会在主循环中被特殊处理
|
||||||
|
if (token.type === 'image') return;
|
||||||
|
|
||||||
if (token.type === 'text') {
|
if (token.type === 'text') {
|
||||||
runs.push(new TextRun(token.raw)); // Use raw for exact text
|
runs.push(new TextRun(token.raw));
|
||||||
} else if (
|
} else if (token.type === 'strong' && 'text' in token) {
|
||||||
token.type === 'strong' &&
|
runs.push(new TextRun({ text: token.text as string, bold: true }));
|
||||||
'text' in token &&
|
} else if (token.type === 'em' && 'text' in token) {
|
||||||
typeof token.text === 'string'
|
runs.push(
|
||||||
) {
|
new TextRun({ text: token.text as string, italics: true }),
|
||||||
runs.push(new TextRun({ text: token.text, bold: true }));
|
);
|
||||||
} else if (
|
} else if (token.type === 'codespan' && 'text' in token) {
|
||||||
token.type === 'em' &&
|
runs.push(
|
||||||
'text' in token &&
|
new TextRun({ text: token.text as string, style: 'Consolas' }),
|
||||||
typeof token.text === 'string'
|
);
|
||||||
) {
|
} else if (token.type === 'del' && 'text' in token) {
|
||||||
runs.push(new TextRun({ text: token.text, italics: true }));
|
runs.push(
|
||||||
} else if (
|
new TextRun({ text: token.text as string, strike: true }),
|
||||||
token.type === 'codespan' &&
|
);
|
||||||
'text' in token &&
|
|
||||||
typeof token.text === 'string'
|
|
||||||
) {
|
|
||||||
runs.push(new TextRun({ text: token.text, style: 'Consolas' }));
|
|
||||||
} else if (
|
|
||||||
token.type === 'del' &&
|
|
||||||
'text' in token &&
|
|
||||||
typeof token.text === 'string'
|
|
||||||
) {
|
|
||||||
runs.push(new TextRun({ text: token.text, strike: true }));
|
|
||||||
} else if (
|
} else if (
|
||||||
token.type === 'link' &&
|
token.type === 'link' &&
|
||||||
'text' in token &&
|
'text' in token &&
|
||||||
typeof token.text === 'string' &&
|
'href' in token
|
||||||
'href' in token &&
|
|
||||||
typeof token.href === 'string'
|
|
||||||
) {
|
) {
|
||||||
runs.push(new TextRun({ text: token.text, style: 'Hyperlink' }));
|
runs.push(
|
||||||
|
new TextRun({ text: token.text as string, style: 'Hyperlink' }),
|
||||||
|
);
|
||||||
} else if ('raw' in token) {
|
} else if ('raw' in token) {
|
||||||
runs.push(new TextRun(token.raw));
|
runs.push(new TextRun(token.raw));
|
||||||
}
|
}
|
||||||
|
@ -618,7 +632,11 @@ const Write = () => {
|
||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens.forEach((token) => {
|
// 用于匹配 Markdown 图片语法的正则表达式
|
||||||
|
const imageMarkdownRegex = /!\[.*?\]\((.*?)\)/;
|
||||||
|
|
||||||
|
// 使用 for...of 循环以支持 await
|
||||||
|
for (const token of tokens) {
|
||||||
if (token.type === 'heading') {
|
if (token.type === 'heading') {
|
||||||
const headingToken = token as MarkedHeadingToken;
|
const headingToken = token as MarkedHeadingToken;
|
||||||
let docxHeadingLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel];
|
let docxHeadingLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel];
|
||||||
|
@ -646,16 +664,7 @@ const Write = () => {
|
||||||
}
|
}
|
||||||
paragraphs.push(
|
paragraphs.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: parseTokensToRuns(
|
children: parseTokensToRuns(headingToken.tokens),
|
||||||
headingToken.tokens ||
|
|
||||||
([
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
raw: headingToken.text,
|
|
||||||
text: headingToken.text,
|
|
||||||
},
|
|
||||||
] as any),
|
|
||||||
),
|
|
||||||
heading: docxHeadingLevel,
|
heading: docxHeadingLevel,
|
||||||
spacing: {
|
spacing: {
|
||||||
after: 200 - headingToken.depth * 20,
|
after: 200 - headingToken.depth * 20,
|
||||||
|
@ -665,16 +674,96 @@ const Write = () => {
|
||||||
);
|
);
|
||||||
} else if (token.type === 'paragraph') {
|
} else if (token.type === 'paragraph') {
|
||||||
const paraToken = token as MarkedParagraphToken;
|
const paraToken = token as MarkedParagraphToken;
|
||||||
const runs = parseTokensToRuns(paraToken.tokens);
|
const paragraphChildren: (TextRun | ImageRun)[] = [];
|
||||||
paragraphs.push(
|
|
||||||
new Paragraph({
|
if (paraToken.tokens) {
|
||||||
children: runs.length > 0 ? runs : [new TextRun('')],
|
for (const inlineToken of paraToken.tokens) {
|
||||||
spacing: { after: 120 },
|
let isImage = false;
|
||||||
}),
|
let imageUrl = '';
|
||||||
);
|
let rawMarkdownForImage = inlineToken.raw;
|
||||||
|
|
||||||
|
// 方案一: `marked` 正确解析为 'image' 类型
|
||||||
|
if (inlineToken.type === 'image') {
|
||||||
|
isImage = true;
|
||||||
|
imageUrl = (inlineToken as MarkedImageToken).href;
|
||||||
|
}
|
||||||
|
// 方案二 (后备): `marked` 未能解析,但文本内容匹配图片格式
|
||||||
|
else if (inlineToken.type === 'text') {
|
||||||
|
const match = inlineToken.raw.match(imageMarkdownRegex);
|
||||||
|
if (match && match[1]) {
|
||||||
|
isImage = true;
|
||||||
|
imageUrl = match[1]; // 获取括号内的 URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是图片,则下载并处理
|
||||||
|
if (isImage) {
|
||||||
|
try {
|
||||||
|
message.info(`正在下载图片: ${imageUrl.substring(0, 50)}...`);
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`下载图片失败: ${response.statusText}`);
|
||||||
|
|
||||||
|
const imageBuffer = await response.arrayBuffer();
|
||||||
|
const { width: naturalWidth, height: naturalHeight } =
|
||||||
|
await getImageDimensions(imageBuffer);
|
||||||
|
|
||||||
|
const aspectRatio =
|
||||||
|
naturalWidth > 0 ? naturalHeight / naturalWidth : 1;
|
||||||
|
const docxWidth = 540;
|
||||||
|
const docxHeight = docxWidth * aspectRatio;
|
||||||
|
|
||||||
|
paragraphChildren.push(
|
||||||
|
new ImageRun({
|
||||||
|
data: imageBuffer,
|
||||||
|
type: 'png',
|
||||||
|
transformation: {
|
||||||
|
width: docxWidth,
|
||||||
|
height: docxHeight,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`无法加载或插入图片 ${imageUrl}:`, error);
|
||||||
|
message.warning(
|
||||||
|
`图片加载失败: ${imageUrl},已在文档中标注。`,
|
||||||
|
);
|
||||||
|
paragraphChildren.push(
|
||||||
|
new TextRun({
|
||||||
|
text: `[图片加载失败: ${rawMarkdownForImage}]`,
|
||||||
|
italics: true,
|
||||||
|
color: 'FF0000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不是图片,则作为普通文本处理
|
||||||
|
const runs = parseTokensToRuns([inlineToken]);
|
||||||
|
paragraphChildren.push(...runs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paragraphChildren.length > 0) {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: paragraphChildren,
|
||||||
|
spacing: { after: 120 },
|
||||||
|
alignment:
|
||||||
|
paragraphChildren.length === 1 &&
|
||||||
|
paragraphChildren[0] instanceof ImageRun
|
||||||
|
? AlignmentType.CENTER
|
||||||
|
: AlignmentType.LEFT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
paragraphs.push(new Paragraph({}));
|
||||||
|
}
|
||||||
} else if (token.type === 'list') {
|
} else if (token.type === 'list') {
|
||||||
const listToken = token as MarkedListToken;
|
const listToken = token as MarkedListToken;
|
||||||
listToken.items.forEach((listItem: MarkedListItem) => {
|
listToken.items.forEach((listItem: MarkedListItem) => {
|
||||||
|
// 注意:列表项内部也可能包含图片,但为简化,此处暂不处理。
|
||||||
|
// 如果列表项中也需要图片,需要将 paragraph 的逻辑应用到这里。
|
||||||
const itemRuns = parseTokensToRuns(listItem.tokens);
|
const itemRuns = parseTokensToRuns(listItem.tokens);
|
||||||
paragraphs.push(
|
paragraphs.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
|
@ -688,16 +777,9 @@ const Write = () => {
|
||||||
});
|
});
|
||||||
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
|
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
|
||||||
} else if (token.type === 'space') {
|
} else if (token.type === 'space') {
|
||||||
const spaceToken = token as MarkedSpaceToken;
|
paragraphs.push(new Paragraph({}));
|
||||||
const newlines = (spaceToken.raw.match(/\n/g) || []).length;
|
|
||||||
if (newlines > 1) {
|
|
||||||
for (let i = 0; i < newlines; i++)
|
|
||||||
paragraphs.push(new Paragraph({}));
|
|
||||||
} else {
|
|
||||||
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (paragraphs.length === 0 && content.trim()) {
|
if (paragraphs.length === 0 && content.trim()) {
|
||||||
paragraphs.push(new Paragraph({ children: [new TextRun(content)] }));
|
paragraphs.push(new Paragraph({ children: [new TextRun(content)] }));
|
||||||
|
|
Loading…
Reference in New Issue