{"record":{"id":"5c3c469b085962e6","repo":"chenglou/pretext","slug":"text-measurement-requires-offscreencanvas-or-a-dom","errorCode":null,"errorMessage":"Text measurement requires OffscreenCanvas or a DOM canvas context.","messagePattern":"Text measurement requires OffscreenCanvas or a DOM canvas context\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"src/measurement.ts","lineNumber":48,"sourceCode":"const emojiPresentationRe = /\\p{Emoji_Presentation}/u\nconst maybeEmojiRe = /[\\p{Emoji_Presentation}\\p{Extended_Pictographic}\\p{Regional_Indicator}\\uFE0F\\u20E3]/u\nlet sharedGraphemeSegmenter: Intl.Segmenter | null = null\nconst emojiCorrectionCache = new Map<string, number>()\n\nexport function getMeasureContext(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D {\n  if (measureContext !== null) return measureContext\n\n  if (typeof OffscreenCanvas !== 'undefined') {\n    measureContext = new OffscreenCanvas(1, 1).getContext('2d')!\n    return measureContext\n  }\n\n  if (typeof document !== 'undefined') {\n    measureContext = document.createElement('canvas').getContext('2d')!\n    return measureContext\n  }\n\n  throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')\n}\n\nexport function getSegmentMetricCache(font: string): Map<string, SegmentMetrics> {\n  let cache = segmentMetricCaches.get(font)\n  if (!cache) {\n    cache = new Map()\n    segmentMetricCaches.set(font, cache)\n  }\n  return cache\n}\n\nexport function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {\n  let metrics = cache.get(seg)\n  if (metrics === undefined) {\n    const ctx = getMeasureContext()\n    metrics = {\n      width: ctx.measureText(seg).width,\n      containsCJK: isCJK(seg),","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/chenglou/pretext/blob/ac49b09b7d83ede19581fa94a8b892b07d309baf/src/measurement.ts#L30-L66","documentation":"The only published-library error in this set. getMeasureContext lazily builds the 2D canvas context that prepare()/prepareWithSegments() use to measure segment widths (via getSegmentMetrics and other internal callers at measurement.ts:63,135,260). It tries OffscreenCanvas first, then document.createElement('canvas').getContext('2d'), and throws if neither global exists. So the library cannot measure text in an environment with no canvas at all.","triggerScenarios":"Calling prepare() or prepareWithSegments() in plain Node/Bun (no DOM, no OffscreenCanvas); a worker/runtime (older workerd, jsdom without the canvas package) where OffscreenCanvas is undefined and document is absent or lacks canvas; SSR that imports the library and calls prepare during render.","commonSituations":"Server-side height precomputation; tests in jsdom (jsdom's canvas returns null for getContext('2d') unless the canvas package is installed); bundlers that guard globals; React/Next SSR importing the module and calling prepare at module-eval time.","solutions":["Polyfill OffscreenCanvas before calling prepare(): install node-canvas (npm i canvas) or @napi-rs/canvas, then assign globalThis.OffscreenCanvas to a class whose getContext('2d') returns the node-canvas 2D context.","Run the measurement in a browser or web worker that has OffscreenCanvas, instead of Node.","Guard the call site: only invoke prepare() when typeof OffscreenCanvas !== 'undefined' || typeof document !== 'undefined'.","For SSR, defer prepare() to the client, or precompute prepared handles in a build step that has canvas, then ship them to layout() (which is pure arithmetic)."],"exampleFix":"// before — runs in Node, throws\nimport { prepare, layout } from '@chenglou/pretext'\nconst p = prepare('hello world', '16px Inter')\n\n// after — provide an OffscreenCanvas via node-canvas before importing the library\nimport { createCanvas } from 'canvas'\nclass OffscreenCanvasPolyfill {\n  #ctx\n  constructor(w = 1, h = 1) { this.#ctx = createCanvas(w, h).getContext('2d') }\n  getContext() { return this.#ctx }\n}\n;(globalThis as any).OffscreenCanvas = OffscreenCanvasPolyfill\nimport { prepare, layout } from '@chenglou/pretext'\nconst p = prepare('hello world', '16px Inter')","handlingStrategy":"validation","validationCode":"const canMeasure =\n  typeof OffscreenCanvas !== 'undefined' ||\n  (typeof document !== 'undefined' && !!document.createElement('canvas').getContext('2d'))\nif (!canMeasure) {\n  throw new Error('Pretext prepare() needs OffscreenCanvas or a DOM canvas in this environment; install node-canvas or run in a browser/worker.')\n}","typeGuard":"const hasCanvas = (): boolean =>\n  typeof OffscreenCanvas !== 'undefined' || typeof document !== 'undefined'","tryCatchPattern":"try {\n  return prepare(text, font, opts)\n} catch (e) {\n  if (e instanceof Error && /Text measurement requires/.test(e.message)) {\n    // Either install an OffscreenCanvas polyfill (node-canvas / @napi-rs/canvas)\n    // or skip prepare() on the server and only use layout() over precomputed handles.\n  }\n  throw e\n}","preventionTips":["Install a canvas polyfill (node-canvas or @napi-rs/canvas) and assign globalThis.OffscreenCanvas before any prepare() call in Node.","Never call prepare() at module-eval time; defer it to a runtime path that has canvas.","Gate SSR imports so prepare() only runs client-side, or precompute prepared handles in a build step that has canvas.","Prefer OffscreenCanvas-capable runtimes (modern browsers, web workers, Chromium) for server-side measurement."],"tags":["ssr","environment","canvas","offscreen-canvas","node","browser"],"backgroundTag":null,"analyzedSha":"ac49b09b7d83ede19581fa94a8b892b07d309baf","analyzedAt":"2026-08-12T17:03:16.263Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}