feat: Add VitePress docs, EPUB to PDF tool, Phosphor icons, and licensing updates
- Set up VitePress documentation site (docs:dev, docs:build, docs:preview) - Added Getting Started, Tools Reference, Contributing, and Commercial License pages - Created self-hosting guides for Docker, Vercel, Netlify, Cloudflare, AWS, Hostinger, Nginx, Apache - Updated README with documentation link, sponsors section, and docs contribution guide - Added EPUB to PDF converter using LibreOffice WASM - Migrated to Phosphor Icons for consistent iconography - Added donation ribbon banner on landing page - Removed 'Like My Work?' section (replaced by ribbon) - Updated licensing.html with delivery model, AGPL notice, invoicing, and no-refund policy - Added Commercial License documentation page - Updated translations table (Chinese added, marked non-English as In Progress) - Added sponsors.yml workflow for auto-generating sponsor avatars
This commit is contained in:
90
src/js/utils/csv-to-pdf.ts
Normal file
90
src/js/utils/csv-to-pdf.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
export interface CsvToPdfOptions {
|
||||
onProgress?: (percent: number, message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CSV file to PDF using jsPDF and autotable
|
||||
*/
|
||||
export async function convertCsvToPdf(
|
||||
file: File,
|
||||
options?: CsvToPdfOptions
|
||||
): Promise<Blob> {
|
||||
const { onProgress } = options || {};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
onProgress?.(10, 'Reading CSV file...');
|
||||
|
||||
Papa.parse(file, {
|
||||
complete: (results) => {
|
||||
try {
|
||||
onProgress?.(50, 'Generating PDF...');
|
||||
|
||||
const data = results.data as string[][];
|
||||
|
||||
// Filter out empty rows
|
||||
const filteredData = data.filter(row =>
|
||||
row.some(cell => cell && cell.trim() !== '')
|
||||
);
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
reject(new Error('CSV file is empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create PDF document
|
||||
const doc = new jsPDF({
|
||||
orientation: 'landscape', // Better for wide tables
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
// Extract headers (first row) and data
|
||||
const headers = filteredData[0];
|
||||
const rows = filteredData.slice(1);
|
||||
|
||||
onProgress?.(70, 'Creating table...');
|
||||
|
||||
// Generate table
|
||||
autoTable(doc, {
|
||||
head: [headers],
|
||||
body: rows,
|
||||
startY: 20,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
overflow: 'linebreak',
|
||||
cellWidth: 'wrap',
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [41, 128, 185], // Nice blue header
|
||||
textColor: 255,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [245, 245, 245], // Light gray for alternate rows
|
||||
},
|
||||
margin: { top: 20, left: 10, right: 10 },
|
||||
theme: 'striped',
|
||||
});
|
||||
|
||||
onProgress?.(90, 'Finalizing PDF...');
|
||||
|
||||
// Get PDF as blob
|
||||
const pdfBlob = doc.output('blob');
|
||||
|
||||
onProgress?.(100, 'Complete!');
|
||||
resolve(pdfBlob);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
reject(new Error(`Failed to parse CSV: ${error.message}`));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
210
src/js/utils/ghostscript-loader.ts
Normal file
210
src/js/utils/ghostscript-loader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* PDF/A Conversion using Ghostscript WASM
|
||||
*
|
||||
* Converts PDFs to PDF/A-1b, PDF/A-2b, or PDF/A-3b format.
|
||||
*/
|
||||
|
||||
import loadWASM from '@bentopdf/gs-wasm';
|
||||
|
||||
interface GhostscriptModule {
|
||||
FS: {
|
||||
writeFile(path: string, data: Uint8Array | string): void;
|
||||
readFile(path: string, opts?: { encoding?: string }): Uint8Array;
|
||||
unlink(path: string): void;
|
||||
stat(path: string): { size: number };
|
||||
};
|
||||
callMain(args: string[]): number;
|
||||
}
|
||||
|
||||
export type PdfALevel = 'PDF/A-1b' | 'PDF/A-2b' | 'PDF/A-3b';
|
||||
|
||||
let cachedGsModule: GhostscriptModule | null = null;
|
||||
|
||||
export function setCachedGsModule(module: GhostscriptModule): void {
|
||||
cachedGsModule = module;
|
||||
}
|
||||
|
||||
export function getCachedGsModule(): GhostscriptModule | null {
|
||||
return cachedGsModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode binary data to Adobe ASCII85 (Base85) format.
|
||||
* This matches Python's base64.a85encode(data, adobe=True)
|
||||
*/
|
||||
function encodeBase85(data: Uint8Array): string {
|
||||
const POW85 = [85 * 85 * 85 * 85, 85 * 85 * 85, 85 * 85, 85, 1];
|
||||
let result = '';
|
||||
|
||||
// Process 4 bytes at a time
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Get 4 bytes (pad with zeros if needed)
|
||||
let value = 0;
|
||||
const remaining = Math.min(4, data.length - i);
|
||||
for (let j = 0; j < 4; j++) {
|
||||
value = value * 256 + (j < remaining ? data[i + j] : 0);
|
||||
}
|
||||
|
||||
// Special case: all zeros become 'z'
|
||||
if (value === 0 && remaining === 4) {
|
||||
result += 'z';
|
||||
} else {
|
||||
// Encode to 5 ASCII85 characters
|
||||
const encoded: string[] = [];
|
||||
for (let j = 0; j < 5; j++) {
|
||||
encoded.push(String.fromCharCode((value / POW85[j]) % 85 + 33));
|
||||
}
|
||||
// For partial blocks, only output needed characters
|
||||
result += encoded.slice(0, remaining + 1).join('');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function convertToPdfA(
|
||||
pdfData: Uint8Array,
|
||||
level: PdfALevel = 'PDF/A-2b',
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Uint8Array> {
|
||||
onProgress?.('Loading Ghostscript...');
|
||||
|
||||
let gs: GhostscriptModule;
|
||||
|
||||
if (cachedGsModule) {
|
||||
gs = cachedGsModule;
|
||||
} else {
|
||||
gs = await loadWASM({
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return import.meta.env.BASE_URL + 'ghostscript-wasm/gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: (text: string) => console.log('[GS]', text),
|
||||
printErr: (text: string) => console.error('[GS Error]', text),
|
||||
}) as GhostscriptModule;
|
||||
cachedGsModule = gs;
|
||||
}
|
||||
|
||||
|
||||
const pdfaMap: Record<PdfALevel, string> = {
|
||||
'PDF/A-1b': '1',
|
||||
'PDF/A-2b': '2',
|
||||
'PDF/A-3b': '3',
|
||||
};
|
||||
|
||||
const inputPath = '/tmp/input.pdf';
|
||||
const outputPath = '/tmp/output.pdf';
|
||||
|
||||
gs.FS.writeFile(inputPath, pdfData);
|
||||
console.log('[Ghostscript] Input file size:', pdfData.length);
|
||||
|
||||
onProgress?.(`Converting to ${level}...`);
|
||||
const pdfaDefPath = '/tmp/pdfa.ps';
|
||||
|
||||
try {
|
||||
const response = await fetch(import.meta.env.BASE_URL + 'ghostscript-wasm/sRGB_v4_ICC_preference.icc');
|
||||
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
|
||||
const iccData = new Uint8Array(await response.arrayBuffer());
|
||||
console.log('[Ghostscript] sRGB v4 ICC profile loaded:', iccData.length, 'bytes');
|
||||
|
||||
// Write ICC profile as a binary file to FS (eliminates encoding issues)
|
||||
const iccPath = '/tmp/pdfa.icc';
|
||||
gs.FS.writeFile(iccPath, iccData);
|
||||
console.log('[Ghostscript] sRGB ICC profile written to FS:', iccPath);
|
||||
|
||||
// Generate PostScript with reference to ICC file (Standard OCRmyPDF/GS approach)
|
||||
const pdfaPS = `%!
|
||||
% Define OutputIntent subtype based on PDF/A level
|
||||
/OutputIntentSubtype ${level === 'PDF/A-1b' ? '/GTS_PDFA1' : '/GTS_PDFA'} def
|
||||
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
[{icc_PDFA} <</N 3 >> /PUT pdfmark
|
||||
[{icc_PDFA} (${iccPath}) (r) file /PUT pdfmark
|
||||
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent
|
||||
/S OutputIntentSubtype
|
||||
/DestOutputProfile {icc_PDFA}
|
||||
/OutputConditionIdentifier (sRGB)
|
||||
>> /PUT pdfmark
|
||||
|
||||
[{Catalog} <<
|
||||
/OutputIntents [ {OutputIntent_PDFA} ]
|
||||
>> /PUT pdfmark
|
||||
`;
|
||||
gs.FS.writeFile(pdfaDefPath, pdfaPS);
|
||||
console.log('[Ghostscript] PDFA PostScript created with embedded ICC profile');
|
||||
} catch (e) {
|
||||
console.error('[Ghostscript] Failed to create PDFA PostScript:', e);
|
||||
throw new Error('Conversion failed: could not create PDF/A definition');
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
`-dPDFA=${pdfaMap[level]}`,
|
||||
'-dPDFACompatibilityPolicy=1',
|
||||
`-dCompatibilityLevel=${level === 'PDF/A-1b' ? '1.4' : '1.7'}`,
|
||||
'-sColorConversionStrategy=RGB',
|
||||
'-dEmbedAllFonts=true',
|
||||
'-dSubsetFonts=true',
|
||||
'-dAutoRotatePages=/None',
|
||||
`-sOutputFile=${outputPath}`,
|
||||
pdfaDefPath,
|
||||
inputPath,
|
||||
];
|
||||
|
||||
console.log('[Ghostscript] Running PDF/A conversion...');
|
||||
|
||||
let exitCode: number;
|
||||
try {
|
||||
exitCode = gs.callMain(args);
|
||||
} catch (e) {
|
||||
console.error('[Ghostscript] Exception:', e);
|
||||
throw new Error(`Ghostscript threw an exception: ${e}`);
|
||||
}
|
||||
|
||||
console.log('[Ghostscript] Exit code:', exitCode);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
|
||||
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
// Read output
|
||||
let output: Uint8Array;
|
||||
try {
|
||||
const stat = gs.FS.stat(outputPath);
|
||||
console.log('[Ghostscript] Output file size:', stat.size);
|
||||
output = gs.FS.readFile(outputPath);
|
||||
} catch (e) {
|
||||
console.error('[Ghostscript] Failed to read output:', e);
|
||||
throw new Error('Ghostscript did not produce output file');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function convertFileToPdfA(
|
||||
file: File,
|
||||
level: PdfALevel = 'PDF/A-2b',
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfData = new Uint8Array(arrayBuffer);
|
||||
const result = await convertToPdfA(pdfData, level, onProgress);
|
||||
// Copy to regular ArrayBuffer to avoid SharedArrayBuffer issues
|
||||
const copy = new Uint8Array(result.length);
|
||||
copy.set(result);
|
||||
return new Blob([copy], { type: 'application/pdf' });
|
||||
}
|
||||
158
src/js/utils/libreoffice-loader.ts
Normal file
158
src/js/utils/libreoffice-loader.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* LibreOffice WASM Converter Wrapper
|
||||
*
|
||||
* Uses @matbee/libreoffice-converter package for document conversion.
|
||||
* Handles progress tracking and provides simpler API.
|
||||
*/
|
||||
|
||||
import { WorkerBrowserConverter } from '@matbee/libreoffice-converter/browser';
|
||||
|
||||
export interface LoadProgress {
|
||||
phase: 'loading' | 'initializing' | 'converting' | 'complete' | 'ready';
|
||||
percent: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (progress: LoadProgress) => void;
|
||||
|
||||
// Singleton for converter instance
|
||||
let converterInstance: LibreOfficeConverter | null = null;
|
||||
|
||||
export class LibreOfficeConverter {
|
||||
private converter: WorkerBrowserConverter | null = null;
|
||||
private initialized = false;
|
||||
private initializing = false;
|
||||
private basePath: string;
|
||||
|
||||
constructor(basePath: string = import.meta.env.BASE_URL + 'libreoffice-wasm/') {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
async initialize(onProgress?: ProgressCallback): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (this.initializing) {
|
||||
while (this.initializing) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.initializing = true;
|
||||
let progressCallback = onProgress; // Store original callback
|
||||
|
||||
try {
|
||||
progressCallback?.({ phase: 'loading', percent: 0, message: 'Loading conversion engine...' });
|
||||
|
||||
this.converter = new WorkerBrowserConverter({
|
||||
sofficeJs: `${this.basePath}soffice.js`,
|
||||
sofficeWasm: `${this.basePath}soffice.wasm.gz`,
|
||||
sofficeData: `${this.basePath}soffice.data.gz`,
|
||||
sofficeWorkerJs: `${this.basePath}soffice.worker.js`,
|
||||
browserWorkerJs: `${this.basePath}browser.worker.global.js`,
|
||||
verbose: false,
|
||||
onProgress: (info: { phase: string; percent: number; message: string }) => {
|
||||
if (progressCallback && !this.initialized) {
|
||||
const simplifiedMessage = `Loading conversion engine (${Math.round(info.percent)}%)...`;
|
||||
progressCallback({
|
||||
phase: info.phase as LoadProgress['phase'],
|
||||
percent: info.percent,
|
||||
message: simplifiedMessage
|
||||
});
|
||||
}
|
||||
},
|
||||
onReady: () => {
|
||||
console.log('[LibreOffice] Ready!');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[LibreOffice] Error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
await this.converter.initialize();
|
||||
this.initialized = true;
|
||||
|
||||
// Call completion message
|
||||
progressCallback?.({ phase: 'ready', percent: 100, message: 'Conversion engine ready!' });
|
||||
|
||||
// Null out the callback to prevent any late-firing progress updates
|
||||
progressCallback = undefined;
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.initialized && this.converter !== null;
|
||||
}
|
||||
|
||||
async convertToPdf(file: File): Promise<Blob> {
|
||||
if (!this.converter) {
|
||||
throw new Error('Converter not initialized');
|
||||
}
|
||||
|
||||
console.log(`[LibreOffice] Converting ${file.name} to PDF...`);
|
||||
console.log(`[LibreOffice] File type: ${file.type}, Size: ${file.size} bytes`);
|
||||
|
||||
try {
|
||||
console.log(`[LibreOffice] Reading file as ArrayBuffer...`);
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
console.log(`[LibreOffice] File loaded, ${uint8Array.length} bytes`);
|
||||
|
||||
console.log(`[LibreOffice] Calling converter.convert() with buffer...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Detect input format - critical for CSV to apply import filters
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
console.log(`[LibreOffice] Detected format from extension: ${ext}`);
|
||||
|
||||
const result = await this.converter.convert(uint8Array, {
|
||||
outputFormat: 'pdf',
|
||||
inputFormat: ext as any, // Explicitly specify format for CSV import filters
|
||||
}, file.name);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[LibreOffice] Conversion complete! Duration: ${duration}ms, Size: ${result.data.length} bytes`);
|
||||
|
||||
// Create a copy to avoid SharedArrayBuffer type issues
|
||||
const data = new Uint8Array(result.data);
|
||||
return new Blob([data], { type: result.mimeType });
|
||||
} catch (error) {
|
||||
console.error(`[LibreOffice] Conversion FAILED for ${file.name}:`, error);
|
||||
console.error(`[LibreOffice] Error details:`, {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async wordToPdf(file: File): Promise<Blob> {
|
||||
return this.convertToPdf(file);
|
||||
}
|
||||
|
||||
async pptToPdf(file: File): Promise<Blob> {
|
||||
return this.convertToPdf(file);
|
||||
}
|
||||
|
||||
async excelToPdf(file: File): Promise<Blob> {
|
||||
return this.convertToPdf(file);
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
if (this.converter) {
|
||||
await this.converter.destroy();
|
||||
}
|
||||
this.converter = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLibreOfficeConverter(basePath?: string): LibreOfficeConverter {
|
||||
if (!converterInstance) {
|
||||
converterInstance = new LibreOfficeConverter(basePath);
|
||||
}
|
||||
return converterInstance;
|
||||
}
|
||||
797
src/js/utils/markdown-editor.ts
Normal file
797
src/js/utils/markdown-editor.ts
Normal file
@@ -0,0 +1,797 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import markdownLang from 'highlight.js/lib/languages/markdown';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import csharp from 'highlight.js/lib/languages/csharp';
|
||||
import cpp from 'highlight.js/lib/languages/cpp';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import rust from 'highlight.js/lib/languages/rust';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import sub from 'markdown-it-sub';
|
||||
import sup from 'markdown-it-sup';
|
||||
import footnote from 'markdown-it-footnote';
|
||||
import deflist from 'markdown-it-deflist';
|
||||
import abbr from 'markdown-it-abbr';
|
||||
import { full as emoji } from 'markdown-it-emoji';
|
||||
import ins from 'markdown-it-ins';
|
||||
import mark from 'markdown-it-mark';
|
||||
import taskLists from 'markdown-it-task-lists';
|
||||
import anchor from 'markdown-it-anchor';
|
||||
import tocDoneRight from 'markdown-it-toc-done-right';
|
||||
import { applyTranslations } from '../i18n/i18n';
|
||||
|
||||
|
||||
|
||||
// Register highlight.js languages
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('js', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('ts', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('py', python);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('html', xml);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('sh', bash);
|
||||
hljs.registerLanguage('shell', bash);
|
||||
hljs.registerLanguage('markdown', markdownLang);
|
||||
hljs.registerLanguage('md', markdownLang);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('csharp', csharp);
|
||||
hljs.registerLanguage('cs', csharp);
|
||||
hljs.registerLanguage('cpp', cpp);
|
||||
hljs.registerLanguage('c', cpp);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('rust', rust);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('yml', yaml);
|
||||
|
||||
export interface MarkdownEditorOptions {
|
||||
/** Initial markdown content */
|
||||
initialContent?: string;
|
||||
/** Callback when user wants to go back */
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export interface MarkdownItOptions {
|
||||
/** Enable HTML tags in source */
|
||||
html: boolean;
|
||||
/** Convert '\n' in paragraphs into <br> */
|
||||
breaks: boolean;
|
||||
/** Autoconvert URL-like text to links */
|
||||
linkify: boolean;
|
||||
/** Enable some language-neutral replacement + quotes beautification */
|
||||
typographer: boolean;
|
||||
/** Highlight function for fenced code blocks */
|
||||
highlight?: (str: string, lang: string) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_MARKDOWN = `# Welcome to BentoPDF Markdown Editor
|
||||
|
||||
This is a **live preview** markdown editor with full plugin support.
|
||||
|
||||
\${toc}
|
||||
|
||||
## Basic Formatting
|
||||
|
||||
- **Bold** and *italic* text
|
||||
- ~~Strikethrough~~ text
|
||||
- [Links](https://bentopdf.com)
|
||||
- ==Highlighted text== using mark
|
||||
- ++Inserted text++ using ins
|
||||
- H~2~O for subscript
|
||||
- E=mc^2^ for superscript
|
||||
|
||||
## Task Lists
|
||||
|
||||
- [x] Completed task
|
||||
- [x] Another done item
|
||||
- [ ] Pending task
|
||||
- [ ] Future work
|
||||
|
||||
## Emoji Support :rocket:
|
||||
|
||||
Use emoji shortcodes: :smile: :heart: :thumbsup: :star: :fire:
|
||||
|
||||
## Code with Syntax Highlighting
|
||||
|
||||
\`\`\`javascript
|
||||
function greet(name) {
|
||||
console.log(\`Hello, \${name}!\`);
|
||||
return { message: 'Welcome!' };
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`python
|
||||
def fibonacci(n):
|
||||
if n <= 1:
|
||||
return n
|
||||
return fibonacci(n-1) + fibonacci(n-2)
|
||||
\`\`\`
|
||||
|
||||
## Tables
|
||||
|
||||
| Feature | Supported | Notes |
|
||||
|---------|:---------:|-------|
|
||||
| Headers | ✓ | Multiple levels |
|
||||
| Lists | ✓ | Ordered & unordered |
|
||||
| Code | ✓ | With highlighting |
|
||||
| Tables | ✓ | With alignment |
|
||||
| Emoji | ✓ | :white_check_mark: |
|
||||
|
||||
## Footnotes
|
||||
|
||||
Here's a sentence with a footnote[^1].
|
||||
|
||||
## Definition Lists
|
||||
|
||||
Term 1
|
||||
: Definition for term 1
|
||||
|
||||
Term 2
|
||||
: Definition for term 2
|
||||
: Another definition for term 2
|
||||
|
||||
## Abbreviations
|
||||
|
||||
The HTML specification is maintained by the W3C.
|
||||
|
||||
*[HTML]: Hyper Text Markup Language
|
||||
*[W3C]: World Wide Web Consortium
|
||||
|
||||
---
|
||||
|
||||
Start editing to see the magic happen!
|
||||
|
||||
[^1]: This is the footnote content.
|
||||
`;
|
||||
|
||||
export class MarkdownEditor {
|
||||
private container: HTMLElement;
|
||||
private md: MarkdownIt;
|
||||
private editor: HTMLTextAreaElement | null = null;
|
||||
private preview: HTMLElement | null = null;
|
||||
private onBack?: () => void;
|
||||
private syncScroll: boolean = false;
|
||||
private isSyncing: boolean = false;
|
||||
private mdOptions: MarkdownItOptions = {
|
||||
html: true,
|
||||
breaks: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
};
|
||||
|
||||
constructor(container: HTMLElement, options: MarkdownEditorOptions) {
|
||||
this.container = container;
|
||||
this.onBack = options.onBack;
|
||||
|
||||
this.md = this.createMarkdownIt();
|
||||
this.configureLinkRenderer();
|
||||
|
||||
this.render();
|
||||
|
||||
if (options.initialContent) {
|
||||
this.setContent(options.initialContent);
|
||||
} else {
|
||||
this.setContent(DEFAULT_MARKDOWN);
|
||||
}
|
||||
}
|
||||
|
||||
private configureLinkRenderer(): void {
|
||||
// Override link renderer to add target="_blank" and rel="noopener"
|
||||
const defaultRender = this.md.renderer.rules.link_open ||
|
||||
((tokens: any[], idx: number, options: any, _env: any, self: any) => self.renderToken(tokens, idx, options));
|
||||
|
||||
this.md.renderer.rules.link_open = (tokens: any[], idx: number, options: any, env: any, self: any) => {
|
||||
const token = tokens[idx];
|
||||
token.attrSet('target', '_blank');
|
||||
token.attrSet('rel', 'noopener noreferrer');
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
this.container.innerHTML = `
|
||||
<div class="md-editor light-mode">
|
||||
<div class="md-editor-wrapper">
|
||||
<div class="md-editor-header">
|
||||
<div class="md-editor-actions">
|
||||
<input type="file" accept=".md,.markdown,.txt" id="mdFileInput" style="display: none;" />
|
||||
<button class="md-editor-btn md-editor-btn-secondary" id="mdUpload">
|
||||
<i data-lucide="upload"></i>
|
||||
<span data-i18n="tools:markdownToPdf.btnUpload">Upload</span>
|
||||
</button>
|
||||
<div class="theme-toggle">
|
||||
<i data-lucide="moon" width="16" height="16"></i>
|
||||
<div class="theme-toggle-slider active" id="themeToggle"></div>
|
||||
<i data-lucide="sun" width="16" height="16"></i>
|
||||
</div>
|
||||
<button class="md-editor-btn md-editor-btn-secondary" id="mdSyncScroll" title="Toggle sync scroll">
|
||||
<i data-lucide="git-compare"></i>
|
||||
<span data-i18n="tools:markdownToPdf.btnSyncScroll">Sync Scroll</span>
|
||||
</button>
|
||||
<button class="md-editor-btn md-editor-btn-secondary" id="mdSettings">
|
||||
<i data-lucide="settings"></i>
|
||||
<span data-i18n="tools:markdownToPdf.btnSettings">Settings</span>
|
||||
</button>
|
||||
<button class="md-editor-btn md-editor-btn-primary" id="mdExport">
|
||||
<i data-lucide="download"></i>
|
||||
<span data-i18n="tools:markdownToPdf.btnExportPdf">Export PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md-editor-main">
|
||||
<div class="md-editor-pane">
|
||||
<div class="md-editor-pane-header">
|
||||
<span data-i18n="tools:markdownToPdf.paneMarkdown">Markdown</span>
|
||||
</div>
|
||||
<textarea class="md-editor-textarea" id="mdTextarea" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="md-editor-pane">
|
||||
<div class="md-editor-pane-header">
|
||||
<span data-i18n="tools:markdownToPdf.panePreview">Preview</span>
|
||||
</div>
|
||||
<div class="md-editor-preview" id="mdPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal (hidden by default) -->
|
||||
<div class="md-editor-modal-overlay" id="mdSettingsModal" style="display: none;">
|
||||
<div class="md-editor-modal">
|
||||
<div class="md-editor-modal-header">
|
||||
<h2 class="md-editor-modal-title" data-i18n="tools:markdownToPdf.settingsTitle">Markdown Settings</h2>
|
||||
<button class="md-editor-modal-close" id="mdCloseSettings">
|
||||
<i data-lucide="x" width="20" height="20"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-editor-settings-group">
|
||||
<h3 data-i18n="tools:markdownToPdf.settingsPreset">Preset</h3>
|
||||
<select id="mdPreset">
|
||||
<option value="default" selected data-i18n="tools:markdownToPdf.presetDefault">Default (GFM-like)</option>
|
||||
<option value="commonmark" data-i18n="tools:markdownToPdf.presetCommonmark">CommonMark (strict)</option>
|
||||
<option value="zero" data-i18n="tools:markdownToPdf.presetZero">Minimal (no features)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md-editor-settings-group">
|
||||
<h3 data-i18n="tools:markdownToPdf.settingsOptions">Markdown Options</h3>
|
||||
<label class="md-editor-checkbox">
|
||||
<input type="checkbox" id="mdOptHtml" ${this.mdOptions.html ? 'checked' : ''} />
|
||||
<span data-i18n="tools:markdownToPdf.optAllowHtml">Allow HTML tags</span>
|
||||
</label>
|
||||
<label class="md-editor-checkbox">
|
||||
<input type="checkbox" id="mdOptBreaks" ${this.mdOptions.breaks ? 'checked' : ''} />
|
||||
<span data-i18n="tools:markdownToPdf.optBreaks">Convert newlines to <br></span>
|
||||
</label>
|
||||
<label class="md-editor-checkbox">
|
||||
<input type="checkbox" id="mdOptLinkify" ${this.mdOptions.linkify ? 'checked' : ''} />
|
||||
<span data-i18n="tools:markdownToPdf.optLinkify">Auto-convert URLs to links</span>
|
||||
</label>
|
||||
<label class="md-editor-checkbox">
|
||||
<input type="checkbox" id="mdOptTypographer" ${this.mdOptions.typographer ? 'checked' : ''} />
|
||||
<span data-i18n="tools:markdownToPdf.optTypographer">Typographer (smart quotes, etc.)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.editor = document.getElementById('mdTextarea') as HTMLTextAreaElement;
|
||||
this.preview = document.getElementById('mdPreview') as HTMLElement;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.applyI18n();
|
||||
|
||||
// Initialize Lucide icons
|
||||
if (typeof (window as any).lucide !== 'undefined') {
|
||||
(window as any).lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Editor input
|
||||
this.editor?.addEventListener('input', () => {
|
||||
this.updatePreview();
|
||||
});
|
||||
|
||||
// Sync scroll
|
||||
const syncScrollBtn = document.getElementById('mdSyncScroll');
|
||||
syncScrollBtn?.addEventListener('click', () => {
|
||||
this.syncScroll = !this.syncScroll;
|
||||
syncScrollBtn.classList.toggle('md-editor-btn-primary');
|
||||
syncScrollBtn.classList.toggle('md-editor-btn-secondary');
|
||||
});
|
||||
|
||||
// Editor scroll sync
|
||||
this.editor?.addEventListener('scroll', () => {
|
||||
if (this.syncScroll && !this.isSyncing && this.editor && this.preview) {
|
||||
this.isSyncing = true;
|
||||
const scrollPercentage = this.editor.scrollTop / (this.editor.scrollHeight - this.editor.clientHeight);
|
||||
this.preview.scrollTop = scrollPercentage * (this.preview.scrollHeight - this.preview.clientHeight);
|
||||
setTimeout(() => this.isSyncing = false, 10);
|
||||
}
|
||||
});
|
||||
|
||||
// Preview scroll sync (bidirectional)
|
||||
this.preview?.addEventListener('scroll', () => {
|
||||
if (this.syncScroll && !this.isSyncing && this.editor && this.preview) {
|
||||
this.isSyncing = true;
|
||||
const scrollPercentage = this.preview.scrollTop / (this.preview.scrollHeight - this.preview.clientHeight);
|
||||
this.editor.scrollTop = scrollPercentage * (this.editor.scrollHeight - this.editor.clientHeight);
|
||||
setTimeout(() => this.isSyncing = false, 10);
|
||||
}
|
||||
});
|
||||
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const editorContainer = document.querySelector('.md-editor');
|
||||
themeToggle?.addEventListener('click', () => {
|
||||
editorContainer?.classList.toggle('light-mode');
|
||||
themeToggle.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Settings modal open
|
||||
document.getElementById('mdSettings')?.addEventListener('click', () => {
|
||||
const modal = document.getElementById('mdSettingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
// Settings modal close
|
||||
document.getElementById('mdCloseSettings')?.addEventListener('click', () => {
|
||||
const modal = document.getElementById('mdSettingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('mdSettingsModal')?.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).classList.contains('md-editor-modal-overlay')) {
|
||||
const modal = document.getElementById('mdSettingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Settings checkboxes
|
||||
document.getElementById('mdOptHtml')?.addEventListener('change', (e) => {
|
||||
this.mdOptions.html = (e.target as HTMLInputElement).checked;
|
||||
this.updateMarkdownIt();
|
||||
});
|
||||
|
||||
document.getElementById('mdOptBreaks')?.addEventListener('change', (e) => {
|
||||
this.mdOptions.breaks = (e.target as HTMLInputElement).checked;
|
||||
this.updateMarkdownIt();
|
||||
});
|
||||
|
||||
document.getElementById('mdOptLinkify')?.addEventListener('change', (e) => {
|
||||
this.mdOptions.linkify = (e.target as HTMLInputElement).checked;
|
||||
this.updateMarkdownIt();
|
||||
});
|
||||
|
||||
document.getElementById('mdOptTypographer')?.addEventListener('change', (e) => {
|
||||
this.mdOptions.typographer = (e.target as HTMLInputElement).checked;
|
||||
this.updateMarkdownIt();
|
||||
});
|
||||
|
||||
// Preset selector
|
||||
document.getElementById('mdPreset')?.addEventListener('change', (e) => {
|
||||
const preset = (e.target as HTMLSelectElement).value;
|
||||
this.applyPreset(preset as 'default' | 'commonmark' | 'zero');
|
||||
});
|
||||
|
||||
// Upload button
|
||||
document.getElementById('mdUpload')?.addEventListener('click', () => {
|
||||
document.getElementById('mdFileInput')?.click();
|
||||
});
|
||||
|
||||
// File input change
|
||||
document.getElementById('mdFileInput')?.addEventListener('change', (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
this.loadFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Export PDF
|
||||
document.getElementById('mdExport')?.addEventListener('click', () => {
|
||||
this.exportPdf();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
this.editor?.addEventListener('keydown', (e) => {
|
||||
// Ctrl/Cmd + S to export
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
this.exportPdf();
|
||||
}
|
||||
// Tab key for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.editor!.selectionStart;
|
||||
const end = this.editor!.selectionEnd;
|
||||
const value = this.editor!.value;
|
||||
this.editor!.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
this.editor!.selectionStart = this.editor!.selectionEnd = start + 2;
|
||||
this.updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private currentPreset: 'default' | 'commonmark' | 'zero' = 'default';
|
||||
|
||||
private applyPreset(preset: 'default' | 'commonmark' | 'zero'): void {
|
||||
this.currentPreset = preset;
|
||||
|
||||
// Update options based on preset
|
||||
if (preset === 'commonmark') {
|
||||
this.mdOptions = { html: false, breaks: false, linkify: false, typographer: false };
|
||||
} else if (preset === 'zero') {
|
||||
this.mdOptions = { html: false, breaks: false, linkify: false, typographer: false };
|
||||
} else {
|
||||
this.mdOptions = { html: true, breaks: false, linkify: true, typographer: true };
|
||||
}
|
||||
|
||||
// Update UI checkboxes
|
||||
(document.getElementById('mdOptHtml') as HTMLInputElement).checked = this.mdOptions.html;
|
||||
(document.getElementById('mdOptBreaks') as HTMLInputElement).checked = this.mdOptions.breaks;
|
||||
(document.getElementById('mdOptLinkify') as HTMLInputElement).checked = this.mdOptions.linkify;
|
||||
(document.getElementById('mdOptTypographer') as HTMLInputElement).checked = this.mdOptions.typographer;
|
||||
|
||||
this.updateMarkdownIt();
|
||||
}
|
||||
|
||||
private async loadFile(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
this.setContent(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private createMarkdownIt(): MarkdownIt {
|
||||
// Use preset if commonmark or zero
|
||||
let md: MarkdownIt;
|
||||
if (this.currentPreset === 'commonmark') {
|
||||
md = new MarkdownIt('commonmark');
|
||||
} else if (this.currentPreset === 'zero') {
|
||||
md = new MarkdownIt('zero');
|
||||
// Enable basic features for zero preset
|
||||
md.enable(['paragraph', 'newline', 'text']);
|
||||
} else {
|
||||
md = new MarkdownIt({
|
||||
...this.mdOptions,
|
||||
highlight: (str: string, lang: string) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
return ''; // Use external default escaping
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply plugins only for default preset (plugins may not work well with commonmark/zero)
|
||||
if (this.currentPreset === 'default') {
|
||||
md.use(sub) // Subscript: ~text~ -> <sub>text</sub>
|
||||
.use(sup) // Superscript: ^text^ -> <sup>text</sup>
|
||||
.use(footnote) // Footnotes: [^1] and [^1]: footnote text
|
||||
.use(deflist) // Definition lists
|
||||
.use(abbr) // Abbreviations: *[abbr]: full text
|
||||
.use(emoji) // Emoji: :smile: -> 😄
|
||||
.use(ins) // Inserted text: ++text++ -> <ins>text</ins>
|
||||
.use(mark) // Marked text: ==text== -> <mark>text</mark>
|
||||
.use(taskLists, { enabled: true, label: true, labelAfter: true }) // Task lists: - [x] done
|
||||
.use(anchor, { permalink: false }) // Header anchors
|
||||
.use(tocDoneRight); // Table of contents: ${toc}
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
private updateMarkdownIt(): void {
|
||||
this.md = this.createMarkdownIt();
|
||||
this.configureLinkRenderer();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
private updatePreview(): void {
|
||||
if (!this.editor || !this.preview) return;
|
||||
|
||||
const markdown = this.editor.value;
|
||||
const html = this.md.render(markdown);
|
||||
this.preview.innerHTML = html;
|
||||
}
|
||||
|
||||
public setContent(content: string): void {
|
||||
if (this.editor) {
|
||||
this.editor.value = content;
|
||||
this.updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
public getContent(): string {
|
||||
return this.editor?.value || '';
|
||||
}
|
||||
|
||||
public getHtml(): string {
|
||||
return this.md.render(this.getContent());
|
||||
}
|
||||
|
||||
private exportPdf(): void {
|
||||
// Use browser's native print functionality
|
||||
window.print();
|
||||
}
|
||||
|
||||
private getStyledHtml(): string {
|
||||
const content = this.getHtml();
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
h4 { font-size: 1em; }
|
||||
p { margin: 1em 0; }
|
||||
a { color: #0366d6; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: #f6f8fa;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
background: #f6f8fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 4px solid #dfe2e5;
|
||||
}
|
||||
ul, ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
li { margin: 0.25em 0; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f6f8fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:nth-child(even) { background: #f6f8fa; }
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 2em 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
/* Syntax highlighting - GitHub style */
|
||||
.hljs {
|
||||
color: #24292e;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #d73a49;
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag .hljs-attr {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-doctag {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-selector-id {
|
||||
color: #6f42c1;
|
||||
font-weight: bold;
|
||||
}
|
||||
.hljs-type,
|
||||
.hljs-class .hljs-title {
|
||||
color: #6f42c1;
|
||||
}
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-attribute {
|
||||
color: #22863a;
|
||||
}
|
||||
.hljs-regexp,
|
||||
.hljs-link {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #e36209;
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-meta {
|
||||
color: #6a737d;
|
||||
font-weight: bold;
|
||||
}
|
||||
.hljs-deletion {
|
||||
color: #b31d28;
|
||||
background-color: #ffeef0;
|
||||
}
|
||||
.hljs-addition {
|
||||
color: #22863a;
|
||||
background-color: #f0fff4;
|
||||
}
|
||||
/* Plugin styles */
|
||||
mark {
|
||||
background-color: #fff3cd;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
ins {
|
||||
text-decoration: none;
|
||||
background-color: #d4edda;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.task-list-item {
|
||||
list-style-type: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
.task-list-item input[type="checkbox"] {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
.footnotes {
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.footnotes-sep {
|
||||
display: none;
|
||||
}
|
||||
.footnote-ref {
|
||||
font-size: 0.75em;
|
||||
vertical-align: super;
|
||||
}
|
||||
.footnote-backref {
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
dl {
|
||||
margin: 1em 0;
|
||||
}
|
||||
dt {
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
}
|
||||
dd {
|
||||
margin-left: 2em;
|
||||
margin-top: 0.25em;
|
||||
color: #6a737d;
|
||||
}
|
||||
abbr {
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
}
|
||||
.table-of-contents {
|
||||
background: #f6f8fa;
|
||||
padding: 1em 1.5em;
|
||||
border-radius: 6px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.table-of-contents ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.table-of-contents li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private applyI18n(): void {
|
||||
// Apply translations to elements within this component
|
||||
applyTranslations();
|
||||
|
||||
// Special handling for select options (data-i18n on options doesn't work with applyTranslations)
|
||||
const presetSelect = document.getElementById('mdPreset') as HTMLSelectElement;
|
||||
if (presetSelect) {
|
||||
const options = presetSelect.querySelectorAll('option[data-i18n]');
|
||||
options.forEach((option) => {
|
||||
const key = option.getAttribute('data-i18n');
|
||||
if (key) {
|
||||
// Use i18next directly for option text
|
||||
const translated = (window as any).i18next?.t(key);
|
||||
if (translated && translated !== key) {
|
||||
option.textContent = translated;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
129
src/js/utils/wasm-preloader.ts
Normal file
129
src/js/utils/wasm-preloader.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getLibreOfficeConverter } from './libreoffice-loader.js';
|
||||
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
|
||||
import loadGsWASM from '@bentopdf/gs-wasm';
|
||||
import { setCachedGsModule } from './ghostscript-loader.js';
|
||||
|
||||
export enum PreloadStatus {
|
||||
IDLE = 'idle',
|
||||
LOADING = 'loading',
|
||||
READY = 'ready',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
interface PreloadState {
|
||||
libreoffice: PreloadStatus;
|
||||
pymupdf: PreloadStatus;
|
||||
ghostscript: PreloadStatus;
|
||||
}
|
||||
|
||||
const preloadState: PreloadState = {
|
||||
libreoffice: PreloadStatus.IDLE,
|
||||
pymupdf: PreloadStatus.IDLE,
|
||||
ghostscript: PreloadStatus.IDLE
|
||||
};
|
||||
|
||||
let pymupdfInstance: PyMuPDF | null = null;
|
||||
|
||||
export function getPreloadStatus(): Readonly<PreloadState> {
|
||||
return { ...preloadState };
|
||||
}
|
||||
|
||||
export function getPymupdfInstance(): PyMuPDF | null {
|
||||
return pymupdfInstance;
|
||||
}
|
||||
|
||||
async function preloadLibreOffice(): Promise<void> {
|
||||
if (preloadState.libreoffice !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.libreoffice = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting LibreOffice WASM preload...');
|
||||
|
||||
try {
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
preloadState.libreoffice = PreloadStatus.READY;
|
||||
console.log('[Preloader] LibreOffice WASM ready');
|
||||
} catch (e) {
|
||||
preloadState.libreoffice = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] LibreOffice preload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadPyMuPDF(): Promise<void> {
|
||||
if (preloadState.pymupdf !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.pymupdf = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting PyMuPDF preload...');
|
||||
|
||||
try {
|
||||
pymupdfInstance = new PyMuPDF(import.meta.env.BASE_URL + 'pymupdf-wasm/');
|
||||
await pymupdfInstance.load();
|
||||
preloadState.pymupdf = PreloadStatus.READY;
|
||||
console.log('[Preloader] PyMuPDF ready');
|
||||
} catch (e) {
|
||||
preloadState.pymupdf = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] PyMuPDF preload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadGhostscript(): Promise<void> {
|
||||
if (preloadState.ghostscript !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.ghostscript = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting Ghostscript WASM preload...');
|
||||
|
||||
try {
|
||||
const gsModule = await loadGsWASM({
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return import.meta.env.BASE_URL + 'ghostscript-wasm/gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: () => { },
|
||||
printErr: () => { },
|
||||
});
|
||||
setCachedGsModule(gsModule as any);
|
||||
preloadState.ghostscript = PreloadStatus.READY;
|
||||
console.log('[Preloader] Ghostscript WASM ready');
|
||||
} catch (e) {
|
||||
preloadState.ghostscript = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] Ghostscript preload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIdleTask(task: () => Promise<void>): void {
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => task(), { timeout: 5000 });
|
||||
} else {
|
||||
setTimeout(() => task(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackgroundPreload(): void {
|
||||
console.log('[Preloader] Scheduling background WASM preloads...');
|
||||
|
||||
const libreOfficePages = [
|
||||
'word-to-pdf', 'excel-to-pdf', 'ppt-to-pdf', 'powerpoint-to-pdf',
|
||||
'docx-to-pdf', 'xlsx-to-pdf', 'pptx-to-pdf', 'csv-to-pdf',
|
||||
'rtf-to-pdf', 'odt-to-pdf', 'ods-to-pdf', 'odp-to-pdf'
|
||||
];
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const isLibreOfficePage = libreOfficePages.some(page => currentPath.includes(page));
|
||||
|
||||
if (isLibreOfficePage) {
|
||||
console.log('[Preloader] Skipping preloads on LibreOffice page to save memory');
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleIdleTask(async () => {
|
||||
console.log('[Preloader] Starting sequential WASM preloads...');
|
||||
|
||||
await preloadPyMuPDF();
|
||||
await preloadGhostscript();
|
||||
|
||||
console.log('[Preloader] Sequential preloads complete (LibreOffice skipped - loaded on demand)');
|
||||
});
|
||||
}
|
||||
|
||||
196
src/js/utils/xml-to-pdf.ts
Normal file
196
src/js/utils/xml-to-pdf.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
export interface XmlToPdfOptions {
|
||||
onProgress?: (percent: number, message: string) => void;
|
||||
}
|
||||
|
||||
interface jsPDFWithAutoTable extends jsPDF {
|
||||
lastAutoTable?: { finalY: number };
|
||||
}
|
||||
|
||||
export async function convertXmlToPdf(
|
||||
file: File,
|
||||
options?: XmlToPdfOptions
|
||||
): Promise<Blob> {
|
||||
const { onProgress } = options || {};
|
||||
|
||||
onProgress?.(10, 'Reading XML file...');
|
||||
const xmlText = await file.text();
|
||||
|
||||
onProgress?.(30, 'Parsing XML structure...');
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||||
|
||||
const parseError = xmlDoc.querySelector('parsererror');
|
||||
if (parseError) {
|
||||
throw new Error('Invalid XML: ' + parseError.textContent);
|
||||
}
|
||||
|
||||
onProgress?.(50, 'Analyzing data structure...');
|
||||
|
||||
const doc: jsPDFWithAutoTable = new jsPDF({
|
||||
orientation: 'landscape',
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
let yPosition = 20;
|
||||
|
||||
const root = xmlDoc.documentElement;
|
||||
const rootName = formatTitle(root.tagName);
|
||||
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(rootName, pageWidth / 2, yPosition, { align: 'center' });
|
||||
yPosition += 15;
|
||||
|
||||
onProgress?.(60, 'Generating formatted content...');
|
||||
|
||||
const children = Array.from(root.children);
|
||||
|
||||
if (children.length > 0) {
|
||||
const groups = groupByTagName(children);
|
||||
|
||||
for (const [groupName, elements] of Object.entries(groups)) {
|
||||
const { headers, rows } = extractTableData(elements);
|
||||
|
||||
if (headers.length > 0 && rows.length > 0) {
|
||||
if (Object.keys(groups).length > 1) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(formatTitle(groupName), 14, yPosition);
|
||||
yPosition += 8;
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
head: [headers.map(h => formatTitle(h))],
|
||||
body: rows,
|
||||
startY: yPosition,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 4,
|
||||
overflow: 'linebreak',
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [79, 70, 229],
|
||||
textColor: 255,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [243, 244, 246],
|
||||
},
|
||||
margin: { top: 20, left: 14, right: 14 },
|
||||
theme: 'striped',
|
||||
didDrawPage: (data) => {
|
||||
yPosition = (data.cursor?.y || yPosition) + 10;
|
||||
}
|
||||
});
|
||||
|
||||
yPosition = (doc.lastAutoTable?.finalY || yPosition) + 15;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const kvPairs = extractKeyValuePairs(root);
|
||||
if (kvPairs.length > 0) {
|
||||
autoTable(doc, {
|
||||
head: [['Property', 'Value']],
|
||||
body: kvPairs,
|
||||
startY: yPosition,
|
||||
styles: {
|
||||
fontSize: 10,
|
||||
cellPadding: 5,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [79, 70, 229],
|
||||
textColor: 255,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', cellWidth: 60 },
|
||||
1: { cellWidth: 'auto' },
|
||||
},
|
||||
margin: { left: 14, right: 14 },
|
||||
theme: 'striped',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(90, 'Finalizing PDF...');
|
||||
|
||||
const pdfBlob = doc.output('blob');
|
||||
|
||||
onProgress?.(100, 'Complete!');
|
||||
return pdfBlob;
|
||||
}
|
||||
|
||||
|
||||
function groupByTagName(elements: Element[]): Record<string, Element[]> {
|
||||
const groups: Record<string, Element[]> = {};
|
||||
|
||||
for (const element of elements) {
|
||||
const tagName = element.tagName;
|
||||
if (!groups[tagName]) {
|
||||
groups[tagName] = [];
|
||||
}
|
||||
groups[tagName].push(element);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function extractTableData(elements: Element[]): { headers: string[], rows: string[][] } {
|
||||
if (elements.length === 0) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
|
||||
const headerSet = new Set<string>();
|
||||
for (const element of elements) {
|
||||
for (const child of Array.from(element.children)) {
|
||||
headerSet.add(child.tagName);
|
||||
}
|
||||
}
|
||||
const headers = Array.from(headerSet);
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (const element of elements) {
|
||||
const row: string[] = [];
|
||||
for (const header of headers) {
|
||||
const child = element.querySelector(header);
|
||||
row.push(child?.textContent?.trim() || '');
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
|
||||
function extractKeyValuePairs(element: Element): string[][] {
|
||||
const pairs: string[][] = [];
|
||||
|
||||
for (const child of Array.from(element.children)) {
|
||||
const key = child.tagName;
|
||||
const value = child.textContent?.trim() || '';
|
||||
if (value) {
|
||||
pairs.push([formatTitle(key), value]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
pairs.push([formatTitle(attr.name), attr.value]);
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
|
||||
function formatTitle(tagName: string): string {
|
||||
return tagName
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user