From 022ef31bcc1eb4e4bab27a610e4752061982c3cb Mon Sep 17 00:00:00 2001 From: yoge Date: Sat, 27 Dec 2025 21:59:22 +0800 Subject: [PATCH] feat: handle image rendor --- package-lock.json | 7 + package.json | 1 + src/components/ExportSidebar.tsx | 96 ++++---- src/components/ResultPanel.tsx | 5 +- src/lib/imageGenerator.ts | 390 +++++++++++++++++++++++++++++++ src/lib/ommlConverter.ts | 65 ++++-- 6 files changed, 497 insertions(+), 67 deletions(-) create mode 100644 src/lib/imageGenerator.ts diff --git a/package-lock.json b/package-lock.json index c345aca..2df1d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/spark-md5": "^3.0.5", "browser-image-compression": "^2.0.2", "clsx": "^2.1.1", + "html-to-image": "^1.11.13", "katex": "^0.16.27", "lucide-react": "^0.344.0", "mathml2omml": "^0.5.0", @@ -2995,6 +2996,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/package.json b/package.json index 602688f..4f7911f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/spark-md5": "^3.0.5", "browser-image-compression": "^2.0.2", "clsx": "^2.1.1", + "html-to-image": "^1.11.13", "katex": "^0.16.27", "lucide-react": "^0.344.0", "mathml2omml": "^0.5.0", diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index 66eea83..75ba2ce 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2 } from 'lucide-react'; import { RecognitionResult } from '../types'; import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter'; +import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator'; import { API_BASE_URL } from '../config/env'; import { tokenManager } from '../lib/api'; @@ -59,36 +60,21 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar id: 'rendered_image', label: 'Rendered Image', category: 'Image', - getContent: (r) => r.rendered_image_path, - // User requested "Copy button" for Image. - // Special handling might be needed for actual image data copy, - // but standard clipboard writeText is for text. - // If we want to copy image data, we need to fetch it. - // For now, I'll stick to the pattern, but if it's a URL, copying the URL is the fallback. - // However, usually "Copy Image" implies the binary. - // Let's treat it as a special case in handleAction. + getContent: (r) => r.markdown_content, }, // File Category { id: 'docx', label: 'DOCX', category: 'File', - getContent: (r) => r.markdown_content, // Placeholder content for file conversion + getContent: (r) => r.markdown_content, isDownload: true, extension: 'docx' - }, - { - id: 'pdf', - label: 'PDF', - category: 'File', - getContent: (r) => r.markdown_content, // Placeholder - isDownload: true, - extension: 'pdf' } ]; - // Handle DOCX/PDF export via API - const handleFileExport = async (type: 'docx' | 'pdf') => { + // Handle DOCX export via API + const handleFileExport = async (type: 'docx') => { if (!result?.id) return; setExportingId(type); @@ -136,10 +122,49 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar } }; + // Handle image generation from Markdown preview + const handleImageGeneration = async (action: 'copy' | 'download') => { + // We capture the rendered result directly from the DOM + const elementId = 'markdown-preview-content'; + + setExportingId('rendered_image'); + + try { + const dataUrl = await generateImageFromElement(elementId, { + format: 'png', + scale: 2, + padding: 24, + }) as string; + + if (action === 'copy') { + await copyImageToClipboard(dataUrl); + } else { + downloadImage(dataUrl, 'rendered_image.png'); + } + + setCopiedId('rendered_image'); + setTimeout(() => { + setCopiedId(null); + onClose(); + }, 1000); + } catch (err) { + console.error('Failed to generate image:', err); + alert(`生成图片失败: ${err}`); + } finally { + setExportingId(null); + } + }; + const handleAction = async (option: ExportOption) => { - // Handle DOCX/PDF export via API - if (option.id === 'docx' || option.id === 'pdf') { - await handleFileExport(option.id as 'docx' | 'pdf'); + // Handle DOCX export via API + if (option.id === 'docx') { + await handleFileExport('docx'); + return; + } + + // Handle image generation from Markdown + if (option.id === 'rendered_image') { + await handleImageGeneration('copy'); return; } @@ -159,27 +184,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar if (!content) return; + setExportingId(option.id); + try { - if (option.category === 'Image' && !option.isDownload) { - // Handle Image Copy - if (content.startsWith('http') || content.startsWith('blob:')) { - try { - const response = await fetch(content); - const blob = await response.blob(); - await navigator.clipboard.write([ - new ClipboardItem({ - [blob.type]: blob - }) - ]); - } catch (err) { - console.error('Failed to copy image:', err); - // Fallback to copying URL - await navigator.clipboard.writeText(content); - } - } else { - await navigator.clipboard.writeText(content); - } - } else if (option.isDownload) { + if (option.isDownload) { // Simulate download (for other file types if any) const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); @@ -199,10 +207,12 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar setTimeout(() => { setCopiedId(null); onClose(); // Auto close after action - }, 1000); // Small delay to show "Copied" state before closing + }, 1000); } catch (err) { console.error('Action failed:', err); + } finally { + setExportingId(null); } }; diff --git a/src/components/ResultPanel.tsx b/src/components/ResultPanel.tsx index 84c715d..807d238 100644 --- a/src/components/ResultPanel.tsx +++ b/src/components/ResultPanel.tsx @@ -119,7 +119,10 @@ export default function ResultPanel({ result, fileStatus }: ResultPanelProps) { {/* Content Area - Rendered Markdown */} {
-
+
= { + backgroundColor: '#ffffff', + padding: 20, + fontSize: 20, + scale: 2, + format: 'png', +}; + +/** + * Renders LaTeX content to an HTML element using KaTeX + */ +function renderLatexToHtml(latex: string, fontSize: number): string { + try { + // Check if it's display mode (block math) + const isDisplayMode = latex.includes('\\begin{') || + latex.includes('\\frac') || + latex.includes('\\sum') || + latex.includes('\\int') || + latex.length > 50; + + const html = katex.renderToString(latex, { + throwOnError: false, + displayMode: isDisplayMode, + output: 'html', + strict: false, + }); + + return ` +
+ ${html} +
+ `; + } catch (error) { + console.error('KaTeX render error:', error); + return `
Render error: ${error}
`; + } +} + +/** + * Renders Markdown with LaTeX to HTML + * For complex Markdown, we extract and render math blocks separately + */ +function renderMarkdownToHtml(markdown: string, fontSize: number): string { + // Simple approach: extract LaTeX blocks and render them + // For full Markdown support, you'd use a Markdown parser + + let html = markdown; + + // Replace display math $$ ... $$ + html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, latex) => { + try { + return `
${katex.renderToString(latex.trim(), { + throwOnError: false, + displayMode: true, + output: 'html', + strict: false, + })}
`; + } catch { + return `
Error: ${latex}
`; + } + }); + + // Replace inline math $ ... $ + html = html.replace(/\$([^$\n]+)\$/g, (_, latex) => { + try { + return katex.renderToString(latex.trim(), { + throwOnError: false, + displayMode: false, + output: 'html', + strict: false, + }); + } catch { + return `Error: ${latex}`; + } + }); + + // Basic Markdown: newlines to
+ html = html.replace(/\n/g, '
'); + + return ` +
+ ${html} +
+ `; +} + +/** + * Waits for all KaTeX fonts to be loaded + */ +async function waitForKatexFonts(): Promise { + // KaTeX font families that need to be loaded + const katexFontFamilies = [ + 'KaTeX_Main', + 'KaTeX_Math', + 'KaTeX_Size1', + 'KaTeX_Size2', + 'KaTeX_Size3', + 'KaTeX_Size4', + ]; + + // Wait for document fonts to be ready + await document.fonts.ready; + + // Check if any KaTeX fonts are loaded, with timeout + const checkFonts = () => { + return katexFontFamilies.some(family => { + try { + return document.fonts.check(`16px ${family}`); + } catch { + return false; + } + }); + }; + + // Wait up to 2 seconds for fonts + const maxAttempts = 20; + for (let i = 0; i < maxAttempts; i++) { + if (checkFonts()) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +/** + * Creates a temporary container element for rendering + * Uses z-index and opacity to hide from user but keep in viewport + */ +function createRenderContainer( + htmlContent: string, + options: Required +): HTMLDivElement { + const container = document.createElement('div'); + container.innerHTML = htmlContent; + + // Place in viewport but hidden + // 1. z-index: -9999 to put behind everything + // 2. opacity: 0 to make invisible + // 3. pointer-events: none to avoid interaction + container.style.cssText = ` + position: fixed; + left: 0; + top: 0; + z-index: -9999; + opacity: 0; + padding: ${options.padding}px; + background-color: ${options.backgroundColor}; + display: inline-block; + max-width: 800px; + pointer-events: none; + `; + container.setAttribute('data-image-generator', 'true'); + + document.body.appendChild(container); + return container; +} + +/** + * Removes the container from the DOM + */ +function removeRenderContainer(container: HTMLDivElement): void { + if (container.parentNode) { + container.parentNode.removeChild(container); + } +} + +/** + * Common image generation logic + */ +async function generateImage( + container: HTMLDivElement, + opts: Required +): Promise { + // Wait for KaTeX fonts to load + await waitForKatexFonts(); + + // Wait for layout + await new Promise(resolve => setTimeout(resolve, 200)); + + // Force layout recalculation and get actual dimensions + const rect = container.getBoundingClientRect(); + + // Ensure container has minimum dimensions + if (rect.width === 0 || rect.height === 0) { + container.style.minWidth = '100px'; + container.style.minHeight = '50px'; + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Options for html-to-image + // IMPORTANT: We override opacity to 1 here so the generated image is visible + // even though the DOM element is invisible (opacity: 0) + const imageOptions = { + backgroundColor: opts.backgroundColor, + pixelRatio: opts.scale, + cacheBust: true, + width: rect.width > 0 ? rect.width : undefined, + height: rect.height > 0 ? rect.height : undefined, + style: { + margin: '0', + padding: `${opts.padding}px`, + opacity: '1', // Force visible in screenshot + visibility: 'visible', + display: 'inline-block', + }, + skipFonts: false, + }; + + let result: string | Blob; + + try { + switch (opts.format) { + case 'svg': + result = await toSvg(container, imageOptions); + break; + case 'blob': + result = await toBlob(container, imageOptions) as Blob; + break; + case 'png': + default: + result = await toPng(container, imageOptions); + break; + } + return result; + } catch (error) { + console.error('Image generation failed:', error); + throw error; + } +} + +/** + * Generates an image from LaTeX content + * + * @param latex Pure LaTeX string (without $ delimiters) + * @param options Image generation options + * @returns Promise resolving to data URL or Blob + */ +export async function generateImageFromLatex( + latex: string, + options: ImageGeneratorOptions = {} +): Promise { + const opts = { ...defaultOptions, ...options }; + + // Clean up LaTeX: remove $$ or $ delimiters if present + let cleanLatex = latex.trim(); + if (cleanLatex.startsWith('$$') && cleanLatex.endsWith('$$')) { + cleanLatex = cleanLatex.slice(2, -2).trim(); + } else if (cleanLatex.startsWith('$') && cleanLatex.endsWith('$')) { + cleanLatex = cleanLatex.slice(1, -1).trim(); + } + + const htmlContent = renderLatexToHtml(cleanLatex, opts.fontSize); + const container = createRenderContainer(htmlContent, opts); + + try { + return await generateImage(container, opts); + } finally { + removeRenderContainer(container); + } +} + +/** + * Generates an image directly from an existing DOM element + * This captures the element exactly as rendered, supporting all CSS styles + * + * @param elementId The ID of the DOM element to capture + * @param options Image generation options + */ +export async function generateImageFromElement( + elementId: string, + options: ImageGeneratorOptions = {} +): Promise { + const element = document.getElementById(elementId); + if (!element) { + throw new Error(`Element with ID "${elementId}" not found`); + } + + const opts = { ...defaultOptions, ...options }; + + // Wait for KaTeX fonts if needed + await waitForKatexFonts(); + + // Wait a bit to ensure everything is rendered + await new Promise(resolve => setTimeout(resolve, 100)); + + // Options for html-to-image + const imageOptions = { + backgroundColor: opts.backgroundColor, + pixelRatio: opts.scale, + cacheBust: true, + // Use scroll dimensions to capture full content + width: element.scrollWidth + (opts.padding * 2), + height: element.scrollHeight + (opts.padding * 2), + style: { + padding: `${opts.padding}px`, + // Reset transform/overflow to ensure full content is visible + transform: 'none', + overflow: 'visible', + // Force visible + visibility: 'visible', + display: 'block', + // Reset height constraints + height: 'auto', + maxHeight: 'none', + }, + skipFonts: false, + }; + + let result: string | Blob; + + try { + switch (opts.format) { + case 'svg': + result = await toSvg(element, imageOptions); + break; + case 'blob': + result = await toBlob(element, imageOptions) as Blob; + break; + case 'png': + default: + result = await toPng(element, imageOptions); + break; + } + return result; + } catch (error) { + console.error('Element image generation failed:', error); + throw error; + } +} + +/** + * Copies a generated image to clipboard + */ +export async function copyImageToClipboard(dataUrlOrBlob: string | Blob): Promise { + let blob: Blob; + + if (typeof dataUrlOrBlob === 'string') { + // Convert data URL to Blob + const response = await fetch(dataUrlOrBlob); + blob = await response.blob(); + } else { + blob = dataUrlOrBlob; + } + + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); +} + +/** + * Downloads a generated image + */ +export function downloadImage(dataUrl: string, filename: string = 'formula.png'): void { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + diff --git a/src/lib/ommlConverter.ts b/src/lib/ommlConverter.ts index 2e839fc..5a3a515 100644 --- a/src/lib/ommlConverter.ts +++ b/src/lib/ommlConverter.ts @@ -3,21 +3,45 @@ * * Uses 'mathml2omml' library to convert MathML to Office Math Markup Language (OMML). * This is a pure JavaScript implementation and does not require external XSLT files. + * + * @see https://github.com/fiduswriter/mathml2omml */ import { mml2omml } from 'mathml2omml'; +/** + * Cleans up OMML output from mathml2omml to fix known library issues. + * These are workarounds for bugs in mathml2omml that produce invalid OMML. + * + * @param omml Raw OMML string from mathml2omml + * @returns Cleaned OMML string compatible with Microsoft Word + */ +function cleanOmml(omml: string): string { + return omml + // Fix: m:sty m:val="undefined" is invalid (library bug with mathvariant="normal") + .replace(//g, '') + // Remove empty control properties that cause parsing issues in some Word versions + .replace(//g, '') + .replace(//g, '') + // Remove inline namespace declarations (will use wrapper's namespaces) + .replace(/ xmlns:m="[^"]*"/g, '') + .replace(/ xmlns:w="[^"]*"/g, ''); +} + /** * Converts MathML string to OMML string using mathml2omml library. + * * @param mathml The MathML content string - * @returns Promise resolving to OMML string + * @returns Promise resolving to cleaned OMML string + * @see https://github.com/fiduswriter/mathml2omml */ export async function convertMathmlToOmml(mathml: string): Promise { try { - // The library is synchronous, but we keep the async signature for compatibility - // and potential future changes (e.g. if we move this to a worker). + // Convert using mml2omml function const omml = mml2omml(mathml); - return omml; + + // Apply fixes for known library issues + return cleanOmml(omml); } catch (error) { console.error('MathML to OMML conversion failed:', error); return ''; @@ -25,26 +49,21 @@ export async function convertMathmlToOmml(mathml: string): Promise { } /** - * Wraps OMML in Word XML clipboard format if needed. - * This helps Word recognize it when pasting as text. + * Wraps OMML for clipboard/paste into Microsoft Word. + * Uses Office Open XML namespaces (Office 2007+). + * + * @param omml Cleaned OMML string from convertMathmlToOmml + * @returns XML string ready for clipboard */ export function wrapOmmlForClipboard(omml: string): string { - // Replace 2006 namespaces with 2004/2003 namespaces for Word 2003 XML compatibility - // This is necessary because the clipboard format uses the older Word XML structure - const compatibleOmml = omml - .replace(/http:\/\/schemas\.openxmlformats\.org\/officeDocument\/2006\/math/g, 'http://schemas.microsoft.com/office/2004/12/omml') - .replace(/http:\/\/schemas\.openxmlformats\.org\/wordprocessingml\/2006\/main/g, 'http://schemas.microsoft.com/office/word/2003/wordml'); + // Extract inner content from tags to avoid nesting + const innerContent = omml + .replace(/]*>/g, '') + .replace(/<\/m:oMath>/g, ''); - // Simple XML declaration wrapper often helps - return ` - - - - - - ${compatibleOmml} - - - -`; + // Return with proper Office Open XML structure + return ` + +${innerContent} +`; }