Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions server/live-loan-router.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion server/util/live-loan/compact-card-constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions server/util/live-loan/live-loan-draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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':
Expand Down
17 changes: 11 additions & 6 deletions test/unit/specs/server/live-loan-router.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 },
]);
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
42 changes: 33 additions & 9 deletions test/unit/specs/server/util/live-loan/live-loan-draw.spec.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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];
Expand All @@ -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(),
Expand Down Expand Up @@ -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');
});
});
Loading