diff --git a/public/locales/ar/common.json b/public/locales/ar/common.json index 8732c29..0b2872a 100644 --- a/public/locales/ar/common.json +++ b/public/locales/ar/common.json @@ -350,9 +350,12 @@ "download": "تنزيل:", "downloadSelected": "تنزيل المحدد", "exportPdf": "تصدير PDF", - "uploadPdfFiles": "اختر ملفات PDF", - "dragAndDrop": "اسحب وأفلت ملفات PDF هنا، أو انقر للاختيار", + "uploadPdfFiles": "اختر ملفات PDF أو صور", + "dragAndDrop": "اسحب وأفلت ملفات PDF أو الصور هنا، أو انقر للاختيار", "selectFiles": "اختر الملفات", + "dropToAdd": "أفلت ملفات PDF أو الصور للإضافة", + "convertingImages": "جارٍ تحويل الصور إلى PDF...", + "failedToConvertImages": "فشل تحويل الصور إلى PDF.", "renderingPages": "جارٍ عرض الصفحات...", "actions": { "duplicatePage": "تكرار هذه الصفحة", diff --git a/public/locales/be/common.json b/public/locales/be/common.json index e622435..4df0b65 100644 --- a/public/locales/be/common.json +++ b/public/locales/be/common.json @@ -350,9 +350,12 @@ "download": "Спампаваць:", "downloadSelected": "Спампаваць выбранае", "exportPdf": "Экспартаваць PDF", - "uploadPdfFiles": "Выбраць PDF файлы", - "dragAndDrop": "Перацягніце PDF файлы сюды або націсніце, каб выбраць", + "uploadPdfFiles": "Выбраць PDF або выявы", + "dragAndDrop": "Перацягніце PDF або выявы сюды або націсніце, каб выбраць", "selectFiles": "Выбраць файлы", + "dropToAdd": "Адпусціце PDF або выявы для дабаўлення", + "convertingImages": "Канвертацыя выяў у PDF...", + "failedToConvertImages": "Не ўдалося канвертаваць выявы ў PDF.", "renderingPages": "Апрацоўка старонак...", "actions": { "duplicatePage": "Дубляваць гэту старонку", diff --git a/public/locales/da/common.json b/public/locales/da/common.json index ade2e22..fd9cf46 100644 --- a/public/locales/da/common.json +++ b/public/locales/da/common.json @@ -350,9 +350,12 @@ "download": "Download:", "downloadSelected": "Download valgte", "exportPdf": "Eksportér PDF", - "uploadPdfFiles": "Vælg PDF-filer", - "dragAndDrop": "Træk og slip PDF-filer her, eller klik for at vælge", + "uploadPdfFiles": "Vælg PDF- eller billedfiler", + "dragAndDrop": "Træk og slip PDF- eller billedfiler her, eller klik for at vælge", "selectFiles": "Vælg filer", + "dropToAdd": "Slip PDF- eller billedfiler for at tilføje", + "convertingImages": "Konverterer billeder til PDF...", + "failedToConvertImages": "Kunne ikke konvertere billeder til PDF.", "renderingPages": "Renderer sider...", "actions": { "duplicatePage": "Duplikér denne side", diff --git a/public/locales/de/common.json b/public/locales/de/common.json index 836bd79..446893a 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -350,9 +350,12 @@ "download": "Download:", "downloadSelected": "Auswahl herunterladen", "exportPdf": "PDF exportieren", - "uploadPdfFiles": "PDF-Dateien auswählen", - "dragAndDrop": "PDF-Dateien hierhin ziehen oder klicken zum Auswählen", + "uploadPdfFiles": "PDF- oder Bilddateien auswählen", + "dragAndDrop": "PDF- oder Bilddateien hierhin ziehen oder klicken zum Auswählen", "selectFiles": "Dateien auswählen", + "dropToAdd": "PDF- oder Bilddateien zum Hinzufügen ablegen", + "convertingImages": "Bilder werden in PDF konvertiert...", + "failedToConvertImages": "Bilder konnten nicht in PDF konvertiert werden.", "renderingPages": "Seiten werden gerendert...", "actions": { "duplicatePage": "Diese Seite duplizieren", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index da9d6f1..17af04d 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -350,9 +350,12 @@ "download": "Download:", "downloadSelected": "Download Selected", "exportPdf": "Export PDF", - "uploadPdfFiles": "Select PDF Files", - "dragAndDrop": "Drag and drop PDF files here, or click to select", + "uploadPdfFiles": "Select PDF or image files", + "dragAndDrop": "Drag and drop PDF or image files here, or click to select", "selectFiles": "Select Files", + "dropToAdd": "Drop PDF or image files to add", + "convertingImages": "Converting images to PDF...", + "failedToConvertImages": "Failed to convert images to PDF.", "renderingPages": "Rendering pages...", "actions": { "duplicatePage": "Duplicate this page", diff --git a/public/locales/es/common.json b/public/locales/es/common.json index cff8cbd..3f0a6a5 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -350,9 +350,12 @@ "download": "Descargar:", "downloadSelected": "Descargar Seleccionados", "exportPdf": "Exportar PDF", - "uploadPdfFiles": "Seleccionar Archivos PDF", - "dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar", + "uploadPdfFiles": "Seleccionar archivos PDF o imágenes", + "dragAndDrop": "Arrastra y suelta archivos PDF o imágenes aquí, o haz clic para seleccionar", "selectFiles": "Seleccionar Archivos", + "dropToAdd": "Suelta archivos PDF o imágenes para añadir", + "convertingImages": "Convirtiendo imágenes a PDF...", + "failedToConvertImages": "No se pudieron convertir las imágenes a PDF.", "renderingPages": "Renderizando páginas...", "actions": { "duplicatePage": "Duplicar esta página", diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index 996c4fd..57dcb80 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -350,9 +350,12 @@ "download": "Téléchargement :", "downloadSelected": "Télécharger la sélection", "exportPdf": "Exporter en PDF", - "uploadPdfFiles": "Sélectionner des fichiers PDF", - "dragAndDrop": "Glissez-déposez vos fichiers PDF ici ou cliquez pour sélectionner", + "uploadPdfFiles": "Sélectionner des fichiers PDF ou des images", + "dragAndDrop": "Glissez-déposez vos fichiers PDF ou images ici, ou cliquez pour sélectionner", "selectFiles": "Sélectionner des fichiers", + "dropToAdd": "Déposez des fichiers PDF ou images à ajouter", + "convertingImages": "Conversion des images en PDF...", + "failedToConvertImages": "Échec de la conversion des images en PDF.", "renderingPages": "Rendu des pages...", "actions": { "duplicatePage": "Dupliquer cette page", diff --git a/public/locales/id/common.json b/public/locales/id/common.json index c51669b..bd52853 100644 --- a/public/locales/id/common.json +++ b/public/locales/id/common.json @@ -350,9 +350,12 @@ "download": "Unduh:", "downloadSelected": "Unduh yang Dipilih", "exportPdf": "Ekspor PDF", - "uploadPdfFiles": "Pilih File PDF", - "dragAndDrop": "Seret dan jatuhkan file PDF di sini, atau klik untuk memilih", + "uploadPdfFiles": "Pilih File PDF atau Gambar", + "dragAndDrop": "Seret dan jatuhkan file PDF atau gambar di sini, atau klik untuk memilih", "selectFiles": "Pilih File", + "dropToAdd": "Jatuhkan file PDF atau gambar untuk menambahkan", + "convertingImages": "Mengonversi gambar ke PDF...", + "failedToConvertImages": "Gagal mengonversi gambar ke PDF.", "renderingPages": "Merender halaman...", "actions": { "duplicatePage": "Duplikat halaman ini", diff --git a/public/locales/it/common.json b/public/locales/it/common.json index e3b6a42..dfe5b60 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -350,9 +350,12 @@ "download": "Scarica:", "downloadSelected": "Scarica selezionati", "exportPdf": "Esporta PDF", - "uploadPdfFiles": "Seleziona file PDF", - "dragAndDrop": "Trascina e rilascia i file PDF qui, oppure clicca per selezionare", + "uploadPdfFiles": "Seleziona file PDF o immagini", + "dragAndDrop": "Trascina e rilascia file PDF o immagini qui, oppure clicca per selezionare", "selectFiles": "Seleziona file", + "dropToAdd": "Rilascia file PDF o immagini per aggiungere", + "convertingImages": "Conversione delle immagini in PDF...", + "failedToConvertImages": "Impossibile convertire le immagini in PDF.", "renderingPages": "Generazione anteprime pagine...", "actions": { "duplicatePage": "Duplica questa pagina", diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json index 6832db6..ed3583b 100644 --- a/public/locales/ja/common.json +++ b/public/locales/ja/common.json @@ -350,9 +350,12 @@ "download": "ダウンロード:", "downloadSelected": "選択したものをダウンロード", "exportPdf": "PDFをエクスポート", - "uploadPdfFiles": "PDFファイルを選択", - "dragAndDrop": "ここにPDFファイルをドラッグ&ドロップするかクリックして選択します", + "uploadPdfFiles": "PDFまたは画像ファイルを選択", + "dragAndDrop": "ここにPDFまたは画像ファイルをドラッグ&ドロップするかクリックして選択します", "selectFiles": "ファイルの選択", + "dropToAdd": "PDFまたは画像ファイルをドロップして追加", + "convertingImages": "画像をPDFに変換中...", + "failedToConvertImages": "画像をPDFに変換できませんでした。", "renderingPages": "ページをレンダリング中...", "actions": { "duplicatePage": "このページを複製", diff --git a/public/locales/ko/common.json b/public/locales/ko/common.json index 24724f9..a0652a0 100644 --- a/public/locales/ko/common.json +++ b/public/locales/ko/common.json @@ -350,9 +350,12 @@ "download": "다운로드:", "downloadSelected": "선택 항목 다운로드", "exportPdf": "PDF 내보내기", - "uploadPdfFiles": "PDF 파일 선택", - "dragAndDrop": "PDF 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요", + "uploadPdfFiles": "PDF 또는 이미지 파일 선택", + "dragAndDrop": "PDF 또는 이미지 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요", "selectFiles": "파일 선택", + "dropToAdd": "PDF 또는 이미지 파일을 놓아 추가", + "convertingImages": "이미지를 PDF로 변환 중...", + "failedToConvertImages": "이미지를 PDF로 변환하지 못했습니다.", "renderingPages": "페이지 렌더링 중...", "actions": { "duplicatePage": "이 페이지 복제", diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json index 4661102..0cf2153 100644 --- a/public/locales/nl/common.json +++ b/public/locales/nl/common.json @@ -350,9 +350,12 @@ "download": "Laden:", "downloadSelected": "Selectie laden", "exportPdf": "PDF exporteren", - "uploadPdfFiles": "PDF-bestanden selecteren", - "dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen", + "uploadPdfFiles": "PDF- of afbeeldingsbestanden selecteren", + "dragAndDrop": "Klik om een bestand te selecteren, of sleep een PDF of afbeelding hierheen", "selectFiles": "Bestanden selecteren", + "dropToAdd": "Sleep PDF- of afbeeldingsbestanden om toe te voegen", + "convertingImages": "Afbeeldingen omzetten naar PDF...", + "failedToConvertImages": "Afbeeldingen omzetten naar PDF mislukt.", "renderingPages": "Pagina's verwerken…", "actions": { "duplicatePage": "Deze pagina dupliceren", diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json index b469bc1..eee7a88 100644 --- a/public/locales/pt/common.json +++ b/public/locales/pt/common.json @@ -350,9 +350,12 @@ "download": "Baixar:", "downloadSelected": "Baixar Selecionadas", "exportPdf": "Exportar PDF", - "uploadPdfFiles": "Selecionar Arquivos PDF", - "dragAndDrop": "Arraste arquivos PDF aqui ou clique para selecionar", + "uploadPdfFiles": "Selecionar Arquivos PDF ou Imagens", + "dragAndDrop": "Arraste arquivos PDF ou imagens aqui ou clique para selecionar", "selectFiles": "Selecionar Arquivos", + "dropToAdd": "Solte arquivos PDF ou imagens para adicionar", + "convertingImages": "Convertendo imagens em PDF...", + "failedToConvertImages": "Falha ao converter imagens em PDF.", "renderingPages": "Renderizando páginas...", "actions": { "duplicatePage": "Duplicar esta página", diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index beb23b4..fd32f7b 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -350,9 +350,12 @@ "download": "Скачать:", "downloadSelected": "Скачать выбранное", "exportPdf": "Экспортировать PDF", - "uploadPdfFiles": "Выбрать PDF-файлы", - "dragAndDrop": "Перетащите PDF-файлы сюда или нажмите, чтобы выбрать", + "uploadPdfFiles": "Выбрать PDF-файлы или изображения", + "dragAndDrop": "Перетащите PDF-файлы или изображения сюда или нажмите, чтобы выбрать", "selectFiles": "Выбрать файлы", + "dropToAdd": "Отпустите PDF-файлы или изображения, чтобы добавить", + "convertingImages": "Преобразование изображений в PDF...", + "failedToConvertImages": "Не удалось преобразовать изображения в PDF.", "renderingPages": "Отрисовка страниц...", "actions": { "duplicatePage": "Дублировать эту страницу", diff --git a/public/locales/sv/common.json b/public/locales/sv/common.json index e184c79..9a960b8 100644 --- a/public/locales/sv/common.json +++ b/public/locales/sv/common.json @@ -350,9 +350,12 @@ "download": "Ladda ner:", "downloadSelected": "Ladda ner markerade", "exportPdf": "Exportera PDF", - "uploadPdfFiles": "Välj PDF-filer", - "dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja", + "uploadPdfFiles": "Välj PDF- eller bildfiler", + "dragAndDrop": "Dra och släpp PDF- eller bildfiler här, eller klicka för att välja", "selectFiles": "Välj filer", + "dropToAdd": "Släpp PDF- eller bildfiler för att lägga till", + "convertingImages": "Konverterar bilder till PDF...", + "failedToConvertImages": "Kunde inte konvertera bilder till PDF.", "renderingPages": "Renderar sidor...", "actions": { "duplicatePage": "Duplicera denna sida", diff --git a/public/locales/tr/common.json b/public/locales/tr/common.json index e795284..9c9b4ff 100644 --- a/public/locales/tr/common.json +++ b/public/locales/tr/common.json @@ -350,9 +350,12 @@ "download": "İndir:", "downloadSelected": "Seçilenleri İndir", "exportPdf": "PDF Olarak Dışa Aktar", - "uploadPdfFiles": "PDF Dosyalarını Seçin", - "dragAndDrop": "PDF dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın", + "uploadPdfFiles": "PDF veya Görsel Dosyalarını Seçin", + "dragAndDrop": "PDF veya görsel dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın", "selectFiles": "Dosya Seç", + "dropToAdd": "Eklemek için PDF veya görsel dosyalarını bırakın", + "convertingImages": "Görseller PDF'e dönüştürülüyor...", + "failedToConvertImages": "Görseller PDF'e dönüştürülemedi.", "renderingPages": "Sayfalar oluşturuluyor...", "actions": { "duplicatePage": "Bu sayfayı çoğalt", diff --git a/public/locales/uk/common.json b/public/locales/uk/common.json index 173685d..12d3179 100644 --- a/public/locales/uk/common.json +++ b/public/locales/uk/common.json @@ -350,9 +350,12 @@ "download": "Завантажити:", "downloadSelected": "Завантажити вибране", "exportPdf": "Експортувати PDF", - "uploadPdfFiles": "Виберіть PDF-файли", - "dragAndDrop": "Перетягніть PDF або натисніть для вибору", + "uploadPdfFiles": "Виберіть PDF-файли або зображення", + "dragAndDrop": "Перетягніть PDF або зображення сюди, або натисніть для вибору", "selectFiles": "Вибрати файли", + "dropToAdd": "Відпустіть PDF або зображення для додавання", + "convertingImages": "Перетворення зображень на PDF...", + "failedToConvertImages": "Не вдалося перетворити зображення на PDF.", "renderingPages": "Рендеринг сторінок...", "actions": { "duplicatePage": "Дублювати сторінку", diff --git a/public/locales/vi/common.json b/public/locales/vi/common.json index 822439a..04ddc0d 100644 --- a/public/locales/vi/common.json +++ b/public/locales/vi/common.json @@ -350,9 +350,12 @@ "download": "Tải xuống:", "downloadSelected": "Tải xuống đã chọn", "exportPdf": "Xuất PDF", - "uploadPdfFiles": "Chọn tệp PDF", - "dragAndDrop": "Kéo và thả tệp PDF vào đây, hoặc nhấp để chọn", + "uploadPdfFiles": "Chọn tệp PDF hoặc hình ảnh", + "dragAndDrop": "Kéo và thả tệp PDF hoặc hình ảnh vào đây, hoặc nhấp để chọn", "selectFiles": "Chọn tệp", + "dropToAdd": "Thả tệp PDF hoặc hình ảnh để thêm", + "convertingImages": "Đang chuyển đổi hình ảnh sang PDF...", + "failedToConvertImages": "Không thể chuyển đổi hình ảnh sang PDF.", "renderingPages": "Đang kết xuất trang...", "actions": { "duplicatePage": "Nhân bản trang này", diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json index a564a50..4dbb13e 100644 --- a/public/locales/zh-TW/common.json +++ b/public/locales/zh-TW/common.json @@ -350,9 +350,12 @@ "download": "下載:", "downloadSelected": "下載選取的項目", "exportPdf": "匯出 PDF", - "uploadPdfFiles": "選擇 PDF 檔案", - "dragAndDrop": "拖放 PDF 檔案至此處,或是點擊以選取", + "uploadPdfFiles": "選擇 PDF 或圖片檔案", + "dragAndDrop": "拖放 PDF 或圖片檔案至此處,或是點擊以選取", "selectFiles": "選擇檔案", + "dropToAdd": "拖放 PDF 或圖片檔案以新增", + "convertingImages": "正在將圖片轉換為 PDF...", + "failedToConvertImages": "圖片轉換為 PDF 失敗。", "renderingPages": "渲染頁面...", "actions": { "duplicatePage": "複製此頁", diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index c8c5467..f8c225d 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -350,9 +350,12 @@ "download": "下载:", "downloadSelected": "下载选中", "exportPdf": "导出 PDF", - "uploadPdfFiles": "选择 PDF 文件", - "dragAndDrop": "将 PDF 文件拖放到此处,或点击选择", + "uploadPdfFiles": "选择 PDF 或图片文件", + "dragAndDrop": "将 PDF 或图片文件拖放到此处,或点击选择", "selectFiles": "选择文件", + "dropToAdd": "拖放 PDF 或图片文件以添加", + "convertingImages": "正在将图片转换为 PDF...", + "failedToConvertImages": "图片转换为 PDF 失败。", "renderingPages": "正在渲染页面...", "actions": { "duplicatePage": "复制此页", diff --git a/scripts/generate-security-headers.mjs b/scripts/generate-security-headers.mjs index 9ba93b3..f832956 100644 --- a/scripts/generate-security-headers.mjs +++ b/scripts/generate-security-headers.mjs @@ -27,6 +27,7 @@ const DEFAULT_WASM_ORIGINS = { }; const DEFAULT_CORS_PROXY_ORIGIN = 'https://bentopdf-cors-proxy.bentopdf.workers.dev'; +const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com'; const wasmOrigins = [ originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf, @@ -43,13 +44,15 @@ const tesseractOrigins = uniq([ const corsProxyOrigin = originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN; -const ocrFontOrigin = originOf(process.env.VITE_OCR_FONT_BASE_URL); +const ocrFontOrigin = + originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN; const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]); const connectOrigins = uniq([ ...wasmOrigins, ...tesseractOrigins, corsProxyOrigin, + ocrFontOrigin, ]); const fontOrigins = uniq([ocrFontOrigin].filter(Boolean)); diff --git a/security-headers-docs.conf b/security-headers-docs.conf index 758ff89..33f5b37 100644 --- a/security-headers-docs.conf +++ b/security-headers-docs.conf @@ -1,4 +1,4 @@ -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; diff --git a/src/js/config/font-mappings.ts b/src/js/config/font-mappings.ts index 29d9da1..a1af1bc 100644 --- a/src/js/config/font-mappings.ts +++ b/src/js/config/font-mappings.ts @@ -160,59 +160,59 @@ export const languageToFontFamily: Record = { export const fontFamilyToUrl: Record = { '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 { diff --git a/src/js/logic/pdf-multi-tool.ts b/src/js/logic/pdf-multi-tool.ts index 69abdb1..fd7e140 100644 --- a/src/js/logic/pdf-multi-tool.ts +++ b/src/js/logic/pdf-multi-tool.ts @@ -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 = ``; + 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 = - ''; + 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 { + 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 = ` - - ${t('common.loading')} - `; + 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) - ? '' - : ''; + 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[][] = []; diff --git a/src/js/utils/images-to-pdf-lib.ts b/src/js/utils/images-to-pdf-lib.ts new file mode 100644 index 0000000..10c9728 --- /dev/null +++ b/src/js/utils/images-to-pdf-lib.ts @@ -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 { + 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((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((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 { + 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', + }); +} diff --git a/src/js/utils/multi-tool-file-input.ts b/src/js/utils/multi-tool-file-input.ts new file mode 100644 index 0000000..48b4948 --- /dev/null +++ b/src/js/utils/multi-tool-file-input.ts @@ -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 }; +} diff --git a/src/pages/pdf-multi-tool.html b/src/pages/pdf-multi-tool.html index d7865f3..d5bde88 100644 --- a/src/pages/pdf-multi-tool.html +++ b/src/pages/pdf-multi-tool.html @@ -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

- Drag and drop PDF files here, or click to select + Drag and drop PDF or image files here, or click to select

diff --git a/src/tests/font-loader.test.ts b/src/tests/font-loader.test.ts index 3cfdf5e..585d6f2 100644 --- a/src/tests/font-loader.test.ts +++ b/src/tests/font-loader.test.ts @@ -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' ); }); diff --git a/src/tests/images-to-pdf-lib.test.ts b/src/tests/images-to-pdf-lib.test.ts new file mode 100644 index 0000000..f572e2b --- /dev/null +++ b/src/tests/images-to-pdf-lib.test.ts @@ -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/ + ); + }); +}); diff --git a/src/tests/multi-tool-file-input.test.ts b/src/tests/multi-tool-file-input.test.ts new file mode 100644 index 0000000..e68b986 --- /dev/null +++ b/src/tests/multi-tool-file-input.test.ts @@ -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); + }); +});