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:
abdullahalam123
2025-12-27 19:30:31 +05:30
parent 0e888743d3
commit f30a084fce
189 changed files with 59872 additions and 3300 deletions

View 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}`));
},
});
});
}

View 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' });
}

View 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;
}

View 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 &lt;br&gt;</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 = '';
}
}

View 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
View 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(' ');
}