You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg, lpi = 85, paperTintIntensity = 0.7, dotShape = 'round', inkSpread = 0.5, paperTintColor = '#f5f0e5') {
// --- Helper Functions ---
/**
* Converts an RGB color value to a newspaper-style CMYK value.
* This simplified model includes basic Gray Component Replacement (GCR)
* to reduce total ink usage and a dot gain simulation.
* @param {number} r The red channel (0-255).
* @param {number} g The green channel (0-255).
* @param {number} b The blue channel (0-255).
* @returns {object} An object with c, m, y, k values (0-1).
*/
const rgbToNewspaperCmyk = (r, g, b) => {
// Normalize RGB to 0-1
const r_ = r / 255;
const g_ = g / 255;
const b_ = b / 255;
// Convert RGB to CMY
let c = 1 - r_;
let m = 1 - g_;
let y = 1 - b_;
// Calculate K (black)
let k = Math.min(c, m, y);
// If it's pure white, all inks are 0
if (k === 1) {
return { c: 0, m: 0, y: 0, k: 0 };
}
// Apply Under Color Removal (UCR) / Gray Component Replacement (GCR)
// This makes blacks and grays use more K ink instead of a CMY mix.
const gcrAmount = 0.8;
const gray = Math.min(c, m, y);
c = (c - gray) + (gray * gcrAmount);
m = (m - gray) + (gray * gcrAmount);
y = (y - gray) + (gray * gcrAmount);
k = k - (gray * gcrAmount) + gray;
// Basic CMY calculation after removing black
c = (c - k) / (1 - k);
m = (m - k) / (1 - k);
y = (y - k) / (1 - k);
// Clamp values to handle potential floating point inaccuracies
c = Math.max(0, c);
m = Math.max(0, m);
y = Math.max(0, y);
k = Math.max(0, k);
// Simulate Dot Gain (Tonal Value Increase - TVI)
// A power function approximates the ink spread on absorbent paper.
// A gamma of ~2.2 approximates the standard 26% TVI at a 40% tone.
const gamma = 2.2;
c = Math.pow(c, 1 / gamma);
m = Math.pow(m, 1 / gamma);
y = Math.pow(y, 1 / gamma);
k = Math.pow(k, 1 / gamma);
return { c, m, y, k };
};
/**
* Returns a function that calculates a dot shape's intensity (0-1)
* based on the offset from the cell center.
* @param {string} shape The name of the dot shape ('round', 'diamond', 'line').
* @param {number} step The size of the halftone grid cell.
* @returns {function} A function that takes dx, dy and returns a threshold value.
*/
const getDotDrawer = (shape, step) => {
const halfStep = step / 2;
switch (shape) {
case 'diamond':
return (dx, dy) => {
const val = (Math.abs(dx) + Math.abs(dy)) / halfStep;
return 1 - Math.min(1, val);
};
case 'line':
return (dx, dy) => {
const val = Math.abs(dx) / halfStep; // Vertical lines
return 1 - Math.min(1, val);
};
case 'round':
default:
// Euclidean distance for a circular dot
return (dx, dy) => {
const dist = Math.sqrt(dx * dx + dy * dy);
const val = dist / halfStep;
return 1 - Math.min(1, val);
};
}
};
/**
* Blends a color with white to simulate tint intensity.
*/
const getPaperColor = (tintHex, intensity) => {
const tint = ((h) => {
let r=0,g=0,b=0;
if(h.length==4){r="0x"+h[1]+h[1];g="0x"+h[2]+h[2];b="0x"+h[3]+h[3];}
else if(h.length==7){r="0x"+h[1]+h[2];g="0x"+h[3]+h[4];b="0x"+h[5]+h[6];}
return {r:+r,g:+g,b:+b};
})(tintHex);
const r = Math.round(255 + (tint.r - 255) * intensity);
const g = Math.round(255 + (tint.g - 255) * intensity);
const b = Math.round(255 + (tint.b - 255) * intensity);
return `rgb(${r},${g},${b})`;
};
// --- Main Processing ---
// 1. Prepare source canvas and pixel data
const width = originalImg.width;
const height = originalImg.height;
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = width;
sourceCanvas.height = height;
const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true });
sourceCtx.drawImage(originalImg, 0, 0);
const imageData = sourceCtx.getImageData(0, 0, width, height);
const sourceData = imageData.data;
// 2. Convert all pixels from RGB to our target CMYK model
const cmykPixels = [];
for (let i = 0; i < sourceData.length; i += 4) {
cmykPixels.push(rgbToNewspaperCmyk(sourceData[i], sourceData[i + 1], sourceData[i + 2]));
}
// 3. Setup final canvas with paper color
const finalCanvas = document.createElement('canvas');
finalCanvas.width = width;
finalCanvas.height = height;
const finalCtx = finalCanvas.getContext('2d');
finalCtx.fillStyle = getPaperColor(paperTintColor, paperTintIntensity);
finalCtx.fillRect(0, 0, width, height);
// 4. Halftone generation for each CMYK channel
const channels = [
{ name: 'cyan', angle: 15, key: 'c', color: [0, 255, 255] },
{ name: 'magenta', angle: 75, key: 'm', color: [255, 0, 255] },
{ name: 'yellow', angle: 0, key: 'y', color: [255, 255, 0] },
{ name: 'black', angle: 135, key: 'k', color: [0, 0, 0] }
];
// This determines the size of the dots. Higher value = smaller dots for a given LPI.
const VIRTUAL_DPI = 300;
const step = VIRTUAL_DPI / lpi;
const dotDrawer = getDotDrawer(dotShape, step);
for (const channel of channels) {
await new Promise(resolve => setTimeout(() => {
const channelCanvas = document.createElement('canvas');
channelCanvas.width = width;
channelCanvas.height = height;
const channelCtx = channelCanvas.getContext('2d', { willReadFrequently: true });
// Initialize channel canvas to white for 'multiply' compositing
channelCtx.fillStyle = 'white';
channelCtx.fillRect(0, 0, width, height);
const channelImageData = channelCtx.getImageData(0, 0, width, height);
const channelData = channelImageData.data;
const angleRad = channel.angle * Math.PI / 180;
const cosA = Math.cos(angleRad);
const sinA = Math.sin(angleRad);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Rotate the coordinate system to align with the channel's screen angle
const xr = x * cosA + y * sinA;
const yr = y * cosA - x * sinA;
// Find the center of the grid cell this pixel belongs to
const cellX = Math.round(xr / step);
const cellY = Math.round(yr / step);
// Un-rotate the cell center to find the corresponding source pixel
const gx = Math.round(cellX * step * cosA - cellY * step * sinA);
const gy = Math.round(cellX * step * sinA + cellY * step * cosA);
if (gx >= 0 && gx < width && gy >= 0 && gy < height) {
const inkValue = cmykPixels[gy * width + gx][channel.key];
// Calculate the dot threshold for this pixel's position within the cell
const threshold = dotDrawer(xr - cellX * step, yr - cellY * step);
// If the ink value is greater than the dot threshold, print a dot
if (inkValue > threshold) {
const pixelIndex = (y * width + x) * 4;
channelData[pixelIndex] = channel.color[0];
channelData[pixelIndex + 1] = channel.color[1];
channelData[pixelIndex + 2] = channel.color[2];
channelData[pixelIndex + 3] = 255;
}
}
}
}
channelCtx.putImageData(channelImageData, 0, 0);
// Composite this channel onto the final canvas using 'multiply'
finalCtx.globalCompositeOperation = 'multiply';
finalCtx.drawImage(channelCanvas, 0, 0);
resolve();
}, 0));
}
// 5. Simulate Ink Spread/Blur
if (inkSpread > 0) {
finalCtx.globalCompositeOperation = 'source-over';
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(finalCanvas, 0, 0);
finalCtx.filter = `blur(${inkSpread}px)`;
finalCtx.clearRect(0, 0, width, height);
finalCtx.drawImage(tempCanvas, 0, 0);
finalCtx.filter = 'none';
}
// 6. Return the final canvas
return finalCanvas;
}
Apply Changes