diff --git a/src/js/logic/tiff-to-pdf-page.ts b/src/js/logic/tiff-to-pdf-page.ts index 436cfcd..fc8eb40 100644 --- a/src/js/logic/tiff-to-pdf-page.ts +++ b/src/js/logic/tiff-to-pdf-page.ts @@ -1,200 +1,239 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { decode } from 'tiff'; +import { tiffIfdToRgba } from '../utils/tiff-utils.js'; let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const processBtn = document.getElementById('process-btn'); - if (!fileDisplayArea || !fileControls || !processBtn) return; + const optionsPanel = document.getElementById('tiff-options'); - fileDisplayArea.innerHTML = ''; + if (!fileDisplayArea || !fileControls || !processBtn) return; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); + fileDisplayArea.innerHTML = ''; - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (files.length > 0) { + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + optionsPanel?.classList.remove('hidden'); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - infoContainer.append(nameSpan, sizeSpan); + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + infoContainer.append(nameSpan, sizeSpan); - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - } + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); + optionsPanel?.classList.add('hidden'); + } }; const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one TIFF file.'); - return; - } - showLoader('Converting TIFF to PDF...'); - try { - const pdfDoc = await PDFLibDocument.create(); - for (const file of files) { - const tiffBytes = await readFileAsArrayBuffer(file); - const ifds = decode(tiffBytes as ArrayBuffer); + if (files.length === 0) { + showAlert('No Files', 'Please select at least one TIFF file.'); + return; + } + const qualitySelect = document.getElementById( + 'tiff-pdf-quality' + ) as HTMLSelectElement; + const quality = qualitySelect?.value || 'medium'; + const jpegQualityMap: Record = { + high: 0.92, + medium: 0.75, + low: 0.5, + }; + const useJpeg = quality !== 'high'; + const jpegQuality = jpegQualityMap[quality] || 0.75; - for (const ifd of ifds) { - const width = ifd.width; - const height = ifd.height; - const rgba = ifd.data; + showLoader('Converting TIFF to PDF...'); + try { + const pdfDoc = await PDFLibDocument.create(); + for (const file of files) { + const tiffBytes = await readFileAsArrayBuffer(file); + const ifds = decode(tiffBytes as ArrayBuffer); - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) continue; + for (const ifd of ifds) { + const width = ifd.width; + const height = ifd.height; - const imageData = ctx.createImageData(width, height); - for (let i = 0; i < rgba.length; i++) { - imageData.data[i] = rgba[i]; - } - ctx.putImageData(imageData, 0, 0); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) continue; - const pngBlob = await new Promise((res) => - canvas.toBlob(res, 'image/png') - ); - if (!pngBlob) continue; - - const pngBytes = await pngBlob.arrayBuffer(); - const pngImage = await pdfDoc.embedPng(pngBytes); - const page = pdfDoc.addPage([pngImage.width, pngImage.height]); - page.drawImage(pngImage, { - x: 0, - y: 0, - width: pngImage.width, - height: pngImage.height, - }); - } - } - const pdfBytes = await pdfDoc.save(); - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - 'from_tiff.pdf' + const rgba = tiffIfdToRgba( + ifd.data, + width, + height, + ifd.samplesPerPixel || 1, + ifd.type ); - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); + const imageData = ctx.createImageData(width, height); + imageData.data.set(rgba); + ctx.putImageData(imageData, 0, 0); + + const blob = await new Promise((res) => + canvas.toBlob( + res, + useJpeg ? 'image/jpeg' : 'image/png', + useJpeg ? jpegQuality : undefined + ) + ); + if (!blob) continue; + + canvas.width = 0; + canvas.height = 0; + + const imgBytes = await blob.arrayBuffer(); + const image = useJpeg + ? await pdfDoc.embedJpg(imgBytes) + : await pdfDoc.embedPng(imgBytes); + const page = pdfDoc.addPage([image.width, image.height]); + page.drawImage(image, { + x: 0, + y: 0, + width: image.width, + height: image.height, }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert TIFF to PDF. One of the files may be invalid.' - ); - } finally { - hideLoader(); + } } + const pdfBytes = await pdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + 'from_tiff.pdf' + ); + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert TIFF to PDF. One of the files may be invalid.' + ); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => + file.type === 'image/tiff' || + file.name.toLowerCase().endsWith('.tiff') || + file.name.toLowerCase().endsWith('.tif') + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only TIFF files are allowed.' + ); } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => - file.type === 'image/tiff' || - file.name.toLowerCase().endsWith('.tiff') || - file.name.toLowerCase().endsWith('.tif') - ); - - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only TIFF files are allowed.'); - } - - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } - }; - - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); } + }; - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (processBtn) { - processBtn.addEventListener('click', convert); - } + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/utils/tiff-utils.ts b/src/js/utils/tiff-utils.ts new file mode 100644 index 0000000..83c8265 --- /dev/null +++ b/src/js/utils/tiff-utils.ts @@ -0,0 +1,45 @@ +type TiffDataArray = Uint8Array | Uint16Array | Float32Array | Float64Array; + +export function tiffIfdToRgba( + src: TiffDataArray, + width: number, + height: number, + channels: number, + photometricType: number +): Uint8ClampedArray { + const totalPixels = width * height; + const dst = new Uint8ClampedArray(totalPixels * 4); + const isWhiteIsZero = photometricType === 0; + + let maxVal = 255; + if (src instanceof Uint16Array) maxVal = 65535; + else if (src instanceof Float32Array || src instanceof Float64Array) + maxVal = 1; + + const norm = (v: number) => { + if (maxVal === 255) return v; + if (maxVal === 1) return Math.round(Math.min(1, Math.max(0, v)) * 255); + return v >> 8; + }; + + for (let p = 0; p < totalPixels; p++) { + const si = p * channels; + const di = p * 4; + + if (channels >= 3) { + dst[di] = norm(src[si]); + dst[di + 1] = norm(src[si + 1]); + dst[di + 2] = norm(src[si + 2]); + dst[di + 3] = channels >= 4 ? norm(src[si + 3]) : 255; + } else { + let gray = norm(src[si]); + if (isWhiteIsZero) gray = 255 - gray; + dst[di] = gray; + dst[di + 1] = gray; + dst[di + 2] = gray; + dst[di + 3] = channels === 2 ? norm(src[si + 1]) : 255; + } + } + + return dst; +} diff --git a/src/pages/tiff-to-pdf.html b/src/pages/tiff-to-pdf.html index 9c7f724..87d695d 100644 --- a/src/pages/tiff-to-pdf.html +++ b/src/pages/tiff-to-pdf.html @@ -179,6 +179,27 @@
+ + diff --git a/src/tests/tiff-to-pdf.test.ts b/src/tests/tiff-to-pdf.test.ts new file mode 100644 index 0000000..f5440d7 --- /dev/null +++ b/src/tests/tiff-to-pdf.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import { tiffIfdToRgba } from '../js/utils/tiff-utils'; + +describe('tiffIfdToRgba', () => { + describe('RGB (3 channels)', () => { + it('converts RGB data to RGBA with full opacity', () => { + const src = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]); + const result = tiffIfdToRgba(src, 3, 1, 3, 2); + expect(Array.from(result)).toEqual([ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, + ]); + }); + + it('handles a 2x2 RGB image', () => { + const src = new Uint8Array([ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, + ]); + const result = tiffIfdToRgba(src, 2, 2, 3, 2); + expect(result.length).toBe(16); + expect(result[0]).toBe(10); + expect(result[1]).toBe(20); + expect(result[2]).toBe(30); + expect(result[3]).toBe(255); + expect(result[4]).toBe(40); + expect(result[5]).toBe(50); + expect(result[6]).toBe(60); + expect(result[7]).toBe(255); + }); + }); + + describe('RGBA (4 channels)', () => { + it('copies all 4 channels directly', () => { + const src = new Uint8Array([255, 128, 64, 200, 0, 0, 0, 0]); + const result = tiffIfdToRgba(src, 2, 1, 4, 2); + expect(Array.from(result)).toEqual([255, 128, 64, 200, 0, 0, 0, 0]); + }); + + it('preserves alpha = 0 (fully transparent)', () => { + const src = new Uint8Array([100, 100, 100, 0]); + const result = tiffIfdToRgba(src, 1, 1, 4, 2); + expect(result[3]).toBe(0); + }); + + it('preserves alpha = 255 (fully opaque)', () => { + const src = new Uint8Array([100, 100, 100, 255]); + const result = tiffIfdToRgba(src, 1, 1, 4, 2); + expect(result[3]).toBe(255); + }); + }); + + describe('Grayscale (1 channel)', () => { + it('expands grayscale to RGB with full opacity', () => { + const src = new Uint8Array([128]); + const result = tiffIfdToRgba(src, 1, 1, 1, 1); + expect(Array.from(result)).toEqual([128, 128, 128, 255]); + }); + + it('handles black pixel', () => { + const src = new Uint8Array([0]); + const result = tiffIfdToRgba(src, 1, 1, 1, 1); + expect(Array.from(result)).toEqual([0, 0, 0, 255]); + }); + + it('handles white pixel', () => { + const src = new Uint8Array([255]); + const result = tiffIfdToRgba(src, 1, 1, 1, 1); + expect(Array.from(result)).toEqual([255, 255, 255, 255]); + }); + + it('handles multiple grayscale pixels', () => { + const src = new Uint8Array([0, 128, 255]); + const result = tiffIfdToRgba(src, 3, 1, 1, 1); + expect(result.length).toBe(12); + expect(result[0]).toBe(0); + expect(result[4]).toBe(128); + expect(result[8]).toBe(255); + expect(result[3]).toBe(255); + expect(result[7]).toBe(255); + expect(result[11]).toBe(255); + }); + }); + + describe('Grayscale + Alpha (2 channels)', () => { + it('expands grayscale and copies alpha', () => { + const src = new Uint8Array([200, 100]); + const result = tiffIfdToRgba(src, 1, 1, 2, 1); + expect(Array.from(result)).toEqual([200, 200, 200, 100]); + }); + + it('handles fully transparent grayscale', () => { + const src = new Uint8Array([255, 0]); + const result = tiffIfdToRgba(src, 1, 1, 2, 1); + expect(result[3]).toBe(0); + }); + }); + + describe('WhiteIsZero (photometric type 0)', () => { + it('inverts grayscale values', () => { + const src = new Uint8Array([0]); + const result = tiffIfdToRgba(src, 1, 1, 1, 0); + expect(Array.from(result)).toEqual([255, 255, 255, 255]); + }); + + it('inverts mid-gray', () => { + const src = new Uint8Array([200]); + const result = tiffIfdToRgba(src, 1, 1, 1, 0); + expect(result[0]).toBe(55); + expect(result[1]).toBe(55); + expect(result[2]).toBe(55); + }); + + it('inverts white to black', () => { + const src = new Uint8Array([255]); + const result = tiffIfdToRgba(src, 1, 1, 1, 0); + expect(Array.from(result)).toEqual([0, 0, 0, 255]); + }); + + it('does not invert alpha in grayscale+alpha', () => { + const src = new Uint8Array([0, 128]); + const result = tiffIfdToRgba(src, 1, 1, 2, 0); + expect(result[0]).toBe(255); + expect(result[3]).toBe(128); + }); + + it('does not affect RGB images', () => { + const src = new Uint8Array([100, 150, 200]); + const result = tiffIfdToRgba(src, 1, 1, 3, 0); + expect(result[0]).toBe(100); + expect(result[1]).toBe(150); + expect(result[2]).toBe(200); + }); + }); + + describe('16-bit images', () => { + it('normalizes 16-bit RGB to 8-bit', () => { + const src = new Uint16Array([65535, 0, 32768]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(128); + expect(result[3]).toBe(255); + }); + + it('normalizes 16-bit grayscale to 8-bit', () => { + const src = new Uint16Array([65535]); + const result = tiffIfdToRgba(src, 1, 1, 1, 1); + expect(result[0]).toBe(255); + expect(result[1]).toBe(255); + expect(result[2]).toBe(255); + }); + + it('handles 16-bit zero values', () => { + const src = new Uint16Array([0, 0, 0]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(0); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + }); + + it('normalizes 16-bit RGBA including alpha', () => { + const src = new Uint16Array([65535, 32768, 0, 49152]); + const result = tiffIfdToRgba(src, 1, 1, 4, 2); + expect(result[0]).toBe(255); + expect(result[1]).toBe(128); + expect(result[2]).toBe(0); + expect(result[3]).toBe(192); + }); + }); + + describe('Float32 images', () => { + it('normalizes float RGB (0.0-1.0) to 8-bit', () => { + const src = new Float32Array([1.0, 0.0, 0.5]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(128); + expect(result[3]).toBe(255); + }); + + it('clamps float values above 1.0', () => { + const src = new Float32Array([1.5, 0.0, 0.0]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(255); + }); + + it('clamps float values below 0.0', () => { + const src = new Float32Array([-0.5, 0.0, 0.0]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(0); + }); + + it('normalizes float grayscale', () => { + const src = new Float32Array([0.5]); + const result = tiffIfdToRgba(src, 1, 1, 1, 1); + expect(result[0]).toBe(128); + expect(result[1]).toBe(128); + expect(result[2]).toBe(128); + }); + }); + + describe('Float64 images', () => { + it('normalizes float64 RGB to 8-bit', () => { + const src = new Float64Array([1.0, 0.5, 0.0]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result[0]).toBe(255); + expect(result[1]).toBe(128); + expect(result[2]).toBe(0); + }); + }); + + describe('output buffer size', () => { + it('always outputs width * height * 4 bytes', () => { + const src1 = new Uint8Array([100, 200, 50]); + expect(tiffIfdToRgba(src1, 1, 1, 3, 2).length).toBe(4); + + const src2 = new Uint8Array([100]); + expect(tiffIfdToRgba(src2, 1, 1, 1, 1).length).toBe(4); + + const src3 = new Uint8Array(new Array(12).fill(128)); + expect(tiffIfdToRgba(src3, 2, 2, 3, 2).length).toBe(16); + }); + + it('returns Uint8ClampedArray', () => { + const src = new Uint8Array([0, 0, 0]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(result).toBeInstanceOf(Uint8ClampedArray); + }); + }); + + describe('edge cases', () => { + it('handles 1x1 pixel image', () => { + const src = new Uint8Array([42, 84, 126]); + const result = tiffIfdToRgba(src, 1, 1, 3, 2); + expect(Array.from(result)).toEqual([42, 84, 126, 255]); + }); + + it('handles 5+ channel images (uses first 4)', () => { + const src = new Uint8Array([10, 20, 30, 40, 99]); + const result = tiffIfdToRgba(src, 1, 1, 5, 2); + expect(result[0]).toBe(10); + expect(result[1]).toBe(20); + expect(result[2]).toBe(30); + expect(result[3]).toBe(40); + }); + }); +});