From 2fb136a29d4214780e98f2ca6b63a696606f1cac Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 20:21:55 +0000 Subject: [PATCH] renderText: the outline doesn't show through translucent text The stroke covers the glyphs as well (it's a distance field from them), and was drawn under them with one minus the fill's alpha: with a translucent text color it showed through. It's now masked by the glyphs' coverage (text and color glyphs), which is the same for opaque text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 5 +++++ src/image.ts | 8 +++++--- tests/regressions.js | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6672e8b2..e2f93e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ 82 instead of 95 ms), with identical output: the predictors run as one loop per mode instead of a switch per pixel, residual costs come from a table, and the LZ77 search hashes every pixel pair once. +### Fixes + +- The outline of `Image.renderText` (`strokeWidth`) showed through translucent text, it now only shows around the + glyphs (opaque text renders as before). + ## 3.2.1 ### Performance diff --git a/src/image.ts b/src/image.ts index aa403ddc..d9ebc6f9 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2095,14 +2095,14 @@ export class Image { // the outline goes around the color glyphs as well let shape = coverage; - if (colored && strokeWidth) { + if (colored) { shape = coverage.slice(); for (let i = 0; i < shape.length; i++) shape[i] = Math.max(shape[i], colored[i * 4 + 3]); } const stroke = strokeWidth ? outline(shape, width, height, strokeWidth) : null; const [sr, sg, sb, sa] = Image.colorToRGBA(strokeColor); - // color glyphs over the text (premultiplied, with the text's alpha), both over their outline + // color glyphs over the text (premultiplied, with the text's alpha), both within their outline for (let i = 0; i < coverage.length; i++) { let af = coverage[i] / 255 * a / 255, pr = r * af, pg = g * af, pb = b * af; if (colored && colored[i * 4 + 3]) { @@ -2113,7 +2113,9 @@ export class Image { af = ca + af * keep; } if (stroke) { - const as = stroke[i] * sa / 255 * (1 - af); + // the outline shows around the glyphs only, not through translucent ones (for opaque text it's the + // same as drawing the glyphs over it) + const as = stroke[i] * sa / 255 * (1 - shape[i] / 255); pr += sr * as; pg += sg * as; pb += sb * as; diff --git a/tests/regressions.js b/tests/regressions.js index 8bcb7226..f0b5660d 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -457,6 +457,18 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); if (color === 0xffffffff) white++; } assert(red > 100 && white > 100, `outlined text should contain both colors (${red} red, ${white} white)`); + + // the stroke goes around translucent text, it doesn't show through it + const translucent = await Image.renderText(font, 40, 'Hi', 0x0000ff80, undefined, {strokeWidth: 3, strokeColor: 0xff0000ff}); + const fill = await Image.renderText(font, 40, 'Hi', 0x0000ff80); + let inside = 0, tinted = 0; + // pixels the glyphs cover fully (the stroked image is 3 pixels larger on every side) + for (const [x, y, color] of fill.iterateWithColors()) { + if (color !== 0x0000ff80) continue; + inside++; + if (translucent.getPixelAt(x + 3, y + 3) !== 0x0000ff80) tinted++; + } + assert(inside > 100 && !tinted, `translucent text should show no stroke inside (${tinted} of ${inside} pixels tinted)`); } // PNGs are oriented by their eXIf chunk, ImageMagick's Exif text or XMP, in that order