From f63515ed7cd4b626b0988948741f64cb66b82d2b Mon Sep 17 00:00:00 2001 From: Roger Gutierrez Date: Fri, 28 Aug 2026 12:50:37 -0600 Subject: [PATCH] feat(live-loan): render compact bundle card as transparent PNG The compact-bundle card was a JPEG baked onto a white margin, so its drop shadow only worked on a white email background. Dark-mode templates need a transparent margin instead. Render the compact card with an alpha channel: clear the canvas to transparent (no white fill) and export PNG, so the margin is see-through and the card + shadow composite onto whatever background the email uses. Content type is resolved per style via contentTypeForStyle so the response header is correct on both fresh draws and cache hits (the cache stores only the buffer). Other card styles are unchanged and still JPEG. Card sizing was verified against the design and already matches; no dimension changes were needed. --- server/live-loan-router.js | 4 +- .../util/live-loan/compact-card-constants.js | 2 +- server/util/live-loan/live-loan-draw.js | 23 +++++++--- .../specs/server/live-loan-router.spec.js | 17 +++++--- .../util/live-loan/live-loan-draw.spec.js | 42 +++++++++++++++---- 5 files changed, 64 insertions(+), 24 deletions(-) diff --git a/server/live-loan-router.js b/server/live-loan-router.js index 237357943b1..855d4989550 100644 --- a/server/live-loan-router.js +++ b/server/live-loan-router.js @@ -1,7 +1,7 @@ import express from 'express'; import { error, warn } from './util/log.js'; import { getFromCache, setToCache } from './util/memJsUtils.js'; -import drawLoanCard from './util/live-loan/live-loan-draw.js'; +import drawLoanCard, { contentTypeForStyle } from './util/live-loan/live-loan-draw.js'; import fetchLoansByType, { QUERY_TYPE } from './util/live-loan/live-loan-fetch.js'; import { trace } from './util/mockTrace.js'; import { resolveBundleSize, DEFAULT_BUNDLE_COUNT, MAX_BUNDLE_COUNT } from './util/live-loan/bundle-size.js'; @@ -193,7 +193,7 @@ async function serveImg(type, style, cache, req, res, queryType = QUERY_TYPE.DEF // Separate out sending response to isolate exception catching try { - res.contentType('image/jpeg'); + res.contentType(contentTypeForStyle(style)); res.set('Cache-Control', [ 'no-store, no-cache, must-revalidate, max-age=0, private', 'post-check=0, pre-check=0' diff --git a/server/util/live-loan/compact-card-constants.js b/server/util/live-loan/compact-card-constants.js index 466ad206db1..7b417f9202c 100644 --- a/server/util/live-loan/compact-card-constants.js +++ b/server/util/live-loan/compact-card-constants.js @@ -29,7 +29,7 @@ export const compactCardHeight = compactCardPadding // so their position stays put no matter how many lines the use text wraps to. export const compactBarY = compactCardHeight - compactCardPadding - compactBarHeight; export const compactToGoY = compactBarY - compactToGoBarGap - compactToGoLineHeight; -// Outer margin around the card, baked onto white, so the drop shadow has room +// Transparent outer margin around the card, giving the drop shadow room to spread export const compactCardMargin = 16; export const compactCardDimensions = { width: (compactCardWidth + (2 * compactCardMargin)) * compactResizeFactor, diff --git a/server/util/live-loan/live-loan-draw.js b/server/util/live-loan/live-loan-draw.js index 273439ccf48..87df59d5d1e 100644 --- a/server/util/live-loan/live-loan-draw.js +++ b/server/util/live-loan/live-loan-draw.js @@ -404,7 +404,9 @@ async function drawClassic(loanData, { skipButton = false } = {}) { async function drawCompact(loanData) { const canvas = trace('compactCanvasPool.use', () => compactCanvasPool.use()); - const ctx = trace('canvas.getContext', () => canvas.getContext('2d', { alpha: false })); + // Alpha channel kept so the margin stays transparent (PNG export) and the drop + // shadow composites over a dark email background instead of a baked-in white. + const ctx = trace('canvas.getContext', () => canvas.getContext('2d')); try { // Work in logical (unscaled) units; the pooled canvas is reused so reset @@ -414,11 +416,13 @@ async function drawCompact(loanData) { ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - // White background across the whole image (incl. the shadow margin) - ctx.fillStyle = compactColors.white; - ctx.fillRect(0, 0, compactCardWidth + (2 * compactCardMargin), compactCardHeight + (2 * compactCardMargin)); + // Clear to transparent (the pooled canvas holds the previous render) so the + // margin stays empty and the card + shadow composite onto the email background + const fullWidth = compactCardWidth + (2 * compactCardMargin); + const fullHeight = compactCardHeight + (2 * compactCardMargin); + ctx.clearRect(0, 0, fullWidth, fullHeight); - // Card with a subtle drop shadow, baked onto the white background + // Card with a subtle drop shadow over the transparent margin ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.08)'; ctx.shadowBlur = 12; @@ -526,7 +530,7 @@ async function drawCompact(loanData) { // Undo the card translate + clip so the pooled canvas is clean for reuse ctx.restore(); - const buffer = trace('export-jpeg', () => canvas.toBuffer('image/jpeg', { quality: 0.5 })); + const buffer = trace('export-png', () => canvas.toBuffer('image/png')); trace('compactCanvasPool.recycle', () => compactCanvasPool.recycle(canvas)); return { buffer, hasBorrowerImage }; } catch (e) { @@ -537,6 +541,13 @@ async function drawCompact(loanData) { } } +// The compact card exports PNG so its transparent margin + drop shadow survive +// on dark email backgrounds; every other style stays JPEG. The router reads this +// for the response header, including cache hits where the drawn buffer is gone. +export function contentTypeForStyle(style) { + return style === 'compact-bundle' ? 'image/png' : 'image/jpeg'; +} + export default async function draw(loanData, style) { switch (style) { case 'bundle': diff --git a/test/unit/specs/server/live-loan-router.spec.js b/test/unit/specs/server/live-loan-router.spec.js index 0e49a139dda..61f647cd085 100644 --- a/test/unit/specs/server/live-loan-router.spec.js +++ b/test/unit/specs/server/live-loan-router.spec.js @@ -9,7 +9,12 @@ import { generateGoogleFeed } from '#server/util/live-loan/ads/google-display/go // Mock out modules to prevent real network/cache calls vi.mock('#server/util/live-loan/live-loan-fetch'); vi.mock('#server/util/memJsUtils'); -vi.mock('#server/util/live-loan/live-loan-draw'); +// Keep the real contentTypeForStyle (the router uses it for the response header); +// only the heavy canvas render (default export) is mocked out. +vi.mock('#server/util/live-loan/live-loan-draw', async importOriginal => ({ + ...(await importOriginal()), + default: vi.fn(), +})); vi.mock('#server/util/live-loan/ads/google-display/google-feed'); vi.mock('#server/util/log', () => ({ log: vi.fn(), @@ -452,10 +457,10 @@ describe('live-loan-router bundle-url routes', () => { describe('serveImg - bundle-img-compact routes', () => { beforeEach(() => { - drawLoanCard.mockResolvedValue({ buffer: Buffer.from('jpeg-bytes'), hasBorrowerImage: true }); + drawLoanCard.mockResolvedValue({ buffer: Buffer.from('png-bytes'), hasBorrowerImage: true }); }); - it('serves jpeg for /u/:id/bundle-img-compact/:offset with compact-bundle style', async () => { + it('serves png for /u/:id/bundle-img-compact/:offset with compact-bundle style', async () => { liveLoanFetch.default.mockResolvedValue([ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, ]); @@ -464,7 +469,7 @@ describe('live-loan-router bundle-url routes', () => { const result = await makeRequest(app, '/live-loan/u/42/bundle-img-compact/1'); expect(result.statusCode).toBe(200); - expect(result.headers['content-type']).toBe('image/jpeg'); + expect(result.headers['content-type']).toBe('image/png'); expect(drawLoanCard).toHaveBeenCalledWith({ id: 1 }, 'compact-bundle'); expect(liveLoanFetch.default).toHaveBeenCalledWith( 'user', @@ -481,7 +486,7 @@ describe('live-loan-router bundle-url routes', () => { const result = await makeRequest(app, '/live-loan/flss/u/42/bundle-img-compact/2'); expect(result.statusCode).toBe(200); - expect(result.headers['content-type']).toBe('image/jpeg'); + expect(result.headers['content-type']).toBe('image/png'); expect(drawLoanCard).toHaveBeenCalledWith({ id: 22 }, 'compact-bundle'); expect(liveLoanFetch.default).toHaveBeenCalledWith( 'user', @@ -498,7 +503,7 @@ describe('live-loan-router bundle-url routes', () => { const result = await makeRequest(app, '/live-loan/recommendations/u/42/bundle-img-compact/1'); expect(result.statusCode).toBe(200); - expect(result.headers['content-type']).toBe('image/jpeg'); + expect(result.headers['content-type']).toBe('image/png'); expect(drawLoanCard).toHaveBeenCalledWith({ id: 33 }, 'compact-bundle'); expect(liveLoanFetch.default).toHaveBeenCalledWith( 'user', diff --git a/test/unit/specs/server/util/live-loan/live-loan-draw.spec.js b/test/unit/specs/server/util/live-loan/live-loan-draw.spec.js index 27ce8791426..5f15c46a216 100644 --- a/test/unit/specs/server/util/live-loan/live-loan-draw.spec.js +++ b/test/unit/specs/server/util/live-loan/live-loan-draw.spec.js @@ -1,6 +1,6 @@ // @vitest-environment node import { createCanvas, loadImage } from 'canvas'; -import draw, { compactCardDimensions } from '#server/util/live-loan/live-loan-draw'; +import draw, { compactCardDimensions, contentTypeForStyle } from '#server/util/live-loan/live-loan-draw'; import { compactColors } from '#server/util/live-loan/compact-card-constants'; import * as canvasImageUtils from '#server/util/live-loan/canvas-image-utils'; @@ -44,17 +44,22 @@ async function dimensionsOf(buffer) { return { width: img.width, height: img.height }; } +// Decodes an image buffer onto a canvas context so its pixels can be sampled +async function decodeToContext(buffer) { + const img = await loadImage(buffer); + const ctx = createCanvas(img.width, img.height).getContext('2d'); + ctx.drawImage(img, 0, 0); + return { ctx, width: img.width, height: img.height }; +} + // Finds the bottom-most device row containing the progress-track grey in a // 1px-wide strip on the right half of the bar, which locates the bar vertically. async function barBottomY(buffer) { - const img = await loadImage(buffer); - const canvas = createCanvas(img.width, img.height); - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); - const x = Math.round(img.width * SAMPLE_X_FRACTION); - const { data } = ctx.getImageData(x, 0, 1, img.height); + const { ctx, width, height } = await decodeToContext(buffer); + const x = Math.round(width * SAMPLE_X_FRACTION); + const { data } = ctx.getImageData(x, 0, 1, height); let lastTrackRow = -1; - for (let y = 0; y < img.height; y += 1) { + for (let y = 0; y < height; y += 1) { const r = data[(y * 4)]; const g = data[(y * 4) + 1]; const b = data[(y * 4) + 2]; @@ -76,14 +81,26 @@ describe('draw – compact-bundle style', () => { }); }); - it('renders the compact-bundle style as a compact-sized JPEG', async () => { + it('renders the compact-bundle style as a compact-sized PNG', async () => { const { buffer, hasBorrowerImage } = await draw(makeLoan(), 'compact-bundle'); expect(Buffer.isBuffer(buffer)).toBe(true); expect(hasBorrowerImage).toBe(true); + // PNG signature bytes, so the card can carry an alpha channel + expect(buffer.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); expect(await dimensionsOf(buffer)).toEqual(compactCardDimensions); }); + it('renders a transparent margin around the card so it composites on any background', async () => { + const { buffer } = await draw(makeLoan(), 'compact-bundle'); + + const { ctx } = await decodeToContext(buffer); + // The outer corner sits in the margin outside the card, so it must be fully transparent + const cornerAlpha = ctx.getImageData(0, 0, 1, 1).data[3]; + + expect(cornerAlpha).toBe(0); + }); + it('passes hasBorrowerImage through when the borrower photo is missing', async () => { canvasImageUtils.loadBorrowerImage.mockResolvedValue({ image: fakeBorrowerImage(), @@ -128,4 +145,11 @@ describe('draw – compact-bundle style', () => { const dims = await dimensionsOf(buffer); expect(dims).not.toEqual(compactCardDimensions); }); + + it('maps only the compact-bundle style to PNG, other styles to JPEG', () => { + expect(contentTypeForStyle('compact-bundle')).toBe('image/png'); + expect(contentTypeForStyle('bundle')).toBe('image/jpeg'); + expect(contentTypeForStyle('classic')).toBe('image/jpeg'); + expect(contentTypeForStyle('legacy')).toBe('image/jpeg'); + }); });