/** * 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 = { 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}
`; } } /** * 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); }