UNPKG

d3-cloud

Version:

Generate word clouds in JavaScript.

526 lines (468 loc) • 18.4 kB
const RADIANS = Math.PI / 180; const SCRATCH_WIDTH = 1 << 11; const SCRATCH_HEIGHT = 1 << 11; const IMAGE_SLACK = 4; const CREATE = Symbol("CloudSprite"); // The mask and geometry used for placement, keyed by each sprite this module // created. Placement reads only this private copy, so changing a sprite's // public mask cannot corrupt a layout, and objects missing from this map were // not created by getSprite(). // // Mask data is row-major, with the leftmost pixel in the most significant // bit of each 32-bit word; words is the padded row stride in words. Geometry // is in pixels relative to the placement origin, with inclusive x0/y0 and // exclusive x1/y1. The envelope uses the same coordinates but may extend past // the ink: it limits searches to positions adjoining the cloud, while the // mask itself controls pixel collisions, fixed-box containment, and bounds. const MASKS = new WeakMap(); // Context and clearing state for each scratch canvas, shared by every layout // that draws to it. const CANVAS_STATES = new WeakMap(); // An immutable, rasterized mask for a text or image source. Sprites are only // created by CloudLayout.getSprite() and can be placed any number of times. export default class CloudSprite { constructor(token, fields) { if (token !== CREATE) { throw new TypeError("CloudSprite instances are created by CloudLayout.getSprite()"); } Object.assign(this, fields); Object.freeze(this); } } export function spriteMask(sprite) { return MASKS.get(sprite); } // Validates the source and options, returning the fields to rasterize, or // null if there is nothing to draw. export function spriteFields(source, options) { const image = isImageSource(source) ? source : null; if (!image && !isTextSource(source)) { throw new TypeError("getSprite() expects text or an image-like source"); } const size = finiteOption(options.size ?? options.fontSize, "size", 1); const padding = finiteOption(options.padding, "padding", image ? 0 : 1); if (padding < 0) { throw new RangeError("getSprite() expects a nonnegative padding"); } const fields = { text: String(image ? options.text ?? source.alt ?? "" : source), image, imageWidth: image ? imageDimensionOption(options.width, "width") : null, imageHeight: image ? imageDimensionOption(options.height, "height") : null, font: String(options.font ?? "serif"), style: String(options.style ?? options.fontStyle ?? "normal"), weight: fontWeightOption(options.weight ?? options.fontWeight ?? "normal"), rotate: finiteOption(options.rotate, "rotate", 0), size: Math.trunc(size), padding, datum: options.datum }; const empty = image ? fields.imageWidth === 0 || fields.imageHeight === 0 : fields.size < 1; return empty ? null : fields; } export function rasterizeSprite(fields, state) { // Image getters could call getSprite() again mid-draw, which would leave ink // that neither call clears. if (state.drawing) { throw new Error("getSprite() cannot be called while a sprite is being drawn to the same canvas"); } // Check the font before drawing: a rejected font is an argument error, not // a canvas failure. const font = fields.image ? null : textFont(fields, state); let raster; state.drawing = true; try { raster = fields.image ? rasterizeImageSprite(fields, state) : rasterizeTextSprite(fields, font, state); } catch (error) { // A failed draw or readback, for example after a cross-origin image // taints the canvas, must not break later sprites, so every layout using // this canvas asks its factory for a canvas again. state.invalid = true; throw error; } finally { state.drawing = false; } if (!raster) { return null; } const { data, spriteWidth, x0, y0, x1, y1 } = raster; const sprite = new CloudSprite(CREATE, { ...fields, imageWidth: raster.imageWidth ?? fields.imageWidth, imageHeight: raster.imageHeight ?? fields.imageHeight, width: raster.width, height: raster.height, x0, y0, x1, y1, spriteWidth, sprite: data.slice() }); MASKS.set(sprite, { data, words: spriteWidth >>> 5, x0, y0, x1, y1, envelope: raster.envelope }); return sprite; } export function createSpriteContext(canvas) { let state = CANVAS_STATES.get(canvas); if (state && !state.invalid) { return state; } // Check the context before resetting the canvas, which may be shared. const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { throw new TypeError("canvas() factories must return a canvas with a 2d context"); } // Setting the dimensions also clears the canvas, even if they are unchanged. canvas.width = SCRATCH_WIDTH; canvas.height = SCRATCH_HEIGHT; // Masks are read one pixel per canvas unit, as the canvas specification // requires. if (context.getImageData(0, 0, 1, 1).data.length !== 4) { throw new TypeError("canvas() factories must return a canvas that reads back one pixel per unit"); } context.fillStyle = context.strokeStyle = "red"; // Keep the state object shared by layouts, including those that already // hold a reference to it when another layout invalidates the context. state = Object.assign(state || {}, { context, invalid: false, drawing: false, clearWidth: 0, clearHeight: 0 }); CANVAS_STATES.set(canvas, state); return state; } function finiteOption(value, name, defaultValue) { if (value == null) { return defaultValue; } value = +value; if (!Number.isFinite(value)) { throw new TypeError(`getSprite() expects a finite ${name}`); } return value; } // Numeric weights are kept as numbers; anything else is converted to a string // before drawing, so a toString() cannot run mid-draw. function fontWeightOption(value) { return typeof value === "number" ? value : String(value); } function imageDimensionOption(value, name) { value = finiteOption(value, name, null); return value == null ? null : Math.max(0, Math.trunc(value)); } function clearContext(state, width = state.clearWidth, height = state.clearHeight) { const context = state.context; if (width && height) { context.clearRect(0, 0, width, height); } return context; } // Records the region the next sprite must clear before drawing. function setClearExtent(state, width, height) { state.clearWidth = width; state.clearHeight = height; } // Calls draw with the origin at the center of the raster, rotated to the // sprite's angle, and restores the context afterwards. function drawAtRasterCenter(context, rasterWidth, rasterHeight, rotate, draw) { context.save(); try { context.translate(rasterWidth >> 1, rasterHeight >> 1); if (rotate) { context.rotate(rotate * RADIANS); } draw(); } finally { context.restore(); } } // Returns the CSS font for a text sprite. Canvases ignore fonts they cannot // parse and keep drawing with the previous one, which would leave a mask that // does not match the rendered text. Set a font of a different size first, so a // rejected font is detectable before drawing. function textFont(sprite, state) { const context = state.context; const font = `${sprite.style} ${sprite.weight} ${sprite.size}px ${sprite.font}`; context.font = `${sprite.size + 1}px serif`; const previousFont = context.font; context.font = font; if (context.font === previousFont) { throw new TypeError(`getSprite() font "${font}" was rejected by the canvas`); } return font; } // The font and alignment are set for every text sprite, and drawing restores // the transform and stroke settings, so a failed draw cannot affect the next // sprite. function rasterizeTextSprite(sprite, font, state) { const context = clearContext(state); context.font = font; // Match the documented rendering contract exactly: some canvases position // centered glyphs differently from a manually offset left-aligned run. context.textAlign = "center"; const { width, height, hasInkBounds } = textRasterSize(sprite, context.measureText(sprite.text)); const overscan = Math.max(4, Math.ceil((sprite.padding + 2) * 2)); // Width-only canvas adapters cannot tell us how far glyphs actually drew, // so clear two extra ems past the raster before the next sprite. const overhang = hasInkBounds ? 0 : 2 * sprite.size; // The horizontal search envelope keeps the measured raster width, even if // under-reported metrics force the ink into a larger raster. const envelopeWidth = Math.ceil(width + overscan * 2); let rasterWidth = envelopeWidth; let rasterHeight = Math.ceil(height + overscan * 2); let redrawn = false; let pixels; let alphaBounds; for (;;) { if (rasterWidth > SCRATCH_WIDTH || rasterHeight > SCRATCH_HEIGHT) { setClearExtent(state, 0, 0); return null; } try { drawAtRasterCenter(context, rasterWidth, rasterHeight, sprite.rotate, () => { context.fillText(sprite.text, 0, 0); if (sprite.padding) { context.lineWidth = 2 * sprite.padding; context.lineJoin = "round"; context.strokeText(sprite.text, 0, 0); } }); } finally { // Clear any partial drawing before the next sprite, even after failure. setClearExtent(state, redrawn ? SCRATCH_WIDTH : Math.min(SCRATCH_WIDTH, rasterWidth + overhang), redrawn ? SCRATCH_HEIGHT : Math.min(SCRATCH_HEIGHT, rasterHeight + overhang)); } pixels = context.getImageData(0, 0, rasterWidth, rasterHeight).data; alphaBounds = findAlphaBounds(pixels, rasterWidth, rasterHeight); if (!alphaBounds) { return null; } const growWidth = alphaBounds.x0 === 0 || alphaBounds.x0 + alphaBounds.width === rasterWidth; const growHeight = alphaBounds.y0 === 0 || alphaBounds.y0 + alphaBounds.height === rasterHeight; if (!growWidth && !growHeight) { break; } // Ink reaching the raster edge may have been clipped because the font // metrics underestimated it, so redraw larger along each axis the ink // reached. Metrics that under-reported once cannot bound where the ink // went, so the whole scratch canvas is cleared before the next sprite. const nextWidth = growWidth ? Math.min(SCRATCH_WIDTH, rasterWidth * 2) : rasterWidth; const nextHeight = growHeight ? Math.min(SCRATCH_HEIGHT, rasterHeight * 2) : rasterHeight; if (nextWidth === rasterWidth && nextHeight === rasterHeight) { return null; } redrawn = true; setClearExtent(state, SCRATCH_WIDTH, SCRATCH_HEIGHT); clearContext(state, nextWidth, nextHeight); rasterWidth = nextWidth; rasterHeight = nextHeight; } const raster = extractSpritePixels(pixels, rasterWidth, rasterHeight, alphaBounds); // Rectangle checks against the cloud use the measured raster width, // centered on the origin and widened only to contain the ink. const left = -(envelopeWidth >> 1); raster.envelope = { x0: Math.min(raster.x0, left), y0: raster.y0, x1: Math.max(raster.x1, left + envelopeWidth), y1: raster.y1 }; return raster; } // Returns the size of a raster that encloses the text's ink and padding at // the sprite's rotation, and whether the canvas reported ink bounds. function textRasterSize(sprite, metrics) { let width = metrics.width + 1; let height = sprite.size * 2; const hasInkBounds = Number.isFinite(metrics.actualBoundingBoxLeft) && Number.isFinite(metrics.actualBoundingBoxRight) && Number.isFinite(metrics.actualBoundingBoxAscent) && Number.isFinite(metrics.actualBoundingBoxDescent); if (hasInkBounds) { // Advance width and font size do not bound italic overhang or accents. // Enclose the ink symmetrically around the rendering origin before // rotation; round stroke joins extend at most padding beyond the ink. width = Math.max(width, 2 * (sprite.padding + Math.max( Math.abs(metrics.actualBoundingBoxLeft), Math.abs(metrics.actualBoundingBoxRight) ))); height = Math.max(height, 2 * (sprite.padding + Math.max( Math.abs(metrics.actualBoundingBoxAscent), Math.abs(metrics.actualBoundingBoxDescent) ))); } return { ...rotatedExtent(width, height, sprite.rotate), hasInkBounds }; } function rasterizeImageSprite(sprite, state) { const context = clearContext(state); const imageSize = resolveImageSize(sprite.image, sprite.imageWidth, sprite.imageHeight); if (!imageSize) { setClearExtent(state, 0, 0); return null; } const { drawWidth, drawHeight, intrinsic } = imageSize; const bounds = rotatedExtent(drawWidth, drawHeight, sprite.rotate); // Leave room on both sides of the integer raster origin for half-pixel // image edges and antialiasing after rotation or resizing. const rasterWidth = Math.ceil(bounds.width + 2); const rasterHeight = Math.ceil(bounds.height + 2); if (rasterWidth > SCRATCH_WIDTH || rasterHeight > SCRATCH_HEIGHT) { setClearExtent(state, 0, 0); return null; } try { drawAtRasterCenter(context, rasterWidth, rasterHeight, sprite.rotate, () => { context.drawImage(sprite.image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight); }); } catch (error) { // Without intrinsic dimensions, the source may be broken rather than // merely unsized, and browsers refuse to draw broken images. if (intrinsic) { throw error; } return null; } finally { // A failed draw must not leave pixels for the next sprite. setClearExtent(state, rasterWidth, rasterHeight); } const pixels = context.getImageData(0, 0, rasterWidth, rasterHeight).data; const alphaBounds = findAlphaBounds(pixels, rasterWidth, rasterHeight); if (!alphaBounds) { return null; } const raster = extractSpritePixels(pixels, rasterWidth, rasterHeight, alphaBounds); raster.imageWidth = drawWidth; raster.imageHeight = drawHeight; // Opaque image masks get a small envelope on every side, so they can be // placed beside the cloud without their pixels overlapping it. raster.envelope = { x0: raster.x0 - IMAGE_SLACK, y0: raster.y0 - IMAGE_SLACK, x1: raster.x1 + IMAGE_SLACK, y1: raster.y1 + IMAGE_SLACK }; return raster; } function extractSpritePixels(pixels, rasterWidth, rasterHeight, alphaBounds) { const width = alphaBounds.width; const height = alphaBounds.height; const spriteWidth = ((width + 31) >>> 5) << 5; const wordsPerRow = spriteWidth >>> 5; const x0 = alphaBounds.x0 - (rasterWidth >> 1); const y0 = alphaBounds.y0 - (rasterHeight >> 1); const data = new Uint32Array(wordsPerRow * height); for (let row = 0; row < height; row += 1) { const sourceRow = alphaBounds.y0 + row; const rowOffset = row * wordsPerRow; for (let column = 0; column < width; column += 1) { const sourceColumn = alphaBounds.x0 + column; if (pixels[((sourceRow * rasterWidth + sourceColumn) << 2) + 3]) { data[rowOffset + (column >>> 5)] |= 1 << (31 - (column & 31)); } } } return { data, spriteWidth, width, height, x0, y0, x1: x0 + width, y1: y0 + height }; } function resolveImageSize(image, imageWidth, imageHeight) { const sourceWidth = resolveImageDimension(image, "naturalWidth", "videoWidth", "width"); const sourceHeight = resolveImageDimension(image, "naturalHeight", "videoHeight", "height"); if (!(sourceWidth > 0) || !(sourceHeight > 0)) { // Images without intrinsic dimensions, such as SVGs with only a viewBox, // can still be drawn at an explicit size once they have loaded. if (imageWidth != null && imageHeight != null && typeof image.naturalWidth === "number" && image.complete === true) { return { drawWidth: imageWidth, drawHeight: imageHeight, intrinsic: false }; } return null; } let drawWidth = imageWidth; let drawHeight = imageHeight; if (drawWidth == null && drawHeight == null) { drawWidth = sourceWidth; drawHeight = sourceHeight; } else if (drawWidth == null) { drawWidth = Math.max(1, Math.round(sourceWidth * (drawHeight / sourceHeight))); } else if (drawHeight == null) { drawHeight = Math.max(1, Math.round(sourceHeight * (drawWidth / sourceWidth))); } return { drawWidth, drawHeight, intrinsic: true }; } function resolveImageDimension(image, ...keys) { for (const key of keys) { const value = image?.[key]; if (typeof value === "number") { // Intrinsic dimensions are authoritative, including zero for unloaded // or broken sources; display dimensions must not make them drawable. return value > 0 && Number.isFinite(value) ? Math.trunc(value) : 0; } } return 0; } function rotatedExtent(width, height, rotate) { if (!rotate) { return { width, height }; } const sine = Math.sin(rotate * RADIANS); const cosine = Math.cos(rotate * RADIANS); const widthCosine = width * cosine; const widthSine = width * sine; const heightCosine = height * cosine; const heightSine = height * sine; return { width: Math.max(Math.abs(widthCosine + heightSine), Math.abs(widthCosine - heightSine)), height: Math.max(Math.abs(widthSine + heightCosine), Math.abs(widthSine - heightCosine)) }; } function findAlphaBounds(pixels, width, height) { let minX = width; let minY = height; let maxX = -1; let maxY = -1; for (let row = 0; row < height; row += 1) { for (let column = 0; column < width; column += 1) { if (!pixels[((row * width + column) << 2) + 3]) { continue; } if (column < minX) minX = column; if (row < minY) minY = row; if (column > maxX) maxX = column; if (row > maxY) maxY = row; } } if (maxX < minX || maxY < minY) { return null; } return { x0: minX, y0: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; } function isTextSource(source) { return typeof source === "string" || typeof source === "number" || typeof source === "boolean" || source instanceof String; } function isImageSource(source) { return !!source && typeof source === "object" && !Array.isArray(source) && ( typeof source.width === "number" || typeof source.naturalWidth === "number" || typeof source.videoWidth === "number" ); }