Files
bentopdf/src/js/logic/md-to-pdf.ts
T
TENSAIandsyazkun 5db93f9fa0 feat: Add Markdown to PDF conversion functionality (#59)
* feat: Add Markdown to PDF conversion functionality

- Implement dual-panel interface with live preview
- Support 3 Markdown flavors: GitHub, CommonMark, Pandoc
- Add file upload support for .md files
- Implement image quality settings (High/Medium/Low)
- Add professional user messaging and error handling
- Support all common Markdown elements (headers, lists, tables, code blocks, etc.)
- Add automatic navigation back to tool after completion

Resolves #37

* fix: improve markdown to PDF conversion with math support and better rendering

- Add KaTeX support for mathematical expressions (inline and block math)
- Fix horizontal separator rendering with improved CSS
- Enhance table rendering with proper borders and cell styling
- Replace CDN dependencies with local KaTeX installation
- Add comprehensive math preprocessing for $...$ and 20112...20112 syntax
- Improve markdown preview with real-time math rendering
- Update UI placeholder text with math examples
- Add fallback rendering for math expressions when KaTeX fails

Resolves issues with:
- Horizontal separators not rendering properly
- Tables missing borders and proper structure
- Mathematical expressions not displaying correctly

---------

Co-authored-by: syazkun <mohamadsyazwan193@gmail.com>
2025-10-23 11:52:04 +05:30

484 lines
14 KiB
TypeScript

/**
* Markdown to PDF Converter
* Purpose: Convert Markdown content to PDF with support for multiple markdown flavors
* Features:
* - GitHub Flavored Markdown support
* - CommonMark support
* - Pandoc Markdown support
* - Real-time preview
* - File upload support
* @version 1.0
* @package BentoPDF
*/
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile } from '../utils/helpers.js';
import { marked } from 'marked';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
import katex from 'katex';
import 'katex/dist/katex.min.css';
// Configure marked for different markdown flavors
const configureMarked = (flavor: string) => {
switch (flavor) {
case 'github':
return marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
});
case 'commonmark':
return marked.setOptions({
gfm: false,
breaks: false,
pedantic: true
});
case 'pandoc':
return marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
});
default:
return marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
});
}
};
// Function to preprocess markdown content to handle math expressions
function preprocessMathContent(content: string): string {
// Handle inline math: $...$
content = content.replace(/\$([^$]+)\$/g, '<span class="math-inline">$1</span>');
// Handle block math: $$...$$
content = content.replace(/\$\$([\s\S]*?)\$\$/g, '<div class="math-block">$1</div>');
return content;
}
// Function to render KaTeX mathematical expressions using local installation
function renderMathExpressions(container: HTMLElement) {
try {
// Render math expressions using local KaTeX
const mathElements = container.querySelectorAll('.math-inline, .math-block');
mathElements.forEach((element) => {
try {
const mathText = element.textContent || '';
if (mathText.trim()) {
const rendered = katex.renderToString(mathText, {
throwOnError: false,
displayMode: element.classList.contains('math-block'),
strict: false
});
element.innerHTML = rendered;
element.classList.add('katex-rendered');
}
} catch (error) {
console.warn('Failed to render math expression:', error);
// Keep the original text as fallback
}
});
} catch (error) {
console.warn('Failed to render math expressions:', error);
}
}
export async function mdToPdf() {
const markdownContent = (document.getElementById('md-input') as HTMLTextAreaElement)?.value?.trim();
if (!markdownContent) {
showAlert('Content Required', 'Please enter your Markdown content in the text area or upload a .md file to get started.');
return;
}
showLoader('Preparing your document for conversion...');
try {
const flavor = (document.getElementById('markdown-flavor') as HTMLSelectElement)?.value || 'github';
const pageFormat = (document.getElementById('page-format') as HTMLSelectElement)?.value || 'a4';
const orientation = (document.getElementById('orientation') as HTMLSelectElement)?.value || 'portrait';
const marginSize = (document.getElementById('margin-size') as HTMLSelectElement)?.value || 'normal';
const imageQuality = (document.getElementById('image-quality') as HTMLSelectElement)?.value || 'high';
// Configure marked based on flavor
configureMarked(flavor);
// Preprocess content to handle math expressions
const processedContent = preprocessMathContent(markdownContent);
// Parse markdown to HTML
const htmlContent = await marked.parse(processedContent);
// Create temporary container for rendering
const tempContainer = document.createElement('div');
tempContainer.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: 800px;
padding: 40px;
background: white;
color: black;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Add comprehensive styling for markdown elements
const styleSheet = document.createElement('style');
styleSheet.textContent = `
/* KaTeX Math Rendering Styles */
.math-inline, .math-block {
font-size: 1.1em;
line-height: 1.2;
}
.math-block {
text-align: center;
margin: 16px 0;
padding: 8px 0;
}
/* Fallback for math rendering when KaTeX is not loaded */
.math-inline:not(.katex-rendered),
.math-block:not(.katex-rendered) {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 2px 6px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.math-block:not(.katex-rendered) {
display: block;
text-align: center;
margin: 16px 0;
padding: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
font-size: 14px;
color: #24292e;
}
h1, h2, h3, h4, h5, h6 {
margin: 24px 0 16px 0;
font-weight: 600;
line-height: 1.25;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1em; }
h5 { font-size: 0.875em; }
h6 { font-size: 0.85em; color: #6a737d; }
p, blockquote, ul, ol, pre, table {
margin: 0 0 16px 0;
}
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background-color: rgba(27,31,35,.05);
border-radius: 3px;
padding: 0.2em 0.4em;
font-size: 85%;
}
pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin: 0 0 16px 0;
display: table;
border: 1px solid #dfe2e5;
}
th, td {
padding: 8px 12px;
border: 1px solid #dfe2e5;
text-align: left;
vertical-align: top;
display: table-cell;
}
th {
font-weight: 600;
background-color: #f6f8fa;
border-bottom: 2px solid #dfe2e5;
}
tr {
display: table-row;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
img {
max-width: 100%;
height: auto;
box-sizing: border-box;
}
ul, ol {
padding-left: 2em;
margin: 0 0 16px 0;
}
li {
margin: 0.25em 0;
}
hr {
height: 2px;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
border-top: 2px solid #e1e4e8;
display: block;
width: 100%;
}
a {
color: #0366d6;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
`;
tempContainer.appendChild(styleSheet);
tempContainer.innerHTML += htmlContent;
document.body.appendChild(tempContainer);
// Render KaTeX mathematical expressions using local installation
renderMathExpressions(tempContainer);
// Configure quality settings based on user selection
const renderSettings = {
high: { scale: 2, imageTimeout: 10000 },
medium: { scale: 1.5, imageTimeout: 5000 },
low: { scale: 1, imageTimeout: 3000 }
};
const settings = renderSettings[imageQuality as keyof typeof renderSettings] || renderSettings.high;
// Update progress
showLoader('Processing your Markdown content...');
// Generate canvas from HTML with optimized settings
const canvas = await html2canvas(tempContainer, {
scale: settings.scale,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false, // Disable logging for better performance
width: 800,
height: tempContainer.scrollHeight,
scrollX: 0,
scrollY: 0,
foreignObjectRendering: false, // Better compatibility
removeContainer: false,
imageTimeout: settings.imageTimeout,
onclone: (clonedDoc) => {
// Optimize cloned document
const clonedContainer = clonedDoc.querySelector('div');
if (clonedContainer) {
// Remove any problematic elements
const images = clonedContainer.querySelectorAll('img');
images.forEach(img => {
if (img.src.startsWith('data:') && img.src.length > 100000) {
// Skip very large inline images
img.style.display = 'none';
}
});
}
}
});
document.body.removeChild(tempContainer);
// Update progress
showLoader('Generating your PDF document...');
// Create PDF
const pdf = new jsPDF({
orientation: orientation as 'portrait' | 'landscape',
unit: 'mm',
format: pageFormat as 'a4' | 'letter' | 'legal'
});
const pageFormats = {
a4: [210, 297],
letter: [216, 279],
legal: [216, 356]
};
const format = pageFormats[pageFormat as keyof typeof pageFormats] || pageFormats.a4;
const [pageWidth, pageHeight] = orientation === 'landscape' ? [format[1], format[0]] : format;
const margins = {
narrow: 10,
normal: 20,
wide: 30
};
const margin = margins[marginSize as keyof typeof margins] || margins.normal;
const contentWidth = pageWidth - margin * 2;
const contentHeight = pageHeight - margin * 2;
// Configure image quality based on user selection
const imageQualitySettings = {
high: 0.95, // 95% quality for high
medium: 0.85, // 85% quality for medium
low: 0.7 // 70% quality for low
};
const quality = imageQualitySettings[imageQuality as keyof typeof imageQualitySettings] || 0.95;
const imgData = canvas.toDataURL('image/jpeg', quality);
const imgHeight = (canvas.height * contentWidth) / canvas.width;
let heightLeft = imgHeight;
let position = margin;
pdf.addImage(imgData, 'JPEG', margin, position, contentWidth, imgHeight);
heightLeft -= contentHeight;
// Add additional pages if content is longer than one page
while (heightLeft > 0) {
position = position - pageHeight;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', margin, position, contentWidth, imgHeight);
heightLeft -= contentHeight;
}
// Generate PDF with compression
const pdfBlob = pdf.output('blob');
// Show file size information
const fileSizeMB = (pdfBlob.size / (1024 * 1024)).toFixed(2);
downloadFile(pdfBlob, 'markdown-document.pdf');
// Show success message
hideLoader();
showAlert('PDF Conversion Complete', 'Your PDF has been generated and downloaded successfully.', () => {
// Navigate back to the markdown tool
const markdownTool = document.querySelector('[data-tool-id="md-to-pdf"]') as HTMLElement;
if (markdownTool) {
markdownTool.click();
}
});
if (parseFloat(fileSizeMB) > 15) {
console.warn(`Large PDF generated: ${fileSizeMB} MB. Consider using "Low Quality" setting for smaller files.`);
}
} catch (error) {
console.error('MD to PDF conversion error:', error);
hideLoader();
showAlert('Conversion Failed', 'We encountered an issue while converting your Markdown to PDF.\n\nPlease check your content and try again. If the problem persists, try using a different Markdown flavor or reducing the content length.');
}
}
export function setupMarkdownTool() {
const mdInput = document.getElementById('md-input') as HTMLTextAreaElement;
const preview = document.getElementById('markdown-preview') as HTMLDivElement;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (mdInput && preview) {
// Real-time preview functionality
const updatePreview = async () => {
const content = mdInput.value;
const flavor = (document.getElementById('markdown-flavor') as HTMLSelectElement)?.value || 'github';
if (content.trim()) {
configureMarked(flavor);
const processedContent = preprocessMathContent(content);
const htmlContent = await marked.parse(processedContent);
preview.innerHTML = htmlContent;
// Render math expressions in preview
renderMathExpressions(preview);
} else {
preview.innerHTML = '<p class="text-gray-500 italic">Preview will appear here...</p>';
}
};
// Update preview on input change
mdInput.addEventListener('input', updatePreview);
// Update preview when flavor changes
const flavorSelect = document.getElementById('markdown-flavor') as HTMLSelectElement;
if (flavorSelect) {
flavorSelect.addEventListener('change', updatePreview);
}
}
// File upload functionality
if (fileInput) {
fileInput.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && file.type === 'text/markdown' || file?.name.endsWith('.md')) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content && mdInput) {
mdInput.value = content;
// Trigger preview update
mdInput.dispatchEvent(new Event('input'));
}
};
reader.readAsText(file);
} else {
showAlert('Invalid File', 'Please select a valid Markdown (.md) file to upload.');
}
});
}
}