feat(multi-pdf): enhance PDF multi-tool functionality to allow drag n drop of PDF and images

fix(ocr): pin font CDN to rawcdn.githack.com with commit SHAs
This commit is contained in:
alam00000
2026-05-29 20:05:50 +05:30
parent 1534ce4d85
commit 1d9b7c2a07
30 changed files with 751 additions and 121 deletions
+27 -27
View File
@@ -160,59 +160,59 @@ export const languageToFontFamily: Record<string, string> = {
export const fontFamilyToUrl: Record<string, string> = {
'Noto Sans JP':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
'Noto Sans SC':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
'Noto Sans TC':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
'Noto Sans KR':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
'Noto Naskh Arabic':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
'Noto Sans Devanagari':
'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
'Noto Sans Bengali':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
'Noto Sans Gujarati':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
'Noto Sans Kannada':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
'Noto Sans Malayalam':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
'Noto Sans Oriya':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
'Noto Sans Gurmukhi':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
'Noto Sans Tamil':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
'Noto Sans Telugu':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
'Noto Sans Sinhala':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
'Noto Sans Thai':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
'Noto Sans Khmer':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
'Noto Sans Lao':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
'Noto Sans Myanmar':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
'Noto Sans Hebrew':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
'Noto Sans Georgian':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
'Noto Sans Ethiopic':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
'Noto Serif Tibetan':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
'Noto Sans Cherokee':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
'Noto Sans Armenian':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
'Noto Sans Syriac':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
'Noto Sans':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
};
export function getFontUrlForFamily(fontFamily: string): string {
+142 -48
View File
@@ -12,6 +12,8 @@ import {
} from '../utils/render-utils';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { repairPdfFile } from './repair-pdf.js';
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -101,7 +103,11 @@ function showModal(
success: 'text-green-400',
};
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
modalIcon.replaceChildren();
const iconEl = document.createElement('i');
iconEl.dataset.lucide = iconMap[type];
iconEl.className = `w-12 h-12 ${colorMap[type]}`;
modalIcon.appendChild(iconEl);
modal.classList.remove('hidden');
createIcons({ icons });
}
@@ -133,20 +139,25 @@ async function withButtonLoading(
const button = document.getElementById(buttonId) as HTMLButtonElement;
if (!button) return;
const originalContent = button.innerHTML;
const originalChildren = Array.from(button.childNodes).map((n) =>
n.cloneNode(true)
);
const originalPointerEvents = button.style.pointerEvents;
try {
button.disabled = true;
button.style.pointerEvents = 'none';
button.innerHTML =
'<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
const spinner = document.createElement('i');
spinner.dataset.lucide = 'loader';
spinner.className = 'w-5 h-5 animate-spin text-white';
button.replaceChildren(spinner);
createIcons({ icons });
await action();
} finally {
button.disabled = false;
button.style.pointerEvents = originalPointerEvents;
button.innerHTML = originalContent;
button.replaceChildren(...originalChildren);
}
}
@@ -330,33 +341,122 @@ function initializeTool() {
}
});
const uploadArea = document.getElementById('upload-area');
if (uploadArea) {
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('border-indigo-500');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('border-indigo-500');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('border-indigo-500');
const files = Array.from(e.dataTransfer?.files || []).filter(
(f) => f.type === 'application/pdf'
);
if (files.length > 0) {
loadPdfs(files);
}
});
}
setupGlobalDragAndDrop();
document.getElementById('upload-area')?.classList.remove('hidden');
}
let dragOverlay: HTMLDivElement | null = null;
let isProcessingDrop = false;
function showDragOverlay() {
if (dragOverlay) return;
dragOverlay = document.createElement('div');
dragOverlay.className =
'fixed inset-0 bg-indigo-900/40 border-4 border-dashed border-indigo-400 z-40 pointer-events-none flex items-center justify-center';
const inner = document.createElement('div');
inner.className =
'text-white text-lg sm:text-xl font-semibold bg-gray-900/80 px-6 py-4 rounded-lg text-center';
inner.textContent = t('multiTool.dropToAdd');
dragOverlay.appendChild(inner);
document.body.appendChild(dragOverlay);
}
function hideDragOverlay() {
if (dragOverlay) {
dragOverlay.remove();
dragOverlay = null;
}
}
function setupGlobalDragAndDrop() {
const target = document.getElementById('main-scroll-container');
if (!target) return;
target.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
showDragOverlay();
});
document.addEventListener('dragleave', (e) => {
if (
e.clientX <= 0 ||
e.clientY <= 0 ||
e.clientX >= window.innerWidth ||
e.clientY >= window.innerHeight
) {
hideDragOverlay();
}
});
document.addEventListener('drop', () => {
hideDragOverlay();
});
target.addEventListener('drop', (e) => {
e.preventDefault();
hideDragOverlay();
const files = Array.from(e.dataTransfer?.files || []);
if (files.length === 0) return;
void handleIncomingFiles(files);
});
}
async function processIncomingFiles(rawFiles: File[]): Promise<File[]> {
const { pdfFiles, imageFiles, skipped } = partitionIncomingFiles(rawFiles);
const result: File[] = [...pdfFiles];
if (imageFiles.length > 0) {
try {
showLoading(0, 100);
const loadingText = document.getElementById('loading-text');
if (loadingText)
loadingText.textContent = t('multiTool.convertingImages');
const imagesPdfFile = await convertImagesToPdfFile(imageFiles);
result.push(imagesPdfFile);
} catch (e) {
console.error('Failed to convert images to PDF:', e);
hideLoading();
showModal(
t('multiTool.error'),
t('multiTool.failedToConvertImages'),
'error'
);
}
}
if (skipped.length > 0) {
console.warn('Skipped unsupported files:', skipped);
}
return result;
}
async function handleIncomingFiles(rawFiles: File[]) {
if (isRendering || isProcessingDrop) {
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info');
return;
}
isProcessingDrop = true;
try {
const processed = await processIncomingFiles(rawFiles);
if (processed.length > 0) {
await loadPdfs(processed);
} else {
hideLoading();
}
} finally {
isProcessingDrop = false;
}
}
function resetAll() {
renderCancelled = true;
isRendering = false;
isProcessingDrop = false;
hideDragOverlay();
snapshot();
allPages = [];
selectedPages.clear();
@@ -382,7 +482,7 @@ async function handlePdfUpload(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length > 0) {
await loadPdfs(files);
await handleIncomingFiles(files);
}
input.value = '';
}
@@ -524,20 +624,6 @@ async function loadPdfs(files: File[]) {
}
}
function getCacheKey(pdfIndex: number, pageIndex: number): string {
return `${pdfIndex}-${pageIndex}`;
}
// Wrapper for compatibility with updatePageDisplay
function createPageCard(pageData: PageData, index: number) {
const pagesContainer = document.getElementById('pages-container');
if (!pagesContainer) return;
const card = createPageElement(pageData.canvas, index);
pagesContainer.appendChild(card);
createIcons({ icons });
}
// Modified to return the element instead of appending it
function createPageElement(
canvas: HTMLCanvasElement | null,
@@ -584,10 +670,13 @@ function createPageElement(
const loading = document.createElement('div');
loading.className =
'flex flex-col items-center justify-center text-gray-400';
loading.innerHTML = `
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
<span class="text-xs">${t('common.loading')}</span>
`;
const loadingIcon = document.createElement('i');
loadingIcon.dataset.lucide = 'loader';
loadingIcon.className = 'w-8 h-8 animate-spin mb-2';
const loadingLabel = document.createElement('span');
loadingLabel.className = 'text-xs';
loadingLabel.textContent = t('common.loading');
loading.append(loadingIcon, loadingLabel);
preview.appendChild(loading);
preview.classList.add('bg-gray-700'); // Darker background for loading
}
@@ -611,9 +700,15 @@ function createPageElement(
const selectBtn = document.createElement('button');
selectBtn.className =
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
selectBtn.innerHTML = selectedPages.has(index)
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
const selectIcon = document.createElement('i');
if (selectedPages.has(index)) {
selectIcon.dataset.lucide = 'check-square';
selectIcon.className = 'w-4 h-4 text-indigo-400';
} else {
selectIcon.dataset.lucide = 'square';
selectIcon.className = 'w-4 h-4 text-gray-200';
}
selectBtn.appendChild(selectIcon);
selectBtn.onclick = (e) => {
e.stopPropagation();
toggleSelectOptimized(index);
@@ -1094,7 +1189,6 @@ async function downloadAll() {
async function downloadSplitPdfs() {
try {
const zip = new JSZip();
const sortedMarkers = Array.from(splitMarkers).sort((a, b) => a - b);
// Create segments based on split markers
const segments: number[][] = [];
+84
View File
@@ -0,0 +1,84 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { preprocessImageFile } from './image-input-utils.js';
export interface EmbeddableImage {
bytes: Uint8Array;
format: 'jpg' | 'png';
}
export async function normalizeImageToEmbeddable(
file: File
): Promise<EmbeddableImage> {
const processed = await preprocessImageFile(file);
const name = processed.name.toLowerCase();
const isJpg =
processed.type === 'image/jpeg' ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg');
const isPng = processed.type === 'image/png' || name.endsWith('.png');
if (isJpg) {
return {
bytes: new Uint8Array(await processed.arrayBuffer()),
format: 'jpg',
};
}
if (isPng) {
return {
bytes: new Uint8Array(await processed.arrayBuffer()),
format: 'png',
};
}
const url = URL.createObjectURL(processed);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () =>
reject(new Error(`Failed to decode image: ${processed.name}`));
i.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas context unavailable');
ctx.drawImage(img, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
'image/png'
);
});
return { bytes: new Uint8Array(await blob.arrayBuffer()), format: 'png' };
} finally {
URL.revokeObjectURL(url);
}
}
export async function convertImagesToPdfFile(images: File[]): Promise<File> {
const pdfDoc = await PDFLibDocument.create();
for (const img of images) {
const { bytes, format } = await normalizeImageToEmbeddable(img);
const embedded =
format === 'jpg'
? await pdfDoc.embedJpg(bytes)
: await pdfDoc.embedPng(bytes);
const page = pdfDoc.addPage([embedded.width, embedded.height]);
page.drawImage(embedded, {
x: 0,
y: 0,
width: embedded.width,
height: embedded.height,
});
}
const pdfBytes = await pdfDoc.save();
const filename =
images.length === 1
? images[0].name.replace(/\.[^.]+$/, '.pdf')
: `images-${Date.now()}.pdf`;
return new File([new Uint8Array(pdfBytes)], filename, {
type: 'application/pdf',
});
}
+25
View File
@@ -0,0 +1,25 @@
import { isValidImageFile } from './image-input-utils.js';
export interface PartitionedFiles {
pdfFiles: File[];
imageFiles: File[];
skipped: string[];
}
export function partitionIncomingFiles(rawFiles: File[]): PartitionedFiles {
const pdfFiles: File[] = [];
const imageFiles: File[] = [];
const skipped: string[] = [];
for (const f of rawFiles) {
if (f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')) {
pdfFiles.push(f);
} else if (isValidImageFile(f)) {
imageFiles.push(f);
} else {
skipped.push(f.name);
}
}
return { pdfFiles, imageFiles, skipped };
}
+3 -3
View File
@@ -383,18 +383,18 @@
class="text-gray-300 text-base sm:text-lg mb-1 sm:mb-2"
data-i18n="multiTool.uploadPdfFiles"
>
Select PDF Files
Select PDF or image files
</p>
<p
class="text-gray-400 text-xs sm:text-sm mb-3 sm:mb-4"
data-i18n="multiTool.dragAndDrop"
>
Drag and drop PDF files here, or click to select
Drag and drop PDF or image files here, or click to select
</p>
<input
type="file"
id="pdf-file-input"
accept="application/pdf"
accept="application/pdf,image/*,.heic,.heif,.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.webp,.svg,.psd,.jp2,.jpx,.jxr,.pnm,.pgm,.pbm,.ppm,.pam"
multiple
class="hidden"
/>
+1 -1
View File
@@ -6,7 +6,7 @@ import { resolveFontUrl } from '../js/utils/font-loader';
describe('font-loader', () => {
it('uses the default public font URL when no offline font base URL is configured', () => {
expect(resolveFontUrl('Noto Sans', {})).toBe(
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
);
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('heic2any', () => ({
default: vi.fn(async ({ blob }: { blob: Blob }) => {
const bytes = new Uint8Array(await blob.arrayBuffer());
return new Blob([bytes], { type: 'image/png' });
}),
}));
const mockEmbedJpg = vi.fn();
const mockEmbedPng = vi.fn();
const mockDrawImage = vi.fn();
const mockAddPage = vi.fn();
const mockSave = vi.fn();
vi.mock('pdf-lib', () => {
return {
PDFDocument: {
create: vi.fn(async () => ({
embedJpg: mockEmbedJpg,
embedPng: mockEmbedPng,
addPage: mockAddPage,
save: mockSave,
})),
},
};
});
import {
normalizeImageToEmbeddable,
convertImagesToPdfFile,
} from '../js/utils/images-to-pdf-lib';
function makeFile(name: string, type: string, bytes = [1, 2, 3]): File {
return new File([new Uint8Array(bytes)], name, { type });
}
describe('normalizeImageToEmbeddable', () => {
beforeEach(() => {
mockEmbedJpg.mockReset();
mockEmbedPng.mockReset();
mockDrawImage.mockReset();
mockAddPage.mockReset();
mockSave.mockReset();
});
it('passes through JPG bytes unchanged when MIME is image/jpeg', async () => {
const file = makeFile('photo.jpg', 'image/jpeg', [0xff, 0xd8, 0xff, 0xe0]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('jpg');
expect(Array.from(result.bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
});
it('passes through JPG by extension when MIME is missing', async () => {
const file = makeFile('photo.jpeg', '', [0xff, 0xd8]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('jpg');
});
it('passes through PNG bytes unchanged when MIME is image/png', async () => {
const file = makeFile('scan.png', 'image/png', [0x89, 0x50, 0x4e, 0x47]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
expect(Array.from(result.bytes)).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
it('passes through PNG by uppercase extension when MIME is missing', async () => {
const file = makeFile('SCAN.PNG', '', [0x89, 0x50]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
});
describe('canvas-decode path (non-JPG/PNG formats)', () => {
const originalCreateElement = document.createElement.bind(document);
const originalImage = globalThis.Image;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const revokeSpy = vi.fn();
beforeEach(() => {
revokeSpy.mockReset();
URL.createObjectURL = vi.fn(() => 'blob:test');
URL.revokeObjectURL = revokeSpy;
globalThis.Image = class {
naturalWidth = 100;
naturalHeight = 50;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
setTimeout(() => this.onload?.(), 0);
}
} as unknown as typeof Image;
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
toBlob: vi.fn((cb: (b: Blob) => void) => {
cb(new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' }));
}),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
});
afterEach(() => {
document.createElement = originalCreateElement;
globalThis.Image = originalImage;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('decodes a BMP through canvas and returns PNG bytes', async () => {
const file = makeFile('scan.bmp', 'image/bmp');
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
expect(Array.from(result.bytes)).toEqual([0x89, 0x50]);
});
it('decodes a TIFF through canvas', async () => {
const file = makeFile('page.tiff', 'image/tiff');
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
});
it('revokes the object URL after success', async () => {
const file = makeFile('scan.bmp', 'image/bmp');
await normalizeImageToEmbeddable(file);
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
it('revokes the object URL even on Image decode error', async () => {
globalThis.Image = class {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
setTimeout(() => this.onerror?.(), 0);
}
} as unknown as typeof Image;
const file = makeFile('broken.gif', 'image/gif');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Failed to decode image/
);
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
it('rejects when canvas getContext returns null', async () => {
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => null),
toBlob: vi.fn(),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
const file = makeFile('scan.bmp', 'image/bmp');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Canvas context unavailable/
);
expect(revokeSpy).toHaveBeenCalled();
});
it('rejects when canvas.toBlob returns null', async () => {
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
toBlob: vi.fn((cb: (b: Blob | null) => void) => cb(null)),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
const file = makeFile('scan.bmp', 'image/bmp');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Canvas toBlob failed/
);
expect(revokeSpy).toHaveBeenCalled();
});
});
});
describe('convertImagesToPdfFile', () => {
beforeEach(() => {
mockEmbedJpg.mockReset();
mockEmbedPng.mockReset();
mockDrawImage.mockReset();
mockAddPage.mockReset();
mockSave.mockReset();
mockEmbedJpg.mockResolvedValue({ width: 300, height: 200 });
mockEmbedPng.mockResolvedValue({ width: 600, height: 400 });
mockAddPage.mockReturnValue({ drawImage: mockDrawImage });
mockSave.mockResolvedValue(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
});
it('embeds a single JPG as one page and names file after image', async () => {
const file = makeFile('vacation.jpg', 'image/jpeg', [0xff, 0xd8]);
const result = await convertImagesToPdfFile([file]);
expect(mockEmbedJpg).toHaveBeenCalledTimes(1);
expect(mockEmbedPng).not.toHaveBeenCalled();
expect(mockAddPage).toHaveBeenCalledWith([300, 200]);
expect(mockDrawImage).toHaveBeenCalledWith(
expect.objectContaining({ width: 300, height: 200 }),
{ x: 0, y: 0, width: 300, height: 200 }
);
expect(result.name).toBe('vacation.pdf');
expect(result.type).toBe('application/pdf');
});
it('embeds a single PNG and names file after image', async () => {
const file = makeFile('scan.png', 'image/png', [0x89, 0x50]);
const result = await convertImagesToPdfFile([file]);
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
expect(mockEmbedJpg).not.toHaveBeenCalled();
expect(mockAddPage).toHaveBeenCalledWith([600, 400]);
expect(result.name).toBe('scan.pdf');
});
it('embeds multiple images and uses generic filename', async () => {
const files = [
makeFile('a.jpg', 'image/jpeg'),
makeFile('b.png', 'image/png'),
makeFile('c.jpg', 'image/jpeg'),
];
const result = await convertImagesToPdfFile(files);
expect(mockEmbedJpg).toHaveBeenCalledTimes(2);
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
expect(mockAddPage).toHaveBeenCalledTimes(3);
expect(result.name).toMatch(/^images-\d+\.pdf$/);
});
it('produces a PDF File with correct MIME type', async () => {
const file = makeFile('a.jpg', 'image/jpeg');
const result = await convertImagesToPdfFile([file]);
expect(result.type).toBe('application/pdf');
expect(result).toBeInstanceOf(File);
});
it('strips the original extension when generating single-file name', async () => {
const file = makeFile('photo.JPEG', 'image/jpeg');
const result = await convertImagesToPdfFile([file]);
expect(result.name).toBe('photo.pdf');
});
it('propagates errors from pdf-lib embed step', async () => {
mockEmbedJpg.mockRejectedValueOnce(new Error('embed failed'));
const file = makeFile('bad.jpg', 'image/jpeg');
await expect(convertImagesToPdfFile([file])).rejects.toThrow(
/embed failed/
);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('heic2any', () => ({
default: vi.fn(),
}));
import { partitionIncomingFiles } from '../js/utils/multi-tool-file-input';
function makeFile(name: string, type: string): File {
return new File([new Uint8Array([1, 2, 3])], name, { type });
}
describe('partitionIncomingFiles', () => {
it('routes PDFs by MIME type', () => {
const result = partitionIncomingFiles([
makeFile('a.pdf', 'application/pdf'),
makeFile('b.pdf', 'application/pdf'),
]);
expect(result.pdfFiles).toHaveLength(2);
expect(result.imageFiles).toHaveLength(0);
expect(result.skipped).toHaveLength(0);
});
it('routes PDFs by extension when MIME is missing', () => {
const result = partitionIncomingFiles([makeFile('doc.pdf', '')]);
expect(result.pdfFiles).toHaveLength(1);
expect(result.imageFiles).toHaveLength(0);
});
it('routes PDFs with uppercase extension', () => {
const result = partitionIncomingFiles([makeFile('SCAN.PDF', '')]);
expect(result.pdfFiles).toHaveLength(1);
});
it('routes images by MIME type', () => {
const result = partitionIncomingFiles([
makeFile('a.png', 'image/png'),
makeFile('b.jpg', 'image/jpeg'),
makeFile('c.webp', 'image/webp'),
]);
expect(result.imageFiles).toHaveLength(3);
expect(result.pdfFiles).toHaveLength(0);
});
it('routes images by extension when MIME is missing (HEIC, BMP, TIFF)', () => {
const result = partitionIncomingFiles([
makeFile('photo.heic', ''),
makeFile('scan.bmp', ''),
makeFile('page.tiff', ''),
]);
expect(result.imageFiles).toHaveLength(3);
expect(result.skipped).toHaveLength(0);
});
it('splits a mixed batch into PDFs, images, and skipped', () => {
const result = partitionIncomingFiles([
makeFile('doc.pdf', 'application/pdf'),
makeFile('photo.png', 'image/png'),
makeFile('readme.txt', 'text/plain'),
makeFile('archive.zip', 'application/zip'),
]);
expect(result.pdfFiles).toHaveLength(1);
expect(result.imageFiles).toHaveLength(1);
expect(result.skipped).toEqual(['readme.txt', 'archive.zip']);
});
it('preserves original File order within each bucket', () => {
const a = makeFile('a.pdf', 'application/pdf');
const b = makeFile('b.png', 'image/png');
const c = makeFile('c.pdf', 'application/pdf');
const d = makeFile('d.jpg', 'image/jpeg');
const result = partitionIncomingFiles([a, b, c, d]);
expect(result.pdfFiles).toEqual([a, c]);
expect(result.imageFiles).toEqual([b, d]);
});
it('returns empty buckets for empty input', () => {
const result = partitionIncomingFiles([]);
expect(result.pdfFiles).toHaveLength(0);
expect(result.imageFiles).toHaveLength(0);
expect(result.skipped).toHaveLength(0);
});
it('skips files with neither matching MIME nor extension', () => {
const result = partitionIncomingFiles([
makeFile('script.js', 'application/javascript'),
makeFile('data.csv', 'text/csv'),
]);
expect(result.skipped).toHaveLength(2);
expect(result.pdfFiles).toHaveLength(0);
expect(result.imageFiles).toHaveLength(0);
});
});