343 lines
8.9 KiB
TypeScript
343 lines
8.9 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}
|
||
|
|
|