This article covers technical and architectural choices in modern web-based design tooling for applications requiring high fidelity, including print.

Geo/GIS applications, light-sensing applications, medical imaging, and print applications handle data that cannot be accurately represented by static, 2D RGB screens.
Within print, we have CMYK coloring that can fall outside of the sRGB gamut, spot colors, pantones, and specialty ink, including neon and UV reactive, foil masks, embossed and other surface-deforming alterations, and specular modifications including UV spot and other effects, like matte, semi-gloss and gloss finishes. These can be approximated for non-professional users.
Shown above is the accompanying demo application, which shows an exaggerated gold foil effect written in OpenGL’s graphic shader language being composited over a Fabric.JS instance with a custom interface.
Contents
Demo Coverage
Supplied with the demo are three novel libraries created to demonstrate the principles behind composite applications with end-user friendly wizards.
* Builder, an assembly for converting masked shapes and managing graphcs shaders),
* precision slider – a micro-ui element,
* Tiff-Writer, which exports high-fidelity TIFF files with embedded, editable multi-channel spot color channels.
Not included: Spot Registry, allowing mapping to 5+ color digital presses with formatted asset bundles with uncompressed 300 DPI resolution and embedded ICC profiles for reproduction.
System Architecture
This is an interactive graph. Click any cell for more information. The bulk of the technical content of this article can be navigated within this graph.
Shaders for gold and silver effects in GLSL
#version 300 es
precision mediump float;
// ================== UNIFORMS ====================
uniform sampler2D u_maskGold;
uniform sampler2D u_maskSilver;
uniform sampler2D u_maskHolo;
uniform sampler2D u_maskUV;
uniform float u_time;
uniform float u_foilIntensity;
uniform float u_foilSpecularity;
uniform float u_holoShimmerIntensity;
uniform float u_holoShimmerSpeed;
uniform float u_uvSpotStrength;
uniform vec3 u_uvSpotColor;
uniform float u_wrinkleScale;
uniform float u_wrinkleStrength;
uniform float u_wrinkleSpeed;
uniform float u_sparkleIntensity;
uniform float u_sparkleFreq;
uniform float u_sparkleSpeed;
in vec2 v_uv;
out vec4 fragColor;
// ================== HELPERS ====================
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i + vec2(0.0,0.0));
float b = hash(i + vec2(1.0,0.0));
float c = hash(i + vec2(0.0,1.0));
float d = hash(i + vec2(1.0,1.0));
vec2 u = f*f*(3.0 - 2.0*f);
return mix(a, b, u.x)
+ (c - a) * u.y * (1.0 - u.x)
+ (d - b) * u.x * u.y;
}
// Simplified “wrinkle” normal—no base image sampling
vec3 calcNormal(vec2 uv) {
float wr = noise(uv * u_wrinkleScale + u_time * u_wrinkleSpeed) - 0.5;
return normalize(vec3(0.0, 0.0, 1.0) + vec3(wr) * u_wrinkleStrength);
}
float fresnelSchlick(float cosTheta, float F0) {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
// ================== FOIL SHADE FUNCTIONS ====================
vec3 shadeGold(float maskAlpha, vec2 uv) {
vec3 goldColor = vec3(1.0, 0.82, 0.36);
vec3 N = calcNormal(uv);
vec3 L = normalize(vec3(-0.3, 0.5, 1.0));
vec3 V = vec3(0.0, 0.0, 1.0);
vec3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 8.0);
float reflect = fresnelSchlick(dot(N, V), u_foilSpecularity);
float sp = noise(uv * u_sparkleFreq + u_time * u_sparkleSpeed);
float glint = smoothstep(0.85, 0.98, sp);
vec3 sparkle = vec3(1.0) * glint * u_sparkleIntensity;
return (goldColor + spec * reflect + sparkle) * maskAlpha;
}
vec3 shadeSilver(float maskAlpha, vec2 uv) {
vec3 silverColor = vec3(0.8, 0.8, 0.85);
vec3 N = calcNormal(uv);
vec3 L = normalize(vec3(0.3, 0.6, 1.0));
vec3 V = vec3(0.0, 0.0, 1.0);
vec3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 12.0);
float reflect = fresnelSchlick(dot(N, V), u_foilSpecularity);
return (silverColor + spec * reflect) * maskAlpha;
}
vec3 shadeHolo(float maskAlpha, vec2 uv) {
float t = sin((uv.x + uv.y) * 10.0 + u_time * u_holoShimmerSpeed);
float shimmer = smoothstep(0.2, 0.8, t) * u_holoShimmerIntensity;
return vec3(1.0) * shimmer * maskAlpha;
}
vec3 shadeUV(float maskAlpha, vec2 uv) {
return u_uvSpotColor * u_uvSpotStrength * maskAlpha;
}
// ================== MAIN FUNCTION ====================
void main() {
// sample each mask’s alpha channel
float mG = texture(u_maskGold, v_uv).a;
float mS = texture(u_maskSilver, v_uv).a;
float mH = texture(u_maskHolo, v_uv).a;
float mU = texture(u_maskUV, v_uv).a;
// start from transparent black
vec3 outCol = vec3(0.0);
// accumulate foil colors
if (mG > 0.0) outCol += shadeGold( mG, v_uv) * u_foilIntensity;
if (mS > 0.0) outCol += shadeSilver(mS, v_uv) * u_foilIntensity;
if (mH > 0.0) outCol += shadeHolo( mH, v_uv);
if (mU > 0.0) outCol += shadeUV( mU, v_uv);
// final alpha is driven purely by the masks
float maskA = max(max(mG, mS), max(mH, mU));
fragColor = vec4(outCol, maskA);
}
Foil shaders need only 1-bit texture masks. The spot and specular masks require sampling the base canvas.
Builder is responsible for managing the shader pipeline.
// =======================================================
// CONFIG & HELPERS (in main.js)
// =======================================================
import { shaders } from './shaders.js';
const Builder = {};
window.Builder = Builder;
Builder.layers = {};
Builder.shaderConfig = {
// wrinkle and sparkle effects
u_wrinkleScale: 10.0, // noise frequency for micro-wrinkles
u_wrinkleStrength: 0.9, // how much noise bends normals
u_wrinkleSpeed: 0.5, // animation speed of subtle surface shifts
u_sparkleIntensity: 1.0, // brightness of sparkles
u_sparkleFreq: 120.0, // noise frequency for sparkle placement
u_sparkleSpeed: 1.0, // how fast glints flicker
// foil effects
u_foilIntensity: 0.3,
u_foilSpecularity: 2.0,
u_uvSpotStrength: 1.0,
u_uvSpotColor: [1,1,1],
u_holoShimmerIntensity: 0.8,
u_holoShimmerSpeed: 10.0
};
Builder.setConfig = cfg => Object.assign(Builder.shaderConfig, cfg);
// -------------------------------------------------------------------
// top-level constants & debug helper
// -------------------------------------------------------------------
const FOIL_CHANNELS = {
gold_foil: { uni: 'u_maskGold', unit: 1 },
silver_foil: { uni: 'u_maskSilver', unit: 2 },
holo_foil: { uni: 'u_maskHolo', unit: 3 },
uv_spot: { uni: 'u_maskUV', unit: 4 }
};
Builder.debug = false;
function dbg(...args) { if (Builder.debug) console.log(...args); }
Builder.shaders = shaders;
// -------------------------------------------------------------------
// Shader compile/link utilities
// -------------------------------------------------------------------
function compileShader(gl, type, src, name) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(`${name} compile failed:`, gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
console.log(`${name} compiled OK`);
return shader;
}
function createProgram(gl, vsSource, fsSource) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSource, "vertex_shader");
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource, "fragment_shader");
if (!vs || !fs) return null;
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program link failed:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
console.log("GL Shader program linked.");
return program;
}
function getLayerShaderProgram(gl, shaderType) {
if (!Builder.shaders[shaderType]) { console.warn(`Shader "${shaderType}" not found; falling back to "multi_foil".`); shaderType = 'multi_foil'; }
const { vertexSrc, fragmentSrc } = Builder.shaders[shaderType];
console.log(`Requesting ${shaderType} shader.`);
return createProgram(gl, vertexSrc, fragmentSrc);
}
function checkGLError(gl, op) {
const err = gl.getError();
if (err !== gl.NO_ERROR) {
console.error(`${op} → WebGL error 0x${err.toString(16)}`);
}
}
GLSL fails silently on many operations, so linking, collating and attaching the WebGL effects requires some instrumentation. Your implementation should be capable of managing multiple layers, retrieving and surfacing GL layer errors, managing the default configurations and uniforms used in the rendering of the effect.
Bitmasks, especially 1-bit bitmasks, given good performance and lend themselves well to tiling.
/**
* Uploads a source (canvas/image/video) into a given WebGL texture unit.
* Re-allocates storage if dimensions change, otherwise performs a sub-image update.
*
* @param {Canvas|Image|Video|OffscreenCanvas} src
* The source element whose pixels will be uploaded.
* @param {object|string|HTMLCanvasElement} layerRef
* The layer object (returned by initWebGLLayer),
* its ID string (glCanvas.id), or its gl-canvas element.
* @param {number} [unit=0]
* Texture unit index: 0→TEXTURE0, 1→TEXTURE1, …
* @param {string} [sampler='u_image']
* The sampler uniform key in layer.textures
* (e.g. 'u_image','u_maskGold','u_maskSilver',…).
*/
Builder.updateTextureUnit = function(src, layerRef, unit = 0, sampler = 'u_image') {
console.log(`Updating ${sampler} for ${unit}`);
// Resolve layer object
let layer;
if (typeof layerRef === 'string') {
layer = Builder.layers[layerRef];
} else if (layerRef instanceof HTMLCanvasElement) {
layer = Builder.layers[layerRef.id];
} else {
layer = layerRef;
}
if (!layer) { console.error('updateTextureUnit: missing layer', layerRef); return; }
const gl = layer.gl;
const texture = layer.textures[sampler];
if (!gl || !texture) { console.error('updateTextureUnit: invalid sampler', sampler); return; }
// Bind the texture to the chosen unit
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
// Determine if we must reallocate or do sub-image
const w = src.width, h = src.height;
const sizeKey = `__size_${sampler}`;
const last = layer[sizeKey] || { w: -1, h: -1 };
if (w !== last.w || h !== last.h) {
// full allocate
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0,
gl.RGBA, gl.UNSIGNED_BYTE, src
);
layer[sizeKey] = { w, h };
// if base image, update texelSize uniform immediately
if (sampler === 'u_image' && layer.uniforms.u_texelSize) {
gl.useProgram(layer.program);
gl.uniform2f(
layer.uniforms.u_texelSize,
1 / w, 1 / h
);
}
} else {
// fast sub-image update
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, w, h,
gl.RGBA, gl.UNSIGNED_BYTE, src
);
}
checkGLError(gl, `unit=${unit}, sampler=${sampler}`);
};
/**
* Starts the per-frame WebGL render loop.
* This loop updates time/config uniforms, handles resize,
* and draws a full-screen quad using the textures already
* bound by updateTextureUnit in your Fabric.js after:render.
*
* @param {object} layer
* The layer object returned by initWebGLLayer().
* Must have gl, program, attribs, vbo, uniforms,
* fabricCanvas, startTime, and lastW/lastH fields.
*/
Builder.startRenderLoop = function(layer) {
if (!layer || !layer.program) { throw new Error('startRenderLoop: invalid layer'); }
const { gl, program, vbo, attribs, uniforms, fabricCanvas, startTime } = layer;
// bind samplers once (units 0–4)
gl.useProgram(program);
gl.uniform1i(uniforms.u_image, 0);
gl.uniform1i(uniforms.u_maskGold, 1);
gl.uniform1i(uniforms.u_maskSilver, 2);
gl.uniform1i(uniforms.u_maskHolo, 3);
gl.uniform1i(uniforms.u_maskUV, 4);
function render(nowMs) {
gl.useProgram(program);
// time uniform
if (uniforms.u_time) {
const t = (nowMs - startTime) * 0.001;
gl.uniform1f(uniforms.u_time, t);
}
// shaderConfig uniforms
const C = Builder.shaderConfig;
Object.entries(C).forEach(([key, val]) => {
const loc = uniforms[key];
if (!loc) return;
if (Array.isArray(val) && val.length === 3) {
gl.uniform3fv(loc, val);
} else if (typeof val === 'number') {
gl.uniform1f(loc, val);
}
});
// handle canvas resize → u_texelSize
const w = fabricCanvas.getWidth();
const h = fabricCanvas.getHeight();
if (w !== layer.lastW || h !== layer.lastH) {
layer.lastW = w;
layer.lastH = h;
if (uniforms.u_texelSize) {
gl.uniform2f(uniforms.u_texelSize, 1 / w, 1 / h);
}
}
// draw full-screen quad
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.enableVertexAttribArray(attribs.a_position);
gl.vertexAttribPointer(attribs.a_position, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(attribs.a_uv);
gl.vertexAttribPointer(attribs.a_uv, 2, gl.FLOAT, false, 16, 8);
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
};
Builder.uploadTextureUnit provides our bitmasks from the base fabric.js layer. Our render loop is reasonably tight in this implementation for mobile performance reasons – a lot of the lifting is deferred to our init and upload functions, and this architecture allows us to throttle at the source of truth (changes initiated by a user through fabric.js).
Additionally, we eschew tiling, because the design characteristics of a small, sub 800×600 preview window offer diminishing returns in comparison to a fullscreen application.
The key performance innovation is driven by the choice to introduce the separate file assets pipeline outside of fabric.js. E.g, By handling those larger print-ready textures with a fully separate export line, our 800×600
UTIF and other javascript-based TIFF libraries are not up to the task of complex TIFF operations, largely as a function of historical support for TIFF in web browsers. One library proudly states that it will “fail silently” because “it will encourage people to stop using Tiff.”
Consequently, most pipelines use a mixture of PDFs and other assets. But this need not be the case. While Spot Registry is the true heavy lifter (not showcased in this demo) and can handle the complexity of linking and producing packages of files and masks for today’s pipelines, there are good reasons to revisit Tiff in a web-fronted context.
Few image formats other than TIFF offer the ability to extend data beyond three to four 0-255 values per point, which is problematic (consider grayscale heightmaps from satellite data or medical imaging). And in print, all our additional processes, masks, spots and raster-based data can be packaged into a completely reproducible, archival-quality single package: one file per job.
To take advantage of this, all we need to do is follow the Tiff spec as it already stands. For that, we’ll roll our own encoder and handle the IFD assembly and the binary data ourselves.
// ES Module for writing little-endian TIFFs with optional spot inks,
// transparency, and ICC profiles. Leaves files in RGB so your RIP can handle the conversion.
// want to check the exports?
// # apt install libtiff-tools
// tiffinfo -v export_spot\(\20\).tif
const LE = true; // little-endian flag for DataView
// TIFF tag codes
const Tags = {
ImageWidth: 256,
ImageLength: 257,
BitsPerSample: 258,
Compression: 259,
PhotometricInterpretation: 262,
PlanarConfiguration: 284,
SamplesPerPixel: 277,
RowsPerStrip: 278,
StripOffsets: 273,
StripByteCounts: 279,
XResolution: 282,
YResolution: 283,
ResolutionUnit: 296,
Software: 305,
InkNames: 333,
ExtraSamples: 338,
SampleFormat: 339,
ICCProfile: 34675,
// Photoshop private tags
AlphaChannelNames: 1006, // 0x3EE
DisplayInfo1: 1007, // 0x3EF
AlphaIdentifiers: 1053, // 0x41D
AlternateSpotColors: 1067, // 0x42B
DisplayInfo2: 1077 // 0x435
};
// Bytes per element for each TIFF field type
const TypeSize = {
1: 1, // BYTE
2: 1, // ASCII
3: 2, // SHORT
4: 4, // LONG
5: 8, // RATIONAL
7: 1, // UNDEFINED (ICC Profile & Photoshop blobs)
12: 8 // DOUBLE
};
// TIFF extra-sample codes (from the TIFF 6.0 spec)
// 0 = unspecified (use InkNames to interpret spot colors)
// 1 = associated alpha (premultiplied) – rarely used
// 2 = unassociated alpha (straight alpha)
const ExtraSample = {
Unspecified: 0,
AssociatedAlpha: 1,
UnassociatedAlpha: 2
};
/**
* Write a JS string as ASCII (plus trailing null) into a DataView.
*/
function writeASCII(view, offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
view.setUint8(offset + str.length, 0);
}
/**
* Encode raw RGB(A) + spot masks + ICC into a TIFF ArrayBuffer.
*
* opts: {
* width: number,
* height: number,
* baseImage: ArrayBuffer|TypedArray, // interleaved RGB or RGBA
* colorOrder: 'RGB'|'RGBA',
* spotMasks?: {
* names: string[], // e.g. ['Gold']
* buffers: ArrayBuffer[] // same length as names
* },
* iccProfile?: ArrayBuffer|Uint8Array, // optional ICC profile
* photoshop?: { // optional Photoshop tags
* alphaChannelNames?: string[],
* alphaChannelIdentifiers?: number[],
* alternateSpotColors?: number[][], // each [C,M,Y,K]
* displayInfo1?: ArrayBuffer|Uint8Array,
* displayInfo2?: ArrayBuffer|Uint8Array
* },
* xResolution?: number|[number,number],
* yResolution?: number|[number,number],
* resolutionUnit?: number
* }
*
* Returns a little-endian TIFF file as an ArrayBuffer.
*/
export function encode(opts) {
const {
width,
height,
baseImage,
colorOrder,
spotMasks,
iccProfile,
xResolution = 1,
yResolution = 1,
resolutionUnit = 2,
photoshop = {}
} = opts;
const [xResNum, xResDen] = Array.isArray(xResolution) ? xResolution : [xResolution, 1];
const [yResNum, yResDen] = Array.isArray(yResolution) ? yResolution : [yResolution, 1];
const hasAlpha = colorOrder === 'RGBA';
const spotCount = spotMasks?.buffers?.length || 0;
const channels = 3 + (hasAlpha ? 1 : 0) + spotCount;
// Normalize pixel data to Uint8Array
const buf = baseImage instanceof ArrayBuffer
? new Uint8Array(baseImage)
: new Uint8Array(baseImage.buffer || baseImage);
// Split into per-channel planes (R, G, B, [A], [spots...])
const planeSize = width * height;
const planes = [];
for (let c = 0; c < 3 + (hasAlpha ? 1 : 0); c++) {
planes.push(new Uint8Array(planeSize));
}
let idx = 0;
for (let i = 0; i < planeSize; i++) {
planes[0][i] = buf[idx++];
planes[1][i] = buf[idx++];
planes[2][i] = buf[idx++];
if (hasAlpha) {
planes[3][i] = buf[idx++];
}
}
// Spot-mask planes (one buffer per spot name)
if (spotCount) {
for (let s = 0; s < spotCount; s++) {
planes.push(new Uint8Array(spotMasks.buffers[s]));
}
}
// Interleave all planes into one big pixel buffer
const pixelBuf = new Uint8Array(planeSize * channels);
for (let i = 0; i < planeSize; i++) {
for (let c = 0; c < channels; c++) {
pixelBuf[i * channels + c] = planes[c][i];
}
}
// Build IFD entries
const entries = [];
function add(tagName, type, count, value) {
entries.push({ tag: Tags[tagName], type, count, value });
}
// Core TIFF tags
add('ImageWidth', 4, 1, [width]);
add('ImageLength', 4, 1, [height]);
add('BitsPerSample', 3, channels, new Array(channels).fill(8));
add('Compression', 3, 1, [1]);
add('SamplesPerPixel', 3, 1, [channels]);
add('RowsPerStrip', 4, 1, [height]);
add('StripByteCounts', 4, 1, [pixelBuf.byteLength]);
add('XResolution', 5, 1, [xResNum, xResDen]);
add('YResolution', 5, 1, [yResNum, yResDen]);
add('ResolutionUnit', 3, 1, [resolutionUnit]);
// Software tag now stores a plain string, not an array
add('Software', 2, 'TIFFWriter'.length + 1, 'TIFFWriter');
// Keep base as RGB
add('PhotometricInterpretation', 3, 1, [2]);
add('PlanarConfiguration', 3, 1, [1]);
// ExtraSamples and SampleFormat
if (hasAlpha || spotCount) {
const extraVals = [];
if (hasAlpha) {
extraVals.push(ExtraSample.UnassociatedAlpha);
}
for (let s = 0; s < spotCount; s++) {
extraVals.push(ExtraSample.Unspecified);
}
add('ExtraSamples', 3, extraVals.length, extraVals);
add('SampleFormat', 3, channels, new Array(channels).fill(1));
}
// InkNames – one null-separated string for R,G,B,[Alpha],[spot…]
if (spotCount > 0) {
const names = [];
names.push('R', 'G', 'B');
if (hasAlpha) { names.push('A'); }
if (spotCount) { names.push(...spotMasks.names); }
const inkNamesStr = names.join('\0') + '\0';
add('InkNames', 2, inkNamesStr.length, inkNamesStr);
}
//
// === Photoshop private tags ===
//
// AlphaChannelNames: null‐terminated names of each extra (spot) plane
if (photoshop.alphaChannelNames?.length) {
const acn = photoshop.alphaChannelNames.join('\0') + '\0';
add('AlphaChannelNames', 2, acn.length, acn);
}
// AlphaIdentifiers: unique short IDs for each extra plane
if (photoshop.alphaChannelIdentifiers?.length === spotCount) {
add(
'AlphaIdentifiers',
3,
photoshop.alphaChannelIdentifiers.length,
photoshop.alphaChannelIdentifiers
);
}
// AlternateSpotColors: fallback CMYK for each spot
if (photoshop.alternateSpotColors?.length === spotCount) {
const flat = photoshop.alternateSpotColors.flat();
add('AlternateSpotColors', 3, flat.length, flat);
}
// DisplayInfo1 & DisplayInfo2: opaque Photoshop “blob” hints
if (photoshop.displayInfo1) {
const blob = photoshop.displayInfo1 instanceof Uint8Array
? photoshop.displayInfo1
: new Uint8Array(photoshop.displayInfo1);
add('DisplayInfo1', 7, blob.length, [blob]);
}
if (photoshop.displayInfo2) {
const blob = photoshop.displayInfo2 instanceof Uint8Array
? photoshop.displayInfo2
: new Uint8Array(photoshop.displayInfo2);
add('DisplayInfo2', 7, blob.length, [blob]);
}
//
// === end Photoshop tags ===
//
// Optional ICC profile
if (iccProfile) {
const icc = iccProfile instanceof ArrayBuffer
? new Uint8Array(iccProfile)
: new Uint8Array(iccProfile.buffer || iccProfile);
add('ICCProfile', 7, icc.length, [icc]);
}
// Placeholder for StripOffsets
add('StripOffsets', 4, 1, [0]);
entries.sort((a, b) => a.tag - b.tag);
// Compute IFD size + overflow area
const ifdStart = 8;
const dirSize = 2 + entries.length * 12 + 4;
let dataOff = ifdStart + dirSize;
const overflow = [];
for (const e of entries) {
const len = TypeSize[e.type] * e.count;
if (len > 4) {
e.offsetValue = dataOff;
overflow.push(e);
dataOff += len + (len & 1);
}
}
// Patch StripOffsets → pixel data
const pixelOffset = dataOff;
const stripEnt = entries.find(e => e.tag === Tags.StripOffsets);
stripEnt.value = [pixelOffset];
stripEnt.offsetValue = pixelOffset;
dataOff += pixelBuf.byteLength;
// Allocate output buffer + DataView
const buffer = new ArrayBuffer(dataOff);
const view = new DataView(buffer);
// Write TIFF header
view.setUint8(0, 0x49);
view.setUint8(1, 0x49);
view.setUint16(2, 42, LE);
view.setUint32(4, ifdStart, LE);
// Write IFD entries
let p = ifdStart;
view.setUint16(p, entries.length, LE);
p += 2;
for (const e of entries) {
view.setUint16(p, e.tag, LE); p += 2;
view.setUint16(p, e.type, LE); p += 2;
view.setUint32(p, e.count, LE); p += 4;
const len = TypeSize[e.type] * e.count;
if (len > 4) {
view.setUint32(p, e.offsetValue, LE);
} else {
if (e.type === 3) {
for (let i = 0; i < e.count; i++) {
view.setUint16(p + 2 * i, e.value[i], LE);
}
}
if (e.type === 4) {
view.setUint32(p, e.value[0], LE);
}
}
p += 4;
}
// next IFD pointer = 0
view.setUint32(p, 0, LE);
// Write overflow blocks (InkNames, Software, ICC, ExtraSamples arrays, etc.)
for (const e of overflow) {
const off = e.offsetValue;
switch (e.type) {
case 2: // ASCII
const str = Array.isArray(e.value) ? e.value.join('') : e.value;
writeASCII(view, off, str);
break;
case 3: // SHORT
for (let i = 0; i < e.count; i++) {
view.setUint16(off + 2 * i, e.value[i], LE);
}
break;
case 5: // RATIONAL
for (let i = 0; i < e.count; i++) {
const num = e.value[2 * i];
const den = e.value[2 * i + 1];
view.setUint32(off + 8 * i, num, LE);
view.setUint32(off + 8 * i + 4, den, LE);
}
break;
case 7: // UNDEFINED (ICC profile or Photoshop blobs)
new Uint8Array(buffer, off, e.value[0].length).set(e.value[0]);
break;
}
}
// Write pixel data into the file
new Uint8Array(buffer, pixelOffset, pixelBuf.byteLength).set(pixelBuf);
return buffer;
}
Spot Registry (outside of the scope of this demo) is the internal tool for managing the links between the front-end editor’s foils, spots and other effects layers and the final output your pipeline expects in the file format you need (whether tiff or pdf), seamlessly packaging the job.
Our canvas is distinct from the fabric.js implementation for performance and stray effect iso
<canvas id="canvas" class="fabric-canvas" width="528" height="672"></canvas>
<canvas id="gl-canvas" class="gl-canvas" data-alpha="true" width="528" height="672"></canvas>
Builder.initWebGLLayer is responsible for setup and management of the GL Layer.
/**
* Initialize a WebGL2 layer over a Fabric.js canvas.
* Compiles & links the multi_foil shader, allocates one base
* texture (u_image) and four mask textures, and binds samplers
* to texture units 0–4.
*
* Foil channels (via FOIL_CHANNELS):
* gold_foil → u_maskGold @ unit 1
* silver_foil → u_maskSilver@ unit 2
* holo_foil → u_maskHolo @ unit 3
* uv_spot → u_maskUV @ unit 4
*
* @param {HTMLCanvasElement} glCanvasEl
* The WebGL <canvas> element.
* @param {HTMLCanvasElement} fabricCanvasEl
* The Fabric.js <canvas> element.
* @returns {object}
* The layer object, stored in Builder.layers by glCanvasEl.id.
*/
Builder.initWebGLLayer = function(glCanvasEl, fabricCanvasEl) {
// validate
if (!(glCanvasEl instanceof HTMLCanvasElement) || !(fabricCanvasEl instanceof HTMLCanvasElement)) {
console.error('initWebGLLayer: both args must be <canvas>');
return;
}
// create WebGL2 context
const wantAlpha = glCanvasEl.dataset.alpha === 'true';
const gl = glCanvasEl.getContext('webgl2', {
alpha: wantAlpha,
preserveDrawingBuffer: true,
premultipliedAlpha: false
});
if (!gl) { console.warn('initWebGLLayer: WebGL2 not supported'); return; }
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.clearColor(0, 0, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// pick shader
const requestedType = glCanvasEl.dataset.shaderType || 'multi_foil'; //this is not how we should be initializing the requested shader types.
const program = getLayerShaderProgram(gl, requestedType);
if (!program) { console.error('initWebGLLayer: failed to get shader program'); return; }
gl.useProgram(program);
// attributes
const attribs = {
a_position: gl.getAttribLocation(program, 'a_position'),
a_uv: gl.getAttribLocation(program, 'a_uv')
};
// uniforms
const uniformNames = [
'u_image', 'u_maskGold','u_maskSilver','u_maskHolo','u_maskUV',
'u_time','u_texelSize',
'u_wrinkleScale','u_wrinkleStrength','u_wrinkleSpeed',
'u_sparkleIntensity','u_sparkleFreq','u_sparkleSpeed',
'u_foilIntensity','u_foilSpecularity',
'u_holoShimmerIntensity','u_holoShimmerSpeed',
'u_uvSpotStrength','u_uvSpotColor'
];
const uniforms = {};
uniformNames.forEach(name => {
uniforms[name] = gl.getUniformLocation(program, name);
});
// full-screen quad VBO
const quadVerts = new Float32Array([
-1,-1, 0,0, 1,-1, 1,0, -1,1, 0,1,
1,1, 1,1, -1,1, 0,1, 1,-1, 1,0
]);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);
const FSIZE = Float32Array.BYTES_PER_ELEMENT * 4;
gl.enableVertexAttribArray(attribs.a_position);
gl.vertexAttribPointer(attribs.a_position, 2, gl.FLOAT, false, FSIZE, 0);
gl.enableVertexAttribArray(attribs.a_uv);
gl.vertexAttribPointer(attribs.a_uv, 2, gl.FLOAT, false, FSIZE, 2 * Float32Array.BYTES_PER_ELEMENT);
// helper: create & configure RGBA texture
function makeTex() {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return t;
}
// create textures
const texBase = makeTex();
const texMasks = {};
Object.values(FOIL_CHANNELS).forEach(({ uni }) => {
texMasks[uni] = makeTex();
});
// bind samplers& units & allocate empty masks
gl.useProgram(program);
gl.uniform1i(uniforms.u_image, 0);
Object.entries(FOIL_CHANNELS).forEach(([type, { uni, unit }]) => {
gl.uniform1i(uniforms[uni], unit);
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texMasks[uni]);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
fabricCanvasEl.width, fabricCanvasEl.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, null
);
});
// set static shaderConfig uniforms
Object.entries(Builder.shaderConfig).forEach(([key, val]) => {
const loc = uniforms[key];
if (!loc) return;
if (typeof val === 'number') gl.uniform1f(loc, val);
else if (Array.isArray(val) && val.length === 3) gl.uniform3fv(loc, val);
});
// assemble layer
const layer = {
glCanvas: glCanvasEl,
fabricCanvas: fabricCanvasEl,
gl, program, attribs, uniforms, vbo,
textures: { u_image: texBase, ...texMasks },
shaderType: requestedType,
foilChannels: FOIL_CHANNELS,
lastW: -1,
lastH: -1,
startTime: performance.now()
};
Builder.layers[glCanvasEl.id] = layer;
return layer;
};
The lowerCanvas is where our real SVG assets are pulled from. Our layer has to pay specific attention to lowerCanvas to drive change and bitmasks. We don’t want a fabric.js UI element from upperCanvas to trigger a texture reload or a mask movement, or worse, end up in the effects pipeline or the print export!
While the outside interface is built outside of fabric.js (the text tool and letter kerning and foil selectors), leveraging the built-in handles is one of the big reasons to use fabric.js. Here, the editting of content should feel familiar and easy.
Tiff (as a file format) stores extra samples at ExtraSamples (Tag 338). This is one of the areas we could choose to add our foil and spot effects in a tiff-only export.
We can embed an ICC profile at ICC (Tag 34675).
We’ve chosen to export in a photometric interpretation (e.g, RGB) in this demo. The type (RGB vs CMYK) is controlled at Tag 262. Other options include CMYK, YCbCr, CIELab, LogLuv, LogL, CFA.
All mask texture updates are driven on the after:render flag by fabric.js and only update if there is a change.
fCanvas.on('after:render', () => {
if (vsx.maskDirtyFlags) { vsx.updateMaskCanvases(); } // Rebuild all mask canvases
// Upload only the dirty masks into their configured texture units
const foilChannels = Builder.layers[glCanvas.id].foilChannels;
if (typeof vsx.maskCanvases !== undefined) {
Object.entries(vsx.maskCanvases).forEach(([key, maskCanvas]) => {
if (!vsx.maskDirtyFlags[key]) {
return;
}
const {unit, uni} = foilChannels[key];
Builder.updateTextureUnit(maskCanvas, glCanvas, unit, uni);
vsx.maskDirtyFlags[key] = false;
});
}
});
This is a rough sketch-up of an export pipeline running on the “magic numbers” when SpotRegistry isn’t in use.
You’ll want to handle all of your spots and treatments within a central registry rather than as config values, and SpotRegistry should run as an internal service, to ensure you’re outputting a job package that your pipeline understands.
import { encode as encodeTiff } from "./libs/tiff-writer/tiff-writer.js";
exportTIFF = async function() {
// Data flow:
// - Fabric lowerCanvasEl (#canvas): contains SVG/vector/text and raster assets; we re-render these at 300 dpi to preserve crispness.
// - Fabric overlay and WebGL preview canvases: visual-only layers, excluded from TIFF export.
// - For each foil type (gold_foil, silver_foil, holo_foil, uv_spot):
// • Clone and re-render those objects at 300 dpi into their own offscreen canvas.
// • Force‐white fill and stroke for pixel-perfect spot coverage.
// • Extract and threshold into a binary Uint8Array mask.
// compute scale from 96 ppi → 300 dpi
const lowerCanvas = document.getElementById("canvas");
const origW = lowerCanvas.width, origH = lowerCanvas.height;
const PPI = 96, DPI = 300;
const SCALE = DPI / PPI;
const w = Math.round(origW * SCALE), h = Math.round(origH * SCALE);
// foil types and mask arrays
const foilTypes = ["gold_foil", "silver_foil", "holo_foil", "uv_spot"];
const spotNames = [];
const spotMasks = [];
// Render base image at 300 dpi by re-drawing non-foil objects
const baseCanvas = document.createElement("canvas");
baseCanvas.width = w;
baseCanvas.height = h;
const bctx = baseCanvas.getContext("2d");
bctx.imageSmoothingEnabled = false;
const baseStatic = new fabric.StaticCanvas(baseCanvas, {
renderOnAddRemove: false,
backgroundColor: window.fCanvas.backgroundColor || null,
});
baseStatic.enableRetinaScaling = false;
baseStatic.setViewportTransform([SCALE, 0, 0, SCALE, 0, 0]);
// clone add every object that isn’t a foil spot
const baseObjs = window.fCanvas.getObjects().filter(o => !foilTypes.includes(o.foilType));
for (const obj of baseObjs) {
const clone = await obj.clone();
baseStatic.add(clone);
}
baseStatic.renderAll();
const rgbaBuffer = bctx.getImageData(0, 0, w, h).data.buffer;
// For each foilType, build a pure-white 300 dpi mask offscreen
for (const type of foilTypes) {
// grab original vector/raster objects for this spot
const objs = window.fCanvas.getObjects().filter(o => o.foilType === type);
if (objs.length === 0) continue;
// set up high-res offscreen canvas
const offCanvas = document.createElement("canvas");
offCanvas.width = w;
offCanvas.height = h;
const staticCanvas = new fabric.StaticCanvas(offCanvas, {
renderOnAddRemove: false,
backgroundColor: "black",
});
staticCanvas.enableRetinaScaling = false;
staticCanvas.setViewportTransform([SCALE, 0, 0, SCALE, 0, 0]);
const offCtx = offCanvas.getContext("2d");
offCtx.imageSmoothingEnabled = false;
// recursively force-white fill/stroke on sub-objects
function forceWhite(o) {
if (o.type === "group" && o._objects) {
o._objects.forEach(forceWhite);
}
o.set({
fill: "rgba(255,255,255,1)",
stroke: "rgba(255,255,255,1)",
strokeUniform: true,
objectCaching: false,
});
if (o.left != null) o.left = Math.round(o.left);
if (o.top != null) o.top = Math.round(o.top);
}
// clone + add each foil object at 300 dpi scale
for (const obj of objs) {
const cloned = await obj.clone();
forceWhite(cloned);
staticCanvas.add(cloned);
}
staticCanvas.renderAll();
// threshold to binary mask
const maskData = offCtx.getImageData(0, 0, w, h).data;
const binary = new Uint8Array(w * h);
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
binary[p] = maskData[i] > 128 ? 255 : 0;
}
spotNames.push(type);
spotMasks.push(binary.buffer);
}
// optional ICC profile
let iccProfile = null;
if (document.getElementById("embed-icc")?.checked) {
iccProfile = window.vsx.iccProfileBuffer || null;
}
// encode & download TIFF at 300 dpi
const opts = {
width: w,
height: h,
baseImage: rgbaBuffer,
colorOrder: "RGBA",
spotMasks: spotNames.length ? { names: spotNames, buffers: spotMasks } : undefined,
iccProfile: iccProfile,
xResolution: [DPI,1],
yResolution: [DPI,1],
resolutionUnit: 2 //inches
};
const tiffBuffer = encodeTiff(opts);
const blob = new Blob([tiffBuffer], { type: "image/tiff" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "export_spot.tif";
a.click();
To preserve fidelity and image color accuracy, all WebGL2 “effects” are built and then displayed outside of Fabric.JS, using Builder. This prevents stray effects intended only for visual preview (such as emboss or foil previews) from affecting the working assets.
The separation allows for the animations to be performance-optimized (important for mobile) with texture sampling occurring only during change events.
For real dimensions without distortion, the base SVG files are then sampled at print DPI using the magic constants explored in “Fundamentals of Sizing for the Web.”
Setting Up Fabric.js and WebGL Composition
We begin by initializing a standard Fabric.js canvas for layout and interaction. This canvas handles all user-driven edits, including text entry, font changes, kerning, and foil selection, while remaining agnostic to rendering effects.
Each object added to the canvas carries a custom foilType
property, which we use to route it into the correct mask layer later.
To render foil effects, we stack a second <canvas>
element directly above the Fabric canvas. This WebGL canvas is initialized via Builder.initWebGLLayer
(), which compiles the shaders, allocates textures, and binds samplers for each foil type. The two canvases are visually aligned but functionally independent: Fabric handles object state, while WebGL reads from offscreen masks to composite effects.
We hook into Fabric’s after:render
event to rebuild foil masks and upload them to WebGL only when needed. Each mask canvas is redrawn with only the objects matching its foilType
, rendered in solid white. These canvases are then uploaded to their respective texture units using Builder.updateTextureUnit
(), ensuring the shader receives fresh data without redundant GPU calls:
fCanvas.on('after:render', () => {
vsx.updateMaskCanvases(); // rebuild masks
Object.entries(vsx.maskCanvases).forEach(([type, maskCanvas]) => {
if (vsx.maskDirtyFlags[type]) {
const { unit, uni } = layer.foilChannels[type];
Builder.updateTextureUnit(maskCanvas, layer, unit, uni);
vsx.maskDirtyFlags[type] = false;
}
});
});
Builder: WebGL Layer Initialization and Management
The Builder
module encapsulates all WebGL setup and rendering logic. It’s responsible for compiling the fragment shader, creating the rendering context, and managing texture units for each foil channel. When Builder.initWebGLLayer
is called, it attaches a WebGL context to the foil canvas, sets up blending modes, and links uniform variables for each mask and foil texture.
Each foil type is assigned a dedicated texture unit and uniform. These are stored under layer
, which maps foilType
and spotName
to corresponding WebGL bindings. The shader samples from these textures and blends them using a custom compositing function that simulates foil reflectivity based on mask intensity and lighting angle.
Texture updates are handled by Builder.updateTextureUnit
(), which takes a mask canvas and uploads it to the GPU. This function ensures that only dirty textures are re-uploaded, minimizing performance overhead. It binds the texture, sets parameters, and transfers pixel data using texImage2D
.
updateTextureUnit(canvas, layer, unit, uniform) {
const gl = layer.gl;
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, layer.textures[unit]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.uniform1i(uniform, unit);
}
TiffWriter
The purpose of the TiffWriter
module is to explore alternative export strategies using an extensible image format. In this case, we’ve designed it to export compositions into a TIFF file with multiple ink channels, each represented as a separate sample plane. Such a format could allow print workflows to treat each mask as a distinct spot color, but for a real pipeline, SpotRegistry needs to be active to split the spot colors and assets.
Without photoshop in the pipeline, using tiff files is currently somewhat constrained as files created without correctly tagged IRB blocks have limited support, and programmatic or automated solutions are not always available, especially in browser (UTIF, TIFF, and many other libraries lack support).
Regardless, .tif is an excellent, well documented and widely used extensible image format, so in principle there’s no reason we can’t automate processing Tiff files into RIP-ready packages.
To bypass these limitations of current technology, we write the binary structure directly, ensuring full control over sample layout and ink metadata. TiffWriter
manually constructs the IFD (Image File Directory), assigning each foil mask to a unique sample index and embedding metadata using tags like InkSet
, InkNames
, and SampleFormat
.
const ifd = {
t277: 4, // SamplesPerPixel
t258: [1, 1, 1, 1], // SampleFormat: unsigned int
t339: [16, 16, 16, 16], // BitsPerSample
t334: 5, // InkSet: named inks
t336: "R\0G\0B\0A\0White\0\0", // InkNames
// Additional tags for tiling, compression, and resolution...
};
Demonstrations
See this visualization for a more complete coverage of functionality.
This demonstration linked above is for discussion purposes. It concerns the investigation of novel techniques within a realworld-like case study.
That code is packaged in a reader-friendly manner in native javascript, and is not minified or abstracted away, so the techniques can be clearly understood.