feat: add TIFF conversion options and integrate wasm-vips for image processing

- Updated README.md to include new dependencies: wasm-vips, pixelmatch, diff, and microdiff.
- Added wasm-vips to package.json and package-lock.json for advanced TIFF encoding.
- Enhanced localization files with new options for DPI, compression, color mode, and multi-page TIFF saving.
- Implemented UI changes in pdf-to-tiff.html to allow users to select DPI, compression type, color mode, and multi-page options.
- Refactored pdf-to-tiff-page.ts to utilize wasm-vips for TIFF encoding, replacing previous UTIF implementation.
- Introduced TiffOptions interface in pdf-to-tiff-type.ts for better type management.
- Updated Vite configuration to exclude wasm-vips from dependency optimization.
This commit is contained in:
alam00000
2026-03-24 13:20:50 +05:30
parent b732ee7925
commit 3ca19af354
26 changed files with 507 additions and 101 deletions

View File

@@ -9,9 +9,11 @@ import {
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import * as pdfjsLib from 'pdfjs-dist';
import UTIF from 'utif';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import type Vips from 'wasm-vips';
import wasmUrl from 'wasm-vips/vips.wasm?url';
import type { TiffOptions } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -19,6 +21,42 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
).toString();
let files: File[] = [];
let vipsInstance: typeof Vips | null = null;
async function getVips(): Promise<typeof Vips> {
if (vipsInstance) return vipsInstance;
const VipsInit = (await import('wasm-vips')).default;
vipsInstance = await VipsInit({
dynamicLibraries: [],
locateFile: (fileName: string) => {
if (fileName.endsWith('.wasm')) {
return wasmUrl;
}
return fileName;
},
});
return vipsInstance;
}
function getOptions(): TiffOptions {
const dpiInput = document.getElementById('tiff-dpi') as HTMLInputElement;
const compressionInput = document.getElementById(
'tiff-compression'
) as HTMLSelectElement;
const colorModeInput = document.getElementById(
'tiff-color-mode'
) as HTMLSelectElement;
const multiPageInput = document.getElementById(
'tiff-multipage'
) as HTMLInputElement;
return {
dpi: dpiInput ? parseInt(dpiInput.value, 10) : 300,
compression: compressionInput ? compressionInput.value : 'lzw',
colorMode: colorModeInput ? colorModeInput.value : 'rgb',
multiPage: multiPageInput ? multiPageInput.checked : false,
};
}
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
@@ -46,7 +84,7 @@ const updateUI = () => {
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)}${t('common.loadingPageCount')}`; // Initial state
metaSpan.textContent = `${formatBytes(file.size)}${t('common.loadingPageCount')}`;
infoContainer.append(nameSpan, metaSpan);
@@ -62,7 +100,6 @@ const updateUI = () => {
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
// Fetch page count asynchronously
readFileAsArrayBuffer(file)
.then((buffer) => {
return getPDFDocument(buffer).promise;
@@ -76,7 +113,6 @@ const updateUI = () => {
});
});
// Initialize icons immediately after synchronous render
createIcons({ icons });
} else {
optionsPanel.classList.add('hidden');
@@ -90,6 +126,82 @@ const resetState = () => {
updateUI();
};
async function renderPageToRgba(
page: PDFPageProxy,
dpi: number
): Promise<{ rgba: Uint8ClampedArray; width: number; height: number }> {
const scale = dpi / 72;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport,
canvas,
}).promise;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
return { rgba: imageData.data, width: canvas.width, height: canvas.height };
}
function encodePageToTiff(
vips: typeof Vips,
rgba: Uint8ClampedArray,
width: number,
height: number,
options: TiffOptions
): Uint8Array {
let image = vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
);
image = image.copy();
const pixelsPerMm = options.dpi / 25.4;
image.setDouble('xres', pixelsPerMm);
image.setDouble('yres', pixelsPerMm);
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
if (image.bands === 4) {
image = image.flatten({ background: [255, 255, 255] });
}
image = image.colourspace(vips.Interpretation.b_w);
} else {
if (image.bands === 4) {
image = image.flatten({ background: [255, 255, 255] });
}
}
const tiffOptions: Parameters<typeof image.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
};
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
const buffer = image.tiffsaveBuffer(tiffOptions);
image.delete();
return buffer;
}
async function convert() {
if (files.length === 0) {
showAlert(
@@ -98,26 +210,110 @@ async function convert() {
);
return;
}
showLoader(t('tools:pdfToTiff.loader.converting'));
showLoader(t('tools:pdfToTiff.loadingVips'));
let vips: typeof Vips;
try {
vips = await getVips();
} catch (e) {
console.error('Failed to load wasm-vips:', e);
hideLoader();
showAlert(
'Error',
'Failed to load the image processor. Please ensure your browser supports SharedArrayBuffer (requires HTTPS or localhost).'
);
return;
}
showLoader(t('tools:pdfToTiff.converting'));
try {
const options = getOptions();
const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0]))
.promise;
if (pdf.numPages === 1) {
if (options.multiPage && pdf.numPages > 1) {
const pages: Vips.Image[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const { rgba, width, height } = await renderPageToRgba(
page,
options.dpi
);
let img = vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
);
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
if (img.bands === 4) {
img = img.flatten({ background: [255, 255, 255] });
}
img = img.colourspace(vips.Interpretation.b_w);
} else {
if (img.bands === 4) {
img = img.flatten({ background: [255, 255, 255] });
}
}
pages.push(img);
}
const firstPage = pages[0];
let joined = firstPage;
if (pages.length > 1) {
joined = vips.Image.arrayjoin(pages, { across: 1 });
}
const tiffOptions: Parameters<typeof joined.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
page_height: firstPage.height,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
};
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
const buffer = joined.tiffsaveBuffer(tiffOptions);
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
joined.delete();
for (const p of pages) {
if (!p.isDeleted()) p.delete();
}
} else if (pdf.numPages === 1) {
const page = await pdf.getPage(1);
const blob = await renderPage(page, 1);
downloadFile(
blob.blobData,
getCleanPdfFilename(files[0].name) + '.' + blob.ending
);
const { rgba, width, height } = await renderPageToRgba(page, options.dpi);
const buffer = encodePageToTiff(vips, rgba, width, height, options);
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
} else {
const zip = new JSZip();
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const blob = await renderPage(page, i);
if (blob.blobData) {
zip.file(`page_${i}.` + blob.ending, blob.blobData);
}
const { rgba, width, height } = await renderPageToRgba(
page,
options.dpi
);
const buffer = encodePageToTiff(vips, rgba, width, height, options);
zip.file(`page_${i}.tiff`, new Uint8Array(buffer));
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
@@ -140,64 +336,19 @@ async function convert() {
}
}
async function renderPage(
page: PDFPageProxy,
pageNumber: number
): Promise<{ blobData: Blob | null; ending: string }> {
const viewport = page.getViewport({ scale: 2.0 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context!,
viewport: viewport,
canvas,
}).promise;
const imageData = context!.getImageData(0, 0, canvas.width, canvas.height);
const rgba = imageData.data;
try {
const tiffData = UTIF.encodeImage(
new Uint8Array(rgba),
canvas.width,
canvas.height
);
const tiffBlob = new Blob([tiffData], { type: 'image/tiff' });
return {
blobData: tiffBlob,
ending: 'tiff',
};
} catch (encodeError: any) {
console.warn(
`TIFF encoding failed for page ${pageNumber}, using PNG fallback:`,
encodeError
);
// Fallback to PNG if TIFF encoding fails (e.g., PackBits compression issues)
const pngBlob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/png')
);
if (pngBlob) {
return {
blobData: pngBlob,
ending: 'png',
};
}
}
return {
blobData: null,
ending: 'tiff',
};
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
const dpiSlider = document.getElementById('tiff-dpi') as HTMLInputElement;
const dpiValue = document.getElementById('tiff-dpi-value');
const compressionSelect = document.getElementById(
'tiff-compression'
) as HTMLSelectElement;
const colorModeSelect = document.getElementById(
'tiff-color-mode'
) as HTMLSelectElement;
if (backBtn) {
backBtn.addEventListener('click', () => {
@@ -205,6 +356,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (dpiSlider && dpiValue) {
dpiSlider.addEventListener('input', () => {
dpiValue.textContent = dpiSlider.value;
});
}
if (compressionSelect && colorModeSelect) {
compressionSelect.addEventListener('change', () => {
if (compressionSelect.value === 'ccittfax4') {
colorModeSelect.value = 'bw';
}
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(