Files
autodoc/core/raster.js
Iisyourdad b7e64c79b4 Add imaging/document primitives: PNG codec, rasterizer, GIF, PDF
- Pure-JS PNG decode (grey/RGB/palette/alpha) + RGBA encode, CRC-checked;
  decode verified byte-identical with ImageMagick
- Software rasterizer: shapes, arrows, blur, highlight, magnify, tooltip,
  number badges, cursor, bitmap text (vendored public-domain font8x8),
  crop/resize, focused-view rendering
- GIF89a encoder with LZW (cross-validated pixel-for-pixel against
  ImageMagick decode) + NETSCAPE looping
- Minimal PDF 1.4 writer: pages, fonts, rects, images, outlines, valid
  xref; rendering validated under Ghostscript
- 12 imaging workflow tests (35 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:48:57 -05:00

413 lines
16 KiB
JavaScript

'use strict';
const { FONT8X8 } = require('./font8x8');
/**
* Software rasterizer for annotation rendering in exports. Operates on
* RGBA images ({ width, height, data: Buffer }). The same normalized
* annotation scene graph drawn by the editor canvas is burned into pixels
* here, so exports match the editor.
*
* Stroke widths are normalized to a 1000px-wide reference image and scaled,
* font sizes are fractions of image height — both resolution-independent.
*/
function createImage(width, height, color = [255, 255, 255, 255]) {
const data = Buffer.alloc(width * height * 4);
for (let p = 0; p < data.length; p += 4) {
data[p] = color[0]; data[p + 1] = color[1]; data[p + 2] = color[2]; data[p + 3] = color[3];
}
return { width, height, data };
}
function cloneImage(img) {
return { width: img.width, height: img.height, data: Buffer.from(img.data) };
}
function parseColor(str, fallback = [0, 0, 0, 255]) {
if (Array.isArray(str)) return str;
if (typeof str !== 'string' || str === 'transparent' || str === 'none' || str === '') return str === undefined ? fallback : [0, 0, 0, 0];
const m = /^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(str.trim());
if (!m) return fallback;
const v = parseInt(m[1], 16);
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, m[2] ? parseInt(m[2], 16) : 255];
}
function blendPixel(img, x, y, color) {
if (x < 0 || y < 0 || x >= img.width || y >= img.height) return;
const a = color[3] / 255;
if (a <= 0) return;
const p = (y * img.width + x) * 4;
const d = img.data;
if (a >= 1) {
d[p] = color[0]; d[p + 1] = color[1]; d[p + 2] = color[2]; d[p + 3] = 255;
return;
}
d[p] = Math.round(color[0] * a + d[p] * (1 - a));
d[p + 1] = Math.round(color[1] * a + d[p + 1] * (1 - a));
d[p + 2] = Math.round(color[2] * a + d[p + 2] * (1 - a));
d[p + 3] = Math.max(d[p + 3], Math.round(255 * a));
}
function fillRect(img, x, y, w, h, color) {
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
for (let yy = y0; yy < y1; yy++) {
for (let xx = x0; xx < x1; xx++) blendPixel(img, xx, yy, color);
}
}
function strokeRect(img, x, y, w, h, color, t) {
fillRect(img, x - t / 2, y - t / 2, w + t, t, color); // top
fillRect(img, x - t / 2, y + h - t / 2, w + t, t, color); // bottom
fillRect(img, x - t / 2, y + t / 2, t, h - t, color); // left
fillRect(img, x + w - t / 2, y + t / 2, t, h - t, color); // right
}
function ovalCoverage(cx, cy, rx, ry, px, py) {
const dx = (px - cx) / rx, dy = (py - cy) / ry;
return dx * dx + dy * dy;
}
function fillOval(img, x, y, w, h, color) {
const cx = x + w / 2, cy = y + h / 2, rx = Math.max(1, w / 2), ry = Math.max(1, h / 2);
const y0 = Math.max(0, Math.floor(y)), y1 = Math.min(img.height, Math.ceil(y + h));
const x0 = Math.max(0, Math.floor(x)), x1 = Math.min(img.width, Math.ceil(x + w));
for (let yy = y0; yy < y1; yy++) {
for (let xx = x0; xx < x1; xx++) {
if (ovalCoverage(cx, cy, rx, ry, xx + 0.5, yy + 0.5) <= 1) blendPixel(img, xx, yy, color);
}
}
}
function strokeOval(img, x, y, w, h, color, t) {
const cx = x + w / 2, cy = y + h / 2;
const rxO = Math.max(1, w / 2 + t / 2), ryO = Math.max(1, h / 2 + t / 2);
const rxI = Math.max(0.5, w / 2 - t / 2), ryI = Math.max(0.5, h / 2 - t / 2);
const y0 = Math.max(0, Math.floor(cy - ryO)), y1 = Math.min(img.height, Math.ceil(cy + ryO));
const x0 = Math.max(0, Math.floor(cx - rxO)), x1 = Math.min(img.width, Math.ceil(cx + rxO));
for (let yy = y0; yy < y1; yy++) {
for (let xx = x0; xx < x1; xx++) {
const px = xx + 0.5, py = yy + 0.5;
if (ovalCoverage(cx, cy, rxO, ryO, px, py) <= 1 && ovalCoverage(cx, cy, rxI, ryI, px, py) > 1) {
blendPixel(img, xx, yy, color);
}
}
}
}
function drawLine(img, x0, y0, x1, y1, color, t) {
const half = Math.max(0.5, t / 2);
const minX = Math.max(0, Math.floor(Math.min(x0, x1) - half - 1));
const maxX = Math.min(img.width, Math.ceil(Math.max(x0, x1) + half + 1));
const minY = Math.max(0, Math.floor(Math.min(y0, y1) - half - 1));
const maxY = Math.min(img.height, Math.ceil(Math.max(y0, y1) + half + 1));
const dx = x1 - x0, dy = y1 - y0;
const lenSq = dx * dx + dy * dy || 1;
for (let yy = minY; yy < maxY; yy++) {
for (let xx = minX; xx < maxX; xx++) {
const px = xx + 0.5, py = yy + 0.5;
let u = ((px - x0) * dx + (py - y0) * dy) / lenSq;
u = Math.max(0, Math.min(1, u));
const ex = x0 + u * dx - px, ey = y0 + u * dy - py;
if (ex * ex + ey * ey <= half * half) blendPixel(img, xx, yy, color);
}
}
}
/** Scanline polygon fill (even-odd). points: [[x,y],...] */
function fillPolygon(img, points, color) {
const ys = points.map((p) => p[1]);
const y0 = Math.max(0, Math.floor(Math.min(...ys)));
const y1 = Math.min(img.height, Math.ceil(Math.max(...ys)));
for (let yy = y0; yy < y1; yy++) {
const scanY = yy + 0.5;
const xs = [];
for (let i = 0; i < points.length; i++) {
const [ax, ay] = points[i];
const [bx, by] = points[(i + 1) % points.length];
if ((ay <= scanY && by > scanY) || (by <= scanY && ay > scanY)) {
xs.push(ax + ((scanY - ay) / (by - ay)) * (bx - ax));
}
}
xs.sort((a, b) => a - b);
for (let k = 0; k + 1 < xs.length; k += 2) {
const xa = Math.max(0, Math.round(xs[k]));
const xb = Math.min(img.width, Math.round(xs[k + 1]));
for (let xx = xa; xx < xb; xx++) blendPixel(img, xx, yy, color);
}
}
}
function drawArrow(img, x0, y0, x1, y1, color, t) {
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy) || 1;
const headLen = Math.min(len * 0.4, Math.max(10, t * 4));
const ux = dx / len, uy = dy / len;
const bx = x1 - ux * headLen, by = y1 - uy * headLen;
drawLine(img, x0, y0, bx, by, color, t);
const wing = headLen * 0.5;
fillPolygon(img, [
[x1, y1],
[bx - uy * wing, by + ux * wing],
[bx + uy * wing, by - ux * wing],
], color);
}
function boxBlur(img, x, y, w, h, radius) {
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
if (x1 <= x0 || y1 <= y0) return;
const r = Math.max(1, Math.round(radius));
// Two passes of box blur approximates gaussian well enough for redaction.
for (let pass = 0; pass < 2; pass++) {
const src = Buffer.from(img.data);
for (let yy = y0; yy < y1; yy++) {
for (let xx = x0; xx < x1; xx++) {
let rs = 0, gs = 0, bs = 0, n = 0;
for (let oy = -r; oy <= r; oy += Math.max(1, Math.floor(r / 3))) {
for (let ox = -r; ox <= r; ox += Math.max(1, Math.floor(r / 3))) {
const sx = Math.min(x1 - 1, Math.max(x0, xx + ox));
const sy = Math.min(y1 - 1, Math.max(y0, yy + oy));
const p = (sy * img.width + sx) * 4;
rs += src[p]; gs += src[p + 1]; bs += src[p + 2]; n++;
}
}
const p = (yy * img.width + xx) * 4;
img.data[p] = Math.round(rs / n);
img.data[p + 1] = Math.round(gs / n);
img.data[p + 2] = Math.round(bs / n);
}
}
}
}
/** Nearest-neighbour scaled copy of a region (used by magnify). */
function magnifyRegion(img, x, y, w, h, zoom, borderColor, t) {
const src = cloneImage(img);
const cx = x + w / 2, cy = y + h / 2;
const sw = w / zoom, sh = h / zoom;
const sx0 = cx - sw / 2, sy0 = cy - sh / 2;
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
for (let yy = y0; yy < y1; yy++) {
for (let xx = x0; xx < x1; xx++) {
// Only inside the oval lens
if (ovalCoverage(cx, cy, w / 2, h / 2, xx + 0.5, yy + 0.5) > 1) continue;
const sx = Math.min(img.width - 1, Math.max(0, Math.round(sx0 + ((xx - x) / w) * sw)));
const sy = Math.min(img.height - 1, Math.max(0, Math.round(sy0 + ((yy - y) / h) * sh)));
const sp = (sy * src.width + sx) * 4;
const dp = (yy * img.width + xx) * 4;
img.data[dp] = src.data[sp]; img.data[dp + 1] = src.data[sp + 1];
img.data[dp + 2] = src.data[sp + 2]; img.data[dp + 3] = 255;
}
}
strokeOval(img, x, y, w, h, borderColor, t);
}
// ---- text -----------------------------------------------------------------
function glyphFor(ch) {
const code = ch.codePointAt(0);
return FONT8X8[code >= 0 && code < 128 ? code : 63]; // '?' fallback
}
/** Width/height of text at a pixel size (8x8 glyphs, integer scaled). */
function measureText(text, sizePx) {
const scale = Math.max(1, Math.round(sizePx / 8));
const lines = String(text).split('\n');
const w = Math.max(...lines.map((l) => l.length)) * 8 * scale;
return { width: w, height: lines.length * 10 * scale, scale, lineHeight: 10 * scale };
}
function drawText(img, x, y, text, sizePx, color) {
const { scale, lineHeight } = measureText(text, sizePx);
const lines = String(text).split('\n');
let ty = Math.round(y);
for (const line of lines) {
let tx = Math.round(x);
for (const ch of line) {
const glyph = glyphFor(ch);
for (let gy = 0; gy < 8; gy++) {
const row = glyph[gy];
for (let gx = 0; gx < 8; gx++) {
if (!(row & (1 << gx))) continue;
fillRect(img, tx + gx * scale, ty + gy * scale, scale, scale, color);
}
}
tx += 8 * scale;
}
ty += lineHeight;
}
}
function drawTextCentered(img, cx, cy, text, sizePx, color) {
const m = measureText(text, sizePx);
drawText(img, cx - m.width / 2, cy - m.height / 2 + m.scale, text, sizePx, color);
}
function drawCursorIcon(img, x, y, sizePx, color) {
const s = sizePx;
fillPolygon(img, [
[x, y], [x, y + s], [x + s * 0.28, y + s * 0.75],
[x + s * 0.45, y + s * 1.05], [x + s * 0.58, y + s * 0.98],
[x + s * 0.42, y + s * 0.68], [x + s * 0.72, y + s * 0.68],
], [255, 255, 255, 255]);
// dark outline by drawing slightly smaller inner arrow
fillPolygon(img, [
[x + 2, y + 4], [x + 2, y + s - 3], [x + s * 0.26, y + s * 0.7],
[x + s * 0.42, y + s * 0.98], [x + s * 0.52, y + s * 0.93],
[x + s * 0.37, y + s * 0.63], [x + s * 0.62, y + s * 0.63],
], color);
}
// ---- composition ----------------------------------------------------------
function crop(img, x, y, w, h) {
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const cw = Math.min(img.width - x0, Math.round(w)), ch = Math.min(img.height - y0, Math.round(h));
if (cw <= 0 || ch <= 0) throw new Error('crop: empty region');
const out = createImage(cw, ch);
for (let yy = 0; yy < ch; yy++) {
img.data.copy(out.data, yy * cw * 4, ((y0 + yy) * img.width + x0) * 4, ((y0 + yy) * img.width + x0 + cw) * 4);
}
return out;
}
/** Bilinear resize. */
function resize(img, w, h) {
const out = createImage(w, h);
for (let yy = 0; yy < h; yy++) {
const sy = ((yy + 0.5) * img.height) / h - 0.5;
const y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(img.height - 1, y0 + 1);
const fy = sy - y0;
for (let xx = 0; xx < w; xx++) {
const sx = ((xx + 0.5) * img.width) / w - 0.5;
const x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(img.width - 1, x0 + 1);
const fx = sx - x0;
const dp = (yy * w + xx) * 4;
for (let c = 0; c < 4; c++) {
const p00 = img.data[(y0 * img.width + x0) * 4 + c];
const p01 = img.data[(y0 * img.width + x1) * 4 + c];
const p10 = img.data[(y1 * img.width + x0) * 4 + c];
const p11 = img.data[(y1 * img.width + x1) * 4 + c];
out.data[dp + c] = Math.round(
p00 * (1 - fx) * (1 - fy) + p01 * fx * (1 - fy) + p10 * (1 - fx) * fy + p11 * fx * fy
);
}
}
}
return out;
}
function drawImage(dst, src, dx, dy) {
for (let yy = 0; yy < src.height; yy++) {
for (let xx = 0; xx < src.width; xx++) {
const sp = (yy * src.width + xx) * 4;
blendPixel(dst, dx + xx, dy + yy, [src.data[sp], src.data[sp + 1], src.data[sp + 2], src.data[sp + 3]]);
}
}
}
// ---- annotation rendering ---------------------------------------------------
const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 };
/**
* Burn annotations into a copy of the base image. Annotation coords are
* fractions of the image; returns a new image.
*/
function renderAnnotations(baseImg, annotations = []) {
const img = cloneImage(baseImg);
const W = img.width, H = img.height;
const px = (frac, total) => frac * total;
const strokePx = (sw) => Math.max(1, Math.round((sw || 3) * W / 1000));
const fontPx = (style) => Math.max(8, Math.round((style.fontSize || 0.022) * H));
const ordered = [...annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
for (const ann of ordered) {
const x = px(ann.x, W), y = px(ann.y, H), w = px(ann.w, W), h = px(ann.h, H);
const style = ann.style || {};
const stroke = parseColor(style.stroke, [229, 72, 77, 255]);
const fill = parseColor(style.fill, [0, 0, 0, 0]);
const textColor = parseColor(style.textColor, [255, 255, 255, 255]);
const t = strokePx(style.strokeWidth);
switch (ann.type) {
case 'rect':
if (fill[3] > 0) fillRect(img, x, y, w, h, fill);
strokeRect(img, x, y, w, h, stroke, t);
break;
case 'oval':
if (fill[3] > 0) fillOval(img, x, y, w, h, fill);
strokeOval(img, x, y, w, h, stroke, t);
break;
case 'line':
drawLine(img, x, y, x + w, y + h, stroke, t);
break;
case 'arrow':
drawArrow(img, x, y, x + w, y + h, stroke, t);
break;
case 'blur':
boxBlur(img, x, y, w, h, ann.radius || 8);
break;
case 'highlight':
fillRect(img, x, y, w, h, [255, 235, 59, 105]);
break;
case 'magnify':
magnifyRegion(img, x, y, w, h, ann.zoom || 2, stroke, t);
break;
case 'text': {
drawText(img, x, y, ann.text || '', fontPx(style), stroke[3] > 0 ? stroke : [0, 0, 0, 255]);
break;
}
case 'tooltip': {
const bg = parseColor(style.fill === 'transparent' || !style.fill ? '#1F2937' : style.fill);
fillRect(img, x, y, w, h, bg);
strokeRect(img, x, y, w, h, parseColor(style.stroke, [17, 24, 39, 255]), Math.max(1, Math.round(t / 2)));
const tail = style.tail || 'bottom';
const ts = Math.max(6, Math.min(w, h) * 0.25);
if (tail === 'bottom') fillPolygon(img, [[x + w / 2 - ts, y + h], [x + w / 2 + ts, y + h], [x + w / 2, y + h + ts * 1.4]], bg);
if (tail === 'top') fillPolygon(img, [[x + w / 2 - ts, y], [x + w / 2 + ts, y], [x + w / 2, y - ts * 1.4]], bg);
if (tail === 'left') fillPolygon(img, [[x, y + h / 2 - ts], [x, y + h / 2 + ts], [x - ts * 1.4, y + h / 2]], bg);
if (tail === 'right') fillPolygon(img, [[x + w, y + h / 2 - ts], [x + w, y + h / 2 + ts], [x + w + ts * 1.4, y + h / 2]], bg);
drawTextCentered(img, x + w / 2, y + h / 2, ann.text || '', fontPx(style), textColor);
break;
}
case 'number': {
const r = Math.max(8, Math.min(w, h) / 2);
const cx = x + w / 2, cy = y + h / 2;
fillOval(img, cx - r, cy - r, r * 2, r * 2, stroke);
drawTextCentered(img, cx, cy, String(ann.value ?? '?'), Math.max(8, r), textColor);
break;
}
case 'cursor':
drawCursorIcon(img, x, y, Math.max(12, Math.min(w, h)), [17, 24, 39, 255]);
break;
default:
break;
}
}
return img;
}
/** Apply focused view (zoom/pan crop, then scale back to original size). */
function applyFocusedView(img, fv) {
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2);
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2);
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh);
return resize(region, img.width, img.height);
}
module.exports = {
createImage, cloneImage, parseColor, blendPixel,
fillRect, strokeRect, fillOval, strokeOval, drawLine, drawArrow,
fillPolygon, boxBlur, magnifyRegion,
measureText, drawText, drawTextCentered, drawCursorIcon,
crop, resize, drawImage,
renderAnnotations, applyFocusedView,
};