feat: handle image rendor
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@types/spark-md5": "^3.0.5",
|
"@types/spark-md5": "^3.0.5",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mathml2omml": "^0.5.0",
|
"mathml2omml": "^0.5.0",
|
||||||
@@ -2995,6 +2996,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@types/spark-md5": "^3.0.5",
|
"@types/spark-md5": "^3.0.5",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mathml2omml": "^0.5.0",
|
"mathml2omml": "^0.5.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2 } from 'lucide-react';
|
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2 } from 'lucide-react';
|
||||||
import { RecognitionResult } from '../types';
|
import { RecognitionResult } from '../types';
|
||||||
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
|
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
|
||||||
|
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
||||||
import { API_BASE_URL } from '../config/env';
|
import { API_BASE_URL } from '../config/env';
|
||||||
import { tokenManager } from '../lib/api';
|
import { tokenManager } from '../lib/api';
|
||||||
|
|
||||||
@@ -59,36 +60,21 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
id: 'rendered_image',
|
id: 'rendered_image',
|
||||||
label: 'Rendered Image',
|
label: 'Rendered Image',
|
||||||
category: 'Image',
|
category: 'Image',
|
||||||
getContent: (r) => r.rendered_image_path,
|
getContent: (r) => r.markdown_content,
|
||||||
// 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.
|
|
||||||
},
|
},
|
||||||
// File Category
|
// File Category
|
||||||
{
|
{
|
||||||
id: 'docx',
|
id: 'docx',
|
||||||
label: 'DOCX',
|
label: 'DOCX',
|
||||||
category: 'File',
|
category: 'File',
|
||||||
getContent: (r) => r.markdown_content, // Placeholder content for file conversion
|
getContent: (r) => r.markdown_content,
|
||||||
isDownload: true,
|
isDownload: true,
|
||||||
extension: 'docx'
|
extension: 'docx'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pdf',
|
|
||||||
label: 'PDF',
|
|
||||||
category: 'File',
|
|
||||||
getContent: (r) => r.markdown_content, // Placeholder
|
|
||||||
isDownload: true,
|
|
||||||
extension: 'pdf'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Handle DOCX/PDF export via API
|
// Handle DOCX export via API
|
||||||
const handleFileExport = async (type: 'docx' | 'pdf') => {
|
const handleFileExport = async (type: 'docx') => {
|
||||||
if (!result?.id) return;
|
if (!result?.id) return;
|
||||||
|
|
||||||
setExportingId(type);
|
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) => {
|
const handleAction = async (option: ExportOption) => {
|
||||||
// Handle DOCX/PDF export via API
|
// Handle DOCX export via API
|
||||||
if (option.id === 'docx' || option.id === 'pdf') {
|
if (option.id === 'docx') {
|
||||||
await handleFileExport(option.id as 'docx' | 'pdf');
|
await handleFileExport('docx');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image generation from Markdown
|
||||||
|
if (option.id === 'rendered_image') {
|
||||||
|
await handleImageGeneration('copy');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,27 +184,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
|
setExportingId(option.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (option.category === 'Image' && !option.isDownload) {
|
if (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) {
|
|
||||||
// Simulate download (for other file types if any)
|
// Simulate download (for other file types if any)
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -199,10 +207,12 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopiedId(null);
|
setCopiedId(null);
|
||||||
onClose(); // Auto close after action
|
onClose(); // Auto close after action
|
||||||
}, 1000); // Small delay to show "Copied" state before closing
|
}, 1000);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Action failed:', err);
|
console.error('Action failed:', err);
|
||||||
|
} finally {
|
||||||
|
setExportingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
|||||||
{/* Content Area - Rendered Markdown */}
|
{/* Content Area - Rendered Markdown */}
|
||||||
{
|
{
|
||||||
<div className="flex-1 overflow-auto p-8 custom-scrollbar flex justify-center">
|
<div className="flex-1 overflow-auto p-8 custom-scrollbar flex justify-center">
|
||||||
<div className="prose prose-blue max-w-3xl w-full prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl prose-p:leading-relaxed prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-100 [&_.katex-display]:text-center">
|
<div
|
||||||
|
id="markdown-preview-content"
|
||||||
|
className="prose prose-blue max-w-3xl w-full prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl prose-p:leading-relaxed prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-100 [&_.katex-display]:text-center bg-white p-4"
|
||||||
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkMath, remarkBreaks]}
|
remarkPlugins={[remarkMath, remarkBreaks]}
|
||||||
rehypePlugins={[[rehypeKatex, {
|
rehypePlugins={[[rehypeKatex, {
|
||||||
|
|||||||
390
src/lib/imageGenerator.ts
Normal file
390
src/lib/imageGenerator.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Image Generator for LaTeX/Markdown content
|
||||||
|
*
|
||||||
|
* Uses html-to-image to convert rendered math content to PNG/SVG images.
|
||||||
|
* Leverages KaTeX for LaTeX rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toPng, toSvg, toBlob } from 'html-to-image';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for image generation
|
||||||
|
*/
|
||||||
|
export interface ImageGeneratorOptions {
|
||||||
|
/** Background color (default: white) */
|
||||||
|
backgroundColor?: string;
|
||||||
|
/** Padding in pixels (default: 20) */
|
||||||
|
padding?: number;
|
||||||
|
/** Font size in pixels (default: 20) */
|
||||||
|
fontSize?: number;
|
||||||
|
/** Scale factor for higher resolution (default: 2) */
|
||||||
|
scale?: number;
|
||||||
|
/** Output format */
|
||||||
|
format?: 'png' | 'svg' | 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Required<ImageGeneratorOptions> = {
|
||||||
|
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 `
|
||||||
|
<div style="font-size: ${fontSize}px; line-height: 1.5;">
|
||||||
|
${html}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('KaTeX render error:', error);
|
||||||
|
return `<div style="color: red;">Render error: ${error}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `<div class="math-block">${katex.renderToString(latex.trim(), {
|
||||||
|
throwOnError: false,
|
||||||
|
displayMode: true,
|
||||||
|
output: 'html',
|
||||||
|
strict: false,
|
||||||
|
})}</div>`;
|
||||||
|
} catch {
|
||||||
|
return `<div class="math-block" style="color: red;">Error: ${latex}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 `<span style="color: red;">Error: ${latex}</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Basic Markdown: newlines to <br>
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-size: ${fontSize}px; line-height: 1.8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
${html}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for all KaTeX fonts to be loaded
|
||||||
|
*/
|
||||||
|
async function waitForKatexFonts(): Promise<void> {
|
||||||
|
// 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<ImageGeneratorOptions>
|
||||||
|
): 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<ImageGeneratorOptions>
|
||||||
|
): Promise<string | Blob> {
|
||||||
|
// 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<string | Blob> {
|
||||||
|
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<string | Blob> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,21 +3,45 @@
|
|||||||
*
|
*
|
||||||
* Uses 'mathml2omml' library to convert MathML to Office Math Markup Language (OMML).
|
* 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.
|
* This is a pure JavaScript implementation and does not require external XSLT files.
|
||||||
|
*
|
||||||
|
* @see https://github.com/fiduswriter/mathml2omml
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mml2omml } from '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(/<m:sty m:val="undefined"\/>/g, '')
|
||||||
|
// Remove empty control properties that cause parsing issues in some Word versions
|
||||||
|
.replace(/<m:ctrlPr\/>/g, '')
|
||||||
|
.replace(/<w:rPr\/>/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.
|
* Converts MathML string to OMML string using mathml2omml library.
|
||||||
|
*
|
||||||
* @param mathml The MathML content string
|
* @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<string> {
|
export async function convertMathmlToOmml(mathml: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// The library is synchronous, but we keep the async signature for compatibility
|
// Convert using mml2omml function
|
||||||
// and potential future changes (e.g. if we move this to a worker).
|
|
||||||
const omml = mml2omml(mathml);
|
const omml = mml2omml(mathml);
|
||||||
return omml;
|
|
||||||
|
// Apply fixes for known library issues
|
||||||
|
return cleanOmml(omml);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('MathML to OMML conversion failed:', error);
|
console.error('MathML to OMML conversion failed:', error);
|
||||||
return '';
|
return '';
|
||||||
@@ -25,26 +49,21 @@ export async function convertMathmlToOmml(mathml: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps OMML in Word XML clipboard format if needed.
|
* Wraps OMML for clipboard/paste into Microsoft Word.
|
||||||
* This helps Word recognize it when pasting as text.
|
* 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 {
|
export function wrapOmmlForClipboard(omml: string): string {
|
||||||
// Replace 2006 namespaces with 2004/2003 namespaces for Word 2003 XML compatibility
|
// Extract inner content from <m:oMath> tags to avoid nesting
|
||||||
// This is necessary because the clipboard format uses the older Word XML structure
|
const innerContent = omml
|
||||||
const compatibleOmml = omml
|
.replace(/<m:oMath[^>]*>/g, '')
|
||||||
.replace(/http:\/\/schemas\.openxmlformats\.org\/officeDocument\/2006\/math/g, 'http://schemas.microsoft.com/office/2004/12/omml')
|
.replace(/<\/m:oMath>/g, '');
|
||||||
.replace(/http:\/\/schemas\.openxmlformats\.org\/wordprocessingml\/2006\/main/g, 'http://schemas.microsoft.com/office/word/2003/wordml');
|
|
||||||
|
|
||||||
// Simple XML declaration wrapper often helps
|
// Return with proper Office Open XML structure
|
||||||
return `<?xml version="1.0"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<?mso-application progid="Word.Document"?>
|
<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:m="http://schemas.microsoft.com/office/2004/12/omml">
|
${innerContent}
|
||||||
<w:body>
|
</m:oMath>`;
|
||||||
<w:p>
|
|
||||||
<m:oMathPara>
|
|
||||||
${compatibleOmml}
|
|
||||||
</m:oMathPara>
|
|
||||||
</w:p>
|
|
||||||
</w:body>
|
|
||||||
</w:wordDocument>`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user