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'); + }); });