mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-07-08 08:50:44 +00:00
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:
@@ -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": "تكرار هذه الصفحة",
|
||||
|
||||
@@ -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": "Дубляваць гэту старонку",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "このページを複製",
|
||||
|
||||
@@ -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": "이 페이지 복제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дублировать эту страницу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дублювати сторінку",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "複製此頁",
|
||||
|
||||
@@ -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": "复制此页",
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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[][] = [];
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user