feat: translate all pages, UI messages and docs and fix SEO redirects and indexing

This commit is contained in:
alam00000 committed 2026-10-25 12:55:21 +05:30
1 parent 5e019de5ae
commit 4edb1718cc
3344 files changed
+415643 -4759

No files matched your search

+2 -2
View File
@@ -60,11 +60,11 @@ export function generateComicInfoXml(params: ComicMetadata): string {
lines.push(` <PageCount>${params.pageCount}</PageCount>`);
if (params.blackAndWhite) {
lines.push(' <BlackAndWhite>Yes</BlackAndWhite>');
lines.push(` <BlackAndWhite>Yes</BlackAndWhite>`);
}
if (params.manga) {
lines.push(' <Manga>YesAndRightToLeft</Manga>');
lines.push(` <Manga>YesAndRightToLeft</Manga>`);
}
lines.push('</ComicInfo>');
+16 -5
View File
@@ -11,6 +11,8 @@ import type {
} from 'hyper-compress-wasm';
import type { HyperCompressRequest, HyperCompressResponse } from '@/types';
import { trText } from '../i18n/i18n.js';
export const COMPRESS_LEVELS: readonly CompressLevel[] = [
'low',
'medium',
@@ -127,7 +129,7 @@ function getHyperWorker(): Worker {
worker,
new HyperError(
'engine_error',
e.message || 'The compression engine stopped unexpectedly.'
e.message || trText('The compression engine stopped unexpectedly.')
)
);
};
@@ -136,7 +138,7 @@ function getHyperWorker(): Worker {
worker,
new HyperError(
'engine_error',
'The compression engine returned an unreadable result.'
trText('The compression engine returned an unreadable result.')
)
);
};
@@ -161,14 +163,20 @@ export async function compressWithHyper(
options: HyperWasmOptions
): Promise<HyperWasmResult> {
if (!hasPdfHeader(pdf)) {
throw new HyperError('engine_error', 'This file is not a valid PDF.');
throw new HyperError(
'engine_error',
trText('This file is not a valid PDF.')
);
}
const message = await postToHyper(
(id) => ({ id, type: 'compress', bytes: pdf, options }),
pdf
);
if (message.type !== 'result') {
throw new HyperError('engine_error', 'Unexpected reply from the engine.');
throw new HyperError(
'engine_error',
trText('Unexpected reply from the engine.')
);
}
return message.result;
}
@@ -182,7 +190,10 @@ export async function verifyHyperPassword(
pdf
);
if (message.type !== 'verified') {
throw new HyperError('engine_error', 'Unexpected reply from the engine.');
throw new HyperError(
'engine_error',
trText('Unexpected reply from the engine.')
);
}
return message.valid;
}
+5 -1
View File
@@ -1,6 +1,8 @@
import { WasmProvider } from './wasm-provider';
import type { WindowWithCoherentPdf, CpdfInstance } from '@/types';
import { trText } from '../i18n/i18n.js';
let cpdfLoaded = false;
let cpdfLoadPromise: Promise<void> | null = null;
@@ -27,7 +29,9 @@ export async function isCpdfLoaded(): Promise<void> {
const cpdfUrl = getCpdfUrl();
if (!cpdfUrl) {
throw new Error(
'CoherentPDF is not configured. Please configure it in WASM Settings.'
trText(
'CoherentPDF is not configured. Please configure it in WASM Settings.'
)
);
}
+71 -65
View File
@@ -2,89 +2,95 @@ import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import Papa from 'papaparse';
import { trText } from '../i18n/i18n.js';
export interface CsvToPdfOptions {
onProgress?: (percent: number, message: string) => void;
onProgress?: (percent: number, message: string) => void;
}
/**
* Convert a CSV file to PDF using jsPDF and autotable
*/
export async function convertCsvToPdf(
file: File,
options?: CsvToPdfOptions
file: File,
options?: CsvToPdfOptions
): Promise<Blob> {
const { onProgress } = options || {};
const { onProgress } = options || {};
return new Promise((resolve, reject) => {
onProgress?.(10, 'Reading CSV file...');
return new Promise((resolve, reject) => {
onProgress?.(10, trText('Reading CSV file...'));
Papa.parse(file, {
complete: (results) => {
try {
onProgress?.(50, 'Generating PDF...');
Papa.parse(file, {
complete: (results) => {
try {
onProgress?.(50, trText('Generating PDF...'));
const data = results.data as string[][];
const data = results.data as string[][];
// Filter out empty rows
const filteredData = data.filter(row =>
row.some(cell => cell && cell.trim() !== '')
);
// 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;
}
if (filteredData.length === 0) {
reject(new Error(trText('CSV file is empty')));
return;
}
// Create PDF document
const doc = new jsPDF({
orientation: 'landscape', // Better for wide tables
unit: 'mm',
format: 'a4'
});
// 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);
// Extract headers (first row) and data
const headers = filteredData[0];
const rows = filteredData.slice(1);
onProgress?.(70, 'Creating table...');
onProgress?.(70, trText('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);
}
// Generate table
autoTable(doc, {
head: [headers],
body: rows,
startY: 20,
styles: {
fontSize: 9,
cellPadding: 3,
overflow: 'linebreak',
cellWidth: 'wrap',
},
error: (error) => {
reject(new Error(`Failed to parse CSV: ${error.message}`));
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, trText('Finalizing PDF...'));
// Get PDF as blob
const pdfBlob = doc.output('blob');
onProgress?.(100, trText('Complete!'));
resolve(pdfBlob);
} catch (error) {
reject(error);
}
},
error: (error) => {
reject(
new Error(
trText('Failed to parse CSV: {{error}}', { error: error.message })
)
);
},
});
});
}
+8 -2
View File
@@ -5,6 +5,8 @@ import {
} from '../config/font-mappings.js';
import { needsFontEmbedding } from './freetext-script.js';
import { trText } from '../i18n/i18n.js';
const fontCache: Map<string, ArrayBuffer> = new Map();
const DB_NAME = 'bentopdf-fonts';
@@ -112,7 +114,11 @@ export async function getFontForLanguage(lang: string): Promise<ArrayBuffer> {
const fontResponse = await fetch(fontUrl);
if (!fontResponse.ok) {
throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
throw new Error(
trText('Failed to fetch font file: {{statusText}}', {
statusText: fontResponse.statusText,
})
);
}
const fontBuffer = await fontResponse.arrayBuffer();
@@ -167,7 +173,7 @@ export async function loadFallbackFonts(
load = getFontForLanguage(lang)
.then((buffer) => {
if (!fontCache.has(family)) {
throw new Error('Only a substitute font was available');
throw new Error(trText('Only a substitute font was available'));
}
into.set(family, new Uint8Array(buffer));
return true;
+2 -1
View File
@@ -1,8 +1,9 @@
// Full-width mode utility
// This script applies the full-width preference from localStorage to page uploaders
import { getStoredItem } from './safe-storage.js';
export function initFullWidthMode() {
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const savedFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (savedFullWidth) {
applyFullWidthMode(true);
+14 -4
View File
@@ -4,6 +4,8 @@ import type {
GhostscriptDynamicInstance,
} from '@/types';
import { trText } from '../i18n/i18n.js';
let cachedGS: GhostscriptInterface | null = null;
let loadPromise: Promise<GhostscriptInterface> | null = null;
@@ -25,7 +27,9 @@ export async function loadGhostscript(): Promise<GhostscriptInterface> {
const baseUrl = WasmProvider.getUrl('ghostscript');
if (!baseUrl) {
throw new Error(
'Ghostscript is not configured. Please configure it in Advanced Settings.'
trText(
'Ghostscript is not configured. Please configure it in Advanced Settings.'
)
);
}
@@ -52,7 +56,9 @@ export async function loadGhostscript(): Promise<GhostscriptInterface> {
cachedGS = instance as unknown as GhostscriptInterface;
} else {
throw new Error(
'Ghostscript wrapper did not expose expected interface. Expected loadGS() or GhostscriptWASM class.'
trText(
'Ghostscript wrapper did not expose expected interface. Expected loadGS() or GhostscriptWASM class.'
)
);
}
@@ -61,7 +67,10 @@ export async function loadGhostscript(): Promise<GhostscriptInterface> {
loadPromise = null;
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to load Ghostscript from ${normalizedUrl}: ${msg}`,
trText('Failed to load Ghostscript from {{normalizedUrl}}: {{msg}}', {
normalizedUrl,
msg,
}),
{ cause: error }
);
}
@@ -83,7 +92,8 @@ function loadScript(url: string): Promise<void> {
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
script.onerror = () =>
reject(new Error(trText('Failed to load script: {{url}}', { url })));
document.head.appendChild(script);
});
+54 -19
View File
@@ -8,6 +8,8 @@ import { getWasmBaseUrl, isWasmAvailable } from '../config/wasm-cdn-config.js';
import { PDFDict, PDFName, PDFArray } from 'pdf-lib';
import { loadPdfDocument } from './load-pdf-document.js';
import { trText } from '../i18n/i18n.js';
interface GhostscriptModule {
FS: {
writeFile(path: string, data: Uint8Array | string): void;
@@ -37,7 +39,11 @@ export async function loadGsModule(): Promise<GhostscriptModule> {
const gsJsUrl = `${normalizedUrl}gs.js`;
const response = await fetch(gsJsUrl);
if (!response.ok) {
throw new Error(`Failed to fetch gs.js: HTTP ${response.status}`);
throw new Error(
trText('Failed to fetch gs.js: HTTP {{status}}', {
status: response.status,
})
);
}
const jsText = await response.text();
const blob = new Blob([jsText], { type: 'application/javascript' });
@@ -69,11 +75,13 @@ export async function convertToPdfA(
): Promise<Uint8Array> {
if (!isWasmAvailable('ghostscript')) {
throw new Error(
'Ghostscript is not configured. Please configure it in WASM Settings.'
trText(
'Ghostscript is not configured. Please configure it in WASM Settings.'
)
);
}
onProgress?.('Loading Ghostscript...');
onProgress?.(trText('Loading Ghostscript...'));
let gs: GhostscriptModule;
@@ -98,7 +106,7 @@ export async function convertToPdfA(
gs.FS.writeFile(inputPath, pdfData);
console.log('[Ghostscript] Input file size:', pdfData.length);
onProgress?.(`Converting to ${level}...`);
onProgress?.(trText('Converting to {{level}}...', { level }));
try {
const iccFileName = 'sRGB_IEC61966-2-1_no_black_scaling.icc';
@@ -107,7 +115,10 @@ export async function convertToPdfA(
if (!response.ok) {
throw new Error(
`Failed to fetch ICC profile from ${iccUrl}: HTTP ${response.status}`
trText('Failed to fetch ICC profile from {{iccUrl}}: HTTP {{status}}', {
iccUrl,
status: response.status,
})
);
}
@@ -157,9 +168,12 @@ export async function convertToPdfA(
);
} catch (e) {
console.error('[Ghostscript] Failed to setup PDF/A assets:', e);
throw new Error('Conversion failed: could not create PDF/A definition', {
cause: e,
});
throw new Error(
trText('Conversion failed: could not create PDF/A definition'),
{
cause: e,
}
);
}
const args = [
@@ -199,7 +213,9 @@ export async function convertToPdfA(
exitCode = gs.callMain(args);
} catch (e) {
console.error('[Ghostscript] Exception:', e);
throw new Error(`Ghostscript threw an exception: ${e}`, { cause: e });
throw new Error(trText('Ghostscript threw an exception: {{e}}', { e }), {
cause: e,
});
}
console.log('[Ghostscript] Exit code:', exitCode);
@@ -225,7 +241,11 @@ export async function convertToPdfA(
} catch (e) {
console.warn('[Ghostscript] Failed to clean up temp file:', e);
}
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
throw new Error(
trText('Ghostscript conversion failed with exit code {{exitCode}}', {
exitCode,
})
);
}
// Read output
@@ -236,7 +256,9 @@ export async function convertToPdfA(
output = gs.FS.readFile(outputPath);
} catch (e) {
console.error('[Ghostscript] Failed to read output:', e);
throw new Error('Ghostscript did not produce output file', { cause: e });
throw new Error(trText('Ghostscript did not produce output file'), {
cause: e,
});
}
// Cleanup
@@ -262,7 +284,7 @@ export async function convertToPdfA(
}
if (level !== 'PDF/A-1b') {
onProgress?.('Post-processing for transparency compliance...');
onProgress?.(trText('Post-processing for transparency compliance...'));
console.log(
'[Ghostscript] Adding Group dictionaries to pages for transparency compliance...'
);
@@ -395,11 +417,13 @@ export async function convertFontsToOutlines(
): Promise<Uint8Array> {
if (!isWasmAvailable('ghostscript')) {
throw new Error(
'Ghostscript is not configured. Please configure it in WASM Settings.'
trText(
'Ghostscript is not configured. Please configure it in WASM Settings.'
)
);
}
onProgress?.('Loading Ghostscript...');
onProgress?.(trText('Loading Ghostscript...'));
let gs: GhostscriptModule;
@@ -415,7 +439,7 @@ export async function convertFontsToOutlines(
gs.FS.writeFile(inputPath, pdfData);
onProgress?.('Converting fonts to outlines...');
onProgress?.(trText('Converting fonts to outlines...'));
const args = [
'-dSAFER',
@@ -438,7 +462,9 @@ export async function convertFontsToOutlines(
} catch (e2) {
console.warn('[Ghostscript] Failed to clean up temp file:', e2);
}
throw new Error(`Ghostscript threw an exception: ${e}`, { cause: e });
throw new Error(trText('Ghostscript threw an exception: {{e}}', { e }), {
cause: e,
});
}
if (exitCode !== 0) {
@@ -452,14 +478,20 @@ export async function convertFontsToOutlines(
} catch (e) {
console.warn('[Ghostscript] Failed to clean up temp file:', e);
}
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
throw new Error(
trText('Ghostscript conversion failed with exit code {{exitCode}}', {
exitCode,
})
);
}
let output: Uint8Array;
try {
output = gs.FS.readFile(outputPath);
} catch (e) {
throw new Error('Ghostscript did not produce output file', { cause: e });
throw new Error(trText('Ghostscript did not produce output file'), {
cause: e,
});
}
try {
@@ -535,7 +567,10 @@ export async function convertPdfToRgb(
]);
if (exitCode !== 0) {
throw new Error(
`Ghostscript RGB conversion failed with exit code ${exitCode}`
trText(
'Ghostscript RGB conversion failed with exit code {{exitCode}}',
{ exitCode }
)
);
}
return gs.FS.readFile(outputPath);
+5 -4
View File
@@ -1,3 +1,4 @@
import { trText } from '../i18n/i18n.js';
export type ImageQuality = 'high' | 'medium' | 'low';
interface QualityConfig {
@@ -54,7 +55,7 @@ export async function compressImageFile(
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas context failed'));
reject(new Error(trText('Canvas context failed')));
return;
}
@@ -64,7 +65,7 @@ export async function compressImageFile(
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Canvas toBlob failed'));
reject(new Error(trText('Canvas toBlob failed')));
return;
}
const newName = file.name.replace(/\.[^.]+$/, '.jpg');
@@ -124,7 +125,7 @@ export async function compressImageBytes(
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas context failed'));
reject(new Error(trText('Canvas context failed')));
return;
}
@@ -134,7 +135,7 @@ export async function compressImageBytes(
canvas.toBlob(
async (jpegBlob) => {
if (!jpegBlob) {
reject(new Error('Canvas toBlob failed'));
reject(new Error(trText('Canvas toBlob failed')));
return;
}
const arrayBuffer = await jpegBlob.arrayBuffer();
+13 -5
View File
@@ -1,5 +1,7 @@
import heic2any from 'heic2any';
import { trText } from '../i18n/i18n.js';
export const IMAGE_EXTENSIONS = [
'.jpg',
'.jpeg',
@@ -60,7 +62,10 @@ export async function preprocessImageFile(file: File): Promise<File> {
});
} catch (cause) {
console.error(`Failed to convert HEIC: ${file.name}`, cause);
throw new Error(`Failed to process HEIC file: ${file.name}`, { cause });
throw new Error(
trText('Failed to process HEIC file: {{name}}', { name: file.name }),
{ cause }
);
}
}
@@ -76,7 +81,7 @@ export async function preprocessImageFile(file: File): Promise<File> {
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas context failed'));
reject(new Error(trText('Canvas context failed')));
return;
}
ctx.drawImage(img, 0, 0);
@@ -89,19 +94,22 @@ export async function preprocessImageFile(file: File): Promise<File> {
})
);
} else {
reject(new Error('Canvas toBlob failed'));
reject(new Error(trText('Canvas toBlob failed')));
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load WebP image'));
reject(new Error(trText('Failed to load WebP image')));
};
img.src = url;
});
} catch (cause) {
console.error(`Failed to convert WebP: ${file.name}`, cause);
throw new Error(`Failed to process WebP file: ${file.name}`, { cause });
throw new Error(
trText('Failed to process WebP file: {{name}}', { name: file.name }),
{ cause }
);
}
}
+10 -3
View File
@@ -1,6 +1,8 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { preprocessImageFile } from './image-input-utils.js';
import { trText } from '../i18n/i18n.js';
export interface EmbeddableImage {
bytes: Uint8Array;
format: 'jpg' | 'png';
@@ -36,18 +38,23 @@ export async function normalizeImageToEmbeddable(
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () =>
reject(new Error(`Failed to decode image: ${processed.name}`));
reject(
new Error(
trText('Failed to decode image: {{name}}', { name: processed.name })
)
);
i.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas context unavailable');
if (!ctx) throw new Error(trText('Canvas context unavailable'));
ctx.drawImage(img, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
(b) =>
b ? resolve(b) : reject(new Error(trText('Canvas toBlob failed'))),
'image/png'
);
});
+15 -5
View File
@@ -8,6 +8,8 @@
import { WorkerBrowserConverter } from '@matbee/libreoffice-converter/browser';
import type { InputFormat } from '@matbee/libreoffice-converter/browser';
import { trText } from '../i18n/i18n.js';
const LIBREOFFICE_LOCAL_PATH = import.meta.env.BASE_URL + 'libreoffice-wasm/';
export interface LoadProgress {
@@ -30,7 +32,12 @@ async function fetchAsDecompressedUrl(
): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
throw new Error(
trText('Failed to fetch {{url}}: HTTP {{status}}', {
url,
status: response.status,
})
);
}
let blob = await response.blob();
@@ -72,7 +79,7 @@ export class LibreOfficeConverter {
progressCallback?.({
phase: 'loading',
percent: 0,
message: 'Loading conversion engine...',
message: trText('Loading conversion engine...'),
});
const [sofficeWasmUrl, sofficeDataUrl] = await Promise.all([
@@ -99,7 +106,10 @@ export class LibreOfficeConverter {
message: string;
}) => {
if (progressCallback && !this.initialized) {
const simplifiedMessage = `Loading conversion engine (${Math.round(info.percent)}%)...`;
const simplifiedMessage = trText(
'Loading conversion engine ({{percent}}%)...',
{ percent: Math.round(info.percent) }
);
progressCallback({
phase: info.phase as LoadProgress['phase'],
percent: info.percent,
@@ -122,7 +132,7 @@ export class LibreOfficeConverter {
progressCallback?.({
phase: 'ready',
percent: 100,
message: 'Conversion engine ready!',
message: trText('Conversion engine ready!'),
});
// Null out the callback to prevent any late-firing progress updates
@@ -138,7 +148,7 @@ export class LibreOfficeConverter {
async convertToPdf(file: File): Promise<Blob> {
if (!this.converter) {
throw new Error('Converter not initialized');
throw new Error(trText('Converter not initialized'));
}
console.log(`[LibreOffice] Converting ${file.name} to PDF...`);
+3 -1
View File
@@ -1,6 +1,8 @@
import { PDFDocument } from 'pdf-lib';
import { initializeQpdf } from './helpers.js';
import { trText } from '../i18n/i18n.js';
type LoadOptions = Parameters<typeof PDFDocument.load>[1];
type PDFDocumentInstance = Awaited<ReturnType<typeof PDFDocument.load>>;
@@ -55,7 +57,7 @@ export async function removeUnreferencedObjects(
[]
);
if (!cleaned) {
throw new Error('Could not remove leftover data from the PDF.');
throw new Error(trText('Could not remove leftover data from the PDF.'));
}
return cleaned;
}
+5 -3
View File
@@ -29,7 +29,7 @@ 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';
import { applyTranslations, trHtml, trText } from '../i18n/i18n';
import type {
WindowWithLucide,
WindowWithI18next,
@@ -352,7 +352,7 @@ export class MarkdownEditor {
<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">
<button class="md-editor-btn md-editor-btn-secondary" id="mdSyncScroll" title="${trHtml('Toggle sync scroll')}">
<i data-lucide="git-compare"></i>
<span data-i18n="tools:markdownToPdf.btnSyncScroll">Sync Scroll</span>
</button>
@@ -738,7 +738,9 @@ export class MarkdownEditor {
console.error('Mermaid rendering error:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'mermaid-error';
errorDiv.textContent = `Mermaid Error: ${(error as Error).message}`;
errorDiv.textContent = trText('Mermaid Error: {{error}}', {
error: (error as Error).message,
});
pre.replaceWith(errorDiv);
}
}
+19 -8
View File
@@ -14,6 +14,8 @@ import { getPDFDocument } from './helpers.js';
import { loadPdfDocument } from './load-pdf-document.js';
import { createConfiguredTesseractWorker } from './tesseract-runtime.js';
import { trText } from '../i18n/i18n.js';
export interface OcrOptions {
language: string;
resolution: number;
@@ -156,7 +158,11 @@ function recognizeWithTimeout(
): Promise<Tesseract.RecognizeResult> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`OCR recognize timed out after ${timeoutMs}ms`));
const error = new Error(
trText('OCR recognize timed out after {{timeoutMs}}ms', { timeoutMs })
);
error.name = 'OcrTimeoutError';
reject(error);
}, timeoutMs);
worker
.recognize(canvas, {}, { text: true, hocr: true })
@@ -217,7 +223,7 @@ export async function performOcr(
newPdfDoc.registerFontkit(fontkit);
progress('Loading fonts...', 0);
progress(trText('Loading fonts...'), 0);
const selectedLangs = language.split('+');
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
@@ -283,7 +289,10 @@ export async function performOcr(
for (let i = 1; i <= pdf.numPages; i++) {
progress(
`Processing page ${i} of ${pdf.numPages}`,
trText('Processing page {{i}} of {{numPages}}', {
i,
numPages: pdf.numPages,
}),
(i - 1) / pdf.numPages
);
@@ -295,7 +304,8 @@ export async function performOcr(
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to create canvas context');
if (!context)
throw new Error(trText('Failed to create canvas context'));
await pdfJsPage.render({ canvasContext: context, viewport, canvas })
.promise;
@@ -318,9 +328,10 @@ export async function performOcr(
const message = err instanceof Error ? err.message : String(err);
warnings.push({
page: i,
kind: /timed out/i.test(message)
? 'recognize-timeout'
: 'recognize-error',
kind:
err instanceof Error && err.name === 'OcrTimeoutError'
? 'recognize-timeout'
: 'recognize-error',
message,
});
}
@@ -369,7 +380,7 @@ export async function performOcr(
}
if (!newPdfDoc) {
throw new Error('OCR aborted before any page was processed');
throw new Error(trText('OCR aborted before any page was processed'));
}
const savedBytes = await newPdfDoc.save();
+13 -9
View File
@@ -3,6 +3,8 @@ import type { PDFDocumentProxy } from 'pdfjs-dist';
import { PreviewState } from '@/types';
import './setup-pdf-worker.js';
import { trText, trHtml } from '../i18n/i18n.js';
const state: PreviewState = {
modal: null,
pdfjsDoc: null,
@@ -20,17 +22,17 @@ function getOrCreateModal(): HTMLElement {
modal.className =
'fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-200';
modal.innerHTML = `
<button id="preview-close" class="absolute top-4 right-4 text-white/70 hover:text-white z-10 transition-colors" title="Close (Esc)">
<button id="preview-close" class="absolute top-4 right-4 text-white/70 hover:text-white z-10 transition-colors" title="${trHtml('Close (Esc)')}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<button id="preview-prev" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Previous page">
<button id="preview-prev" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="${trHtml('Previous page')}">
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</button>
<button id="preview-next" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Next page">
<button id="preview-next" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="${trHtml('Next page')}">
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</button>
<div id="preview-canvas-container" class="flex items-center justify-center max-w-[90vw] max-h-[85vh]">
<div id="preview-loading" class="text-white/60 text-sm">Loading...</div>
<div id="preview-loading" class="text-white/60 text-sm">${trHtml('Loading...')}</div>
</div>
<div id="preview-page-info" class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-gray-900/80 text-white text-sm px-4 py-2 rounded-full backdrop-blur-sm"></div>
`;
@@ -62,9 +64,12 @@ async function renderPreviewPage(pageNumber: number): Promise<void> {
const prevBtn = modal.querySelector('#preview-prev') as HTMLElement;
const nextBtn = modal.querySelector('#preview-next') as HTMLElement;
container.innerHTML = '<div class="text-white/60 text-sm">Loading...</div>';
container.innerHTML = `<div class="text-white/60 text-sm">${trHtml('Loading...')}</div>`;
pageInfo.textContent = `Page ${pageNumber} of ${state.totalPages}`;
pageInfo.textContent = trText('Page {{pageNumber}} of {{totalPages}}', {
pageNumber,
totalPages: state.totalPages,
});
prevBtn.style.visibility = pageNumber > 1 ? 'visible' : 'hidden';
nextBtn.style.visibility =
pageNumber < state.totalPages ? 'visible' : 'hidden';
@@ -92,8 +97,7 @@ async function renderPreviewPage(pageNumber: number): Promise<void> {
state.currentPage = pageNumber;
} catch (err) {
console.error('Preview render error:', err);
container.innerHTML =
'<div class="text-red-400 text-sm">Failed to render page</div>';
container.innerHTML = `<div class="text-red-400 text-sm">${trHtml('Failed to render page')}</div>`;
}
}
@@ -172,7 +176,7 @@ export function initPagePreview(
const icon = document.createElement('button');
icon.className =
'page-preview-btn absolute bottom-1 right-1 bg-gray-900/80 hover:bg-indigo-600 text-white/70 hover:text-white rounded-full w-7 h-7 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10';
icon.title = 'Preview';
icon.title = trText('Preview');
icon.innerHTML =
'<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>';
icon.addEventListener('click', (e) => {
+53 -34
View File
@@ -4,6 +4,8 @@ import { createIcons, icons } from 'lucide';
import { PasswordResponses } from 'pdfjs-dist';
import type { LoadedPdf } from '@/types';
import { trText, trHtml } from '../i18n/i18n.js';
let cachedPassword: string | null = null;
let activeModalPromise: Promise<unknown> | null = null;
@@ -35,7 +37,7 @@ function ensureSingleModal(): HTMLDivElement {
<i data-lucide="lock" class="w-6 h-6 text-indigo-400"></i>
</div>
<div class="flex-1">
<h3 id="password-modal-title" class="text-xl font-bold text-white mb-1">Password Required</h3>
<h3 id="password-modal-title" class="text-xl font-bold text-white mb-1">${trHtml('Password Required')}</h3>
<p id="password-modal-subtitle" class="text-gray-400 text-sm truncate"></p>
</div>
</div>
@@ -43,7 +45,7 @@ function ensureSingleModal(): HTMLDivElement {
<div class="relative">
<input type="password" id="password-modal-input"
class="w-full bg-gray-700 border border-gray-600 text-gray-200 rounded-lg px-4 py-2.5 pr-10 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Enter password" autocomplete="off" />
placeholder="${trHtml('Enter password')}" autocomplete="off" />
<button id="password-modal-toggle" type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200">
<i data-lucide="eye" class="w-4 h-4"></i>
@@ -56,11 +58,11 @@ function ensureSingleModal(): HTMLDivElement {
<div class="flex gap-3 p-4 border-t border-gray-700">
<button id="password-modal-cancel"
class="flex-1 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors">
Skip
${trHtml('Skip')}
</button>
<button id="password-modal-submit"
class="flex-1 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors">
Unlock
${trHtml('Unlock')}
</button>
</div>
</div>`;
@@ -85,19 +87,19 @@ function ensureBatchModal(): HTMLDivElement {
</div>
<div class="flex-1">
<h3 id="batch-modal-title" class="text-xl font-bold text-white mb-1"></h3>
<p class="text-gray-400 text-sm">Enter passwords for each encrypted file</p>
<p class="text-gray-400 text-sm">${trHtml('Enter passwords for each encrypted file')}</p>
</div>
</div>
<div class="flex items-center gap-2 mt-3 mb-3">
<input type="checkbox" id="batch-modal-same-pw" checked
class="w-4 h-4 rounded bg-gray-700 border-gray-600 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-0 cursor-pointer" />
<label for="batch-modal-same-pw" class="text-sm text-gray-300 cursor-pointer select-none">Use same password for all files</label>
<label for="batch-modal-same-pw" class="text-sm text-gray-300 cursor-pointer select-none">${trHtml('Use same password for all files')}</label>
</div>
<div id="batch-modal-shared" class="mb-3">
<div class="relative">
<input type="password" id="batch-modal-shared-input"
class="w-full bg-gray-700 border border-gray-600 text-gray-200 rounded-lg px-4 py-2.5 pr-10 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Password for all files" autocomplete="off" />
placeholder="${trHtml('Password for all files')}" autocomplete="off" />
<button id="batch-modal-shared-toggle" type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200">
<i data-lucide="eye" class="w-4 h-4"></i>
@@ -111,11 +113,11 @@ function ensureBatchModal(): HTMLDivElement {
<div class="flex gap-3 p-4 border-t border-gray-700 flex-shrink-0">
<button id="batch-modal-cancel"
class="flex-1 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors">
Skip All
${trHtml('Skip All')}
</button>
<button id="batch-modal-submit"
class="flex-1 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors">
Unlock All
${trHtml('Unlock All')}
</button>
</div>
</div>`;
@@ -237,7 +239,7 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
if (!input || !submitBtn || !cancelBtn) return null;
if (titleEl) titleEl.textContent = 'Password Required';
if (titleEl) titleEl.textContent = trText('Password Required');
if (subtitleEl) subtitleEl.textContent = file.name;
if (errorEl) {
errorEl.textContent = '';
@@ -250,10 +252,10 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
input.value = '';
input.type = 'password';
submitBtn.disabled = false;
submitBtn.textContent = 'Unlock';
submitBtn.dataset.originalText = 'Unlock';
submitBtn.textContent = trText('Unlock');
submitBtn.dataset.originalText = trText('Unlock');
cancelBtn.disabled = false;
cancelBtn.textContent = 'Skip';
cancelBtn.textContent = trText('Skip');
modal.classList.remove('hidden');
modal.classList.add('flex');
@@ -285,7 +287,7 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
const password = input.value;
if (!password) {
if (errorEl) {
errorEl.textContent = 'Please enter a password';
errorEl.textContent = trText('Please enter a password');
errorEl.classList.remove('hidden');
}
return;
@@ -296,7 +298,7 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
submitBtn.disabled = true;
cancelBtn.disabled = true;
if (progressEl) {
progressEl.textContent = 'Validating...';
progressEl.textContent = trText('Validating...');
progressEl.classList.remove('hidden');
}
@@ -310,13 +312,13 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
input.value = '';
input.focus();
if (errorEl) {
errorEl.textContent = 'Incorrect password. Please try again.';
errorEl.textContent = trText('Incorrect password. Please try again.');
errorEl.classList.remove('hidden');
}
return;
}
if (progressEl) progressEl.textContent = 'Decrypting...';
if (progressEl) progressEl.textContent = trText('Decrypting...');
try {
const decrypted = await decryptFileWithPassword(file, password);
@@ -329,8 +331,9 @@ export async function promptAndDecryptFile(file: File): Promise<File | null> {
cancelBtn.disabled = false;
if (progressEl) progressEl.classList.add('hidden');
if (errorEl) {
errorEl.textContent =
'Failed to decrypt. Try the Decrypt tool instead.';
errorEl.textContent = trText(
'Failed to decrypt. Try the Decrypt tool instead.'
);
errorEl.classList.remove('hidden');
}
}
@@ -450,7 +453,9 @@ export async function promptAndDecryptBatch(
}
if (titleEl)
titleEl.textContent = `${fileNames.length} Files Need a Password`;
titleEl.textContent = trText('{{length}} Files Need a Password', {
length: fileNames.length,
});
samePwCheckbox.checked = true;
sharedInput.value = '';
@@ -465,9 +470,9 @@ export async function promptAndDecryptBatch(
<i data-lucide="file-lock" class="w-4 h-4 text-indigo-400 flex-shrink-0" data-icon-idx="${i}"></i>
<span class="text-sm text-gray-300 truncate flex-1" title="${esc(name)}">${esc(name)}</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<input type="password" data-pw-idx="${i}" placeholder="Password"
<input type="password" data-pw-idx="${i}" placeholder="${trHtml('Password')}"
class="w-32 bg-gray-600 border border-gray-500 text-gray-200 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-indigo-500 focus:border-transparent" autocomplete="off" />
<button type="button" data-skip-idx="${i}" title="Skip this file"
<button type="button" data-skip-idx="${i}" title="${trHtml('Skip this file')}"
class="p-1 rounded text-gray-400 hover:text-red-400 hover:bg-gray-600 transition-colors">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
@@ -485,8 +490,8 @@ export async function promptAndDecryptBatch(
progressEl.classList.add('hidden');
}
submitBtn.disabled = false;
submitBtn.textContent = 'Unlock All';
submitBtn.dataset.originalText = 'Unlock All';
submitBtn.textContent = trText('Unlock All');
submitBtn.dataset.originalText = trText('Unlock All');
cancelBtn.disabled = false;
modal.classList.remove('hidden');
@@ -510,7 +515,9 @@ export async function promptAndDecryptBatch(
function updateButtons(autoClose = false) {
const hasSucceeded = succeededSet.size > 0;
cancelBtn.textContent = hasSucceeded ? 'Skip Remaining' : 'Skip All';
cancelBtn.textContent = hasSucceeded
? trText('Skip Remaining')
: trText('Skip All');
const remaining = getRemainingCount();
if (autoClose && remaining === 0) {
@@ -519,7 +526,9 @@ export async function promptAndDecryptBatch(
}
if (hasSucceeded) {
submitBtn.textContent = `Unlock Remaining (${remaining})`;
submitBtn.textContent = trText('Unlock Remaining ({{remaining}})', {
remaining,
});
submitBtn.dataset.originalText = submitBtn.textContent;
}
}
@@ -596,7 +605,7 @@ export async function promptAndDecryptBatch(
const pwInput =
row.querySelector<HTMLInputElement>('input[data-pw-idx]');
if (pwInput) pwInput.disabled = false;
btn.title = 'Skip this file';
btn.title = trText('Skip this file');
} else {
skippedSet.add(idx);
row.classList.add('opacity-40');
@@ -604,7 +613,7 @@ export async function promptAndDecryptBatch(
const pwInput =
row.querySelector<HTMLInputElement>('input[data-pw-idx]');
if (pwInput) pwInput.disabled = true;
btn.title = 'Include this file';
btn.title = trText('Include this file');
}
updateButtons(true);
}
@@ -650,8 +659,10 @@ export async function promptAndDecryptBatch(
if (!password) {
if (errorEl) {
const msg = useSame
? 'Please enter a password'
: `Please enter a password for ${fileNames[i]} or skip it`;
? trText('Please enter a password')
: trText('Please enter a password for {{i}} or skip it', {
i: fileNames[i],
});
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
}
@@ -678,7 +689,10 @@ export async function promptAndDecryptBatch(
const realIdx = encryptedIndices[localIdx];
if (progressEl) {
progressEl.textContent = `Validating ${i + 1} of ${toProcess.length}: ${files[realIdx].name}`;
progressEl.textContent = trText(
'Validating {{n}} of {{length}}: {{name}}',
{ n: i + 1, length: toProcess.length, name: files[realIdx].name }
);
progressEl.classList.remove('hidden');
}
@@ -694,7 +708,10 @@ export async function promptAndDecryptBatch(
}
if (progressEl) {
progressEl.textContent = `Decrypting ${i + 1} of ${toProcess.length}: ${files[realIdx].name}`;
progressEl.textContent = trText(
'Decrypting {{n}} of {{length}}: {{name}}',
{ n: i + 1, length: toProcess.length, name: files[realIdx].name }
);
}
try {
@@ -717,7 +734,9 @@ export async function promptAndDecryptBatch(
if (failedNames.length > 0) {
if (errorEl) {
errorEl.textContent = `Wrong password for: ${failedNames.join(', ')}`;
errorEl.textContent = trText('Wrong password for: {{join}}', {
join: failedNames.join(', '),
});
errorEl.classList.remove('hidden');
}
if (!samePwCheckbox.checked) {
@@ -730,7 +749,7 @@ export async function promptAndDecryptBatch(
sharedInput.focus();
}
updateButtons();
submitBtn.textContent = 'Retry Failed';
submitBtn.textContent = trText('Retry Failed');
return;
}
+13 -9
View File
@@ -1,5 +1,7 @@
import type { ContentOperation, ContentToken } from '@/types';
import { trText } from '../i18n/i18n.js';
const charCode = (char: string) => char.charCodeAt(0);
const BACKSLASH = charCode('\\');
@@ -82,7 +84,7 @@ function findLiteralStringEnd(bytes: Uint8Array, start: number): number {
if (depth === 0) return i + 1;
}
}
throw new Error('Unterminated string in content stream');
throw new Error(trText('Unterminated string in content stream'));
}
function findHexStringEnd(bytes: Uint8Array, start: number): number {
@@ -90,10 +92,10 @@ function findHexStringEnd(bytes: Uint8Array, start: number): number {
const byte = bytes[i];
if (byte === GREATER_THAN) return i + 1;
if (!isHexDigit(byte) && !WHITESPACE.has(byte)) {
throw new Error('Invalid hex string in content stream');
throw new Error(trText('Invalid hex string in content stream'));
}
}
throw new Error('Unterminated hex string in content stream');
throw new Error(trText('Unterminated hex string in content stream'));
}
function findClosingDelimiter(
@@ -105,7 +107,9 @@ function findClosingDelimiter(
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated array or dictionary in content stream');
throw new Error(
trText('Unterminated array or dictionary in content stream')
);
}
if (closing.every((byte, offset) => bytes[pos + offset] === byte)) {
return pos + closing.length;
@@ -143,7 +147,7 @@ function readToken(bytes: Uint8Array, start: number): ContentToken {
return { kind: 'name', value: name, start, end };
}
if (DELIMITERS.has(byte)) {
throw new Error('Unexpected delimiter in content stream');
throw new Error(trText('Unexpected delimiter in content stream'));
}
let end = start;
@@ -228,7 +232,7 @@ function skipInlineImage(bytes: Uint8Array, start: number): number {
while (true) {
pos = skipWhitespaceAndComments(bytes, pos);
if (pos >= bytes.length) {
throw new Error('Unterminated inline image in content stream');
throw new Error(trText('Unterminated inline image in content stream'));
}
const token = readToken(bytes, pos);
pos = token.end;
@@ -239,7 +243,7 @@ function skipInlineImage(bytes: Uint8Array, start: number): number {
} else if (token.kind === 'name') {
pendingKey = token;
} else {
throw new Error('Malformed inline image in content stream');
throw new Error(trText('Malformed inline image in content stream'));
}
}
@@ -250,7 +254,7 @@ function skipInlineImage(bytes: Uint8Array, start: number): number {
let end = dataStart + dataLength;
while (end < bytes.length && WHITESPACE.has(bytes[end])) end++;
if (isEndImageOperator(bytes, end)) return end + 2;
throw new Error('Malformed inline image in content stream');
throw new Error(trText('Malformed inline image in content stream'));
}
for (let i = dataStart; i + 1 < bytes.length; i++) {
@@ -260,7 +264,7 @@ function skipInlineImage(bytes: Uint8Array, start: number): number {
if (!isFollowedByText(bytes, i + 2)) break;
return i + 2;
}
throw new Error('Unterminated inline image in content stream');
throw new Error(trText('Unterminated inline image in content stream'));
}
export function parseContentOperations(bytes: Uint8Array): ContentOperation[] {
+9 -5
View File
@@ -2,6 +2,8 @@ import { getCpdf, isCpdfAvailable } from './cpdf-helper';
import { isPyMuPDFAvailable, loadPyMuPDF } from './pymupdf-loader';
import type { CpdfInstance } from '@/types';
import { trText } from '../i18n/i18n.js';
export type PdfDecryptEngine = 'cpdf' | 'pymupdf';
export interface PdfDecryptResult {
@@ -76,7 +78,7 @@ async function decryptWithCpdf(
pdf = null;
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
throw new Error('CoherentPDF produced an empty decrypted file.');
throw new Error(trText('CoherentPDF produced an empty decrypted file.'));
}
return copyBytes(outputBytes);
@@ -102,13 +104,15 @@ async function decryptWithPyMuPDF(
if (document.needsPass || document.isEncrypted) {
const authenticated = document.authenticate(password);
if (!authenticated) {
throw new Error('Invalid PDF password.');
const error = new Error(trText('Invalid PDF password.'));
error.name = 'PasswordError';
throw error;
}
}
const outputBytes = document.save();
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
throw new Error('PyMuPDF produced an empty decrypted file.');
throw new Error(trText('PyMuPDF produced an empty decrypted file.'));
}
return copyBytes(outputBytes);
@@ -145,7 +149,7 @@ export async function decryptPdfBytes(
console.info(
`${DECRYPT_LOG_PREFIX} CoherentPDF is not configured, skipping to PyMuPDF`
);
errors.push('CoherentPDF: not configured.');
errors.push(trText('CoherentPDF: not configured.'));
}
if (isPyMuPDFAvailable()) {
@@ -166,7 +170,7 @@ export async function decryptPdfBytes(
}
} else {
console.warn(`${DECRYPT_LOG_PREFIX} PyMuPDF is not configured`);
errors.push('PyMuPDF: not configured.');
errors.push(trText('PyMuPDF: not configured.'));
}
throw new Error(errors.join('\n'));
+7 -3
View File
@@ -1,6 +1,8 @@
import { PDFDocument, degrees, rgb, StandardFonts, PageSizes } from 'pdf-lib';
import { loadPdfDocument } from './load-pdf-document.js';
import { trText } from '../i18n/i18n.js';
export async function mergePdfs(
pdfBytesList: Uint8Array[]
): Promise<Uint8Array> {
@@ -132,7 +134,8 @@ export async function deletePdfPages(
}
}
if (pagesToKeep.length === 0) throw new Error('Cannot delete all pages');
if (pagesToKeep.length === 0)
throw new Error(trText('Cannot delete all pages'));
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(srcDoc, pagesToKeep);
@@ -280,7 +283,7 @@ export async function addTextWatermark(
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to create canvas context');
if (!ctx) throw new Error(trText('Failed to create canvas context'));
const dpr = 2;
const colorR = Math.round(options.color.r * 255);
@@ -301,7 +304,8 @@ export async function addTextWatermark(
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
(b) =>
b ? resolve(b) : reject(new Error(trText('Canvas toBlob failed'))),
'image/png'
);
});
+15 -6
View File
@@ -6,6 +6,8 @@ import type {
PyMuPDFRasterizeOptions,
} from '@/types';
import { trText } from '../i18n/i18n.js';
let cachedPyMuPDF: PyMuPDFInstance | null = null;
let loadPromise: Promise<PyMuPDFInstance> | null = null;
@@ -40,12 +42,16 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> {
loadPromise = (async () => {
if (!WasmProvider.isConfigured('pymupdf')) {
throw new Error(
'PyMuPDF is not configured. Please configure it in Advanced Settings.'
trText(
'PyMuPDF is not configured. Please configure it in Advanced Settings.'
)
);
}
if (!WasmProvider.isConfigured('ghostscript')) {
throw new Error(
'Ghostscript is not configured. PyMuPDF requires Ghostscript for some operations. Please configure both in Advanced Settings.'
trText(
'Ghostscript is not configured. PyMuPDF requires Ghostscript for some operations. Please configure both in Advanced Settings.'
)
);
}
@@ -60,7 +66,7 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> {
if (typeof module.PyMuPDF !== 'function') {
throw new Error(
'PyMuPDF module did not export expected PyMuPDF class.'
trText('PyMuPDF module did not export expected PyMuPDF class.')
);
}
@@ -75,9 +81,12 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> {
} catch (error: unknown) {
loadPromise = null;
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to load PyMuPDF from CDN: ${msg}`, {
cause: error,
});
throw new Error(
trText('Failed to load PyMuPDF from CDN: {{msg}}', { msg }),
{
cause: error,
}
);
}
})();
+6 -2
View File
@@ -1,6 +1,8 @@
import * as pdfjsLib from 'pdfjs-dist';
import './setup-pdf-worker.js';
import { trText } from '../i18n/i18n.js';
/**
* Configuration for progressive rendering
*/
@@ -76,7 +78,7 @@ export function createPlaceholder(
const loadingText = document.createElement('span');
loadingText.className = 'text-gray-500 text-xs';
loadingText.textContent = 'Loading...';
loadingText.textContent = trText('Loading...');
skeletonContainer.appendChild(loadingText);
placeholder.appendChild(skeletonContainer);
@@ -101,7 +103,9 @@ export async function renderPageToCanvas(
const context = canvas.getContext('2d');
if (!context) {
throw new Error(`Failed to get 2D context for page ${pageNumber}`);
throw new Error(
trText('Failed to get 2D context for page {{pageNumber}}', { pageNumber })
);
}
await page.render({
+3 -1
View File
@@ -20,6 +20,8 @@ import { flattenFormsInDoc } from './flatten-annotations.js';
import { parseContentOperations } from './pdf-content-tokens.js';
import type { ContentOperation, PDFDocumentInternal } from '@/types';
import { trText } from '../i18n/i18n.js';
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
return pdfDoc.catalog;
}
@@ -485,7 +487,7 @@ function removeHiddenContent(
? properties.get(PDFName.of(property.value))
: undefined;
if (value === undefined) {
throw new Error('Unresolved optional content reference');
throw new Error(trText('Unresolved optional content reference'));
}
return !isVisible(value);
};
+21 -8
View File
@@ -1,4 +1,5 @@
import { createLanguageSwitcher } from '../i18n/language-switcher.js';
import { onPageReady } from '../i18n/i18n.js';
// Handle simple mode adjustments for tool pages
if (__SIMPLE_MODE__) {
@@ -9,11 +10,21 @@ if (__SIMPLE_MODE__) {
'Frequently Asked Questions',
];
const keysToHide = [
'howItWorks.title',
'relatedTools.title',
'faq.sectionTitle',
];
document.querySelectorAll('section').forEach((section) => {
const h2 = section.querySelector('h2');
if (h2) {
const heading = h2.textContent?.trim() || '';
if (sectionsToHide.some((text) => heading.includes(text))) {
const key = h2.getAttribute('data-i18n') || '';
if (
keysToHide.includes(key) ||
sectionsToHide.some((text) => heading.includes(text))
) {
(section as HTMLElement).style.display = 'none';
}
}
@@ -21,12 +32,14 @@ if (__SIMPLE_MODE__) {
const langContainer = document.getElementById('simple-mode-lang-switcher');
if (langContainer) {
const switcher = createLanguageSwitcher();
const dropdown = switcher.querySelector('div[role="menu"]');
if (dropdown) {
dropdown.classList.remove('mt-2');
dropdown.classList.add('bottom-full', 'mb-2');
}
langContainer.appendChild(switcher);
onPageReady(() => {
const switcher = createLanguageSwitcher();
const dropdown = switcher.querySelector('div[role="menu"]');
if (dropdown) {
dropdown.classList.remove('mt-2');
dropdown.classList.add('bottom-full', 'mb-2');
}
langContainer.appendChild(switcher);
});
}
}
+8 -2
View File
@@ -1,5 +1,7 @@
import type { QpdfInstanceExtended } from '@/types';
import { trText } from '../i18n/i18n.js';
export function parseRangeGroups(
input: string,
totalPages: number
@@ -131,11 +133,15 @@ export function extractPagesWithQpdf(
]);
try {
if (exitCode !== 0 && exitCode !== 3) {
throw new Error(`Failed to extract pages (qpdf exit code ${exitCode}).`);
throw new Error(
trText('Failed to extract pages (qpdf exit code {{exitCode}}).', {
exitCode,
})
);
}
const bytes = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
if (!bytes || bytes.length === 0) {
throw new Error('Page extraction produced an empty PDF.');
throw new Error(trText('Page extraction produced an empty PDF.'));
}
return bytes;
} finally {
@@ -1,5 +1,7 @@
import { tesseractLanguages } from '../config/tesseract-languages.js';
import { trText } from '../i18n/i18n.js';
export const TESSERACT_AVAILABLE_LANGUAGES_ENV_KEY =
'VITE_TESSERACT_AVAILABLE_LANGUAGES' as const;
@@ -89,9 +91,16 @@ function buildUnsupportedLanguageMessage(
const availableText = formatTesseractLanguageList(availableLanguages);
return [
`This BentoPDF build only bundles OCR data for ${availableText}.`,
`The requested OCR language is not available: ${unavailableText}.`,
'Choose one of the bundled languages or rebuild the air-gapped bundle with the missing language added to --ocr-languages.',
trText('This BentoPDF build only bundles OCR data for {{availableText}}.', {
availableText,
}),
trText(
'The requested OCR language is not available: {{unavailableText}}.',
{ unavailableText }
),
trText(
'Choose one of the bundled languages or rebuild the air-gapped bundle with the missing language added to --ocr-languages.'
),
].join(' ');
}
+6 -1
View File
@@ -4,6 +4,8 @@ import {
TESSERACT_AVAILABLE_LANGUAGES_ENV_KEY,
} from './tesseract-language-availability.js';
import { trText } from '../i18n/i18n.js';
const TESSERACT_ENV_KEYS = [
'VITE_TESSERACT_WORKER_URL',
'VITE_TESSERACT_CORE_URL',
@@ -101,7 +103,10 @@ export function buildTesseractWorkerOptions(
if (!hasCompleteTesseractOverrides(config)) {
const missing = getIncompleteTesseractOverrideKeys(config).join(', ');
throw new Error(
`Self-hosted OCR assets are partially configured. Set ${missing} together with the other Tesseract asset URLs.`
trText(
'Self-hosted OCR assets are partially configured. Set {{missing}} together with the other Tesseract asset URLs.',
{ missing }
)
);
}
+56 -44
View File
@@ -1,3 +1,4 @@
import { trHtml, trText } from '../i18n/i18n.js';
export type WasmPackage = 'pymupdf' | 'ghostscript' | 'cpdf';
interface WasmProviderConfig {
@@ -152,7 +153,7 @@ class WasmProviderManager {
const normalizedUrl = withTrailingSlash(url);
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
throw new Error(trText('Invalid URL'));
}
const envDefault = this.getEnvDefault(packageName);
if (envDefault && normalizedUrl === withTrailingSlash(envDefault)) {
@@ -196,7 +197,7 @@ class WasmProviderManager {
): Promise<{ valid: boolean; error?: string }> {
const testUrl = url || this.config[packageName];
if (!testUrl) {
return { valid: false, error: 'No URL configured' };
return { valid: false, error: trText('No URL configured') };
}
try {
@@ -204,14 +205,15 @@ class WasmProviderManager {
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
valid: false,
error: 'URL must start with http:// or https://',
error: trText('URL must start with http:// or https://'),
};
}
} catch {
return {
valid: false,
error:
'Invalid URL format. Please enter a valid URL (e.g., https://example.com/wasm/)',
error: trText(
'Invalid URL format. Please enter a valid URL (e.g., https://example.com/wasm/)'
),
};
}
@@ -241,7 +243,10 @@ class WasmProviderManager {
if (!response.ok) {
return {
valid: false,
error: `Could not find ${testFile} at the specified URL (HTTP ${response.status}). Make sure the file exists.`,
error: trText(
'Could not find {{testFile}} at the specified URL (HTTP {{status}}). Make sure the file exists.',
{ testFile, status: response.status }
),
};
}
@@ -253,7 +258,9 @@ class WasmProviderManager {
} catch {
return {
valid: false,
error: `File exists but could not be read. Check CORS configuration.`,
error: trText(
'File exists but could not be read. Check CORS configuration.'
),
};
}
}
@@ -267,7 +274,10 @@ class WasmProviderManager {
) {
return {
valid: false,
error: `The URL returned unexpected content type: ${contentType}. Expected a JavaScript file.`,
error: trText(
'The URL returned unexpected content type: {{contentType}}. Expected a JavaScript file.',
{ contentType }
),
};
}
@@ -277,7 +287,8 @@ class WasmProviderManager {
return { valid: true };
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
const errorMessage =
e instanceof Error ? e.message : trText('Unknown error');
if (
errorMessage.includes('Failed to fetch') ||
@@ -285,14 +296,15 @@ class WasmProviderManager {
) {
return {
valid: false,
error:
'Network error: Could not connect to the URL. Check that the URL is correct and the server allows CORS requests.',
error: trText(
'Network error: Could not connect to the URL. Check that the URL is correct and the server allows CORS requests.'
),
};
}
return {
valid: false,
error: `Network error: ${errorMessage}`,
error: trText('Network error: {{error}}', { error: errorMessage }),
};
}
}
@@ -322,9 +334,9 @@ class WasmProviderManager {
getPackageDisplayName(packageName: WasmPackage): string {
const names: Record<WasmPackage, string> = {
pymupdf: 'PyMuPDF (Document Processing)',
ghostscript: 'Ghostscript (PDF/A Conversion)',
cpdf: 'CoherentPDF (Bookmarks & Metadata)',
pymupdf: trText('PyMuPDF (Document Processing)'),
ghostscript: trText('Ghostscript (PDF/A Conversion)'),
cpdf: trText('CoherentPDF (Bookmarks & Metadata)'),
};
return names[packageName];
}
@@ -332,29 +344,29 @@ class WasmProviderManager {
getPackageFeatures(packageName: WasmPackage): string[] {
const features: Record<WasmPackage, string[]> = {
pymupdf: [
'PDF to Text',
'PDF to Markdown',
'PDF to SVG',
'PDF to Images (High Quality)',
'PDF to DOCX',
'PDF to Excel/CSV',
'Extract Images',
'Extract Tables',
'EPUB/MOBI/FB2/XPS/CBZ to PDF',
'Deskew PDF',
'PDF Layers',
trText('PDF to Text'),
trText('PDF to Markdown'),
trText('PDF to SVG'),
trText('PDF to Images (High Quality)'),
trText('PDF to DOCX'),
trText('PDF to Excel/CSV'),
trText('Extract Images'),
trText('Extract Tables'),
trText('EPUB/MOBI/FB2/XPS/CBZ to PDF'),
trText('Deskew PDF'),
trText('PDF Layers'),
],
ghostscript: ['PDF/A Conversion', 'Font to Outline'],
ghostscript: [trText('PDF/A Conversion'), trText('Font to Outline')],
cpdf: [
'Merge PDF',
'Alternate Merge',
'Split by Bookmarks',
'Table of Contents',
'PDF to JSON',
'JSON to PDF',
'Add/Edit/Extract Attachments',
'Edit Bookmarks',
'PDF Metadata',
trText('Merge PDF'),
trText('Alternate Merge'),
trText('Split by Bookmarks'),
trText('Table of Contents'),
trText('PDF to JSON'),
trText('JSON to PDF'),
trText('Add/Edit/Extract Attachments'),
trText('Edit Bookmarks'),
trText('PDF Metadata'),
],
};
return features[packageName];
@@ -389,17 +401,17 @@ export function showWasmRequiredDialog(
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-white">Advanced Feature Required</h3>
<p class="text-sm text-gray-400">External processing module needed</p>
<h3 class="text-lg font-semibold text-white">${trHtml('Advanced Feature Required')}</h3>
<p class="text-sm text-gray-400">${trHtml('External processing module needed')}</p>
</div>
</div>
<p class="text-gray-300 mb-4">
This feature requires <strong class="text-white">${displayName}</strong> to be configured.
${trHtml('This feature requires {{name}} to be configured.', { name: '\u0000' }).replace('\u0000', `<strong class="text-white">${displayName}</strong>`)}
</p>
<div class="bg-gray-700/50 rounded-lg p-4 mb-4">
<p class="text-sm text-gray-400 mb-2">Features enabled by this module:</p>
<p class="text-sm text-gray-400 mb-2">${trHtml('Features enabled by this module:')}</p>
<ul class="text-sm text-gray-300 space-y-1">
${features
.slice(0, 4)
@@ -408,21 +420,21 @@ export function showWasmRequiredDialog(
`<li class="flex items-center gap-2"><span class="text-green-400">✓</span> ${f}</li>`
)
.join('')}
${features.length > 4 ? `<li class="text-gray-500">+ ${features.length - 4} more...</li>` : ''}
${features.length > 4 ? `<li class="text-gray-500">${trHtml('+ {{n}} more...', { n: features.length - 4 })}</li>` : ''}
</ul>
</div>
<p class="text-xs text-gray-500 mb-4">
This module is licensed under AGPL-3.0. By configuring it, you agree to its license terms.
${trHtml('This module is licensed under AGPL-3.0. By configuring it, you agree to its license terms.')}
</p>
</div>
<div class="border-t border-gray-700 p-4 flex gap-3">
<button id="wasm-modal-cancel" class="flex-1 px-4 py-2.5 rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors font-medium">
Cancel
${trHtml('Cancel')}
</button>
<button id="wasm-modal-configure" class="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 text-white hover:from-blue-500 hover:to-blue-400 transition-all font-medium">
Configure
${trHtml('Configure')}
</button>
</div>
`;
+8 -6
View File
@@ -2,6 +2,8 @@ import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { XMLParser } from 'fast-xml-parser';
import { trText } from '../i18n/i18n.js';
export interface XmlToPdfOptions {
onProgress?: (percent: number, message: string) => void;
}
@@ -19,14 +21,14 @@ export async function convertXmlToPdf(
): Promise<Blob> {
const { onProgress } = options || {};
onProgress?.(10, 'Reading XML file...');
onProgress?.(10, trText('Reading XML file...'));
const rawXmlText = await file.text();
const xmlText = String(rawXmlText)
.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
.replace(/<!ENTITY[\s\S]*?>/gi, '')
.replace(/<\?xml-stylesheet[\s\S]*?\?>/gi, '');
onProgress?.(30, 'Parsing XML structure...');
onProgress?.(30, trText('Parsing XML structure...'));
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: ATTR_PREFIX,
@@ -50,12 +52,12 @@ export async function convertXmlToPdf(
const rootKeys = Object.keys(parsed);
if (rootKeys.length === 0) {
throw new Error('Invalid XML: no root element');
throw new Error(trText('Invalid XML: no root element'));
}
const rootName = rootKeys[0];
const rootValue = parsed[rootName];
onProgress?.(50, 'Analyzing data structure...');
onProgress?.(50, trText('Analyzing data structure...'));
const doc: jsPDFWithAutoTable = new jsPDF({
orientation: 'landscape',
@@ -73,7 +75,7 @@ export async function convertXmlToPdf(
});
yPosition += 15;
onProgress?.(60, 'Generating formatted content...');
onProgress?.(60, trText('Generating formatted content...'));
if (isPlainObject(rootValue)) {
const rootObj = rootValue;
@@ -155,7 +157,7 @@ export async function convertXmlToPdf(
}
}
onProgress?.(90, 'Finalizing PDF...');
onProgress?.(90, trText('Finalizing PDF...'));
const pdfBlob = doc.output('blob');