mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-12 09:34:01 +01:00
feat: translate all pages, UI messages and docs and fix SEO redirects and indexing
This commit is contained in:
1 parent
5e019de5ae
commit
4edb1718cc
3344 files changed
+415643
-4759
No files matched your search
@@ -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>');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in new issue
Block a user