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}
+`;
}