Skip to content

Commit c5dd4d6

Browse files
staredclaude
andcommitted
Implement LaTeX export with minimal preamble and xcolor
- Complete standalone LaTeX document (article class) - Minimal preamble: fontenc, amsmath, xcolor packages - Colors defined in preamble with \definecolor{termX}{HTML}{hex} - Non-interactive: \htmlClass removed, \textcolor applied for colors - Equation in equation environment - Description with colored terms - Definitions with colored headings and preserved inline math - Helper functions: - stripHtmlClassForLatex: converts \htmlClass to \textcolor - convertDescriptionToLatex: strips HTML tags, escapes LaTeX - escapeLatexPreservingMath: preserves $...$ while escaping - Comprehensive test with 12/12 checks passing - Compiles cleanly with pdflatex, generates colored PDF - Added LaTeX option to export dropdown UI - Uses currently selected color scheme from app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 9873a17 commit c5dd4d6

File tree

3 files changed

+342
-4
lines changed

3 files changed

+342
-4
lines changed

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ <h1 id="equation-title">Interactive Equations</h1>
6262
<button id="export-btn" class="toolbar-btn" title="Export">Export as...</button>
6363
<div id="export-menu" class="export-menu" style="display: none;">
6464
<button class="export-option" data-format="html">HTML</button>
65+
<button class="export-option" data-format="latex">LaTeX</button>
6566
</div>
6667
</div>
6768
<button id="copy-btn" class="toolbar-btn" title="Copy to clipboard">Copy</button>

src/exporter.ts

