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

+3 -1
View File
@@ -8,6 +8,8 @@ import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import type { CropBox } from '@/types';
import './utils/setup-pdf-worker.js';
import { trText } from './i18n/i18n.js';
const editorState: {
pdf: PDFDocumentProxy | null;
canvas: HTMLCanvasElement | null;
@@ -61,7 +63,7 @@ function calculateFitScale(page: PDFPageProxy) {
*/
async function renderPage(num: number) {
editorState.pageRendering = true;
showLoader(`Loading page ${num}...`);
showLoader(trText('Loading page {{num}}...', { num }));
try {
const page = await editorState.pdf!.getPage(num);
+20 -4
View File
@@ -7,6 +7,8 @@ import type {
CompareTextChange,
} from '../types.ts';
import { trText } from '../../i18n/i18n.js';
const HEADER_FOOTER_ZONE = 0.12;
export function classifyChangeCategory(
@@ -130,7 +132,15 @@ export function diffImages(
id: `image-modified-${idx++}`,
type: 'modified',
category: 'image',
description: `Image resized from ${bImg.width}×${bImg.height} to ${match.width}×${match.height}`,
description: trText(
'Image resized from {{width}}×{{height}} to {{width2}}×{{height2}}',
{
width: bImg.width,
height: bImg.height,
width2: match.width,
height2: match.height,
}
),
beforeText: `${bImg.width}×${bImg.height}`,
afterText: `${match.width}×${match.height}`,
beforeRects: [bImg.rect],
@@ -142,7 +152,10 @@ export function diffImages(
id: `image-removed-${idx++}`,
type: 'removed',
category: 'image',
description: `Removed image (${bImg.width}×${bImg.height})`,
description: trText('Removed image ({{width}}×{{height}})', {
width: bImg.width,
height: bImg.height,
}),
beforeText: '',
afterText: '',
beforeRects: [bImg.rect],
@@ -157,7 +170,10 @@ export function diffImages(
id: `image-added-${idx++}`,
type: 'added',
category: 'image',
description: `Added image (${aImg.width}×${aImg.height})`,
description: trText('Added image ({{width}}×{{height}})', {
width: aImg.width,
height: aImg.height,
}),
beforeText: '',
afterText: '',
beforeRects: [],
@@ -207,7 +223,7 @@ export function detectBackgroundChanges(
id: `background-changed-${baseId}`,
type: 'modified',
category: 'background',
description: 'Page background or layout changed',
description: trText('Page background or layout changed'),
beforeText: '',
afterText: '',
beforeRects: [
+14 -4
View File
@@ -4,7 +4,7 @@ import type {
CompareCategorySummary,
} from '../types.ts';
import { diffTextRuns } from './diff-text-runs.ts';
import { diffTextRunsAsync } from '../worker-api.ts';
import { diffStrings, diffTextRunsAsync } from '../worker-api.ts';
import {
classifyChangeCategory,
diffAnnotations,
@@ -12,6 +12,8 @@ import {
buildCategorySummary,
} from './compare-content.ts';
import { trText } from '../../i18n/i18n.js';
const EMPTY_CATEGORY_SUMMARY: CompareCategorySummary = {
text: 0,
image: 0,
@@ -54,7 +56,10 @@ function comparePageModelsCore(
id: 'page-removed',
type: 'page-removed',
category: 'text',
description: `Page ${leftPage.pageNumber} exists only in the first PDF.`,
description: trText(
'Page {{pageNumber}} exists only in the first PDF.',
{ pageNumber: leftPage.pageNumber }
),
beforeText: leftPage.plainText.slice(0, 200),
afterText: '',
beforeRects: [],
@@ -78,7 +83,10 @@ function comparePageModelsCore(
id: 'page-added',
type: 'page-added',
category: 'text',
description: `Page ${rightPage.pageNumber} exists only in the second PDF.`,
description: trText(
'Page {{pageNumber}} exists only in the second PDF.',
{ pageNumber: rightPage.pageNumber }
),
beforeText: '',
afterText: rightPage.plainText.slice(0, 200),
beforeRects: [],
@@ -150,5 +158,7 @@ function comparePageModelsCore(
);
}
return buildResult(diffTextRuns(leftPage.textItems, rightPage.textItems));
return buildResult(
diffTextRuns(leftPage.textItems, rightPage.textItems, diffStrings())
);
}
+9 -2
View File
@@ -13,6 +13,7 @@ interface DiffMessage {
id: number;
beforeItems: CompareTextItem[];
afterItems: CompareTextItem[];
strings: Record<string, string>;
}
interface PairMessage {
@@ -48,7 +49,12 @@ function isValidMessage(data: unknown): data is WorkerMessage {
const m = data as Record<string, unknown>;
if (typeof m.id !== 'number') return false;
if (m.type === 'diff') {
return Array.isArray(m.beforeItems) && Array.isArray(m.afterItems);
return (
Array.isArray(m.beforeItems) &&
Array.isArray(m.afterItems) &&
typeof m.strings === 'object' &&
m.strings !== null
);
}
if (m.type === 'pair') {
return Array.isArray(m.leftPages) && Array.isArray(m.rightPages);
@@ -68,7 +74,8 @@ self.onmessage = function (e: MessageEvent<unknown>) {
if (msg.type === 'diff') {
const { changes, summary } = diffTextRuns(
msg.beforeItems,
msg.afterItems
msg.afterItems,
msg.strings
);
const result: DiffResult = {
type: 'diff',
+34 -8
View File
@@ -15,6 +15,15 @@ import {
} from './text-normalization.ts';
import { COMPARE_GEOMETRY } from '../config.ts';
import { fillPlaceholders } from '../../i18n/placeholders.js';
let activeStrings: Record<string, string> = {};
const describeChange = (
template: string,
params: Record<string, string | number>
): string => fillPlaceholders(activeStrings[template] ?? template, params);
interface WordToken {
word: string;
compareWord: string;
@@ -174,7 +183,13 @@ function createWordChange(
id,
type,
category: 'text',
description: `Replaced "${beforeText}" with "${afterText}"`,
description: describeChange(
'Replaced "{{beforeText}}" with "{{afterText}}"',
{
beforeText,
afterText,
}
),
beforeText,
afterText,
beforeRects,
@@ -185,7 +200,7 @@ function createWordChange(
id,
type,
category: 'text',
description: `Removed "${beforeText}"`,
description: describeChange('Removed "{{beforeText}}"', { beforeText }),
beforeText,
afterText: '',
beforeRects,
@@ -196,7 +211,7 @@ function createWordChange(
id,
type,
category: 'text',
description: `Added "${afterText}"`,
description: describeChange('Added "{{afterText}}"', { afterText }),
beforeText: '',
afterText,
beforeRects: [],
@@ -221,8 +236,10 @@ function toSummary(changes: CompareTextChange[]): CompareChangeSummary {
export function diffTextRuns(
beforeItems: CompareTextItem[],
afterItems: CompareTextItem[]
afterItems: CompareTextItem[],
strings: Record<string, string> = {}
) {
activeStrings = strings;
const beforeWords = beforeItems.flatMap(splitLineIntoWords);
const afterWords = afterItems.flatMap(splitLineIntoWords);
@@ -383,11 +400,18 @@ function detectStyleChanges(
const allBeforeRects = groupFrags.flatMap((f) => f.beforeRects);
const allAfterRects = groupFrags.flatMap((f) => f.afterRects);
let desc = `Style changed (${groupFrags.length} regions)`;
let desc = describeChange('Style changed ({{length}} regions)', {
length: groupFrags.length,
});
const details: string[] = [];
if (bFont !== aFont) details.push(`Font: ${bFont} → ${aFont}`);
if (bFont !== aFont)
details.push(
describeChange('Font: {{bFont}} → {{aFont}}', { bFont, aFont })
);
if (bSize && aSize && Math.abs(bSize - aSize) > 0.5)
details.push(`Font size: ${bSize} → ${aSize}`);
details.push(
describeChange('Font size: {{bSize}} → {{aSize}}', { bSize, aSize })
);
if (details.length) desc += '\n' + details.map((d) => `• ${d}`).join('\n');
changes.push({
@@ -457,7 +481,9 @@ function detectMovedText(changes: CompareTextChange[]) {
id: `moved-${changes.length}`,
type: 'moved',
category: 'text',
description: `Moved "${rem.beforeText.slice(0, 80)}"`,
description: describeChange('Moved "{{value}}"', {
value: rem.beforeText.slice(0, 80),
}),
beforeText: rem.beforeText,
afterText: bestMatch.afterText,
beforeRects: rem.beforeRects,
+5 -3
View File
@@ -3,6 +3,8 @@ import pixelmatch from 'pixelmatch';
import type { CompareVisualDiff } from '../types.ts';
import { VISUAL_DIFF as VISUAL_DIFF_CONFIG } from '../config.ts';
import { trText } from '../../i18n/i18n.js';
type FocusRegion = {
x: number;
y: number;
@@ -23,7 +25,7 @@ function drawNormalized(
) {
const context = targetCanvas.getContext('2d');
if (!context) {
throw new Error('Could not create comparison canvas context.');
throw new Error(trText('Could not create comparison canvas context.'));
}
context.fillStyle = '#ffffff';
@@ -56,7 +58,7 @@ export function renderVisualDiff(
const outputContext = outputCanvas.getContext('2d');
if (!context1 || !context2 || !outputContext) {
throw new Error('Could not create visual diff context.');
throw new Error(trText('Could not create visual diff context.'));
}
const image1 = context1.getImageData(0, 0, width, height);
@@ -87,7 +89,7 @@ export function renderVisualDiff(
const overlayContext = overlayCanvas.getContext('2d');
if (!overlayContext) {
throw new Error('Could not create visual diff overlay context.');
throw new Error(trText('Could not create visual diff overlay context.'));
}
overlayContext.putImageData(diffImage, 0, 0);
+10 -3
View File
@@ -17,6 +17,8 @@ import { computeComparisonForPair } from '../../logic/compare-render.ts';
import { LRUCache } from '../lru-cache.ts';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import { trText } from '../../i18n/i18n.js';
const HIGHLIGHT_COLORS: Record<
string,
{ r: number; g: number; b: number; opacity: number }
@@ -99,10 +101,12 @@ export async function exportComparePdf(
}
) {
if (!pdfDoc1 && !pdfDoc2) {
throw new Error('At least one PDF document is required for export.');
throw new Error(
trText('At least one PDF document is required for export.')
);
}
if (!pairs || pairs.length === 0) {
throw new Error('No page pairs to export.');
throw new Error(trText('No page pairs to export.'));
}
const outDoc = await PDFDocument.create();
@@ -139,7 +143,10 @@ export async function exportComparePdf(
for (let i = 0; i < pairs.length; i++) {
const pair = pairs[i];
onProgress?.(
`Rendering page ${i + 1} of ${pairs.length}...`,
trText('Rendering page {{n}} of {{length}}...', {
n: i + 1,
length: pairs.length,
}),
Math.round(((i + 1) / pairs.length) * 100)
);
+29 -3
View File
@@ -7,6 +7,31 @@ import type {
} from './types.ts';
import { diffTextRuns } from './engine/diff-text-runs.ts';
import { pairPages } from './engine/pair-pages.ts';
import { trText } from '../i18n/i18n.js';
export function diffStrings(): Record<string, string> {
const strings: Record<string, string> = {};
const add = (english: string, translated: string) => {
strings[english] = translated;
};
add(
'Replaced "{{beforeText}}" with "{{afterText}}"',
trText('Replaced "{{beforeText}}" with "{{afterText}}"')
);
add('Removed "{{beforeText}}"', trText('Removed "{{beforeText}}"'));
add('Added "{{afterText}}"', trText('Added "{{afterText}}"'));
add(
'Style changed ({{length}} regions)',
trText('Style changed ({{length}} regions)')
);
add('Font: {{bFont}} → {{aFont}}', trText('Font: {{bFont}} → {{aFont}}'));
add(
'Font size: {{bSize}} → {{aSize}}',
trText('Font size: {{bSize}} → {{aSize}}')
);
add('Moved "{{value}}"', trText('Moved "{{value}}"'));
return strings;
}
let worker: Worker | null = null;
let messageId = 0;
@@ -37,7 +62,7 @@ function getWorker(): Worker | null {
worker?.terminate();
worker = null;
for (const [, p] of pending) {
p.reject(new Error('Worker crashed'));
p.reject(new Error(trText('Worker crashed')));
}
pending.clear();
};
@@ -49,7 +74,7 @@ function getWorker(): Worker | null {
function postToWorker(msg: Record<string, unknown>): Promise<unknown> {
const w = getWorker();
if (!w) return Promise.reject(new Error('No worker'));
if (!w) return Promise.reject(new Error(trText('No worker')));
const id = ++messageId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
@@ -66,10 +91,11 @@ export async function diffTextRunsAsync(
type: 'diff',
beforeItems,
afterItems,
strings: diffStrings(),
})) as { changes: CompareTextChange[]; summary: CompareChangeSummary };
return result;
} catch {
return diffTextRuns(beforeItems, afterItems);
return diffTextRuns(beforeItems, afterItems, diffStrings());
}
}
+33 -17
View File
@@ -31,6 +31,8 @@ import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, trHtml } from '../i18n/i18n.js';
export {
getRotationState,
updateRotationState,
@@ -278,7 +280,7 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
}
};
const infoSection = createSection('Info Dictionary');
const infoSection = createSection(trText('Info Dictionary'));
if (info && Object.keys(info).length > 0) {
for (const key in info) {
const value = (info as Record<string, unknown>)[key];
@@ -310,11 +312,11 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
infoSection.ul.appendChild(createListItem(key, displayValue));
}
} else {
infoSection.ul.innerHTML = `<li><span class="text-gray-500 italic">- No Info Dictionary data found -</span></li>`;
infoSection.ul.innerHTML = `<li><span class="text-gray-500 italic">${trHtml('- No Info Dictionary data found -')}</span></li>`;
}
resultsDiv.appendChild(infoSection.wrapper);
const fieldsSection = createSection('Interactive Form Fields');
const fieldsSection = createSection(trText('Interactive Form Fields'));
if (fieldObjects && Object.keys(fieldObjects).length > 0) {
for (const fieldName in fieldObjects) {
const field = fieldObjects[fieldName][0] as Record<string, unknown>;
@@ -324,7 +326,7 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
);
}
} else {
fieldsSection.ul.innerHTML = `<li><span class="text-gray-500 italic">- No interactive form fields found -</span></li>`;
fieldsSection.ul.innerHTML = `<li><span class="text-gray-500 italic">${trHtml('- No interactive form fields found -')}</span></li>`;
}
resultsDiv.appendChild(fieldsSection.wrapper);
@@ -412,7 +414,7 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
}
};
const xmpSection = createSection('XMP Metadata');
const xmpSection = createSection(trText('XMP Metadata'));
if (rawXmpString) {
try {
const parser = new DOMParser();
@@ -431,11 +433,11 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
}
if (xmpSection.ul.children.length === 0) {
xmpSection.ul.innerHTML = `<li><span class="text-gray-500 italic">- No parseable XMP properties found -</span></li>`;
xmpSection.ul.innerHTML = `<li><span class="text-gray-500 italic">${trHtml('- No parseable XMP properties found -')}</span></li>`;
}
} catch (xmlError) {
console.error('Failed to parse XMP XML:', xmlError);
xmpSection.ul.innerHTML = `<li><span class="text-red-500 italic">- Error parsing XMP XML. Displaying raw. -</span></li>`;
xmpSection.ul.innerHTML = `<li><span class="text-red-500 italic">${trHtml('- Error parsing XMP XML. Displaying raw. -')}</span></li>`;
const pre = document.createElement('pre');
pre.className =
'text-xs text-gray-300 whitespace-pre-wrap break-all';
@@ -443,7 +445,7 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
xmpSection.ul.appendChild(pre);
}
} else {
xmpSection.ul.innerHTML = `<li><span class="text-gray-500 italic">- No XMP metadata found -</span></li>`;
xmpSection.ul.innerHTML = `<li><span class="text-gray-500 italic">${trHtml('- No XMP metadata found -')}</span></li>`;
}
resultsDiv.appendChild(xmpSection.wrapper);
@@ -495,13 +497,13 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.placeholder = 'Key (e.g., Department)';
keyInput.placeholder = trText('Key (e.g., Department)');
keyInput.className =
'custom-meta-key w-full sm:w-1/3 bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.placeholder = 'Value (e.g., Marketing)';
valueInput.placeholder = trText('Value (e.g., Marketing)');
valueInput.className =
'custom-meta-value w-full sm:flex-grow bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
@@ -539,7 +541,9 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
const qualityValue = document.getElementById('jpg-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
@@ -553,7 +557,9 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
const qualityValue = document.getElementById('png-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${qualitySlider.value}x`;
qualityValue.textContent = trText('{{value}}x', {
value: qualitySlider.value,
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
@@ -567,7 +573,9 @@ async function handleSinglePdfUpload(toolId: string, file: File) {
const qualityValue = document.getElementById('webp-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
@@ -768,7 +776,9 @@ async function handleMultiFileUpload(toolId: string) {
const value = document.getElementById('image-pdf-quality-value');
if (slider && value) {
const update = () =>
(value.textContent = `${Math.round(parseFloat(slider.value) * 100)}%`);
(value.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(slider.value) * 100),
}));
slider.addEventListener('input', update);
update();
}
@@ -782,7 +792,9 @@ async function handleMultiFileUpload(toolId: string) {
const qualityValue = document.getElementById('jpg-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
@@ -796,7 +808,9 @@ async function handleMultiFileUpload(toolId: string) {
const qualityValue = document.getElementById('png-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${qualitySlider.value}x`;
qualityValue.textContent = trText('{{value}}x', {
value: qualitySlider.value,
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
@@ -810,7 +824,9 @@ async function handleMultiFileUpload(toolId: string) {
const qualityValue = document.getElementById('webp-quality-value');
if (qualitySlider && qualityValue) {
const updateValue = () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
};
qualitySlider.addEventListener('input', updateValue);
updateValue();
+87 -37
View File
@@ -1,6 +1,7 @@
import i18next from 'i18next';
import HttpBackend from 'i18next-http-backend';
import { getStoredItem, setStoredItem } from '../utils/safe-storage.js';
import { setStoredItem } from '../utils/safe-storage.js';
import { fillPlaceholders } from './placeholders.js';
import enCommon from '../../../public/locales/en/common.json';
import enTools from '../../../public/locales/en/tools.json';
@@ -82,50 +83,21 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
return langMatch[1] as SupportedLanguage;
}
const storedLang = getStoredItem('i18nextLng');
if (
storedLang &&
supportedLanguages.includes(storedLang as SupportedLanguage)
) {
return storedLang as SupportedLanguage;
}
// Check browser language preferences
if (typeof navigator !== 'undefined' && navigator.languages) {
for (const lang of navigator.languages) {
if (supportedLanguages.includes(lang as SupportedLanguage)) {
return lang as SupportedLanguage;
}
const primaryLang = lang.split('-')[0];
if (supportedLanguages.includes(primaryLang as SupportedLanguage)) {
return primaryLang as SupportedLanguage;
}
}
}
const envLang = import.meta.env?.VITE_DEFAULT_LANGUAGE;
if (envLang && supportedLanguages.includes(envLang as SupportedLanguage)) {
return envLang as SupportedLanguage;
}
return 'en';
};
let initialized = false;
export const LANGUAGE_CHOICE_KEY = 'bentopdf-language';
export const initI18n = async (): Promise<typeof i18next> => {
if (initialized) return i18next;
let initPromise: Promise<typeof i18next> | null = null;
const runInit = async (): Promise<typeof i18next> => {
const currentLang = getLanguageFromUrl();
setStoredItem('i18nextLng', currentLang);
await i18next.use(HttpBackend).init({
lng: currentLang,
fallbackLng: 'en',
supportedLngs: supportedLanguages as unknown as string[],
ns: ['common', 'tools'],
ns: currentLang === 'en' ? ['common', 'tools'] : ['common', 'tools', 'ui'],
defaultNS: 'common',
preload: [currentLang],
partialBundledLanguages: true,
@@ -133,6 +105,7 @@ export const initI18n = async (): Promise<typeof i18next> => {
en: {
common: enCommon,
tools: enTools,
ui: {},
},
},
backend: {
@@ -144,18 +117,87 @@ export const initI18n = async (): Promise<typeof i18next> => {
});
await i18next.loadNamespaces('tools');
if (currentLang !== 'en') {
try {
await i18next.loadNamespaces('ui');
} catch (error) {
console.warn('[i18n] Could not load interface messages:', error);
}
}
initialized = true;
return i18next;
};
export const initI18n = (): Promise<typeof i18next> => {
initPromise ??= runInit();
return initPromise;
};
const domReady = (): Promise<void> =>
document.readyState === 'loading'
? new Promise((resolve) =>
document.addEventListener('DOMContentLoaded', () => resolve(), {
once: true,
})
)
: Promise.resolve();
const I18N_READY_TIMEOUT_MS = 5000;
export const onPageReady = (callback: () => void | Promise<void>): void => {
const i18nReady = Promise.race([
initI18n().then(
(): void => undefined,
(): void => undefined
),
new Promise<void>((resolve) => setTimeout(resolve, I18N_READY_TIMEOUT_MS)),
]);
const holdDrop = (event: DragEvent) => event.preventDefault();
window.addEventListener('dragover', holdDrop);
window.addEventListener('drop', holdDrop);
void Promise.all([domReady(), i18nReady]).then(() => {
window.removeEventListener('dragover', holdDrop);
window.removeEventListener('drop', holdDrop);
return callback();
});
};
export const t = (key: string, options?: Record<string, unknown>): string => {
return i18next.t(key, options);
};
export const hasKey = (key: string): boolean => i18next.exists(key);
export const trText = (
text: string,
params?: Record<string, string | number>
): string => {
const language = i18next.language;
let template = text;
if (i18next.isInitialized && language && language !== 'en') {
const translated = i18next.getResource(language, 'ui', text, {
keySeparator: false,
});
if (typeof translated === 'string' && translated) template = translated;
}
return fillPlaceholders(template, params);
};
const escapeForHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
export const trHtml = (
text: string,
params?: Record<string, string | number>
): string => escapeForHtml(trText(text, params));
export const changeLanguage = (lang: SupportedLanguage): void => {
if (!supportedLanguages.includes(lang)) return;
setStoredItem('i18nextLng', lang);
setStoredItem(LANGUAGE_CHOICE_KEY, lang);
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
let relativePath = window.location.pathname;
@@ -269,8 +311,16 @@ export const rewriteLinks = (): void => {
return;
}
const pathOnly =
basePath && basePath !== '/' && href.startsWith(`${basePath}/`)
? href.slice(basePath.length)
: href;
if (/^\/(blog|docs)(\/|$|[?#])/.test(pathOnly)) {
return;
}
const langPrefixRegex = new RegExp(
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(/|$)`
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(/|$|[?#])`
);
if (langPrefixRegex.test(href)) {
return;
+3
View File
@@ -1,6 +1,9 @@
export {
initI18n,
onPageReady,
t,
trText,
trHtml,
changeLanguage,
applyTranslations,
rewriteLinks,
+14 -20
View File
@@ -51,10 +51,16 @@ export const createLanguageSwitcher = (): HTMLElement => {
searchWrapper.className =
'p-2 border-b border-gray-700 bg-gray-800 flex-shrink-0';
const searchPlaceholder =
t('nav.searchLanguage') !== 'nav.searchLanguage'
? t('nav.searchLanguage')
: 'Search language…';
const translated = (key: string, fallback: string): string => {
const value = t(key);
return typeof value === 'string' && value && value !== key
? value
: fallback;
};
const searchPlaceholder = translated(
'nav.searchLanguage',
'Search language…'
);
const searchInput = document.createElement('input');
searchInput.type = 'search';
@@ -76,10 +82,7 @@ export const createLanguageSwitcher = (): HTMLElement => {
const emptyState = document.createElement('p');
emptyState.className = 'hidden px-4 py-3 text-sm text-gray-400 text-center';
const emptyText =
t('nav.noLanguagesFound') !== 'nav.noLanguagesFound'
? t('nav.noLanguagesFound')
: 'No languages found';
const emptyText = translated('nav.noLanguagesFound', 'No languages found');
emptyState.textContent = emptyText;
const options: HTMLButtonElement[] = [];
@@ -176,18 +179,9 @@ export const injectLanguageSwitcher = (): void => {
const footer = document.querySelector('footer');
if (!footer) return;
const headings = footer.querySelectorAll('h3');
let followUsColumn: HTMLElement | null = null;
headings.forEach((h3) => {
if (
h3.textContent?.trim() === 'Follow Us' ||
h3.textContent?.trim() === 'Folgen Sie uns' ||
h3.textContent?.trim() === 'Theo dõi chúng tôi'
) {
followUsColumn = h3.parentElement;
}
});
const followUsHeading = footer.querySelector('[data-i18n="footer.followUs"]');
const followUsColumn: HTMLElement | null =
followUsHeading?.parentElement ?? null;
if (followUsColumn) {
const socialIconsContainer = followUsColumn.querySelector('.space-x-4');
+11
View File
@@ -0,0 +1,11 @@
export const fillPlaceholders = (
text: string,
params?: Record<string, string | number>
): string => {
if (!params) return text;
return text.replace(/\{\{(\w+)\}\}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(params, name)
? String(params[name])
: match
);
};
+61
View File
@@ -0,0 +1,61 @@
(function () {
var script = document.currentScript;
if (!script) return;
var base = (script.getAttribute('data-base') || '/').replace(/\/?$/, '/');
var languages = (script.getAttribute('data-languages') || '').split(',');
var fallback = script.getAttribute('data-default-language') || 'en';
var path = window.location.pathname;
if (path + '/' === base) path = base;
if (path.indexOf(base) !== 0) return;
var rest = path.slice(base.length);
var first = rest.split('/')[0];
if (first === 'en' || languages.indexOf(first) !== -1) return;
if (/^(blog|docs)(\/|$)/.test(rest)) return;
var choice;
try {
choice = window.localStorage.getItem('bentopdf-language');
} catch {
choice = null;
}
function supported(code) {
var lower = code.toLowerCase();
if (/^zh-(tw|hk|mo|hant)/.test(lower)) return 'zh-TW';
var primary = lower.split('-')[0];
if (primary === 'en') return 'en';
return languages.indexOf(primary) !== -1 ? primary : null;
}
function cameFromThisSite() {
var origin = window.location.origin + '/';
return document.referrer.slice(0, origin.length) === origin;
}
function detect() {
var preferred = window.navigator.languages || [window.navigator.language];
for (var i = 0; i < preferred.length; i++) {
if (!preferred[i]) continue;
var match = supported(preferred[i]);
if (match) return match;
}
return languages.indexOf(fallback) !== -1 ? fallback : 'en';
}
var target = null;
if (choice) {
if (languages.indexOf(choice) !== -1) target = choice;
} else if ((rest === '' || rest === 'index.html') && !cameFromThisSite()) {
target = detect();
}
if (!target || target === 'en') return;
var page = rest === 'index.html' ? '' : rest;
window.location.replace(
base +
target +
(page ? '/' + page : '') +
window.location.search +
window.location.hash
);
})();
+21 -6
View File
@@ -10,6 +10,8 @@ import {
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const worker = new Worker(
import.meta.env.BASE_URL + 'workers/add-attachments.worker.js'
);
@@ -72,7 +74,9 @@ worker.onmessage = function (e) {
showAlert(
'Success',
`${pageState.attachments.length} file(s) attached successfully.`,
trText('{{length}} file(s) attached successfully.', {
length: pageState.attachments.length,
}),
'success',
function () {
resetState();
@@ -112,7 +116,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -140,7 +146,10 @@ async function updateUI() {
pageState.pdfDoc = await loadPdfDocument(result.bytes);
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
const totalPagesSpan = document.getElementById('attachment-total-pages');
if (totalPagesSpan) totalPagesSpan.textContent = pageCount.toString();
@@ -249,7 +258,11 @@ async function addAttachments() {
for (let i = 0; i < pageState.attachments.length; i++) {
const file = pageState.attachments[i];
showLoader(
`Reading ${file.name} (${i + 1}/${pageState.attachments.length})...`
trText('Reading {{name}} ({{n}}/{{length}})...', {
name: file.name,
n: i + 1,
length: pageState.attachments.length,
})
);
const fileBuffer = await file.arrayBuffer();
@@ -276,7 +289,9 @@ async function addAttachments() {
hideLoader();
showAlert(
'Error',
`Failed to attach files: ${error instanceof Error ? error.message : 'Unknown error'}`
trText('Failed to attach files: {{error}}', {
error: error instanceof Error ? error.message : trText('Unknown error'),
})
);
}
}
@@ -301,7 +316,7 @@ function handleAttachmentSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const attachmentInput = document.getElementById(
+30 -8
View File
@@ -6,6 +6,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { AddBlankPageState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: AddBlankPageState = {
file: null,
pdfDoc: null,
@@ -61,7 +63,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -90,10 +94,16 @@ async function updateUI() {
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
if (pagePositionHint) {
pagePositionHint.textContent = `Enter 0 to insert at the beginning, or ${pageCount} to insert at the end.`;
pagePositionHint.textContent = trText(
'Enter 0 to insert at the beginning, or {{pageCount}} to insert at the end.',
{ pageCount }
);
}
if (pagePositionInput) {
pagePositionInput.max = pageCount.toString();
@@ -131,7 +141,9 @@ async function addBlankPages() {
if (isNaN(position) || position < 0 || position > totalPages) {
showAlert(
'Invalid Input',
`Please enter a number between 0 and ${totalPages}.`
trText('Please enter a number between 0 and {{totalPages}}.', {
totalPages,
})
);
return;
}
@@ -145,7 +157,9 @@ async function addBlankPages() {
}
showLoader(
`Adding ${insertCount} blank page${insertCount > 1 ? 's' : ''}...`
insertCount > 1
? trText('Adding {{insertCount}} blank pages...', { insertCount })
: trText('Adding {{insertCount}} blank page...', { insertCount })
);
try {
@@ -185,7 +199,13 @@ async function addBlankPages() {
showAlert(
'Success',
`Added ${insertCount} blank page${insertCount > 1 ? 's' : ''} successfully!`,
insertCount > 1
? trText('Added {{insertCount}} blank pages successfully!', {
insertCount,
})
: trText('Added {{insertCount}} blank page successfully!', {
insertCount,
}),
'success',
function () {
resetState();
@@ -195,7 +215,9 @@ async function addBlankPages() {
console.error(e);
showAlert(
'Error',
`Could not add blank page${insertCount > 1 ? 's' : ''}.`
insertCount > 1
? trText('Could not add blank pages.')
: trText('Could not add blank page.')
);
} finally {
hideLoader();
@@ -215,7 +237,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+2 -6
View File
@@ -6,7 +6,7 @@ import type {
CpdfInstance,
} from '@/types';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { t } from '../i18n/index.js';
import { t, onPageReady } from '../i18n/index.js';
import {
downloadFile,
formatBytes,
@@ -47,11 +47,7 @@ const pageState: AddPageLabelsState = {
rules: [createLabelRule()],
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function createLabelRule(overrides: Partial<LabelRule> = {}): LabelRule {
labelRuleCounter += 1;
+17 -8
View File
@@ -7,6 +7,9 @@ import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { createIcons, icons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { trText } from '../i18n/i18n.js';
import { getStoredItem, setStoredItem } from '../utils/safe-storage.js';
let selectedFile: File | null = null;
let viewerIframe: HTMLIFrameElement | null = null;
let currentBlobUrl: string | null = null;
@@ -55,7 +58,7 @@ function resetState() {
viewerContainer.style.aspectRatio = '';
}
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const isFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl');
toolUploader.classList.add('max-w-2xl');
@@ -76,7 +79,7 @@ function updateFileList() {
fileListDiv.innerHTML = '';
// Expand container width for viewer if NOT in full width mode (default to true if not set)
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const isFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl');
toolUploader.classList.add('max-w-6xl');
@@ -105,7 +108,7 @@ function updateFileList() {
const deleteBtn = document.createElement('button');
deleteBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
deleteBtn.title = 'Remove file';
deleteBtn.title = trText('Remove file');
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
deleteBtn.onclick = (e) => {
e.stopPropagation();
@@ -165,14 +168,14 @@ async function loadPdfInViewer(file: File) {
currentBlobUrl = URL.createObjectURL(blob);
try {
const existingPrefsRaw = localStorage.getItem('pdfjs.preferences');
const existingPrefsRaw = getStoredItem('pdfjs.preferences');
const existingPrefs = existingPrefsRaw ? JSON.parse(existingPrefsRaw) : {};
delete (existingPrefs as Record<string, unknown>).annotationEditorMode;
const newPrefs = {
...existingPrefs,
enablePermissions: false,
};
localStorage.setItem('pdfjs.preferences', JSON.stringify(newPrefs));
setStoredItem('pdfjs.preferences', JSON.stringify(newPrefs));
} catch (e) {
console.warn('Failed to update pdfjs.preferences in localStorage', e);
}
@@ -301,7 +304,9 @@ if (saveStampedBtn) {
saveStampedBtn.addEventListener('click', () => {
if (!viewerIframe) {
alert(
'Viewer not ready. Please upload a PDF and wait for it to finish loading.'
trText(
'Viewer not ready. Please upload a PDF and wait for it to finish loading.'
)
);
return;
}
@@ -338,12 +343,16 @@ if (saveStampedBtn) {
}
alert(
'Could not access the stamped-PDF exporter. Please use the Export → PDF button in the viewer toolbar as a fallback.'
trText(
'Could not access the stamped-PDF exporter. Please use the Export → PDF button in the viewer toolbar as a fallback.'
)
);
} catch (e) {
console.error('Failed to trigger stamped PDF export:', e);
alert(
'Could not export the stamped PDF. Please use the Export → PDF button in the viewer toolbar as a fallback.'
trText(
'Could not export the stamped PDF. Please use the Export → PDF button in the viewer toolbar as a fallback.'
)
);
}
});
+24 -11
View File
@@ -23,6 +23,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: AddWatermarkState = {
file: null,
pdfDoc: null,
@@ -58,11 +60,7 @@ let cachedPdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null;
const pageWatermarks: Map<number, PageWatermarkConfig> = new Map();
let applyToAllPages = true;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -145,7 +143,10 @@ async function handleFiles(files: FileList) {
const editorFileInfo = document.getElementById('editor-file-info');
if (editorFileInfo) {
editorFileInfo.textContent = `${file.name} (${formatBytes(file.size)}, ${totalPageCount} pages)`;
editorFileInfo.textContent = trText(
'{{name}} ({{size}}, {{totalPageCount}} pages)',
{ name: file.name, size: formatBytes(file.size), totalPageCount }
);
}
updatePageNavUI();
@@ -173,7 +174,10 @@ function updateFileDisplay() {
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
@@ -1068,7 +1072,7 @@ async function applyWatermark() {
if (config.type === 'text') {
const text = config.text;
if (!text.trim())
throw new Error('Please enter text for the watermark.');
throw new Error(trText('Please enter text for the watermark.'));
const textColor = hexToRgb(config.color);
resultBytes = new Uint8Array(
@@ -1089,7 +1093,9 @@ async function applyWatermark() {
} else {
const imageFile = config.imageFile;
if (!imageFile)
throw new Error('Please select an image file for the watermark.');
throw new Error(
trText('Please select an image file for the watermark.')
);
const imageBytes = await readFileAsArrayBuffer(imageFile);
let imageType: 'png' | 'jpg';
@@ -1099,7 +1105,9 @@ async function applyWatermark() {
imageType = 'jpg';
} else {
throw new Error(
'Unsupported Image. Please use a PNG or JPG for the watermark.'
trText(
'Unsupported Image. Please use a PNG or JPG for the watermark.'
)
);
}
@@ -1223,7 +1231,12 @@ async function applyWatermark() {
const renderScale = 2.5;
for (let i = 1; i <= totalPages; i++) {
showLoader(`Flattening page ${i} of ${totalPages}...`);
showLoader(
trText('Flattening page {{i}} of {{totalPages}}...', {
i,
totalPages,
})
);
const page = await watermarkedPdf.getPage(i);
const unscaledVP = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: renderScale });
+17 -5
View File
@@ -10,7 +10,7 @@ import { PDFDocument } from 'pdf-lib';
import { applyColorAdjustments } from '../utils/image-effects.js';
import * as pdfjsLib from 'pdfjs-dist';
import type { AdjustColorsSettings } from '../types/adjust-colors-type.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -121,7 +121,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -144,7 +147,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf: pdfjsLib.PDFDocumentProxy) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch(() => {
metaSpan.textContent = formatBytes(file.size);
@@ -181,7 +188,12 @@ async function processAllPages(): Promise<void> {
const newPdfDoc = await PDFDocument.create();
for (let i = 1; i <= doc.numPages; i++) {
showLoader(`Processing page ${i} of ${doc.numPages}...`);
showLoader(
trText('Processing page {{i}} of {{numPages}}...', {
i,
numPages: doc.numPages,
})
);
const page = await doc.getPage(i);
const viewport = page.getViewport({ scale: 2.0 });
@@ -331,7 +343,7 @@ function setupSettingsListeners(): void {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+12 -5
View File
@@ -9,6 +9,8 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { interleavePdfs } from '../utils/alternate-merge.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: AlternateMergeState = {
files: [],
pdfBytes: new Map(),
@@ -50,7 +52,9 @@ async function updateUI() {
const infoSpan = document.createElement('span');
infoSpan.className = 'text-gray-200';
infoSpan.textContent = `${pageState.files.length} PDF files selected`;
infoSpan.textContent = trText('{{length}} PDF files selected', {
length: pageState.files.length,
});
const clearBtn = document.createElement('button');
clearBtn.className = 'text-red-400 hover:text-red-300';
@@ -94,7 +98,10 @@ async function updateUI() {
const metaSpan = document.createElement('span');
metaSpan.className = 'text-sm text-gray-400 flex-shrink-0';
metaSpan.textContent = `${formatBytes(file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(file.size),
pageCount,
});
infoDiv.append(nameSpan, metaSpan);
@@ -147,7 +154,7 @@ async function mixPages() {
try {
const fileList = document.getElementById('file-list');
if (!fileList) throw new Error('File list not found');
if (!fileList) throw new Error(trText('File list not found'));
const sortedFileNames = Array.from(fileList.children)
.map(function (li) {
@@ -199,7 +206,7 @@ async function mixPages() {
const message =
e instanceof Error
? e.message
: 'An error occurred while mixing the PDFs.';
: trText('An error occurred while mixing the PDFs.');
showAlert('Error', message);
}
}
@@ -218,7 +225,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+7 -6
View File
@@ -6,13 +6,11 @@ import { BackgroundColorState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: BackgroundColorState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -90,7 +88,10 @@ function updateFileDisplay() {
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+21 -8
View File
@@ -8,6 +8,8 @@ import Sortable from 'sortablejs';
import { FileEntry, Position, StylePreset } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const FONT_MAP: Record<string, keyof typeof StandardFonts> = {
Helvetica: 'Helvetica',
TimesRoman: 'TimesRoman',
@@ -54,11 +56,7 @@ const STYLE_PRESETS: Record<string, StylePreset> = {
const files: FileEntry[] = [];
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -237,7 +235,10 @@ function renderFileList() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(entry.file.size),
pageCount: entry.pageCount,
});
infoContainer.append(nameSpan, metaSpan);
leftSection.append(dragHandle, infoContainer);
@@ -260,7 +261,16 @@ function renderFileList() {
const summary = document.createElement('div');
summary.className = 'text-xs text-gray-400 mt-1';
summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`;
summary.textContent =
files.length !== 1
? trText('{{length}} files • {{totalPages}} total pages', {
length: files.length,
totalPages,
})
: trText('{{length}} file • {{totalPages}} total pages', {
length: files.length,
totalPages,
});
fileListEl.appendChild(summary);
}
@@ -539,7 +549,10 @@ async function applyBatesNumbers() {
showAlert(
'Success',
`Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`,
trText(
'Bates numbers applied successfully! ({{batesStart}} through {{n}})',
{ batesStart, n: batesCounter - 1 }
),
'success',
() => {
resetState();
+175 -165
View File
@@ -3,202 +3,212 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { trText, onPageReady } from '../i18n/i18n.js';
let files: File[] = [];
async function convertImageToPngBytes(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.onload = async () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise<Blob | null>((res) =>
canvas.toBlob(res, 'image/png')
);
if (!pngBlob) {
reject(new Error('Failed to create PNG blob'));
return;
}
const pngBytes = await pngBlob.arrayBuffer();
resolve(pngBytes);
};
img.onerror = () => reject(new Error('Failed to load image.'));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
});
reader.onload = (e) => {
img.onload = async () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error(trText('Failed to get canvas context')));
return;
}
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise<Blob | null>((res) =>
canvas.toBlob(res, 'image/png')
);
if (!pngBlob) {
reject(new Error(trText('Failed to create PNG blob')));
return;
}
const pngBytes = await pngBlob.arrayBuffer();
resolve(pngBytes);
};
img.onerror = () => reject(new Error(trText('Failed to load image.')));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error(trText('Failed to read file.')));
reader.readAsDataURL(file);
});
}
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const processBtn = document.getElementById('process-btn');
if (!fileDisplayArea || !fileControls || !processBtn) return;
if (!fileDisplayArea || !fileControls || !processBtn) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.innerHTML = '';
if (files.length > 0) {
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
if (files.length > 0) {
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
infoContainer.append(nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
};
const resetState = () => {
files = [];
updateUI();
files = [];
updateUI();
};
async function convert() {
if (files.length === 0) {
showAlert('No Files', 'Please select at least one BMP file.');
return;
}
showLoader('Converting BMP to PDF...');
try {
const pdfDoc = await PDFLibDocument.create();
for (const file of files) {
const pngBytes = await convertImageToPngBytes(file);
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
}
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_bmps.pdf'
);
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e) {
console.error(e);
showAlert(
'Error',
'Failed to convert BMP to PDF. One of the files may be invalid.'
);
} finally {
hideLoader();
if (files.length === 0) {
showAlert('No Files', 'Please select at least one BMP file.');
return;
}
showLoader('Converting BMP to PDF...');
try {
const pdfDoc = await PDFLibDocument.create();
for (const file of files) {
const pngBytes = await convertImageToPngBytes(file);
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
}
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_bmps.pdf'
);
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e) {
console.error(e);
showAlert(
'Error',
'Failed to convert BMP to PDF. One of the files may be invalid.'
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(
(file) =>
file.type === 'image/bmp' || file.name.toLowerCase().endsWith('.bmp')
);
if (validFiles.length < newFiles.length) {
showAlert(
'Invalid Files',
'Some files were skipped. Only BMP files are allowed.'
);
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(
(file) => file.type === 'image/bmp' || file.name.toLowerCase().endsWith('.bmp')
);
if (validFiles.length < newFiles.length) {
showAlert('Invalid Files', 'Some files were skipped. Only BMP files are allowed.');
}
if (validFiles.length > 0) {
files = [...files, ...validFiles];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
if (validFiles.length > 0) {
files = [...files, ...validFiles];
updateUI();
}
};
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput?.click();
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
if (processBtn) {
processBtn.addEventListener('click', convert);
}
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput?.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});
+101 -70
View File
@@ -12,6 +12,7 @@ import {
escapeHtml,
hexToRgb,
} from '../utils/helpers.js';
import { trText, trHtml, onPageReady } from '../i18n/i18n.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
@@ -262,21 +263,21 @@ placeholder="${escapeHTML(field.placeholder || '')}" />
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs">
<input type="checkbox" id="modal-use-destination" class="w-4 h-4" ${hasDestination ? 'checked' : ''}>
<span class="text-gray-700">Set custom destination</span>
<span class="text-gray-700">${trHtml('Set custom destination')}</span>
</label>
</div>
<div id="destination-controls" class="${hasDestination ? '' : 'hidden'} space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">Page</label>
<label class="text-xs text-gray-600">${trHtml('Page')}</label>
<input type="number" id="modal-dest-page" min="1" max="${escapeHTML(String(field.maxPages || 1))}" value="${escapeHTML(String(defaultValues.destPage || field.page || 1))}"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" step="1" />
</div>
<div>
<label class="text-xs text-gray-600">Zoom(%)</label>
<label class="text-xs text-gray-600">${trHtml('Zoom(%)')}</label>
<select id="modal-dest-zoom" class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900">
<option value="">Inherit</option>
<option value="0">Fit Page</option>
<option value="">${trHtml('Inherit')}</option>
<option value="0">${trHtml('Fit Page')}</option>
<option value="50">50%</option>
<option value="75">75%</option>
<option value="100">100%</option>
@@ -288,20 +289,20 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" st
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">X Position</label>
<label class="text-xs text-gray-600">${trHtml('X Position')}</label>
<input type="number" id="modal-dest-x" value="0" step="10"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
</div>
<div>
<label class="text-xs text-gray-600">Y Position</label>
<label class="text-xs text-gray-600">${trHtml('Y Position')}</label>
<input type="number" id="modal-dest-y" value="0" step="10"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
</div>
</div>
<button id="modal-pick-destination" class="w-full px-3 py-2 btn-gradient text-white rounded text-xs !flex items-center justify-center gap-1">
<i data-lucide="crosshair" class="w-3 h-3"></i> Click on PDF to Pick Location
<i data-lucide="crosshair" class="w-3 h-3"></i> ${trHtml('Click on PDF to Pick Location')}
</button>
<p class="text-xs text-gray-500 italic">Click the button above, then click on the PDF where you want the bookmark to jump to</p>
<p class="text-xs text-gray-500 italic">${trHtml('Click the button above, then click on the PDF where you want the bookmark to jump to')}</p>
</div>
</div>
</div>
@@ -311,7 +312,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div id="modal-preview" class="style-preview bg-gray-50">
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
<span id="preview-text" style="font-size: 16px;">${trHtml('Preview Text')}</span>
</div>
</div>
`;
@@ -322,13 +323,13 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(trText(title))}</h3>
<div class="mb-6">
${fieldsHTML}
</div>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">${trHtml('Cancel')}</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">${trHtml('Confirm')}</button>
</div>
</div>
`;
@@ -354,11 +355,13 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
'#modal-color-picker'
) as HTMLInputElement | null;
const titleVal = titleInputEl ? titleInputEl.value : 'Preview Text';
const titleVal = titleInputEl
? titleInputEl.value
: trText('Preview Text');
const color = colorSelectEl ? colorSelectEl.value : '';
const style = styleSelectEl ? styleSelectEl.value : '';
previewText.textContent = titleVal || 'Preview Text';
previewText.textContent = titleVal || trText('Preview Text');
if (color === 'custom' && colorPickerEl) {
previewText.style.color = colorPickerEl.value;
@@ -695,7 +698,7 @@ function cancelDestinationPicking(): void {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
initializeGlobalShortcuts();
const canvasEl = document.getElementById(
@@ -723,7 +726,10 @@ document.addEventListener('DOMContentLoaded', () => {
canvasWrapperEl.appendChild(coordTooltip);
}
coordTooltip.textContent = `X: ${Math.round(x)}, Y: ${Math.round(y)} `;
coordTooltip.textContent = trText('X: {{x}}, Y: {{y}} ', {
x: Math.round(x),
y: Math.round(y),
});
coordTooltip.style.left = e.clientX - rect.left + 15 + 'px';
coordTooltip.style.top = e.clientY - rect.top + 15 + 'px';
});
@@ -797,7 +803,10 @@ document.addEventListener('DOMContentLoaded', () => {
canvasX + canvasRect.left - wrapperRect.left + 20 + 'px';
coordDisplay.style.top =
canvasY + canvasRect.top - wrapperRect.top - 30 + 'px';
coordDisplay.textContent = `X: ${Math.round(pdfX)}, Y: ${Math.round(pdfY)} `;
coordDisplay.textContent = trText('X: {{pdfX}}, Y: {{pdfY}} ', {
pdfX: Math.round(pdfX),
pdfY: Math.round(pdfY),
});
canvasWrapperEl.appendChild(coordDisplay);
currentPickingCallback(currentPage, pdfX, pdfY);
@@ -825,11 +834,11 @@ function showConfirmModal(message: string): Promise<boolean> {
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<h3 class="text-xl font-bold text-gray-800 mb-4">${trHtml('Confirm Action')}</h3>
<p class="text-gray-600 mb-6">${escapeHTML(trText(message))}</p>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">${trHtml('Cancel')}</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">${trHtml('Confirm')}</button>
</div>
</div>
`;
@@ -881,7 +890,7 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex justify-end">
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">${trHtml('OK')}</button>
</div>
</div>
`;
@@ -1005,7 +1014,9 @@ redoBtn?.addEventListener('click', redo);
resetBtn?.addEventListener('click', async () => {
const confirmed = await showConfirmModal(
'Reset and go back to file uploader? All unsaved changes will be lost.'
trText(
'Reset and go back to file uploader? All unsaved changes will be lost.'
)
);
if (confirmed) {
resetToUploader();
@@ -1014,12 +1025,14 @@ resetBtn?.addEventListener('click', async () => {
deleteAllBtn?.addEventListener('click', async () => {
if (bookmarkTree.length === 0) {
await showAlertModal('Info', 'No bookmarks to delete.');
await showAlertModal('Info', trText('No bookmarks to delete.'));
return;
}
const confirmed = await showConfirmModal(
`Delete all ${bookmarkTree.length} bookmark(s) ? `
trText('Delete all {{length}} bookmark(s) ? ', {
length: bookmarkTree.length,
})
);
if (confirmed) {
bookmarkTree = [];
@@ -1146,7 +1159,7 @@ batchDeleteBtn?.addEventListener('click', async () => {
if (selectedBookmarks.size === 0) return;
const confirmed = await showConfirmModal(
`Delete ${selectedBookmarks.size} bookmark(s) ? `
trText('Delete {{size}} bookmark(s) ? ', { size: selectedBookmarks.size })
);
if (!confirmed) return;
@@ -1292,8 +1305,10 @@ csvInput?.addEventListener('change', async (e: Event) => {
csvBookmarks = parseCSV(text);
await showAlertModal(
'CSV Loaded',
`Loaded ${csvBookmarks.length} bookmarks from CSV. Now upload your PDF.`
trText('CSV Loaded'),
trText('Loaded {{length}} bookmarks from CSV. Now upload your PDF.', {
length: csvBookmarks.length,
})
);
});
@@ -1306,11 +1321,11 @@ jsonInput?.addEventListener('change', async (e: Event) => {
try {
jsonBookmarks = JSON.parse(text);
await showAlertModal(
'JSON Loaded',
'Loaded bookmarks from JSON. Now upload your PDF.'
trText('JSON Loaded'),
trText('Loaded bookmarks from JSON. Now upload your PDF.')
);
} catch {
await showAlertModal('Error', 'Invalid JSON format');
await showAlertModal('Error', trText('Invalid JSON format'));
}
});
@@ -1388,7 +1403,10 @@ async function renderPage(
ctx.restore();
}
pageIndicator!.textContent = `Page ${num} / ${pdfJsDoc.numPages}`;
pageIndicator!.textContent = trText('Page {{num}} / {{numPages}}', {
num,
numPages: pdfJsDoc.numPages,
});
if (gotoPageInput) gotoPageInput.value = String(num);
currentPage = num;
if (currentPageDisplay) currentPageDisplay.textContent = String(num);
@@ -1425,7 +1443,9 @@ gotoPageInput?.addEventListener('keypress', (e: KeyboardEvent) => {
function updateZoomIndicator(): void {
if (zoomIndicator) {
zoomIndicator.textContent = `${Math.round(currentZoom * 100)}%`;
zoomIndicator.textContent = trText('{{value}}%', {
value: Math.round(currentZoom * 100),
});
}
}
@@ -1699,7 +1719,7 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
titleDiv.innerHTML = `
<span class="text-sm block ${styleClass} ${textColorClass}" ${customColorStyle}>${escapeHTML(node.title)}${destinationIcon}</span>
<span class="text-xs text-gray-500">Page ${escapeHTML(String(node.page))}</span>
<span class="text-xs text-gray-500">${trHtml('Page {{page}}', { page: escapeHTML(String(node.page)) })}</span>
`;
titleDiv.addEventListener('click', async () => {
@@ -1734,16 +1754,16 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
const addChildBtn = document.createElement('button');
addChildBtn.className = 'p-1 hover:bg-gray-200 rounded text-gray-700';
addChildBtn.title = 'Add child';
addChildBtn.title = trText('Add child');
addChildBtn.innerHTML = '<i data-lucide="plus" class="w-4 h-4"></i>';
addChildBtn.addEventListener('click', async (e: MouseEvent) => {
e.stopPropagation();
const result = await showInputModal('Add Child Bookmark', [
const result = await showInputModal(trText('Add Child Bookmark'), [
{
type: 'text',
name: 'title',
label: 'Title',
placeholder: 'Enter bookmark title',
label: trText('Title'),
placeholder: trText('Enter bookmark title'),
},
]);
if (result && result.title) {
@@ -1767,52 +1787,52 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
const editBtn = document.createElement('button');
editBtn.className = 'p-1 hover:bg-gray-200 rounded text-gray-700';
editBtn.title = 'Edit';
editBtn.title = trText('Edit');
editBtn.innerHTML = '<i data-lucide="edit-2" class="w-4 h-4"></i>';
editBtn.addEventListener('click', async (e: MouseEvent) => {
e.stopPropagation();
const result = await showInputModal(
'Edit Bookmark',
trText('Edit Bookmark'),
[
{
type: 'text',
name: 'title',
label: 'Title',
placeholder: 'Enter bookmark title',
label: trText('Title'),
placeholder: trText('Enter bookmark title'),
},
{
type: 'select',
name: 'color',
label: 'Color',
label: trText('Color'),
options: [
{ value: '', label: 'None' },
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'purple', label: 'Purple' },
{ value: 'custom', label: 'Custom...' },
{ value: '', label: trText('None') },
{ value: 'red', label: trText('Red') },
{ value: 'blue', label: trText('Blue') },
{ value: 'green', label: trText('Green') },
{ value: 'yellow', label: trText('Yellow') },
{ value: 'purple', label: trText('Purple') },
{ value: 'custom', label: trText('Custom...') },
],
},
{
type: 'select',
name: 'style',
label: 'Style',
label: trText('Style'),
options: [
{ value: '', label: 'Normal' },
{ value: 'bold', label: 'Bold' },
{ value: 'italic', label: 'Italic' },
{ value: 'bold-italic', label: 'Bold & Italic' },
{ value: '', label: trText('Normal') },
{ value: 'bold', label: trText('Bold') },
{ value: 'italic', label: trText('Italic') },
{ value: 'bold-italic', label: trText('Bold & Italic') },
],
},
{
type: 'destination',
name: 'destination',
label: 'Destination',
label: trText('Destination'),
page: node.page,
maxPages: pdfJsDoc ? pdfJsDoc.numPages : 1,
},
{ type: 'preview', label: 'Preview' },
{ type: 'preview', label: trText('Preview') },
],
{
title: node.title,
@@ -1845,11 +1865,13 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'p-1 hover:bg-gray-200 rounded text-red-600';
deleteBtn.title = 'Delete';
deleteBtn.title = trText('Delete');
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
deleteBtn.addEventListener('click', async (e: MouseEvent) => {
e.stopPropagation();
const confirmed = await showConfirmModal(`Delete "${node.title}"?`);
const confirmed = await showConfirmModal(
trText('Delete "{{title}}"?', { title: node.title })
);
if (confirmed) {
removeNodeById(bookmarkTree, node.id);
saveState();
@@ -1883,7 +1905,7 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
addTopLevelBtn?.addEventListener('click', async () => {
const title = titleInput?.value.trim();
if (!title) {
await showAlertModal('Error', 'Please enter a title.');
await showAlertModal('Error', trText('Please enter a title.'));
return;
}
@@ -1929,7 +1951,10 @@ csvImportHidden?.addEventListener('change', async (e: Event) => {
bookmarkTree = imported;
saveState();
renderBookmarkTree();
await showAlertModal('Success', `Imported ${imported.length} bookmarks!`);
await showAlertModal(
'Success',
trText('Imported {{length}} bookmarks!', { length: imported.length })
);
}
if (csvImportHidden) csvImportHidden.value = '';
@@ -1939,7 +1964,7 @@ exportCsvBtn?.addEventListener('click', () => {
exportDropdown?.classList.add('hidden');
if (bookmarkTree.length === 0) {
showAlertModal('Error', 'No bookmarks to export!');
showAlertModal('Error', trText('No bookmarks to export!'));
return;
}
@@ -2045,9 +2070,9 @@ jsonImportHidden?.addEventListener('change', async (e: Event) => {
bookmarkTree = imported;
saveState();
renderBookmarkTree();
await showAlertModal('Success', 'Bookmarks imported from JSON!');
await showAlertModal('Success', trText('Bookmarks imported from JSON!'));
} catch {
await showAlertModal('Error', 'Invalid JSON format');
await showAlertModal('Error', trText('Invalid JSON format'));
}
if (jsonImportHidden) jsonImportHidden.value = '';
@@ -2057,7 +2082,7 @@ exportJsonBtn?.addEventListener('click', () => {
exportDropdown?.classList.add('hidden');
if (bookmarkTree.length === 0) {
showAlertModal('Error', 'No bookmarks to export!');
showAlertModal('Error', trText('No bookmarks to export!'));
return;
}
@@ -2072,7 +2097,10 @@ extractExistingBtn?.addEventListener('click', async () => {
const extracted = await extractExistingBookmarks();
if (extracted.length > 0) {
const confirmed = await showConfirmModal(
`Found ${extracted.length} existing bookmarks. Replace current bookmarks?`
trText(
'Found {{length}} existing bookmarks. Replace current bookmarks?',
{ length: extracted.length }
)
);
if (confirmed) {
bookmarkTree = extracted;
@@ -2080,7 +2108,10 @@ extractExistingBtn?.addEventListener('click', async () => {
renderBookmarkTree();
}
} else {
await showAlertModal('Info', 'No existing bookmarks found in this PDF.');
await showAlertModal(
'Info',
trText('No existing bookmarks found in this PDF.')
);
}
});
@@ -2340,7 +2371,7 @@ downloadBtn?.addEventListener('click', async () => {
});
downloadFile(blob, `${originalFileName}.pdf`);
await showAlertModal('Success', 'PDF saved successfully!');
await showAlertModal('Success', trText('PDF saved successfully!'));
setTimeout(() => {
resetToUploader();
@@ -2349,7 +2380,7 @@ downloadBtn?.addEventListener('click', async () => {
console.error(err);
await showAlertModal(
'Error',
'Error saving PDF. Check console for details.'
trText('Error saving PDF. Check console for details.')
);
}
});
+43 -13
View File
@@ -7,6 +7,8 @@ import type { PyMuPDFInstance } from '@/types';
import JSZip from 'jszip';
import { PDFDocument } from 'pdf-lib';
import { trText, onPageReady } from '../i18n/i18n.js';
const EXTENSIONS = ['.cbz', '.cbr'];
const TOOL_NAME = 'CBZ';
const ALL_IMAGE_EXTENSIONS = [
@@ -99,7 +101,7 @@ async function convertImageToPng(
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Failed to get canvas context'));
reject(new Error(trText('Failed to get canvas context')));
return;
}
ctx.drawImage(img, 0, 0);
@@ -108,13 +110,19 @@ async function convertImageToPng(
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error(`Failed to convert ${filename} to PNG`));
reject(
new Error(
trText('Failed to convert {{filename}} to PNG', { filename })
)
);
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Failed to load image: ${filename}`));
reject(
new Error(trText('Failed to load image: {{filename}}', { filename }))
);
};
img.src = url;
});
@@ -138,7 +146,11 @@ async function convertCbzToPdf(file: File): Promise<Blob> {
const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
const MAX_TOTAL_BYTES = 500 * 1024 * 1024;
if (imageFiles.length > MAX_CBZ_PAGES) {
throw new Error(`Archive has too many images (max ${MAX_CBZ_PAGES}).`);
throw new Error(
trText('Archive has too many images (max {{MAX_CBZ_PAGES}}).', {
MAX_CBZ_PAGES,
})
);
}
let totalBytes = 0;
@@ -148,12 +160,12 @@ async function convertCbzToPdf(file: File): Promise<Blob> {
zipEntry as unknown as { _data?: { uncompressedSize?: number } }
)._data?.uncompressedSize;
if (typeof declared === 'number' && declared > MAX_ENTRY_BYTES) {
throw new Error('Archive contains an oversized image entry.');
throw new Error(trText('Archive contains an oversized image entry.'));
}
const imageData = await zipEntry.async('arraybuffer');
totalBytes += imageData.byteLength;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error('Archive is too large when decompressed.');
throw new Error(trText('Archive is too large when decompressed.'));
}
const dataArray = new Uint8Array(imageData);
const actualFormat = detectImageFormat(dataArray);
@@ -197,7 +209,7 @@ async function convertCbrToPdf(file: File): Promise<Blob> {
return await pymupdf.convertToPdf(file, { filetype: 'cbz' });
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -272,13 +284,20 @@ document.addEventListener('DOMContentLoaded', () => {
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
showAlert(
'No Files',
trText('Please select at least one {{TOOL_NAME}} file.', {
TOOL_NAME,
})
);
return;
}
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
showLoader(
trText('Converting {{name}}...', { name: originalFile.name })
);
let pdfBlob: Blob;
if (isCbzFile(originalFile.name)) {
@@ -293,7 +312,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -304,7 +325,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
let pdfBlob: Blob;
@@ -326,7 +351,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
trText(
'Successfully converted {{length}} {{TOOL_NAME}} file(s) to PDF.',
{ length: state.files.length, TOOL_NAME }
),
'success',
() => resetState()
);
@@ -336,7 +364,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+18 -10
View File
@@ -8,6 +8,8 @@ import {
import { icons, createIcons } from 'lucide';
import { ChangePermissionsState, QpdfInstanceExtended } from '@/types';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: ChangePermissionsState = {
file: null,
};
@@ -129,16 +131,17 @@ async function changePermissions() {
try {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Initializing...';
if (loaderText) loaderText.textContent = trText('Initializing...');
qpdf = await initializeQpdf();
if (loaderText) loaderText.textContent = 'Reading PDF...';
if (loaderText) loaderText.textContent = trText('Reading PDF...');
const fileBuffer = await readFileAsArrayBuffer(pageState.file);
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
qpdf.FS.writeFile(inputPath, uint8Array);
if (loaderText) loaderText.textContent = 'Processing PDF permissions...';
if (loaderText)
loaderText.textContent = trText('Processing PDF permissions...');
const args = [inputPath];
@@ -220,11 +223,11 @@ async function changePermissions() {
});
}
if (loaderText) loaderText.textContent = 'Preparing download...';
if (loaderText) loaderText.textContent = trText('Preparing download...');
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
if (!outputFile || outputFile.length === 0) {
throw new Error('Processing resulted in an empty file.');
throw new Error(trText('Processing resulted in an empty file.'));
}
const blob = new Blob([new Uint8Array(outputFile)], {
@@ -234,10 +237,11 @@ async function changePermissions() {
if (loaderModal) loaderModal.classList.add('hidden');
let successMessage = 'PDF permissions changed successfully!';
let successMessage = trText('PDF permissions changed successfully!');
if (!shouldEncrypt) {
successMessage =
'PDF decrypted successfully! All encryption and restrictions removed.';
successMessage = trText(
'PDF decrypted successfully! All encryption and restrictions removed.'
);
}
showAlert('Success', successMessage, 'success', () => {
@@ -261,7 +265,11 @@ async function changePermissions() {
} else {
showAlert(
'Processing Failed',
`An error occurred: ${errorMessage || 'The PDF might be corrupted or password protected.'}`
trText('An error occurred: {{error}}', {
error:
errorMessage ||
trText('The PDF might be corrupted or password protected.'),
})
);
}
} finally {
@@ -284,7 +292,7 @@ async function changePermissions() {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+16 -4
View File
@@ -13,6 +13,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: CombineSinglePageState = {
file: null,
pdfDoc: null,
@@ -54,7 +56,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -82,7 +86,10 @@ async function updateUI() {
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
if (toolOptions) toolOptions.classList.remove('hidden');
} catch (error) {
@@ -174,7 +181,12 @@ async function combineToSinglePage() {
let currentY = finalHeight;
for (let i = 0; i < sourcePages.length; i++) {
showLoader(`Processing page ${i + 1} of ${sourcePages.length}...`);
showLoader(
trText('Processing page {{n}} of {{length}}...', {
n: i + 1,
length: sourcePages.length,
})
);
const sourcePage = sourcePages[i];
const { width, height } = sourcePage.getSize();
@@ -273,7 +285,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+28 -15
View File
@@ -26,6 +26,8 @@ import {
getComparisonCacheKey,
} from './compare-render.ts';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: CompareState = {
pdfDoc1: null,
pdfDoc2: null,
@@ -389,8 +391,8 @@ function renderChangeList() {
if (!comparison || visibleChanges.length === 0) {
emptyState.textContent =
comparison?.status === 'match'
? 'No differences detected on this page.'
: 'No changes match the current filter.';
? trText('No differences detected on this page.')
: trText('No changes match the current filter.');
emptyState.classList.remove('hidden');
list.classList.add('hidden');
prevChangeBtn.disabled = true;
@@ -403,13 +405,13 @@ function renderChangeList() {
list.classList.remove('hidden');
const typeLabels: Record<string, string> = {
added: 'Added',
removed: 'Deleted',
modified: 'Modified',
moved: 'Moved',
'style-changed': 'Style Changed',
'page-added': 'Page Added',
'page-removed': 'Page Removed',
added: trText('Added'),
removed: trText('Deleted'),
modified: trText('Modified'),
moved: trText('Moved'),
'style-changed': trText('Style Changed'),
'page-added': trText('Page Added'),
'page-removed': trText('Page Removed'),
};
const grouped = new Map<
@@ -497,7 +499,10 @@ async function buildPagePairs() {
pageState.pdfDoc1,
function (pageNumber, totalPages) {
showLoader(
`Indexing PDF 1 page ${pageNumber} of ${totalPages}...`,
trText('Indexing PDF 1 page {{pageNumber}} of {{totalPages}}...', {
pageNumber,
totalPages,
}),
(pageNumber / Math.max(totalPages * 2, 1)) * 100
);
}
@@ -506,7 +511,10 @@ async function buildPagePairs() {
pageState.pdfDoc2,
function (pageNumber, totalPages) {
showLoader(
`Indexing PDF 2 page ${pageNumber} of ${totalPages}...`,
trText('Indexing PDF 2 page {{pageNumber}} of {{totalPages}}...', {
pageNumber,
totalPages,
}),
50 + (pageNumber / Math.max(totalPages * 2, 1)) * 100
);
}
@@ -558,7 +566,10 @@ async function renderBothPages() {
const gen = ++renderGeneration;
showLoader(
`Loading comparison ${pageState.currentPage} of ${pageState.pagePairs.length}...`
trText('Loading comparison {{currentPage}} of {{length}}...', {
currentPage: pageState.currentPage,
length: pageState.pagePairs.length,
})
);
const canvas1 = getElement<HTMLCanvasElement>(
@@ -744,7 +755,7 @@ async function handleFileInput(
hideLoader();
const result = await loadPdfWithPasswordPrompt(file);
if (!result) return;
showLoader(`Loading ${result.file.name}...`);
showLoader(trText('Loading {{name}}...', { name: result.file.name }));
pageState[docKey] = result.pdf;
caches.pageModelCache.clear();
caches.comparisonCache.clear();
@@ -792,7 +803,7 @@ async function handleFileInput(
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
if (backBtn) {
@@ -960,7 +971,9 @@ document.addEventListener('DOMContentLoaded', function () {
function updateZoomDisplay() {
if (zoomDisplay) {
zoomDisplay.textContent = `${Math.round(pageState.zoomLevel * 100)}%`;
zoomDisplay.textContent = trText('{{value}}%', {
value: Math.round(pageState.zoomLevel * 100),
});
}
if (zoomOutBtn) zoomOutBtn.disabled = pageState.zoomLevel <= ZOOM_MIN;
if (zoomInBtn) zoomInBtn.disabled = pageState.zoomLevel >= ZOOM_MAX;
+7 -5
View File
@@ -19,6 +19,8 @@ import { recognizePageCanvas } from '../compare/engine/ocr-page.ts';
import { isLowQualityExtractedText } from '../compare/engine/text-normalization.ts';
import { COMPARE_RENDER, COMPARE_GEOMETRY } from '../compare/config.ts';
import { trText } from '../i18n/i18n.js';
export function getElement<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T | null;
}
@@ -228,7 +230,7 @@ export async function renderPage(
renderMissingPage(
canvas,
placeholderId,
`Page ${pageNum} does not exist in this PDF.`
trText('Page {{pageNum}} does not exist in this PDF.', { pageNum })
);
return { model: null, exists: false };
}
@@ -280,12 +282,12 @@ export async function renderPage(
);
finalModel.pageNumber = pageNum;
} else {
ctx.showLoader(`Running OCR on page ${pageNum}...`);
ctx.showLoader(trText('Running OCR on page {{pageNum}}...', { pageNum }));
const ocrModel = await recognizePageCanvas(
canvas,
ctx.ocrLanguage,
function (status, progress) {
ctx.showLoader(`OCR: ${status}`, progress * 100);
ctx.showLoader(trText('OCR: {{status}}', { status }), progress * 100);
}
);
finalModel = {
@@ -324,7 +326,7 @@ export async function loadComparisonPage(
renderMissingPage(
renderTarget.canvas,
renderTarget.placeholderId,
'No paired page for this side.'
trText('No paired page for this side.')
);
}
return { model: null, exists: false };
@@ -358,7 +360,7 @@ export async function loadComparisonPage(
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create offscreen comparison canvas.');
throw new Error(trText('Could not create offscreen comparison canvas.'));
}
const extractedModel = await extractPageModel(page, viewport);
+224 -112
View File
@@ -28,34 +28,37 @@ import {
verifyHyperPassword,
} from '../utils/compress.js';
import { trText, onPageReady } from '../i18n/i18n.js';
type OptionChangeHandler = (
key: keyof HyperCompressOptions,
value: number | boolean | null
) => void;
const LOCK_MESSAGES = {
locked: {
text: 'Password protected',
color: 'text-gray-400',
field: 'border-gray-600 focus:ring-indigo-500',
},
wrong: {
text: 'Wrong password',
color: 'text-red-400',
field: 'border-red-500 focus:ring-red-500',
},
unlocked: {
text: 'Unlocked',
color: 'text-green-400',
field: 'border-green-500 focus:ring-green-500',
},
} as const;
const getLockMessages = () =>
({
locked: {
text: trText('Password protected'),
color: 'text-gray-400',
field: 'border-gray-600 focus:ring-indigo-500',
},
wrong: {
text: trText('Wrong password'),
color: 'text-red-400',
field: 'border-red-500 focus:ring-red-500',
},
unlocked: {
text: trText('Unlocked'),
color: 'text-green-400',
field: 'border-green-500 focus:ring-green-500',
},
}) as const;
const PASSWORD_INPUT_CLASS =
'w-full bg-gray-800 border text-gray-200 placeholder:text-gray-500 rounded-lg pl-9 pr-10 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent';
const DPI_CHOICES = [
{ value: 0, label: 'None' },
const getDpiChoices = () => [
{ value: 0, label: trText('None') },
{ value: 72, label: '72 DPI' },
{ value: 96, label: '96 DPI' },
{ value: 144, label: '144 DPI' },
@@ -64,191 +67,221 @@ const DPI_CHOICES = [
{ value: 300, label: '300 DPI' },
];
const HYPER_OPTION_SECTIONS: HyperOptionSection[] = [
const getOptionSections = (): HyperOptionSection[] => [
{
title: 'Images',
title: trText('Images'),
open: true,
fields: [
{
key: 'imageQuality',
type: 'number',
label: 'Image quality',
label: trText('Image quality'),
min: 20,
max: 100,
help: 'JPEG quality for re-encoded images, 20 to 100. Lower means smaller files and more visible artifacts.',
help: trText(
'JPEG quality for re-encoded images, 20 to 100. Lower means smaller files and more visible artifacts.'
),
},
{
key: 'maxDpi',
type: 'dpi',
label: 'Maximum resolution',
help: 'Images above this are resampled down. None leaves resolution alone.',
label: trText('Maximum resolution'),
help: trText(
'Images above this are resampled down. None leaves resolution alone.'
),
},
{
key: 'forceDownsample',
type: 'bool',
label: 'Always resample to that resolution',
help: 'Resamples even when an image is only slightly above the limit.',
label: trText('Always resample to that resolution'),
help: trText(
'Resamples even when an image is only slightly above the limit.'
),
},
{
key: 'grayscale',
type: 'bool',
label: 'Convert to grayscale',
help: 'Reduces file size by removing color information.',
label: trText('Convert to grayscale'),
help: trText('Reduces file size by removing color information.'),
},
{
key: 'reduceColor',
type: 'bool',
label: 'Reduce color complexity',
help: 'Only where it does not change how the image looks.',
label: trText('Reduce color complexity'),
help: trText('Only where it does not change how the image looks.'),
},
{
key: 'clipImages',
type: 'bool',
label: 'Clip invisible parts of images',
label: trText('Clip invisible parts of images'),
},
{
key: 'removeAlternates',
type: 'bool',
label: 'Remove alternate images',
label: trText('Remove alternate images'),
},
{
key: 'flattenIcc',
type: 'bool',
label: 'Simplify large color profiles',
label: trText('Simplify large color profiles'),
},
{
key: 'preferJpx',
type: 'bool',
label: 'Prefer JPEG 2000 for photos',
help: 'Tries JPEG 2000 against JPEG for each image and keeps the smaller one.',
label: trText('Prefer JPEG 2000 for photos'),
help: trText(
'Tries JPEG 2000 against JPEG for each image and keeps the smaller one.'
),
},
{
key: 'lossless',
type: 'bool',
label: 'Lossless (no image re-encoding)',
help: 'Turns off quality, resolution and every other lossy image step.',
label: trText('Lossless (no image re-encoding)'),
help: trText(
'Turns off quality, resolution and every other lossy image step.'
),
},
],
},
{
title: 'Fonts',
title: trText('Fonts'),
fields: [
{
key: 'subsetFonts',
type: 'bool',
label: 'Subset embedded fonts',
help: 'Keeps only the characters the document uses.',
label: trText('Subset embedded fonts'),
help: trText('Keeps only the characters the document uses.'),
},
{
key: 'mergeFonts',
type: 'bool',
label: trText('Merge duplicate fonts'),
},
{ key: 'mergeFonts', type: 'bool', label: 'Merge duplicate fonts' },
{
key: 'removeStandardFonts',
type: 'bool',
label: 'Unembed standard fonts',
help: 'PDF readers supply the 14 standard PDF fonts themselves.',
label: trText('Unembed standard fonts'),
help: trText(
'PDF readers supply the 14 standard PDF fonts themselves.'
),
},
{
key: 'unembedAliasedFonts',
type: 'bool',
label: 'Unembed standard-font look-alikes',
help: 'Arial, Times New Roman and Courier New map to the built-in equivalents. Only applied when safe.',
label: trText('Unembed standard-font look-alikes'),
help: trText(
'Arial, Times New Roman and Courier New map to the built-in equivalents. Only applied when safe.'
),
},
],
},
{
title: 'Content',
title: trText('Content'),
fields: [
{
key: 'removeAnnots',
type: 'bool',
label: 'Flatten annotations',
help: 'Comments and markup become part of the page. Form fields and links keep working.',
label: trText('Flatten annotations'),
help: trText(
'Comments and markup become part of the page. Form fields and links keep working.'
),
},
{
key: 'flattenForms',
type: 'bool',
label: 'Flatten form fields',
help: 'Form fields stop being fillable.',
label: trText('Flatten form fields'),
help: trText('Form fields stop being fillable.'),
},
{
key: 'flattenLinks',
type: 'bool',
label: 'Flatten links',
help: 'Links stop being clickable.',
label: trText('Flatten links'),
help: trText('Links stop being clickable.'),
},
],
},
{
title: 'Cleanup',
title: trText('Cleanup'),
fields: [
{
key: 'removeThumbnails',
type: 'bool',
label: 'Remove page thumbnails',
label: trText('Remove page thumbnails'),
},
{
key: 'removeAppData',
type: 'bool',
label: 'Remove application data',
help: 'Private data that editing apps store inside the PDF.',
label: trText('Remove application data'),
help: trText('Private data that editing apps store inside the PDF.'),
},
{
key: 'removeThreads',
type: 'bool',
label: trText('Remove article threads'),
},
{ key: 'removeThreads', type: 'bool', label: 'Remove article threads' },
{
key: 'removeSpiderInfo',
type: 'bool',
label: 'Remove web capture information',
label: trText('Remove web capture information'),
},
{
key: 'removeStructTree',
type: 'bool',
label: 'Remove document structure tree',
help: 'Drops tagging, which screen readers rely on.',
label: trText('Remove document structure tree'),
help: trText('Drops tagging, which screen readers rely on.'),
},
{
key: 'removeOutputIntents',
type: 'bool',
label: 'Remove output intents',
label: trText('Remove output intents'),
},
],
},
{
title: 'Rasterize',
title: trText('Rasterize'),
fields: [
{
key: 'rasterizePages',
type: 'bool',
label: 'Rasterize pages to images',
help: 'For image-only PDFs such as scans. Skipped when the document has selectable text, unless Override content protection is on.',
label: trText('Rasterize pages to images'),
help: trText(
'For image-only PDFs such as scans. Skipped when the document has selectable text, unless Override content protection is on.'
),
},
{
key: 'rasterizeDpi',
type: 'number',
label: 'Rasterize resolution (DPI)',
label: trText('Rasterize resolution (DPI)'),
min: 36,
max: 600,
},
{
key: 'rasterizeQuality',
type: 'number',
label: 'Rasterize quality',
label: trText('Rasterize quality'),
min: 1,
max: 100,
},
],
},
{
title: 'Output',
title: trText('Output'),
fields: [
{
key: 'preserveConformance',
type: 'bool',
label: 'Preserve PDF/A',
help: 'Keeps a PDF/A file conformant. Brotli and rasterizing are skipped while this is on.',
label: trText('Preserve PDF/A'),
help: trText(
'Keeps a PDF/A file conformant. Brotli and rasterizing are skipped while this is on.'
),
},
{
key: 'brotli',
type: 'bool',
label: 'Brotli compression (PDF 2.0)',
help: 'Smaller files, but only readers with Brotli support can open them, such as Firefox, MuPDF 1.26+ and Ghostscript 10.06+.',
label: trText('Brotli compression (PDF 2.0)'),
help: trText(
'Smaller files, but only readers with Brotli support can open them, such as Firefox, MuPDF 1.26+ and Ghostscript 10.06+.'
),
},
],
},
@@ -303,7 +336,7 @@ function buildOptionField(
const select = document.createElement('select');
select.id = id;
select.className = controlClass;
for (const choice of DPI_CHOICES) {
for (const choice of getDpiChoices()) {
const option = document.createElement('option');
option.value = String(choice.value);
option.textContent = choice.label;
@@ -355,7 +388,7 @@ function renderHyperOptions(
onChange: OptionChangeHandler
): (options: HyperCompressOptions) => void {
const syncs: ((options: HyperCompressOptions) => void)[] = [];
for (const section of HYPER_OPTION_SECTIONS) {
for (const section of getOptionSections()) {
const details = document.createElement('details');
details.className = 'group rounded-lg border border-gray-700';
details.open = Boolean(section.open);
@@ -393,21 +426,41 @@ function describeCompressResult(
if (saved > 0) {
const percent = ((saved / result.originalSize) * 100).toFixed(1);
parts.push(
`File size reduced from ${formatBytes(result.originalSize)} to ${formatBytes(result.compressedSize)} (Saved ${percent}%).`
trText(
'File size reduced from {{originalSize}} to {{compressedSize}} (Saved {{percent}}%).',
{
originalSize: formatBytes(result.originalSize),
compressedSize: formatBytes(result.compressedSize),
percent,
}
)
);
} else {
parts.push(
`Could not reduce file size further. Original: ${formatBytes(result.originalSize)}, New: ${formatBytes(result.compressedSize)}.`
trText(
'Could not reduce file size further. Original: {{originalSize}}, New: {{compressedSize}}.',
{
originalSize: formatBytes(result.originalSize),
compressedSize: formatBytes(result.compressedSize),
}
)
);
}
const preservingPdfa = options.preserveConformance && result.pdfa !== null;
if (targetBytes !== undefined) {
if (result.metTarget) {
parts.push(`It fits the ${formatBytes(targetBytes)} target.`);
parts.push(
trText('It fits the {{targetBytes}} target.', {
targetBytes: formatBytes(targetBytes),
})
);
} else if (preservingPdfa) {
parts.push(
`It is still above the ${formatBytes(targetBytes)} target because Preserve PDF/A limits how far it can be compressed.`
trText(
'It is still above the {{targetBytes}} target because Preserve PDF/A limits how far it can be compressed.',
{ targetBytes: formatBytes(targetBytes) }
)
);
} else {
parts.push(
@@ -418,34 +471,38 @@ function describeCompressResult(
if (result.signaturesRemoved) {
parts.push(
'Its digital signatures were removed so it could be compressed. Sign it again if needed.'
trText(
'Its digital signatures were removed so it could be compressed. Sign it again if needed.'
)
);
}
if (result.pdfaOutcome === 'claim-withdrawn') {
parts.push(
'It no longer claims PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.'
trText(
'It no longer claims PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.'
)
);
} else if (preservingPdfa && (options.brotli || options.rasterizePages)) {
parts.push('Brotli and rasterizing were skipped to keep it PDF/A.');
parts.push(trText('Brotli and rasterizing were skipped to keep it PDF/A.'));
}
if (saved <= 0) {
return {
title: 'Compression Finished',
title: trText('Compression Finished'),
message: parts.join(' '),
type: 'warning',
};
}
if (result.metTarget === false) {
return {
title: 'Target Not Reached',
title: trText('Target Not Reached'),
message: parts.join(' '),
type: 'warning',
};
}
return {
title: 'Compression Complete',
title: trText('Compression Complete'),
message: parts.join(' '),
type: 'success',
};
@@ -477,11 +534,11 @@ function createPasswordField(
const input = document.createElement('input');
input.type = 'password';
input.placeholder = 'Enter password';
input.placeholder = trText('Enter password');
input.autocomplete = 'off';
input.value = value;
input.setAttribute('aria-label', label);
input.className = `${PASSWORD_INPUT_CLASS} ${LOCK_MESSAGES.locked.field}`;
input.className = `${PASSWORD_INPUT_CLASS} ${getLockMessages().locked.field}`;
input.addEventListener('input', () => onInput(input.value));
const showIcon = createIcon('eye', '');
@@ -490,14 +547,14 @@ function createPasswordField(
toggle.type = 'button';
toggle.className =
'absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-200';
toggle.setAttribute('aria-label', 'Show password');
toggle.setAttribute('aria-label', trText('Show password'));
toggle.append(showIcon, hideIcon);
toggle.addEventListener('click', () => {
const reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
toggle.setAttribute(
'aria-label',
reveal ? 'Hide password' : 'Show password'
reveal ? trText('Hide password') : trText('Show password')
);
showIcon.classList.toggle('hidden', reveal);
hideIcon.classList.toggle('hidden', !reveal);
@@ -517,7 +574,7 @@ function createIcon(name: string, className: string): HTMLElement {
return icon;
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const compressOptions = document.getElementById('compress-options');
@@ -652,12 +709,16 @@ document.addEventListener('DOMContentLoaded', () => {
(file) => fileLocks.get(file)?.status === 'unlocked'
).length;
if (sharedMissed && unlocked < files.length) {
sharedView.status.textContent =
'That password did not open any of the remaining files.';
sharedView.status.textContent = trText(
'That password did not open any of the remaining files.'
);
sharedView.status.className = 'text-xs mt-1 text-red-400';
return;
}
sharedView.status.textContent = `Unlocked ${unlocked} of ${files.length} files.`;
sharedView.status.textContent = trText(
'Unlocked {{unlocked}} of {{length}} files.',
{ unlocked, length: files.length }
);
sharedView.status.className = `text-xs mt-1 ${unlocked === files.length ? 'text-green-400' : 'text-gray-400'}`;
};
@@ -665,7 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
const lock = fileLocks.get(file);
const view = lockViews.get(file);
if (lock && view && lock.status !== 'checking' && lock.status !== 'open') {
const { text, color, field } = LOCK_MESSAGES[lock.status];
const { text, color, field } = getLockMessages()[lock.status];
if (view.input.value !== lock.password) view.input.value = lock.password;
view.status.textContent = text;
view.status.className = `truncate ${color}`;
@@ -736,9 +797,9 @@ document.addEventListener('DOMContentLoaded', () => {
sharedDiv.className = 'bg-gray-700 p-3 rounded-lg text-sm';
const sharedLabel = document.createElement('div');
sharedLabel.className = 'font-medium text-gray-200 text-sm';
sharedLabel.textContent = 'Password for all locked files';
sharedLabel.textContent = trText('Password for all locked files');
sharedView = createPasswordField(
'Password for all locked files',
trText('Password for all locked files'),
sharedPassword,
applySharedPassword
);
@@ -774,7 +835,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (locked) {
const view = createPasswordField(
`Password for ${file.name}`,
trText('Password for {{name}}', { name: file.name }),
lock.password,
(value) => {
lock.password = value;
@@ -785,7 +846,9 @@ document.addEventListener('DOMContentLoaded', () => {
view.root.classList.add('mt-3');
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 whitespace-nowrap';
sizeSpan.textContent = `${formatBytes(file.size)} ·`;
sizeSpan.textContent = trText('{{size}} ·', {
size: formatBytes(file.size),
});
metaSpan.className = 'flex gap-1 min-w-0 text-xs text-gray-400';
metaSpan.replaceChildren(sizeSpan, view.status);
fileDiv.append(view.root);
@@ -795,7 +858,10 @@ document.addEventListener('DOMContentLoaded', () => {
removeBtn.type = 'button';
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.setAttribute('aria-label', `Remove ${file.name}`);
removeBtn.setAttribute(
'aria-label',
trText('Remove {{name}}', { name: file.name })
);
removeBtn.append(createIcon('trash', ''));
removeBtn.onclick = () => {
state.files = state.files.filter((f) => f !== file);
@@ -842,7 +908,9 @@ document.addEventListener('DOMContentLoaded', () => {
showLoader(
targetBytes === undefined
? 'Compressing...'
: `Compressing to ${formatBytes(targetBytes)}...`
: trText('Compressing to {{size}}...', {
size: formatBytes(targetBytes),
})
);
const result = await compressWithHyper(await file.arrayBuffer(), {
options: runOptions,
@@ -854,7 +922,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (result.signed) {
showAlert(
'Signed PDF Not Changed',
`${file.name} is digitally signed, so it was left as it is. Compressing it would break the signature. To compress it anyway, turn on Override content protection under Custom Settings; its signatures will be removed.`,
trText(
'{{name}} is digitally signed, so it was left as it is. Compressing it would break the signature. To compress it anyway, turn on Override content protection under Custom Settings; its signatures will be removed.',
{ name: file.name }
),
'warning',
() => resetState()
);
@@ -873,7 +944,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
title,
password
? `${message} It was password protected, so the compressed copy has no password. Use Protect PDF to add one again.`
? trText(
'{{message}} It was password protected, so the compressed copy has no password. Use Protect PDF to add one again.',
{ message }
)
: message,
type,
() => resetState()
@@ -901,7 +975,13 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < files.length; i++) {
const file = files[i];
showLoader(`Compressing ${i + 1}/${files.length}: ${file.name}...`);
showLoader(
trText('Compressing {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: files.length,
name: file.name,
})
);
const lock = fileLocks.get(file);
const password = isProtected(lock) ? lock.password : '';
try {
@@ -931,7 +1011,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`None of the files could be compressed: ${failures.join(', ')}.`
trText('None of the files could be compressed: {{join}}.', {
join: failures.join(', '),
})
);
return;
}
@@ -944,8 +1026,22 @@ document.addEventListener('DOMContentLoaded', () => {
const totalSavings = totalOriginalSize - totalCompressedSize;
const parts = [
totalSavings > 0
? `Compressed ${compressedCount} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${((totalSavings / totalOriginalSize) * 100).toFixed(1)}%).`
: `Compressed ${compressedCount} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`,
? trText(
'Compressed {{compressedCount}} PDF(s). Total size reduced from {{totalOriginalSize}} to {{totalCompressedSize}} (Saved {{value}}%).',
{
compressedCount,
totalOriginalSize: formatBytes(totalOriginalSize),
totalCompressedSize: formatBytes(totalCompressedSize),
value: ((totalSavings / totalOriginalSize) * 100).toFixed(1),
}
)
: trText(
'Compressed {{compressedCount}} PDF(s). Total size: {{totalCompressedSize}}.',
{
compressedCount,
totalCompressedSize: formatBytes(totalCompressedSize),
}
),
];
if (signedCount > 0) {
parts.push(
@@ -973,7 +1069,7 @@ document.addEventListener('DOMContentLoaded', () => {
);
}
if (failures.length > 0) {
parts.push(`Skipped: ${failures.join(', ')}.`);
parts.push(trText('Skipped: {{join}}.', { join: failures.join(', ') }));
}
const hasProblems = failures.length > 0 || missedTargetCount > 0;
@@ -999,8 +1095,14 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Password Needed',
needPassword.length === 1
? `${needPassword[0].name} is password protected. Enter its password on the file card, then compress again.`
: `These files are password protected: ${needPassword.map((file) => file.name).join(', ')}. Enter their passwords on the file cards, or remove them, then compress again.`,
? trText(
'{{name}} is password protected. Enter its password on the file card, then compress again.',
{ name: needPassword[0].name }
)
: trText(
'These files are password protected: {{names}}. Enter their passwords on the file cards, or remove them, then compress again.',
{ names: needPassword.map((file) => file.name).join(', ') }
),
'warning',
() => lockViews.get(needPassword[0])?.input.focus()
);
@@ -1032,7 +1134,10 @@ document.addEventListener('DOMContentLoaded', () => {
updateUI();
showAlert(
'Password Needed',
`${file.name} is password protected. Enter its password on the file card, then compress again.`,
trText(
'{{name}} is password protected. Enter its password on the file card, then compress again.',
{ name: file.name }
),
'warning',
() => lockViews.get(file)?.input.focus()
);
@@ -1041,7 +1146,9 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('[CompressPDF] Error:', e);
showAlert(
'Error',
`An error occurred during compression. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during compression. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
@@ -1061,8 +1168,13 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Not a PDF',
rejected.length === 1
? `${rejected[0]} is not a valid PDF, so it was not added.`
: `These files are not valid PDFs, so they were not added: ${rejected.join(', ')}.`,
? trText('{{name}} is not a valid PDF, so it was not added.', {
name: rejected[0],
})
: trText(
'These files are not valid PDFs, so they were not added: {{names}}.',
{ names: rejected.join(', ') }
),
'warning'
);
}
+18 -9
View File
@@ -9,6 +9,8 @@ import { CropperState, CropPercentages } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const cropperState: CropperState = {
pdfDoc: null,
currentPageNum: 1,
@@ -18,11 +20,7 @@ const cropperState: CropperState = {
file: null,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -124,7 +122,10 @@ function updateFileDisplay() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(cropperState.file.size)} • ${cropperState.pdfDoc?.numPages || 0} pages`;
metaSpan.textContent = trText('{{size}} • {{value}} pages', {
size: formatBytes(cropperState.file.size),
value: cropperState.pdfDoc?.numPages || 0,
});
infoContainer.append(nameSpan, metaSpan);
@@ -153,7 +154,7 @@ function saveCurrentCrop() {
}
async function displayPageAsImage(num: number) {
showLoader(`Rendering Page ${num}...`);
showLoader(trText('Rendering Page {{num}}...', { num }));
try {
const page = await cropperState.pdfDoc.getPage(num);
@@ -226,7 +227,10 @@ async function changePage(offset: number) {
function updatePageInfo() {
const pageInfo = document.getElementById('page-info');
if (pageInfo)
pageInfo.textContent = `Page ${cropperState.currentPageNum} of ${cropperState.pdfDoc.numPages}`;
pageInfo.textContent = trText('Page {{currentPageNum}} of {{numPages}}', {
currentPageNum: cropperState.currentPageNum,
numPages: cropperState.pdfDoc.numPages,
});
}
function enableControls() {
@@ -353,7 +357,12 @@ async function performFlatteningCrop(
for (let i = 0; i < totalPages; i++) {
const pageNum = i + 1;
showLoader(`Processing page ${pageNum} of ${totalPages}...`);
showLoader(
trText('Processing page {{pageNum}} of {{totalPages}}...', {
pageNum,
totalPages,
})
);
if (cropData[pageNum]) {
const page = await cropperState.pdfDoc.getPage(pageNum);
+16 -4
View File
@@ -15,6 +15,8 @@ import type { CropPercentages } from '@/types';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import '../utils/setup-pdf-worker.js';
import { trText } from '../i18n/i18n.js';
const cropperState: {
pdfDoc: PDFDocumentProxy | null;
currentPageNum: number;
@@ -54,7 +56,7 @@ function saveCurrentCrop() {
*/
async function displayPageAsImage(num: number) {
showLoader(`Rendering Page ${num}...`);
showLoader(trText('Rendering Page {{num}}...', { num }));
try {
const page = await cropperState.pdfDoc.getPage(num);
@@ -130,8 +132,13 @@ async function changePage(offset: number) {
}
function updatePageInfo() {
document.getElementById('page-info').textContent =
`Page ${cropperState.currentPageNum} of ${cropperState.pdfDoc.numPages}`;
document.getElementById('page-info').textContent = trText(
'Page {{currentPageNum}} of {{numPages}}',
{
currentPageNum: cropperState.currentPageNum,
numPages: cropperState.pdfDoc.numPages,
}
);
}
function enableControls() {
@@ -216,7 +223,12 @@ async function performFlatteningCrop(
for (let i = 0; i < totalPages; i++) {
const pageNum = i + 1;
showLoader(`Processing page ${pageNum} of ${totalPages}...`);
showLoader(
trText('Processing page {{pageNum}} of {{totalPages}}...', {
pageNum,
totalPages,
})
);
if (cropData[pageNum]) {
const page = await cropperState.pdfDoc.getPage(pageNum);
+17 -5
View File
@@ -3,7 +3,9 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
document.addEventListener('DOMContentLoaded', () => {
import { trText, onPageReady } from '../i18n/i18n.js';
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@@ -123,7 +125,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -145,7 +149,11 @@ document.addEventListener('DOMContentLoaded', () => {
const overallPercent =
(i / state.files.length) * 100 + percent / state.files.length;
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`,
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
}),
overallPercent
);
},
@@ -172,7 +180,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} CSV file(s) to PDF.`,
trText('Successfully converted {{length}} CSV file(s) to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -186,7 +196,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+11 -8
View File
@@ -12,6 +12,8 @@ import { DeletePagesState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const deleteState: DeletePagesState = {
file: null,
pdfDoc: null,
@@ -20,11 +22,7 @@ const deleteState: DeletePagesState = {
pagesToDelete: new Set(),
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -124,7 +122,10 @@ function updateFileDisplay() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(deleteState.file.size)} • ${deleteState.totalPages} pages`;
metaSpan.textContent = trText('{{size}} • {{totalPages}} pages', {
size: formatBytes(deleteState.file.size),
totalPages: deleteState.totalPages,
});
infoContainer.append(nameSpan, metaSpan);
@@ -177,7 +178,7 @@ async function renderThumbnails() {
const pageLabel = document.createElement('span');
pageLabel.className =
'absolute top-1 left-1 bg-gray-800 text-white text-xs px-1.5 py-0.5 rounded';
pageLabel.textContent = `${i}`;
pageLabel.textContent = trText('{{i}}', { i });
const deleteOverlay = document.createElement('div');
deleteOverlay.className =
@@ -273,7 +274,9 @@ async function deletePages() {
hideLoader();
showAlert(
'Success',
`Deleted ${deleteState.pagesToDelete.size} page(s) successfully!`,
trText('Deleted {{size}} page(s) successfully!', {
size: deleteState.pagesToDelete.size,
}),
'success',
() => resetState()
);
+22 -10
View File
@@ -6,6 +6,8 @@ import { downloadFile } from '../utils/helpers';
import { isWasmAvailable } from '../config/wasm-cdn-config.js';
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { trText, trHtml, onPageReady } from '../i18n/i18n.js';
interface DeskewResult {
totalPages: number;
correctedPages: number;
@@ -27,7 +29,7 @@ function showLoader(message: string): void {
const loader = document.getElementById('loader-modal');
const text = document.getElementById('loader-text');
if (loader && text) {
text.textContent = message;
text.textContent = trText(message);
loader.classList.remove('hidden');
}
}
@@ -44,8 +46,8 @@ function showAlert(title: string, message: string): void {
const titleEl = document.getElementById('alert-title');
const msgEl = document.getElementById('alert-message');
if (modal && titleEl && msgEl) {
titleEl.textContent = title;
msgEl.textContent = message;
titleEl.textContent = trText(title);
msgEl.textContent = trText(message);
modal.classList.remove('hidden');
}
}
@@ -90,7 +92,9 @@ function updateFileDisplay(): void {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'text-gray-500 text-sm';
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
sizeSpan.textContent = trText('({{value}} KB)', {
value: (file.size / 1024).toFixed(1),
});
info.append(fileIcon, nameSpan, sizeSpan);
@@ -141,9 +145,9 @@ function displayResults(result: DeskewResult): void {
return `
<div class="flex items-center gap-2 text-sm py-1">
<i data-lucide="${icon}" class="w-4 h-4 ${color}"></i>
<span class="text-gray-300">Page ${idx + 1}:</span>
<span class="text-gray-300">${trHtml('Page {{n}}:', { n: idx + 1 })}</span>
<span class="${color}">${angle.toFixed(2)}°</span>
${wasCorrected ? '<span class="text-green-400 text-xs">(corrected)</span>' : ''}
${wasCorrected ? `<span class="text-green-400 text-xs">${trHtml('(corrected)')}</span>` : ''}
</div>
`;
})
@@ -181,7 +185,7 @@ async function processDeskew(): Promise<void> {
await pdf.load();
for (const file of selectedFiles) {
showLoader(`Deskewing ${file.name}...`);
showLoader(trText('Deskewing {{name}}...', { name: file.name }));
const { pdf: resultPdf, result } = await pdf.deskewPdf(file, {
threshold,
@@ -196,14 +200,22 @@ async function processDeskew(): Promise<void> {
hideLoader();
showAlert(
'Success',
`Deskewed ${selectedFiles.length} file(s). ${selectedFiles.length > 1 ? 'Downloads started for all files.' : ''}`
trText('Deskewed {{length}} file(s). {{value}}', {
length: selectedFiles.length,
value:
selectedFiles.length > 1
? trText('Downloads started for all files.')
: '',
})
);
} catch (error) {
hideLoader();
console.error('Deskew error:', error);
showAlert(
'Error',
`Failed to deskew PDF: ${error instanceof Error ? error.message : 'Unknown error'}`
trText('Failed to deskew PDF: {{error}}', {
error: error instanceof Error ? error.message : trText('Unknown error'),
})
);
}
}
@@ -280,4 +292,4 @@ function initPage(): void {
createIcons({ icons });
}
document.addEventListener('DOMContentLoaded', initPage);
onPageReady(initPage);
+20 -10
View File
@@ -7,7 +7,7 @@ import {
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { sanitizeTranslationHtml } from '../utils/translation-html.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
signPdf,
parsePfxFile,
@@ -286,7 +286,10 @@ async function updatePdfDisplay(): Promise<void> {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(state.pdfFile.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -318,7 +321,12 @@ async function updatePdfDisplay(): Promise<void> {
state.pdfFile = result.file;
state.pdfBytes = new Uint8Array(result.bytes);
nameSpan.textContent = result.file.name;
metaSpan.textContent = `${formatBytes(result.file.size)} • ${t('tools:digitalSignPdf.pageCount', { count: result.pdf.numPages })}`;
metaSpan.textContent = trText('{{size}} • {{numPages}}', {
size: formatBytes(result.file.size),
numPages: t('tools:digitalSignPdf.pageCount', {
count: result.pdf.numPages,
}),
});
result.pdf.destroy();
}
}
@@ -575,7 +583,10 @@ async function handlePasswordInput(): Promise<void> {
error instanceof Error
? error.message
: t('tools:digitalSignPdf.invalidPasswordOrCertificate');
certStatus.textContent = errorMessage.includes('password')
const passwordProblem =
error instanceof Error &&
(error.name === 'PasswordError' || /password/i.test(errorMessage));
certStatus.textContent = passwordProblem
? t('tools:digitalSignPdf.incorrectPassword')
: t('tools:digitalSignPdf.failedParseCertificate');
certStatus.className = 'text-xs text-red-400';
@@ -603,7 +614,10 @@ function updateCertInfo(): void {
}
if (certValidity) {
const formatDate = (date: Date) => date.toLocaleDateString();
certValidity.textContent = `${formatDate(info.validFrom)} - ${formatDate(info.validTo)}`;
certValidity.textContent = trText('{{validFrom}} - {{validTo}}', {
validFrom: formatDate(info.validFrom),
validTo: formatDate(info.validTo),
});
}
certInfo.classList.remove('hidden');
@@ -787,8 +801,4 @@ async function processSignature(): Promise<void> {
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
+20 -10
View File
@@ -3,6 +3,8 @@ import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types';
import { isValidTsaRequestUrl } from '../config/timestamp-tsa.js';
import { trText } from '../i18n/i18n.js';
export function parsePfxFile(
pfxBytes: ArrayBuffer,
password: string
@@ -19,17 +21,17 @@ export function parsePfxFile(
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file');
throw new Error(trText('No certificate found in PFX file'));
}
if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file');
throw new Error(trText('No private key found in PFX file'));
}
const certificate = certBagArray[0].cert;
if (!certificate) {
throw new Error('Failed to extract certificate from PFX file');
throw new Error(trText('Failed to extract certificate from PFX file'));
}
return { p12Buffer: pfxBytes, password, certificate };
@@ -45,11 +47,17 @@ export function parsePemFiles(
let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) {
throw new Error('Password required for encrypted private key');
const error = new Error(
trText('Password required for encrypted private key')
);
error.name = 'PasswordError';
throw error;
}
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
const error = new Error(trText('Failed to decrypt private key'));
error.name = 'PasswordError';
throw error;
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
@@ -83,11 +91,11 @@ export function parseCombinedPem(
);
if (!certMatch) {
throw new Error('No certificate found in PEM file');
throw new Error(trText('No certificate found in PEM file'));
}
if (!keyMatch) {
throw new Error('No private key found in PEM file');
throw new Error(trText('No private key found in PEM file'));
}
return parsePemFiles(certMatch[0], keyMatch[0], password);
@@ -378,7 +386,9 @@ export async function timestampPdf(
): Promise<Uint8Array> {
if (!isValidTsaRequestUrl(tsaUrl)) {
throw new Error(
`Invalid TSA URL. The timestamp authority must be a valid http:// or https:// URL.`
trText(
'Invalid TSA URL. The timestamp authority must be a valid http:// or https:// URL.'
)
);
}
@@ -436,8 +446,8 @@ export function getCertificateInfo(certificate: forge.pki.Certificate): {
const issuerCN = certificate.issuer.getField('CN');
return {
subject: (subjectCN?.value as string) ?? 'Unknown',
issuer: (issuerCN?.value as string) ?? 'Unknown',
subject: (subjectCN?.value as string) ?? trText('Unknown'),
issuer: (issuerCN?.value as string) ?? trText('Unknown'),
validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber,
+16 -4
View File
@@ -10,6 +10,8 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: DividePagesState = {
file: null,
pdfDoc: null,
@@ -63,7 +65,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -92,7 +96,10 @@ async function updateUI() {
pageState.totalPages = pageState.pdfDoc.getPageCount();
hideLoader();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.totalPages} pages`;
metaSpan.textContent = trText('{{size}} • {{totalPages}} pages', {
size: formatBytes(pageState.file.size),
totalPages: pageState.totalPages,
});
if (toolOptions) toolOptions.classList.remove('hidden');
} catch (error) {
@@ -151,7 +158,12 @@ async function dividePages() {
const originalPage = pages[i];
const { width, height } = originalPage.getSize();
showLoader(`Processing page ${pageNum} of ${pages.length}...`);
showLoader(
trText('Processing page {{pageNum}} of {{length}}...', {
pageNum,
length: pages.length,
})
);
if (pagesToDivide.has(pageNum)) {
const [page1] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
@@ -211,7 +223,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+2 -6
View File
@@ -3,7 +3,7 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { formatBytes, downloadFile } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { t } from '../i18n/index.js';
import { t, onPageReady } from '../i18n/index.js';
import { PDFDocument } from 'pdf-lib';
import JSZip from 'jszip';
@@ -28,11 +28,7 @@ const translate = (
return translation && translation !== key ? translation : fallback;
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
/**
* Wires duplex-collate page event handlers and initial UI behavior.
+10 -3
View File
@@ -12,6 +12,8 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import '../utils/setup-pdf-worker.js';
import { trText } from '../i18n/i18n.js';
const duplicateOrganizeState: {
sortableInstances: { pageGrid?: Sortable };
} = {
@@ -133,7 +135,7 @@ export async function renderDuplicateOrganizeThumbnails() {
const duplicateBtn = document.createElement('button');
duplicateBtn.className =
'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
duplicateBtn.title = 'Duplicate Page';
duplicateBtn.title = trText('Duplicate Page');
const duplicateIcon = document.createElement('i');
duplicateIcon.setAttribute('data-lucide', 'copy-plus');
duplicateIcon.className = 'w-5 h-5';
@@ -142,7 +144,7 @@ export async function renderDuplicateOrganizeThumbnails() {
const deleteBtn = document.createElement('button');
deleteBtn.className =
'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
deleteBtn.title = 'Delete Page';
deleteBtn.title = trText('Delete Page');
const deleteIcon = document.createElement('i');
deleteIcon.setAttribute('data-lucide', 'x-circle');
deleteIcon.className = 'w-5 h-5';
@@ -163,7 +165,12 @@ export async function renderDuplicateOrganizeThumbnails() {
useLazyLoading: true,
lazyLoadMargin: '400px',
onProgress: (current, total) => {
showLoader(`Rendering page previews: ${current}/${total}`);
showLoader(
trText('Rendering page previews: {{current}}/{{total}}', {
current,
total,
})
);
},
onBatchComplete: () => {
createIcons({ icons });
+14 -10
View File
@@ -9,6 +9,8 @@ import {
} from '../utils/wasm-provider.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const worker = new Worker(
import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js'
);
@@ -96,7 +98,7 @@ function displayAttachments(attachments: AttachmentInfo[]) {
if (attachments.length === 0) {
const noAttachments = document.createElement('p');
noAttachments.className = 'text-gray-400 text-center py-4';
noAttachments.textContent = 'No attachments found in this PDF.';
noAttachments.textContent = trText('No attachments found in this PDF.');
attachmentsList.appendChild(noAttachments);
return;
}
@@ -108,7 +110,7 @@ function displayAttachments(attachments: AttachmentInfo[]) {
const removeAllBtn = document.createElement('button');
removeAllBtn.className =
'bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded text-sm';
removeAllBtn.textContent = 'Remove All Attachments';
removeAllBtn.textContent = trText('Remove All Attachments');
removeAllBtn.onclick = function () {
if (pageState.allAttachments.length === 0) return;
@@ -131,7 +133,7 @@ function displayAttachments(attachments: AttachmentInfo[]) {
}
}
});
removeAllBtn.textContent = 'Remove All Attachments';
removeAllBtn.textContent = trText('Remove All Attachments');
} else {
pageState.allAttachments.forEach(function (attachment) {
pageState.attachmentsToRemove.add(attachment.index);
@@ -147,7 +149,7 @@ function displayAttachments(attachments: AttachmentInfo[]) {
}
}
});
removeAllBtn.textContent = 'Deselect All';
removeAllBtn.textContent = trText('Deselect All');
}
};
@@ -171,9 +173,11 @@ function displayAttachments(attachments: AttachmentInfo[]) {
const levelSpan = document.createElement('span');
levelSpan.className = 'text-gray-400 text-sm block';
if (attachment.page === 0) {
levelSpan.textContent = 'Document-level attachment';
levelSpan.textContent = trText('Document-level attachment');
} else {
levelSpan.textContent = `Page ${attachment.page} attachment`;
levelSpan.textContent = trText('Page {{page}} attachment', {
page: attachment.page,
});
}
infoDiv.append(nameSpan, levelSpan);
@@ -184,7 +188,7 @@ function displayAttachments(attachments: AttachmentInfo[]) {
const removeBtn = document.createElement('button');
removeBtn.className = `${pageState.attachmentsToRemove.has(attachment.index) ? 'bg-gray-600' : 'bg-red-600'} hover:bg-red-700 text-white px-3 py-1 rounded text-sm`;
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.title = 'Remove attachment';
removeBtn.title = trText('Remove attachment');
removeBtn.onclick = function () {
if (pageState.attachmentsToRemove.has(attachment.index)) {
pageState.attachmentsToRemove.delete(attachment.index);
@@ -202,8 +206,8 @@ function displayAttachments(attachments: AttachmentInfo[]) {
return pageState.attachmentsToRemove.has(att.index);
});
removeAllBtn.textContent = allSelected
? 'Deselect All'
: 'Remove All Attachments';
? trText('Deselect All')
: trText('Remove All Attachments');
};
actionsDiv.append(removeBtn);
@@ -346,7 +350,7 @@ async function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+12 -5
View File
@@ -6,6 +6,8 @@ import { PDFName, PDFString } from 'pdf-lib';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: EditMetadataState = {
file: null,
pdfDoc: null,
@@ -70,14 +72,14 @@ function addCustomFieldRow(key: string = '', value: string = '') {
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.placeholder = 'Key (e.g., Department)';
keyInput.placeholder = trText('Key (e.g., Department)');
keyInput.value = key;
keyInput.className =
'custom-meta-key w-full bg-gray-700 border border-gray-600 text-white text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 p-2.5';
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.placeholder = 'Value (e.g., Marketing)';
valueInput.placeholder = trText('Value (e.g., Marketing)');
valueInput.value = value;
valueInput.className =
'custom-meta-value w-full bg-gray-700 border border-gray-600 text-white text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 p-2.5';
@@ -205,7 +207,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -233,7 +237,10 @@ async function updateUI() {
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
populateMetadataFields();
@@ -388,7 +395,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+20 -17
View File
@@ -24,6 +24,8 @@ import type {
RedactionPluginLite,
} from '@/types';
import { trText, onPageReady } from '../i18n/i18n.js';
const FREETEXT_SUBTYPE = 3;
export function trackAppliedRedactions(
@@ -139,7 +141,7 @@ function confirmChoice(
cancelBtn.type = 'button';
cancelBtn.className =
'flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
cancelBtn.textContent = 'Cancel';
cancelBtn.textContent = trText('Cancel');
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
@@ -241,11 +243,7 @@ function removeFileEntry(documentId: string) {
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -417,7 +415,7 @@ async function handleFiles(files: FileList) {
downloadBtn = document.createElement('button');
downloadBtn.id = 'download-edited-pdf';
downloadBtn.className = 'btn-gradient w-full mt-6';
downloadBtn.textContent = 'Download Edited PDF';
downloadBtn.textContent = trText('Download Edited PDF');
pdfWrapper.appendChild(downloadBtn);
}
downloadBtn.classList.remove('hidden');
@@ -427,14 +425,17 @@ async function handleFiles(files: FileList) {
const documentId = activeDocuments.getActiveDocumentId();
const pendingCount = redactionPlugin?.getState().pendingCount ?? 0;
if (redactionPlugin && pendingCount > 0) {
const marks =
pendingCount === 1
? "1 redaction mark that hasn't"
: `${pendingCount} redaction marks that haven't`;
const shouldApply = await confirmChoice(
'Apply redactions?',
`This document has ${marks} been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?`,
'Apply and download'
trText('Apply redactions?'),
pendingCount === 1
? trText(
"This document has 1 redaction mark that hasn't been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?"
)
: trText(
"This document has {{count}} redaction marks that haven't been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?",
{ count: pendingCount }
),
trText('Apply and download')
);
if (!shouldApply) return;
try {
@@ -505,9 +506,11 @@ async function handleFiles(files: FileList) {
});
if (exported.isEncrypted) {
const shouldDownload = await confirmChoice(
'Encrypted file',
"Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?",
'Download anyway'
trText('Encrypted file'),
trText(
"Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?"
),
trText('Download anyway')
);
if (!shouldDownload) return;
} else {
+76 -63
View File
@@ -1,30 +1,35 @@
import { trText } from '../i18n/i18n.js';
type SectionSpec = { id: string; title: string; icon: string };
type RangeLink = { range: HTMLInputElement; num: HTMLInputElement };
const TEXT_SECTIONS: SectionSpec[] = [
{ id: 'font', title: 'Font', icon: 'ph-text-aa' },
{ id: 'size', title: 'Font Size', icon: 'ph-text-t' },
{ id: 'color', title: 'Text Color', icon: 'ph-palette' },
{ id: 'align', title: 'Alignment', icon: 'ph-text-align-left' },
{ id: 'spacing', title: 'Spacing', icon: 'ph-arrows-out-line-vertical' },
{ id: 'lists', title: 'Lists', icon: 'ph-list-bullets' },
{ id: 'more', title: 'More', icon: 'ph-dots-three' },
const getTextSections = (): SectionSpec[] => [
{ id: 'font', title: trText('Font'), icon: 'ph-text-aa' },
{ id: 'size', title: trText('Font Size'), icon: 'ph-text-t' },
{ id: 'color', title: trText('Text Color'), icon: 'ph-palette' },
{ id: 'align', title: trText('Alignment'), icon: 'ph-text-align-left' },
{
id: 'spacing',
title: trText('Spacing'),
icon: 'ph-arrows-out-line-vertical',
},
{ id: 'lists', title: trText('Lists'), icon: 'ph-list-bullets' },
{ id: 'more', title: trText('More'), icon: 'ph-dots-three' },
];
const SCOPE_SECTION: SectionSpec = {
const getScopeSection = (): SectionSpec => ({
id: 'scope',
title: 'Edit Scope',
title: trText('Edit Scope'),
icon: 'ph-pencil-simple',
};
const STROKE_SECTION: SectionSpec = {
});
const getStrokeSection = (): SectionSpec => ({
id: 'stroke',
title: 'Path Stroke',
title: trText('Path Stroke'),
icon: 'ph-path',
};
const MULTI_SECTION: SectionSpec = {
});
const getMultiSection = (): SectionSpec => ({
id: 'alignMulti',
title: 'Align Objects',
title: trText('Align Objects'),
icon: 'ph-align-left',
};
});
const OBJ_BTN_IDS = [
'rotL',
@@ -39,16 +44,16 @@ const OBJ_BTN_IDS = [
'dupe',
];
const SLIDER_FIELDS: [string, string][] = [
['fLine', 'Line'],
['fPara', 'Paragraph'],
['fChar', 'Character'],
['fWordSp', 'Word'],
const getSliderFields = (): [string, string][] => [
['fLine', trText('Line')],
['fPara', trText('Paragraph')],
['fChar', trText('Character')],
['fWordSp', trText('Word')],
];
const MORE_FIELDS: [string, string][] = [
['fHScale', 'H-Scale %'],
['fFirstInd', 'First Indent'],
['fHangInd', 'Hanging Indent'],
const getMoreFields = (): [string, string][] => [
['fHScale', trText('H-Scale %')],
['fFirstInd', trText('First Indent')],
['fHangInd', trText('Hanging Indent')],
];
const q = (id: string): HTMLElement | null => document.getElementById(id);
@@ -95,7 +100,7 @@ function setupPagePill(): void {
const pageLabel = q('pageLabel');
const app = q('text-editor-app');
if (!save || !pageLabel || !app || !save.parentElement) return;
const pill = mkBtn('dpagepill', 'Go to page');
const pill = mkBtn('dpagepill', trText('Go to page'));
pill.textContent = '– / –';
save.parentElement.insertBefore(pill, save);
const syncPill = () => {
@@ -130,10 +135,10 @@ function setupPagePill(): void {
menu.style.right = '8px';
menu.addEventListener('click', (e) => e.stopPropagation());
const nav = div('dpagenav');
const prevB = mkBtn('dnavbtn', 'Previous page');
const prevB = mkBtn('dnavbtn', trText('Previous page'));
prevB.appendChild(ph('ph-caret-left'));
prevB.addEventListener('click', () => q('prev')?.click());
const nextB = mkBtn('dnavbtn', 'Next page');
const nextB = mkBtn('dnavbtn', trText('Next page'));
nextB.appendChild(ph('ph-caret-right'));
nextB.addEventListener('click', () => q('next')?.click());
const inp = document.createElement('input');
@@ -141,7 +146,7 @@ function setupPagePill(): void {
inp.min = '1';
inp.step = '1';
inp.className = 'dpageinp';
inp.setAttribute('aria-label', 'Page number');
inp.setAttribute('aria-label', trText('Page number'));
const st = parse();
if (Number.isFinite(st.current)) inp.value = String(st.current);
if (Number.isFinite(st.total)) inp.max = String(st.total);
@@ -165,15 +170,15 @@ function setupPagePill(): void {
e.preventDefault();
go();
});
const goBtn = mkBtn('dnavbtn dgo', 'Go to page');
goBtn.textContent = 'Go';
const goBtn = mkBtn('dnavbtn dgo', trText('Go to page'));
goBtn.textContent = trText('Go');
goBtn.addEventListener('click', go);
nav.append(prevB, inp, goBtn, nextB);
menu.appendChild(nav);
menu.appendChild(document.createElement('hr'));
const props = mkBtn('ecmenu-item', 'Document properties');
const props = mkBtn('ecmenu-item', trText('Document properties'));
const propLabel = document.createElement('span');
propLabel.textContent = 'Document properties';
propLabel.textContent = trText('Document properties');
props.append(ph('ph-info'), propLabel);
props.addEventListener('click', () => {
closeMenu();
@@ -221,7 +226,7 @@ export function setupFormatDock(): void {
const dticon = document.createElement('span');
const dtext = document.createElement('span');
dtitle.append(dticon, dtext);
const dclose = mkBtn('dclose', 'Close panel');
const dclose = mkBtn('dclose', trText('Close panel'));
dclose.appendChild(ph('ph-x'));
head.append(dtitle, dclose);
const slotsWrap = div('dockslots');
@@ -232,7 +237,7 @@ export function setupFormatDock(): void {
const rail = div('dockrail');
const chipsWrap = div('dockchips');
const acts = div('dockacts');
const delChip = mkBtn('dchip ddel', 'Delete');
const delChip = mkBtn('dchip ddel', trText('Delete'));
delChip.appendChild(ph('ph-trash'));
delChip.addEventListener('click', () => q('del')?.click());
acts.appendChild(delChip);
@@ -296,8 +301,8 @@ export function setupFormatDock(): void {
});
return b;
};
const minus = step(-1, 'ph-minus', 'Decrease size');
const plus = step(1, 'ph-plus', 'Increase size');
const minus = step(-1, 'ph-minus', trText('Decrease size'));
const plus = step(1, 'ph-plus', trText('Increase size'));
const host = stepperOf(fSize) ?? fSize;
sizer.append(minus, host, plus);
sizeSlot.appendChild(sizer);
@@ -311,13 +316,13 @@ export function setupFormatDock(): void {
const alignRow = rowOf(q('fAlign'));
if (alignRow) alignSlot.appendChild(alignRow);
const dirLab = div('dlab dsublab');
dirLab.textContent = 'Direction';
dirLab.textContent = trText('Direction');
alignSlot.appendChild(dirLab);
const dirRow = rowOf(q('fDir'));
if (dirRow) alignSlot.appendChild(dirRow);
const spacingSlot = mkSlot('spacing');
for (const [id, label] of SLIDER_FIELDS) {
for (const [id, label] of getSliderFields()) {
const row = sliderRow(id, label);
if (row) spacingSlot.appendChild(row);
}
@@ -328,11 +333,11 @@ export function setupFormatDock(): void {
const moreSlot = mkSlot('more');
const outLab = div('dlab dsublab');
outLab.textContent = 'Outline';
outLab.textContent = trText('Outline');
moreSlot.appendChild(outLab);
const outlineRow = rowOf(q('fStrokeColor'));
if (outlineRow) moreSlot.appendChild(outlineRow);
for (const [id, label] of MORE_FIELDS) {
for (const [id, label] of getMoreFields()) {
const row = sliderRow(id, label);
if (row) moreSlot.appendChild(row);
}
@@ -348,10 +353,10 @@ export function setupFormatDock(): void {
scopeSlot.appendChild(scopeWrap);
let picked = new Set<string>(['all']);
const scopeDefs: [string, string][] = [
['all', 'All'],
['text', 'Text'],
['image', 'Images'],
['shape', 'Shapes'],
['all', trText('All')],
['text', trText('Text')],
['image', trText('Images')],
['shape', trText('Shapes')],
];
const renderScope = () => {
scopeWrap.replaceChildren();
@@ -459,7 +464,10 @@ export function setupFormatDock(): void {
const grab = div('dgrab');
grab.appendChild(document.createElement('span'));
const shead = div('dsheethead');
shead.append(ph('ph-text-aa'), document.createTextNode(' Select Font'));
shead.append(
ph('ph-text-aa'),
document.createTextNode(` ${trText('Select Font')}`)
);
const fontListEl = div('dfontlist');
sheet.append(grab, shead, fontListEl);
inspector.append(dim, sheet);
@@ -488,9 +496,9 @@ export function setupFormatDock(): void {
}
if ('queryLocalFonts' in window) {
fontListEl.appendChild(div('ddivider'));
const sys = mkBtn('dfrow dfsys', 'Load system fonts');
const sys = mkBtn('dfrow dfsys', trText('Load system fonts'));
const label = document.createElement('span');
label.textContent = 'Load system fonts…';
label.textContent = trText('Load system fonts…');
sys.appendChild(label);
sys.addEventListener('click', () => q('sysFonts')?.click());
fontListEl.appendChild(sys);
@@ -572,7 +580,7 @@ export function setupFormatDock(): void {
};
const proxyChip = (src: HTMLButtonElement): HTMLButtonElement => {
const b = mkBtn('dchip', src.title || 'Action');
const b = mkBtn('dchip', src.title || trText('Action'));
b.dataset.proxy = src.id;
const icon = src.querySelector('i');
b.appendChild(
@@ -599,7 +607,7 @@ export function setupFormatDock(): void {
const togglesRow = q('fToggles');
const toggleChip = (src: HTMLButtonElement): HTMLButtonElement => {
const b = mkBtn('dchip', src.title || 'Toggle style');
const b = mkBtn('dchip', src.title || trText('Toggle style'));
b.dataset.tog = src.dataset.t ?? '';
const icon = src.querySelector('i');
b.appendChild(
@@ -614,7 +622,7 @@ export function setupFormatDock(): void {
chipsWrap.replaceChildren();
if (mode !== 'idle') {
chipsWrap.appendChild(
toolChip('done', 'ph-check', 'Done — back to tools', () => {
toolChip('done', 'ph-check', trText('Done: back to tools'), () => {
closePanel();
closeSheet();
editBtn.click();
@@ -622,7 +630,7 @@ export function setupFormatDock(): void {
);
}
if (mode === 'text') {
for (const s of TEXT_SECTIONS) {
for (const s of getTextSections()) {
chipsWrap.appendChild(secChip(s));
if (s.id === 'color' && togglesRow) {
for (const t of togglesRow.children) {
@@ -633,8 +641,8 @@ export function setupFormatDock(): void {
}
} else if (mode === 'idle') {
chipsWrap.appendChild(
toolChip('edit', 'ph-pencil-simple', 'Edit tool', () => {
if (editBtn.classList.contains('on')) openSec(SCOPE_SECTION);
toolChip('edit', 'ph-pencil-simple', trText('Edit tool'), () => {
if (editBtn.classList.contains('on')) openSec(getScopeSection());
else {
closePanel();
applyScope();
@@ -642,7 +650,7 @@ export function setupFormatDock(): void {
})
);
chipsWrap.appendChild(
toolChip('addText', 'ph-textbox', 'Add a text box', () => {
toolChip('addText', 'ph-textbox', trText('Add a text box'), () => {
closePanel();
document
.querySelector<HTMLButtonElement>('[data-tool="addText"]')
@@ -650,20 +658,25 @@ export function setupFormatDock(): void {
})
);
chipsWrap.appendChild(
toolChip('addImage', 'ph-image', 'Add an image', () => {
toolChip('addImage', 'ph-image', trText('Add an image'), () => {
closePanel();
q('addImage')?.click();
})
);
chipsWrap.appendChild(
toolChip('find', 'ph-magnifying-glass', 'Find & replace', () => {
closePanel();
q('find')?.click();
})
toolChip(
'find',
'ph-magnifying-glass',
trText('Find & replace'),
() => {
closePanel();
q('find')?.click();
}
)
);
}
if (!alignTools.hidden) chipsWrap.appendChild(secChip(MULTI_SECTION));
if (!pStrokeRow.hidden) chipsWrap.appendChild(secChip(STROKE_SECTION));
if (!alignTools.hidden) chipsWrap.appendChild(secChip(getMultiSection()));
if (!pStrokeRow.hidden) chipsWrap.appendChild(secChip(getStrokeSection()));
for (const b of visibleObjBtns()) chipsWrap.appendChild(proxyChip(b));
const delBtn = q('del');
acts.hidden = !(delBtn instanceof HTMLButtonElement) || delBtn.hidden;
+48 -39
View File
@@ -1,7 +1,7 @@
import '@phosphor-icons/web/regular';
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import {
fallbackFontLanguages,
@@ -64,10 +64,10 @@ function menuItem(
return item;
}
const SCOPES: [string, string, string][] = [
['text', 'Edit Text', 'ph-text-t'],
['image', 'Edit Images', 'ph-image'],
['shape', 'Edit Shapes', 'ph-shapes'],
const getScopes = (): [string, string, string][] => [
['text', trText('Edit Text'), 'ph-text-t'],
['image', trText('Edit Images'), 'ph-image'],
['shape', trText('Edit Shapes'), 'ph-shapes'],
];
function setupEditMenu() {
@@ -89,12 +89,19 @@ function setupEditMenu() {
}
scopeSel.value = value;
scopeSel.dispatchEvent(new Event('change'));
const names: Record<string, string> = {
text: trText('Text'),
image: trText('Images'),
shape: trText('Shapes'),
};
label.textContent = picked.has('all')
? 'Edit All'
: 'Edit ' +
SCOPES.filter(([k]) => picked.has(k))
.map(([, l]) => l.replace('Edit ', ''))
.join(' + ');
? trText('Edit All')
: trText('Edit {{scopes}}', {
scopes: getScopes()
.filter(([k]) => picked.has(k))
.map(([k]) => names[k])
.join(' + '),
});
};
let open: HTMLDivElement | null = null;
const caret = editBtn.querySelector('.editcaret');
@@ -121,7 +128,7 @@ function setupEditMenu() {
menu.replaceChildren();
menu.appendChild(
menuItem(
'Edit All',
trText('Edit All'),
picked.has('all'),
() => {
picked = new Set(['all']);
@@ -131,7 +138,7 @@ function setupEditMenu() {
'ph-selection-all'
)
);
for (const [key, lbl, glyph] of SCOPES) {
for (const [key, lbl, glyph] of getScopes()) {
menu.appendChild(
menuItem(
lbl,
@@ -140,7 +147,7 @@ function setupEditMenu() {
if (picked.has('all')) picked.clear();
if (picked.has(key)) picked.delete(key);
else picked.add(key);
if (picked.size === 0 || picked.size === SCOPES.length)
if (picked.size === 0 || picked.size === getScopes().length)
picked = new Set(['all']);
apply();
rebuild();
@@ -232,7 +239,7 @@ function setupZoomMenu() {
inp.type = 'number';
inp.min = '20';
inp.max = '600';
inp.placeholder = 'Custom Zoom';
inp.placeholder = trText('Custom Zoom');
const pctSpan = document.createElement('span');
pctSpan.textContent = '%';
form.append(inp, pctSpan);
@@ -299,7 +306,7 @@ function relocateAddText() {
icon.className = 'ph ph-text-t';
addTextBtn.appendChild(icon);
addTextBtn.classList.add('btn', 'icon');
addTextBtn.title = 'Add a text box';
addTextBtn.title = trText('Add a text box');
findBtn.parentElement.insertBefore(addTextBtn, findBtn);
const sync = () =>
addTextBtn.classList.toggle('on', !editBtn.classList.contains('on'));
@@ -335,10 +342,10 @@ function setupDocPanel() {
const head = document.createElement('div');
head.className = 'docmodal-head';
const title = document.createElement('span');
title.textContent = 'Document Properties';
title.textContent = trText('Document Properties');
const x = document.createElement('button');
x.className = 'btn icon';
x.title = 'Close';
x.title = trText('Close');
const xi = document.createElement('i');
xi.className = 'ph ph-x';
x.appendChild(xi);
@@ -349,38 +356,44 @@ function setupDocPanel() {
const inch = (pt: number) => (pt / 72).toFixed(2);
const groups: [string, [string, string][]][] = [
[
'Description',
trText('Description'),
[
['File', desc.meta.name],
['Title', f.Title || '—'],
['Author', f.Author || '—'],
['Subject', f.Subject || '—'],
['Keywords', f.Keywords || '—'],
['Created', f.CreationDate ? formatPdfDate(f.CreationDate) : '—'],
['Modified', f.ModDate ? formatPdfDate(f.ModDate) : '—'],
['Application', f.Creator || '—'],
[trText('File'), desc.meta.name],
[trText('Title'), f.Title || '—'],
[trText('Author'), f.Author || '—'],
[trText('Subject'), f.Subject || '—'],
[trText('Keywords'), f.Keywords || '—'],
[
trText('Created'),
f.CreationDate ? formatPdfDate(f.CreationDate) : '—',
],
[trText('Modified'), f.ModDate ? formatPdfDate(f.ModDate) : '—'],
[trText('Application'), f.Creator || '—'],
],
],
[
'Advanced',
trText('Advanced'),
[
['PDF Producer', f.Producer || '—'],
['File Size', `${(desc.meta.size / 1024).toFixed(0)} KB`],
[trText('PDF Producer'), f.Producer || '—'],
[trText('File Size'), `${(desc.meta.size / 1024).toFixed(0)} KB`],
[
'Page Size',
trText('Page Size'),
desc.pageSize
? `${inch(desc.pageSize.w)} × ${inch(desc.pageSize.h)} in`
: '—',
],
['Number of Pages', desc.info?.pages ? String(desc.info.pages) : '—'],
[
trText('Number of Pages'),
desc.info?.pages ? String(desc.info.pages) : '—',
],
...(desc.info?.signatures
? ([['Signatures', String(desc.info.signatures)]] as [
? ([[trText('Signatures'), String(desc.info.signatures)]] as [
string,
string,
][])
: []),
...(desc.pdfa
? ([['Conformance', 'PDF/A']] as [string, string][])
? ([[trText('Conformance'), 'PDF/A']] as [string, string][])
: []),
],
],
@@ -819,7 +832,7 @@ function initializePage() {
if (document.activeElement !== pageInput)
pageInput.value = String(current);
pageInput.max = String(total);
pageTotal.textContent = `/ ${total}`;
pageTotal.textContent = trText('/ {{total}}', { total });
};
new MutationObserver(sync).observe(pageLabel, {
childList: true,
@@ -1045,8 +1058,4 @@ function initializePage() {
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
+23 -7
View File
@@ -5,10 +5,12 @@ import { createIcons, icons } from 'lucide';
import { parseEmailFile, renderEmailToHtml } from './email-to-pdf.js';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const EXTENSIONS = ['.eml', '.msg'];
const TOOL_NAME = 'Email';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -87,7 +89,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 0) {
showAlert(
'No Files',
`Please select at least one ${TOOL_NAME} file (.eml or .msg).`
trText(
'Please select at least one {{TOOL_NAME}} file (.eml or .msg).',
{ TOOL_NAME }
)
);
return;
}
@@ -112,7 +117,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Parsing ${originalFile.name}...`);
showLoader(trText('Parsing {{name}}...', { name: originalFile.name }));
const email = await parseEmailFile(originalFile);
@@ -144,7 +149,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -156,7 +163,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
try {
@@ -197,7 +208,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
trText(
'Successfully converted {{length}} {{TOOL_NAME}} file(s) to PDF.',
{ length: state.files.length, TOOL_NAME }
),
'success',
() => resetState()
);
@@ -207,7 +221,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+3 -1
View File
@@ -9,6 +9,8 @@ import {
} from '../utils/helpers.js';
import type { EmailAttachment, ParsedEmail, EmailRenderOptions } from '@/types';
import { trText } from '../i18n/i18n.js';
type MsgReaderCtor = typeof MsgReaderDefault;
const MsgReader: MsgReaderCtor =
(MsgReaderDefault as unknown as { default?: MsgReaderCtor }).default ??
@@ -333,6 +335,6 @@ export async function parseEmailFile(file: File): Promise<ParsedEmail> {
} else if (ext === 'msg') {
return parseMsgFile(file);
} else {
throw new Error(`Unsupported file type: .${ext}`);
throw new Error(trText('Unsupported file type: .{{ext}}', { ext }));
}
}
+27 -7
View File
@@ -4,11 +4,13 @@ import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const FILETYPE = 'epub';
const EXTENSIONS = ['.epub'];
const TOOL_NAME = 'EPUB';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -87,7 +89,12 @@ document.addEventListener('DOMContentLoaded', () => {
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
showAlert(
'No Files',
trText('Please select at least one {{TOOL_NAME}} file.', {
TOOL_NAME,
})
);
return;
}
@@ -96,7 +103,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
showLoader(
trText('Converting {{name}}...', { name: originalFile.name })
);
const pdfBlob = await pymupdf.convertToPdf(originalFile, {
filetype: FILETYPE,
@@ -108,7 +117,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -120,7 +131,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await pymupdf.convertToPdf(file, {
@@ -138,7 +153,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
trText(
'Successfully converted {{length}} {{TOOL_NAME}} file(s) to PDF.',
{ length: state.files.length, TOOL_NAME }
),
'success',
() => resetState()
);
@@ -148,7 +166,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+17 -5
View File
@@ -7,7 +7,9 @@ import {
type LoadProgress,
} from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
import { trText, onPageReady } from '../i18n/i18n.js';
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@@ -114,7 +116,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -126,7 +130,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
@@ -144,7 +152,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} Excel file(s) to PDF.`,
trText('Successfully converted {{length}} Excel file(s) to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -153,7 +163,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+28 -10
View File
@@ -9,6 +9,8 @@ import {
} from '../utils/wasm-provider.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const worker = new Worker(
import.meta.env.BASE_URL + 'workers/extract-attachments.worker.js'
);
@@ -114,18 +116,24 @@ worker.onmessage = function (e) {
showAlert(
'Success',
`${attachments.length} attachment(s) extracted successfully!`
trText('{{length}} attachment(s) extracted successfully!', {
length: attachments.length,
})
);
showStatus(
`Extraction completed! ${attachments.length} attachment(s) in zip file (${formatBytes(totalSize)}). Download started.`,
trText(
'Extraction completed! {{length}} attachment(s) in zip file ({{totalSize}}). Download started.',
{ length: attachments.length, totalSize: formatBytes(totalSize) }
),
'success'
);
resetState();
});
} else if (e.data.status === 'error') {
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
const errorMessage =
e.data.message || trText('Unknown error occurred in worker.');
console.error('Worker Error:', errorMessage);
if (errorMessage.includes('No attachments were found')) {
@@ -142,7 +150,10 @@ worker.onmessage = function (e) {
worker.onerror = function (error) {
console.error('Worker error:', error);
showStatus('Worker error occurred. Check console for details.', 'error');
showStatus(
trText('Worker error occurred. Check console for details.'),
'error'
);
const processBtn = document.getElementById('process-btn');
if (processBtn) {
@@ -169,7 +180,9 @@ async function updateUI() {
const countSpan = document.createElement('div');
countSpan.className = 'font-medium text-gray-200 text-sm mb-1';
countSpan.textContent = `${pageState.files.length} PDF file(s) selected`;
countSpan.textContent = trText('{{length}} PDF file(s) selected', {
length: pageState.files.length,
});
const sizeSpan = document.createElement('div');
sizeSpan.className = 'text-xs text-gray-400';
@@ -199,7 +212,7 @@ async function updateUI() {
async function extractAttachments() {
if (pageState.files.length === 0) {
showStatus('No Files', 'error');
showStatus(trText('No Files'), 'error');
return;
}
@@ -215,7 +228,7 @@ async function extractAttachments() {
processBtn.setAttribute('disabled', 'true');
}
showStatus('Reading files...', 'info');
showStatus(trText('Reading files...'), 'info');
try {
pageState.files = await batchDecryptIfNeeded(pageState.files);
@@ -239,7 +252,9 @@ async function extractAttachments() {
}
showStatus(
`Extracting attachments from ${pageState.files.length} file(s)...`,
trText('Extracting attachments from {{length}} file(s)...', {
length: pageState.files.length,
}),
'info'
);
@@ -257,7 +272,10 @@ async function extractAttachments() {
} catch (error) {
console.error('Error reading files:', error);
showStatus(
`Error reading files: ${error instanceof Error ? error.message : 'Unknown error occurred'}`,
trText('Error reading files: {{error}}', {
error:
error instanceof Error ? error.message : 'Unknown error occurred',
}),
'error'
);
@@ -282,7 +300,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+24 -8
View File
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
downloadFile,
readFileAsArrayBuffer,
@@ -19,7 +19,7 @@ interface ExtractedImage {
let extractedImages: ExtractedImage[] = [];
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -66,7 +66,10 @@ document.addEventListener('DOMContentLoaded', () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -85,9 +88,15 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`;
metaSpan.textContent = trText('{{size}} • {{numPages}} pages', {
size: formatBytes(file.size),
numPages: pdfDoc.numPages,
});
} catch {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
metaSpan.textContent = trText(
'{{size}} • Could not load page count',
{ size: formatBytes(file.size) }
);
}
}
@@ -167,7 +176,9 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Extracting images from ${file.name}...`);
showLoader(
trText('Extracting images from {{name}}...', { name: file.name })
);
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
@@ -206,7 +217,10 @@ document.addEventListener('DOMContentLoaded', () => {
displayImages();
showAlert(
'Extraction Complete',
`Found ${extractedImages.length} image(s) in ${state.files.length} PDF(s).`,
trText('Found {{length}} image(s) in {{length2}} PDF(s).', {
length: extractedImages.length,
length2: state.files.length,
}),
'success'
);
}
@@ -214,7 +228,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during extraction. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during extraction. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+10 -7
View File
@@ -10,6 +10,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import JSZip from 'jszip';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface ExtractState {
file: File | null;
pdfDoc: PDFDocument | null;
@@ -22,11 +24,7 @@ const extractState: ExtractState = {
totalPages: 0,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -131,7 +129,10 @@ function updateFileDisplay() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(extractState.file.size)} • ${extractState.totalPages} pages`;
metaSpan.textContent = trText('{{size}} • {{totalPages}} pages', {
size: formatBytes(extractState.file.size),
totalPages: extractState.totalPages,
});
infoContainer.append(nameSpan, metaSpan);
@@ -197,7 +198,9 @@ async function extractPages() {
hideLoader();
showAlert(
'Success',
`Extracted ${pagesToExtract.length} page(s) successfully!`,
trText('Extracted {{length}} page(s) successfully!', {
length: pagesToExtract.length,
}),
'success',
() => {
resetState();
+17 -5
View File
@@ -4,6 +4,8 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let file: File | null = null;
const updateUI = () => {
@@ -117,7 +119,12 @@ async function extract() {
const allTables: TableData[] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
showLoader(
trText('Scanning page {{n}} of {{pageCount}}...', {
n: i + 1,
pageCount,
})
);
const page = doc.getPage(i);
const tables = page.findTables();
@@ -193,21 +200,26 @@ async function extract() {
downloadFile(zipBlob, `${baseName}_tables.zip`);
showAlert(
'Success',
`Extracted ${allTables.length} tables successfully!`,
trText('Extracted {{length}} tables successfully!', {
length: allTables.length,
}),
'success',
resetState
);
}
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to extract tables. ${message}`);
const message = e instanceof Error ? e.message : trText('Unknown error');
showAlert(
'Error',
trText('Failed to extract tables. {{message}}', { message })
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+27 -7
View File
@@ -4,11 +4,13 @@ import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const FILETYPE = 'fb2';
const EXTENSIONS = ['.fb2'];
const TOOL_NAME = 'FB2';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -83,7 +85,12 @@ document.addEventListener('DOMContentLoaded', () => {
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
showAlert(
'No Files',
trText('Please select at least one {{TOOL_NAME}} file.', {
TOOL_NAME,
})
);
return;
}
@@ -92,7 +99,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
showLoader(
trText('Converting {{name}}...', { name: originalFile.name })
);
const pdfBlob = await pymupdf.convertToPdf(originalFile, {
filetype: FILETYPE,
@@ -104,7 +113,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -116,7 +127,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await pymupdf.convertToPdf(file, {
@@ -134,7 +149,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
trText(
'Successfully converted {{length}} {{TOOL_NAME}} file(s) to PDF.',
{ length: state.files.length, TOOL_NAME }
),
'success',
() => resetState()
);
@@ -144,7 +162,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+4 -2
View File
@@ -5,6 +5,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { icons, createIcons } from 'lucide';
import { FixPageSizeState } from '@/types';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: FixPageSizeState = {
file: null,
};
@@ -105,7 +107,7 @@ async function fixPageSize() {
const loaderModal = document.getElementById('loader-modal');
const loaderText = document.getElementById('loader-text');
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Standardizing pages...';
if (loaderText) loaderText.textContent = trText('Standardizing pages...');
try {
const customWidth =
@@ -153,7 +155,7 @@ async function fixPageSize() {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+12 -6
View File
@@ -14,6 +14,8 @@ import {
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: FlattenPdfState = {
files: [],
};
@@ -112,7 +114,7 @@ async function flattenPdf() {
try {
if (pageState.files.length === 1) {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening PDF...';
if (loaderText) loaderText.textContent = trText('Flattening PDF...');
const file = pageState.files[0];
const arrayBuffer = await file.arrayBuffer();
@@ -142,7 +144,8 @@ async function flattenPdf() {
if (loaderModal) loaderModal.classList.add('hidden');
} else {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening multiple PDFs...';
if (loaderText)
loaderText.textContent = trText('Flattening multiple PDFs...');
const zip = new JSZip();
const usedNames = new Set<string>();
@@ -151,7 +154,10 @@ async function flattenPdf() {
for (let i = 0; i < pageState.files.length; i++) {
const file = pageState.files[i];
if (loaderText)
loaderText.textContent = `Flattening ${i + 1}/${pageState.files.length}: ${file.name}...`;
loaderText.textContent = trText(
'Flattening {{n}}/{{length}}: {{name}}...',
{ n: i + 1, length: pageState.files.length, name: file.name }
);
try {
const arrayBuffer = await file.arrayBuffer();
@@ -189,7 +195,7 @@ async function flattenPdf() {
downloadFile(zipBlob, 'flattened_pdfs.zip');
showAlert(
'Success',
`Processed ${processedCount} PDFs.`,
trText('Processed {{processedCount}} PDFs.', { processedCount }),
'success',
() => {
resetState();
@@ -204,12 +210,12 @@ async function flattenPdf() {
console.error(e);
if (loaderModal) loaderModal.classList.add('hidden');
const errorMessage =
e instanceof Error ? e.message : 'An unexpected error occurred.';
e instanceof Error ? e.message : trText('An unexpected error occurred.');
showAlert('Error', errorMessage);
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+12 -6
View File
@@ -8,6 +8,8 @@ import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface FontToOutlineState {
files: File[];
}
@@ -117,7 +119,7 @@ async function processFiles() {
if (pageState.files.length === 1) {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText)
loaderText.textContent = 'Converting fonts to outlines...';
loaderText.textContent = trText('Converting fonts to outlines...');
const file = pageState.files[0];
const resultBlob = await convertFileToOutlines(file, (msg) => {
@@ -128,7 +130,8 @@ async function processFiles() {
if (loaderModal) loaderModal.classList.add('hidden');
} else {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Processing multiple PDFs...';
if (loaderText)
loaderText.textContent = trText('Processing multiple PDFs...');
const zip = new JSZip();
const usedNames = new Set<string>();
@@ -137,7 +140,10 @@ async function processFiles() {
for (let i = 0; i < pageState.files.length; i++) {
const file = pageState.files[i];
if (loaderText)
loaderText.textContent = `Processing ${i + 1}/${pageState.files.length}: ${file.name}...`;
loaderText.textContent = trText(
'Processing {{n}}/{{length}}: {{name}}...',
{ n: i + 1, length: pageState.files.length, name: file.name }
);
try {
const resultBlob = await convertFileToOutlines(file, () => {});
@@ -155,7 +161,7 @@ async function processFiles() {
downloadFile(zipBlob, 'outlined_pdfs.zip');
showAlert(
'Success',
`Processed ${processedCount} PDFs.`,
trText('Processed {{processedCount}} PDFs.', { processedCount }),
'success',
() => {
resetState();
@@ -170,12 +176,12 @@ async function processFiles() {
console.error(e);
if (loaderModal) loaderModal.classList.add('hidden');
const errorMessage =
e instanceof Error ? e.message : 'An unexpected error occurred.';
e instanceof Error ? e.message : trText('An unexpected error occurred.');
showAlert('Error', errorMessage);
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+138 -104
View File
@@ -41,6 +41,8 @@ import { extractExistingFields as extractExistingPdfFields } from './form-creato
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, trHtml } from '../i18n/i18n.js';
let fields: FormField[] = [];
let selectedField: FormField | null = null;
let fieldCounter = 0;
@@ -247,7 +249,11 @@ if (gotoPageBtn && gotoPageInput) {
renderCanvas();
updatePageNavigation();
} else {
alert(`Please enter a valid page number between 1 and ${pages.length}`);
alert(
trText('Please enter a valid page number between 1 and {{total}}', {
total: pages.length,
})
);
}
});
@@ -389,6 +395,13 @@ canvas.addEventListener('click', (e) => {
}
});
const standardFontText = (text: string, fallback: string): string =>
/^[\u0020-\u007e\u00a0-\u00ff\u2013\u2014\u2018\u2019\u201c\u201d\u2026\u20ac]*$/.test(
text
)
? text
: fallback;
function createField(type: FormField['type'], x: number, y: number): void {
fieldCounter++;
const field: FormField = {
@@ -429,7 +442,10 @@ function createField(type: FormField['type'], x: number, y: number): void {
type === 'button'
? 'Button'
: type === 'image'
? 'Click to Upload Image'
? standardFontText(
trText('Click to Upload Image'),
'Click to Upload Image'
)
: undefined,
action: type === 'button' ? 'none' : undefined,
jsScript: type === 'button' ? 'app.alert("Hello World!");' : undefined,
@@ -683,7 +699,7 @@ function renderField(field: FormField): void {
// Empty state
const optEl = document.createElement('div');
optEl.className = 'px-1 w-full text-black italic';
optEl.textContent = 'Item 1';
optEl.textContent = trText('Item 1');
contentEl.appendChild(optEl);
}
} else if (field.type === 'button') {
@@ -694,7 +710,7 @@ function renderField(field: FormField): void {
'#e5e7eb'
);
contentEl.style.color = field.textColor || '#000000';
contentEl.textContent = field.label || 'Button';
contentEl.textContent = field.label || trText('Button');
} else if (field.type === 'signature') {
contentEl.className =
'w-full h-full flex items-center justify-center text-gray-400';
@@ -702,8 +718,7 @@ function renderField(field: FormField): void {
field,
'#f9fafb'
);
contentEl.innerHTML =
'<div class="flex flex-col items-center"><i data-lucide="pen-tool" class="w-6 h-6 mb-1"></i><span class="text-[10px]">Sign Here</span></div>';
contentEl.innerHTML = `<div class="flex flex-col items-center"><i data-lucide="pen-tool" class="w-6 h-6 mb-1"></i><span class="text-[10px]">${trHtml('Sign Here')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'date') {
contentEl.className =
@@ -750,11 +765,11 @@ function renderField(field: FormField): void {
String(field.name).replace(/[\r\n]+/g, ' '),
error
);
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${trHtml('Invalid data')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
}
} else {
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Barcode</span></div>`;
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${trHtml('Barcode')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
}
}
@@ -1090,35 +1105,35 @@ function showProperties(field: FormField): void {
if (field.type === 'text') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Value')}</label>
<input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Max Length (0 for unlimited)')}</label>
<input type="number" id="propMaxLength" value="${field.maxLength}" min="0" ${field.combCells > 0 ? 'disabled' : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Divide into boxes (0 to disable)</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Divide into boxes (0 to disable)')}</label>
<input type="number" id="propComb" value="${field.combCells}" min="0" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Font Size</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Font Size')}</label>
<input type="number" id="propFontSize" value="${field.fontSize}" min="8" max="72" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Text Color')}</label>
<input type="color" id="propTextColor" value="${field.textColor}">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Alignment')}</label>
<select id="propAlignment" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="left" ${field.alignment === 'left' ? 'selected' : ''}>Left</option>
<option value="center" ${field.alignment === 'center' ? 'selected' : ''}>Center</option>
<option value="right" ${field.alignment === 'right' ? 'selected' : ''}>Right</option>
<option value="left" ${field.alignment === 'left' ? 'selected' : ''}>${trHtml('Left')}</option>
<option value="center" ${field.alignment === 'center' ? 'selected' : ''}>${trHtml('Center')}</option>
<option value="right" ${field.alignment === 'right' ? 'selected' : ''}>${trHtml('Right')}</option>
</select>
</div>
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
<label for="propMultiline" class="text-xs font-semibold text-gray-300">Multi-line</label>
<label for="propMultiline" class="text-xs font-semibold text-gray-300">${trHtml('Multi-line')}</label>
<button id="propMultilineBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.multiline ? 'bg-indigo-600' : 'bg-gray-500'} relative">
<span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.multiline ? 'translate-x-6' : 'translate-x-0'}"></span>
</button>
@@ -1127,7 +1142,7 @@ function showProperties(field: FormField): void {
} else if (field.type === 'checkbox') {
specificProps = `
<div class="flex items-center justify-between bg-gray-600 p-2 rounded">
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
<label for="propChecked" class="text-xs font-semibold text-gray-300">${trHtml('Checked State')}</label>
<button id="propCheckedBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.checked ? 'bg-indigo-600' : 'bg-gray-500'} relative">
<span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.checked ? 'translate-x-6' : 'translate-x-0'}"></span>
</button>
@@ -1136,15 +1151,15 @@ function showProperties(field: FormField): void {
} else if (field.type === 'radio') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Group Name (Must be same for group)')}</label>
<input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Export Value')}</label>
<input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
<label for="propChecked" class="text-xs font-semibold text-gray-300">${trHtml('Checked State')}</label>
<button id="propCheckedBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.checked ? 'bg-indigo-600' : 'bg-gray-500'} relative">
<span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.checked ? 'translate-x-6' : 'translate-x-0'}"></span>
</button>
@@ -1153,50 +1168,50 @@ function showProperties(field: FormField): void {
} else if (field.type === 'dropdown' || field.type === 'optionlist') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Options (One per line or comma separated)')}</label>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Selected Option')}</label>
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">None</option>
<option value="">${trHtml('None')}</option>
${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
</select>
</div>
<div class="text-xs text-gray-400 italic mt-2">
To actually fill or change the options, use our PDF Form Filler tool.
${trHtml('To actually fill or change the options, use our PDF Form Filler tool.')}
</div>
`;
} else if (field.type === 'button') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Label')}</label>
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Action')}</label>
<select id="propAction" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="none" ${field.action === 'none' ? 'selected' : ''}>None</option>
<option value="reset" ${field.action === 'reset' ? 'selected' : ''}>Reset Form</option>
<option value="print" ${field.action === 'print' ? 'selected' : ''}>Print Form</option>
<option value="url" ${field.action === 'url' ? 'selected' : ''}>Open URL</option>
<option value="js" ${field.action === 'js' ? 'selected' : ''}>Run Javascript</option>
<option value="showHide" ${field.action === 'showHide' ? 'selected' : ''}>Show/Hide Field</option>
<option value="none" ${field.action === 'none' ? 'selected' : ''}>${trHtml('None')}</option>
<option value="reset" ${field.action === 'reset' ? 'selected' : ''}>${trHtml('Reset Form')}</option>
<option value="print" ${field.action === 'print' ? 'selected' : ''}>${trHtml('Print Form')}</option>
<option value="url" ${field.action === 'url' ? 'selected' : ''}>${trHtml('Open URL')}</option>
<option value="js" ${field.action === 'js' ? 'selected' : ''}>${trHtml('Run Javascript')}</option>
<option value="showHide" ${field.action === 'showHide' ? 'selected' : ''}>${trHtml('Show/Hide Field')}</option>
</select>
</div>
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('URL')}</label>
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="${trHtml('https://example.com')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Javascript Code')}</label>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
</div>
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
<div class="mb-2">
<label class="block text-xs font-semibold text-gray-300 mb-1">Target Field</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Target Field')}</label>
<select id="propTargetField" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Select a field...</option>
<option value="">${trHtml('Select a field...')}</option>
${fields
.filter((f) => f.id !== field.id)
.map(
@@ -1207,11 +1222,11 @@ function showProperties(field: FormField): void {
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Visibility</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Visibility')}</label>
<select id="propVisibilityAction" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="show" ${field.visibilityAction === 'show' ? 'selected' : ''}>Show</option>
<option value="hide" ${field.visibilityAction === 'hide' ? 'selected' : ''}>Hide</option>
<option value="toggle" ${field.visibilityAction === 'toggle' ? 'selected' : ''}>Toggle</option>
<option value="show" ${field.visibilityAction === 'show' ? 'selected' : ''}>${trHtml('Show')}</option>
<option value="hide" ${field.visibilityAction === 'hide' ? 'selected' : ''}>${trHtml('Hide')}</option>
<option value="toggle" ${field.visibilityAction === 'toggle' ? 'selected' : ''}>${trHtml('Toggle')}</option>
</select>
</div>
</div>
@@ -1219,7 +1234,7 @@ function showProperties(field: FormField): void {
} else if (field.type === 'signature') {
specificProps = `
<div class="text-xs text-gray-400 italic mb-2">
Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer.
${trHtml('Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer.')}
</div>
`;
} else if (field.type === 'date') {
@@ -1259,53 +1274,53 @@ function showProperties(field: FormField): void {
const isCustom = !formats.includes(field.dateFormat || 'mm/dd/yyyy');
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Date Format</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Date Format')}</label>
<select id="propDateFormat" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
${formats.map((f) => `<option value="${f}" ${field.dateFormat === f ? 'selected' : ''}>${f}</option>`).join('')}
<option value="custom" ${isCustom ? 'selected' : ''}>Custom</option>
<option value="custom" ${isCustom ? 'selected' : ''}>${trHtml('Custom')}</option>
</select>
</div>
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Custom Format')}</label>
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="${trHtml('e.g. dd/mm/yyyy HH:MM:ss')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-3 p-2 bg-gray-700 rounded">
<span class="text-xs text-gray-400">Example of current format:</span>
<span class="text-xs text-gray-400">${trHtml('Example of current format:')}</span>
<span id="dateFormatExample" class="text-sm text-white font-medium ml-2"></span>
</div>
<div class="bg-blue-900/30 border border-blue-700/50 rounded p-2 mt-2">
<p class="text-xs text-blue-200">
<i data-lucide="info" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
<span><strong>Browser Note:</strong> Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date.</span>
<span><strong>${trHtml('Browser Note:')}</strong> ${trHtml('Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date.')}</span>
</p>
</div>
`;
} else if (field.type === 'image') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Label / Prompt')}</label>
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="text-xs text-gray-400 italic mt-2">
Clicking this field in the PDF will open a file picker to upload an image.
${trHtml('Clicking this field in the PDF will open a file picker to upload an image.')}
</div>
`;
} else if (field.type === 'barcode') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Format</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Barcode Format')}</label>
<select id="propBarcodeFormat" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>QR Code</option>
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>Code 128</option>
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>Code 39</option>
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>EAN-13</option>
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>UPC-A</option>
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>DataMatrix</option>
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>PDF417</option>
<option value="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>${trHtml('QR Code')}</option>
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>${trHtml('Code 128')}</option>
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>${trHtml('Code 39')}</option>
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>${trHtml('EAN-13')}</option>
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>${trHtml('UPC-A')}</option>
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>${trHtml('DataMatrix')}</option>
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>${trHtml('PDF417')}</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Barcode Value')}</label>
<input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
@@ -1315,7 +1330,7 @@ function showProperties(field: FormField): void {
const propertiesHtml = `
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${field.type === 'radio' ? trHtml('Field Name (Group Name)') : trHtml('Field Name')}</label>
<input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
</div>
@@ -1325,9 +1340,9 @@ function showProperties(field: FormField): void {
fields.some((f) => f.type === 'radio' && f.id !== field.id))
? `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Existing Radio Groups</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Existing Radio Groups')}</label>
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">-- Select existing group --</option>
<option value="">${trHtml('-- Select existing group --')}</option>
${Array.from(existingRadioGroups)
.map(
(name) =>
@@ -1348,38 +1363,38 @@ function showProperties(field: FormField): void {
)
.join('')}
</select>
<p class="text-xs text-gray-400 mt-1">Select to add this button to an existing group</p>
<p class="text-xs text-gray-400 mt-1">${trHtml('Select to add this button to an existing group')}</p>
</div>
`
: ''
}
${specificProps}
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Tooltip / Help Text')}</label>
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="${trHtml('Description for screen readers')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
<label for="propRequired" class="text-xs font-semibold text-gray-300">Required</label>
<label for="propRequired" class="text-xs font-semibold text-gray-300">${trHtml('Required')}</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="propReadOnly" ${field.readOnly ? 'checked' : ''} class="mr-2">
<label for="propReadOnly" class="text-xs font-semibold text-gray-300">Read Only</label>
<label for="propReadOnly" class="text-xs font-semibold text-gray-300">${trHtml('Read Only')}</label>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
<label class="block text-xs font-semibold text-gray-300 mb-1">${trHtml('Border Color')}</label>
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}">
</div>
<div class="flex items-center">
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
<label for="propHideBorder" class="text-xs font-semibold text-gray-300">Hide Border</label>
<label for="propHideBorder" class="text-xs font-semibold text-gray-300">${trHtml('Hide Border')}</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="propTransparentBackground" ${field.transparentBackground ? 'checked' : ''} class="mr-2">
<label for="propTransparentBackground" class="text-xs font-semibold text-gray-300">Transparent Background</label>
<label for="propTransparentBackground" class="text-xs font-semibold text-gray-300">${trHtml('Transparent Background')}</label>
</div>
<button id="deleteBtn" class="w-full bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-sm font-semibold">
Delete Field
${trHtml('Delete Field')}
</button>
</div>
`;
@@ -1404,7 +1419,7 @@ function showProperties(field: FormField): void {
const validateName = (newName: string): boolean => {
if (!newName) {
nameError.textContent = 'Field name cannot be empty';
nameError.textContent = trText('Field name cannot be empty');
nameError.classList.remove('hidden');
propName.classList.add('border-red-500');
return false;
@@ -1422,7 +1437,10 @@ function showProperties(field: FormField): void {
const isDuplicateInPdf = existingFieldNames.has(newName);
if (isDuplicateInFields || isDuplicateInPdf) {
nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.`;
nameError.textContent = trText(
'Field name "{{newName}}" already exists in this {{value}}. Please try using a unique name.',
{ newName, value: isDuplicateInPdf ? trText('PDF') : trText('form') }
);
nameError.classList.remove('hidden');
propName.classList.add('border-red-500');
return false;
@@ -1773,7 +1791,7 @@ function showProperties(field: FormField): void {
if (propSelectedOption) {
const currentVal = field.defaultValue;
propSelectedOption.innerHTML =
'<option value="">None</option>' +
`<option value="">${trHtml('None')}</option>` +
field.options
?.map(
(opt) =>
@@ -1812,7 +1830,7 @@ function showProperties(field: FormField): void {
const contentEl = fieldWrapper.querySelector(
'.field-content'
) as HTMLElement;
if (contentEl) contentEl.textContent = field.label || 'Button';
if (contentEl) contentEl.textContent = field.label || trText('Button');
}
});
@@ -2033,13 +2051,13 @@ function showProperties(field: FormField): void {
};
const barcodeFormatHints: Record<string, string> = {
qrcode: 'Any text, URL, or data',
code128: 'ASCII characters (letters, numbers, symbols)',
code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE',
ean13: 'Exactly 12 or 13 digits',
upca: 'Exactly 11 or 12 digits',
datamatrix: 'Any text, URL, or data',
pdf417: 'Any text, URL, or data',
qrcode: trText('Any text, URL, or data'),
code128: trText('ASCII characters (letters, numbers, symbols)'),
code39: trText('Uppercase A-Z, digits 0-9, and - . $ / + % SPACE'),
ean13: trText('Exactly 12 or 13 digits'),
upca: trText('Exactly 11 or 12 digits'),
datamatrix: trText('Any text, URL, or data'),
pdf417: trText('Any text, URL, or data'),
};
const hintEl = document.getElementById('barcodeFormatHint');
@@ -2069,8 +2087,7 @@ function showProperties(field: FormField): void {
// Hide properties panel
function hideProperties(): void {
propertiesPanel.innerHTML =
'<p class="text-gray-500 text-sm">Select a field to edit properties</p>';
propertiesPanel.innerHTML = `<p class="text-gray-500 text-sm">${trHtml('Select a field to edit properties')}</p>`;
}
// Delete field
@@ -2137,8 +2154,11 @@ downloadBtn.addEventListener('click', async () => {
.map((name) => `"${name}"`)
.join(', ');
showModal(
'Field Name Conflict',
`The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`,
trText('Field Name Conflict'),
trText(
'The following field names already exist in the uploaded PDF: {{conflictList}}. Please rename these fields before downloading.',
{ conflictList }
),
'error'
);
return;
@@ -2147,20 +2167,23 @@ downloadBtn.addEventListener('click', async () => {
if (duplicates.length > 0) {
const duplicateList = duplicates.map((name) => `"${name}"`).join(', ');
showModal(
'Duplicate Field Names',
`The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`,
trText('Duplicate Field Names'),
trText(
'The following field names are used more than once: {{duplicateList}}. Please rename these fields to use unique names before downloading.',
{ duplicateList }
),
'error'
);
return;
}
if (fields.length === 0) {
alert('Please add at least one field before downloading.');
alert(trText('Please add at least one field before downloading.'));
return;
}
if (pages.length === 0) {
alert('No pages found. Please create a blank PDF or upload one.');
alert(trText('No pages found. Please create a blank PDF or upload one.'));
return;
}
@@ -2705,7 +2728,7 @@ downloadBtn.addEventListener('click', async () => {
downloadFile(blob, uploadedFileName || 'document.pdf');
showModal(
'Success',
'Your PDF has been downloaded successfully.',
trText('Your PDF has been downloaded successfully.'),
'info',
() => {
resetToInitial();
@@ -2730,13 +2753,20 @@ downloadBtn.addEventListener('click', async () => {
console.log(`Adding to existing radio group: ${fieldName}`);
} else {
showModal(
'Duplicate Field Name',
`A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`,
trText('Duplicate Field Name'),
trText(
'A field named "{{fieldName}}" already exists. Please rename this field to use a unique name before downloading.',
{ fieldName }
),
'error'
);
}
} else {
showModal('Error', 'Error generating PDF: ' + errorMessage, 'error');
showModal(
'Error',
trText('Error generating PDF: {{error}}', { error: errorMessage }),
'error'
);
}
}
});
@@ -2795,8 +2825,7 @@ function resetToInitial(): void {
canvas.innerHTML = '';
propertiesPanel.innerHTML =
'<p class="text-gray-500 text-sm">Select a field to edit properties</p>';
propertiesPanel.innerHTML = `<p class="text-gray-500 text-sm">${trHtml('Select a field to edit properties')}</p>`;
updateFieldCount();
@@ -2906,7 +2935,10 @@ async function renderCanvas(): Promise<void> {
}
function updatePageNavigation(): void {
pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}`;
pageIndicator.textContent = trText('Page {{n}} of {{length}}', {
n: currentPageIndex + 1,
length: pages.length,
});
prevPageBtn.disabled = currentPageIndex === 0;
nextPageBtn.disabled = currentPageIndex === pages.length - 1;
}
@@ -3067,7 +3099,7 @@ async function handlePdfUpload(file: File) {
console.error('Error loading PDF:', error);
showModal(
'Error',
'Error loading PDF file. Please try again with a valid PDF.',
trText('Error loading PDF file. Please try again with a valid PDF.'),
'error'
);
}
@@ -3094,7 +3126,9 @@ addPageBtn.addEventListener('click', () => {
resetBtn.addEventListener('click', () => {
if (fields.length > 0 || pages.length > 0) {
if (
confirm('Are you sure you want to reset? All your work will be lost.')
confirm(
trText('Are you sure you want to reset? All your work will be lost.')
)
) {
resetToInitial();
}
@@ -3121,9 +3155,9 @@ function showModal(
if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose)
return;
errorModalTitle.textContent = title;
errorModalMessage.textContent = message;
errorModalClose.textContent = buttonText;
errorModalTitle.textContent = trText(title);
errorModalMessage.textContent = trText(message);
errorModalClose.textContent = trText(buttonText);
modalCloseCallback = onClose || null;
errorModal.classList.remove('hidden');
+10 -7
View File
@@ -3,6 +3,9 @@ import { createIcons, icons } from 'lucide';
import { getPDFDocument } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { trText, onPageReady } from '../i18n/i18n.js';
import { getStoredItem } from '../utils/safe-storage.js';
let viewerIframe: HTMLIFrameElement | null = null;
let viewerReady = false;
let currentFile: File | null = null;
@@ -12,7 +15,7 @@ function showLoader(message: string = 'Processing...') {
const loader = document.getElementById('loader-modal');
const loaderText = document.getElementById('loader-text');
if (loader) loader.classList.remove('hidden');
if (loaderText) loaderText.textContent = message;
if (loaderText) loaderText.textContent = trText(message);
}
function hideLoader() {
@@ -31,8 +34,8 @@ function showAlert(
const alertMessage = document.getElementById('alert-message');
const okBtn = document.getElementById('alert-ok');
if (alertTitle) alertTitle.textContent = title;
if (alertMessage) alertMessage.textContent = message;
if (alertTitle) alertTitle.textContent = trText(title);
if (alertMessage) alertMessage.textContent = trText(message);
if (modal) modal.classList.remove('hidden');
if (okBtn) {
@@ -80,7 +83,7 @@ function updateFileDisplay() {
removeBtn.id = 'remove-file';
removeBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
removeBtn.title = 'Remove file';
removeBtn.title = trText('Remove file');
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'trash-2');
@@ -117,7 +120,7 @@ function resetState() {
}
const toolUploader = document.getElementById('tool-uploader');
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const isFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl');
toolUploader.classList.add('max-w-2xl');
@@ -181,7 +184,7 @@ async function setupFormViewer() {
const toolUploader = document.getElementById('tool-uploader');
// Default to true if not set
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const isFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl');
toolUploader.classList.add('max-w-6xl');
@@ -281,7 +284,7 @@ async function processAndDownloadForm() {
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+8 -7
View File
@@ -11,13 +11,11 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { HeaderFooterState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: HeaderFooterState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -100,7 +98,10 @@ function updateFileDisplay() {
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
@@ -167,7 +168,7 @@ async function addHeaderFooter() {
};
const indicesToProcess = parsePageRanges(pageRangeInput, totalPages);
if (indicesToProcess.length === 0)
throw new Error('Invalid page range specified.');
throw new Error(trText('Invalid page range specified.'));
const drawOptions = {
font: helveticaFont,
size: fontSize,
+154 -143
View File
@@ -4,176 +4,187 @@ import { createIcons, icons } from 'lucide';
import heic2any from 'heic2any';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { trText, onPageReady } from '../i18n/i18n.js';
let files: File[] = [];
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const processBtn = document.getElementById('process-btn');
if (!fileDisplayArea || !fileControls || !processBtn) return;
if (!fileDisplayArea || !fileControls || !processBtn) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.innerHTML = '';
if (files.length > 0) {
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
if (files.length > 0) {
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
infoContainer.append(nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
};
const resetState = () => {
files = [];
updateUI();
files = [];
updateUI();
};
async function convert() {
if (files.length === 0) {
showAlert('No Files', 'Please select at least one HEIC file.');
return;
}
showLoader('Converting HEIC to PDF...');
try {
const pdfDoc = await PDFLibDocument.create();
for (const file of files) {
const conversionResult = await heic2any({
blob: file,
toType: 'image/png',
quality: 0.92,
});
const pngBlob = Array.isArray(conversionResult)
? conversionResult[0]
: conversionResult;
const pngBytes = await pngBlob.arrayBuffer();
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
}
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_heic.pdf'
);
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e) {
console.error(e);
showAlert(
'Error',
'Failed to convert HEIC to PDF. One of the files may be invalid.'
);
} finally {
hideLoader();
if (files.length === 0) {
showAlert('No Files', 'Please select at least one HEIC file.');
return;
}
showLoader('Converting HEIC to PDF...');
try {
const pdfDoc = await PDFLibDocument.create();
for (const file of files) {
const conversionResult = await heic2any({
blob: file,
toType: 'image/png',
quality: 0.92,
});
const pngBlob = Array.isArray(conversionResult)
? conversionResult[0]
: conversionResult;
const pngBytes = await pngBlob.arrayBuffer();
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
}
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_heic.pdf'
);
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e) {
console.error(e);
showAlert(
'Error',
'Failed to convert HEIC to PDF. One of the files may be invalid.'
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(
(file) =>
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
);
if (validFiles.length < newFiles.length) {
showAlert(
'Invalid Files',
'Some files were skipped. Only HEIC/HEIF files are allowed.'
);
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(
(file) => file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif')
);
if (validFiles.length < newFiles.length) {
showAlert('Invalid Files', 'Some files were skipped. Only HEIC/HEIF files are allowed.');
}
if (validFiles.length > 0) {
files = [...files, ...validFiles];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
if (validFiles.length > 0) {
files = [...files, ...validFiles];
updateUI();
}
};
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput?.click();
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
if (processBtn) {
processBtn.addEventListener('click', convert);
}
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput?.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});
+6 -6
View File
@@ -14,14 +14,12 @@ import {
preprocessImageFile,
} from '@/js/utils/image-input-utils.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let files: File[] = [];
let pymupdf: PyMuPDFInstance | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -143,7 +141,9 @@ function updateUI() {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
+13 -7
View File
@@ -9,13 +9,11 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: InvertColorsState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -93,7 +91,10 @@ function updateFileDisplay() {
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
@@ -126,7 +127,12 @@ async function invertColors() {
const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
showLoader(`Processing page ${i} of ${pdfjsDoc.numPages}...`);
showLoader(
trText('Processing page {{i}} of {{numPages}}...', {
i,
numPages: pdfjsDoc.numPages,
})
);
const page = await pdfjsDoc.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
+6 -6
View File
@@ -8,17 +8,15 @@ import {
compressImageFile,
} from '../utils/image-compress.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const SUPPORTED_FORMATS = '.jpg,.jpeg,.jp2,.jpx';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/jp2'];
let files: File[] = [];
let pymupdf: PyMuPDFInstance | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -146,7 +144,9 @@ function updateUI() {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
+23 -9
View File
@@ -10,6 +10,8 @@ import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { LinearizePdfState, QpdfInstanceExtended } from '@/types';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: LinearizePdfState = {
files: [],
};
@@ -107,7 +109,9 @@ async function linearizePdf() {
const loaderText = document.getElementById('loader-text');
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText)
loaderText.textContent = 'Optimizing PDFs for web view (linearizing)...';
loaderText.textContent = trText(
'Optimizing PDFs for web view (linearizing)...'
);
const zip = new JSZip();
const usedNames = new Set<string>();
@@ -124,7 +128,10 @@ async function linearizePdf() {
const outputPath = `/output_${i}.pdf`;
if (loaderText)
loaderText.textContent = `Optimizing ${file.name} (${i + 1}/${pdfFiles.length})...`;
loaderText.textContent = trText(
'Optimizing {{name}} ({{n}}/{{length}})...',
{ name: file.name, n: i + 1, length: pdfFiles.length }
);
try {
const fileBuffer = await readFileAsArrayBuffer(file);
@@ -141,7 +148,9 @@ async function linearizePdf() {
console.error(
`Linearization resulted in an empty file for ${file.name}.`
);
throw new Error(`Processing failed for ${file.name}.`);
throw new Error(
trText('Processing failed for {{name}}.', { name: file.name })
);
}
const zipEntryName = deduplicateFileName(
@@ -173,16 +182,19 @@ async function linearizePdf() {
}
if (successCount === 0) {
throw new Error('No PDF files could be linearized.');
throw new Error(trText('No PDF files could be linearized.'));
}
if (loaderText) loaderText.textContent = 'Generating ZIP file...';
if (loaderText) loaderText.textContent = trText('Generating ZIP file...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'linearized-pdfs.zip');
let alertMessage = `${successCount} PDF(s) linearized successfully.`;
let alertMessage = trText(
'{{successCount}} PDF(s) linearized successfully.',
{ successCount }
);
if (errorCount > 0) {
alertMessage += ` ${errorCount} file(s) failed.`;
alertMessage += ` ${trText('Files that failed: {{errorCount}}.', { errorCount })}`;
}
showAlert('Processing Complete', alertMessage, 'success', () => {
resetState();
@@ -191,14 +203,16 @@ async function linearizePdf() {
console.error('Linearization process error:', error);
showAlert(
'Linearization Failed',
`An error occurred: ${error instanceof Error ? error.message : 'Unknown error'}.`
trText('An error occurred: {{error}}.', {
error: error instanceof Error ? error.message : trText('Unknown error'),
})
);
} finally {
if (loaderModal) loaderModal.classList.add('hidden');
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+3 -1
View File
@@ -1,6 +1,8 @@
import { MarkdownEditor } from '../utils/markdown-editor.js';
document.addEventListener('DOMContentLoaded', () => {
import { onPageReady } from '../i18n/i18n.js';
onPageReady(() => {
const container = document.getElementById('markdown-editor-container');
if (!container) {
+15 -10
View File
@@ -12,7 +12,7 @@ import {
showWasmRequiredDialog,
WasmProvider,
} from '../utils/wasm-provider.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
@@ -166,11 +166,14 @@ async function renderPageMergeThumbnails() {
'text-xs text-gray-400 truncate w-full text-center';
const fullTitle = displayName
? `${displayName} (page ${pageNumber})`
: `Page ${pageNumber}`;
: trText('Page {{pageNumber}}', { pageNumber });
fileNamePara.title = fullTitle;
fileNamePara.textContent = displayName
? `${displayName.substring(0, 10)}... (p${pageNumber})`
: `Page ${pageNumber}`;
? trText('{{name}}... (p{{pageNumber}})', {
name: displayName.substring(0, 10),
pageNumber,
})
: trText('Page {{pageNumber}}', { pageNumber });
wrapper.append(imgContainer, fileNamePara);
return wrapper;
@@ -291,7 +294,7 @@ export async function merge() {
if (mergeState.activeMode === 'file') {
const fileList = document.getElementById('file-list');
if (!fileList) throw new Error('File list not found');
if (!fileList) throw new Error(trText('File list not found'));
const sortedFileKeys = Array.from(fileList.children)
.map((li) => (li as HTMLElement).dataset.fileName)
@@ -321,7 +324,7 @@ export async function merge() {
} else {
// Page Mode
const pageContainer = document.getElementById('page-merge-preview');
if (!pageContainer) throw new Error('Page container not found');
if (!pageContainer) throw new Error(trText('Page container not found'));
const pageElements = Array.from(pageContainer.children);
const rawPages: { fileName: string; pageIndex: number }[] = [];
@@ -523,14 +526,16 @@ export async function refreshMergeUI() {
const label = document.createElement('label');
label.htmlFor = `range-${safeFileName}`;
label.className = 'text-xs text-gray-400';
label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`;
label.textContent = trText('Pages (e.g., 1-3, 5) - Total: {{pageCount}}', {
pageCount,
});
const input = document.createElement('input');
input.type = 'text';
input.id = `range-${safeFileName}`;
input.className =
'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors';
input.placeholder = 'Leave blank for all pages';
input.placeholder = trText('Leave blank for all pages');
inputWrapper.append(label, input);
@@ -538,7 +543,7 @@ export async function refreshMergeUI() {
deleteBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 self-end';
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
deleteBtn.title = 'Remove file';
deleteBtn.title = trText('Remove file');
deleteBtn.onclick = (e) => {
e.stopPropagation();
state.files = state.files.filter((_, i) => i !== index);
@@ -603,7 +608,7 @@ export async function refreshMergeUI() {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+27 -7
View File
@@ -4,11 +4,13 @@ import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const FILETYPE = 'mobi';
const EXTENSIONS = ['.mobi'];
const TOOL_NAME = 'MOBI';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -83,7 +85,12 @@ document.addEventListener('DOMContentLoaded', () => {
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
showAlert(
'No Files',
trText('Please select at least one {{TOOL_NAME}} file.', {
TOOL_NAME,
})
);
return;
}
@@ -92,7 +99,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
showLoader(
trText('Converting {{name}}...', { name: originalFile.name })
);
const pdfBlob = await pymupdf.convertToPdf(originalFile, {
filetype: FILETYPE,
@@ -104,7 +113,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -116,7 +127,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await pymupdf.convertToPdf(file, {
@@ -134,7 +149,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
trText(
'Successfully converted {{length}} {{TOOL_NAME}} file(s) to PDF.',
{ length: state.files.length, TOOL_NAME }
),
'success',
() => resetState()
);
@@ -144,7 +162,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+13 -4
View File
@@ -5,6 +5,8 @@ import { PDFDocument as PDFLibDocument, rgb, PageSizes } from 'pdf-lib';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface NUpState {
file: File | null;
pdfDoc: PDFLibDocument | null;
@@ -51,7 +53,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -79,7 +83,10 @@ async function updateUI() {
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
if (toolOptions) toolOptions.classList.remove('hidden');
} catch (error) {
@@ -152,7 +159,9 @@ async function nUpTool() {
const usableHeight = pageHeight - margin * 2;
for (let i = 0; i < sourcePages.length; i += n) {
showLoader(`Processing sheet ${Math.floor(i / n) + 1}...`);
showLoader(
trText('Processing sheet {{n}}...', { n: Math.floor(i / n) + 1 })
);
const chunk = sourcePages.slice(i, i + n);
const outputPage = newDoc.addPage([pageWidth, pageHeight]);
@@ -234,7 +243,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+2 -2
View File
@@ -10,7 +10,7 @@ import {
resolveConfiguredTesseractAvailableLanguages,
UnsupportedOcrLanguageError,
} from '../utils/tesseract-language-availability.js';
import { initI18n, t } from '../i18n/index.js';
import { initI18n, t, onPageReady } from '../i18n/index.js';
const pageState: OcrState = {
file: null,
@@ -285,7 +285,7 @@ function populateLanguageList() {
});
}
document.addEventListener('DOMContentLoaded', async function () {
onPageReady(async function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById(
+215 -179
View File
@@ -1,189 +1,225 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
import {
getLibreOfficeConverter,
type LoadProgress,
} from '../utils/libreoffice-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const ACCEPTED_EXTENSIONS = ['.odg'];
const FILETYPE_NAME = 'ODG';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className =
'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert(
'No Files',
trText('Please select at least one {{FILETYPE_NAME}} file.', {
FILETYPE_NAME,
})
);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(trText('Converting {{name}}...', { name: file.name }));
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{name}} to PDF.', {
name: file.name,
}),
'success',
() => resetState()
);
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{length}} files to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
}
} catch (err) {
hideLoader();
const message =
err instanceof Error ? err.message : trText('Unknown error');
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert(
'Error',
trText('An error occurred during conversion. Error: {{message}}', {
message,
})
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter((file) => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});
+215 -179
View File
@@ -1,189 +1,225 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
import {
getLibreOfficeConverter,
type LoadProgress,
} from '../utils/libreoffice-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const ACCEPTED_EXTENSIONS = ['.odp'];
const FILETYPE_NAME = 'ODP';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className =
'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert(
'No Files',
trText('Please select at least one {{FILETYPE_NAME}} file.', {
FILETYPE_NAME,
})
);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(trText('Converting {{name}}...', { name: file.name }));
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{name}} to PDF.', {
name: file.name,
}),
'success',
() => resetState()
);
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{length}} files to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
}
} catch (err) {
hideLoader();
const message =
err instanceof Error ? err.message : trText('Unknown error');
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert(
'Error',
trText('An error occurred during conversion. Error: {{message}}', {
message,
})
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter((file) => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});
+215 -179
View File
@@ -1,189 +1,225 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
import {
getLibreOfficeConverter,
type LoadProgress,
} from '../utils/libreoffice-loader.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const ACCEPTED_EXTENSIONS = ['.ods'];
const FILETYPE_NAME = 'ODS';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className =
'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert(
'No Files',
trText('Please select at least one {{FILETYPE_NAME}} file.', {
FILETYPE_NAME,
})
);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(trText('Converting {{name}}...', { name: file.name }));
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{name}} to PDF.', {
name: file.name,
}),
'success',
() => resetState()
);
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert(
'Conversion Complete',
trText('Successfully converted {{length}} files to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
}
} catch (err) {
hideLoader();
const message =
err instanceof Error ? err.message : trText('Unknown error');
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert(
'Error',
trText('An error occurred during conversion. Error: {{message}}', {
message,
})
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter((file) => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});
+17 -5
View File
@@ -7,7 +7,9 @@ import {
type LoadProgress,
} from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
import { trText, onPageReady } from '../i18n/i18n.js';
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@@ -113,7 +115,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: originalFile.name,
}),
'success',
() => resetState()
);
@@ -125,7 +129,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
@@ -143,7 +151,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ODT file(s) to PDF.`,
trText('Successfully converted {{length}} ODT file(s) to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -152,7 +162,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+13 -9
View File
@@ -9,6 +9,8 @@ import Sortable from 'sortablejs';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface OrganizeState {
file: File | null;
pdfDoc: PDFDocument | null;
@@ -25,11 +27,7 @@ const organizeState: OrganizeState = {
sortableInstance: null,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -97,7 +95,10 @@ function applyCustomOrder() {
if (newOrder.length !== currentGridCount) {
showAlert(
'Incorrect Page Count',
`The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`
trText(
'The number of pages specified ({{length}}) does not match the current number of pages in the document ({{currentGridCount}}). Please provide a complete ordering for all pages.',
{ length: newOrder.length, currentGridCount }
)
);
return;
}
@@ -206,7 +207,10 @@ function updateFileDisplay() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`;
metaSpan.textContent = trText('{{size}} • {{totalPages}} pages', {
size: formatBytes(organizeState.file.size),
totalPages: organizeState.totalPages,
});
infoContainer.append(nameSpan, metaSpan);
@@ -306,13 +310,13 @@ async function renderThumbnails() {
const duplicateBtn = document.createElement('button');
duplicateBtn.className =
'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
duplicateBtn.title = 'Duplicate Page';
duplicateBtn.title = trText('Duplicate Page');
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
const deleteBtn = document.createElement('button');
deleteBtn.className =
'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
deleteBtn.title = 'Delete Page';
deleteBtn.title = trText('Delete Page');
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
controlsDiv.append(duplicateBtn, deleteBtn);
+13 -7
View File
@@ -8,6 +8,8 @@ import {
import { icons, createIcons } from 'lucide';
import type { OverlayPdfState, QpdfInstanceExtended } from '@/types';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: OverlayPdfState = {
baseFile: null,
overlayFile: null,
@@ -119,7 +121,7 @@ async function processOverlay() {
const loaderModal = document.getElementById('loader-modal');
const loaderText = document.getElementById('loader-text');
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Initializing PDF engine...';
if (loaderText) loaderText.textContent = trText('Initializing PDF engine...');
const inputPath = '/input_base.pdf';
const overlayPath = '/input_overlay.pdf';
@@ -129,7 +131,7 @@ async function processOverlay() {
try {
qpdf = await initializeQpdf();
if (loaderText) loaderText.textContent = 'Reading files...';
if (loaderText) loaderText.textContent = trText('Reading files...');
const baseBuffer = await readFileAsArrayBuffer(pageState.baseFile);
const overlayBuffer = await readFileAsArrayBuffer(pageState.overlayFile);
@@ -155,7 +157,9 @@ async function processOverlay() {
const shouldRepeat = repeatCheckbox?.checked;
if (loaderText)
loaderText.textContent = `Applying ${mode.replace('--', '')}...`;
loaderText.textContent = trText('Applying {{replace}}...', {
replace: mode.replace('--', ''),
});
const args = [inputPath, mode, overlayPath];
@@ -173,7 +177,7 @@ async function processOverlay() {
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
if (!outputFile || outputFile.length === 0) {
throw new Error('Processing produced an empty file.');
throw new Error(trText('Processing produced an empty file.'));
}
const modeLabel = mode.replace('--', '');
@@ -184,7 +188,7 @@ async function processOverlay() {
showAlert(
'Success',
`PDF ${modeLabel} applied successfully.`,
trText('PDF {{modeLabel}} applied successfully.', { modeLabel }),
'success',
() => {
resetState();
@@ -194,7 +198,9 @@ async function processOverlay() {
console.error('Overlay/underlay error:', error);
showAlert(
'Processing Failed',
`An error occurred: ${error instanceof Error ? error.message : 'Unknown error'}.`
trText('An error occurred: {{error}}.', {
error: error instanceof Error ? error.message : trText('Unknown error'),
})
);
} finally {
try {
@@ -247,7 +253,7 @@ function setupDropZone(
});
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const baseDropZone = document.getElementById('base-drop-zone');
const baseInput = document.getElementById(
'base-file-input'
+37 -22
View File
@@ -13,6 +13,8 @@ import {
} from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, trHtml, onPageReady } from '../i18n/i18n.js';
const pageState: PageDimensionsState = {
file: null,
pdfDoc: null,
@@ -88,17 +90,17 @@ function renderSummary() {
let summaryHTML = `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="bg-gray-900 border border-gray-700 rounded-lg p-4">
<p class="text-sm text-gray-400 mb-1">Total Pages</p>
<p class="text-sm text-gray-400 mb-1">${trHtml('Total Pages')}</p>
<p class="text-2xl font-bold text-white">${stats.totalPages}</p>
</div>
<div class="bg-gray-900 border border-gray-700 rounded-lg p-4">
<p class="text-sm text-gray-400 mb-1">Unique Page Sizes</p>
<p class="text-sm text-gray-400 mb-1">${trHtml('Unique Page Sizes')}</p>
<p class="text-2xl font-bold text-white">${stats.uniqueSizesCount}</p>
</div>
<div class="bg-gray-900 border border-gray-700 rounded-lg p-4">
<p class="text-sm text-gray-400 mb-1">Document Type</p>
<p class="text-sm text-gray-400 mb-1">${trHtml('Document Type')}</p>
<p class="text-2xl font-bold ${stats.hasMixedSizes ? 'text-yellow-400' : 'text-green-400'}">
${stats.hasMixedSizes ? 'Mixed Sizes' : 'Uniform'}
${stats.hasMixedSizes ? trText('Mixed Sizes') : trText('Uniform')}
</p>
</div>
</div>
@@ -110,13 +112,13 @@ function renderSummary() {
<div class="flex items-start gap-3">
<i data-lucide="alert-triangle" class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0"></i>
<div>
<h4 class="text-yellow-200 font-semibold mb-2">Mixed Page Sizes Detected</h4>
<p class="text-sm text-gray-300 mb-3">This document contains pages with different dimensions:</p>
<h4 class="text-yellow-200 font-semibold mb-2">${trHtml('Mixed Page Sizes Detected')}</h4>
<p class="text-sm text-gray-300 mb-3">${trHtml('This document contains pages with different dimensions:')}</p>
<ul class="space-y-1 text-sm text-gray-300">
${stats.uniqueSizes
.map(
(size: UniqueSizeEntry) => `
<li>• ${size.label}: ${size.count} page${size.count > 1 ? 's' : ''}</li>
<li>${size.count > 1 ? trHtml('• {{label}}: {{count}} pages', { label: size.label, count: size.count }) : trHtml('• {{label}}: 1 page', { label: size.label })}</li>
`
)
.join('')}
@@ -154,7 +156,11 @@ function renderTable(unit: string) {
const dimensionsCell = document.createElement('td');
dimensionsCell.className = 'px-4 py-3 text-gray-300';
dimensionsCell.textContent = `${width} x ${height} ${unit}`;
dimensionsCell.textContent = trText('{{width}} x {{height}} {{unit}}', {
width,
height,
unit,
});
const sizeCell = document.createElement('td');
sizeCell.className = 'px-4 py-3 text-gray-300';
@@ -174,7 +180,9 @@ function renderTable(unit: string) {
const rotationCell = document.createElement('td');
rotationCell.className = 'px-4 py-3 text-gray-300';
rotationCell.textContent = `${pageData.rotation}°`;
rotationCell.textContent = trText('{{rotation}}°', {
rotation: pageData.rotation,
});
row.append(
pageNumCell,
@@ -196,16 +204,20 @@ function exportToCSV() {
const unit = unitsSelect?.value || 'pt';
const headers = [
'Page #',
`Width (${unit})`,
`Height (${unit})`,
'Standard Size',
'Orientation',
'Aspect Ratio',
`Area (${unit}²)`,
'Rotation',
trText('Page #'),
trText('Width ({{unit}})', { unit }),
trText('Height ({{unit}})', { unit }),
trText('Standard Size'),
trText('Orientation'),
trText('Aspect Ratio'),
trText('Area ({{unit}}²)', { unit }),
trText('Rotation'),
];
const csvRows = [headers.join(',')];
const csvCell = (value: string | number) => {
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
const csvRows = [headers.map(csvCell).join(',')];
analyzedPagesData.forEach((pageData) => {
const width = convertPoints(pageData.width, unit);
@@ -223,7 +235,7 @@ function exportToCSV() {
area,
`${pageData.rotation}°`,
];
csvRows.push(row.join(','));
csvRows.push(row.map(csvCell).join(','));
});
const csvContent = csvRows.join('\n');
@@ -252,8 +264,11 @@ function analyzeAndDisplayDimensions() {
pageNum: index + 1,
width,
height,
orientation: width > height ? 'Landscape' : 'Portrait',
standardSize: getStandardPageName(width, height),
orientation: width > height ? trText('Landscape') : trText('Portrait'),
standardSize: (() => {
const name = getStandardPageName(width, height);
return name === 'Custom' ? trText('Custom') : name;
})(),
rotation: rotation,
});
});
@@ -357,7 +372,7 @@ async function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const backBtn = document.getElementById('back-to-tools');
+7 -6
View File
@@ -10,6 +10,8 @@ import {
} from '../utils/pdf-operations.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface PageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
@@ -20,11 +22,7 @@ const pageState: PageState = {
pdfDoc: null,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -122,7 +120,10 @@ function updateFileDisplay() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
+21 -7
View File
@@ -8,10 +8,12 @@ import {
} from '../utils/libreoffice-loader.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const ACCEPTED_EXTENSIONS = ['.pages'];
const FILETYPE_NAME = 'Pages';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@@ -90,7 +92,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 0) {
showAlert(
'No Files',
`Please select at least one ${FILETYPE_NAME} file.`
trText('Please select at least one {{FILETYPE_NAME}} file.', {
FILETYPE_NAME,
})
);
return;
}
@@ -105,7 +109,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
showLoader(trText('Converting {{name}}...', { name: file.name }));
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
@@ -114,7 +118,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${file.name} to PDF.`,
trText('Successfully converted {{name}} to PDF.', {
name: file.name,
}),
'success',
() => resetState()
);
@@ -127,7 +133,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const pdfBlob = await converter.convertToPdf(file);
@@ -145,7 +155,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} files to PDF.`,
trText('Successfully converted {{length}} files to PDF.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -155,7 +167,9 @@ document.addEventListener('DOMContentLoaded', () => {
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, e);
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+23 -11
View File
@@ -7,6 +7,8 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import { trText, trHtml, onPageReady } from '../i18n/i18n.js';
interface BookletState {
file: File | null;
pdfDoc: PDFLibDocument | null;
@@ -38,8 +40,7 @@ function resetState() {
const previewArea = document.getElementById('booklet-preview');
if (previewArea)
previewArea.innerHTML =
'<p class="text-gray-400 text-center py-8">Upload a PDF and click "Generate Preview" to see the booklet layout</p>';
previewArea.innerHTML = `<p class="text-gray-400 text-center py-8">${trHtml('Upload a PDF and click "Generate Preview" to see the booklet layout')}</p>`;
const downloadBtn = document.getElementById(
'download-btn'
@@ -69,7 +70,9 @@ async function updateUI() {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
metaSpan.textContent = trText('{{size}} • Loading...', {
size: formatBytes(pageState.file.size),
});
infoContainer.append(nameSpan, metaSpan);
@@ -100,7 +103,10 @@ async function updateUI() {
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
metaSpan.textContent = trText('{{size}} • {{pageCount}} pages', {
size: formatBytes(pageState.file.size),
pageCount,
});
if (toolOptions) toolOptions.classList.remove('hidden');
@@ -197,8 +203,7 @@ async function generatePreview() {
const canvasWidth = containerWidth;
const canvasHeight = containerWidth / aspectRatio;
previewArea.innerHTML =
'<p class="text-gray-400 text-center py-4">Generating preview...</p>';
previewArea.innerHTML = `<p class="text-gray-400 text-center py-4">${trHtml('Generating preview...')}</p>`;
const totalRounded = isBookletMode
? Math.ceil(totalPages / 4) * 4
@@ -234,7 +239,7 @@ async function generatePreview() {
}
}
previewArea.innerHTML = `<p class="text-indigo-400 text-sm mb-4 text-center">${totalPages} pages → ${numSheets} output sheets</p>`;
previewArea.innerHTML = `<p class="text-indigo-400 text-sm mb-4 text-center">${trHtml('{{totalPages}} pages → {{numSheets}} output sheets', { totalPages, numSheets })}</p>`;
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
const canvas = document.createElement('canvas');
@@ -375,9 +380,16 @@ async function generatePreview() {
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : '';
const sideLabel = isBookletMode
? isFront
? trText('Front')
: trText('Back')
: '';
ctx.fillText(
`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`,
trText('Sheet {{n}} {{sideLabel}}', {
n: Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1,
sideLabel,
}),
canvasWidth - 6,
4
);
@@ -527,7 +539,7 @@ async function createBooklet() {
showAlert(
'Success',
`Booklet created with ${numSheets} sheets!`,
trText('Booklet created with {{numSheets}} sheets!', { numSheets }),
'success',
function () {
resetState();
@@ -554,7 +566,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const previewBtn = document.getElementById('preview-btn');
+25 -13
View File
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, trHtml, onPageReady } from '../i18n/i18n';
import {
downloadFile,
escapeHtml,
@@ -41,7 +41,7 @@ let currentDoc: PyMuPDFDocument | null = null;
const layersMap = new Map<number, LayerData>();
let nextDisplayOrder = 0;
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -75,7 +75,10 @@ document.addEventListener('DOMContentLoaded', () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(currentFile.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(currentFile.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -93,10 +96,15 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const arrayBuffer = await readFileAsArrayBuffer(currentFile);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(currentFile.size)} • ${pdfDoc.numPages} pages`;
metaSpan.textContent = trText('{{size}} • {{numPages}} pages', {
size: formatBytes(currentFile.size),
numPages: pdfDoc.numPages,
});
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(currentFile.size)} • Could not load page count`;
metaSpan.textContent = trText('{{size}} • Could not load page count', {
size: formatBytes(currentFile.size),
});
}
createIcons({ icons });
@@ -191,8 +199,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (layersArray.length === 0) {
layersList.innerHTML = `
<div class="layers-empty">
<p>This PDF has no layers (OCG).</p>
<p>Add a new layer to get started!</p>
<p>${trHtml('This PDF has no layers (OCG).')}</p>
<p>${trHtml('Add a new layer to get started!')}</p>
</div>
`;
return;
@@ -209,12 +217,12 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || trText('Layer {{number}}', { number: layer.number }))}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label>
<div class="layer-actions">
${!layer.locked ? `<button class="layer-add-child" data-xref="${layer.xref}" title="Add child layer">+</button>` : ''}
${!layer.locked ? `<button class="layer-delete" data-xref="${layer.xref}" title="Delete layer">✕</button>` : ''}
${!layer.locked ? `<button class="layer-add-child" data-xref="${layer.xref}" title="${trHtml('Add child layer')}">+</button>` : ''}
${!layer.locked ? `<button class="layer-delete" data-xref="${layer.xref}" title="${trHtml('Delete layer')}">✕</button>` : ''}
</div>
</div>
`
@@ -280,8 +288,10 @@ document.addEventListener('DOMContentLoaded', () => {
);
const childName = await promptForInput(
'Add Child Layer',
`Enter name for child layer under "${parentLayer?.text || 'Layer'}":`
trText('Add Child Layer'),
trText('Enter name for child layer under "{{Layer}}":', {
Layer: parentLayer?.text || 'Layer',
})
);
if (!childName || !childName.trim()) return;
@@ -328,7 +338,9 @@ document.addEventListener('DOMContentLoaded', () => {
showLoader('Loading engine...');
const pymupdf = await loadPyMuPDF();
showLoader(`Loading layers from ${currentFile.name}...`);
showLoader(
trText('Loading layers from {{name}}...', { name: currentFile.name })
);
currentDoc = await (
pymupdf as { open: (file: File) => Promise<PyMuPDFDocument> }
).open(currentFile);
+38 -29
View File
@@ -15,7 +15,7 @@ import { repairPdfFile } from './repair-pdf.js';
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
@@ -85,8 +85,8 @@ function showModal(
if (!modal || !modalTitle || !modalMessage || !modalIcon) return;
modalTitle.textContent = title;
modalMessage.textContent = message;
modalTitle.textContent = trText(title);
modalMessage.textContent = trText(message);
const iconMap = {
info: 'info',
@@ -162,17 +162,7 @@ function hideLoading() {
if (loader) loader.classList.add('hidden');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
console.log('PDF Multi Tool: DOMContentLoaded');
initializeTool();
});
} else {
console.log(
'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'
);
initializeTool();
}
onPageReady(initializeTool);
function initializeTool() {
console.log('PDF Multi Tool: Initializing...');
@@ -514,7 +504,9 @@ async function loadPdfs(files: File[]) {
console.log(`Repairing ${file.name}...`);
const loadingText = document.getElementById('loading-text');
if (loadingText)
loadingText.textContent = `Repairing ${file.name}...`;
loadingText.textContent = trText('Repairing {{name}}...', {
name: file.name,
});
const repairedData = await repairPdfFile(file);
if (repairedData) {
@@ -680,7 +672,10 @@ function createPageElement(
// Page info
const info = document.createElement('div');
info.className = 'text-xs text-gray-400 text-center mb-2';
info.textContent = `${t('common.page')} ${index + 1}`;
info.textContent = trText('{{page}} {{n}}', {
page: t('common.page'),
n: index + 1,
});
// Actions toolbar
const actions = document.createElement('div');
@@ -1026,7 +1021,7 @@ async function handleInsertPdf(e: Event) {
console.error('Failed to insert PDF:', e);
showModal(
'Error',
'Failed to insert PDF. The file may be corrupted.',
trText('Failed to insert PDF. The file may be corrupted.'),
'error'
);
}
@@ -1085,7 +1080,11 @@ function addBlankPage() {
function bulkRotate(delta: number) {
if (selectedPages.size === 0) {
showModal('No Selection', 'Please select pages to rotate.', 'info');
showModal(
trText('No Selection'),
trText('Please select pages to rotate.'),
'info'
);
return;
}
@@ -1117,7 +1116,11 @@ function bulkRotate(delta: number) {
function bulkDelete() {
if (selectedPages.size === 0) {
showModal('No Selection', 'Please select pages to delete.', 'info');
showModal(
trText('No Selection'),
trText('Please select pages to delete.'),
'info'
);
return;
}
const indices = Array.from(selectedPages).sort((a, b) => b - a);
@@ -1134,7 +1137,11 @@ function bulkDelete() {
function bulkDuplicate() {
if (selectedPages.size === 0) {
showModal('No Selection', 'Please select pages to duplicate.', 'info');
showModal(
trText('No Selection'),
trText('Please select pages to duplicate.'),
'info'
);
return;
}
const indices = Array.from(selectedPages).sort((a, b) => b - a);
@@ -1148,8 +1155,8 @@ function bulkDuplicate() {
function bulkSplit() {
if (selectedPages.size === 0) {
showModal(
'No Selection',
'Please select pages to mark for splitting.',
trText('No Selection'),
trText('Please select pages to mark for splitting.'),
'info'
);
return;
@@ -1167,7 +1174,7 @@ function bulkSplit() {
async function downloadAll() {
if (allPages.length === 0) {
showModal('No Pages', 'Please upload PDFs first.', 'info');
showModal(trText('No Pages'), trText('Please upload PDFs first.'), 'info');
return;
}
@@ -1278,12 +1285,14 @@ async function downloadSplitPdfs() {
showModal(
'Success',
`Downloaded ${segments.length} PDF files in a ZIP archive.`,
trText('Downloaded {{length}} PDF files in a ZIP archive.', {
length: segments.length,
}),
'success'
);
} catch (e) {
console.error('Failed to create split PDFs:', e);
showModal('Error', 'Failed to create split PDFs.', 'error');
showModal('Error', trText('Failed to create split PDFs.'), 'error');
} finally {
hideLoading(); // Ensure loader is hidden if we used it (though showModal replaces it)
}
@@ -1358,10 +1367,10 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
});
downloadFile(blob, filename);
showModal('Success', 'PDF downloaded successfully.', 'success');
showModal('Success', trText('PDF downloaded successfully.'), 'success');
} catch (e) {
console.error('Failed to create PDF:', e);
showModal('Error', 'Failed to create PDF.', 'error');
showModal('Error', trText('Failed to create PDF.'), 'error');
}
}
@@ -1400,7 +1409,7 @@ function updatePageDisplay() {
const info = card.querySelector(
'.text-xs.text-gray-400.text-center.mb-2'
);
if (info) info.textContent = `Page ${index + 1} `;
if (info) info.textContent = trText('Page {{n}} ', { n: index + 1 });
// Update selection state
const selectBtn = card.querySelector(
@@ -1508,7 +1517,7 @@ function updatePageNumbers() {
// Update visible page number text
const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2');
if (info) {
info.textContent = `Page ${index + 1} `;
info.textContent = trText('Page {{n}} ', { n: index + 1 });
}
// Re-attach event listeners for buttons
+11 -4
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -42,7 +42,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
}); // Initial state
infoContainer.append(nameSpan, metaSpan);
@@ -65,7 +68,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -154,7 +161,7 @@ async function renderPage(page: PDFPageProxy): Promise<Blob | null> {
return blob;
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+23 -12
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
generateComicInfoXml,
generateMetadataOpf,
@@ -112,7 +112,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -131,7 +134,11 @@ const updateUI = () => {
readFileAsArrayBuffer(file)
.then((buffer) => getPDFDocument(buffer).promise)
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch(() => {
metaSpan.textContent = formatBytes(file.size);
@@ -180,7 +187,7 @@ async function renderPage(
const viewport = page.getViewport({ scale: options.scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to acquire 2D canvas context');
if (!context) throw new Error(trText('Failed to acquire 2D canvas context'));
canvas.height = viewport.height;
canvas.width = viewport.width;
@@ -233,7 +240,7 @@ async function convert() {
const { pdf } = result;
if (pdf.numPages === 0) {
throw new Error('PDF has no pages');
throw new Error(trText('PDF has no pages'));
}
const zip = new JSZip();
@@ -292,7 +299,7 @@ async function convert() {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -320,13 +327,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (qualitySlider && qualityValue) {
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = `${qualitySlider.value}%`;
qualityValue.textContent = trText('{{value}}%', {
value: qualitySlider.value,
});
});
}
if (scaleSlider && scaleValue) {
scaleSlider.addEventListener('input', () => {
scaleValue.textContent = `${parseFloat(scaleSlider.value).toFixed(1)}x`;
scaleValue.textContent = trText('{{value}}x', {
value: parseFloat(scaleSlider.value).toFixed(1),
});
});
}
@@ -398,7 +409,7 @@ document.addEventListener('DOMContentLoaded', () => {
setInputValidity(
yearInput,
!isNaN(year) && year >= 1900 && year <= 2100,
'Year must be between 1900 and 2100'
trText('Year must be between 1900 and 2100')
);
});
}
@@ -414,7 +425,7 @@ document.addEventListener('DOMContentLoaded', () => {
setInputValidity(
ratingInput,
!isNaN(rating) && rating >= 0 && rating <= 5,
'Rating must be between 0 and 5'
trText('Rating must be between 0 and 5')
);
});
}
@@ -429,7 +440,7 @@ document.addEventListener('DOMContentLoaded', () => {
setInputValidity(
numberInput,
!isNaN(parseFloat(val)) && parseFloat(val) >= 0,
'Must be a positive number'
trText('Must be a positive number')
);
});
}
@@ -445,7 +456,7 @@ document.addEventListener('DOMContentLoaded', () => {
setInputValidity(
volumeInput,
!isNaN(vol) && vol >= 1,
'Must be a positive integer'
trText('Must be a positive integer')
);
});
}
+14 -4
View File
@@ -3,6 +3,8 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let file: File | null = null;
const updateUI = () => {
@@ -100,7 +102,12 @@ async function convert() {
const allRows: (string | null)[][] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
showLoader(
trText('Scanning page {{n}} of {{pageCount}}...', {
n: i + 1,
pageCount,
})
);
const page = doc.getPage(i);
const tables = page.findTables();
@@ -126,14 +133,17 @@ async function convert() {
);
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to CSV. ${message}`);
const message = e instanceof Error ? e.message : trText('Unknown error');
showAlert(
'Error',
trText('Failed to convert PDF to CSV. {{message}}', { message })
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+21 -9
View File
@@ -4,6 +4,8 @@ import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as XLSX from 'xlsx';
import { trText, onPageReady } from '../i18n/i18n.js';
let file: File | null = null;
const updateUI = () => {
@@ -86,7 +88,12 @@ async function convert() {
const allTables: TableData[] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
showLoader(
trText('Scanning page {{n}} of {{pageCount}}...', {
n: i + 1,
pageCount,
})
);
const page = doc.getPage(i);
const tables = page.findTables();
@@ -112,10 +119,10 @@ async function convert() {
XLSX.utils.book_append_sheet(workbook, worksheet, 'Table');
} else {
allTables.forEach((table, idx) => {
const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(
0,
31
);
const sheetName = trText('Table {{n}} (Page {{page}})', {
n: idx + 1,
page: table.page,
}).substring(0, 31);
const worksheet = XLSX.utils.aoa_to_sheet(table.rows);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
@@ -128,20 +135,25 @@ async function convert() {
downloadFile(blob, `${baseName}.xlsx`);
showAlert(
'Success',
`Extracted ${allTables.length} table(s) to Excel!`,
trText('Extracted {{length}} table(s) to Excel!', {
length: allTables.length,
}),
'success',
resetState
);
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to Excel. ${message}`);
const message = e instanceof Error ? e.message : trText('Unknown error');
showAlert(
'Error',
trText('Failed to convert PDF to Excel. {{message}}', { message })
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+11 -4
View File
@@ -9,7 +9,7 @@ import { createIcons, icons } from 'lucide';
import { PDFDocument } from 'pdf-lib';
import { applyGreyscale } from '../utils/image-effects.js';
import * as pdfjsLib from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -42,7 +42,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
}); // Initial state
infoContainer.append(nameSpan, metaSpan);
@@ -64,7 +67,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -159,7 +166,7 @@ async function convert() {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+14 -5
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -42,7 +42,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
}); // Initial state
infoContainer.append(nameSpan, metaSpan);
@@ -64,7 +67,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -167,7 +174,7 @@ async function renderPage(
return blob;
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -185,7 +192,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (qualitySlider && qualityValue) {
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
});
}
+29 -10
View File
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
downloadFile,
readFileAsArrayBuffer,
@@ -12,7 +12,7 @@ import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -54,7 +54,10 @@ document.addEventListener('DOMContentLoaded', () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -73,9 +76,15 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`;
metaSpan.textContent = trText('{{size}} • {{numPages}} pages', {
size: formatBytes(file.size),
numPages: pdfDoc.numPages,
});
} catch {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
metaSpan.textContent = trText(
'{{size}} • Could not load page count',
{ size: formatBytes(file.size) }
);
}
}
@@ -115,7 +124,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
showLoader(trText('Converting {{name}}...', { name: file.name }));
const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
const outName = file.name.replace(/\.pdf$/i, '') + '.md';
@@ -126,7 +135,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${file.name} to Markdown.`,
trText('Successfully converted {{name}} to Markdown.', {
name: file.name,
}),
'success',
() => resetState()
);
@@ -138,7 +149,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
@@ -155,7 +170,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to Markdown.`,
trText('Successfully converted {{length}} PDF(s) to Markdown.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -164,7 +181,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+30 -9
View File
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
downloadFile,
readFileAsArrayBuffer,
@@ -15,7 +15,7 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -57,7 +57,10 @@ document.addEventListener('DOMContentLoaded', () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -76,10 +79,16 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`;
metaSpan.textContent = trText('{{size}} • {{numPages}} pages', {
size: formatBytes(file.size),
numPages: pdfDoc.numPages,
});
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
metaSpan.textContent = trText(
'{{size}} • Could not load page count',
{ size: formatBytes(file.size) }
);
}
}
@@ -164,7 +173,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to ${level}.`,
trText('Successfully converted {{name}} to {{level}}.', {
name: originalFile.name,
level,
}),
'success',
() => resetState()
);
@@ -177,7 +189,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const convertedBlob = await convertFileToPdfA(file, level, (msg) =>
@@ -201,7 +217,10 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to ${level}.`,
trText('Successfully converted {{length}} PDF(s) to {{level}}.', {
length: state.files.length,
level,
}),
'success',
() => resetState()
);
@@ -210,7 +229,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+14 -5
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -42,7 +42,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
}); // Initial state
infoContainer.append(nameSpan, metaSpan);
@@ -64,7 +67,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -163,7 +170,7 @@ async function renderPage(
return blob;
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -179,7 +186,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (scaleSlider && scaleValue) {
scaleSlider.addEventListener('input', () => {
scaleValue.textContent = `${parseFloat(scaleSlider.value).toFixed(1)}x`;
scaleValue.textContent = trText('{{value}}x', {
value: parseFloat(scaleSlider.value).toFixed(1),
});
});
}
+31 -8
View File
@@ -7,6 +7,8 @@ import type { PyMuPDFInstance } from '@/types';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let pymupdf: PyMuPDFInstance | null = null;
let files: File[] = [];
@@ -114,7 +116,12 @@ async function convert() {
} else {
const zip = new JSZip();
for (let i = 0; i < pageCount; i++) {
showLoader(`Converting page ${i + 1} of ${pageCount}...`);
showLoader(
trText('Converting page {{n}} of {{pageCount}}...', {
n: i + 1,
pageCount,
})
);
const page = doc.getPage(i);
const svgContent = page.toSvg();
zip.file(`page_${i + 1}.svg`, svgContent);
@@ -124,7 +131,7 @@ async function convert() {
downloadFile(zipBlob, `${baseName}_svg.zip`);
showAlert(
'Success',
`Converted ${pageCount} pages to SVG!`,
trText('Converted {{pageCount}} pages to SVG!', { pageCount }),
'success',
() => resetState()
);
@@ -135,14 +142,24 @@ async function convert() {
for (let f = 0; f < files.length; f++) {
const file = files[f];
showLoader(`Processing file ${f + 1} of ${files.length}...`);
showLoader(
trText('Processing file {{n}} of {{length}}...', {
n: f + 1,
length: files.length,
})
);
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
const baseName = file.name.replace(/\.[^/.]+$/, '');
for (let i = 0; i < pageCount; i++) {
showLoader(
`File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}`
trText('File {{n}}/{{length}}: Page {{n2}}/{{pageCount}}', {
n: f + 1,
length: files.length,
n2: i + 1,
pageCount,
})
);
const page = doc.getPage(i);
const svgContent = page.toSvg();
@@ -160,21 +177,27 @@ async function convert() {
downloadFile(zipBlob, 'pdf_to_svg.zip');
showAlert(
'Success',
`Converted ${files.length} files (${totalPages} pages) to SVG!`,
trText('Converted {{length}} files ({{totalPages}} pages) to SVG!', {
length: files.length,
totalPages,
}),
'success',
() => resetState()
);
}
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to SVG. ${message}`);
const message = e instanceof Error ? e.message : trText('Unknown error');
showAlert(
'Error',
trText('Failed to convert PDF to SVG. {{message}}', { message })
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+17 -9
View File
@@ -6,14 +6,12 @@ import type { PyMuPDFInstance } from '@/types';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let files: File[] = [];
let pymupdf: PyMuPDFInstance | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -135,7 +133,9 @@ function updateUI() {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
@@ -182,7 +182,9 @@ async function extractText() {
if (files.length === 1) {
const file = files[0];
showLoader(`Extracting text from ${file.name}...`);
showLoader(
trText('Extracting text from {{name}}...', { name: file.name })
);
const fullText = await mupdf.pdfToText(file);
@@ -206,7 +208,11 @@ async function extractText() {
for (let i = 0; i < files.length; i++) {
const file = files[i];
showLoader(
`Extracting text from file ${i + 1}/${files.length}: ${file.name}...`
trText('Extracting text from file {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: files.length,
name: file.name,
})
);
const fullText = await mupdf.pdfToText(file);
@@ -222,7 +228,9 @@ async function extractText() {
hideLoader();
showAlert(
'Success',
`Extracted text from ${files.length} PDF files!`,
trText('Extracted text from {{length}} PDF files!', {
length: files.length,
}),
'success',
() => {
resetState();
+11 -4
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import type Vips from 'wasm-vips';
import wasmUrl from 'wasm-vips/vips.wasm?url';
import type { TiffOptions } from '@/types';
@@ -82,7 +82,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -103,7 +106,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -377,7 +384,7 @@ async function convert() {
}
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+14 -5
View File
@@ -10,7 +10,7 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import '../utils/setup-pdf-worker.js';
@@ -42,7 +42,10 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
}); // Initial state
infoContainer.append(nameSpan, metaSpan);
@@ -64,7 +67,11 @@ const updateUI = () => {
return getPDFDocument(buffer).promise;
})
.then((pdf) => {
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} ${pdf.numPages !== 1 ? t('common.pages') : t('common.page')}`;
metaSpan.textContent = trText('{{size}} • {{numPages}} {{value}}', {
size: formatBytes(file.size),
numPages: pdf.numPages,
value: pdf.numPages !== 1 ? t('common.pages') : t('common.page'),
});
})
.catch((e) => {
console.warn('Error loading PDF page count:', e);
@@ -167,7 +174,7 @@ async function renderPage(
return blob;
}
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -185,7 +192,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (qualitySlider && qualityValue) {
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`;
qualityValue.textContent = trText('{{value}}%', {
value: Math.round(parseFloat(qualitySlider.value) * 100),
});
});
}
+29 -10
View File
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { t, trText, onPageReady } from '../i18n/i18n';
import {
downloadFile,
readFileAsArrayBuffer,
@@ -13,7 +13,7 @@ import { normalizeCmykForDocx } from '../utils/ghostscript-loader.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
document.addEventListener('DOMContentLoaded', () => {
onPageReady(() => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
@@ -52,7 +52,10 @@ document.addEventListener('DOMContentLoaded', () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
metaSpan.textContent = trText('{{size}} • {{loadingPageCount}}', {
size: formatBytes(file.size),
loadingPageCount: t('common.loadingPageCount'),
});
infoContainer.append(nameSpan, metaSpan);
@@ -71,9 +74,15 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`;
metaSpan.textContent = trText('{{size}} • {{numPages}} pages', {
size: formatBytes(file.size),
numPages: pdfDoc.numPages,
});
} catch {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
metaSpan.textContent = trText(
'{{size}} • Could not load page count',
{ size: formatBytes(file.size) }
);
}
}
@@ -111,7 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
showLoader(trText('Converting {{name}}...', { name: file.name }));
const docxBlob = await pymupdf.pdfToDocx(
await normalizeCmykForDocx(file)
@@ -123,7 +132,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${file.name} to DOCX.`,
trText('Successfully converted {{name}} to DOCX.', {
name: file.name,
}),
'success',
() => resetState()
);
@@ -135,7 +146,11 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
trText('Converting {{n}}/{{length}}: {{name}}...', {
n: i + 1,
length: state.files.length,
name: file.name,
})
);
const docxBlob = await pymupdf.pdfToDocx(
@@ -158,7 +173,9 @@ document.addEventListener('DOMContentLoaded', () => {
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to DOCX.`,
trText('Successfully converted {{length}} PDF(s) to DOCX.', {
length: state.files.length,
}),
'success',
() => resetState()
);
@@ -167,7 +184,9 @@ document.addEventListener('DOMContentLoaded', () => {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e instanceof Error ? e.message : String(e)}`
trText('An error occurred during conversion. Error: {{error}}', {
error: e instanceof Error ? e.message : String(e),
})
);
}
};
+10 -2
View File
@@ -4,6 +4,8 @@ import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { trText, onPageReady } from '../i18n/i18n.js';
interface PdfToZipState {
files: File[];
}
@@ -89,7 +91,13 @@ async function createZipArchive() {
for (let i = 0; i < pageState.files.length; i++) {
const file = pageState.files[i];
showLoader(`Adding ${file.name} (${i + 1}/${pageState.files.length})...`);
showLoader(
trText('Adding {{name}} ({{n}}/{{length}})...', {
name: file.name,
n: i + 1,
length: pageState.files.length,
})
);
const arrayBuffer = await file.arrayBuffer();
const zipEntryName = deduplicateFileName(file.name, usedNames);
zip.file(zipEntryName, arrayBuffer);
@@ -130,7 +138,7 @@ function handleFileSelect(files: FileList | null) {
}
}
document.addEventListener('DOMContentLoaded', function () {
onPageReady(function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
+150 -103
View File
@@ -3,6 +3,7 @@ import { createWorkflowEditor, updateNodeDisplay } from '@/js/workflow/editor';
import type { WorkflowEditor } from '@/js/workflow/editor';
import { executeWorkflow } from '@/js/workflow/engine';
import { translateCategory, translateNodeLabel } from '@/js/workflow/i18n';
import { onPageReady, trText } from '@/js/i18n/i18n.js';
import type { NodeCategory } from '@/js/workflow/types';
import {
type BaseWorkflowNode,
@@ -51,11 +52,7 @@ let workflowEditor: WorkflowEditor | null = null;
let selectedNodeId: string | null = null;
let deleteNodeHandler: EventListener | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
async function initializePage() {
const container = document.getElementById('rete-container');
@@ -104,12 +101,14 @@ async function initializePage() {
try {
await executeWorkflow(editor, engine, area, (progress) => {
const msg = progress.message || `Processing ${progress.nodeName}...`;
const msg =
progress.message ||
trText('Processing {{nodeName}}...', { nodeName: progress.nodeName });
if (statusText) statusText.textContent = msg;
});
if (statusText) statusText.textContent = 'Workflow completed';
if (statusText) statusText.textContent = trText('Workflow completed');
} catch (err) {
if (statusText) statusText.textContent = 'Error during execution';
if (statusText) statusText.textContent = trText('Error during execution');
showAlert('Error', (err as Error).message);
} finally {
runBtn.disabled = false;
@@ -121,7 +120,7 @@ async function initializePage() {
await editor.clear();
updateNodeCount();
const statusText = document.getElementById('status-text');
if (statusText) statusText.textContent = 'Ready';
if (statusText) statusText.textContent = trText('Ready');
document.getElementById('settings-sidebar')?.classList.add('hidden');
});
@@ -290,7 +289,11 @@ function updateNodeCount() {
if (!workflowEditor) return;
const count = workflowEditor.editor.getNodes().length;
const el = document.getElementById('node-count');
if (el) el.textContent = `${count} node${count !== 1 ? 's' : ''}`;
if (el)
el.textContent =
count !== 1
? trText('{{count}} nodes', { count })
: trText('{{count}} node', { count });
}
function showSaveTemplateModal(
@@ -320,18 +323,22 @@ function showSaveTemplateModal(
const doSave = () => {
const name = nameInput.value.trim();
if (!name) {
errorEl.textContent = 'Please enter a name.';
errorEl.textContent = trText('Please enter a name.');
errorEl.classList.remove('hidden');
return;
}
if (templateNameExists(name)) {
errorEl.textContent = 'A template with this name already exists.';
errorEl.textContent = trText('A template with this name already exists.');
errorEl.classList.remove('hidden');
return;
}
saveWorkflow(editor, area, name);
cleanup();
showAlert('Saved', `Template "${name}" saved.`, 'success');
showAlert(
'Saved',
trText('Template "{{name}}" saved.', { name }),
'success'
);
};
const onKeydown = (e: KeyboardEvent) => {
@@ -381,13 +388,17 @@ function showLoadTemplateModal(
const loadBtn = document.createElement('button');
loadBtn.className =
'bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium px-3 py-1.5 rounded-md transition-colors flex-shrink-0';
loadBtn.textContent = 'Load';
loadBtn.textContent = trText('Load');
loadBtn.addEventListener('click', async () => {
const loaded = await loadWorkflow(editor, area, name);
cleanup();
if (loaded) {
updateNodeCount();
showAlert('Loaded', `Template "${name}" loaded.`, 'success');
showAlert(
'Loaded',
trText('Template "{{name}}" loaded.', { name }),
'success'
);
} else {
showAlert('Error', 'Failed to load template.');
}
@@ -675,7 +686,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const label = document.createElement('label');
label.className = 'block text-xs text-gray-400 mb-1';
label.textContent = 'PDF Files';
label.textContent = trText('PDF Files');
fileSection.appendChild(label);
if (node.hasFile()) {
@@ -688,7 +699,9 @@ function showNodeSettings(node: BaseWorkflowNode) {
const uploadBtn = document.createElement('button');
uploadBtn.className =
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
uploadBtn.textContent = node.hasFile() ? 'Add More Files' : 'Upload PDFs';
uploadBtn.textContent = node.hasFile()
? trText('Add More Files')
: trText('Upload PDFs');
const fileInput = document.createElement('input');
fileInput.type = 'file';
@@ -710,7 +723,9 @@ function showNodeSettings(node: BaseWorkflowNode) {
} catch {
showAlert(
'Error',
`Wrong password or failed to decrypt "${file.name}".`
trText('Wrong password or failed to decrypt "{{name}}".', {
name: file.name,
})
);
}
}
@@ -734,13 +749,14 @@ function showNodeSettings(node: BaseWorkflowNode) {
const label = document.createElement('label');
label.className = 'block text-xs text-gray-400 mb-1';
label.textContent = 'Images';
label.textContent = trText('Images');
fileSection.appendChild(label);
const formatHint = document.createElement('p');
formatHint.className = 'text-[10px] text-gray-500 mb-2';
formatHint.textContent =
'Supported: JPG, PNG, BMP, GIF, TIFF, WebP, HEIC, PSD, SVG, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2';
formatHint.textContent = trText(
'Supported: JPG, PNG, BMP, GIF, TIFF, WebP, HEIC, PSD, SVG, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2'
);
fileSection.appendChild(formatHint);
if (node.hasFile()) {
@@ -754,8 +770,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
uploadBtn.className =
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
uploadBtn.textContent = node.hasFile()
? 'Add More Images'
: 'Upload Images';
? trText('Add More Images')
: trText('Upload Images');
const fileInput = document.createElement('input');
fileInput.type = 'file';
@@ -785,7 +801,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const certLabel = document.createElement('label');
certLabel.className = 'block text-xs text-gray-400 mb-1';
certLabel.textContent = 'Certificate (.pfx, .p12, .pem)';
certLabel.textContent = trText('Certificate (.pfx, .p12, .pem)');
certSection.appendChild(certLabel);
if (node.hasCertFile()) {
@@ -803,7 +819,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const removeBtn = document.createElement('button');
removeBtn.className =
'text-red-400 hover:text-red-300 text-xs flex-shrink-0';
removeBtn.textContent = 'Remove';
removeBtn.textContent = trText('Remove');
removeBtn.addEventListener('click', () => {
node.removeCert();
showNodeSettings(node);
@@ -818,7 +834,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const pwLabel = document.createElement('label');
pwLabel.className = 'block text-xs text-gray-400 mb-1';
pwLabel.textContent = 'Certificate Password';
pwLabel.textContent = trText('Certificate Password');
pwSection.appendChild(pwLabel);
const pwRow = document.createElement('div');
@@ -826,14 +842,14 @@ function showNodeSettings(node: BaseWorkflowNode) {
const pwInput = document.createElement('input');
pwInput.type = 'password';
pwInput.placeholder = 'Enter password...';
pwInput.placeholder = trText('Enter password...');
pwInput.className =
'flex-1 bg-gray-700 border border-gray-600 text-white text-xs px-3 py-2 rounded-lg focus:outline-none focus:border-indigo-500';
const unlockBtn = document.createElement('button');
unlockBtn.className =
'bg-indigo-600 hover:bg-indigo-500 text-white text-xs px-3 py-2 rounded-lg transition-colors flex-shrink-0';
unlockBtn.textContent = 'Unlock';
unlockBtn.textContent = trText('Unlock');
const statusMsg = document.createElement('div');
statusMsg.className = 'text-xs mt-1 hidden';
@@ -841,15 +857,15 @@ function showNodeSettings(node: BaseWorkflowNode) {
const doUnlock = async () => {
const pw = pwInput.value;
if (!pw) return;
unlockBtn.textContent = 'Unlocking...';
unlockBtn.textContent = trText('Unlocking...');
unlockBtn.disabled = true;
const success = await node.unlockCert(pw);
if (success) {
showNodeSettings(node);
} else {
unlockBtn.textContent = 'Unlock';
unlockBtn.textContent = trText('Unlock');
unlockBtn.disabled = false;
statusMsg.textContent = 'Incorrect password';
statusMsg.textContent = trText('Incorrect password');
statusMsg.className = 'text-xs mt-1 text-red-400';
statusMsg.classList.remove('hidden');
}
@@ -866,7 +882,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
} else if (node.hasCert()) {
const okMsg = document.createElement('div');
okMsg.className = 'text-xs text-green-400 mb-2';
okMsg.textContent = 'Certificate unlocked';
okMsg.textContent = trText('Certificate unlocked');
certSection.appendChild(okMsg);
}
}
@@ -875,8 +891,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
uploadBtn.className =
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
uploadBtn.textContent = node.hasCertFile()
? 'Change Certificate'
: 'Upload Certificate';
? trText('Change Certificate')
: trText('Upload Certificate');
const certInput = document.createElement('input');
certInput.type = 'file';
@@ -921,135 +937,156 @@ function showNodeSettings(node: BaseWorkflowNode) {
cls: new (...args: unknown[]) => FileInputNode;
label: string;
accept: string;
btnLabel: string;
uploadLabel: string;
addMoreLabel: string;
hint?: string;
}[] = [
{
cls: WordToPdfNode,
label: 'Word Documents',
label: trText('Word Documents'),
accept: '.doc,.docx,.odt,.rtf',
btnLabel: 'Documents',
hint: 'Supported: DOC, DOCX, ODT, RTF',
uploadLabel: trText('Upload Documents'),
addMoreLabel: trText('Add More Documents'),
hint: trText('Supported: DOC, DOCX, ODT, RTF'),
},
{
cls: ExcelToPdfNode,
label: 'Spreadsheets',
label: trText('Spreadsheets'),
accept: '.xlsx,.xls,.ods,.csv',
btnLabel: 'Spreadsheets',
hint: 'Supported: XLSX, XLS, ODS, CSV',
uploadLabel: trText('Upload Spreadsheets'),
addMoreLabel: trText('Add More Spreadsheets'),
hint: trText('Supported: XLSX, XLS, ODS, CSV'),
},
{
cls: PowerPointToPdfNode,
label: 'Presentations',
label: trText('Presentations'),
accept: '.ppt,.pptx,.odp',
btnLabel: 'Presentations',
hint: 'Supported: PPT, PPTX, ODP',
uploadLabel: trText('Upload Presentations'),
addMoreLabel: trText('Add More Presentations'),
hint: trText('Supported: PPT, PPTX, ODP'),
},
{
cls: TextToPdfNode,
label: 'Text Files',
label: trText('Text Files'),
accept: '.txt',
btnLabel: 'Text Files',
uploadLabel: trText('Upload Text Files'),
addMoreLabel: trText('Add More Text Files'),
},
{
cls: SvgToPdfNode,
label: 'SVG Files',
label: trText('SVG Files'),
accept: '.svg',
btnLabel: 'SVG Files',
uploadLabel: trText('Upload SVG Files'),
addMoreLabel: trText('Add More SVG Files'),
},
{
cls: EpubToPdfNode,
label: 'EPUB Files',
label: trText('EPUB Files'),
accept: '.epub',
btnLabel: 'EPUB Files',
uploadLabel: trText('Upload EPUB Files'),
addMoreLabel: trText('Add More EPUB Files'),
},
{
cls: EmailToPdfNode,
label: 'Email Files',
label: trText('Email Files'),
accept: '.eml,.msg',
btnLabel: 'Email Files',
hint: 'Supported: EML, MSG',
uploadLabel: trText('Upload Email Files'),
addMoreLabel: trText('Add More Email Files'),
hint: trText('Supported: EML, MSG'),
},
{
cls: XpsToPdfNode,
label: 'XPS Files',
label: trText('XPS Files'),
accept: '.xps,.oxps',
btnLabel: 'XPS Files',
hint: 'Supported: XPS, OXPS',
uploadLabel: trText('Upload XPS Files'),
addMoreLabel: trText('Add More XPS Files'),
hint: trText('Supported: XPS, OXPS'),
},
{
cls: MobiToPdfNode,
label: 'MOBI Files',
label: trText('MOBI Files'),
accept: '.mobi',
btnLabel: 'MOBI Files',
uploadLabel: trText('Upload MOBI Files'),
addMoreLabel: trText('Add More MOBI Files'),
},
{
cls: Fb2ToPdfNode,
label: 'FB2 Files',
label: trText('FB2 Files'),
accept: '.fb2',
btnLabel: 'FB2 Files',
uploadLabel: trText('Upload FB2 Files'),
addMoreLabel: trText('Add More FB2 Files'),
},
{
cls: CbzToPdfNode,
label: 'Comic Archives',
label: trText('Comic Archives'),
accept: '.cbz,.cbr',
btnLabel: 'Comics',
hint: 'Supported: CBZ, CBR',
uploadLabel: trText('Upload Comics'),
addMoreLabel: trText('Add More Comics'),
hint: trText('Supported: CBZ, CBR'),
},
{
cls: MarkdownToPdfNode,
label: 'Markdown Files',
label: trText('Markdown Files'),
accept: '.md,.markdown',
btnLabel: 'Markdown Files',
uploadLabel: trText('Upload Markdown Files'),
addMoreLabel: trText('Add More Markdown Files'),
},
{
cls: JsonToPdfNode,
label: 'JSON Files',
label: trText('JSON Files'),
accept: '.json',
btnLabel: 'JSON Files',
uploadLabel: trText('Upload JSON Files'),
addMoreLabel: trText('Add More JSON Files'),
},
{
cls: XmlToPdfNode,
label: 'XML Files',
label: trText('XML Files'),
accept: '.xml',
btnLabel: 'XML Files',
uploadLabel: trText('Upload XML Files'),
addMoreLabel: trText('Add More XML Files'),
},
{
cls: WpdToPdfNode,
label: 'WordPerfect Files',
label: trText('WordPerfect Files'),
accept: '.wpd',
btnLabel: 'WPD Files',
uploadLabel: trText('Upload WPD Files'),
addMoreLabel: trText('Add More WPD Files'),
},
{
cls: WpsToPdfNode,
label: 'WPS Files',
label: trText('WPS Files'),
accept: '.wps',
btnLabel: 'WPS Files',
uploadLabel: trText('Upload WPS Files'),
addMoreLabel: trText('Add More WPS Files'),
},
{
cls: PagesToPdfNode,
label: 'Pages Files',
label: trText('Pages Files'),
accept: '.pages',
btnLabel: 'Pages Files',
uploadLabel: trText('Upload Pages Files'),
addMoreLabel: trText('Add More Pages Files'),
},
{
cls: OdgToPdfNode,
label: 'ODG Files',
label: trText('ODG Files'),
accept: '.odg',
btnLabel: 'ODG Files',
uploadLabel: trText('Upload ODG Files'),
addMoreLabel: trText('Add More ODG Files'),
},
{
cls: PubToPdfNode,
label: 'Publisher Files',
label: trText('Publisher Files'),
accept: '.pub',
btnLabel: 'PUB Files',
uploadLabel: trText('Upload PUB Files'),
addMoreLabel: trText('Add More PUB Files'),
},
{
cls: VsdToPdfNode,
label: 'Visio Files',
label: trText('Visio Files'),
accept: '.vsd,.vsdx',
btnLabel: 'Visio Files',
hint: 'Supported: VSD, VSDX',
uploadLabel: trText('Upload Visio Files'),
addMoreLabel: trText('Add More Visio Files'),
hint: trText('Supported: VSD, VSDX'),
},
];
@@ -1081,8 +1118,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
uploadBtn.className =
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
uploadBtn.textContent = fileNode.hasFile()
? `Add More ${fileInputConfig.btnLabel}`
: `Upload ${fileInputConfig.btnLabel}`;
? fileInputConfig.addMoreLabel
: fileInputConfig.uploadLabel;
const fileInput = document.createElement('input');
fileInput.type = 'file';
@@ -1096,7 +1133,12 @@ function showNodeSettings(node: BaseWorkflowNode) {
await fileNode.addFiles(files);
showNodeSettings(node);
} catch (err) {
showAlert('Error', `Failed to load files: ${(err as Error).message}`);
showAlert(
'Error',
trText('Failed to load files: {{error}}', {
error: (err as Error).message,
})
);
}
});
@@ -1119,7 +1161,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
if (controlEntries.length === 0) {
const empty = document.createElement('p');
empty.className = 'text-xs text-gray-500';
empty.textContent = 'No configurable settings for this node.';
empty.textContent = trText('No configurable settings for this node.');
content.appendChild(empty);
return;
}
@@ -1128,16 +1170,18 @@ function showNodeSettings(node: BaseWorkflowNode) {
const controlHints: Record<string, string> = {
pages: 'e.g. 1-3, 5, 7-9',
whitelist: 'Limit recognized characters (leave empty for all)',
afterPage: 'Insert blank pages after this page number',
x0: 'Left edge in points (1 inch = 72 pts)',
y0: 'Top edge in points',
x1: 'Right edge in points',
y1: 'Bottom edge in points',
targetKilobytes:
'Finds the best quality under this size. 0 means no target.',
retainPageLabels:
"Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels).",
whitelist: trText('Limit recognized characters (leave empty for all)'),
afterPage: trText('Insert blank pages after this page number'),
x0: trText('Left edge in points (1 inch = 72 pts)'),
y0: trText('Top edge in points'),
x1: trText('Right edge in points'),
y1: trText('Bottom edge in points'),
targetKilobytes: trText(
'Finds the best quality under this size. 0 means no target.'
),
retainPageLabels: trText(
"Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels)."
),
};
const inputClass =
@@ -1166,7 +1210,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const controlLabel = document.createElement('label');
controlLabel.className = 'block text-xs text-gray-400 mb-1';
controlLabel.textContent = formatLabel(key);
controlLabel.textContent = trText(formatLabel(key));
wrapper.appendChild(controlLabel);
if (spec?.kind === 'multiselect') {
@@ -1179,7 +1223,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search languages...';
searchInput.placeholder = trText('Search languages...');
searchInput.className = inputClass;
container.appendChild(searchInput);
@@ -1200,7 +1244,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
const tag = document.createElement('span');
tag.className =
'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-indigo-600 text-white text-[10px]';
tag.textContent = opt.label;
tag.textContent = trText(opt.label);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '\u00d7';
@@ -1226,7 +1270,10 @@ function showNodeSettings(node: BaseWorkflowNode) {
listDiv.innerHTML = '';
const lowerFilter = filter.toLowerCase();
for (const opt of options) {
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
if (
lowerFilter &&
!trText(opt.label).toLowerCase().includes(lowerFilter)
)
continue;
const label = document.createElement('label');
label.className =
@@ -1246,7 +1293,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
updateCtrl();
});
label.appendChild(cb);
label.appendChild(document.createTextNode(opt.label));
label.appendChild(document.createTextNode(trText(opt.label)));
listDiv.appendChild(label);
}
}
@@ -1264,7 +1311,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
for (const opt of spec.options) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
option.textContent = trText(opt.label);
if (currentValue === opt.value) option.selected = true;
select.appendChild(option);
}
@@ -1397,7 +1444,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
summary.className =
'text-xs font-medium text-gray-400 cursor-pointer select-none flex items-center justify-between';
const summaryText = document.createElement('span');
summaryText.textContent = 'Advanced Settings';
summaryText.textContent = trText('Advanced Settings');
summary.appendChild(summaryText);
const chevron = document.createElement('i');
chevron.className =
+12 -8
View File
@@ -11,13 +11,11 @@ import {
compressImageBytes,
} from '../utils/image-compress.js';
import { trText, onPageReady } from '../i18n/i18n.js';
let files: File[] = [];
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
@@ -137,7 +135,9 @@ function updateUI() {
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
sizeSpan.textContent = trText('({{size}})', {
size: formatBytes(file.size),
});
infoContainer.append(nameSpan, sizeSpan);
@@ -180,7 +180,9 @@ function sanitizeImageAsJpeg(imageBytes: Uint8Array | ArrayBuffer) {
canvas.toBlob(
async (jpegBlob) => {
if (!jpegBlob) {
return reject(new Error('Canvas toBlob conversion failed.'));
return reject(
new Error(trText('Canvas toBlob conversion failed.'))
);
}
const arrayBuffer = await jpegBlob.arrayBuffer();
resolve(new Uint8Array(arrayBuffer));
@@ -195,7 +197,9 @@ function sanitizeImageAsJpeg(imageBytes: Uint8Array | ArrayBuffer) {
URL.revokeObjectURL(imageUrl);
reject(
new Error(
'The provided file could not be loaded as an image. It may be corrupted.'
trText(
'The provided file could not be loaded as an image. It may be corrupted.'
)
)
);
};
Loaded 100 of 3344 files, more files were not shown because too many files have changed in this diff. Show more