Spaces:
Sleeping
Sleeping
| const INF = 1e20; | |
| // lookup table for gamma-corrected, signed squared alpha distance values | |
| const alphaTable = new Float64Array(256); | |
| for (let i = 0; i < 256; i++) { | |
| const d = 0.5 - Math.pow(i / 255, 1 / 2.2); | |
| alphaTable[i] = d * Math.abs(d); | |
| } | |
| alphaTable[255] = -INF; | |
| export default class TinySDF { | |
| constructor({ | |
| fontSize = 24, | |
| buffer = 3, | |
| radius = 8, | |
| cutoff = 0.25, | |
| fontFamily = 'sans-serif', | |
| fontWeight = 'normal', | |
| fontStyle = 'normal', | |
| lang = null | |
| } = {}) { | |
| this.buffer = buffer; // padding around a glyph's bounding box | |
| this.radius = radius; // how many pixels around the glyph edge are encoded as signed distances | |
| this.cutoff = cutoff; // how much of the SDF byte range represents inside vs outside the edge | |
| this.lang = lang; // language of the Canvas drawing context | |
| // make the canvas size big enough to both have the specified buffer around the glyph | |
| // for "halo", and account for some glyphs possibly being larger than their font size | |
| const size = this.size = fontSize + buffer * 4; | |
| const canvas = this._createCanvas(size); | |
| const ctx = this.ctx = canvas.getContext('2d', {willReadFrequently: true}); | |
| ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`; | |
| ctx.textBaseline = 'alphabetic'; | |
| ctx.textAlign = 'left'; // Necessary so that RTL text doesn't have different alignment | |
| ctx.fillStyle = 'black'; | |
| // two grids of squared distances: one for the outside of the glyph shape, one for the inside; | |
| // the signed distance is derived as sqrt(outer) - sqrt(inner) | |
| this.gridOuter = new Float64Array(size * size); | |
| this.gridInner = new Float64Array(size * size); | |
| this.f = new Float64Array(size); | |
| this.z = new Float64Array(size + 1); | |
| this.v = new Uint16Array(size); | |
| } | |
| _createCanvas(size) { | |
| if (typeof OffscreenCanvas !== 'undefined') { | |
| return new OffscreenCanvas(size, size); | |
| } | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = canvas.height = size; | |
| return canvas; | |
| } | |
| draw(char) { | |
| const { | |
| width: glyphAdvance, | |
| actualBoundingBoxAscent, | |
| actualBoundingBoxDescent, | |
| actualBoundingBoxLeft, | |
| actualBoundingBoxRight | |
| } = this.ctx.measureText(char); | |
| // The integer/pixel part of the alignment is encoded in metrics.glyphTop/glyphLeft | |
| // The remainder is implicitly encoded in the rasterization | |
| const glyphTop = Math.ceil(actualBoundingBoxAscent); | |
| // actualBoundingBoxLeft is positive when ink extends LEFT of the origin (per spec), | |
| // so negate to get the ink's left edge in canvas x-coords (positive = right of origin) | |
| const glyphLeft = Math.floor(-actualBoundingBoxLeft); | |
| // If the glyph overflows the canvas size, it will be clipped at the bottom/right | |
| const glyphWidth = Math.max(0, Math.min(this.size - this.buffer, Math.ceil(actualBoundingBoxRight) - glyphLeft)); | |
| const glyphHeight = Math.max(0, Math.min(this.size - this.buffer, glyphTop + Math.ceil(actualBoundingBoxDescent))); | |
| const width = glyphWidth + 2 * this.buffer; | |
| const height = glyphHeight + 2 * this.buffer; | |
| const len = Math.max(width * height, 0); | |
| const data = new Uint8ClampedArray(len); | |
| const glyph = {data, width, height, glyphWidth, glyphHeight, glyphTop, glyphLeft, glyphAdvance}; | |
| if (glyphWidth === 0 || glyphHeight === 0) return glyph; | |
| const {ctx, buffer, gridInner, gridOuter} = this; | |
| if (this.lang) ctx.lang = this.lang; | |
| ctx.clearRect(buffer, buffer, glyphWidth, glyphHeight); | |
| ctx.fillText(char, buffer - glyphLeft, buffer + glyphTop); | |
| const imgData = ctx.getImageData(buffer, buffer, glyphWidth, glyphHeight); | |
| // default: outside the glyph (INF distance) for outer, inside (0 distance) for inner | |
| gridOuter.fill(INF, 0, len); | |
| gridInner.fill(0, 0, len); | |
| // for anti-aliased pixels, treat partial coverage as a distance approximation: | |
| // a fully covered pixel gets 0 outer / INF inner; a partial pixel gets a small | |
| // non-zero outer or inner distance based on how far its coverage deviates from 0.5 | |
| let imgIdx = 3; // start at the alpha channel of the first pixel | |
| for (let y = 0; y < glyphHeight; y++) { | |
| let j = (y + buffer) * width + buffer; | |
| for (let x = 0; x < glyphWidth; x++, imgIdx += 4, j++) { | |
| const a = imgData.data[imgIdx]; // alpha value | |
| if (a === 0) continue; // empty pixels | |
| const t = alphaTable[a]; | |
| gridOuter[j] = Math.max(0, t); | |
| gridInner[j] = Math.max(0, -t); | |
| } | |
| } | |
| edt(gridOuter, 0, 0, width, height, width, this.f, this.v, this.z); | |
| // Pad the inner EDT region by 1 px so ink pixels touching the bbox edge can see the | |
| // outside-ink seeds in the buffer region; clamp to buffer so we don't underflow when buffer=0 | |
| const pad = Math.min(buffer, 1); | |
| edt(gridInner, buffer - pad, buffer - pad, glyphWidth + 2 * pad, glyphHeight + 2 * pad, width, this.f, this.v, this.z); | |
| // encode signed distance as a byte: inside the glyph maps to high values, outside to low, | |
| // with the edge gradient spanning [-radius * cutoff, radius * (1 - cutoff)] pixels around the edge; | |
| // Uint8ClampedArray clamps beyond that | |
| const scale = 255 / this.radius; | |
| const base = 255 * (1 - this.cutoff); | |
| for (let i = 0; i < len; i++) { | |
| const d = Math.sqrt(gridOuter[i]) - Math.sqrt(gridInner[i]); | |
| data[i] = Math.round(base - scale * d); | |
| } | |
| return glyph; | |
| } | |
| } | |
| // 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/papers/dt-final.pdf | |
| function edt(data, x0, y0, width, height, gridSize, f, v, z) { | |
| for (let x = x0; x < x0 + width; x++) edt1d(data, y0 * gridSize + x, gridSize, height, f, v, z); | |
| for (let y = y0; y < y0 + height; y++) edt1d(data, y * gridSize + x0, 1, width, f, v, z); | |
| } | |
| // 1D squared distance transform | |
| function edt1d(grid, offset, stride, length, f, v, z) { | |
| v[0] = 0; | |
| z[0] = -INF; | |
| z[1] = INF; | |
| f[0] = grid[offset]; | |
| for (let q = 1, k = 0, s = 0; q < length; q++) { | |
| f[q] = grid[offset + q * stride]; | |
| const q2 = q * q; | |
| do { | |
| const r = v[k]; | |
| s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2; | |
| } while (s <= z[k] && --k > -1); | |
| k++; | |
| v[k] = q; | |
| z[k] = s; | |
| z[k + 1] = INF; | |
| } | |
| for (let q = 0, k = 0; q < length; q++) { | |
| while (z[k + 1] < q) k++; | |
| const r = v[k]; | |
| const qr = q - r; | |
| grid[offset + q * stride] = f[r] + qr * qr; | |
| } | |
| } | |