Lines changed: 251 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,48 @@ export function escapeLaTeX(text: string): string {
6464
return text.replace(/[\\{}$&%#_~^]/g, (char) => map[char]);
6565
}
6666

67+
/**
68+
* Escape LaTeX text while preserving inline math ($...$)
69+
*/
70+
function escapeLatexPreservingMath(text: string): string {
71+
let result = '';
72+
let i = 0;
73+
let inMath = false;
74+
let mathStart = -1;
75+
76+
while (i < text.length) {
77+
if (text[i] === '$' && (i === 0 || text[i - 1] !== '\\')) {
78+
if (!inMath) {
79+
// Start of math
80+
mathStart = i;
81+
inMath = true;
82+
result += '$'; // Keep the $ for math mode
83+
i++;
84+
} else {
85+
// End of math - keep everything in math mode as-is
86+
result += text.substring(mathStart + 1, i) + '$';
87+
inMath = false;
88+
i++;
89+
mathStart = -1;
90+
}
91+
} else if (!inMath) {
92+
// Outside math - escape special characters
93+
result += escapeLaTeX(text[i]);
94+
i++;
95+
} else {
96+
// Inside math - don't process yet, will be added when we hit closing $
97+
i++;
98+
}
99+
}
100+
101+
// Handle unclosed math
102+
if (inMath) {
103+
result += escapeLaTeX(text.substring(mathStart));
104+
}
105+
106+
return result;
107+
}
108+
67109
/**
68110
* Get file extension for export format
69111
*/
@@ -486,15 +528,220 @@ function renderInlineMath(text: string): string {
486528
return result;
487529
}
488530

531+
/**
532+
* Strip \htmlClass wrappers from LaTeX and replace with \textcolor
533+
* Converts \htmlClass{term-X}{content} → \textcolor{termX}{content}
534+
*/
535+
function stripHtmlClassForLatex(latex: string): string {
536+
let result = '';
537+
let i = 0;
538+
539+
while (i < latex.length) {
540+
// Look for \htmlClass{
541+
if (latex.substring(i, i + 11) === '\\htmlClass{') {
542+
// Find the closing }
543+
const classStart = i + 11;
544+
let classEnd = latex.indexOf('}', classStart);
545+
546+
if (classEnd === -1) {
547+
// Malformed, just copy and continue
548+
result += latex[i];
549+
i++;
550+
continue;
551+
}
552+
553+
const fullClassName = latex.substring(classStart, classEnd);
554+
555+
// Check if it's a term-X class
556+
if (!fullClassName.startsWith('term-')) {
557+
// Not a term class, just copy
558+
result += latex.substring(i, classEnd + 1);
559+
i = classEnd + 1;
560+
continue;
561+
}
562+
563+
// Extract className from term-X
564+
const className = fullClassName.substring(5); // Remove 'term-' prefix
565+
const latexColorName = `term${className}`;
566+
567+
// Check if there's a { after }
568+
if (latex[classEnd + 1] !== '{') {
569+
// Malformed, just copy and continue
570+
result += latex.substring(i, classEnd + 1);
571+
i = classEnd + 1;
572+
continue;
573+
}
574+
575+
// Find the matching closing brace for content
576+
const contentStart = classEnd + 2; // After }{
577+
let braceCount = 1;
578+
let contentEnd = contentStart;
579+
580+
while (contentEnd < latex.length && braceCount > 0) {
581+
if (latex[contentEnd] === '{' && latex[contentEnd - 1] !== '\\') {
582+
braceCount++;
583+
} else if (latex[contentEnd] === '}' && latex[contentEnd - 1] !== '\\') {
584+
braceCount--;
585+
}
586+
contentEnd++;
587+
}
588+
589+
if (braceCount !== 0) {
590+
// Unmatched braces, just copy and continue
591+
result += latex.substring(i, contentStart);
592+
i = contentStart;
593+
continue;
594+
}
595+
596+
// Extract content (excluding the final })
597+
const content = latex.substring(contentStart, contentEnd - 1);
598+
599+
// Replace with \textcolor{termX}{content}
600+
result += `\\textcolor{${latexColorName}}{${content}}`;
601+
602+
i = contentEnd;
603+
} else {
604+
result += latex[i];
605+
i++;
606+
}
607+
}
608+
609+
return result;
610+
}
611+
612+
/**
613+
* Convert HTML description to LaTeX text
614+
* Strips <span> tags and converts content to LaTeX
615+
*/
616+
function convertDescriptionToLatex(html: string): string {
617+
let result = '';
618+
let i = 0;
619+
620+
while (i < html.length) {
621+
// Look for <span class="term-X">
622+
if (html.substring(i, i + 18) === '<span class="term-') {
623+
// Find the closing >
624+
const tagEnd = html.indexOf('>', i);
625+
if (tagEnd === -1) {
626+
result += escapeLaTeX(html[i]);
627+
i++;
628+
continue;
629+
}
630+
631+
// Extract class name
632+
const tagContent = html.substring(i, tagEnd + 1);
633+
const classMatch = tagContent.match(/class="term-([^"]+)"/);
634+
635+
if (!classMatch) {
636+
result += escapeLaTeX(html.substring(i, tagEnd + 1));
637+
i = tagEnd + 1;
638+
continue;
639+
}
640+
641+
const className = classMatch[1];
642+
const latexColorName = `term${className}`;
643+
644+
// Find the closing </span>
645+
const closeTag = '</span>';
646+
const closeIndex = html.indexOf(closeTag, tagEnd + 1);
647+
648+
if (closeIndex === -1) {
649+
result += escapeLaTeX(html.substring(i, tagEnd + 1));
650+
i = tagEnd + 1;
651+
continue;
652+
}
653+
654+
// Extract content between tags
655+
const content = html.substring(tagEnd + 1, closeIndex);
656+
657+
// Output colored text in LaTeX
658+
result += `\\textcolor{${latexColorName}}{${escapeLaTeX(content)}}`;
659+
660+
i = closeIndex + closeTag.length;
661+
} else if (html.substring(i, i + 3) === '<p>') {
662+
// Skip <p> tags
663+
i += 3;
664+
} else if (html.substring(i, i + 4) === '</p>') {
665+
// Replace </p> with paragraph break
666+
result += '\n\n';
667+
i += 4;
668+
} else {
669+
// Regular text - escape for LaTeX
670+
result += escapeLaTeX(html[i]);
671+
i++;
672+
}
673+
}
674+
675+
return result.trim();
676+
}
677+
489678
/**
490679
* Export to LaTeX (complete document with xcolor)
491-
* Implementation: Commit 3
680+
* Non-interactive: colors defined in preamble, applied with \textcolor
492681
*/
493682
export function exportToLaTeX(
494-
_content: ParsedContent,
495-
_colorScheme: ColorScheme
683+
content: ParsedContent,
684+
colorScheme: ColorScheme
496685
): string {
497-
throw new Error('LaTeX export not yet implemented');
686+
// Generate color definitions for preamble
687+
const colorDefinitions = content.termOrder
688+
.map((className) => {
689+
const color = getTermColor(className, content.termOrder, colorScheme);
690+
const latexColorName = `term${className}`;
691+
// Convert #RRGGBB to uppercase hex for LaTeX
692+
const hexColor = color.replace('#', '').toUpperCase();
693+
return `\\definecolor{${latexColorName}}{HTML}{${hexColor}}`;
694+
})
695+
.join('\n');
696+
697+
// Convert LaTeX: replace \htmlClass{term-X}{content} with \textcolor{termX}{content}
698+
const coloredLatex = stripHtmlClassForLatex(content.latex);
699+
700+
// Convert description: strip HTML tags, convert to LaTeX
701+
const descriptionLatex = convertDescriptionToLatex(content.description);
702+
703+
// Convert definitions
704+
const definitionsLatex = Array.from(content.definitions.entries())
705+
.map(([className, definition]) => {
706+
const latexColorName = `term${className}`;
707+
// Definitions are plain text, but may contain inline math $...$ which should be kept as-is
708+
// Escape text outside of math mode, keep math mode untouched
709+
const definitionLatex = escapeLatexPreservingMath(definition);
710+
return `\\subsection*{\\textcolor{${latexColorName}}{${escapeLaTeX(className)}}}\n${definitionLatex}`;
711+
})
712+
.join('\n\n');
713+
714+
return `\\documentclass{article}
715+
716+
% Minimal preamble
717+
\\usepackage[T1]{fontenc}
718+
\\usepackage{amsmath}
719+
\\usepackage{xcolor}
720+
721+
% Define colors from scheme
722+
${colorDefinitions}
723+
724+
\\title{${escapeLaTeX(content.title || 'Mathematical Equation')}}
725+
\\date{}
726+
727+
\\begin{document}
728+
\\maketitle
729+
730+
\\section*{Equation}
731+
732+
\\begin{equation}
733+
${coloredLatex}
734+
\\end{equation}
735+
736+
\\section*{Description}
737+
738+
${descriptionLatex}
739+
740+
\\section*{Definitions}
741+
742+
${definitionsLatex}
743+
744+
\\end{document}`;
498745
}
499746

500747
/**

test-latex-export-real.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Test LaTeX export with real equation file
2+
import { parseContent } from './src/parser';
3+
import { exportToLaTeX } from './src/exporter';
4+
import type { ColorScheme } from './src/exporter';
5+
import { writeFileSync, readFileSync } from 'fs';
6+
7+
// Test color scheme
8+
const colorScheme: ColorScheme = {
9+
name: 'viridis',
10+
colors: ['#440154', '#31688e', '#35b779', '#fde724', '#20908d', '#5ec962', '#3b528b'],
11+
};
12+
13+
async function testLatexExport() {
14+
console.log('Testing LaTeX export with real equation file...\n');
15+
16+
// Load Euler's identity equation
17+
const markdown = readFileSync('./public/examples/euler.md', 'utf-8');
18+
const parsed = await parseContent(markdown);
19+
console.log('✓ Content loaded successfully');
20+
console.log(` Title: ${parsed.title}`);
21+
console.log(` Terms: ${parsed.termOrder.join(', ')}`);
22+
console.log(` Definitions: ${parsed.definitions.size}\n`);
23+
24+
// Generate LaTeX export
25+
const latex = exportToLaTeX(parsed, colorScheme);
26+
console.log('✓ LaTeX export generated successfully\n');
27+
28+
// Validation checks
29+
const checks = [
30+
{ name: 'Document class', test: () => latex.includes('\\documentclass{article}') },
31+
{ name: 'xcolor package', test: () => latex.includes('\\usepackage{xcolor}') },
32+
{ name: 'amsmath package', test: () => latex.includes('\\usepackage{amsmath}') },
33+
{ name: 'Color definitions exist', test: () => latex.includes('\\definecolor{term') },
34+
{ name: 'Colored equation terms', test: () => latex.includes('\\textcolor{term') },
35+
{ name: 'No \\htmlClass', test: () => !latex.includes('\\htmlClass') },
36+
{ name: 'Title present', test: () => latex.includes('Euler') },
37+
{ name: 'Description section', test: () => latex.includes('\\section*{Description}') },
38+
{ name: 'Definitions section', test: () => latex.includes('\\section*{Definitions}') },
39+
{ name: 'Document structure', test: () => latex.includes('\\begin{document}') && latex.includes('\\end{document}') },
40+
{ name: 'Equation environment', test: () => latex.includes('\\begin{equation}') && latex.includes('\\end{equation}') },
41+
{ name: 'Equation not empty', test: () => {
42+
const eqMatch = latex.match(/\\begin\{equation\}(.+?)\\end\{equation\}/s);
43+
return eqMatch && eqMatch[1].trim().length > 0;
44+
}},
45+
];
46+
47+
let passed = 0;
48+
let failed = 0;
49+
50+
checks.forEach((check) => {
51+
try {
52+
if (check.test()) {
53+
console.log(` ✓ ${check.name}`);
54+
passed++;
55+
} else {
56+
console.log(` ✗ ${check.name}`);
57+
failed++;
58+
}
59+
} catch (error) {
60+
console.log(` ✗ ${check.name} (error: ${error})`);
61+
failed++;
62+
}
63+
});
64+
65+
console.log(`\n${passed}/${checks.length} checks passed\n`);
66+
67+
// Write to file for manual inspection
68+
const outputPath = '/tmp/test-euler-export.tex';
69+
writeFileSync(outputPath, latex);
70+
console.log(`LaTeX output written to: ${outputPath}`);
71+
console.log('You can compile it with: pdflatex /tmp/test-euler-export.tex\n');
72+
73+
// Show first few lines
74+
console.log('First 40 lines of LaTeX output:');
75+
console.log('---');
76+
console.log(latex.split('\n').slice(0, 40).join('\n'));
77+
console.log('...\n');
78+
79+
if (failed > 0) {
80+
console.error(`❌ ${failed} checks failed`);
81+
process.exit(1);
82+
} else {
83+
console.log('✅ All checks passed!');
84+
}
85+
}
86+
87+
testLatexExport().catch((error) => {
88+
console.error('Test failed:', error);
89+
process.exit(1);
90+
});

0 commit comments

Comments
 (0)