Add TIFF (CMYK/Multilayer) and PDF (RGB/CMYK) export formats

- tiff-writer.js: pure-JS multipage TIFF encoder with RGBA and CMYK
  color modes; RGB→CMYK conversion; one IFD per layer for multilayer
- pdf-writer.js: pure-JS PDF-1.4 writer; RGB pages use browser JPEG
  encoding (DCTDecode); CMYK pages embed raw DeviceCMYK pixel data;
  multipage support (one page per visible layer)
- save.js: adds TIFF (CMYK), TIFF (Multilayer), PDF (RGB), PDF (CMYK)
  to the export dialog; CMYK types bypass the separated-layers loop and
  composite with a white background before color conversion
This commit is contained in:
Claude
2026-06-10 19:26:14 +00:00
parent a187b8e0f2
commit 78341297a2
3 changed files with 539 additions and 13 deletions
+240
View File
@@ -0,0 +1,240 @@
/**
* Minimal pure-JS PDF writer for image export.
*
* Supports:
* - Single-page and multipage (one page per canvas)
* - RGB color space: images JPEG-encoded (browser-native, small files)
* - CMYK color space: raw DeviceCMYK bytes (print-ready, no alpha)
*
* PDF-1.4 structure used. No external dependencies.
*
* Usage:
* PdfWriter.fromCanvases(canvases, { colorMode: 'rgb'|'cmyk', quality: 0.9, dpi: 300 })
* .then(blob => FileSaver.saveAs(blob, 'file.pdf'));
*/
import { rgbToCmyk } from './tiff-writer.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Encode a JS string to a Uint8Array of bytes (Latin-1 safe). */
function strBytes(s) {
var a = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) a[i] = s.charCodeAt(i) & 0xff;
return a;
}
/** Concatenate multiple Uint8Arrays / ArrayBuffers into one Uint8Array. */
function concat(parts) {
var total = 0;
for (var i = 0; i < parts.length; i++)
total += parts[i].byteLength || parts[i].length;
var out = new Uint8Array(total), pos = 0;
for (var i = 0; i < parts.length; i++) {
var p = parts[i] instanceof ArrayBuffer ? new Uint8Array(parts[i]) : parts[i];
out.set(p, pos);
pos += p.length;
}
return out;
}
/** canvas → raw CMYK Uint8Array (W*H*4 bytes, alpha discarded). */
function canvasToCmykBytes(canvas) {
var idata = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
var out = new Uint8Array(canvas.width * canvas.height * 4);
for (var px = 0, i = 0, len = idata.length; px < len; px += 4, i += 4) {
var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]);
out[i] = cmyk[0];
out[i + 1] = cmyk[1];
out[i + 2] = cmyk[2];
out[i + 3] = cmyk[3];
}
return out;
}
/** canvas → JPEG Uint8Array via browser encoding. Returns a Promise. */
function canvasToJpegBytes(canvas, quality) {
return new Promise(function(resolve) {
canvas.toBlob(function(blob) {
blob.arrayBuffer().then(function(buf) {
resolve(new Uint8Array(buf));
});
}, 'image/jpeg', quality || 0.92);
});
}
// ---------------------------------------------------------------------------
// PDF object builder
// ---------------------------------------------------------------------------
/**
* Build a complete PDF byte stream for an array of pages.
*
* @param {Array<{width, height, colorSpace, imgBytes, filter}>} pages
* @param {number} dpi — used for MediaBox sizing (px → pt: pt = px * 72 / dpi)
* @returns {Uint8Array}
*/
function buildPDF(pages, dpi) {
dpi = dpi || 300;
var px2pt = 72 / dpi;
// Object registry: we'll collect byte-offset of each object for xref.
var objs = []; // each element is the raw bytes of "N 0 obj ... endobj\n"
var objNums = {}; // logical name → 1-based index
function addObj(name, content) {
var n = objs.length + 1;
if (name) objNums[name] = n;
var s = n + ' 0 obj\n' + content + '\nendobj\n';
objs.push(strBytes(s));
return n;
}
function addStreamObj(name, dict, dataBytes) {
var n = objs.length + 1;
if (name) objNums[name] = n;
var header = n + ' 0 obj\n' + dict + '\nstream\n';
var footer = '\nendstream\nendobj\n';
var combined = concat([strBytes(header), dataBytes, strBytes(footer)]);
objs.push(combined);
return n;
}
// 1. Catalog
addObj('catalog', '<< /Type /Catalog /Pages 2 0 R >>');
// 2. Pages (placeholder — children added later)
var pagesIdx = objs.length + 1;
addObj('pages', ''); // placeholder
// 3. Per-page objects
var pageObjNums = [];
for (var p = 0; p < pages.length; p++) {
var pg = pages[p];
var W_pt = (pg.width * px2pt).toFixed(3);
var H_pt = (pg.height * px2pt).toFixed(3);
var imgName = 'Im' + (p + 1);
var imgIdx = objs.length + 2; // will be added after content stream
// Content stream: scale and paint image
var contentStr = 'q ' + W_pt + ' 0 0 ' + H_pt + ' 0 0 cm /' + imgName + ' Do Q';
var contentNum = addStreamObj(null,
'<< /Length ' + contentStr.length + ' >>',
strBytes(contentStr));
// Image XObject
var samples = pg.colorSpace === 'DeviceCMYK' ? 4 : 3;
var imgDict = '<< /Type /XObject /Subtype /Image'
+ ' /Width ' + pg.width
+ ' /Height ' + pg.height
+ ' /ColorSpace /' + pg.colorSpace
+ ' /BitsPerComponent 8'
+ (pg.filter ? ' /Filter /' + pg.filter : '')
+ ' /Length ' + pg.imgBytes.length
+ ' >>';
var imgNum = addStreamObj(null, imgDict, pg.imgBytes);
// Page object
var pageNum = addObj(null,
'<< /Type /Page /Parent ' + pagesIdx + ' 0 R'
+ ' /MediaBox [0 0 ' + W_pt + ' ' + H_pt + ']'
+ ' /Contents ' + contentNum + ' 0 R'
+ ' /Resources << /XObject << /' + imgName + ' ' + imgNum + ' 0 R >> >>'
+ ' >>');
pageObjNums.push(pageNum);
}
// Fill in Pages object properly
var kidsStr = pageObjNums.map(function(n) { return n + ' 0 R'; }).join(' ');
var pagesContent = '<< /Type /Pages /Kids [' + kidsStr + '] /Count ' + pages.length + ' >>';
objs[pagesIdx - 1] = strBytes(pagesIdx + ' 0 obj\n' + pagesContent + '\nendobj\n');
// ---- Assemble file ----
var header = strBytes('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n'); // binary hint comment
var offsets = [];
var parts = [header];
var bytePos = header.length;
for (var i = 0; i < objs.length; i++) {
offsets.push(bytePos);
parts.push(objs[i]);
bytePos += objs[i].length;
}
// xref table
var xrefOffset = bytePos;
var xrefLines = 'xref\n0 ' + (objs.length + 1) + '\n';
xrefLines += '0000000000 65535 f \n';
for (var i = 0; i < offsets.length; i++) {
xrefLines += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
}
parts.push(strBytes(xrefLines));
// trailer
var trailerStr = 'trailer\n<< /Size ' + (objs.length + 1)
+ ' /Root 1 0 R >>\nstartxref\n' + xrefOffset + '\n%%EOF\n';
parts.push(strBytes(trailerStr));
return concat(parts);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
var PdfWriter = {
/**
* Export an array of canvases as a PDF.
*
* @param {HTMLCanvasElement|HTMLCanvasElement[]} canvases
* @param {object} [opts]
* @param {'rgb'|'cmyk'} [opts.colorMode='rgb']
* @param {number} [opts.quality=0.92] JPEG quality for RGB mode (01)
* @param {number} [opts.dpi=300] dots-per-inch for page sizing
* @returns {Promise<Blob>}
*/
fromCanvases: function(canvases, opts) {
if (!Array.isArray(canvases)) canvases = [canvases];
opts = opts || {};
var colorMode = opts.colorMode === 'cmyk' ? 'cmyk' : 'rgb';
var dpi = +(opts.dpi || 300) | 0;
var quality = opts.quality != null ? opts.quality : 0.92;
if (colorMode === 'cmyk') {
// Synchronous path: raw CMYK bytes
var pages = canvases.map(function(cv) {
return {
width: cv.width,
height: cv.height,
colorSpace: 'DeviceCMYK',
filter: null,
imgBytes: canvasToCmykBytes(cv),
};
});
var pdfBytes = buildPDF(pages, dpi);
return Promise.resolve(new Blob([pdfBytes], { type: 'application/pdf' }));
} else {
// Async path: JPEG-encode each canvas
var promises = canvases.map(function(cv) {
return canvasToJpegBytes(cv, quality).then(function(bytes) {
return {
width: cv.width,
height: cv.height,
colorSpace: 'DeviceRGB',
filter: 'DCTDecode',
imgBytes: bytes,
};
});
});
return Promise.all(promises).then(function(pages) {
var pdfBytes = buildPDF(pages, dpi);
return new Blob([pdfBytes], { type: 'application/pdf' });
});
}
},
};
export default PdfWriter;
+195
View File
@@ -0,0 +1,195 @@
/**
* TIFF writer with support for:
* - Single-page RGBA (32-bit, interleaved, with alpha)
* - Single-page CMYK (8-bit per channel, no alpha — print-ready)
* - Multipage variants of both (one IFD per canvas layer)
*
* TIFF spec references: TIFF 6.0, ISO 12234-2 (CMYK)
* PhotometricInterpretation 2 = RGB, 5 = CMYK
*/
// --- RGB → CMYK conversion -------------------------------------------
function rgbToCmyk(r, g, b) {
var rn = r / 255, gn = g / 255, bn = b / 255;
var k = 1 - Math.max(rn, gn, bn);
if (k >= 1) return [0, 0, 0, 255];
var d = 1 - k;
return [
Math.round((d - rn) / d * 255),
Math.round((d - gn) / d * 255),
Math.round((d - bn) / d * 255),
Math.round(k * 255),
];
}
// --- Low-level TIFF binary builder ------------------------------------
/**
* Build a multipage TIFF buffer from an array of canvases.
*
* @param {HTMLCanvasElement[]} canvases
* @param {'rgba'|'cmyk'} colorMode
* @param {object} [opts]
* @param {boolean} [opts.littleEndian=false]
* @param {number} [opts.dpi=300]
* @returns {ArrayBuffer}
*/
function buildTIFF(canvases, colorMode, opts) {
opts = opts || {};
var lsb = !!opts.littleEndian;
var dpi = +(opts.dpi || 300) | 0;
var isCMYK = colorMode === 'cmyk';
// IFD field counts differ: RGBA has ExtraSamples tag, CMYK does not.
var ENTRY_COUNT = isCMYK ? 14 : 15;
var IFD_SIZE = 2 + ENTRY_COUNT * 12 + 4; // count + entries + nextIFD ptr
var FIELDS_SIZE = 64; // BPS(8)+XRes(8)+YRes(8)+sw(20)+dt(20)
var PAGE_OH = IFD_SIZE + FIELDS_SIZE;
// Compute page start offsets inside the final buffer.
var offsets = [];
var total = 8; // TIFF header
for (var i = 0; i < canvases.length; i++) {
offsets.push(total);
total += PAGE_OH + canvases[i].width * canvases[i].height * 4;
}
var buf = new ArrayBuffer(total);
var view = new DataView(buf);
var u8 = new Uint8Array(buf);
var pos = 0;
function s16(v) { view.setUint16(pos, v, lsb); pos += 2; }
function s32(v) { view.setUint32(pos, v, lsb); pos += 4; }
function entry(tag, type, count, value) {
s16(tag); s16(type); s32(count);
// SHORT with count==1 gets packed into the value field with padding.
if (type === 3 && count === 1) { s16(value); s16(0); }
else { s32(value); }
}
// Date helpers
var d = new Date();
var p2 = function(n) { return n < 10 ? '0' + n : '' + n; };
var dtStr = d.getFullYear() + ':' + p2(d.getMonth() + 1) + ':' + p2(d.getDate())
+ ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds());
var swStr = 'tiff-writer 1.0\0\0\0\0\0'; // 20 chars (null-padded)
// ---- TIFF header ----
s16(lsb ? 0x4949 : 0x4d4d);
s16(42);
s32(8); // offset to first IFD
// ---- Per-page IFDs + image data ----
for (var p = 0; p < canvases.length; p++) {
var cv = canvases[p];
var W = cv.width, H = cv.height;
var pageBase = offsets[p];
var fBase = pageBase + IFD_SIZE; // start of fields section
var imgBase = fBase + FIELDS_SIZE; // start of image data
var nextIFD = p + 1 < canvases.length ? offsets[p + 1] : 0;
// IFD entry count
s16(ENTRY_COUNT);
entry(0x00fe, 4, 1, 0); // NewSubfileType
entry(0x0100, 4, 1, W); // ImageWidth
entry(0x0101, 4, 1, H); // ImageLength
entry(0x0102, 3, 4, fBase); // BitsPerSample (offset → 4 shorts)
entry(0x0103, 3, 1, 1); // Compression: none
entry(0x0106, 3, 1, isCMYK ? 5 : 2); // PhotometricInterp: 5=CMYK, 2=RGB
entry(0x0111, 4, 1, imgBase); // StripOffsets
entry(0x0115, 3, 1, 4); // SamplesPerPixel: 4
entry(0x0117, 4, 1, W * H * 4); // StripByteCounts
entry(0x011a, 5, 1, fBase + 8); // XResolution
entry(0x011b, 5, 1, fBase + 16); // YResolution
entry(0x0128, 3, 1, 2); // ResolutionUnit: inch
entry(0x0131, 2, 20, fBase + 24); // Software (20 bytes)
entry(0x0132, 2, 20, fBase + 44); // DateTime (20 bytes)
if (!isCMYK) {
entry(0x0152, 3, 1, 2); // ExtraSamples: assoc. alpha (RGBA only)
}
s32(nextIFD);
// ---- Fields section (64 bytes) ----
// BitsPerSample: 8,8,8,8 as four SHORTs (8 bytes)
s16(8); s16(8); s16(8); s16(8);
// XResolution RATIONAL (8 bytes)
s32(dpi); s32(1);
// YResolution RATIONAL (8 bytes)
s32(dpi); s32(1);
// Software string (20 bytes, null-padded)
for (var i = 0; i < 20; i++)
view.setUint8(pos++, swStr.charCodeAt(i) & 0xff);
// DateTime string (20 bytes, null-padded)
for (var i = 0; i < 20; i++)
view.setUint8(pos++, i < dtStr.length ? dtStr.charCodeAt(i) & 0xff : 0);
// ---- Image data ----
var idata = cv.getContext('2d').getImageData(0, 0, W, H).data;
if (isCMYK) {
// Convert RGBA → CMYK and write 4 bytes per pixel (alpha discarded)
for (var px = 0, len = idata.length; px < len; px += 4) {
var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]);
u8[pos++] = cmyk[0];
u8[pos++] = cmyk[1];
u8[pos++] = cmyk[2];
u8[pos++] = cmyk[3];
}
} else {
// Write RGBA directly
u8.set(idata, pos);
pos += idata.length;
}
}
return buf;
}
// --- Public API -------------------------------------------------------
var TiffWriter = {
/** Single-page 32-bit RGBA TIFF */
toRGBA: function(canvas, callback, opts) {
setTimeout(function() {
callback(buildTIFF([canvas], 'rgba', opts));
}, 9);
},
/** Single-page CMYK TIFF (print-ready, no alpha) */
toCMYK: function(canvas, callback, opts) {
setTimeout(function() {
callback(buildTIFF([canvas], 'cmyk', opts));
}, 9);
},
/** Multipage RGBA TIFF — one IFD per canvas in the array */
toMultipageRGBA: function(canvases, callback, opts) {
setTimeout(function() {
callback(buildTIFF(canvases, 'rgba', opts));
}, 9);
},
/** Multipage CMYK TIFF — one IFD per canvas in the array */
toMultipageCMYK: function(canvases, callback, opts) {
setTimeout(function() {
callback(buildTIFF(canvases, 'cmyk', opts));
}, 9);
},
/** Convenience: returns a Blob instead of ArrayBuffer */
toBlob: function(canvases, colorMode, callback, opts) {
if (!Array.isArray(canvases)) canvases = [canvases];
setTimeout(function() {
var buf = buildTIFF(canvases, colorMode, opts);
callback(new Blob([buf], { type: 'image/tiff' }));
}, 9);
},
};
export default TiffWriter;
export { rgbToCmyk };
+104 -13
View File
@@ -8,6 +8,8 @@ import canvasToBlob from './../../../../node_modules/blueimp-canvas-to-blob/js/c
import filesaver from './../../../../node_modules/file-saver/dist/FileSaver.min.js';
import GIF from './../../../../node_modules/gif.js.optimized/';
import CanvasToTIFF from './../../libs/canvastotiff.js';
import TiffWriter from './../../libs/tiff-writer.js';
import PdfWriter from './../../libs/pdf-writer.js';
import Tools_settings_class from "../tools/settings";
var instance = null;
@@ -42,7 +44,11 @@ class File_save_class {
WEBP: "Weppy File Format",
GIF: "Graphics Interchange Format",
BMP: "Windows Bitmap",
TIFF: "Tag Image File Format",
TIFF: "TIFF (RGBA)",
TIFF_CMYK: "TIFF (CMYK, print)",
TIFF_LAYERS: "TIFF (Multilayer)",
PDF: "PDF Document (RGB)",
PDF_CMYK: "PDF Document (CMYK, print)",
};
this.default_extension = 'PNG';
@@ -154,7 +160,12 @@ class File_save_class {
_this.save_dialog_onchange(true);
},
on_finish: function (params) {
if (params.layers == 'Separated' || params.layers == 'Separated (original types)') {
// These types handle their own layering internally — skip the separated loop.
var _type = params.type ? params.type.split(' ')[0] : '';
var _multilayerType = _type === 'TIFF_LAYERS' || _type === 'TIFF_CMYK'
|| _type === 'PDF' || _type === 'PDF_CMYK';
if (!_multilayerType && (params.layers == 'Separated' || params.layers == 'Separated (original types)')) {
var active_layer = config.layer.id;
var original_layer_type = params.layers;
@@ -277,7 +288,7 @@ class File_save_class {
else
document.getElementById('popup-tr-delay').style.display = 'none';
if (type == 'JSON' || type == 'GIF')
if (type == 'JSON' || type == 'GIF' || type == 'TIFF_LAYERS' || type == 'TIFF_CMYK' || type == 'PDF_CMYK')
document.getElementById('popup-tr-layers').style.display = 'none';
else
document.getElementById('popup-tr-layers').style.display = '';
@@ -414,12 +425,16 @@ class File_save_class {
}, data_header);
}
else if (type == 'TIFF') {
//tiff
var data_header = "image/tiff";
CanvasToTIFF.toBlob(canvas, function(blob) {
_this.update_file_size(blob.size);
}, data_header);
}, {});
}
else if (type == 'TIFF_CMYK' || type == 'TIFF_LAYERS') {
// size estimate: W*H*4 bytes of pixel data + small overhead
_this.update_file_size(config.WIDTH * config.HEIGHT * 4 + 512);
}
else if (type == 'PDF' || type == 'PDF_CMYK') {
_this.update_file_size('-');
}
else if (type == 'JSON') {
//json
@@ -493,8 +508,8 @@ class File_save_class {
}
}
if (type != 'JSON' && (type == 'JPG' || config.TRANSPARENCY == false)) {
//add white background
// CMYK and JPG need an opaque white background (no alpha channel in output)
if (type != 'JSON' && (type == 'JPG' || type == 'TIFF_CMYK' || config.TRANSPARENCY == false)) {
ctx.globalCompositeOperation = 'destination-over';
this.fillCanvasBackground(ctx, '#ffffff');
ctx.globalCompositeOperation = 'source-over';
@@ -568,14 +583,61 @@ class File_save_class {
}, data_header);
}
else if (type == 'TIFF') {
//tiff
//tiff - single page RGBA (existing behaviour)
if (this.Helper.strpos(fname, '.tiff') == false)
fname = fname + ".tiff";
var data_header = "image/tiff";
CanvasToTIFF.toBlob(canvas, function(blob) {
filesaver.saveAs(blob, fname);
}, data_header);
}, {});
}
else if (type == 'TIFF_CMYK') {
//tiff - single page CMYK, print-ready
if (this.Helper.strpos(fname, '.tiff') == false)
fname = fname + ".tiff";
var resolution = this.Tools_settings.get_setting('resolution') || 300;
TiffWriter.toCMYK(canvas, function(buf) {
filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname);
}, {dpi: resolution});
}
else if (type == 'TIFF_LAYERS') {
//tiff - multipage: one IFD per visible layer
if (this.Helper.strpos(fname, '.tiff') == false)
fname = fname + ".tiff";
var resolution = this.Tools_settings.get_setting('resolution') || 300;
var layerCanvases = this._collect_layer_canvases();
TiffWriter.toMultipageRGBA(layerCanvases, function(buf) {
filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname);
}, {dpi: resolution});
}
else if (type == 'PDF') {
//pdf - RGB, one page per visible layer
if (this.Helper.strpos(fname, '.pdf') == false)
fname = fname + ".pdf";
var resolution = this.Tools_settings.get_setting('resolution') || 300;
var quality_val = quality;
var pdfCanvases;
if (user_response.layers == 'Selected') {
pdfCanvases = [canvas];
} else {
pdfCanvases = this._collect_layer_canvases();
}
PdfWriter.fromCanvases(pdfCanvases, {colorMode: 'rgb', quality: quality_val, dpi: resolution})
.then(function(blob) { filesaver.saveAs(blob, fname); });
}
else if (type == 'PDF_CMYK') {
//pdf - CMYK, one page per visible layer, print-ready
if (this.Helper.strpos(fname, '.pdf') == false)
fname = fname + ".pdf";
var resolution = this.Tools_settings.get_setting('resolution') || 300;
var layerCanvases = this._collect_layer_canvases();
PdfWriter.fromCanvases(layerCanvases, {colorMode: 'cmyk', dpi: resolution})
.then(function(blob) { filesaver.saveAs(blob, fname); });
}
else if (type == 'JSON') {
//json - full data with layers
@@ -719,9 +781,38 @@ class File_save_class {
return JSON.stringify(export_data, null, "\t");
}
/**
* Returns one canvas per visible layer, each composited individually.
* Used for multilayer TIFF and multipage PDF export.
*/
_collect_layer_canvases() {
var canvases = [];
for (var i = 0; i < config.layers.length; i++) {
if (config.layers[i].visible == false) continue;
var c = document.createElement('canvas');
var cx = c.getContext('2d');
c.width = config.WIDTH;
c.height = config.HEIGHT;
this.disable_canvas_smooth(cx);
this.Base_layers.convert_layers_to_canvas(cx, config.layers[i].id, false);
canvases.push(c);
}
// Fall back to full composite if no layers found
if (canvases.length === 0) {
var c = document.createElement('canvas');
var cx = c.getContext('2d');
c.width = config.WIDTH;
c.height = config.HEIGHT;
this.disable_canvas_smooth(cx);
this.Base_layers.convert_layers_to_canvas(cx, null, false);
canvases.push(c);
}
return canvases;
}
/**
* removes smoothing, because it look ugly during zoom
*
*
* @param {ctx} ctx
*/
disable_canvas_smooth(ctx) {