feat(write): 支持导出word时,图片下载插入

- 新增 ImageRun 导入和图片处理逻辑
- 解析 Markdown 图片语法并下载图片
- 保持图片宽高比插入文档
- 优化段落处理,支持纯图片段落居中显示
This commit is contained in:
zstar 2025-06-06 23:54:08 +08:00
parent 720b19f9f4
commit 8bfe8d468f
2 changed files with 206 additions and 64 deletions

60
web/src/locales/until.ts Normal file
View File

@ -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;
});
}

View File

@ -27,6 +27,7 @@ import {
AlignmentType,
Document,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
TextRun,
@ -55,9 +56,9 @@ interface KnowledgeBaseItem {
type MarkedHeadingToken = Tokens.Heading;
type MarkedParagraphToken = Tokens.Paragraph;
type MarkedImageToken = Tokens.Image; // <-- 新增:定义图片 Token 类型
type MarkedListItem = Tokens.ListItem;
type MarkedListToken = Tokens.List;
type MarkedSpaceToken = Tokens.Space;
// 定义插入点标记以便在onChange时识别并移除
// const INSERTION_MARKER = '【AI内容将插入此处】';
@ -118,7 +119,7 @@ const Write = () => {
{
id: 'default_1_v4f',
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,
},
{
@ -540,15 +541,12 @@ const Write = () => {
} else {
message.error(t('aiRequestFailedError'));
}
} finally {
// AI加载状态在 done 状态或错误处理中会更新,这里不主动设置为 false
// 只有当 isStreaming 状态完全结束时,才彻底清除临时状态
}
}
};
// 导出为Word
const handleSave = () => {
const handleSave = async () => {
// 将函数声明为 async
const selectedTemplateItem = templates.find(
(item) => item.id === selectedTemplate,
);
@ -556,7 +554,9 @@ const Write = () => {
? selectedTemplateItem.name
: t('document', { defaultValue: '文档' });
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`;
if (!content.trim()) {
@ -570,6 +570,27 @@ const Write = () => {
const tokens = marked.lexer(content) as Token[];
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(
inlineTokens: Tokens.Generic[] | undefined,
): TextRun[] {
@ -577,40 +598,33 @@ const Write = () => {
if (!inlineTokens) return runs;
inlineTokens.forEach((token) => {
// 跳过 image token因为它会在主循环中被特殊处理
if (token.type === 'image') return;
if (token.type === 'text') {
runs.push(new TextRun(token.raw)); // Use raw for exact text
} else if (
token.type === 'strong' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, bold: true }));
} else if (
token.type === 'em' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, italics: true }));
} else if (
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 }));
runs.push(new TextRun(token.raw));
} else if (token.type === 'strong' && 'text' in token) {
runs.push(new TextRun({ text: token.text as string, bold: true }));
} else if (token.type === 'em' && 'text' in token) {
runs.push(
new TextRun({ text: token.text as string, italics: true }),
);
} else if (token.type === 'codespan' && 'text' in token) {
runs.push(
new TextRun({ text: token.text as string, style: 'Consolas' }),
);
} else if (token.type === 'del' && 'text' in token) {
runs.push(
new TextRun({ text: token.text as string, strike: true }),
);
} else if (
token.type === 'link' &&
'text' in token &&
typeof token.text === 'string' &&
'href' in token &&
typeof token.href === 'string'
'href' in token
) {
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) {
runs.push(new TextRun(token.raw));
}
@ -618,7 +632,11 @@ const Write = () => {
return runs;
}
tokens.forEach((token) => {
// 用于匹配 Markdown 图片语法的正则表达式
const imageMarkdownRegex = /!\[.*?\]\((.*?)\)/;
// 使用 for...of 循环以支持 await
for (const token of tokens) {
if (token.type === 'heading') {
const headingToken = token as MarkedHeadingToken;
let docxHeadingLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel];
@ -646,16 +664,7 @@ const Write = () => {
}
paragraphs.push(
new Paragraph({
children: parseTokensToRuns(
headingToken.tokens ||
([
{
type: 'text',
raw: headingToken.text,
text: headingToken.text,
},
] as any),
),
children: parseTokensToRuns(headingToken.tokens),
heading: docxHeadingLevel,
spacing: {
after: 200 - headingToken.depth * 20,
@ -665,16 +674,96 @@ const Write = () => {
);
} else if (token.type === 'paragraph') {
const paraToken = token as MarkedParagraphToken;
const runs = parseTokensToRuns(paraToken.tokens);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun('')],
spacing: { after: 120 },
}),
);
const paragraphChildren: (TextRun | ImageRun)[] = [];
if (paraToken.tokens) {
for (const inlineToken of paraToken.tokens) {
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') {
const listToken = token as MarkedListToken;
listToken.items.forEach((listItem: MarkedListItem) => {
// 注意:列表项内部也可能包含图片,但为简化,此处暂不处理。
// 如果列表项中也需要图片,需要将 paragraph 的逻辑应用到这里。
const itemRuns = parseTokensToRuns(listItem.tokens);
paragraphs.push(
new Paragraph({
@ -688,16 +777,9 @@ const Write = () => {
});
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
} else if (token.type === 'space') {
const spaceToken = token as MarkedSpaceToken;
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 } }));
}
paragraphs.push(new Paragraph({}));
}
});
}
if (paragraphs.length === 0 && content.trim()) {
paragraphs.push(new Paragraph({ children: [new TextRun(content)] }));