Commit e390e4a6 authored by fisherdaddy's avatar fisherdaddy

feature: 新增Latex转图片工具

parent 0444fc8a
...@@ -10,8 +10,10 @@ ...@@ -10,8 +10,10 @@
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.1", "@react-oauth/google": "^0.12.1",
"antd": "^5.21.6", "antd": "^5.21.6",
"dompurify": "^3.1.7",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18next": "^23.16.5", "i18next": "^23.16.5",
"katex": "^0.16.11",
"marked": "^15.0.0", "marked": "^15.0.0",
"marked-react": "^2.0.0", "marked-react": "^2.0.0",
"react": "^18.2.0", "react": "^18.2.0",
......
...@@ -12,7 +12,7 @@ const Blog = lazy(() => import('./pages/Blog')); ...@@ -12,7 +12,7 @@ const Blog = lazy(() => import('./pages/Blog'));
const AIProduct = lazy(() => import('./pages/AIProduct')); const AIProduct = lazy(() => import('./pages/AIProduct'));
const JsonFormatter = lazy(() => import('./components/JsonFormatter')); const JsonFormatter = lazy(() => import('./components/JsonFormatter'));
const TextToImage = lazy(() => import('./components/TextToImage')); const MarkdownToImage = lazy(() => import('./components/MarkdownToImage'));
const UrlEnDecode = lazy(() => import('./components/UrlEnDecode')); const UrlEnDecode = lazy(() => import('./components/UrlEnDecode'));
const About = lazy(() => import('./pages/About')); const About = lazy(() => import('./pages/About'));
const OpenAITimeline = lazy(() => import('./components/OpenAITimeline')); const OpenAITimeline = lazy(() => import('./components/OpenAITimeline'));
...@@ -20,6 +20,7 @@ const PricingCharts = lazy(() => import('./components/PricingCharts')); ...@@ -20,6 +20,7 @@ const PricingCharts = lazy(() => import('./components/PricingCharts'));
const HandwriteGen = lazy(() => import('./components/HandwriteGen')); const HandwriteGen = lazy(() => import('./components/HandwriteGen'));
const ImageBase64Converter = lazy(() => import('./components/ImageBase64Converter')); const ImageBase64Converter = lazy(() => import('./components/ImageBase64Converter'));
const QuoteCard = lazy(() => import('./components/QuoteCard')); const QuoteCard = lazy(() => import('./components/QuoteCard'));
const LatexToImage = lazy(() => import('./components/LatexToImage'));
function App() { function App() {
return ( return (
...@@ -38,7 +39,7 @@ function App() { ...@@ -38,7 +39,7 @@ function App() {
<Route path="/ai-products" element={<AIProduct />} /> <Route path="/ai-products" element={<AIProduct />} />
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
<Route path="/text2image" element={<TextToImage />} /> <Route path="/markdown-to-image" element={<MarkdownToImage />} />
<Route path="/json-formatter" element={<JsonFormatter />} /> <Route path="/json-formatter" element={<JsonFormatter />} />
<Route path="/url-encode-and-decode" element={<UrlEnDecode />} /> <Route path="/url-encode-and-decode" element={<UrlEnDecode />} />
<Route path="/openai-timeline" element={<OpenAITimeline />} /> <Route path="/openai-timeline" element={<OpenAITimeline />} />
...@@ -46,6 +47,7 @@ function App() { ...@@ -46,6 +47,7 @@ function App() {
<Route path="/handwriting" element={<HandwriteGen />} /> <Route path="/handwriting" element={<HandwriteGen />} />
<Route path="/image-base64" element={<ImageBase64Converter />} /> <Route path="/image-base64" element={<ImageBase64Converter />} />
<Route path="/quote-card" element={<QuoteCard />} /> <Route path="/quote-card" element={<QuoteCard />} />
<Route path="/latex-to-image" element={<LatexToImage />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
......
import React, { useState, useMemo } from 'react';
import styled from 'styled-components';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import DOMPurify from 'dompurify';
import SEO from './SEO';
import { useTranslation } from '../js/i18n';
import html2canvas from 'html2canvas';
// 容器样式
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
`;
const ContentWrapper = styled.div`
display: flex;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
@media (max-width: 768px) {
flex-direction: column;
}
`;
const EditorContainer = styled.div`
flex: 1;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
`;
const PreviewContainer = styled(EditorContainer)`
overflow: auto;
position: relative;
`;
const Editor = styled.textarea`
width: 100%;
min-height: 400px;
padding: 1rem;
border: none;
background: transparent;
font-family: 'SF Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
color: #1a1a1a;
&:focus {
outline: none;
}
`;
const Preview = styled.div`
font-family: -apple-system, system-ui, sans-serif;
color: #1a1a1a;
line-height: 1.6;
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
}
p {
margin: 1em 0;
}
img {
max-width: 100%;
height: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid rgba(99, 102, 241, 0.2);
padding: 0.5em;
}
.katex {
font-size: 1.1em;
}
`;
const TitleLabel = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.02em;
`;
const DownloadButton = styled.button`
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.2s;
font-size: 0.9rem;
position: absolute;
top: 1.5rem;
right: 1.5rem;
opacity: ${props => props.visible ? 1 : 0};
pointer-events: ${props => props.visible ? 'auto' : 'none'};
&:hover {
opacity: 0.9;
}
`;
function HtmlPreview() {
const [html, setHtml] = useState('');
const { t } = useTranslation();
// 处理 LaTeX 公式
const processLatex = (content) => {
return content.replace(/\$\$([\s\S]*?)\$\$|\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)|\$(.*?)\$/g, (match, p1, p2, p3, p4) => {
const formula = p1 || p2 || p3 || p4;
const displayMode = match.startsWith('$$') || match.startsWith('\\[');
try {
return katex.renderToString(formula.trim(), {
displayMode,
throwOnError: false
});
} catch (e) {
console.error('LaTeX解析错误:', e);
return match;
}
});
};
// 使用 useMemo 缓存处理后的 HTML
const renderedContent = useMemo(() => {
if (!html) return '';
try {
// 处理 LaTeX 公式
let processedHtml = processLatex(html);
// 清理 HTML,防止 XSS 攻击
const sanitizedHtml = DOMPurify.sanitize(processedHtml, {
ADD_TAGS: ['math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'mfrac'],
ADD_ATTR: ['display', 'mode', 'class']
});
return sanitizedHtml;
} catch (e) {
console.error('渲染错误:', e);
return `渲染错误: ${e.message}`;
}
}, [html]);
const handleDownload = async () => {
const previewElement = document.querySelector('.preview-content');
if (!previewElement) return;
try {
// 等待图片加载
const waitForImages = () => {
const images = previewElement.getElementsByTagName('img');
const promises = Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve;
});
});
return Promise.all(promises);
};
await waitForImages();
// 等待 LaTeX 渲染
await new Promise(resolve => setTimeout(resolve, 500));
// 计算实际内容高度(包括所有子元素)
const computeActualHeight = (element) => {
const style = window.getComputedStyle(element);
const marginTop = parseInt(style.marginTop);
const marginBottom = parseInt(style.marginBottom);
let height = element.offsetHeight + marginTop + marginBottom;
// 获取所有子元素的位置信息
const children = element.children;
if (children.length > 0) {
const lastChild = children[children.length - 1];
const lastChildRect = lastChild.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
height = Math.max(height, lastChildRect.bottom - elementRect.top + marginBottom + 50);
}
return height;
};
const actualHeight = computeActualHeight(previewElement);
const actualWidth = previewElement.offsetWidth;
const canvas = await html2canvas(previewElement, {
backgroundColor: '#ffffff',
scale: 2,
width: actualWidth,
height: actualHeight,
windowWidth: actualWidth,
windowHeight: actualHeight,
useCORS: true,
logging: false,
onclone: (clonedDoc) => {
const clonedElement = clonedDoc.querySelector('.preview-content');
if (clonedElement) {
// 设置固定尺寸
clonedElement.style.width = `${actualWidth}px`;
clonedElement.style.height = `${actualHeight}px`;
clonedElement.style.position = 'relative';
clonedElement.style.transform = 'none';
clonedElement.style.transformOrigin = '0 0';
// 确保内容不会溢出
clonedElement.style.overflow = 'visible';
clonedElement.style.padding = '20px';
clonedElement.style.boxSizing = 'border-box';
// 强制重新计算布局
clonedElement.style.display = 'block';
}
}
});
const link = document.createElement('a');
link.download = 'latex-preview.png';
link.href = canvas.toDataURL('image/png');
link.click();
} catch (error) {
console.error('导出图片失败:', error);
}
};
return (
<>
<SEO
title={t('tools.latex2image.title')}
description={t('tools.latex2image.description')}
/>
<Container>
<ContentWrapper>
<EditorContainer>
<TitleLabel>{t('tools.latex2image.title')}</TitleLabel>
<Editor
value={html}
onChange={(e) => setHtml(e.target.value)}
placeholder={t('tools.latex2image.placeholder')}
/>
</EditorContainer>
<PreviewContainer>
<TitleLabel>{t('tools.latex2image.preview')}</TitleLabel>
<DownloadButton
onClick={handleDownload}
visible={renderedContent.length > 0}
>
{t('tools.latex2image.download')}
</DownloadButton>
<Preview
className="preview-content"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</PreviewContainer>
</ContentWrapper>
</Container>
</>
);
}
export default HtmlPreview;
...@@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react'; ...@@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { marked } from 'marked'; import { marked } from 'marked';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO'; import SEO from './SEO';
import Marked from 'marked-react'; import Marked from 'marked-react';
// 更新预设模板 // 更新预设模板
...@@ -277,18 +277,18 @@ function TextToImage() { ...@@ -277,18 +277,18 @@ function TextToImage() {
return ( return (
<> <>
<SEO <SEO
title={t('tools.text2image.title')} title={t('tools.markdown2image.title')}
description={t('tools.text2image.description')} description={t('tools.markdown2image.description')}
/> />
<Container> <Container>
<ContentWrapper> <ContentWrapper>
<InputContainer> <InputContainer>
<TitleLabel>{t('tools.text2image.title')}</TitleLabel> <TitleLabel>{t('tools.markdown2image.title')}</TitleLabel>
{/* 模板选择 */} {/* 模板选择 */}
<Section> <Section>
<Label>{t('tools.text2image.selectTemplate')}</Label> <Label>{t('tools.markdown2image.selectTemplate')}</Label>
<TemplateGrid> <TemplateGrid>
{templates.map(template => ( {templates.map(template => (
<TemplateItem <TemplateItem
...@@ -298,7 +298,7 @@ function TextToImage() { ...@@ -298,7 +298,7 @@ function TextToImage() {
background={template.bgColor} background={template.bgColor}
color={template.textColor} color={template.textColor}
> >
{t(`tools.text2image.templates.${template.name}`)} {t(`tools.markdown2image.templates.${template.name}`)}
</TemplateItem> </TemplateItem>
))} ))}
</TemplateGrid> </TemplateGrid>
...@@ -306,16 +306,16 @@ function TextToImage() { ...@@ -306,16 +306,16 @@ function TextToImage() {
{/* Markdown 编辑器 */} {/* Markdown 编辑器 */}
<Section> <Section>
<Label>{t('tools.text2image.inputLabel')}</Label> <Label>{t('tools.markdown2image.inputLabel')}</Label>
<MarkdownEditor <MarkdownEditor
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder={t('tools.text2image.placeholder')} placeholder={t('tools.markdown2image.placeholder')}
/> />
</Section> </Section>
<DownloadButton onClick={handleDownload}> <DownloadButton onClick={handleDownload}>
{t('tools.text2image.downloadButton')} {t('tools.markdown2image.downloadButton')}
</DownloadButton> </DownloadButton>
</InputContainer> </InputContainer>
...@@ -332,7 +332,7 @@ function TextToImage() { ...@@ -332,7 +332,7 @@ function TextToImage() {
}} }}
> >
<Marked> <Marked>
{text || t('tools.text2image.previewDefault')} {text || t('tools.markdown2image.previewDefault')}
</Marked> </Marked>
</div> </div>
</PreviewContainer> </PreviewContainer>
......
{ {
"text2image": { "markdown2image": {
"title": "Text to Image Card", "title": "Markdown to Image",
"description": "Convert text to image card", "description": "Convert Markdown data into beautiful images",
"selectTemplate": "Select Template", "selectTemplate": "Select Template",
"inputLabel": "Input Text (Markdown Supported)", "inputLabel": "Input Text (Markdown Supported)",
"placeholder": "# Title\n## Subtitle\n- List item\n**Bold** *Italic*", "placeholder": "# Title\n## Subtitle\n- List item\n**Bold** *Italic*",
...@@ -16,6 +16,13 @@ ...@@ -16,6 +16,13 @@
"tech": "Tech" "tech": "Tech"
} }
}, },
"latex2image": {
"title": "Latex to Image",
"description": "Effortlessly convert Latex formulas to images!",
"preview": "Preview",
"placeholder": "Enter your Latex formula here, HTML is supported:\nExample:\n<h1>Title</h1>\n<p>This is an inline formula: $E=mc^2$</p>\n<p>This is a block-level formula: $$\\sum_{i=1}^n i$$</p>",
"download": "Download Preview Image"
},
"quoteCard": { "quoteCard": {
"title": "Quote Card Generator", "title": "Quote Card Generator",
"description": "Convert bilingual quotes to cards", "description": "Convert bilingual quotes to cards",
......
{ {
"text2image": { "markdown2image": {
"title": "テキストから画像", "title": "Markdownを画像に変換",
"description": "テキストを画像カードに変換", "description": "Markdownデータを美しい画像に変換します",
"inputPlaceholder": "テキストを入力(タイトルを含めることができます、例:# タイトル1)", "inputPlaceholder": "テキストを入力(タイトルを含めることができます、例:# タイトル1)",
"selectTemplate": "テンプレートを選択", "selectTemplate": "テンプレートを選択",
"previewDefault": "# プレビューエリア\nテキストを入力すると、ここにプレビューが表示されます", "previewDefault": "# プレビューエリア\nテキストを入力すると、ここにプレビューが表示されます",
...@@ -16,6 +16,13 @@ ...@@ -16,6 +16,13 @@
"tech": "テック" "tech": "テック"
} }
}, },
"latex2image": {
"title": "Latex画像変換",
"description": "Latexの数式を簡単に画像に変換できます!",
"preview": "プレビュー",
"placeholder": "ここにLatexの数式を入力してください。HTMLも使用可能です:\n例:\n<h1>タイトル</h1>\n<p>これはインライン数式です:$E=mc^2$</p>\n<p>これはブロックレベルの数式です:$$\\sum_{i=1}^n i$$</p>",
"download": "プレビュー画像をダウンロード"
},
"quoteCard": { "quoteCard": {
"title": "名言カードジェネレーター", "title": "名言カードジェネレーター",
"description": "名言のバイリンガルテキストをカードに変換", "description": "名言のバイリンガルテキストをカードに変換",
......
{ {
"text2image": { "markdown2image": {
"title": "텍스트를 이미지로", "title": "Markdown을 이미지로 변환",
"description": "텍스트를 이미지 카드로 변환", "description": "Markdown 데이터를 아름다운 이미지로 변환합니다",
"inputPlaceholder": "텍스트 입력 (제목 포함 가능, 예: # 제목 1)", "inputPlaceholder": "텍스트 입력 (제목 포함 가능, 예: # 제목 1)",
"downloadButton": "이미지로 내보내기", "downloadButton": "이미지로 내보내기",
"selectTemplate": "템플릿 선택", "selectTemplate": "템플릿 선택",
...@@ -17,6 +17,13 @@ ...@@ -17,6 +17,13 @@
"tech": "테크" "tech": "테크"
} }
}, },
"latex2image": {
"title": "Latex 이미지 변환",
"description": "Latex 수식을 손쉽게 이미지로 변환하세요! ",
"preview": "미리보기",
"placeholder": "여기에 Latex 수식을 입력하세요. HTML도 지원됩니다:\n예시:\n<h1>제목</h1>\n<p>이것은 인라인 수식입니다: $E=mc^2$</p>\n<p>이것은 블록 수식입니다: $$\\sum_{i=1}^n i$$</p>",
"download": "미리보기 이미지 다운로드"
},
"quoteCard": { "quoteCard": {
"title": "명언 카드 생성기", "title": "명언 카드 생성기",
"description": "명언 이중 언어 텍스트를 카드로 변환", "description": "명언 이중 언어 텍스트를 카드로 변환",
......
{ {
"text2image": { "markdown2image": {
"title": "文字转图片生成器", "title": "Markdown转图片",
"description": "将文本转换为精美的图片", "description": "将Markdown数据转换为精美的图片",
"selectTemplate": "选择模板", "selectTemplate": "选择模板",
"inputLabel": "输入文本 (支持 Markdown)", "inputLabel": "输入文本 (支持 Markdown)",
"placeholder": "# 标题\n## 子标题\n- 列表项\n**粗体** *斜体*", "placeholder": "# 标题\n## 子标题\n- 列表项\n**粗体** *斜体*",
...@@ -16,6 +16,13 @@ ...@@ -16,6 +16,13 @@
"tech": "科技" "tech": "科技"
} }
}, },
"latex2image": {
"title": "Latex转图片",
"description": "轻松将Latex公式转换为图片!",
"preview": "预览",
"placeholder": "在此输入Latex公式,支持HTML:\n例如:\n<h1>标题</h1>\n<p>这是一个行内公式:$E=mc^2$</p>\n<p>这是一个块级公式:$$\\sum_{i=1}^n i$$</p>",
"download": "下载预览图片"
},
"quoteCard": { "quoteCard": {
"title": "名言卡片生成器", "title": "名言卡片生成器",
"description": "名言双语文本转为卡片", "description": "名言双语文本转为卡片",
......
...@@ -6,7 +6,8 @@ import SEO from '../components/SEO'; ...@@ -6,7 +6,8 @@ import SEO from '../components/SEO';
const tools = [ const tools = [
{ id: 'handwrite', icon: '/assets/icon/handwrite.png', path: '/handwriting' }, { id: 'handwrite', icon: '/assets/icon/handwrite.png', path: '/handwriting' },
{ id: 'quoteCard', icon: '/assets/icon/quotecard.png', path: '/quote-card' }, { id: 'quoteCard', icon: '/assets/icon/quotecard.png', path: '/quote-card' },
{ id: 'text2image', icon: '/assets/icon/text2image.png', path: '/text2image' }, { id: 'markdown2image', icon: '/assets/icon/markdown2image.png', path: '/markdown-to-image' },
{ id: 'latex2image', icon: '/assets/icon/latex2image.png', path: '/latex-to-image' },
{ id: 'jsonFormatter', icon: '/assets/icon/json-format.png', path: '/json-formatter' }, { id: 'jsonFormatter', icon: '/assets/icon/json-format.png', path: '/json-formatter' },
{ id: 'urlEncodeDecode', icon: '/assets/icon/url-endecode.png', path: '/url-encode-and-decode' }, { id: 'urlEncodeDecode', icon: '/assets/icon/url-endecode.png', path: '/url-encode-and-decode' },
{ id: 'imageBase64Converter', icon: '/assets/icon/image-base64.png', path: '/image-base64' }, { id: 'imageBase64Converter', icon: '/assets/icon/image-base64.png', path: '/image-base64' },
......
...@@ -6,7 +6,8 @@ import SEO from '../components/SEO'; ...@@ -6,7 +6,8 @@ import SEO from '../components/SEO';
const tools = [ const tools = [
{ id: 'handwrite', icon: '/assets/icon/handwrite.png', path: '/handwriting' }, { id: 'handwrite', icon: '/assets/icon/handwrite.png', path: '/handwriting' },
{ id: 'quoteCard', icon: '/assets/icon/quotecard.png', path: '/quote-card' }, { id: 'quoteCard', icon: '/assets/icon/quotecard.png', path: '/quote-card' },
{ id: 'text2image', icon: '/assets/icon/text2image.png', path: '/text2image' }, { id: 'markdown2image', icon: '/assets/icon/markdown2image.png', path: '/markdown-to-image' },
{ id: 'latex2image', icon: '/assets/icon/latex2image.png', path: '/latex-to-image' },
]; ];
const ImageTools = () => { const ImageTools = () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment