feat: add "Add Page Labels" tool to the application

- Introduced a new tool for adding page labels to PDF documents, allowing users to apply Roman numerals, prefixes, and custom numbering ranges.
- Created a new HTML page for the tool with a user-friendly interface for file upload and label rule configuration.
- Implemented logic for handling file uploads, processing PDF files, and applying page labels based on user-defined rules.
- Added necessary types and utility functions for managing page label styles and normalization of start values.
- Updated main application configuration to include the new tool in the navigation.
- Added tests for page label utilities to ensure correct functionality.
This commit is contained in:
alam00000
2026-03-16 14:34:27 +05:30
parent 31f43b557f
commit 477839f106
27 changed files with 2318 additions and 0 deletions

View File

@@ -0,0 +1,565 @@
import { createIcons, icons } from 'lucide';
import { PDFDocument } from 'pdf-lib';
import type {
AddPageLabelsState,
LabelRule,
PageLabelStyleName,
} from '@/types';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { t } from '../i18n/index.js';
import {
downloadFile,
formatBytes,
readFileAsArrayBuffer,
} from '../utils/helpers.js';
import { getCpdf, isCpdfAvailable } from '../utils/cpdf-helper.js';
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import {
PAGE_LABEL_STYLE_OPTIONS,
normalizePageLabelStartValue,
resolvePageLabelStyle,
} from '../utils/page-labels.js';
type AddPageLabelsCpdf = {
setSlow?: () => void;
fromMemory(data: Uint8Array, userpw: string): CoherentPdf;
removePageLabels(pdf: CoherentPdf): void;
parsePagespec(pdf: CoherentPdf, pagespec: string): CpdfPageRange;
all(pdf: CoherentPdf): CpdfPageRange;
addPageLabels(
pdf: CoherentPdf,
style: CpdfLabelStyle,
prefix: string,
offset: number,
range: CpdfPageRange,
progress: boolean
): void;
toMemory(pdf: CoherentPdf, linearize: boolean, makeId: boolean): Uint8Array;
deletePdf(pdf: CoherentPdf): void;
decimalArabic: CpdfLabelStyle;
lowercaseRoman: CpdfLabelStyle;
uppercaseRoman: CpdfLabelStyle;
lowercaseLetters: CpdfLabelStyle;
uppercaseLetters: CpdfLabelStyle;
noLabelPrefixOnly?: CpdfLabelStyle;
};
let labelRuleCounter = 0;
const translate = (
key: string,
fallback: string,
options?: Record<string, unknown>
) => {
const translation = t(key, options);
return translation && translation !== key ? translation : fallback;
};
const STYLE_LABEL_FALLBACKS: Record<PageLabelStyleName, string> = {
DecimalArabic: 'Decimal Arabic',
LowercaseRoman: 'Lowercase Roman',
UppercaseRoman: 'Uppercase Roman',
LowercaseLetters: 'Lowercase Letters',
UppercaseLetters: 'Uppercase Letters',
NoLabelPrefixOnly: 'No Label Prefix Only',
};
const pageState: AddPageLabelsState = {
file: null,
pageCount: 0,
rules: [createLabelRule()],
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
function createLabelRule(overrides: Partial<LabelRule> = {}): LabelRule {
labelRuleCounter += 1;
return {
id: `label-rule-${labelRuleCounter}`,
pageRange: '',
style: 'DecimalArabic',
prefix: '',
startValue: 1,
progress: false,
...overrides,
};
}
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById(
'drop-zone'
) as HTMLDivElement | null;
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
const addRuleBtn = document.getElementById('add-rule-btn');
if (fileInput) {
fileInput.addEventListener('change', handleFileUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('border-indigo-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-500');
});
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('border-indigo-500');
if (event.dataTransfer?.files.length) {
handleFiles(event.dataTransfer.files);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
if (addRuleBtn) {
addRuleBtn.addEventListener('click', () => {
pageState.rules.push(createLabelRule());
renderRules();
});
}
if (processBtn) {
processBtn.addEventListener('click', addPageLabels);
}
renderRules();
}
function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
handleFiles(input.files);
}
}
async function handleFiles(files: FileList) {
const file = files[0];
if (
!file ||
(file.type !== 'application/pdf' &&
!file.name.toLowerCase().endsWith('.pdf'))
) {
showAlert(
translate('tools:addPageLabels.invalidFileTitle', 'Invalid File'),
translate(
'tools:addPageLabels.invalidFileMessage',
'Please upload a valid PDF file.'
)
);
return;
}
showLoader(translate('tools:addPageLabels.loadingPdf', 'Loading PDF...'));
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
if (pdfDoc.isEncrypted) {
showAlert(
translate('tools:addPageLabels.protectedPdfTitle', 'Protected PDF'),
translate(
'tools:addPageLabels.protectedPdfMessage',
'This PDF is password-protected. Please use the Decrypt or Change Permissions tool first.'
)
);
resetState();
return;
}
pageState.file = file;
pageState.pageCount = pdfDoc.getPageCount();
updateFileDisplay();
document.getElementById('options-panel')?.classList.remove('hidden');
} catch (error) {
console.error(error);
showAlert(
translate('common.error', 'Error'),
translate(
'tools:addPageLabels.loadErrorMessage',
'Failed to load PDF file. The file may be invalid, corrupted, or password-protected.'
)
);
} finally {
hideLoader();
}
}
function updateFileDisplay() {
const fileDisplayArea = document.getElementById('file-display-area');
if (!fileDisplayArea || !pageState.file) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = translate(
'tools:addPageLabels.fileMeta',
`${formatBytes(pageState.file.size)}${pageState.pageCount} pages`,
{
size: formatBytes(pageState.file.size),
count: pageState.pageCount,
}
);
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 = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function resetState() {
pageState.file = null;
pageState.pageCount = 0;
pageState.rules = [createLabelRule()];
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
document.getElementById('options-panel')?.classList.add('hidden');
renderRules();
}
function renderRules() {
const ruleList = document.getElementById('label-rules');
if (!ruleList) return;
ruleList.innerHTML = '';
pageState.rules.forEach((rule, index) => {
const card = document.createElement('div');
card.className =
'rounded-lg border border-gray-700 bg-gray-900 p-4 space-y-4';
const header = document.createElement('div');
header.className = 'flex items-center justify-between gap-4';
const title = document.createElement('div');
title.className = 'text-sm font-semibold text-white';
title.textContent = translate(
'tools:addPageLabels.ruleTitle',
`Label Rule ${index + 1}`,
{ number: index + 1 }
);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className =
'text-red-400 hover:text-red-300 disabled:text-gray-600 disabled:cursor-not-allowed';
removeBtn.disabled = pageState.rules.length === 1;
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.addEventListener('click', () => {
if (pageState.rules.length === 1) {
return;
}
pageState.rules = pageState.rules.filter((entry) => entry.id !== rule.id);
renderRules();
});
header.append(title, removeBtn);
const rangeGroup = document.createElement('div');
const rangeLabel = document.createElement('label');
rangeLabel.className = 'block mb-2 text-sm font-medium text-gray-300';
rangeLabel.textContent = translate(
'tools:addPageLabels.pageRangeLabel',
'Page Range'
);
const rangeInput = document.createElement('input');
rangeInput.type = 'text';
rangeInput.value = rule.pageRange;
rangeInput.placeholder = translate(
'tools:addPageLabels.pageRangePlaceholder',
'All pages, or e.g. 1-4, 7, odd'
);
rangeInput.className =
'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500';
rangeInput.addEventListener('input', (event) => {
rule.pageRange = (event.target as HTMLInputElement).value;
});
rangeGroup.append(rangeLabel, rangeInput);
const styleGrid = document.createElement('div');
styleGrid.className = 'grid grid-cols-1 md:grid-cols-2 gap-4';
const styleGroup = document.createElement('div');
const styleLabel = document.createElement('label');
styleLabel.className = 'block mb-2 text-sm font-medium text-gray-300';
styleLabel.textContent = translate(
'tools:addPageLabels.labelStyleLabel',
'Label Style'
);
const styleSelect = document.createElement('select');
styleSelect.className =
'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500';
PAGE_LABEL_STYLE_OPTIONS.forEach((styleName) => {
const option = document.createElement('option');
option.value = styleName;
option.textContent = translate(
`tools:addPageLabels.styleOptions.${styleName}`,
STYLE_LABEL_FALLBACKS[styleName]
);
option.selected = styleName === rule.style;
styleSelect.appendChild(option);
});
styleSelect.addEventListener('change', (event) => {
rule.style = (event.target as HTMLSelectElement)
.value as PageLabelStyleName;
});
styleGroup.append(styleLabel, styleSelect);
const prefixGroup = document.createElement('div');
const prefixLabel = document.createElement('label');
prefixLabel.className = 'block mb-2 text-sm font-medium text-gray-300';
prefixLabel.textContent = translate(
'tools:addPageLabels.labelPrefixLabel',
'Label Prefix'
);
const prefixInput = document.createElement('input');
prefixInput.type = 'text';
prefixInput.value = rule.prefix;
prefixInput.placeholder = translate(
'tools:addPageLabels.labelPrefixPlaceholder',
'Optional prefix, e.g. A-'
);
prefixInput.className =
'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500';
prefixInput.addEventListener('input', (event) => {
rule.prefix = (event.target as HTMLInputElement).value;
});
prefixGroup.append(prefixLabel, prefixInput);
styleGrid.append(styleGroup, prefixGroup);
const startGrid = document.createElement('div');
startGrid.className = 'grid grid-cols-1 md:grid-cols-2 gap-4';
const startGroup = document.createElement('div');
const startLabel = document.createElement('label');
startLabel.className = 'block mb-2 text-sm font-medium text-gray-300';
startLabel.textContent = translate(
'tools:addPageLabels.startValueLabel',
'Start Value'
);
const startInput = document.createElement('input');
startInput.type = 'number';
startInput.min = '0';
startInput.step = '1';
startInput.value = String(rule.startValue);
startInput.className =
'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500';
startInput.addEventListener('input', (event) => {
rule.startValue = normalizePageLabelStartValue(
parseInt((event.target as HTMLInputElement).value, 10)
);
});
startGroup.append(startLabel, startInput);
const progressGroup = document.createElement('div');
progressGroup.className = 'flex items-end';
const progressLabel = document.createElement('label');
progressLabel.className =
'flex w-full items-center gap-3 rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-sm text-gray-300';
const progressInput = document.createElement('input');
progressInput.type = 'checkbox';
progressInput.checked = rule.progress;
progressInput.className =
'h-4 w-4 rounded border-gray-500 bg-gray-700 text-indigo-600 focus:ring-indigo-500';
progressInput.addEventListener('change', (event) => {
rule.progress = (event.target as HTMLInputElement).checked;
});
const progressText = document.createElement('span');
progressText.textContent = translate(
'tools:addPageLabels.continueNumbering',
'Continue numbering across disjoint ranges'
);
progressLabel.append(progressInput, progressText);
progressGroup.appendChild(progressLabel);
startGrid.append(startGroup, progressGroup);
const note = document.createElement('p');
note.className = 'text-xs text-gray-500';
note.textContent = translate(
'tools:addPageLabels.examplesNote',
'Examples: 1-4 for Roman front matter, 15-20 with prefix A- and start value 0, or odd with progress enabled.'
);
card.append(header, rangeGroup, styleGrid, startGrid, note);
ruleList.appendChild(card);
});
createIcons({ icons });
}
async function addPageLabels() {
if (!pageState.file) {
showAlert(
translate('common.error', 'Error'),
translate(
'tools:addPageLabels.uploadFirstMessage',
'Please upload a PDF file first.'
)
);
return;
}
if (!isCpdfAvailable()) {
showWasmRequiredDialog('cpdf');
return;
}
showLoader(
translate('tools:addPageLabels.applyingLabels', 'Applying page labels...')
);
const removeExistingLabels =
(
document.getElementById(
'remove-existing-labels'
) as HTMLInputElement | null
)?.checked ?? true;
let cpdf: AddPageLabelsCpdf | null = null;
let pdf: CoherentPdf | null = null;
try {
cpdf = await getCpdf();
cpdf.setSlow?.();
const inputBytes = new Uint8Array(await pageState.file.arrayBuffer());
pdf = cpdf.fromMemory(inputBytes, '');
if (removeExistingLabels) {
cpdf.removePageLabels(pdf);
}
for (let index = 0; index < pageState.rules.length; index += 1) {
const rule = pageState.rules[index];
const trimmedRange = rule.pageRange.trim();
let range: CpdfPageRange;
try {
range = trimmedRange
? cpdf.parsePagespec(pdf, trimmedRange)
: cpdf.all(pdf);
} catch (error) {
throw new Error(
translate(
'tools:addPageLabels.invalidRangeMessage',
`Rule ${index + 1} has an invalid page range: ${trimmedRange || 'all pages'}`,
{
number: index + 1,
range:
trimmedRange ||
translate('tools:addPageLabels.allPages', 'all pages'),
}
),
{ cause: error }
);
}
cpdf.addPageLabels(
pdf,
resolvePageLabelStyle(cpdf, rule.style),
rule.prefix.trim(),
normalizePageLabelStartValue(rule.startValue),
range,
rule.progress
);
}
const outputBytes = new Uint8Array(cpdf.toMemory(pdf, false, false));
if (!outputBytes || outputBytes.length === 0) {
throw new Error(
translate(
'tools:addPageLabels.emptyOutputMessage',
'CoherentPDF produced an empty file.'
)
);
}
downloadFile(
new Blob([outputBytes], { type: 'application/pdf' }),
'page-labels-added.pdf'
);
showAlert(
translate('common.success', 'Success'),
translate(
'tools:addPageLabels.successMessage',
'Page labels added successfully!'
),
'success',
() => {
resetState();
}
);
} catch (error) {
console.error(error);
const message =
error instanceof Error
? error.message
: translate(
'tools:addPageLabels.processErrorMessage',
'Could not add page labels.'
);
showAlert(translate('common.error', 'Error'), message);
} finally {
if (cpdf && pdf) {
try {
cpdf.deletePdf(pdf);
} catch (cleanupError) {
console.warn('Failed to cleanup CoherentPDF document:', cleanupError);
}
}
hideLoader();
}
}