Files
xterm.js/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts
T

756 lines
24 KiB
TypeScript

/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphCharacterDefinition, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
/**
* Cached custom glyph definitions. This is loaded dynamically to support code splitting.
*/
let customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefinition | undefined } | undefined;
/**
* Promise that resolves when custom glyph definitions are loaded.
*/
let loadingPromise: Promise<void> | undefined;
/**
* Begins loading the custom glyph definitions. This should be called as early as possible
* to ensure the definitions are ready when needed.
* @returns A promise that resolves when the definitions are loaded.
*/
export function loadCustomGlyphDefinitions(): Promise<void> {
if (customGlyphDefinitions) {
return Promise.resolve();
}
if (!loadingPromise) {
loadingPromise = import('./CustomGlyphDefinitions').then(module => {
customGlyphDefinitions = module.customGlyphDefinitions;
}).catch(err => {
// Reset the loading promise on failure to allow retry
loadingPromise = undefined;
throw err;
});
}
return loadingPromise;
}
/**
* Try drawing a custom block element or box drawing character, returning whether it was
* successfully drawn.
*/
export function tryDrawCustomGlyph(
ctx: CanvasRenderingContext2D,
c: string,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
deviceCharWidth: number,
deviceCharHeight: number,
fontSize: number,
devicePixelRatio: number,
backgroundColor?: string
): boolean {
// If definitions are not loaded yet, return false to fall back to font rendering
if (!customGlyphDefinitions) {
return false;
}
const unifiedCharDefinition = customGlyphDefinitions[c];
if (unifiedCharDefinition) {
// Normalize to array for uniform handling
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
for (const part of parts) {
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor);
}
return true;
}
return false;
}
function drawDefinitionPart(
ctx: CanvasRenderingContext2D,
part: CustomGlyphDefinitionPart,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
deviceCharWidth: number,
deviceCharHeight: number,
fontSize: number,
devicePixelRatio: number,
backgroundColor?: string
): void {
// Handle scaleType - adjust dimensions and offset when scaling to character area
let drawWidth = deviceCellWidth;
let drawHeight = deviceCellHeight;
let drawXOffset = xOffset;
let drawYOffset = yOffset;
if (part.scaleType === CustomGlyphScaleType.CHAR) {
drawWidth = deviceCharWidth;
drawHeight = deviceCharHeight;
// Center the character within the cell
drawXOffset = xOffset + (deviceCellWidth - deviceCharWidth) / 2;
drawYOffset = yOffset + (deviceCellHeight - deviceCharHeight) / 2;
}
// Handle clipPath generically for any definition type
if (part.clipPath) {
ctx.save();
applyClipPath(ctx, part.clipPath, drawXOffset, drawYOffset, drawWidth, drawHeight);
}
switch (part.type) {
case CustomGlyphDefinitionType.SOLID_OCTANT_BLOCK_VECTOR:
drawBlockVectorChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
break;
case CustomGlyphDefinitionType.BLOCK_PATTERN:
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
break;
case CustomGlyphDefinitionType.PATH_FUNCTION:
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
break;
case CustomGlyphDefinitionType.PATH:
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
break;
case CustomGlyphDefinitionType.PATH_NEGATIVE:
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
break;
case CustomGlyphDefinitionType.VECTOR_SHAPE:
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
break;
case CustomGlyphDefinitionType.BRAILLE:
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
break;
}
if (part.clipPath) {
ctx.restore();
}
}
function drawBlockVectorChar(
ctx: CanvasRenderingContext2D,
charDefinition: ICustomGlyphSolidOctantBlockVector[],
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
for (let i = 0; i < charDefinition.length; i++) {
const box = charDefinition[i];
const xEighth = deviceCellWidth / 8;
const yEighth = deviceCellHeight / 8;
ctx.fillRect(
xOffset + box.x * xEighth,
yOffset + box.y * yEighth,
box.w * xEighth,
box.h * yEighth
);
}
}
/**
* Braille dot positions in octant coordinates (x, y for center of each dot area)
* Columns: left=1-2, right=5-6 (leaving 0 and 7 as margins, 3-4 as gap)
* Rows: 0-1, 2-3, 4-5, 6-7 for the 4 rows
*/
const brailleDotPositions = new Uint8Array([
1, 0, // dot 1 - bit 0
1, 2, // dot 2 - bit 1
1, 4, // dot 3 - bit 2
5, 0, // dot 4 - bit 3
5, 2, // dot 5 - bit 4
5, 4, // dot 6 - bit 5
1, 6, // dot 7 - bit 6
5, 6, // dot 8 - bit 7
]);
/**
* Draws a braille pattern
*/
function drawBrailleCharacter(
ctx: CanvasRenderingContext2D,
pattern: number,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
const xEighth = deviceCellWidth / 8;
const paddingY = deviceCellHeight * 0.1;
const usableHeight = deviceCellHeight * 0.8;
const yEighth = usableHeight / 8;
const radius = Math.min(xEighth, yEighth);
for (let bit = 0; bit < 8; bit++) {
if (pattern & (1 << bit)) {
const x = brailleDotPositions[bit * 2];
const y = brailleDotPositions[bit * 2 + 1];
const cx = xOffset + (x + 1) * xEighth;
const cy = yOffset + paddingY + (y + 1) * yEighth;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawPathDefinitionCharacter(
ctx: CanvasRenderingContext2D,
charDefinition: CustomGlyphPathDrawFunctionDefinition | string,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
devicePixelRatio: number,
strokeWidth?: number
): void {
const instructions = typeof charDefinition === 'string' ? charDefinition : charDefinition(0, 0);
ctx.beginPath();
let currentX = 0;
let currentY = 0;
let lastControlX = 0;
let lastControlY = 0;
let lastCommand = '';
for (const instruction of instructions.split(' ')) {
const type = instruction[0];
const args: string[] = instruction.substring(1).split(',');
if (type === 'Z') {
ctx.closePath();
lastCommand = type;
continue;
}
if (type === 'V') {
const y = yOffset + parseFloat(args[0]) * deviceCellHeight;
ctx.lineTo(currentX, y);
currentY = y;
lastControlX = currentX;
lastControlY = currentY;
lastCommand = type;
continue;
}
if (type === 'H') {
const x = xOffset + parseFloat(args[0]) * deviceCellWidth;
ctx.lineTo(x, currentY);
currentX = x;
lastControlX = currentX;
lastControlY = currentY;
lastCommand = type;
continue;
}
if (!args[0] || !args[1]) {
continue;
}
if (type === 'A') {
// SVG arc: A rx,ry,xAxisRotation,largeArcFlag,sweepFlag,x,y
const rx = parseFloat(args[0]) * deviceCellWidth;
const ry = parseFloat(args[1]) * deviceCellHeight;
const xAxisRotation = parseFloat(args[2]) * Math.PI / 180;
const largeArcFlag = parseInt(args[3]);
const sweepFlag = parseInt(args[4]);
const x = xOffset + parseFloat(args[5]) * deviceCellWidth;
const y = yOffset + parseFloat(args[6]) * deviceCellHeight;
drawSvgArc(ctx, currentX, currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y);
currentX = x;
currentY = y;
continue;
}
const translatedArgs = args.map((e, i) => {
const val = parseFloat(e);
return i % 2 === 0
? xOffset + val * deviceCellWidth
: yOffset + val * deviceCellHeight;
});
if (type === 'M') {
ctx.moveTo(translatedArgs[0], translatedArgs[1]);
currentX = translatedArgs[0];
currentY = translatedArgs[1];
lastControlX = currentX;
lastControlY = currentY;
} else if (type === 'L') {
ctx.lineTo(translatedArgs[0], translatedArgs[1]);
currentX = translatedArgs[0];
currentY = translatedArgs[1];
lastControlX = currentX;
lastControlY = currentY;
} else if (type === 'Q') {
ctx.quadraticCurveTo(translatedArgs[0], translatedArgs[1], translatedArgs[2], translatedArgs[3]);
lastControlX = translatedArgs[0];
lastControlY = translatedArgs[1];
currentX = translatedArgs[2];
currentY = translatedArgs[3];
} else if (type === 'T') {
// T uses reflection of last control point if previous command was Q or T
let cpX: number;
let cpY: number;
if (lastCommand === 'Q' || lastCommand === 'T') {
cpX = 2 * currentX - lastControlX;
cpY = 2 * currentY - lastControlY;
} else {
cpX = currentX;
cpY = currentY;
}
ctx.quadraticCurveTo(cpX, cpY, translatedArgs[0], translatedArgs[1]);
lastControlX = cpX;
lastControlY = cpY;
currentX = translatedArgs[0];
currentY = translatedArgs[1];
} else if (type === 'C') {
ctx.bezierCurveTo(translatedArgs[0], translatedArgs[1], translatedArgs[2], translatedArgs[3], translatedArgs[4], translatedArgs[5]);
lastControlX = translatedArgs[2];
lastControlY = translatedArgs[3];
currentX = translatedArgs[4];
currentY = translatedArgs[5];
}
lastCommand = type;
}
if (strokeWidth !== undefined) {
ctx.strokeStyle = ctx.fillStyle;
ctx.lineWidth = devicePixelRatio * strokeWidth;
ctx.stroke();
} else {
ctx.fill();
}
}
/**
* Converts SVG arc parameters to canvas arc/ellipse calls.
* Based on the SVG spec's endpoint to center parameterization conversion.
*/
function drawSvgArc(
ctx: CanvasRenderingContext2D,
x1: number, y1: number,
rx: number, ry: number,
phi: number,
largeArcFlag: number,
sweepFlag: number,
x2: number, y2: number
): void {
// Handle degenerate cases
if (rx === 0 || ry === 0) {
ctx.lineTo(x2, y2);
return;
}
rx = Math.abs(rx);
ry = Math.abs(ry);
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
// Step 1: Compute (x1', y1')
const dx = (x1 - x2) / 2;
const dy = (y1 - y2) / 2;
const x1p = cosPhi * dx + sinPhi * dy;
const y1p = -sinPhi * dx + cosPhi * dy;
// Step 2: Compute (cx', cy')
let rxSq = rx * rx;
let rySq = ry * ry;
const x1pSq = x1p * x1p;
const y1pSq = y1p * y1p;
// Correct radii if necessary
const lambda = x1pSq / rxSq + y1pSq / rySq;
if (lambda > 1) {
const lambdaSqrt = Math.sqrt(lambda);
rx *= lambdaSqrt;
ry *= lambdaSqrt;
rxSq = rx * rx;
rySq = ry * ry;
}
let sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq);
if (sq < 0) sq = 0;
const coef = (largeArcFlag === sweepFlag ? -1 : 1) * Math.sqrt(sq);
const cxp = coef * (rx * y1p / ry);
const cyp = coef * -(ry * x1p / rx);
// Step 3: Compute (cx, cy) from (cx', cy')
const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2;
const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2;
// Step 4: Compute angles
const ux = (x1p - cxp) / rx;
const uy = (y1p - cyp) / ry;
const vx = (-x1p - cxp) / rx;
const vy = (-y1p - cyp) / ry;
const startAngle = Math.atan2(uy, ux);
let dTheta = Math.atan2(vy, vx) - startAngle;
if (sweepFlag === 0 && dTheta > 0) {
dTheta -= 2 * Math.PI;
} else if (sweepFlag === 1 && dTheta < 0) {
dTheta += 2 * Math.PI;
}
const endAngle = startAngle + dTheta;
ctx.ellipse(cx, cy, rx, ry, phi, startAngle, endAngle, sweepFlag === 0);
}
/**
* Draws a "negative" path where the background color is used to draw the shape on top of a
* foreground-filled cell. This creates the appearance of a cutout without using actual
* transparency, which allows SPAA (subpixel anti-aliasing) to work correctly.
*
* @param ctx The canvas rendering context (fillStyle should be set to foreground color)
* @param charDefinition The vector shape definition for the negative shape
* @param xOffset The x offset to draw at
* @param yOffset The y offset to draw at
* @param deviceCellWidth The width of the cell in device pixels
* @param deviceCellHeight The height of the cell in device pixels
* @param devicePixelRatio The device pixel ratio
* @param backgroundColor The background color to use for the "cutout" portion
*/
function drawPathNegativeDefinitionCharacter(
ctx: CanvasRenderingContext2D,
charDefinition: ICustomGlyphVectorShape,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
devicePixelRatio: number,
backgroundColor?: string
): void {
ctx.save();
// First, fill the entire cell with foreground color
ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
// Then draw the "negative" shape with the background color
if (backgroundColor) {
ctx.fillStyle = backgroundColor;
ctx.strokeStyle = backgroundColor;
}
ctx.lineWidth = devicePixelRatio;
ctx.lineCap = 'square';
ctx.beginPath();
for (const instruction of charDefinition.d.split(' ')) {
const type = instruction[0];
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
if (type === 'Z') {
ctx.closePath();
}
continue;
}
const translatedArgs = args.map((e, i) => {
const val = parseFloat(e);
return i % 2 === 0
? xOffset + val * deviceCellWidth
: yOffset + val * deviceCellHeight;
});
if (type === 'M') {
ctx.moveTo(translatedArgs[0], translatedArgs[1]);
} else if (type === 'L') {
ctx.lineTo(translatedArgs[0], translatedArgs[1]);
}
}
if (charDefinition.type === CustomGlyphVectorType.STROKE) {
ctx.stroke();
} else {
ctx.fill();
}
ctx.restore();
}
const cachedPatterns: Map<CustomGlyphPatternDefinition, Map</* fillStyle */string, CanvasPattern>> = new Map();
function drawPatternChar(
ctx: CanvasRenderingContext2D,
charDefinition: number[][],
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
let patternSet = cachedPatterns.get(charDefinition);
if (!patternSet) {
patternSet = new Map();
cachedPatterns.set(charDefinition, patternSet);
}
const fillStyle = ctx.fillStyle;
if (typeof fillStyle !== 'string') {
throw new Error(`Unexpected fillStyle type "${fillStyle}"`);
}
let pattern = patternSet.get(fillStyle);
if (!pattern) {
const width = charDefinition[0].length;
const height = charDefinition.length;
const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas');
tmpCanvas.width = width;
tmpCanvas.height = height;
const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d'));
const imageData = new ImageData(width, height);
// Extract rgba from fillStyle
let r: number;
let g: number;
let b: number;
let a: number;
if (fillStyle.startsWith('#')) {
r = parseInt(fillStyle.slice(1, 3), 16);
g = parseInt(fillStyle.slice(3, 5), 16);
b = parseInt(fillStyle.slice(5, 7), 16);
a = fillStyle.length > 7 && parseInt(fillStyle.slice(7, 9), 16) || 1;
} else if (fillStyle.startsWith('rgba')) {
([r, g, b, a] = fillStyle.substring(5, fillStyle.length - 1).split(',').map(e => parseFloat(e)));
} else {
throw new Error(`Unexpected fillStyle color format "${fillStyle}" when drawing pattern glyph`);
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
imageData.data[(y * width + x) * 4 ] = r;
imageData.data[(y * width + x) * 4 + 1] = g;
imageData.data[(y * width + x) * 4 + 2] = b;
imageData.data[(y * width + x) * 4 + 3] = charDefinition[y][x] * (a * 255);
}
}
tmpCtx.putImageData(imageData, 0, 0);
pattern = throwIfFalsy(ctx.createPattern(tmpCanvas, null));
patternSet.set(fillStyle, pattern);
}
ctx.fillStyle = pattern;
ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
}
function drawPathFunctionCharacter(
ctx: CanvasRenderingContext2D,
charDefinition: string | ((xp: number, yp: number) => string),
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
devicePixelRatio: number,
strokeWidth?: number
): void {
ctx.beginPath();
let actualInstructions: string;
if (typeof charDefinition === 'function') {
const xp = .15;
const yp = .15 / deviceCellHeight * deviceCellWidth;
actualInstructions = charDefinition(xp, yp);
} else {
actualInstructions = charDefinition;
}
const state: ISvgPathState = { currentX: 0, currentY: 0, lastControlX: 0, lastControlY: 0, lastCommand: '' };
for (const instruction of actualInstructions.split(' ')) {
const type = instruction[0];
if (type === 'Z') {
ctx.closePath();
state.lastCommand = type;
continue;
}
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
continue;
}
f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio), state);
state.lastCommand = type;
}
if (strokeWidth !== undefined) {
ctx.strokeStyle = ctx.fillStyle;
ctx.lineWidth = devicePixelRatio * strokeWidth;
ctx.stroke();
} else {
ctx.fill();
}
ctx.closePath();
}
/**
* Applies a clip path to the canvas context from SVG-like path instructions.
*/
function applyClipPath(
ctx: CanvasRenderingContext2D,
clipPath: string,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
ctx.beginPath();
for (const instruction of clipPath.split(' ')) {
const type = instruction[0];
if (type === 'Z') {
ctx.closePath();
continue;
}
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
continue;
}
const x = xOffset + parseFloat(args[0]) * deviceCellWidth;
const y = yOffset + parseFloat(args[1]) * deviceCellHeight;
if (type === 'M') {
ctx.moveTo(x, y);
} else if (type === 'L') {
ctx.lineTo(x, y);
}
}
ctx.clip();
}
function drawVectorShape(
ctx: CanvasRenderingContext2D,
charDefinition: ICustomGlyphVectorShape,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
fontSize: number,
devicePixelRatio: number
): void {
// Clip the cell to make sure drawing doesn't occur beyond bounds
const clipRegion = new Path2D();
clipRegion.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
ctx.clip(clipRegion);
ctx.beginPath();
// Scale the stroke with DPR and font size
const cssLineWidth = fontSize / 12;
ctx.lineWidth = devicePixelRatio * cssLineWidth;
const state: ISvgPathState = { currentX: 0, currentY: 0, lastControlX: 0, lastControlY: 0, lastCommand: '' };
for (const instruction of charDefinition.d.split(' ')) {
const type = instruction[0];
if (type === 'Z') {
ctx.closePath();
state.lastCommand = type;
continue;
}
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
continue;
}
f(ctx, translateArgs(
args,
deviceCellWidth,
deviceCellHeight,
xOffset,
yOffset,
false,
devicePixelRatio,
(charDefinition.leftPadding ?? 0) * (cssLineWidth / 2),
(charDefinition.rightPadding ?? 0) * (cssLineWidth / 2)
), state);
state.lastCommand = type;
}
if (charDefinition.type === CustomGlyphVectorType.STROKE) {
ctx.strokeStyle = ctx.fillStyle;
ctx.stroke();
} else {
ctx.fill();
}
ctx.closePath();
}
function clamp(value: number, max: number, min: number = 0): number {
return Math.max(Math.min(value, max), min);
}
interface ISvgPathState {
currentX: number;
currentY: number;
lastControlX: number;
lastControlY: number;
lastCommand: string;
}
const svgToCanvasInstructionMap: { [index: string]: (ctx: CanvasRenderingContext2D, args: number[], state: ISvgPathState) => void } = {
'C': (ctx, args, state) => {
ctx.bezierCurveTo(args[0], args[1], args[2], args[3], args[4], args[5]);
state.lastControlX = args[2];
state.lastControlY = args[3];
state.currentX = args[4];
state.currentY = args[5];
},
'L': (ctx, args, state) => {
ctx.lineTo(args[0], args[1]);
state.lastControlX = state.currentX = args[0];
state.lastControlY = state.currentY = args[1];
},
'M': (ctx, args, state) => {
ctx.moveTo(args[0], args[1]);
state.lastControlX = state.currentX = args[0];
state.lastControlY = state.currentY = args[1];
},
'Q': (ctx, args, state) => {
ctx.quadraticCurveTo(args[0], args[1], args[2], args[3]);
state.lastControlX = args[0];
state.lastControlY = args[1];
state.currentX = args[2];
state.currentY = args[3];
},
'T': (ctx, args, state) => {
let cpX: number;
let cpY: number;
if (state.lastCommand === 'Q' || state.lastCommand === 'T') {
cpX = 2 * state.currentX - state.lastControlX;
cpY = 2 * state.currentY - state.lastControlY;
} else {
cpX = state.currentX;
cpY = state.currentY;
}
ctx.quadraticCurveTo(cpX, cpY, args[0], args[1]);
state.lastControlX = cpX;
state.lastControlY = cpY;
state.currentX = args[0];
state.currentY = args[1];
}
};
function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0): number[] {
const result = args.map(e => parseFloat(e) || parseInt(e));
if (result.length < 2) {
throw new Error('Too few arguments for instruction');
}
for (let x = 0; x < result.length; x += 2) {
// Translate from 0-1 to 0-cellWidth
result[x] *= cellWidth - (leftPadding * devicePixelRatio) - (rightPadding * devicePixelRatio);
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
// line at 100% devicePixelRatio
if (doClamp && result[x] !== 0) {
result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0);
}
// Apply the cell's offset (ie. x*cellWidth)
result[x] += xOffset + (leftPadding * devicePixelRatio);
}
for (let y = 1; y < result.length; y += 2) {
// Translate from 0-1 to 0-cellHeight
result[y] *= cellHeight;
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
// line at 100% devicePixelRatio
if (doClamp && result[y] !== 0) {
result[y] = clamp(Math.round(result[y] + 0.5) - 0.5, cellHeight, 0);
}
// Apply the cell's offset (ie. x*cellHeight)
result[y] += yOffset;
}
return result;
}