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>
This commit is contained in:
+412
@@ -0,0 +1,412 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user