From 8bfe8d468f4231da571379ffa8f4464ceac5d33f Mon Sep 17 00:00:00 2001 From: zstar <65890619+zstar1003@users.noreply.github.com> Date: Fri, 6 Jun 2025 23:54:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(write):=20=E6=94=AF=E6=8C=81=E5=AF=BC?= =?UTF-8?q?=E5=87=BAword=E6=97=B6=EF=BC=8C=E5=9B=BE=E7=89=87=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E6=8F=92=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ImageRun 导入和图片处理逻辑 - 解析 Markdown 图片语法并下载图片 - 保持图片宽高比插入文档 - 优化段落处理,支持纯图片段落居中显示 --- web/src/locales/until.ts | 60 ++++++++++ web/src/pages/write/index.tsx | 210 +++++++++++++++++++++++----------- 2 files changed, 206 insertions(+), 64 deletions(-) create mode 100644 web/src/locales/until.ts diff --git a/web/src/locales/until.ts b/web/src/locales/until.ts new file mode 100644 index 0000000..9934a97 --- /dev/null +++ b/web/src/locales/until.ts @@ -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(); + + // 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; + }); +} diff --git a/web/src/pages/write/index.tsx b/web/src/pages/write/index.tsx index 5c5a9e4..e48d673 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -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)] }));