From a5d7930f87188d2d88e323ff5c02f1e8c1457d90 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:07:29 -0500 Subject: [PATCH 1/8] feat: derive sleep image metrics from account reading stats --- src/models/reading-goals.ts | 10 ++++- src/models/sleep-image.ts | 78 +++++++++++++++++++++++++++++++++++++ test/sleep-image.test.ts | 49 +++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/models/sleep-image.ts create mode 100644 test/sleep-image.test.ts diff --git a/src/models/reading-goals.ts b/src/models/reading-goals.ts index 3d05d75..ef58425 100644 --- a/src/models/reading-goals.ts +++ b/src/models/reading-goals.ts @@ -15,6 +15,14 @@ export function readingGoals(db: DB, userId: number, tz: number) { const year = Number(today.slice(0, 4)); const targets = db.prepare('SELECT year, target FROM reading_goals WHERE user_id=? ORDER BY year').all(userId) as { year: number; target: number }[]; if (!targets.length) return { year, goals: [] }; + const { counts, recent_finished } = readingYearSummary(db, userId, tz); + return { year, goals: targets.map(g => ({ ...g, completed: counts.get(g.year) ?? 0 })), recent_finished }; +} + +/** Shared canonical completion calendar, including books with only uploaded stats. */ +export function readingYearSummary(db: DB, userId: number, tz: number) { + const today = new Date(Date.now() - tz * 60000).toISOString().slice(0, 10); + const year = Number(today.slice(0, 4)); const aliases = new Set([...aliasesByDocument(db, userId).values()].flat()); const counts = new Map(); const recent: { document: string; at: number }[] = []; @@ -31,5 +39,5 @@ export function readingGoals(db: DB, userId: number, tz: number) { const info = title.get(userId, b.document); return { document: b.document, title: String(info?.title || info?.filename || 'Untitled book') }; }); - return { year, goals: targets.map(g => ({ ...g, completed: counts.get(g.year) ?? 0 })), recent_finished }; + return { year, counts, recent_finished }; } diff --git a/src/models/sleep-image.ts b/src/models/sleep-image.ts new file mode 100644 index 0000000..359382b --- /dev/null +++ b/src/models/sleep-image.ts @@ -0,0 +1,78 @@ +import type { DB } from '../db/db.js'; +import { readingActivity } from './activity.js'; +import { readingYearSummary } from './reading-goals.js'; +import { combineReadingStreak } from './reading-streak.js'; +import { readBookorbitStats } from './bookorbit-stats.js'; +import type { GlobalStatsSnapshot } from './stats.js'; + +export class SleepImageError extends Error {} +export const MAX_SLEEP_PIXELS = 2_000_000; +export const MAX_BACKGROUND_BYTES = 8 * 1024 * 1024; +export const SLEEP_METRICS = ['books', 'goal', 'current', 'streak'] as const; +export type SleepMetric = typeof SLEEP_METRICS[number]; +export interface SleepOptions { + width: number; + height: number; + mode: 'overlay' | 'custom'; + placement: 'top' | 'bottom'; + timezone: string; + metrics: SleepMetric[]; +} +export interface SleepMetrics { + year: number; + finished: number; + target: number | null; + currentPercent: number | null; + streak: number; +} + +/** JS getTimezoneOffset sign, recalculated at render time for seasonal changes. */ +export function timezoneOffset(timezone: string, date = new Date()): number { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }).formatToParts(date); + const get = (key: string) => Number(parts.find(p => p.type === key)!.value); + const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')); + return Math.round((Math.floor(date.getTime() / 1000) * 1000 - local) / 60000); + } catch { throw new SleepImageError('Choose a valid IANA timezone.'); } +} + +export function parseSleepOptions(query: Record): SleepOptions { + const dimension = (raw: string | undefined, fallback: number) => { + const n = raw === undefined ? fallback : /^\d+$/.test(raw) ? Number(raw) : NaN; + if (!Number.isInteger(n) || n < 240 || n > 1600) throw new SleepImageError('Dimensions must be integers from 240 to 1600.'); + return n; + }; + const width = dimension(query.width, 480), height = dimension(query.height, 800); + if (width * height > MAX_SLEEP_PIXELS) throw new SleepImageError('Image exceeds 2,000,000 pixels.'); + const mode = query.mode ?? 'overlay', placement = query.placement ?? 'bottom'; + if (mode !== 'overlay' && mode !== 'custom') throw new SleepImageError('Choose overlay or custom mode.'); + if (placement !== 'top' && placement !== 'bottom') throw new SleepImageError('Choose top or bottom placement.'); + const metrics = [...new Set((query.metrics ?? SLEEP_METRICS.join(',')).split(','))]; + if (!metrics.length || metrics.some(m => !SLEEP_METRICS.includes(m as SleepMetric))) throw new SleepImageError('Choose supported metrics.'); + const timezone = query.timezone ?? 'UTC'; + if (timezone.length > 100) throw new SleepImageError('Invalid timezone.'); + timezoneOffset(timezone); + return { width, height, mode, placement, timezone, metrics: metrics as SleepMetric[] }; +} + +export function sleepMetrics(db: DB, userId: number, timezone: string, percent?: string): SleepMetrics { + const tz = timezoneOffset(timezone); + const { year, counts } = readingYearSummary(db, userId, tz); + const goal = db.prepare('SELECT target FROM reading_goals WHERE user_id=? AND year=?').get(userId, year); + const activity = readingActivity(db, userId, tz); + const snapshots = db.prepare('SELECT payload FROM stats_device_global WHERE user_id=?').all(userId) + .map(r => JSON.parse(r.payload as string) as GlobalStatsSnapshot); + const imported = readBookorbitStats(db, userId); + if (imported.global.sessions) snapshots.push(imported.global); + const daily = db.prepare('SELECT DISTINCT date FROM stats_device_day WHERE user_id=? AND seconds>0').all(userId).map(r => r.date as string); + daily.push(...imported.daily.filter(d => d.seconds > 0).map(d => d.date)); + const n = percent !== undefined && /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(percent) ? Number(percent) : NaN; + return { + year, finished: counts.get(year) ?? 0, target: goal ? Number(goal.target) : null, + currentPercent: Number.isFinite(n) && n >= 0 && n <= 100 ? n : null, + streak: combineReadingStreak(snapshots, [...activity.reading_days, ...daily], tz).current_streak, + }; +} diff --git a/test/sleep-image.test.ts b/test/sleep-image.test.ts new file mode 100644 index 0000000..cc5766c --- /dev/null +++ b/test/sleep-image.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { makeTestApp, registerUser, DOC } from './helpers.js'; +import { parseSleepOptions, sleepMetrics, timezoneOffset } from '../src/models/sleep-image.js'; + +afterEach(() => vi.useRealTimers()); + +describe('sleep image data', () => { + it('uses the account goal and canonical completion dates without writing progress', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2027-01-01T02:00:00Z')); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,1,?)') + .run(id, DOC, 'reader', Date.parse('2027-01-01T01:00:00Z') / 1000); + const before = s.db.prepare('SELECT COUNT(*) AS n FROM progress_log').get()!.n; + expect(sleepMetrics(s.db, id, 'America/New_York', '42')).toMatchObject({ year: 2026, finished: 1, target: null, currentPercent: 42, streak: 1 }); + s.db.prepare('INSERT INTO reading_goals (user_id,year,target) VALUES (?,?,?)').run(id, 2026, 1); + expect(sleepMetrics(s.db, id, 'America/New_York', '100')).toMatchObject({ year: 2026, finished: 1, target: 1, currentPercent: 100 }); + expect(sleepMetrics(s.db, id, 'UTC', undefined)).toMatchObject({ year: 2027, finished: 1, target: null, currentPercent: null }); + expect(s.db.prepare('SELECT COUNT(*) AS n FROM progress_log').get()!.n).toBe(before); + } finally { s.db.close(); } + }); + + it.each([undefined, '', '{event.percent}', '-1', '101', 'NaN', 'Infinity', '4x'])('omits unavailable percentage %s', async value => { + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + expect(sleepMetrics(s.db, id, 'UTC', value).currentPercent).toBeNull(); + } finally { s.db.close(); } + }); + + it('uses seasonal timezone offsets and rejects invalid zones', () => { + expect(timezoneOffset('America/New_York', new Date('2026-07-01T12:00Z'))).toBe(240); + expect(timezoneOffset('America/New_York', new Date('2026-12-01T12:00Z'))).toBe(300); + expect(() => timezoneOffset('Imaginary/Place')).toThrow(/timezone/i); + }); + + it('bounds output and accepts all supported metric selections', () => { + expect(parseSleepOptions({ width: '1200', height: '1600', mode: 'custom', metrics: 'books,goal,current,streak', placement: 'top', timezone: 'UTC' })) + .toMatchObject({ width: 1200, height: 1600, mode: 'custom', placement: 'top' }); + for (const query of [{ width: '239' }, { width: '1601' }, { width: '1600', height: '1600' }, { height: 'NaN' }, { mode: 'cover' }, { metrics: 'secrets' }, { placement: 'middle' }, { timezone: 'bad' }]) { + expect(() => parseSleepOptions(query)).toThrow(); + } + expect(() => parseSleepOptions({ metrics: '' })).toThrow(); + }); +}); From 9c10c98571e318199103a2a7c9b83c72163e590f Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:09:29 -0500 Subject: [PATCH 2/8] feat: render compact e-paper sleep panels and wallpapers --- package-lock.json | 60 +++++++------------ package.json | 2 + src/render/sleep-font.ts | 25 ++++++++ src/render/sleep-image.ts | 102 ++++++++++++++++++++++++++++++++ test/sleep-image-render.test.ts | 58 ++++++++++++++++++ 5 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 src/render/sleep-font.ts create mode 100644 src/render/sleep-image.ts create mode 100644 test/sleep-image-render.test.ts diff --git a/package-lock.json b/package-lock.json index d2a19d5..e8aa67e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "@hono/node-server": "^1.13.7", "@xmldom/xmldom": "^0.9.12", "hono": "^4.6.16", + "pngjs": "^7.0.0", "yauzl": "^3.4.0" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/pngjs": "^6.0.5", "@types/yauzl": "^3.4.0", "tsx": "^4.19.2", "typescript": "^5.7.3", @@ -578,9 +580,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -595,9 +594,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -612,9 +608,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -629,9 +622,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -646,9 +636,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -663,9 +650,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -680,9 +664,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -697,9 +678,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -714,9 +692,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -731,9 +706,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -748,9 +720,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -765,9 +734,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -782,9 +748,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -910,6 +873,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", @@ -1323,6 +1296,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.18", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", diff --git a/package.json b/package.json index fcbe713..521d15c 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@hono/node-server": "^1.13.7", "@xmldom/xmldom": "^0.9.12", "hono": "^4.6.16", + "pngjs": "^7.0.0", "yauzl": "^3.4.0" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/pngjs": "^6.0.5", "@types/yauzl": "^3.4.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/src/render/sleep-font.ts b/src/render/sleep-font.ts new file mode 100644 index 0000000..f8ee9ba --- /dev/null +++ b/src/render/sleep-font.ts @@ -0,0 +1,25 @@ +// Original 5x7 bitmap glyphs; deliberately small and deterministic on e-paper. +const patterns: Record = { + A:'01110/10001/10001/11111/10001/10001/10001', B:'11110/10001/10001/11110/10001/10001/11110', + C:'01111/10000/10000/10000/10000/10000/01111', D:'11110/10001/10001/10001/10001/10001/11110', + E:'11111/10000/10000/11110/10000/10000/11111', F:'11111/10000/10000/11110/10000/10000/10000', + G:'01111/10000/10000/10111/10001/10001/01111', H:'10001/10001/10001/11111/10001/10001/10001', + I:'11111/00100/00100/00100/00100/00100/11111', J:'00111/00010/00010/00010/10010/10010/01100', + K:'10001/10010/10100/11000/10100/10010/10001', L:'10000/10000/10000/10000/10000/10000/11111', + M:'10001/11011/10101/10101/10001/10001/10001', N:'10001/11001/10101/10011/10001/10001/10001', + O:'01110/10001/10001/10001/10001/10001/01110', P:'11110/10001/10001/11110/10000/10000/10000', + Q:'01110/10001/10001/10001/10101/10010/01101', R:'11110/10001/10001/11110/10100/10010/10001', + S:'01111/10000/10000/01110/00001/00001/11110', T:'11111/00100/00100/00100/00100/00100/00100', + U:'10001/10001/10001/10001/10001/10001/01110', V:'10001/10001/10001/10001/10001/01010/00100', + W:'10001/10001/10001/10101/10101/10101/01010', X:'10001/10001/01010/00100/01010/10001/10001', + Y:'10001/10001/01010/00100/00100/00100/00100', Z:'11111/00001/00010/00100/01000/10000/11111', + '0':'01110/10001/10011/10101/11001/10001/01110', '1':'00100/01100/00100/00100/00100/00100/01110', + '2':'01110/10001/00001/00010/00100/01000/11111', '3':'11110/00001/00001/01110/00001/00001/11110', + '4':'00010/00110/01010/10010/11111/00010/00010', '5':'11111/10000/10000/11110/00001/00001/11110', + '6':'01110/10000/10000/11110/10001/10001/01110', '7':'11111/00001/00010/00100/01000/01000/01000', + '8':'01110/10001/10001/01110/10001/10001/01110', '9':'01110/10001/10001/01111/00001/00001/01110', + '/':'00001/00010/00010/00100/01000/01000/10000', '%':'11001/11010/00010/00100/01000/01011/10011', + '!':'00100/00100/00100/00100/00100/00000/00100', '-':'00000/00000/00000/11111/00000/00000/00000', + '.':'00000/00000/00000/00000/00000/00110/00110', '*':'00100/10101/01110/11111/01110/10101/00100', +}; +export const sleepGlyph = (character: string): string[] => (patterns[character] ?? '00000/00000/00000/00000/00000/00000/00000').split('/'); diff --git a/src/render/sleep-image.ts b/src/render/sleep-image.ts new file mode 100644 index 0000000..57f7e27 --- /dev/null +++ b/src/render/sleep-image.ts @@ -0,0 +1,102 @@ +import { PNG } from 'pngjs'; +import { MAX_BACKGROUND_BYTES, MAX_SLEEP_PIXELS, SleepImageError, type SleepMetrics, type SleepOptions } from '../models/sleep-image.js'; +import { sleepGlyph } from './sleep-font.js'; + +export function normalizeBackground(bytes: Buffer): Buffer { + if (bytes.length > MAX_BACKGROUND_BYTES) throw new SleepImageError('Background exceeds 8 MB.'); + if (bytes.length < 33 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) || bytes.toString('ascii', 12, 16) !== 'IHDR') { + throw new SleepImageError('Upload a PNG background.'); + } + const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); + if (!width || !height || width > 1600 || height > 1600 || width * height > MAX_SLEEP_PIXELS) throw new SleepImageError('Background dimensions exceed the pixel limit.'); + try { return PNG.sync.write(PNG.sync.read(bytes, { checkCRC: true }), { colorType: 6 }); } + catch { throw new SleepImageError('Invalid PNG background.'); } +} + +type PanelLine = { text: string; fraction?: number; large?: boolean }; +export function panelLines(stats: SleepMetrics, options: SleepOptions): PanelLine[] { + const lines: PanelLine[] = []; + if (options.metrics.includes('books')) lines.push({ text: `${stats.finished} BOOK${stats.finished === 1 ? '' : 'S'} IN ${stats.year}`, large: true }); + if (options.metrics.includes('goal') && stats.target !== null) { + lines.push({ text: stats.finished >= stats.target ? '* GOAL REACHED! *' : 'YOUR READING GOAL' }); + lines.push({ text: `${stats.finished} / ${stats.target} BOOKS`, fraction: Math.min(1, stats.finished / stats.target) }); + } + if (options.metrics.includes('current') && stats.currentPercent !== null) lines.push({ text: `CURRENT BOOK ${Math.floor(stats.currentPercent)}%`, fraction: stats.currentPercent / 100 }); + if (options.metrics.includes('streak')) lines.push({ text: `${stats.streak} DAY${stats.streak === 1 ? '' : 'S'} READING STREAK` }); + return lines; +} + +export function renderSleepImage(stats: SleepMetrics, options: SleepOptions, background?: Buffer): { bytes: Buffer; contentType: 'image/png' | 'image/bmp' } { + const { width, height } = options; + const png = new PNG({ width, height }); + const pixel = (x: number, y: number, value: number, alpha = 255) => { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const p = (y * width + x) * 4; + png.data[p] = png.data[p + 1] = png.data[p + 2] = value; + png.data[p + 3] = alpha; + }; + const rect = (x: number, y: number, w: number, h: number, value: number) => { + for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) pixel(xx, yy, value); + }; + if (options.mode === 'custom') { + png.data.fill(255); + if (background) { + const source = PNG.sync.read(background); + const scale = Math.max(width / source.width, height / source.height); + const left = (source.width - width / scale) / 2, top = (source.height - height / scale) / 2; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const p = (Math.min(source.height - 1, Math.floor(top + y / scale)) * source.width + Math.min(source.width - 1, Math.floor(left + x / scale))) * 4; + const alpha = source.data[p + 3] / 255; + const gray = Math.round((source.data[p] * .299 + source.data[p + 1] * .587 + source.data[p + 2] * .114) * alpha + 255 * (1 - alpha)); + pixel(x, y, gray); + } + } + } + const lines = panelLines(stats, options); + if (lines.length) { + const scale = Math.max(1, Math.floor(width / 240)); + const margin = 8 * scale, padding = 10 * scale, lineHeight = 16 * scale; + const panelHeight = padding * 2 + lines.reduce((sum, l) => sum + lineHeight + (l.fraction !== undefined ? 8 * scale : 0), 0); + const y0 = options.placement === 'top' ? margin : height - margin - panelHeight; + rect(margin, y0, width - margin * 2, panelHeight, 0); + rect(margin + scale, y0 + scale, width - margin * 2 - 2 * scale, panelHeight - 2 * scale, 255); + let y = y0 + padding; + for (const line of lines) { + const available = width - 2 * (margin + padding); + const fontScale = Math.max(1, Math.min(line.large ? scale + 1 : scale, Math.floor(available / (line.text.length * 6)))); + let x = margin + padding; + for (const ch of line.text) { + sleepGlyph(ch).forEach((row, yy) => [...row].forEach((bit, xx) => { + if (bit === '1') rect(x + xx * fontScale, y + yy * fontScale, fontScale, fontScale, 0); + })); + x += 6 * fontScale; + } + y += lineHeight; + if (line.fraction !== undefined) { + const barWidth = available, barHeight = 4 * scale; + rect(margin + padding, y - 3 * scale, barWidth, barHeight, 0); + rect(margin + padding + scale, y - 2 * scale, barWidth - scale * 2, barHeight - scale * 2, 255); + rect(margin + padding, y - 3 * scale, Math.round(barWidth * line.fraction), barHeight, 0); + y += 8 * scale; + } + } + } + const bytes = options.mode === 'overlay' ? PNG.sync.write(png, { colorType: 6 }) : grayBmp(png); + if (bytes.length >= 1024 * 1024) throw new SleepImageError('Rendered image exceeds the reader download limit.'); + return { bytes, contentType: options.mode === 'overlay' ? 'image/png' : 'image/bmp' }; +} + +function grayBmp(png: PNG): Buffer { + const stride = Math.ceil(png.width / 8) * 4; + const offset = 54 + 16 * 4; + const out = Buffer.alloc(offset + stride * png.height); + out.write('BM'); out.writeUInt32LE(out.length, 2); out.writeUInt32LE(offset, 10); + out.writeUInt32LE(40, 14); out.writeInt32LE(png.width, 18); out.writeInt32LE(png.height, 22); + out.writeUInt16LE(1, 26); out.writeUInt16LE(4, 28); out.writeUInt32LE(stride * png.height, 34); out.writeUInt32LE(16, 46); + for (let i = 0; i < 16; i++) out.fill(i * 17, 54 + i * 4, 54 + i * 4 + 3); + for (let y = 0; y < png.height; y++) for (let x = 0; x < png.width; x++) { + const value = Math.round(png.data[(y * png.width + x) * 4] / 17); + out[offset + (png.height - y - 1) * stride + (x >> 1)] |= value << (x % 2 ? 0 : 4); + } + return out; +} diff --git a/test/sleep-image-render.test.ts b/test/sleep-image-render.test.ts new file mode 100644 index 0000000..2b1e425 --- /dev/null +++ b/test/sleep-image-render.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { PNG } from 'pngjs'; +import { normalizeBackground, panelLines, renderSleepImage } from '../src/render/sleep-image.js'; +import { parseSleepOptions } from '../src/models/sleep-image.js'; + +const stats = { year: 2026, finished: 12, target: 24, currentPercent: 42, streak: 3 }; +describe('sleep image rendering', () => { + it('keeps the cover visible outside the readable panel', () => { + const result = renderSleepImage(stats, parseSleepOptions({})); + const png = PNG.sync.read(result.bytes); + expect(result.contentType).toBe('image/png'); + expect([png.width, png.height]).toEqual([480, 800]); + expect(png.data[3]).toBe(0); + const alpha = (x: number, y: number) => png.data[(y * png.width + x) * 4 + 3]; + expect(alpha(30, 770)).toBe(255); + const top = PNG.sync.read(renderSleepImage(stats, parseSleepOptions({ placement: 'top' })).bytes); + expect(top.data[(30 * 480 + 30) * 4 + 3]).toBe(255); + expect(top.data[(770 * 480 + 30) * 4 + 3]).toBe(0); + }); + + it('celebrates reached and exceeded goals and hides absent values', () => { + const options = parseSleepOptions({}); + expect(panelLines(stats, options).map(l => l.text).join(' ')).toContain('12 / 24'); + expect(panelLines({ ...stats, target: 12 }, options).some(l => l.text.includes('GOAL REACHED'))).toBe(true); + expect(panelLines({ ...stats, target: 10 }, options).map(l => l.text).join(' ')).toContain('12 / 10'); + const absent = panelLines({ ...stats, target: null, currentPercent: null }, options).map(l => l.text).join(' '); + expect(absent).not.toMatch(/GOAL|CURRENT/); + expect(panelLines(stats, parseSleepOptions({ metrics: 'streak' })).map(l => l.text).join(' ')).not.toMatch(/BOOKS|CURRENT|GOAL/); + }); + + it('encodes padded 4-bit BMPs below the firmware cap even with noisy backgrounds', () => { + const background = new PNG({ width: 480, height: 800 }); + for (let i = 0; i < background.data.length; i += 4) { + const n = ((i * 1664525 + (i >>> 5) * 1013904223) >>> 8) & 255; + background.data.set([n, n, n, 255], i); + } + for (const [width, height] of [[481, 800], [1200, 1600]]) { + const result = renderSleepImage(stats, parseSleepOptions({ width: String(width), height: String(height), mode: 'custom' }), PNG.sync.write(background)); + expect(result.contentType).toBe('image/bmp'); + expect(result.bytes.toString('ascii', 0, 2)).toBe('BM'); + expect(result.bytes.readUInt32LE(18)).toBe(width); + expect(result.bytes.readUInt32LE(22)).toBe(height); + expect(result.bytes.readUInt16LE(28)).toBe(4); + expect(result.bytes.length).toBe(118 + Math.ceil(width / 8) * 4 * height); + expect(result.bytes.length).toBeLessThan(1048576); + expect(result.bytes.readUInt32LE(2)).toBe(result.bytes.length); + } + }); + + it('rejects invalid and oversized decoded backgrounds before allocation', () => { + expect(() => normalizeBackground(Buffer.from('not PNG'))).toThrow(/PNG/i); + const large = PNG.sync.write(new PNG({ width: 1, height: 1 })); + large.writeUInt32BE(100000, 16); + expect(() => normalizeBackground(large)).toThrow(/dimension|pixel/i); + const valid = PNG.sync.write(new PNG({ width: 240, height: 240 })); + expect(PNG.sync.read(normalizeBackground(valid)).width).toBe(240); + }); +}); From 3a6bdfdd4ad34a4496c1909648e591c95dd6106f Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:11:48 -0500 Subject: [PATCH 3/8] feat: serve authenticated sleep images and account backgrounds --- docs/API.md | 14 +++++++ migrations/0049_sleep_backgrounds.sql | 4 ++ src/app.ts | 4 +- src/routes/account.ts | 1 + src/routes/v1/sleep-image.ts | 53 +++++++++++++++++++++++++++ test/sleep-image.test.ts | 44 ++++++++++++++++++++++ 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 migrations/0049_sleep_backgrounds.sql create mode 100644 src/routes/v1/sleep-image.ts diff --git a/docs/API.md b/docs/API.md index c7f82ad..4569ef1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -985,3 +985,17 @@ progress. These rows have `history_only: true`, null `percentage`, `progress`, or metadata. Pagination works across current and historical books. The app uses this option for its Library. Without this option, the endpoint continues to return only synced progress; the KOReader sync and progress change endpoints are unchanged. + +### Sleep-image plugin support + +Authenticated with the usual session or `x-auth-user` / `x-auth-key` headers. +These routes require a server version including migration `0049_sleep_backgrounds.sql`. + +- `GET /api/v1/stats/sleep-image/status` → `{ "version": 1, "background": false }`. +- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full 4-bit grayscale BMP using the account background, or white when none exists. Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. Unknown options values return 400. +- `X-Sleep-Percent: 42` supplies the current visible-page percentage (0–100). Missing, unresolved, or invalid values omit that metric. This does not write progress, finish books, or trust a queued event as a new completion. Account totals reflect already-synced history; annual goals reuse the existing account goal. +- `PUT /api/v1/stats/sleep-image/background`, `Content-Type: image/png`: normalized PNG upload, at most 8 MB, 1600 pixels per axis and 2,000,000 decoded pixels. The app/plugin should resize and convert JPEG/other artwork to PNG before uploading. Invalid uploads preserve the previous background. +- `GET /api/v1/stats/sleep-image/background` → the signed-in account's PNG (404 when absent). +- `DELETE /api/v1/stats/sleep-image/background` → `{ "background": false }`. + +All image/background responses are private and not cached. Backgrounds belong to one account, are cleared when its reading data/account is deleted, and are presentation data outside reading-history exports. No external image URL fetching is performed. IANA timezone rules are evaluated on each render, including seasonal offsets. These routes work under `BASE_PATH` using the same prefix as all other APIs. diff --git a/migrations/0049_sleep_backgrounds.sql b/migrations/0049_sleep_backgrounds.sql new file mode 100644 index 0000000..5621438 --- /dev/null +++ b/migrations/0049_sleep_backgrounds.sql @@ -0,0 +1,4 @@ +CREATE TABLE sleep_backgrounds ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + png BLOB NOT NULL +); diff --git a/src/app.ts b/src/app.ts index 0ee8460..4a760c7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { progressRoutes } from './routes/v1/progress.js'; import { bookmarkRoutes } from './routes/v1/bookmarks.js'; import { clippingRoutes } from './routes/v1/clippings.js'; import { statsRoutes } from './routes/v1/stats.js'; +import { sleepImageRoutes } from './routes/v1/sleep-image.js'; import { documentRoutes } from './routes/v1/documents.js'; import { connectorRoutes, oauthCallbackRoutes } from './routes/v1/connectors.js'; import { profileSettingsRoutes, publicProfileRoutes } from './routes/profile.js'; @@ -40,7 +41,7 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono { for (const table of [ + 'sleep_backgrounds', 'reading_goals', 'microblog_clipping_selections', 'microblog_clipping_posts', diff --git a/src/routes/v1/sleep-image.ts b/src/routes/v1/sleep-image.ts new file mode 100644 index 0000000..3288efd --- /dev/null +++ b/src/routes/v1/sleep-image.ts @@ -0,0 +1,53 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { AppEnv } from '../../auth/middleware.js'; +import type { DB } from '../../db/db.js'; +import { MAX_BACKGROUND_BYTES, parseSleepOptions, sleepMetrics, SleepImageError } from '../../models/sleep-image.js'; +import { normalizeBackground, renderSleepImage } from '../../render/sleep-image.js'; + +export function sleepImageRoutes(db: DB): Hono { + const app = new Hono(); + const path = '/stats/sleep-image'; + const background = (userId: number) => { + const row = db.prepare('SELECT png FROM sleep_backgrounds WHERE user_id=?').get(userId); + return row ? Buffer.from(row.png as Uint8Array) : undefined; + }; + for (const match of [path, path + '/*']) app.use(match, async (c, next) => { + c.header('Cache-Control', 'private, no-store'); + c.header('X-Content-Type-Options', 'nosniff'); + await next(); + }); + + app.get(path + '/status', c => c.json({ version: 1, background: !!background(c.get('user').id) })); + app.get(path + '/background', c => { + const png = background(c.get('user').id); + return png ? c.body(new Uint8Array(png), 200, { 'Content-Type': 'image/png' }) : c.json({ message: 'No background uploaded.' }, 404); + }); + app.put(path + '/background', bodyLimit({ maxSize: MAX_BACKGROUND_BYTES }), async c => { + if (c.req.header('Content-Type')?.split(';')[0].trim().toLowerCase() !== 'image/png') return c.json({ message: 'Upload a PNG background.' }, 400); + try { + const png = normalizeBackground(Buffer.from(await c.req.arrayBuffer())); + db.prepare('INSERT INTO sleep_backgrounds (user_id,png) VALUES (?,?) ON CONFLICT(user_id) DO UPDATE SET png=excluded.png').run(c.get('user').id, png); + return c.json({ background: true }); + } catch (error) { + if (error instanceof SleepImageError) return c.json({ message: error.message }, 400); + throw error; + } + }); + app.delete(path + '/background', c => { + db.prepare('DELETE FROM sleep_backgrounds WHERE user_id=?').run(c.get('user').id); + return c.json({ background: false }); + }); + app.get(path, c => { + try { + const options = parseSleepOptions(c.req.query()); + const stats = sleepMetrics(db, c.get('user').id, options.timezone, c.req.header('X-Sleep-Percent')); + const result = renderSleepImage(stats, options, options.mode === 'custom' ? background(c.get('user').id) : undefined); + return c.body(new Uint8Array(result.bytes), 200, { 'Content-Type': result.contentType }); + } catch (error) { + if (error instanceof SleepImageError) return c.json({ message: error.message }, 400); + throw error; + } + }); + return app; +} diff --git a/test/sleep-image.test.ts b/test/sleep-image.test.ts index cc5766c..3b2ecc4 100644 --- a/test/sleep-image.test.ts +++ b/test/sleep-image.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { makeTestApp, registerUser, DOC } from './helpers.js'; import { parseSleepOptions, sleepMetrics, timezoneOffset } from '../src/models/sleep-image.js'; +import { PNG } from 'pngjs'; afterEach(() => vi.useRealTimers()); @@ -47,3 +48,46 @@ describe('sleep image data', () => { expect(() => parseSleepOptions({ metrics: '' })).toThrow(); }); }); + +describe('authenticated sleep image API', () => { + it('renders account images, supports prefixed installs, and keeps backgrounds private', async () => { + const s = makeTestApp({ basePath: '/sync' }); + try { + const user = async (name: string) => { + const key = 'a'.repeat(32); + expect((await s.app.request('/sync/users/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name, password: key }) })).status).toBe(201); + return { 'x-auth-user': name, 'x-auth-key': key }; + }; + const first = await user('sleep-first'), second = await user('sleep-second'); + const base = '/sync/api/v1/stats/sleep-image'; + expect((await s.app.request(base)).status).toBe(401); + expect((await s.app.request(base, { headers: { ...first, 'x-auth-key': 'b'.repeat(32) } })).status).toBe(401); + const status = async (headers: Record) => (await s.app.request(base + '/status', { headers })).json(); + expect(await status(first)).toMatchObject({ version: 1, background: false }); + const image = await s.app.request(base + '?mode=overlay&timezone=America%2FNew_York', { headers: { ...first, 'X-Sleep-Percent': '56' } }); + expect(image.status).toBe(200); + expect(image.headers.get('content-type')).toBe('image/png'); + expect(image.headers.get('cache-control')).toBe('private, no-store'); + expect(PNG.sync.read(Buffer.from(await image.arrayBuffer())).width).toBe(480); + const bytes = PNG.sync.write(new PNG({ width: 240, height: 240 })); + const put = (body: Buffer) => s.app.request(base + '/background', { method: 'PUT', headers: { ...first, 'Content-Type': 'image/png' }, body: new Uint8Array(body) }); + expect((await put(bytes)).status).toBe(200); + expect(await status(first)).toMatchObject({ background: true }); + expect(await status(second)).toMatchObject({ background: false }); + expect((await s.app.request(base + '/background', { headers: second })).status).toBe(404); + expect((await put(Buffer.from('bad'))).status).toBe(400); + const saved = await s.app.request(base + '/background', { headers: first }); + expect(PNG.sync.read(Buffer.from(await saved.arrayBuffer())).width).toBe(240); + expect((await put(Buffer.alloc(8 * 1024 * 1024 + 1))).status).toBe(413); + expect((await s.app.request(base + '?width=9999', { headers: first })).status).toBe(400); + const bmp = await s.app.request(base + '?mode=custom', { headers: first }); + expect(bmp.headers.get('content-type')).toBe('image/bmp'); + expect(Buffer.from(await bmp.arrayBuffer()).toString('ascii', 0, 2)).toBe('BM'); + expect((await s.app.request(base + '/background', { method: 'DELETE', headers: first })).status).toBe(200); + expect(await status(first)).toMatchObject({ background: false }); + await put(bytes); + expect((await s.app.request('/sync/api/v1/account/data', { method: 'DELETE', headers: first })).status).toBe(200); + expect(await status(first)).toMatchObject({ background: false }); + } finally { s.db.close(); } + }); +}); From ce0ac169b8027f77ce100a24a15e9df67128c603 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:26:22 -0500 Subject: [PATCH 4/8] fix: bound PNG decoding and render native screen-ready sleep images --- docs/API.md | 4 +-- package-lock.json | 39 ++++++++++++++++++++ src/models/sleep-image.ts | 4 ++- src/render/sleep-image.ts | 63 ++++++++++++++++++++++++++++----- test/sleep-image-render.test.ts | 62 +++++++++++++++++++++++++++++--- test/sleep-image.test.ts | 3 ++ 6 files changed, 159 insertions(+), 16 deletions(-) diff --git a/docs/API.md b/docs/API.md index 4569ef1..de7ff89 100644 --- a/docs/API.md +++ b/docs/API.md @@ -992,9 +992,9 @@ Authenticated with the usual session or `x-auth-user` / `x-auth-key` headers. These routes require a server version including migration `0049_sleep_backgrounds.sql`. - `GET /api/v1/stats/sleep-image/status` → `{ "version": 1, "background": false }`. -- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full 4-bit grayscale BMP using the account background, or white when none exists. Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. Unknown options values return 400. +- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full native 2-bit grayscale BMP using the account background, or white when none exists. Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. `preview=1` returns a browser-compatible PNG for Custom, with the same four native gray levels; omit it for the reader BMP. At the configured screen resolution, 2-bit BMP avoids per-refresh firmware conversion caching. Unknown options values return 400. - `X-Sleep-Percent: 42` supplies the current visible-page percentage (0–100). Missing, unresolved, or invalid values omit that metric. This does not write progress, finish books, or trust a queued event as a new completion. Account totals reflect already-synced history; annual goals reuse the existing account goal. -- `PUT /api/v1/stats/sleep-image/background`, `Content-Type: image/png`: normalized PNG upload, at most 8 MB, 1600 pixels per axis and 2,000,000 decoded pixels. The app/plugin should resize and convert JPEG/other artwork to PNG before uploading. Invalid uploads preserve the previous background. +- `PUT /api/v1/stats/sleep-image/background`, `Content-Type: image/png`: normalized PNG upload, at most 8 MB, 1600 pixels per axis and 2,000,000 decoded pixels. The app/plugin should resize and convert JPEG/other artwork to PNG before uploading. Invalid uploads preserve the previous background. PNG chunk structure is validated and IDAT inflation is bounded by the validated scanline budget before decoding, including interlaced input. - `GET /api/v1/stats/sleep-image/background` → the signed-in account's PNG (404 when absent). - `DELETE /api/v1/stats/sleep-image/background` → `{ "background": false }`. diff --git a/package-lock.json b/package-lock.json index e8aa67e..7a33c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -580,6 +580,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -594,6 +597,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -608,6 +614,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -622,6 +631,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -636,6 +648,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -650,6 +665,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -664,6 +682,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -678,6 +699,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -692,6 +716,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -706,6 +733,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -720,6 +750,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -734,6 +767,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -748,6 +784,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ diff --git a/src/models/sleep-image.ts b/src/models/sleep-image.ts index 359382b..179fb06 100644 --- a/src/models/sleep-image.ts +++ b/src/models/sleep-image.ts @@ -17,6 +17,7 @@ export interface SleepOptions { placement: 'top' | 'bottom'; timezone: string; metrics: SleepMetric[]; + preview?: boolean; } export interface SleepMetrics { year: number; @@ -55,7 +56,8 @@ export function parseSleepOptions(query: Record): Sl const timezone = query.timezone ?? 'UTC'; if (timezone.length > 100) throw new SleepImageError('Invalid timezone.'); timezoneOffset(timezone); - return { width, height, mode, placement, timezone, metrics: metrics as SleepMetric[] }; + if (query.preview !== undefined && query.preview !== '0' && query.preview !== '1') throw new SleepImageError('Preview must be 0 or 1.'); + return { width, height, mode, placement, timezone, metrics: metrics as SleepMetric[], preview: query.preview === '1' }; } export function sleepMetrics(db: DB, userId: number, timezone: string, percent?: string): SleepMetrics { diff --git a/src/render/sleep-image.ts b/src/render/sleep-image.ts index 57f7e27..3e43897 100644 --- a/src/render/sleep-image.ts +++ b/src/render/sleep-image.ts @@ -1,4 +1,5 @@ import { PNG } from 'pngjs'; +import zlib from 'node:zlib'; import { MAX_BACKGROUND_BYTES, MAX_SLEEP_PIXELS, SleepImageError, type SleepMetrics, type SleepOptions } from '../models/sleep-image.js'; import { sleepGlyph } from './sleep-font.js'; @@ -9,10 +10,48 @@ export function normalizeBackground(bytes: Buffer): Buffer { } const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); if (!width || !height || width > 1600 || height > 1600 || width * height > MAX_SLEEP_PIXELS) throw new SleepImageError('Background dimensions exceed the pixel limit.'); + validatePngStream(bytes, width, height); try { return PNG.sync.write(PNG.sync.read(bytes, { checkCRC: true }), { colorType: 6 }); } catch { throw new SleepImageError('Invalid PNG background.'); } } +/** pngjs accepts repeated IHDRs and inflates interlaced data without a limit. + * Validate the actual chunks and bounded scanline stream before invoking it. */ +function validatePngStream(bytes: Buffer, width: number, height: number): void { + const fail = (): never => { throw new SleepImageError('Invalid PNG structure or decompressed scanline data.'); }; + const depth = bytes[24], color = bytes[25], interlace = bytes[28]; + const allowed: Record = { 0: [1,2,4,8,16], 2: [8,16], 3: [1,2,4,8], 4: [8,16], 6: [8,16] }; + if (!allowed[color]?.includes(depth) || bytes[26] !== 0 || bytes[27] !== 0 || interlace > 1) fail(); + const data: Buffer[] = []; + let offset = 8, chunks = 0, seenData = false, endedData = false, ended = false; + while (offset < bytes.length) { + if (++chunks > 4096 || offset + 12 > bytes.length) fail(); + const length = bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8); + if (offset + 12 + length > bytes.length) fail(); + if (chunks === 1 ? type !== 'IHDR' || length !== 13 : type === 'IHDR') fail(); + if (type === 'IDAT') { + if (endedData) fail(); + seenData = true; data.push(bytes.subarray(offset + 8, offset + 8 + length)); + } else if (seenData) endedData = true; + offset += 12 + length; + if (type === 'IEND') { + if (length !== 0 || offset !== bytes.length || !seenData) fail(); + ended = true; break; + } + } + if (!ended) fail(); + const channels: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; + const bpp = channels[color] * depth; + const passes = interlace ? [[0,0,8,8],[4,0,8,8],[0,4,4,8],[2,0,4,4],[0,2,2,4],[1,0,2,2],[0,1,1,2]] : [[0,0,1,1]]; + const budget = passes.reduce((sum, [x, y, dx, dy]) => { + const w = Math.max(0, Math.ceil((width - x) / dx)), h = Math.max(0, Math.ceil((height - y) / dy)); + return sum + (w && h ? (Math.ceil(w * bpp / 8) + 1) * h : 0); + }, 0); + try { + if (zlib.inflateSync(Buffer.concat(data), { maxOutputLength: budget }).length !== budget) fail(); + } catch { fail(); } +} + type PanelLine = { text: string; fraction?: number; large?: boolean }; export function panelLines(stats: SleepMetrics, options: SleepOptions): PanelLine[] { const lines: PanelLine[] = []; @@ -54,7 +93,8 @@ export function renderSleepImage(stats: SleepMetrics, options: SleepOptions, bac } const lines = panelLines(stats, options); if (lines.length) { - const scale = Math.max(1, Math.floor(width / 240)); + const logicalHeight = 36 + lines.reduce((sum, line) => sum + 16 + (line.fraction !== undefined ? 8 : 0), 0); + const scale = Math.max(1, Math.min(Math.floor(width / 240), Math.floor(height / logicalHeight))); const margin = 8 * scale, padding = 10 * scale, lineHeight = 16 * scale; const panelHeight = padding * 2 + lines.reduce((sum, l) => sum + lineHeight + (l.fraction !== undefined ? 8 * scale : 0), 0); const y0 = options.placement === 'top' ? margin : height - margin - panelHeight; @@ -81,22 +121,27 @@ export function renderSleepImage(stats: SleepMetrics, options: SleepOptions, bac } } } - const bytes = options.mode === 'overlay' ? PNG.sync.write(png, { colorType: 6 }) : grayBmp(png); + if (options.mode === 'custom') { + // Match the display's native four shades and bypass firmware conversion caches. + for (let p = 0; p < png.data.length; p += 4) png.data[p] = png.data[p + 1] = png.data[p + 2] = Math.round(png.data[p] / 85) * 85; + } + const asPng = options.mode === 'overlay' || options.preview; + const bytes = asPng ? PNG.sync.write(png, { colorType: 6 }) : grayBmp(png); if (bytes.length >= 1024 * 1024) throw new SleepImageError('Rendered image exceeds the reader download limit.'); - return { bytes, contentType: options.mode === 'overlay' ? 'image/png' : 'image/bmp' }; + return { bytes, contentType: asPng ? 'image/png' : 'image/bmp' }; } function grayBmp(png: PNG): Buffer { - const stride = Math.ceil(png.width / 8) * 4; - const offset = 54 + 16 * 4; + const stride = Math.ceil(png.width / 16) * 4; + const offset = 54 + 4 * 4; const out = Buffer.alloc(offset + stride * png.height); out.write('BM'); out.writeUInt32LE(out.length, 2); out.writeUInt32LE(offset, 10); out.writeUInt32LE(40, 14); out.writeInt32LE(png.width, 18); out.writeInt32LE(png.height, 22); - out.writeUInt16LE(1, 26); out.writeUInt16LE(4, 28); out.writeUInt32LE(stride * png.height, 34); out.writeUInt32LE(16, 46); - for (let i = 0; i < 16; i++) out.fill(i * 17, 54 + i * 4, 54 + i * 4 + 3); + out.writeUInt16LE(1, 26); out.writeUInt16LE(2, 28); out.writeUInt32LE(stride * png.height, 34); out.writeUInt32LE(4, 46); + for (let i = 0; i < 4; i++) out.fill(i * 85, 54 + i * 4, 54 + i * 4 + 3); for (let y = 0; y < png.height; y++) for (let x = 0; x < png.width; x++) { - const value = Math.round(png.data[(y * png.width + x) * 4] / 17); - out[offset + (png.height - y - 1) * stride + (x >> 1)] |= value << (x % 2 ? 0 : 4); + const value = Math.round(png.data[(y * png.width + x) * 4] / 85); + out[offset + (png.height - y - 1) * stride + (x >> 2)] |= value << (6 - (x % 4) * 2); } return out; } diff --git a/test/sleep-image-render.test.ts b/test/sleep-image-render.test.ts index 2b1e425..137aebd 100644 --- a/test/sleep-image-render.test.ts +++ b/test/sleep-image-render.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PNG } from 'pngjs'; +import { deflateSync } from 'node:zlib'; +import zlib from 'node:zlib'; import { normalizeBackground, panelLines, renderSleepImage } from '../src/render/sleep-image.js'; import { parseSleepOptions } from '../src/models/sleep-image.js'; @@ -28,7 +30,7 @@ describe('sleep image rendering', () => { expect(panelLines(stats, parseSleepOptions({ metrics: 'streak' })).map(l => l.text).join(' ')).not.toMatch(/BOOKS|CURRENT|GOAL/); }); - it('encodes padded 4-bit BMPs below the firmware cap even with noisy backgrounds', () => { + it('encodes screen-ready 2-bit BMPs below the firmware cap even with noisy backgrounds', () => { const background = new PNG({ width: 480, height: 800 }); for (let i = 0; i < background.data.length; i += 4) { const n = ((i * 1664525 + (i >>> 5) * 1013904223) >>> 8) & 255; @@ -40,13 +42,20 @@ describe('sleep image rendering', () => { expect(result.bytes.toString('ascii', 0, 2)).toBe('BM'); expect(result.bytes.readUInt32LE(18)).toBe(width); expect(result.bytes.readUInt32LE(22)).toBe(height); - expect(result.bytes.readUInt16LE(28)).toBe(4); - expect(result.bytes.length).toBe(118 + Math.ceil(width / 8) * 4 * height); + expect(result.bytes.readUInt16LE(28)).toBe(2); + expect(result.bytes.length).toBe(70 + Math.ceil(width / 16) * 4 * height); expect(result.bytes.length).toBeLessThan(1048576); expect(result.bytes.readUInt32LE(2)).toBe(result.bytes.length); + expect([0,1,2,3].map(index => result.bytes[54 + index * 4])).toEqual([0,85,170,255]); } }); + it('provides a browser-compatible PNG preview for custom wallpapers', () => { + const preview = renderSleepImage(stats, parseSleepOptions({ mode: 'custom', preview: '1' })); + expect(preview.contentType).toBe('image/png'); + expect(PNG.sync.read(preview.bytes).data[3]).toBe(255); + }); + it('rejects invalid and oversized decoded backgrounds before allocation', () => { expect(() => normalizeBackground(Buffer.from('not PNG'))).toThrow(/PNG/i); const large = PNG.sync.write(new PNG({ width: 1, height: 1 })); @@ -55,4 +64,49 @@ describe('sleep image rendering', () => { const valid = PNG.sync.write(new PNG({ width: 240, height: 240 })); expect(PNG.sync.read(normalizeBackground(valid)).width).toBe(240); }); + + it('keeps the entire panel inside short landscape screens', () => { + const png = PNG.sync.read(renderSleepImage(stats, parseSleepOptions({ width: '1200', height: '240' })).bytes); + for (let x = 0; x < png.width; x++) { + expect(png.data[x * 4 + 3]).toBe(0); + expect(png.data[((png.height - 1) * png.width + x) * 4 + 3]).toBe(0); + } + }); + + it('rejects a second IHDR before the decoder can replace validated dimensions', () => { + const first = PNG.sync.write(new PNG({ width: 1, height: 1 })); + const oversized = PNG.sync.write(new PNG({ width: 1601, height: 1 })); + const attack = Buffer.concat([first.subarray(0, 33), oversized.subarray(8)]); + expect(() => normalizeBackground(attack)).toThrow(/PNG|header|IHDR/i); + }); + + it('bounds IDAT decompression even for interlaced images with tiny headers', () => { + // A valid CRC lets the real decoder reach the allocation that must be bounded. + const chunk = (type: string, data: Buffer) => { + const body = Buffer.concat([Buffer.from(type), data]); + let crc = 0xffffffff; + for (const byte of body) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + const out = Buffer.alloc(data.length + 12); + out.writeUInt32BE(data.length); body.copy(out, 4); out.writeUInt32BE((crc ^ 0xffffffff) >>> 0, out.length - 4); + return out; + }; + const ihdr = Buffer.from([0,0,0,1, 0,0,0,1, 8,6,0,0,1]); + const attack = Buffer.concat([ + Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(Buffer.alloc(10000))), chunk('IEND', Buffer.alloc(0)), + ]); + const inflate = vi.spyOn(zlib, 'inflateSync'); + try { + expect(() => normalizeBackground(attack)).toThrow(); + expect(inflate).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ maxOutputLength: 5 })); + } finally { inflate.mockRestore(); } + const valid = Buffer.concat([ + Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(Buffer.from([0,0,0,0,255]))), chunk('IEND', Buffer.alloc(0)), + ]); + expect([...PNG.sync.read(normalizeBackground(valid)).data]).toEqual([0,0,0,255]); + }); }); diff --git a/test/sleep-image.test.ts b/test/sleep-image.test.ts index 3b2ecc4..2f377ff 100644 --- a/test/sleep-image.test.ts +++ b/test/sleep-image.test.ts @@ -75,6 +75,9 @@ describe('authenticated sleep image API', () => { expect(await status(first)).toMatchObject({ background: true }); expect(await status(second)).toMatchObject({ background: false }); expect((await s.app.request(base + '/background', { headers: second })).status).toBe(404); + const tiny = PNG.sync.write(new PNG({ width: 1, height: 1 })); + const oversized = PNG.sync.write(new PNG({ width: 1601, height: 1 })); + expect((await put(Buffer.concat([tiny.subarray(0, 33), oversized.subarray(8)]))).status).toBe(400); expect((await put(Buffer.from('bad'))).status).toBe(400); const saved = await s.app.request(base + '/background', { headers: first }); expect(PNG.sync.read(Buffer.from(await saved.arrayBuffer())).width).toBe(240); From fb3b0175ef828f71db697ff301d94dcc71889536 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:54:41 -0500 Subject: [PATCH 5/8] fix: reconcile renamed legacy reading-goals migration --- src/db/db.ts | 16 +++++++++++++++- test/db.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/db/db.ts b/src/db/db.ts index ec54068..dee6a92 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -56,7 +56,21 @@ export function migrate(db: DB, migrationsDir: string = DEFAULT_MIGRATIONS_DIR): // Newer installs already have the column, so the repair must accept both. const hasBookorbitAttempt = file === '0032_bookorbit_stats_attempted_at.sql' && db.prepare('PRAGMA table_info(bookorbit_stats_sync)').all().some((column) => column.name === 'attempted_at'); - if (!hasBookorbitAttempt) db.exec(sql); + // Development installs recorded this identical migration as 0044 before + // upstream assigned 0048. Adopt only that known history and exact schema; + // never drop goals or silently accept an unrelated existing table. + let hasLegacyReadingGoals = false; + if (file === '0048_reading_goals.sql' && applied.has('0044_reading_goals.sql')) { + const table = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='reading_goals'").get(); + if (table) { + const normalized = (value: string) => value.trim().replace(/;$/, '').replace(/\s+/g, ' ').toLowerCase(); + if (normalized(String(table.sql)) !== normalized(sql)) { + throw new Error('Cannot reconcile legacy reading_goals migration: existing table has an unexpected schema.'); + } + hasLegacyReadingGoals = true; + } + } + if (!hasBookorbitAttempt && !hasLegacyReadingGoals) db.exec(sql); db.prepare('INSERT INTO migrations (name, applied_at) VALUES (?, ?)').run( file, Math.floor(Date.now() / 1000) diff --git a/test/db.test.ts b/test/db.test.ts index c133ab3..9487b63 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -1,6 +1,34 @@ import { expect, it } from 'vitest'; import { migrate, openDatabase, withTransaction } from '../src/db/db.js'; +it('upgrades the renamed legacy reading-goals migration without losing targets', () => { + const db = openDatabase(':memory:'); + try { + migrate(db); + db.prepare("INSERT INTO users (username, key_hash, created_at) VALUES ('legacy-reader', 'test-key', 0)").run(); + const userId = Number(db.prepare("SELECT id FROM users WHERE username='legacy-reader'").get()!.id); + db.prepare('INSERT INTO reading_goals (user_id, year, target) VALUES (?, 2026, 24)').run(userId); + db.prepare("UPDATE migrations SET name='0044_reading_goals.sql' WHERE name='0048_reading_goals.sql'").run(); + migrate(db); + migrate(db); + expect(db.prepare('SELECT year, target FROM reading_goals WHERE user_id=?').get(userId)).toEqual({ year: 2026, target: 24 }); + expect(db.prepare("SELECT COUNT(*) AS n FROM migrations WHERE name='0048_reading_goals.sql'").get()).toEqual({ n: 1 }); + expect(() => db.prepare('INSERT INTO reading_goals (user_id,year,target) VALUES (?,2027,0)').run(userId)).toThrow(); + } finally { db.close(); } +}); + +it('does not adopt an incompatible table when repairing the legacy goal migration', () => { + const db = openDatabase(':memory:'); + try { + migrate(db); + db.prepare("UPDATE migrations SET name='0044_reading_goals.sql' WHERE name='0048_reading_goals.sql'").run(); + db.exec('DROP TABLE reading_goals; CREATE TABLE reading_goals (unexpected TEXT); INSERT INTO reading_goals VALUES (\'keep this\')'); + expect(() => migrate(db)).toThrow(/reading.goals|reading_goals/i); + expect(db.prepare('SELECT unexpected FROM reading_goals').get()).toEqual({ unexpected: 'keep this' }); + expect(db.prepare("SELECT COUNT(*) AS n FROM migrations WHERE name='0048_reading_goals.sql'").get()).toEqual({ n: 0 }); + } finally { db.close(); } +}); + it.each([false, true])('repairs BookOrbit imports with an existing attempted_at column: %s', (hasColumn) => { const db = openDatabase(':memory:'); try { From 5a4b57a8ff9158f8c6ad6930a97388d19680165a Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:20:24 -0500 Subject: [PATCH 6/8] feat: add reading journal layouts with tilted goal seals --- assets/fonts/sleep-journal/OFL.txt | 93 +++++++++++ assets/fonts/sleep-journal/lora-regular.woff | Bin 0 -> 25960 bytes assets/fonts/sleep-journal/lora-semibold.woff | Bin 0 -> 26812 bytes docs/API.md | 2 + package-lock.json | 37 +++++ package.json | 2 + src/render/sleep-font.ts | 25 --- src/render/sleep-graphics.ts | 121 ++++++++++++++ src/render/sleep-image.ts | 71 +------- src/render/sleep-journal.ts | 153 ++++++++++++++++++ test/sleep-image-render.test.ts | 18 ++- test/sleep-journal.test.ts | 63 ++++++++ 12 files changed, 483 insertions(+), 102 deletions(-) create mode 100644 assets/fonts/sleep-journal/OFL.txt create mode 100644 assets/fonts/sleep-journal/lora-regular.woff create mode 100644 assets/fonts/sleep-journal/lora-semibold.woff delete mode 100644 src/render/sleep-font.ts create mode 100644 src/render/sleep-graphics.ts create mode 100644 src/render/sleep-journal.ts create mode 100644 test/sleep-journal.test.ts diff --git a/assets/fonts/sleep-journal/OFL.txt b/assets/fonts/sleep-journal/OFL.txt new file mode 100644 index 0000000..372d3cb --- /dev/null +++ b/assets/fonts/sleep-journal/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". Lora-Italic[wght].ttf: Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/sleep-journal/lora-regular.woff b/assets/fonts/sleep-journal/lora-regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..1fc4302a24404d5d1f5ed71c4affe8bc62fef48a GIT binary patch literal 25960 zcmZ5`1CXb^viEwr$(CZQIzft#96Q?m73?mwLKV`Bl>Cbf-Ic zlDNops<0%e(*pxe`QYIDx2;zg8bv&EXQaGxTJCP83fxp5< zhH+ifOaO_YmP7!nFE{DjZn9ec=ga`R=rExo+oe6#h2=apo! z{c>_9%lo2_0aK|s=Tz=O!j$PTF$oJHOcmTgfamR zD(ZO}M~7s0_+hwRU!*J7R2!j+r2GJGNfOsg@o?L!*&~{($9SamTp!-720;+XUI6GN<{{UWv%h@L?WUkbb%Vwl zMfHt{!_~!l1P1I4m-@D}tmg)0EcBV(OO8~HG&rUZ_w$!P9d#?N@@A6PUR9{iqi4iqP>MH3SoO#eo(nTttXLo?4A_1Pyj+p-!nPpjrhgy zXQOwvE=uQt$(cY-*ApM7R>*odW&HO-<-95QuQx+PzQ+xu1BEQSnU3?X4_;C-7agU8Ica*mlH$^=s96ZNVJ9=2lmY<{W+%kb@I3(C;!;1MifuBHJ z!qNvD_w0c!-j&@}H|ICkKNv?S`$${=coe+3JONyK`MYz~-n@k)HESb1U$l^N@O~QoKE4C4yQxj9T=s3?WoO^!~sRo;om0a)DmR z9|>>eNZ=)P=p_>92b{rWbr@A2h(r(~%^P$?_s~uPwb#zB%o})9utOY2bfG{E zc|alr-HIqBIk(4xK+?*=vkJ(;Z$w;im-k2LK96J!8S)uZpo^PYp+0fGdPwKz!WN$%HYmGO&oXpS?wc3d!$!7IpPjsUw#$;5zty%QFGH0_nwb9JB zsbCFyPYCb;*W!>`kSPSYPY3!z|EB;D@2!L)#9vUY9ctE>&rFJIl{iDr@tA{O+WfYU2XSnj zjEDZfa%Li13hb&d>JF`Z_8N7r>h{tBa8*VqaLYI8D9&-r_!BduVrN-ju#k)@H## zH^|bwHJP~7a5Xb3-rsQnNGA%N*6L;O?rv~}#*U?Ib-Dkd4t163E%>VIcK&q^_WIBh zE=?$wP${#k0&3hztSu9ZBMR{|GB!3oieLQ%gRZv*gFii<`N=ImgW)c>Rv0{`ZhPbdZmGv{2%N^%^9y$r+eQt9b7_)S3`1E5B z{)Mq376<*t_D1o>Qo34yWokq&0c)Tl5?e?uCgG<%DG?^=XV7}$yk?f-($@MXqme}% z>uCfA#`s->i<(W%4Q>prJ~8Xsn(gMGj@?&H;KE&Hh`<$7^u8+fL1;hxU793!BSlWEto=hy&8z>Pyl^U%>*RYClf(0+q@AP1F#)RFeUz^CXZ&ZpxrkEw7 znQ+nxp<0n^5lWjK(|VtwELR6@zMS+f3QJ<+6$_hOd&b$bo~7i4m6$tODoKs2=etrA zEFSg@V69;Z#7u>Sa9?K>WgUnUjrenyeEBzp1e4J+F$L&ZqD>@HlKNcDshbknb0Mpe zCoYZ8n%d(qm5Z+d+Jl_!HT~5iY|4EVi2>z(ZVd$-!b+6^O#l`Cs-c$oVXuTWqE((57-WZ9ux9--SN z?Jnq$!3^Hc@G7K#&fNfO(oqiec0Op4RI&|t)M7B8Dc4XNm%gY%T9n8Z7wDKlV|xxV zeh}ZutZeE_UAoXBM(RYG{aIY7akY-VDQDOxZoC?exJcW>dRha#_dYB`XFeQB`Z0A8 zN8Sb5O-zb$*80W?t)o!SlhD46?hsJ2#S-M$uRA<-mGZ#0{v1u~0f@Q)^wYiL3R!rt zvRJftHd#5yeebB?u5y~J3^w?!?#8#ZiDYdP44<^VU?!GyAZKO#xJ~u^GM^@C)7Nk^ z`fZxpk4;!QD_Az~c7qC4YltdGi}0!@1n(ZcgNfLZgdf|S0y|Z-jH(ruZ@FmAvd60)dCMR}1Y;0z^%T@)!bO7! zS)vyeQp1w4Oc$O=IU1Y8t*M_vM622E#bdlf>3XV8o5(z34Q)p2;2{(z3HBU*3EEn< z?J=n5MZ}^T44PoaL(&NYOC@5@r}|Ax87uZcdkf`eq}f~T zMP<{xMsUNBx&bwU2=&Q5k)XVY3qlUsV=3Dn8OTwx_F?lGyV%hjPw3H{+J>RwJUU6% z8fY#Rg6+oPoGL@@8~fh)?$s1anN>4JqM0GV%$zcHES|D-Bn(U7?OlcnVkAN-kn&k$ z7|!=(H+I654#~eA%ZIC`$uE(;7%qLJ4?L1(cZ=VG=6b=545m8fQ`JqvGDqERC#wHg zwK}hm5Pptbc?~RXm5GA)f;DmHUzAMh#~fUa7Q{$|&>`jxbWBU!JyK)>k0n+mF3HP+ zL^91v!Cag-%izU4;Rmx#AV$q$B$PR*zT~;1s?z-OL54=Y9tILuLL$x`;hzV&b z^RvJ)&GJ%F*Hx*P}Qvr;v%hffsK|05C!qFtwlzj)F8kl6`r{RI8Azh`Jjx`oWY zZE!qzaT0>X9!^zTaSmNxQ9A{mZW$ZWwB@3Y-Z*_iNLpkl` zhAW0=&mxAD_#ZRq7ntQcLQf2ZnsQeCuQ0Tjnfzxk#^*e;O#iXI1Yw!T7HGUws%+8! zsHQ4Aq%8G=xR`AcW6Ex;xHz8z`2UlnEcLOjLsjUh>`fDS^DN57HeO@Hc>Rg74VDR=SGV0a*iCIKCHlLVV;jjnrU8u^Qipz z!b5;RgNM^1o5dO9Cs|s|J@2Qt!9He%Xa*RG+Q8R{lW?3_enmhp< zy4q897q$6U>rx*5eBOg`ggD&NeuO026Pi3P1xbQD-(TW?qR#T0_s;@FKI;D+F~o!) zMB@|zoI^mq<0-qAddDz&=zOvA+kebrSDURHM@~7@;P|hOJG{2)90Qe?ew+M}ZfYqw zXTBQ^ud;SufpZynq=)|ES z|FwdL@cxU@BdU??<3~<# z|AgIr=B3`1@cp$TzyB(;0Hgd1mZMQH8KY9;#cE40%DZ^=XrJ&){9|u^%8M2;k|LW! zt8;v+S%Xv7?26U(>9Yx6w3L?eLAB7keBmnLwe(n?i0cNIA{9je3XxPG0p0BNHwV~) zf++V7ra2)->W28gP(#oE)|EPA+d}8?b138GLsxsiBFKt{U-|TA%(77 z%UH|@3+?uM21LJI#(S=L@sfQ2>+E_|m<3-!{5MFh9IG+?k*XUZ4c5FJDUKlwBhO7> z7_0R9IVVvz(5bF2NNMG0D@Td9M<>cAQEcjT0#a|ZX6(w#3^dU z(@U(l=C01sPvtP-0LwXC>=HcAQ~jTr5{i?{L~l@E&b#^a@?adydTqRgigIdz=Xu+W z=#A%@61_u9B>UKm2bLXsRnN5v{kqSZZ5f#Tf5kM%FpLvsVLylyj3F?)Z19dMhR5B#HYM>C`$_2)GQhefIn$*f3N zY!$1=8EW}n`(hcBNLIDk=T9r0na7`2R{{R4PGcg8==ljYFik;GqbG>unU>=HgzX&J$Hc0e>jdl=mk#=BJ>`O(hxtW$B4YPTuq!K&@U4s*ZX zt&w~dTjDBa(ahEM*>BzIcC?>b2C%z5OI%(qKz-cg=e6WzIIPZSnq1fZDSu?=KN$Idu5b6qZX zMGCkoB3n97&_A+guZ{Mi?Np~-S}d2DkCs#yCZNhBT3xO0D-7{&DYstj^kEjF19b+P zu3}C(=FWwb?1Hlz-gQRh2}Nnyqo5O4vR_e3qn~vY%B2-cu`JN2!;cyyi(j4no`nUm zt}b%2o1*CtWHxX(DEd!oAG?J@88S{bk4M-=r$XO7+%`tjj;8ME_&jmNB&X5mFB9IZ zxtkLrZV2z?!@JDsm8gg2IAWG)P8l6_9EYhUhM*1GtIm_MOTUz4TYkFiTu!*~DE7<7 zVVXx$Oqa#p=kStohj+A3p!L>!=~2hn2&xMgJ7%?TS{=H)TvcgKBYr6IS zbF{y^Eo|239*=JdZJU=ho0D`ht44K}CixVqKNhHai;mNpy~HHZ3XoXYmkWo^4K+|j>|3W&LNFK(MtyH0QO{*w%b^aNfg8-Vq4E+m0jtv*;4B5U6dgan4e#YuXIMohwCIaMK=s866-mF&&olNX~ViZ!zZvrpzH#&R|;2 zpbDD?@3jTPn>HGxt=gLmE88>=$i8=jCg-uf*17R!ZQ_B@A^f337v#diY#?OECZba) z6^+fO2RERH+iwipETfZu6@T|&DAJ{%l?j>rM-IOQHP&rWzom8bYEP zWS~km4&FOQm?w_NO>t`eJBcGU`B<+ny<>T7AX#tivH z*0{<6pIi z<6lHl`1yyO4&Q?*Lro1jG1YaOKGMfcYkI$QA8*&Ta_Sg*TQcu-`qYm8u@WM^5<$HZ z2EHwP*e%sH9-9&PMb-Ff$6o$L*XZuISgG`$`TOJ3wtM6elrkwFST>MRtutUzR7b<*C@@GV@c` z!3hqn)dKWsDt}L1u+%}vvb~V;th04>!z7oFW8E-=lQgqmC%_P5mv*&lKEN;gGeHQ^c ztlyR`if;ppAX;Q2ambDh$7F{J+mcs)a*n%d%o5L`#5pZQEhX~jGx_>v7^j@dQ^lHh z`gvMxqI$>Jhxx+whx$|Yx806QU)pp3k3Tr^Dm$DpJBpw=3bQ(j!ru$gV*3l)p~DR$ zvdwuu{<<~A?mvzqiS9pP12G}+zQKR1Q{}xM2EZO*{2A#*_s@MdOG(0vLJWB`Jur>v z{TYI(eu{F!uZcRlbNLh-^pjOUEPTH z!zIR!HROr6S8`n5ueiV?=#b(?Gm&X=`>vi# zT^}!4&1gwdr+>f_OxwRmWzvLjeb#9cOWK=V-hUU}FV=nImp#*vF4Yi0)V=BX#u)7M zt%=Ifcs&577BajRRI(P9el50qWRb3pm1B5J#CdtW;m!Hn*Xz;JP*zo^qKz^kADEWJ zF1RxunV5FLhqLGB>*#}i_%>u}x{p<$J2G|kY+dlr?CFc8cY;;4y7WEOf>AWbi+i=Q zi^>|Xd@e^y&t+2xarn)cOy~AC@{Oja1QRIahP_yeAn7rJQBU z+x>(0D^I5Ap@l73Qa-|of0dGrCRZjqh#Yeoi6jt?u@9iw5Xp88GyE0pFsFo^{d=(9 z{u`t4FZs+9{~7a|JqkOf0Uhc-opu}4DY`6T$*!SPuM%a1*@0t<&O$cf4xR%&{N16B zZ{Y8M!QcLczk?3vD#S$Bm8(?aE#5n)9{#l>5SUBzL6UwnK4z#Z>C3l1RC9?b%oVhb zemVQz5)>>+IkV_h#Y`aoHQ045&QIBlWH@hSjikzUrKB9U8A{JpY2b1S{xoU-+n zx#yjUSCJV&7wKab8Gz&19SmUmOb>FUln=x@CZ3`5k2KCz{b%p8ub^3&`eZXdv|bCs z$=&`PBI-YH&kSYJ--ul0Ke~P-UCkd=YRDCd1P>)z)hS*_dAqvDL9f0bJ9yPkqZ4!x zrTW!9Y>p|m`Fu%&j!viSS=L^#4)9H_2EloeG{^yEz zU8JG5_WRJqHI=D;WOAeFNqQOqNZ&FJ)>XM|(X6jviU{ICPa!)>@HIA!MTnxQEG-f< zk&b0GvPy9iJnK2R?mNWXRz zg8e96+pJVN1PTFIkP^Q#9c0W(=W4Z&d&n7q&y$>$92A7|kU91|qH)bkT|?E+Y)E~L zAYGPFNP1{NN|7?wnuRu>$!_q~MEZEO31c{i)L~E5Uk=o7>^TG9e_2TX9A{kGCVO-o z;#|G-i1OT|(s~|Z1(jsNS11hr9v0aaRGCGW;Snp<(A95w7 zixrnLhE_~1noyR|my$u=W+9jV(cvlDwirGZ|T^?4O-9OM; zATcC;8Jp4UNkr{UR}}*xh^Y_BBYCqjNen9vS-avbtt%b_9dsk*=zuzh;7n$NC62J@ zJPu1>$PXM`<4#Fz3;87s9ji|kzR zDL;k0Gg|w?Y$lMiC*8fO?}vAOxIAbx77blmPVEF%P^| zO#g%&;g-LLE=dJilRB^*s%w8mO<`z?H@ZBW7&H+Zl3Wv)@L87Y1`(E{csnSp$o6|4 z8tCdln`HF#jP>*^45IY*^z8h|#n?xWKzBlq;R68?(Eaul z<>zOFm7fL-((f39y^mJJ%_rgKhXG)K{^9Am_G7sB2Ol2_SdSZCo-Z}Ofs7b7>uiue z%J7EBK;8_Biqn-S{8X1#>OcvGoH*|3ApVyL0?%|782z^_R^x&I1T_F)=Eg5dmE|W5`=#s_T4L0krroDtv~Sy3 zJ5H$aURTI?9#U!FcgT3(25r2bqJ2J&eHx`3)`)|h7Nc&XBxo*eE;t3@vidrBNYnDMM zewAn3mbnj9XDRjXXFUcKbM3oTyRA}cKlr(HoN?2>Zm@RUCti74W!rpQ!27&PO7oT- z5^M)=5F|Sg1g09NnzvyTX#pq6hG_irist)W(bpFM-gPb06T%Z~IQ43Z%2$|ZjU@pL zH5|-&ma_&Q$R89+B~~fFjY=}d0z|Hg^ueItCW zIb7unk6?T8g$UXK#Ulj@m56^JQLDcA7irQiVQ4JTTv=Y3$cRBcWFn44SzZaFQ7V`2 z>^bV`*ciOYr`%OKVS}AqktIyt?DollWedErLK=EmzsB~o{2awbOcFoi5t~ly@6Qc@ zKNCJLWmLETwOEW6=m7|a|DH~ipGu>7;y(K&ZNUD%wZ6YKAEwp2P5^AdpXIjPNTwR^ z;Wc&m!}c5jfIq%ek2^Ab4i8&S6jha?ENsa_%ie#ZW z^-n>B8_^FnJ*D!*I{&{^{U>wJERt^|kOg4~zsBWm>o=x93mnxW&~NVe#nGfu)2v!c zG~w)5U&8vqWxe)gB`JXO^eMpnFrx~W*}X1&71yx|D?T9fpJ<-R^F$WjJb}vyZ|v3N ze~kgDC8ju%zRq*Qnx1?`{08q!V3NUs^ys?T=`Vk1FE^JZO{)os0JQ`#7M(= z(?@ArSmD|CuRdqR@9JUlmoSCA$9{*c8^@mI!ws-&8CQ-h)9tiH`GZg^93{M6H)dC; zfOy6iOGFBry_6<5*$1f*S_DZ9dbdUrNof;Y3wE(5nLfPcvp+`Hby$B#xhmHS)6@4? z3Bgy>gTgY=mkAT(>PHC?B+N<^8JAj3U;g-hyMrAdi8Znr41j1~1Ib^6Q@(^Q%OHX; zZnB)^|I}-PD8JA{7bAobC0N3=1#~Eesxl@W;%h+O|7S_kS$Qfga$2W*rH%Yj7Kc4! z#c{t&!2I~4-CVS8h~oLg?~butl@`}^Gs!)&QucOHu-Q7T{Vs1b=J&@R9*yGyp7rN( z6Gpx8LPr96II&V`q|rg4(zLu!3KuNmhdqo-RYJHW+yL2V+uP`uK%LSgs~ejLmPhExI+l9&vfTJh2=iidn2 zU#(Bn!)Gsls<~&}PP72v3^Z*{y+s2P7Bo>{;!t5}u%Dr!?6E^}?b?klM@549szm$1 z_}%+qC-q;(>`IY&FDF~?-g!a@j)h9Abe+~G?-+D7BeZ{EWJLXRFV-Lri12{Y5ylcKkAL77&Z%qlJC-8CLHsHp=JU02bu((k=YR@dPrDa8GtDO!(yxGsAd>3%e zn^$`IaQdS>mBKybo|V72B=1e`;kQ!m2OG@zSZVThMHESv%xtKegvy>Oc{2!nT;p+ofAP zeC|7t(O5@5Z@g^FoR1wjYqERnNSCW@eHe5Ei1h}Td$n)sx!fDxaudhTnK6*px|)~P zZP^;k>l$sUMYLt!3*Si|0Pk#k^6tFTBDUVa-AC)qis5TUc3ssz__#QqfVA@;-H zi^z)hgkN9(^>AGSV%Wiv%AbbYc6+t`L+()vu@D=1Cv=cFq_{lkwm&0S^cGgnBvY>l zJ~_8^k#Mzka$Lg=?ubGLu5>Cc^^>mn00gA$gnP1_eOEaIUawCohpB zkJA;imyIi4TB$eqWb%w=8u^@+f~j>DJilQvWNcX;`~w5bx4VS&u?Zi;$0m#!`MmtjgZiM4B?a zCSQg-Lf(=r@cBqq=ri0f4cC2GQpbr)b96TloFb0ON-#1 z;@7ZEs|&lK|;3?0kbSHF8D+F#@zwpeRCmPGHu^NwCFVF)s`I7v-jr;1vXA(*WP;$4H#5h<`qT+*t4Y}%-Ld_6wI5T$9t`WDiV^}40e-T@)yIt+KEJg zDLyB$c+j$&`-LwV*3D8^W=u38Cpn^Ge=78}Q!@o={Dy`sbHW}R?Jpii$8fWFR<+XpamE>iIfldt$@D-*ZO|~C+%893N3Y-eu#1K$QtF?n$ zMS2gqcTisl6zK7(4paJ5*V*+(sN~v=KqVR7)o$E|!A=#Th4JyjIK-uv^Fv*wT0?bg z=&dbk#qpWgh8VXP?`8qW3)!YLXblNmaet!ZFx)1+WM5Ao&M~`{3P6is_T|0(FM!)U z2X!u~&VJ{<0lmOQri_!tsQ1hc7Z_;77Z@~kv3A4JA5J;%r!%QK1t;`5 zoj;;z`}^XkzEixhe9H|2?lDC_^()dzN=@si*w1rOL{JDNEiM0)_nG7+`ln#f22Z;$ zmaRQ0nw^TRCy#+2dJ5S(Td(ib$)dXv&=>YC)oPHvU6yG%JNBAELGCte({Q)X+PopP zBYC5)8hG@>C5oc6$D4Weo>wLpWmzMJp63s7M)<*6s7Bzl6@d>_N=k)TxA%+R6y=P` zM2l5OI)j)#xnCqSaZeYmt2Okr${`2eZWPI2d}CZe@(o z7AF)MOL6)r9_dc}W+@$#WGz=T_a-l-iW=rho!lQ=g%X5%C+M!{NSBN%LT^^j7`5Pp z@zy!@Xnz8;xRD~7Y)KU0ys|N6z>@d?t=7Pfy9OH}1;fE2iJy4Q)669gP+lU&l>w2V zyaE8~pdNV>08mruzlNo3r$9d|yYl=Md9T#H)cy6Sfj=Qtp13~QFtvm-c!NZykwGID zeJe$$+ixgL86N6!i< ziXrSWOEkZ!o2s>dcnSz{fVF>o&9u4ukhH!{)pwm_&nMdt7^D4o0SG95QQnxIh0p7N zUt~P|Jgw$;bB`+DUsNsgv?+AwbgjFdPwBk@uvVSn$p>$S;NnR-9|&bA-y~T%i$jyA+hP z7h?`l!TA2{RPx^hifKClF;TI~-`%ct&e}p*Qq|h6)PJMb2zs1jD=;_5`hH}$A#30S zGW40qMP%WL3{(-B$DzxU3&CB-sym4Zl3JFA%}Ij?`ZzI)fZngtZyiR>N97=6S~*ja zn~FQEAjuP#3t|&cS4`5@%G8gV8SPE55}AigUXBHB1>MrtufMAg@Ec|Hi;Y?uoUXVU zlOL<(RJ41x=TSAz^4Mz$usB#EtJ<-sB)R$-s$&4y@=1hMbt>vy3woX^uHC^K(WCNr| zacr_oH@MGaJ5+kJYmV0^3!9kBG@m;MU{9B8hiB@HzC!avp9)q{s13tpvpH(QikNCa z^`I3Bny7w}avaj(zn|R9fV_CYP-XAm81#(O+*}(v@ZRSVaUGa9oHN|7?ma*kTS5^qFhm7mTzj>NaGT3%Zwgq^rwo~o)SG=us6p_1d z|6x$HSCRrLI4pEifX@8kFrP!LO9M>V))w?l9Fiz~UNSuYjfQG%|7547-_p>NN_CC1 ztqlwSDevj2VK^TEtt%oG5e%?>!$EW^O39R|%^_#q2e|$*UeZV7j|o-k_kg3JNJmjk z0YV_ohM1D$j~W!}WkI4-v5T0$iPtOHL6=DhX~Zf7VqPo;&ST2yDNrlc zU!fZHHTO}%>Z&%6J2sIC%P6mtDO(MPddfY(Dsk>e^H@*Ok|sU<_62yqa%9DupIdZl z0>UpAG&Zov{|^3oB&4j&E!zSbCKYIZR)qnZu9j2-{ke6;|X?nbt4pOvO@rK-z-guzW_mQ1nQ}m)b-gT0ywLRlu9$jL|-KepP zI0>-i)3%fE-)HZ9+cC7acrmh6qq>Dt8gyH4UZ9nv&ebvTb79e{Nip*ZM*r?M5r?(O zYj(mbQNGIkQRuCs?eB0bvtPpvY@CN@OI7hSZTkJ|B!27ZC`LdrZMupvT-0KdNoPki zv)aT>LcW)Zd11o($-^63lGeVeC3SW^J65T924n#urNzzd!p+_HqX4vgsk-xvsk4P+ zD*`Qe)weIMfca?X&*1}E@hrXt#ND&NSzah9yLNQZ32G5!WJOs?5r_*X2IsL;b!Jcj zAI#cKF_y_BQz8so{R@)VM{_X`X!-d(Y1D>zik3+)mQdrI|eK=#;D z8}M66%BwvHSkxtAL`T_?^de-!D*rioAySrWGbLS8y2#CoGn0_4nuS553{GN12ugUq zwCX*aiI@b6pJ8UeC>fM}zfeoJ(FrwqYCJr2#_X$g7>d#=8b{6-YT6+q?7tO?iO?!M z3le?Yzz8s=NzG{SRJ6}Uz2w5DjtZ?zTgUpnb~}Tat~#8gRcX;QrQJ=QV^y7+RF$%W zg8N3a5}aWJF4}%+X(88}io}m-QKCYh6TgJJ2^vCu)|vfRF*KfY`3>n0J%=kS@J=FF z2WHpG-RXR#>xX7g9t0LJl?@VgI+`{2#Lqmc{Xzoq7fbyCyFE}qfuy6CQSR~4Pk5mGJ!omb6{XlzBuyHRa_g8#ejB)T z^Xe99Sx4PNiP+S5*NDJKQAwsYPg3b<%1&O4opb%(P=iV6^qWzOul1k)zNUA|opa;Z zN*K@_unZ5qWgDza!191@5EXj^<{D%}FXlgNEz(@qq7(7jLX3lzR@z4vm&ueGR3f9% zav$pz!08}bQ<|4tt&DsZ@CS_4K@!KB+-$K1?Yh8skw2Na+h!kiZf+88UjaoCahpD@ zU_*GQ(^o<0%ap^kC-KYb{r>rMz5&HYJo7SJAPgnmKaLx9yO8G)CZ0C>V&U6rlM+=eq zao0SB4~QvW_|x805o}qBo4MPI@sh)p@dvK5{R==70&PsW9^BrvBpA+Z;=`)ig@kB3 z1>PS5L57H{P20Y!fm7A7_l%v{>FIuY+qycwKA$YBlm_JD$fTWxL{wk3^fNr&Mm?p= zQ}5+y&~;yzq~3yNm92!CHg&M8Vmfa)1f1$&V4JFs`Sw(4>L>`>#V3|CRKe~lQp(e?Q zND*zeh4K&h^>hA-taSIEdy7)5cm%l}bl;Idl9mo2Q zvrTn(ZVO(S*?4d*0Um8F*@Ve42g>>hrkPo9i9w1<57eQS3@615YW6uXND5KaWgMMrbS8XevbqW;jy*-z>t^Zq+>voj=|yfwU=mVz8};%iV{wF z#hjH181M_Wc@8f^F5B`n#uhiO7U5~@JQx=g$S8>?#F|4%$eJK@f!D<$;=~URY%Q~2 z5NiC}@Jnc3o0EvChin+t9Nc8Or3k+G4czR`1?(qz%J8ftTRZorwOCU{7!jNPH4eWBNki5!YU6!y zThsS5&Qk8Q4_fvl?BD-BpZ~14-z+A=6%6TzykT4Jhw*8NYQ8hjv3;-mUP&j}w61V^ za5y=Xxf#f>GvCbG(x-uc^C`gXr{&gS@za&~)h2`q@?+-&VF6*U49kPgb|=_mt*5k_ z)3EQ?&l=}qiEWKb@{nM9R8%hbjk#yR| zIiKfQBj8svLHLZHZGUP`o+-3D!-xmCpQe;Ji)N2~l7>8i*&$a(_Pugl7EPTEya0CE zww2RiUY92uajF|@3>!X50a}p0Er!wbm7r;vSahd?7i3ne5-t4VBLVocg598rSej5u zhQj+`h|*8NNQIF?sYGDK5cD7c=0xgpX|>epB$jqy>z7<1%F`qe0jZkxkJ-9DR7U=2 z*%#5)#Ic|Op^;q$5ynAsc^#+Nr%5rJ{$NPR6mR&-F;zr-2t%E$UYBMnVirze z`X7Se-A6?q#C?{gk-}w_1zhReHCQ1pNQ|?4vXZyuXx8GB(L-uT_Q?BorATq>Ckt`o zhqpWn8yO+x>_a%!CTr=t?<|AB#?@&aYOmcVWh`iTnW533*a#~HV)%&8?t6`fb`%jW z2C)Y=LCpvRRBi(Z(#p2d=vU$+{2PQ{J;%t}E$7um6S44~01hIcTt0{vC9XY8c~ne% zU-|TDI9>I5TL_8+p*Xpf}VIY7m>cc zX=$*a+F;n@Y-JlsDEnXjj}He|avjQ($mVwGuA@&U0ukx=!d#gNOzV4m-IYjYSN5Cy z#Awz#6z6WeF!k5%w$9nD9R5RX%(}G>3n#fQHoY~y2W8x`2s zD2_n};&ODSJPcs~X6h90{Si^WDUX#YPrP_`l?zbXad44S3<`PtGlDF$Jc|Ey|2la( zluB26ZA+bT@~VKwBXLyPBu#*EnEmge&t6(PxcRd^lXNNPc=0bgU(n_nf~g2yU-wU3 zn_jFLP~DUFg#CKWus7IzL2zf0YsV^Z>u6EueBQd@`IoCtHS|D)KkO3E@e}EX6nmw>97K$_>N!U3ahexr}HEE26_;4x}j(Zo&hiV5;RvgPa%S;-t z2$oJ{|2l5+O!s9B%tj9*lDQjU>89$3pP@Cmd3hD=`MQKI$qpjn#+yR8IC-=7&1B0S zbr$7t15FtyA(zUOd|_U0oZYsG8Bu)3Zt;N9Xir@~ksZv>GbW!4*(}IIV6KZ=k zTeV-#-9|ACzoLt^!R$_0zBhLa^z7iH2&@3P=i@C|bM+2>GysG+59uP=_SZ@E3lQO& z8d}!FP5TW_H>Lv|CE#Cp0y>J8^&JN~>6ki>r~7!76GyF=7izK35Hx#vSX3zIQyJQ8 zxdd%Enk^f1gfwo+QY?pL=q209w^f zM7tvQL|x54p=aGS@;ren^0> z*bVeSqC1YW&WZkr40T&`8caSg2kgf5M zga`ec2ynZnGzUs9h@MRKF%r~Ab$8-1nh|7kj)ZX&HR85{`UI*Q8PYTd;py}8bgB{3 zWI!2|rHT|y?I4-)woq!0ZIl>o2bYzy)(ytC#W28M1^0#jFSa8|-1iNF7p%H-sn{gF z1K1dcg&DDpRODF1D6FPpH3pj9PAk_rnTx~sbY2|YS4jrJt2=LxBs`SW=}{DKFqMvh zE&Gch4=p1;Ybfql^*dhiKYQDX1lrP(8Xs()GrMqT)GIU&O{Qv+!x0%vr|MCcnT=Lm ztqX6Nn7Hlu^c4DTqHndj_qaBG{khHR-ivF=1N#>B8-pJ7V*$Ycffl-f-fHFi;s zImv68FGc`>s}dB75%9z1;OkGlhmNAtM*`sUXz*LzrihYW>m4M&TtC4Z=7BN|ZX#u+ z+FYzP##Zx*Nt@MdK^dOcUs)-~H#KB7w^(iOqW-zCDHSqRw#~Gn&0_^+E}IBhd7FiE zfiTCGGC>;cVtixMo6@zzvJt?bdXIin@hR{}gs44yC$x7dwD%~bc-#JT3#$LFqar6z;C z2!NBbSQ%?1w6d7qT!a8*@>4>h70daojLpnCLaZ=dh%8Tt6II%J?n--48rgsyN%SPb z&*K}Mu|bR|;k(3A1(e)8E0ZMe25$u)FOyCPYU`J6D3z3oSW8A03#6M^b|&}cdy+6DHNMaY zTEu3>T`0H(2*}Y?z|{E{?{d1iOY_fMdYT9}_sqygCpRby$E&A#8bpzmfNWtgNzXl{1=Q__(QN_{uSMcia#TOs!{6Am; z$2r-7!%8%uB{oJCG~-b;V+@``Gu~dn?LDYn)Dhev;r5kl{|$NgDb#+|0o*=}p2zj) zck24si`oUfeIki#^-rQG^;#wWx83jC^cKP8KMASEf`u;(`_4pQoAXMTF#Ow8p z^{wFjorinRzj=n|ZwGhj&#UP9Gy3zl^`3vM_xv9_4YQEtK>) zJ1$kkBqxP@lEG*gagR8KT3pm3u3{;onpR7!*Jl95z^z~2J0VX+K3G|x}=B38ZL*&VK9&?r>gd- zj1o?pDq8|5aUr`f0~NJ2kxZ7$%b);9MF$5so6&Bh>5Ax%hd?+WTYWhQyiSANga9)m zo>Zuk_El`g@kDV(uP%cEm_}$l1dAn(ZBj=07maL^#UfWL)O$qF2fykEwVI8wo^w4Y z1VK!&h5*xf7DN}2;9vW9AH=esTziW6FmV}S>)q$*@p3&cM|qzVb;SY!MF|9AxbM{3 zGVwX$D8cCW0id4Mt^XI()d3rVOxaCzK;q;mE&VIwC{Kh)QecHpMe)tgAWND?^L-2& zkR{zuVhg&TAi*2;=(0?FNvFTt(50I&9pi(KB7CU7Zh7|u2cbWQ0)Zv5fX4eAl7tzf z(nv+m!%1og*X1e+)-+nfBlNhu zXX7})(K(C<0{0CD!iXf~hac7Nl20H+T?5hZUp>IBN4SAiQx3!WwqY3guYx6iidQBf z;Ho*H5F(9~m2q)wVeiDso){!^QEgiEOufVXE?j<)^eI}? z(DPEXml&4Qa|m_P8*AyPgLx75Q0MneEbkqlrq}^$E-Zo?h8<(WTk6sA9&8Ka1R$j2 z-kLZwZfsA$OneO7-LoVw0nnQAD{wZn5>8$Er^Sx1C1&FBnMAUkNVI>YwiC%|Rh>>I z+WKxL3Zi;eK8+(lSy!2UJ6zK*#_|0Zw>hqNMX`$Mx{Ns7fuT(O19-{-Z*;h=e9QZw7kxHSL@T0iB-j>&c5uXJ z?pY%#Ag%;_4#jJ-R?BwOqI|_)?^Alc}M1buJ>vmbiF@4mHHm~yx;l0^7~!y zP0wc@O^-fS{jTc^jo&vuiT;54?;BtE!WTNf*DIE&-f=&C5wAS73i!=qaK6=fI!gXJ zvEeBI8h}Sm{c{V>cb+DHmN*&28YB*&h;X?c5iaiP5rUU%7E#VezW!4b3rxiQ`U-di z#f)DOM^XFT5u*RR8(ZSt7!n6>?Dy-&mPnT#1#d*MF`(FT;xCrXKJ_N&jrm)hH^(Q{ zkE@f)&5oP1w>WQz&%{5hmOhgI*vn<3V?x~hw98$*%Uv7-0wC^!;C~EJS&`osSkoUu;V|nQU)?FY<|fht5A1SN*uMS# zS6_tpyl5;(cPv`P?w=)9EkxoBEp>*WI#0jCP#715#+pK7mDlgS;BX>Wy34KZwI#Nj zm3M;?tLN@|TVsLJUI`nG@fy$9;@0p=TPW3#V}?t?U@43|vR3Lmoj*7moNfC{#d5Z! z?nuw=OGjXMZj_s7dJ~#xf<|dH$uBMPqbmv!FeW>~G}pyM%N0&X)D7PiJrUR2RHYY9 zaLmT{4+aS1LRmurAzT)Oau@|ptt`wehph>X=e2~<7+&r?EdXU@lwVxplcSOmnnW$( zZBB5bb72@s@0&~SP)pfzvE*+f;0N;v(dIP(_M!7M3h9-WSc>G?%>Yje4+7#Hyn#&u>?*Z%nld*c&tMWax?@gWgjdkOp) zMiIq{-(su)o?>}Tt!Vfg6gy7>XZVX>KK7L_g|E4e0$u}fici8*l`oK=3BDITh$W_> z5uWLfz^htC)c|YMzj^i-;+MY|2F}jW@O9UOzeI_r;FDtKX~UO-55o5bKSN^66VdKB^HUj2$88u$U*e2WR-iM_(fzFrmV`6CIvw9J?~k~^`CCex65;tqpfwdR8b_qi zKW=w90I=9?K1ZhII?FOq4qq@evE4^G%+u#Z!%=5yI##O|vk5KdVso1+ts`QjUKi(g z#3tgg_Jk<3TYir;!e>MvB@81;fv$(GygjZ;v20?j2s06Cgqk`xLDy=mHX8xQWBbzi ziSb%$l>jJb0N@SqQ@SnFy%I!;lxd252uiI~VoLrsoQ1o{HTX6%*evHKrIRz{8_1KF zA=YONtLv$E8Seucid}^e?iTOmF^^R^TCHKgjCi};0a%?n22Y`b?wr)b{ zKg#Kf$vDT4E*4U#PgV9dv?!=>qe7~gizj{DXdpF_izje(D!uj{avDCVzZ1Op=idp& zdF${2dsigG7rZ~R=k@2;>c?KcbJy!H7#lzSx-FZo-IdMlzJ77xx}7=nQs|bM~Ig(a{gIs>$t5ZFeyqk>eRRVHj}ZArxW0OS) zC)xa!yLP|+f>E8(>vkeWuiLbE?apjw$Mu)R zYycE}({10O-CQ|mW=E8i;MnDidL02fXN{Qw$V2F@#iKPbW*zYs>&M=(Yx^56X*Mr; z!~0%$yjiLpx^ZUq=EI|-hi{&px$#i#L!0f6j(0;I_QLcTLbY$FJOuO^z9-Y+?lEBXsv6_i2n z`h5;f>z@qwo~pP%8N(G6x7>Z}g+~ISWH*C)z)xIiv|5Y+m=UhRy&)PHj1Z)5-DBlO zOm?f`=v6@nz=iC>{|#`Y1po+cKK1FKdotuh4s)R)1VZUF$b!_G-4_@!)8;2eSTh+B zfFkZYuizlb`1ie9(C+tJUzC6ne#|CVp)8Q1dZ*pX+dE%32yy_7fPChTI|@0_`L@jr zqln`icoR5RKW7RM1pV@xZi9gL_1uh-nor`NbBRQ$ltdr!rsRAoHJeP%rqHAEL^9E8 zC6ap8Jp-O0zDbUBS0|d^BJWrL&p3e{?HTYha0oq9h{yHJ<#AlM!ub-qulkPOPJ}Fx z+sI7@v>;M{dU}*94eEeNDu7 z#`G+D?Rr#OyO>Df{JK|Ju9T=h`2qy>QsYpqa%i#zmiKu%KTDZUd)GleF9+_t+T6N$ zv3d2X?btEHe0J!)hKtdA4UJNi(Z-BdBkbcu9Q+vkl#uW{0-CDf8PQkoBB;jeNGP7_ z9kgj19Pr#kJ<8C{M3t*WYT8uiloOemDe)85gNgymCQI}iLP*}`uN@z|YtxiYwv z)Bo*IU4gA%7#e;Xm|~v?ktM-cm5qlt*B4RMIlEJ1(hKD(2x&dD=*;bXs ziCAjq)Z6cdUU&TQ{o7}?%y^#j);8C@V_Fnkbrnf7$tUvLijitasij#aS)R>hw^fXH zBb+i?)xU#lGrf1eDXpZG@usgEYqb*9!$uh?qlw{-6ty06KfQYEHrHXt3V>%h_R-sJ zUajs)fhrTF;tONdv)fLvCD8dI0Duvm^xn3+b!FV2I{SdDeeE)6uxYk2cg?&IEPNxs zlrEh!2Wx=MvY-YIU8dF8`UFq(et!={=$865$m%OBjU11J$RU*79tdBlds={tl)cQX zS4LIk%B_?wzr9*-2N}lcHXF^RxEw3_-PGG&YXV%*9vDe2j;Dh#3`+&EKH7+w4F+Sv z=Vl6u&1p4n2ImK-lGEo@)hh4tff1TA(H15cpNy4uB=YHTO@=TO62pxxK_v*3QQi?K z*xXqmTn*qIjL`cXFw?zL0;5RSAzP^Z1b*3F ze|PNGzu3-@0EX@C_T_7qocl-S0NgRM-MxI(N^V;mROpy7HdQX{Y>t3Rp!0JGKu7oy zclGA_MKOPT&+e|B@rv(dcIK_vhV2ZY-H*`a^pk_1WN;h(fv?t28h-j4e3J0g+Bd-E z@OS-_1YZJdEmUdJf~#vu;%@K_11guirjUNeDCWbue%op?m>-v zqk01)OcJ-DSvTYQFYy}U4c%|>GiV}yhry%SENHez_1Lb@#}4MipGS*FiaPck;P)_0 zm{1=hYpld#N$}~;G`OyF0*1dZ{gvkX-j8}kP_LhNd&Odv7;7|wpLeEF#}nYX9o=3! zA_B_+D#UN`3WRZV9@5`m{XO_SxEj|;Vi8@(-Cx0>?}s#G)!@=tDUk{#v_M#ks|6(% zh)(&K`tSK5FEA7S04sPQ8Q>?lkbWohKJY8Bn=qo$R3obFfPWx6*MZO6cQb6lk7G+~ zpsMX3bzKr5W|Tn*zy~`obe`V^%6mW&eE7Mi0NwdFEW_Sy4J5N05n>}+)Bc;BhPY-# z>+hb;b+tWs6RCQpyXqkbOoPyL#^?Vx3Co#Nr|Mf1$*p6}ZSmsvTr}af@Uftr^jKIG zJlx!tD6W-;d$;#l&qe-LtL7dbaf#_!;is?*4xZ`u~rj_P;f-#Lpvk!02gKblyfQ zT3Z|H^&c}`RD_O7!Kie5X>=aa>Gz@0ccXq!PGPOMk#AlHzoBa-N|cEig3tz9*(mk( zt~u_>QHAgb+GqwO2jvcdEwA(>D!~h)oj>H4Q;DtB)TUf+ zTQQm#4+h(X9zxmO5B10~^uG_YVTt8M2_71BMt%s}#KR;(SaJ5KDBW^B|F4(QMw@|T z1jj0DAM=D55?Uz!1kN^5|3~1O-hcgN_yu9rDKOArHF{v1KfzIENHRfM?>`3)lUBXv zrpoxZlLzxR!&`mthHr**$IlJE`$3(1WXJHOL2f)aL*NH@u)0^U9`I&@00>#HHyr{X zm|;DIK}LT|Lw_slDp&=J z@IQ$NdQ#qaI4chyfMQmj@Qqk4g3l9x&_LVmtT!iBYP`qpfmM&!X6CI-n6{B-qut?Q z++nta_9hOC(~Ra>T$?BUZtY)Qb`nVkLo_3VJ|=RUh(82IyXGYRhcFY~)80%jQ9UFM zSQ@-?9IOS-^g{7P+su||DAapfI76DjGI;`9>FKsr8n!boEOE%r?$^IVVbUD)2V${+ zKi2<7$kJ%n@I3jz2D|(D>p8oTgzg}H6MS)x!_OK>8+2cb`b(()zw7;n?M_?&r3xE< zKSGR5fJO3MgtKQ)+8}TEXsDKP`dw3sGUW<5vl^N3W+gi-NvvJUdUdIh7VwYcYYE)w z{-&3Ky@a4{FaPLJ(_Ane4SP6=j<{Q5C>e<`0o6%fmnd*-kPQoTdJF(o;8Z^vu?;tY zrLGsyC7!~L@J)ELa2`A&lGX=>gJ%WtysPj*FuZig3_P{?)#2Ri7{Lcgk+#YzE2>{$4_=X8U+#cAs36QuwupkpK z>1_he5;u_t^s4Y@&^UmYfKT000000RR910|E@g zXb@Fj4?Oh%0|V#)0002tjT5~90002tpMur@hyL*d>I7N<000I60ssI20001Z+GAj3 zU|@dt?;QgJEBF8QKYv+X0Yy*%qXYoB69;y9+I-VPm|Rg11>jS4$F@ChGHS%OZQHiZ zO>Ays+gW8FXA|4Dlg^nQ#991bT~}2@Ls3#kx<37nIpBJ%Ls3qT!Qu80~X$}pfu2g~gPzM@Ky{MyHaXmAkuo*8SQ?Eg z?*G{bq^kv|9!CQAdeB(vPd?O{Amdywm^-IHM`A3JRuFT^GUZS?@4Ga8VexL?0?TP= z!MGn$(OxKk%H*J2vKOdK=>_94)bOW08>Lc4!MrxLmRY>>1-WFG#Z(oQWhK(37snRt z_`V|y4507@0EySXqyPYT+C9Mol;cnU0Kk;N#w?QbCuMZDZTlY1wr$(CZQHhO+vYy* z3I1+E|N%wtS>ean~L2_ ztB|%CNAULeZ2ULjCm2E|l0-J5AhCt`N#-OclP{?JR41w*HIkY_EubFLZRmaUN%{-@ zpTQV`0gT1uV%joCnU8Eo_5^#G^K&exa49YaHArAqe45w{&4;9=<1W`d>QI(Q5|fWI21Nm@azv9?zGrlY!|$Mvjw2feSpKwqh!(qHNSqii%D z%@*Ap%Nna3yA>}QKc6U*=#m(e7?+rn*p%3n_?0Y|oR@r?s-4=EI+QwN>5R&Q5PPXx2EiP+GAj3 zU|^JBC}dz~Xk%an@{j=w10w($2mvH`+I5hHO+`Tv00(!XXatGi?hwJ8rQGy6J40cn)3Em%>M180Y9%&tgBv5l9=lpbac|s8 zvsO4`PpTwTlp5av#)eyL0001Z+HAo?bQ}NxMbY z_AI0W9huHdW-)_Kbfybk`NlcAafPdFWRuKs#SM2n@Wg-@-uU1v3t7ra*0PbU>|`$o zIdXx2{HIb*a+Zr+F_oL#<$*Jf_~Flf%-Bva+qlkr?6JUwe0jq0u)FOJ}HPfT*Okr%w?TI z6sjr*cmQl=7Uh{!Byyc_HRZ-Iv73?$&!%zvKmBPl6&!|er7#z2W6SrwU zkfvT|k;WxSI>y&Cryg*6etx!}olI7>g8_0Z3~965zGu;5SrM2$AlrHF>7ohPn{grz*Z|A$8I|GdTL&sYaxvh!W6C2g?K49yYGo`1uI|CfrsEfu zFM`Xwb!q@jU7af(LyAPJ1Xp=L)BIPSW(T4VRnyoxZ79@W==fJsEvI6XUh)3dWu#&y iR7#&mP0U2gWtRf)B}(Z6lTWqY2(Etsw-Hi30000=B~Kv$ literal 0 HcmV?d00001 diff --git a/assets/fonts/sleep-journal/lora-semibold.woff b/assets/fonts/sleep-journal/lora-semibold.woff new file mode 100644 index 0000000000000000000000000000000000000000..d4c9c1e27782d4ec4600336ffa947d54f6113a46 GIT binary patch literal 26812 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.0.1" } }, + "node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "license": "MIT", + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1449,6 +1474,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1462,6 +1493,12 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 521d15c..26dfcdc 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,13 @@ "@hono/node-server": "^1.13.7", "@xmldom/xmldom": "^0.9.12", "hono": "^4.6.16", + "opentype.js": "^1.3.4", "pngjs": "^7.0.0", "yauzl": "^3.4.0" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/opentype.js": "^1.3.10", "@types/pngjs": "^6.0.5", "@types/yauzl": "^3.4.0", "tsx": "^4.19.2", diff --git a/src/render/sleep-font.ts b/src/render/sleep-font.ts deleted file mode 100644 index f8ee9ba..0000000 --- a/src/render/sleep-font.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Original 5x7 bitmap glyphs; deliberately small and deterministic on e-paper. -const patterns: Record = { - A:'01110/10001/10001/11111/10001/10001/10001', B:'11110/10001/10001/11110/10001/10001/11110', - C:'01111/10000/10000/10000/10000/10000/01111', D:'11110/10001/10001/10001/10001/10001/11110', - E:'11111/10000/10000/11110/10000/10000/11111', F:'11111/10000/10000/11110/10000/10000/10000', - G:'01111/10000/10000/10111/10001/10001/01111', H:'10001/10001/10001/11111/10001/10001/10001', - I:'11111/00100/00100/00100/00100/00100/11111', J:'00111/00010/00010/00010/10010/10010/01100', - K:'10001/10010/10100/11000/10100/10010/10001', L:'10000/10000/10000/10000/10000/10000/11111', - M:'10001/11011/10101/10101/10001/10001/10001', N:'10001/11001/10101/10011/10001/10001/10001', - O:'01110/10001/10001/10001/10001/10001/01110', P:'11110/10001/10001/11110/10000/10000/10000', - Q:'01110/10001/10001/10001/10101/10010/01101', R:'11110/10001/10001/11110/10100/10010/10001', - S:'01111/10000/10000/01110/00001/00001/11110', T:'11111/00100/00100/00100/00100/00100/00100', - U:'10001/10001/10001/10001/10001/10001/01110', V:'10001/10001/10001/10001/10001/01010/00100', - W:'10001/10001/10001/10101/10101/10101/01010', X:'10001/10001/01010/00100/01010/10001/10001', - Y:'10001/10001/01010/00100/00100/00100/00100', Z:'11111/00001/00010/00100/01000/10000/11111', - '0':'01110/10001/10011/10101/11001/10001/01110', '1':'00100/01100/00100/00100/00100/00100/01110', - '2':'01110/10001/00001/00010/00100/01000/11111', '3':'11110/00001/00001/01110/00001/00001/11110', - '4':'00010/00110/01010/10010/11111/00010/00010', '5':'11111/10000/10000/11110/00001/00001/11110', - '6':'01110/10000/10000/11110/10001/10001/01110', '7':'11111/00001/00010/00100/01000/01000/01000', - '8':'01110/10001/10001/01110/10001/10001/01110', '9':'01110/10001/10001/01111/00001/00001/01110', - '/':'00001/00010/00010/00100/01000/01000/10000', '%':'11001/11010/00010/00100/01000/01011/10011', - '!':'00100/00100/00100/00100/00100/00000/00100', '-':'00000/00000/00000/11111/00000/00000/00000', - '.':'00000/00000/00000/00000/00000/00110/00110', '*':'00100/10101/01110/11111/01110/10101/00100', -}; -export const sleepGlyph = (character: string): string[] => (patterns[character] ?? '00000/00000/00000/00000/00000/00000/00000').split('/'); diff --git a/src/render/sleep-graphics.ts b/src/render/sleep-graphics.ts new file mode 100644 index 0000000..61f76c1 --- /dev/null +++ b/src/render/sleep-graphics.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import opentype, { type Font, type PathCommand } from 'opentype.js'; +import { PNG } from 'pngjs'; + +type Point = { x: number; y: number }; +type Rotate = { cx: number; cy: number; angle: number }; +const fonts = new Map(); +function fontFor(weight: 'regular' | 'semibold'): Font { + if (!fonts.has(weight)) { + const bytes = fs.readFileSync(new URL(`../../assets/fonts/sleep-journal/lora-${weight}.woff`, import.meta.url)); + fonts.set(weight, opentype.parse(new Uint8Array(bytes).buffer)); + } + return fonts.get(weight)!; +} + +/** Small pure-JS vector rasterizer. Two scanlines per pixel retain fine serif edges. */ +export class SleepGraphics { + readonly image: PNG; + constructor(width: number, height: number, paper = false) { + this.image = new PNG({ width, height }); + if (paper) this.image.data.fill(255); + } + get width() { return this.image.width; } + get height() { return this.image.height; } + pixel(x: number, y: number, gray: number, alpha = 1) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height || alpha <= 0) return; + const p = (y * this.width + x) * 4, oldAlpha = this.image.data[p + 3] / 255; + const combined = alpha + oldAlpha * (1 - alpha); + const value = Math.round((gray * alpha + this.image.data[p] * oldAlpha * (1 - alpha)) / combined); + this.image.data[p] = this.image.data[p + 1] = this.image.data[p + 2] = value; + this.image.data[p + 3] = Math.round(combined * 255); + } + fill(contours: Point[][], gray = 0) { + const edges: [Point, Point][] = []; + let minY = this.height, maxY = 0; + for (const points of contours) for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length]; + if (a.y !== b.y) edges.push([a,b]); + minY = Math.min(minY, a.y); maxY = Math.max(maxY, a.y); + } + for (let y = Math.max(0, Math.floor(minY)); y < Math.min(this.height, Math.ceil(maxY)); y++) { + const coverage = new Float32Array(this.width); + for (const dy of [.25,.75]) { + const scan = y + dy, xs: number[] = []; + for (const [a,b] of edges) if ((a.y <= scan && b.y > scan) || (b.y <= scan && a.y > scan)) xs.push(a.x + (scan - a.y) * (b.x - a.x) / (b.y - a.y)); + xs.sort((a,b) => a - b); + for (let i = 0; i + 1 < xs.length; i += 2) { + const left = Math.max(0, xs[i]), right = Math.min(this.width, xs[i + 1]); + for (let x = Math.floor(left); x < Math.ceil(right); x++) if (x >= 0 && x < this.width) coverage[x] += .5 * Math.max(0, Math.min(x + 1, right) - Math.max(x, left)); + } + } + for (let x = 0; x < this.width; x++) if (coverage[x]) this.pixel(x, y, gray, Math.min(1, coverage[x])); + } + } + line(x1: number, y1: number, x2: number, y2: number, thickness = 1, gray = 0) { + const length = Math.hypot(x2 - x1, y2 - y1); + if (!length) return; + const dx = -(y2 - y1) / length * thickness / 2, dy = (x2 - x1) / length * thickness / 2; + this.fill([[{ x:x1+dx,y:y1+dy },{ x:x2+dx,y:y2+dy },{ x:x2-dx,y:y2-dy },{ x:x1-dx,y:y1-dy }]], gray); + } + circlePoints(cx: number, cy: number, radius: number): Point[] { + return Array.from({ length: 80 }, (_, i) => ({ x: cx + Math.cos(i / 80 * Math.PI * 2) * radius, y: cy + Math.sin(i / 80 * Math.PI * 2) * radius })); + } + disc(cx: number, cy: number, radius: number, gray = 255) { this.fill([this.circlePoints(cx, cy, radius)], gray); } + ring(cx: number, cy: number, radius: number, thickness = 1, gray = 0) { + this.fill([this.circlePoints(cx,cy,radius), this.circlePoints(cx,cy,radius-thickness)], gray); + } + roundRect(x: number, y: number, width: number, height: number, radius: number, gray = 255, border = 0) { + const points: Point[] = []; + for (const [cx,cy,start] of [[x+width-radius,y+radius,-90],[x+width-radius,y+height-radius,0],[x+radius,y+height-radius,90],[x+radius,y+radius,180]]) { + for (let i = 0; i <= 8; i++) { + const angle = (start + i / 8 * 90) * Math.PI / 180; + points.push({ x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) }); + } + } + if (border) { + this.fill([points], 0); + this.roundRect(x+border,y+border,width-2*border,height-2*border,Math.max(0,radius-border),gray); + } else this.fill([points], gray); + } + text(text: string, x: number, baseline: number, size: number, options: { weight?: 'regular' | 'semibold'; align?: 'left' | 'center' | 'right'; maxWidth?: number; tracking?: number; gray?: number; rotate?: Rotate } = {}) { + const font = fontFor(options.weight ?? 'regular'); + const settings = { kerning: true, tracking: options.tracking ?? 0 }; + const measured = font.getAdvanceWidth(text, size, settings); + if (options.maxWidth && measured > options.maxWidth) size *= options.maxWidth / measured; + const width = font.getAdvanceWidth(text, size, settings); + if (options.align === 'center') x -= width / 2; + if (options.align === 'right') x -= width; + this.path(font.getPath(text, x, baseline, size, settings).commands, options.gray ?? 0, options.rotate); + } + path(commands: PathCommand[], gray = 0, rotate?: Rotate) { + const contours: Point[][] = []; + let points: Point[] = [], previous = { x: 0, y: 0 }; + const add = (point: Point) => { + previous = point; + if (rotate) { + const x = point.x - rotate.cx, y = point.y - rotate.cy; + points.push({ x: rotate.cx + x * Math.cos(rotate.angle) - y * Math.sin(rotate.angle), y: rotate.cy + x * Math.sin(rotate.angle) + y * Math.cos(rotate.angle) }); + } else points.push(point); + }; + for (const command of commands) { + if (command.type === 'M') { + if (points.length) contours.push(points); + points = []; add({ x: command.x, y: command.y }); + } else if (command.type === 'L') add({ x: command.x, y: command.y }); + else if (command.type === 'Q' || command.type === 'C') { + const a = previous, b = { x: command.x1, y: command.y1 }, end = { x: command.x, y: command.y }; + const c = command.type === 'C' ? { x: command.x2, y: command.y2 } : end; + const steps = Math.max(6, Math.min(40, Math.ceil((Math.hypot(b.x-a.x,b.y-a.y)+Math.hypot(c.x-b.x,c.y-b.y)+Math.hypot(end.x-c.x,end.y-c.y))/3))); + for (let i = 1; i <= steps; i++) { + const t = i / steps, u = 1 - t; + add(command.type === 'Q' + ? { x:u*u*a.x+2*u*t*b.x+t*t*end.x, y:u*u*a.y+2*u*t*b.y+t*t*end.y } + : { x:u*u*u*a.x+3*u*u*t*b.x+3*u*t*t*c.x+t*t*t*end.x, y:u*u*u*a.y+3*u*u*t*b.y+3*u*t*t*c.y+t*t*t*end.y }); + } + } else if (command.type === 'Z') { if (points.length) contours.push(points); points = []; } + } + if (points.length) contours.push(points); + this.fill(contours, gray); + } +} diff --git a/src/render/sleep-image.ts b/src/render/sleep-image.ts index 3e43897..cafc29c 100644 --- a/src/render/sleep-image.ts +++ b/src/render/sleep-image.ts @@ -1,7 +1,7 @@ import { PNG } from 'pngjs'; import zlib from 'node:zlib'; import { MAX_BACKGROUND_BYTES, MAX_SLEEP_PIXELS, SleepImageError, type SleepMetrics, type SleepOptions } from '../models/sleep-image.js'; -import { sleepGlyph } from './sleep-font.js'; +import { drawJournal } from './sleep-journal.js'; export function normalizeBackground(bytes: Buffer): Buffer { if (bytes.length > MAX_BACKGROUND_BYTES) throw new SleepImageError('Background exceeds 8 MB.'); @@ -52,75 +52,8 @@ function validatePngStream(bytes: Buffer, width: number, height: number): void { } catch { fail(); } } -type PanelLine = { text: string; fraction?: number; large?: boolean }; -export function panelLines(stats: SleepMetrics, options: SleepOptions): PanelLine[] { - const lines: PanelLine[] = []; - if (options.metrics.includes('books')) lines.push({ text: `${stats.finished} BOOK${stats.finished === 1 ? '' : 'S'} IN ${stats.year}`, large: true }); - if (options.metrics.includes('goal') && stats.target !== null) { - lines.push({ text: stats.finished >= stats.target ? '* GOAL REACHED! *' : 'YOUR READING GOAL' }); - lines.push({ text: `${stats.finished} / ${stats.target} BOOKS`, fraction: Math.min(1, stats.finished / stats.target) }); - } - if (options.metrics.includes('current') && stats.currentPercent !== null) lines.push({ text: `CURRENT BOOK ${Math.floor(stats.currentPercent)}%`, fraction: stats.currentPercent / 100 }); - if (options.metrics.includes('streak')) lines.push({ text: `${stats.streak} DAY${stats.streak === 1 ? '' : 'S'} READING STREAK` }); - return lines; -} - export function renderSleepImage(stats: SleepMetrics, options: SleepOptions, background?: Buffer): { bytes: Buffer; contentType: 'image/png' | 'image/bmp' } { - const { width, height } = options; - const png = new PNG({ width, height }); - const pixel = (x: number, y: number, value: number, alpha = 255) => { - if (x < 0 || y < 0 || x >= width || y >= height) return; - const p = (y * width + x) * 4; - png.data[p] = png.data[p + 1] = png.data[p + 2] = value; - png.data[p + 3] = alpha; - }; - const rect = (x: number, y: number, w: number, h: number, value: number) => { - for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) pixel(xx, yy, value); - }; - if (options.mode === 'custom') { - png.data.fill(255); - if (background) { - const source = PNG.sync.read(background); - const scale = Math.max(width / source.width, height / source.height); - const left = (source.width - width / scale) / 2, top = (source.height - height / scale) / 2; - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - const p = (Math.min(source.height - 1, Math.floor(top + y / scale)) * source.width + Math.min(source.width - 1, Math.floor(left + x / scale))) * 4; - const alpha = source.data[p + 3] / 255; - const gray = Math.round((source.data[p] * .299 + source.data[p + 1] * .587 + source.data[p + 2] * .114) * alpha + 255 * (1 - alpha)); - pixel(x, y, gray); - } - } - } - const lines = panelLines(stats, options); - if (lines.length) { - const logicalHeight = 36 + lines.reduce((sum, line) => sum + 16 + (line.fraction !== undefined ? 8 : 0), 0); - const scale = Math.max(1, Math.min(Math.floor(width / 240), Math.floor(height / logicalHeight))); - const margin = 8 * scale, padding = 10 * scale, lineHeight = 16 * scale; - const panelHeight = padding * 2 + lines.reduce((sum, l) => sum + lineHeight + (l.fraction !== undefined ? 8 * scale : 0), 0); - const y0 = options.placement === 'top' ? margin : height - margin - panelHeight; - rect(margin, y0, width - margin * 2, panelHeight, 0); - rect(margin + scale, y0 + scale, width - margin * 2 - 2 * scale, panelHeight - 2 * scale, 255); - let y = y0 + padding; - for (const line of lines) { - const available = width - 2 * (margin + padding); - const fontScale = Math.max(1, Math.min(line.large ? scale + 1 : scale, Math.floor(available / (line.text.length * 6)))); - let x = margin + padding; - for (const ch of line.text) { - sleepGlyph(ch).forEach((row, yy) => [...row].forEach((bit, xx) => { - if (bit === '1') rect(x + xx * fontScale, y + yy * fontScale, fontScale, fontScale, 0); - })); - x += 6 * fontScale; - } - y += lineHeight; - if (line.fraction !== undefined) { - const barWidth = available, barHeight = 4 * scale; - rect(margin + padding, y - 3 * scale, barWidth, barHeight, 0); - rect(margin + padding + scale, y - 2 * scale, barWidth - scale * 2, barHeight - scale * 2, 255); - rect(margin + padding, y - 3 * scale, Math.round(barWidth * line.fraction), barHeight, 0); - y += 8 * scale; - } - } - } + const png = drawJournal(stats, options, background); if (options.mode === 'custom') { // Match the display's native four shades and bypass firmware conversion caches. for (let p = 0; p < png.data.length; p += 4) png.data[p] = png.data[p + 1] = png.data[p + 2] = Math.round(png.data[p] / 85) * 85; diff --git a/src/render/sleep-journal.ts b/src/render/sleep-journal.ts new file mode 100644 index 0000000..49dca6a --- /dev/null +++ b/src/render/sleep-journal.ts @@ -0,0 +1,153 @@ +import { PNG } from 'pngjs'; +import type { SleepMetrics, SleepOptions } from '../models/sleep-image.js'; +import { SleepGraphics } from './sleep-graphics.js'; + +interface Rect { x: number; y: number; width: number; height: number } +interface Cell { value: string; label: string; fraction?: number } +interface Seal { cx: number; cy: number; radius: number; angle: number; count: string; reached: boolean } +export interface JournalLayout { scale: number; card: Rect; art?: Rect; fullPage: boolean; cells: Cell[]; seal?: Seal } + +export function journalLayout(stats: SleepMetrics, options: SleepOptions): JournalLayout { + const { width, height, metrics } = options; + const scale = Math.max(.5, Math.min(width / 480, height / 360)); + const margin = Math.max(10, 22 * scale), padding = 20 * scale; + const cells: Cell[] = []; + if (metrics.includes('books')) cells.push({ value: String(stats.finished), label: 'books this year' }); + if (metrics.includes('current') && stats.currentPercent !== null) cells.push({ value: `${Math.floor(stats.currentPercent)}%`, label: 'current book', fraction: stats.currentPercent / 100 }); + if (metrics.includes('streak')) cells.push({ value: String(stats.streak), label: 'day streak' }); + const hasSeal = metrics.includes('goal') && stats.target !== null; + const radius = Math.max(34, 55 * scale), rise = hasSeal ? radius * .72 : 0; + const cardHeight = Math.max(128, (cells.length ? 190 : 116) * scale); + const fullPage = options.mode === 'custom' && height >= width * 1.25 && height >= 480; + const inset = fullPage ? 12 * scale : 0; + const card: Rect = { x: margin + inset, y: 0, width: width - 2 * (margin + inset), height: cardHeight }; + const bottom = height - margin - inset; + card.y = options.placement === 'bottom' ? bottom - cardHeight : margin + (fullPage ? 108 * scale : 0) + rise; + const seal: Seal | undefined = hasSeal ? { + cx: card.x + card.width - padding - radius, + cy: card.y + radius - rise, + radius, angle: -10 * Math.PI / 180, count: `${stats.finished} / ${stats.target}`, + reached: stats.finished >= stats.target!, + } : undefined; + let art: Rect | undefined; + if (fullPage) { + const top = options.placement === 'bottom' ? margin + 108 * scale : card.y + card.height + 24 * scale; + const end = options.placement === 'bottom' ? card.y - 24 * scale : bottom; + art = { x: card.x + 4 * scale, y: top, width: card.width - 8 * scale, height: end - top }; + } + return { scale, card, art, fullPage, cells, seal }; +} + +function star(g: SleepGraphics, cx: number, cy: number, size: number, gray = 0) { + g.line(cx-size,cy,cx+size,cy,1,gray); g.line(cx,cy-size,cx,cy+size,1,gray); + g.line(cx-size*.45,cy-size*.45,cx+size*.45,cy+size*.45,.7,gray); + g.line(cx+size*.45,cy-size*.45,cx-size*.45,cy+size*.45,.7,gray); +} + +function book(g: SleepGraphics, cx: number, cy: number, width: number, height: number, stroke: number) { + // Two bowed pages form an original open-book motif. + for (const direction of [-1,1]) { + const edge = cx + direction * width / 2; + g.path([ + { type:'M',x:cx,y:cy }, + { type:'Q',x1:cx+direction*width*.25,y1:cy-height*.26,x:edge,y:cy-height*.13 }, + { type:'L',x:edge,y:cy+height*.68 }, + { type:'Q',x1:cx+direction*width*.25,y1:cy+height*.53,x:cx,y:cy+height*.83 }, + { type:'Z' }, + ], 0); + g.path([ + { type:'M',x:cx+direction*stroke,y:cy+stroke }, + { type:'Q',x1:cx+direction*width*.25,y1:cy-height*.22+stroke,x:edge-direction*stroke,y:cy-height*.13+stroke }, + { type:'L',x:edge-direction*stroke,y:cy+height*.68-stroke }, + { type:'Q',x1:cx+direction*width*.25,y1:cy+height*.53-stroke,x:cx+direction*stroke,y:cy+height*.83-stroke }, + { type:'Z' }, + ], 255); + for (let row = 0; row < 4; row++) { + const y = cy + height * (.15 + row * .12); + g.line(cx+direction*width*.1,y,edge-direction*width*.1,y-height*.06,stroke*.65,85); + } + } +} + +function artwork(g: SleepGraphics, rect: Rect, scale: number, background?: Buffer) { + const { x,y,width,height } = rect; + const radius = 12 * scale; + g.roundRect(x-2*scale,y-2*scale,width+4*scale,height+4*scale,radius,255,Math.max(1,scale)); + if (background) { + const source = PNG.sync.read(background); + const ratio = Math.max(width/source.width,height/source.height); + const left = (source.width-width/ratio)/2, top = (source.height-height/ratio)/2; + for (let yy = Math.ceil(y); yy < y+height; yy++) for (let xx = Math.ceil(x); xx < x+width; xx++) { + const localX = xx+.5-x, localY = yy+.5-y; + const cornerX = Math.max(radius-localX,localX-(width-radius),0), cornerY = Math.max(radius-localY,localY-(height-radius),0); + if (cornerX*cornerX+cornerY*cornerY > radius*radius) continue; + const sx = Math.max(0,Math.min(source.width-1,Math.floor(left+localX/ratio))); + const sy = Math.max(0,Math.min(source.height-1,Math.floor(top+localY/ratio))); + const p = (sy*source.width+sx)*4, alpha = source.data[p+3]/255; + g.pixel(xx,yy,Math.round((source.data[p]*.299+source.data[p+1]*.587+source.data[p+2]*.114)*alpha+255*(1-alpha))); + } + } else { + const cx = x+width/2, cy = y+height*.49; + const bw = Math.min(width*.65,height*.73), bh = bw*.53; + book(g,cx,cy-bh*.25,bw,bh,Math.max(1,1.4*scale)); + star(g,cx+width*.25,cy-height*.23,8*scale); + star(g,cx-width*.22,cy-height*.18,5*scale,85); + star(g,cx+width*.08,cy-height*.31,4*scale,85); + for (const direction of [-1,1]) g.line(cx+direction*bw*.53,cy+bh*.73,cx+direction*bw*.63,cy+bh*.73,scale,85); + g.text('Good stories, quiet moments.',cx,y+height-26*scale,Math.max(10,13*scale),{ align:'center',maxWidth:width-24*scale,gray:85 }); + } +} + +export function drawJournal(stats: SleepMetrics, options: SleepOptions, background?: Buffer): PNG { + const g = new SleepGraphics(options.width,options.height,options.mode==='custom'); + const layout = journalLayout(stats,options); + const { scale:s,card,seal,cells } = layout; + const pad = 20*s, stroke = Math.max(1,s); + if (layout.fullPage) { + const m = Math.max(10,22*s); + g.roundRect(m-7*s,m-7*s,options.width-2*m+14*s,options.height-2*m+14*s,16*s,255,stroke); + book(g,options.width/2,m+7*s,26*s,14*s,Math.max(.8,s)); + g.text('THE READING JOURNAL',options.width/2,m+48*s,Math.max(9,11*s),{ align:'center',tracking:130,maxWidth:options.width-2*m }); + g.text('One more chapter.',options.width/2,m+81*s,27*s,{ align:'center',maxWidth:options.width-2*m-16*s }); + if (layout.art && layout.art.height > 0) artwork(g,layout.art,s,background); + } else if (options.mode==='custom') { + artwork(g,{ x:12*s,y:12*s,width:options.width-24*s,height:options.height-24*s },s,background); + } + if (!cells.length && !seal) return g.image; + g.roundRect(card.x,card.y,card.width,card.height,12*s,255,stroke); + const titleWidth = seal ? seal.cx-seal.radius-card.x-pad-6*s : card.width-2*pad; + g.text(`READING JOURNAL / ${stats.year}`,card.x+pad,card.y+27*s,Math.max(8.5,10*s),{ tracking:65,maxWidth:titleWidth,gray:85 }); + g.text(seal?.reached ? 'Well read.' : 'Between chapters',card.x+pad,card.y+64*s,Math.max(17,25*s),{ maxWidth:titleWidth }); + if (cells.length) { + const divider = card.y+card.height*.47; + g.line(card.x+pad,divider,card.x+card.width-pad,divider,Math.max(.7,.8*s),85); + const cellWidth = (card.width-2*pad)/cells.length; + cells.forEach((cell,index) => { + const cx = card.x+pad+cellWidth*(index+.5); + const numberY = divider+(card.height-(divider-card.y))*.43; + g.text(cell.value,cx,numberY,Math.max(20,33*s),{ align:'center',weight:'semibold',maxWidth:cellWidth-8*s }); + g.text(cell.label,cx,numberY+23*s,Math.max(8,10.5*s),{ align:'center',maxWidth:cellWidth-4*s }); + if (index) g.line(card.x+pad+cellWidth*index,divider+18*s,card.x+pad+cellWidth*index,card.y+card.height-27*s,Math.max(.6,.7*s),170); + if (cell.fraction !== undefined) { + const w = Math.min(cellWidth-14*s,88*s), y = card.y+card.height-15*s; + g.line(cx-w/2,y,cx+w/2,y,2*s,170); + if (cell.fraction > 0) g.line(cx-w/2,y,cx-w/2+w*cell.fraction,y,2*s,0); + } + }); + } + if (seal) { + const { cx,cy,radius:r,angle } = seal; + g.disc(cx,cy,r+2*s); + g.ring(cx,cy,r,Math.max(1,1.3*s)); + g.ring(cx,cy,r-5*s,Math.max(.6,.7*s),85); + const rotate = { cx,cy,angle }; + g.text(seal.reached?'GOAL REACHED':'YEARLY GOAL',cx,cy-17*s,Math.max(8,9*s),{ align:'center',weight:'semibold',tracking:50,maxWidth:r*1.5,rotate }); + g.text(seal.count,cx,cy+8*s,Math.max(13,21*s),{ align:'center',weight:'semibold',maxWidth:r*1.5,rotate }); + g.text(String(stats.year),cx,cy+29*s,Math.max(9,11*s),{ align:'center',tracking:140,rotate }); + if (seal.reached) { + star(g,cx-r*.86,cy-r*.58,5*s); + star(g,cx+r*.85,cy+r*.58,5*s); + } + } + return g.image; +} diff --git a/test/sleep-image-render.test.ts b/test/sleep-image-render.test.ts index 137aebd..5d8469f 100644 --- a/test/sleep-image-render.test.ts +++ b/test/sleep-image-render.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { PNG } from 'pngjs'; import { deflateSync } from 'node:zlib'; import zlib from 'node:zlib'; -import { normalizeBackground, panelLines, renderSleepImage } from '../src/render/sleep-image.js'; +import { normalizeBackground, renderSleepImage } from '../src/render/sleep-image.js'; +import { journalLayout } from '../src/render/sleep-journal.js'; import { parseSleepOptions } from '../src/models/sleep-image.js'; const stats = { year: 2026, finished: 12, target: 24, currentPercent: 42, streak: 3 }; @@ -16,18 +17,19 @@ describe('sleep image rendering', () => { const alpha = (x: number, y: number) => png.data[(y * png.width + x) * 4 + 3]; expect(alpha(30, 770)).toBe(255); const top = PNG.sync.read(renderSleepImage(stats, parseSleepOptions({ placement: 'top' })).bytes); - expect(top.data[(30 * 480 + 30) * 4 + 3]).toBe(255); + expect(top.data[(200 * 480 + 240) * 4 + 3]).toBe(255); expect(top.data[(770 * 480 + 30) * 4 + 3]).toBe(0); }); it('celebrates reached and exceeded goals and hides absent values', () => { const options = parseSleepOptions({}); - expect(panelLines(stats, options).map(l => l.text).join(' ')).toContain('12 / 24'); - expect(panelLines({ ...stats, target: 12 }, options).some(l => l.text.includes('GOAL REACHED'))).toBe(true); - expect(panelLines({ ...stats, target: 10 }, options).map(l => l.text).join(' ')).toContain('12 / 10'); - const absent = panelLines({ ...stats, target: null, currentPercent: null }, options).map(l => l.text).join(' '); - expect(absent).not.toMatch(/GOAL|CURRENT/); - expect(panelLines(stats, parseSleepOptions({ metrics: 'streak' })).map(l => l.text).join(' ')).not.toMatch(/BOOKS|CURRENT|GOAL/); + expect(journalLayout(stats, options).seal!.count).toBe('12 / 24'); + expect(journalLayout({ ...stats, target: 12 }, options).seal!.reached).toBe(true); + expect(journalLayout({ ...stats, target: 10 }, options).seal!.count).toBe('12 / 10'); + const absent = journalLayout({ ...stats, target: null, currentPercent: null }, options); + expect(absent.seal).toBeUndefined(); + expect(absent.cells.map(c => c.label)).not.toContain('current book'); + expect(journalLayout(stats, parseSleepOptions({ metrics: 'streak' })).cells.map(c => c.label)).toEqual(['day streak']); }); it('encodes screen-ready 2-bit BMPs below the firmware cap even with noisy backgrounds', () => { diff --git a/test/sleep-journal.test.ts b/test/sleep-journal.test.ts new file mode 100644 index 0000000..6faad95 --- /dev/null +++ b/test/sleep-journal.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { PNG } from 'pngjs'; +import { journalLayout } from '../src/render/sleep-journal.js'; +import { renderSleepImage } from '../src/render/sleep-image.js'; +import { parseSleepOptions } from '../src/models/sleep-image.js'; + +const stats = { year: 2026, finished: 12, target: 24, currentPercent: 42, streak: 7 }; + +describe('reading journal composition', () => { + it('overlaps the compact bookplate with a tilted annual-goal seal', () => { + const layout = journalLayout(stats, parseSleepOptions({})); + expect(layout.card.y).toBeGreaterThan(500); + expect(layout.seal!.angle).toBeCloseTo(-10 * Math.PI / 180); + expect(layout.seal!.cy - layout.seal!.radius).toBeLessThan(layout.card.y); + expect(layout.seal!.cy + layout.seal!.radius).toBeGreaterThan(layout.card.y); + expect(layout.cells.map(cell => cell.value)).toEqual(['12', '42%', '7']); + expect(layout.art).toBeUndefined(); + }); + + it('uses a framed artwork region for full-page Custom and retains paper margins', () => { + const options = parseSleepOptions({ mode: 'custom', preview: '1' }); + const layout = journalLayout(stats, options); + expect(layout.fullPage).toBe(true); + expect(layout.art!.height).toBeGreaterThan(160); + expect(layout.art!.y + layout.art!.height).toBeLessThan(layout.card.y); + const background = new PNG({ width: 240, height: 240 }); + for (let p = 0; p < background.data.length; p += 4) background.data.set([85,85,85,255], p); + const png = PNG.sync.read(renderSleepImage(stats, options, PNG.sync.write(background)).bytes); + expect([...png.data.subarray(0, 4)]).toEqual([255,255,255,255]); + const art = layout.art!; + const center = ((Math.floor(art.y + art.height / 2) * png.width) + Math.floor(art.x + art.width / 2)) * 4; + expect([...png.data.subarray(center, center + 4)]).toEqual([85,85,85,255]); + }); + + it('hides absent and disabled metrics including their seal', () => { + const layout = journalLayout({ ...stats, target: null, currentPercent: null }, parseSleepOptions({ metrics: 'goal,current,streak' })); + expect(layout.seal).toBeUndefined(); + expect(layout.cells.map(cell => cell.value)).toEqual(['7']); + const reached = journalLayout({ ...stats, finished: 30 }, parseSleepOptions({})); + expect(reached.seal!.reached).toBe(true); + expect(reached.seal!.count).toBe('30 / 24'); + }); + + it('keeps cards and stamps in bounds at supported sizes and placements', () => { + for (const [width, height] of [[240,320], [480,800], [1200,1600], [1200,240]]) { + for (const mode of ['overlay','custom']) for (const placement of ['top','bottom']) { + const layout = journalLayout(stats, parseSleepOptions({ width: String(width), height: String(height), mode, placement })); + const card = layout.card; + expect(card.x).toBeGreaterThanOrEqual(1); + expect(card.y).toBeGreaterThanOrEqual(1); + expect(card.x + card.width).toBeLessThan(width); + expect(card.y + card.height).toBeLessThan(height); + if (layout.seal) { + const { cx, cy, radius } = layout.seal; + expect(cx - radius).toBeGreaterThanOrEqual(1); + expect(cy - radius).toBeGreaterThanOrEqual(1); + expect(cx + radius).toBeLessThan(width); + expect(cy + radius).toBeLessThan(height); + } + } + } + }); +}); From f39c744c02157864356093f6e1e869275756e3c6 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:53:50 -0500 Subject: [PATCH 7/8] feat: refine e-ink stats typography and goal progress seal --- assets/fonts/sleep-journal/lora-bold.woff | Bin 0 -> 25940 bytes docs/API.md | 2 +- src/render/sleep-graphics.ts | 4 +- src/render/sleep-journal.ts | 121 +++++++++++++++------- test/sleep-journal.test.ts | 33 ++++++ 5 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 assets/fonts/sleep-journal/lora-bold.woff diff --git a/assets/fonts/sleep-journal/lora-bold.woff b/assets/fonts/sleep-journal/lora-bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..77bdfb72a0b51d347254074136efd48be02d4cc3 GIT binary patch literal 25940 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 NtMw+~`VW|4R({1)>Bj&7 literal 0 HcmV?d00001 diff --git a/docs/API.md b/docs/API.md index 142f4ef..6ac09d0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1000,4 +1000,4 @@ These routes require a server version including migration `0049_sleep_background All image/background responses are private and not cached. Backgrounds belong to one account, are cleared when its reading data/account is deleted, and are presentation data outside reading-history exports. No external image URL fetching is performed. IANA timezone rules are evaluated on each render, including seasonal offsets. These routes work under `BASE_PATH` using the same prefix as all other APIs. -The sleep images use a reading-journal design: a compact transparent bookplate with serif type and an annual-goal seal tilted approximately ten degrees, or a full-page Custom composition with framed account artwork. Without artwork, Custom draws an original open-book illustration. Top/bottom placement and metric omission work in both modes; short landscape screens use the compact composition. Fonts (Lora regular/semibold) and their SIL OFL license are bundled under `assets/fonts/sleep-journal`; rendering uses pure JavaScript and no runtime downloads or native graphics libraries. +The sleep images use a reading-journal design: a compact transparent bookplate with serif type and an annual-goal seal tilted approximately ten degrees, or a full-page Custom composition with framed account artwork. Without artwork, Custom draws an original open-book illustration. Top/bottom placement and metric omission work in both modes; short landscape screens use the compact composition. Fonts (Lora regular/semibold/bold) and their SIL OFL license are bundled under `assets/fonts/sleep-journal`; rendering uses pure JavaScript and no runtime downloads or native graphics libraries. diff --git a/src/render/sleep-graphics.ts b/src/render/sleep-graphics.ts index 61f76c1..89a8214 100644 --- a/src/render/sleep-graphics.ts +++ b/src/render/sleep-graphics.ts @@ -5,7 +5,7 @@ import { PNG } from 'pngjs'; type Point = { x: number; y: number }; type Rotate = { cx: number; cy: number; angle: number }; const fonts = new Map(); -function fontFor(weight: 'regular' | 'semibold'): Font { +function fontFor(weight: 'regular' | 'semibold' | 'bold'): Font { if (!fonts.has(weight)) { const bytes = fs.readFileSync(new URL(`../../assets/fonts/sleep-journal/lora-${weight}.woff`, import.meta.url)); fonts.set(weight, opentype.parse(new Uint8Array(bytes).buffer)); @@ -78,7 +78,7 @@ export class SleepGraphics { this.roundRect(x+border,y+border,width-2*border,height-2*border,Math.max(0,radius-border),gray); } else this.fill([points], gray); } - text(text: string, x: number, baseline: number, size: number, options: { weight?: 'regular' | 'semibold'; align?: 'left' | 'center' | 'right'; maxWidth?: number; tracking?: number; gray?: number; rotate?: Rotate } = {}) { + text(text: string, x: number, baseline: number, size: number, options: { weight?: 'regular' | 'semibold' | 'bold'; align?: 'left' | 'center' | 'right'; maxWidth?: number; tracking?: number; gray?: number; rotate?: Rotate } = {}) { const font = fontFor(options.weight ?? 'regular'); const settings = { kerning: true, tracking: options.tracking ?? 0 }; const measured = font.getAdvanceWidth(text, size, settings); diff --git a/src/render/sleep-journal.ts b/src/render/sleep-journal.ts index 49dca6a..2fc1e8d 100644 --- a/src/render/sleep-journal.ts +++ b/src/render/sleep-journal.ts @@ -4,8 +4,8 @@ import { SleepGraphics } from './sleep-graphics.js'; interface Rect { x: number; y: number; width: number; height: number } interface Cell { value: string; label: string; fraction?: number } -interface Seal { cx: number; cy: number; radius: number; angle: number; count: string; reached: boolean } -export interface JournalLayout { scale: number; card: Rect; art?: Rect; fullPage: boolean; cells: Cell[]; seal?: Seal } +interface Seal { cx: number; cy: number; radius: number; angle: number; count: string; reached: boolean; fraction: number } +export interface JournalLayout { scale: number; headline: string; card: Rect; art?: Rect; fullPage: boolean; cells: Cell[]; seal?: Seal } export function journalLayout(stats: SleepMetrics, options: SleepOptions): JournalLayout { const { width, height, metrics } = options; @@ -16,8 +16,8 @@ export function journalLayout(stats: SleepMetrics, options: SleepOptions): Journ if (metrics.includes('current') && stats.currentPercent !== null) cells.push({ value: `${Math.floor(stats.currentPercent)}%`, label: 'current book', fraction: stats.currentPercent / 100 }); if (metrics.includes('streak')) cells.push({ value: String(stats.streak), label: 'day streak' }); const hasSeal = metrics.includes('goal') && stats.target !== null; - const radius = Math.max(34, 55 * scale), rise = hasSeal ? radius * .72 : 0; - const cardHeight = Math.max(128, (cells.length ? 190 : 116) * scale); + const radius = Math.max(42, 64 * scale), rise = hasSeal ? radius * .72 : 0; + const cardHeight = Math.max(128, (cells.length ? 178 : 116) * scale); const fullPage = options.mode === 'custom' && height >= width * 1.25 && height >= 480; const inset = fullPage ? 12 * scale : 0; const card: Rect = { x: margin + inset, y: 0, width: width - 2 * (margin + inset), height: cardHeight }; @@ -25,9 +25,10 @@ export function journalLayout(stats: SleepMetrics, options: SleepOptions): Journ card.y = options.placement === 'bottom' ? bottom - cardHeight : margin + (fullPage ? 108 * scale : 0) + rise; const seal: Seal | undefined = hasSeal ? { cx: card.x + card.width - padding - radius, - cy: card.y + radius - rise, + cy: card.y + radius - rise - 8 * scale, radius, angle: -10 * Math.PI / 180, count: `${stats.finished} / ${stats.target}`, reached: stats.finished >= stats.target!, + fraction: Math.max(0, Math.min(1, stats.finished / stats.target!)), } : undefined; let art: Rect | undefined; if (fullPage) { @@ -35,13 +36,15 @@ export function journalLayout(stats: SleepMetrics, options: SleepOptions): Journ const end = options.placement === 'bottom' ? card.y - 24 * scale : bottom; art = { x: card.x + 4 * scale, y: top, width: card.width - 8 * scale, height: end - top }; } - return { scale, card, art, fullPage, cells, seal }; + const remaining = seal ? Math.max(0, stats.target! - stats.finished) : 0; + const headline = seal ? (seal.reached ? 'You did it!' : `${remaining} ${remaining === 1 ? 'book' : 'books'} to go.`) : 'Between chapters'; + return { scale, headline, card, art, fullPage, cells, seal }; } function star(g: SleepGraphics, cx: number, cy: number, size: number, gray = 0) { - g.line(cx-size,cy,cx+size,cy,1,gray); g.line(cx,cy-size,cx,cy+size,1,gray); - g.line(cx-size*.45,cy-size*.45,cx+size*.45,cy+size*.45,.7,gray); - g.line(cx+size*.45,cy-size*.45,cx-size*.45,cy+size*.45,.7,gray); + g.line(cx-size,cy,cx+size,cy,2.4,gray); g.line(cx,cy-size,cx,cy+size,2.4,gray); + g.line(cx-size*.45,cy-size*.45,cx+size*.45,cy+size*.45,1.7,gray); + g.line(cx+size*.45,cy-size*.45,cx-size*.45,cy+size*.45,1.7,gray); } function book(g: SleepGraphics, cx: number, cy: number, width: number, height: number, stroke: number) { @@ -64,15 +67,37 @@ function book(g: SleepGraphics, cx: number, cy: number, width: number, height: n ], 255); for (let row = 0; row < 4; row++) { const y = cy + height * (.15 + row * .12); - g.line(cx+direction*width*.1,y,edge-direction*width*.1,y-height*.06,stroke*.65,85); + g.line(cx+direction*width*.1,y,edge-direction*width*.1,y-height*.06,stroke*.8,0); } } } +function celebration(g: SleepGraphics, rect: Rect, scale: number, finished: number) { + const { x, y, width, height } = rect; + const cx = x + width / 2, cy = y + height * .40; + const radius = Math.min(width * .29, height * .21); + // A burst of ink around the book turns the quiet journal into an award page. + for (let i = 0; i < 20; i++) { + const angle = i * Math.PI / 10; + const inner = radius * 1.15, outer = radius * (i % 2 ? 1.34 : 1.52); + g.line(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner, + cx + Math.cos(angle) * outer, cy + Math.sin(angle) * outer, Math.max(2, 3.8 * scale)); + } + g.ring(cx, cy, radius, Math.max(2, 4 * scale)); + book(g, cx, cy - radius * .24, radius * 1.35, radius * .73, Math.max(2, 4 * scale)); + for (const [dx, dy, size] of [[-.39,-.32,9],[.39,-.27,7],[-.37,.25,6],[.38,.28,10]]) { + star(g, cx + width * dx, cy + height * dy, size * scale); + } + g.text('GOAL COMPLETE!', cx, y + height * .79, 25 * scale, + { align: 'center', weight: 'bold', maxWidth: width - 24 * scale }); + g.text(`${finished} books. A year of stories.`, cx, y + height * .88, 16 * scale, + { weight: 'bold', align: 'center', maxWidth: width - 24 * scale }); +} + function artwork(g: SleepGraphics, rect: Rect, scale: number, background?: Buffer) { const { x,y,width,height } = rect; const radius = 12 * scale; - g.roundRect(x-2*scale,y-2*scale,width+4*scale,height+4*scale,radius,255,Math.max(1,scale)); + g.roundRect(x-2*scale,y-2*scale,width+4*scale,height+4*scale,radius,255,Math.max(2,3*scale)); if (background) { const source = PNG.sync.read(background); const ratio = Math.max(width/source.width,height/source.height); @@ -89,12 +114,12 @@ function artwork(g: SleepGraphics, rect: Rect, scale: number, background?: Buffe } else { const cx = x+width/2, cy = y+height*.49; const bw = Math.min(width*.65,height*.73), bh = bw*.53; - book(g,cx,cy-bh*.25,bw,bh,Math.max(1,1.4*scale)); + book(g,cx,cy-bh*.25,bw,bh,Math.max(2,3*scale)); star(g,cx+width*.25,cy-height*.23,8*scale); - star(g,cx-width*.22,cy-height*.18,5*scale,85); - star(g,cx+width*.08,cy-height*.31,4*scale,85); - for (const direction of [-1,1]) g.line(cx+direction*bw*.53,cy+bh*.73,cx+direction*bw*.63,cy+bh*.73,scale,85); - g.text('Good stories, quiet moments.',cx,y+height-26*scale,Math.max(10,13*scale),{ align:'center',maxWidth:width-24*scale,gray:85 }); + star(g,cx-width*.22,cy-height*.18,5*scale,0); + star(g,cx+width*.08,cy-height*.31,4*scale,0); + for (const direction of [-1,1]) g.line(cx+direction*bw*.53,cy+bh*.73,cx+direction*bw*.63,cy+bh*.73,1.6*scale,0); + g.text('Good stories, quiet moments.',cx,y+height-26*scale,Math.max(11,14*scale),{ align:'center',weight:'bold',maxWidth:width-24*scale }); } } @@ -102,51 +127,75 @@ export function drawJournal(stats: SleepMetrics, options: SleepOptions, backgrou const g = new SleepGraphics(options.width,options.height,options.mode==='custom'); const layout = journalLayout(stats,options); const { scale:s,card,seal,cells } = layout; - const pad = 20*s, stroke = Math.max(1,s); + const pad = 20*s, stroke = Math.max(2,3*s); if (layout.fullPage) { const m = Math.max(10,22*s); g.roundRect(m-7*s,m-7*s,options.width-2*m+14*s,options.height-2*m+14*s,16*s,255,stroke); - book(g,options.width/2,m+7*s,26*s,14*s,Math.max(.8,s)); - g.text('THE READING JOURNAL',options.width/2,m+48*s,Math.max(9,11*s),{ align:'center',tracking:130,maxWidth:options.width-2*m }); - g.text('One more chapter.',options.width/2,m+81*s,27*s,{ align:'center',maxWidth:options.width-2*m-16*s }); - if (layout.art && layout.art.height > 0) artwork(g,layout.art,s,background); + book(g,options.width/2,m+7*s,26*s,14*s,Math.max(1,1.4*s)); + g.text('THE READING JOURNAL',options.width/2,m+48*s,Math.max(11,13*s),{ align:'center',weight:'bold',tracking:100,maxWidth:options.width-2*m }); + g.text(seal?.reached ? 'You did it!' : 'One more chapter.',options.width/2,m+81*s,29*s,{ weight:'bold',align:'center',maxWidth:options.width-2*m-16*s }); + if (layout.art && layout.art.height > 0) { + if (seal?.reached && !background) celebration(g,layout.art,s,stats.finished); + else artwork(g,layout.art,s,background); + } } else if (options.mode==='custom') { artwork(g,{ x:12*s,y:12*s,width:options.width-24*s,height:options.height-24*s },s,background); } if (!cells.length && !seal) return g.image; g.roundRect(card.x,card.y,card.width,card.height,12*s,255,stroke); + if (seal?.reached) { + g.roundRect(card.x,card.y,card.width,card.height * .47,12*s,0); + } const titleWidth = seal ? seal.cx-seal.radius-card.x-pad-6*s : card.width-2*pad; - g.text(`READING JOURNAL / ${stats.year}`,card.x+pad,card.y+27*s,Math.max(8.5,10*s),{ tracking:65,maxWidth:titleWidth,gray:85 }); - g.text(seal?.reached ? 'Well read.' : 'Between chapters',card.x+pad,card.y+64*s,Math.max(17,25*s),{ maxWidth:titleWidth }); + g.text('YOUR READING YEAR',card.x+pad,card.y+27*s,Math.max(11,13*s),{ weight:'bold',tracking:40,maxWidth:titleWidth,gray:seal?.reached ? 255 : 0 }); + g.text(layout.headline,card.x+pad,card.y+64*s,Math.max(19,27*s),{ maxWidth:titleWidth,weight:'bold',gray:seal?.reached ? 255 : 0 }); if (cells.length) { const divider = card.y+card.height*.47; - g.line(card.x+pad,divider,card.x+card.width-pad,divider,Math.max(.7,.8*s),85); + g.line(card.x+pad,divider,card.x+card.width-pad,divider,Math.max(1.8,2.7*s),0); const cellWidth = (card.width-2*pad)/cells.length; cells.forEach((cell,index) => { const cx = card.x+pad+cellWidth*(index+.5); const numberY = divider+(card.height-(divider-card.y))*.43; - g.text(cell.value,cx,numberY,Math.max(20,33*s),{ align:'center',weight:'semibold',maxWidth:cellWidth-8*s }); - g.text(cell.label,cx,numberY+23*s,Math.max(8,10.5*s),{ align:'center',maxWidth:cellWidth-4*s }); - if (index) g.line(card.x+pad+cellWidth*index,divider+18*s,card.x+pad+cellWidth*index,card.y+card.height-27*s,Math.max(.6,.7*s),170); + g.text(cell.value,cx,numberY,Math.max(22,38*s),{ align:'center',weight:'bold',maxWidth:cellWidth-8*s }); + g.text(cell.label,cx,numberY+23*s,Math.max(11,14.5*s),{ align:'center',weight:'bold',maxWidth:cellWidth-4*s }); + if (index) g.line(card.x+pad+cellWidth*index,divider+18*s,card.x+pad+cellWidth*index,card.y+card.height-27*s,Math.max(1.5,2.2*s),0); if (cell.fraction !== undefined) { const w = Math.min(cellWidth-14*s,88*s), y = card.y+card.height-15*s; - g.line(cx-w/2,y,cx+w/2,y,2*s,170); - if (cell.fraction > 0) g.line(cx-w/2,y,cx-w/2+w*cell.fraction,y,2*s,0); + g.roundRect(cx-w/2,y-2.5*s,w,5*s,1*s,255,Math.max(.8,s)); + if (cell.fraction > 0) g.line(cx-w/2,y,cx-w/2+w*cell.fraction,y,3.5*s,0); } }); } if (seal) { const { cx,cy,radius:r,angle } = seal; g.disc(cx,cy,r+2*s); - g.ring(cx,cy,r,Math.max(1,1.3*s)); - g.ring(cx,cy,r-5*s,Math.max(.6,.7*s),85); + if (seal.reached) g.disc(cx,cy,r,0); + const ink = seal.reached ? 255 : 0; + g.ring(cx,cy,r,Math.max(1.2,1.6*s),ink); + // The heavier arc is the year's actual progress, starting at the tilted top. + const points = [], steps = Math.max(1,Math.ceil(seal.fraction*120)); + for (let i=0;i<=steps;i++) { + const a = -Math.PI/2+angle+i/steps*seal.fraction*Math.PI*2; + points.push({x:cx+Math.cos(a)*r,y:cy+Math.sin(a)*r}); + } + for (let i=steps;i>=0;i--) { + const a = -Math.PI/2+angle+i/steps*seal.fraction*Math.PI*2; + points.push({x:cx+Math.cos(a)*(r-5.5*s),y:cy+Math.sin(a)*(r-5.5*s)}); + } + if (seal.fraction>0) g.fill([points],ink); + g.ring(cx,cy,r-8*s,Math.max(1.2,1.5*s),ink); const rotate = { cx,cy,angle }; - g.text(seal.reached?'GOAL REACHED':'YEARLY GOAL',cx,cy-17*s,Math.max(8,9*s),{ align:'center',weight:'semibold',tracking:50,maxWidth:r*1.5,rotate }); - g.text(seal.count,cx,cy+8*s,Math.max(13,21*s),{ align:'center',weight:'semibold',maxWidth:r*1.5,rotate }); - g.text(String(stats.year),cx,cy+29*s,Math.max(9,11*s),{ align:'center',tracking:140,rotate }); + g.text(seal.reached?'GOAL COMPLETE!':'YEARLY GOAL',cx,cy-14*s,Math.max(9.5,11*s),{ align:'center',weight:'bold',tracking:30,maxWidth:r*1.32,gray:ink,rotate }); + g.text(seal.count,cx,cy+8*s,Math.max(16,25*s),{ align:'center',weight:'bold',maxWidth:r*1.5,gray:ink,rotate }); + g.text(String(stats.year),cx,cy+29*s,Math.max(11,13*s),{ align:'center',weight:'bold',tracking:100,gray:ink,rotate }); if (seal.reached) { - star(g,cx-r*.86,cy-r*.58,5*s); - star(g,cx+r*.85,cy+r*.58,5*s); + for (let i = 0; i < 12; i++) { + const a = i * Math.PI / 6; + g.line(cx+Math.cos(a)*(r+4*s),cy+Math.sin(a)*(r+4*s), + cx+Math.cos(a)*(r+9*s),cy+Math.sin(a)*(r+9*s),Math.max(2,3.2*s)); + } + star(g,cx-r*.55,cy-r*.53,4*s,255); + star(g,cx+r*.55,cy+r*.53,4*s,255); } } return g.image; diff --git a/test/sleep-journal.test.ts b/test/sleep-journal.test.ts index 6faad95..e41fdbc 100644 --- a/test/sleep-journal.test.ts +++ b/test/sleep-journal.test.ts @@ -41,6 +41,39 @@ describe('reading journal composition', () => { expect(reached.seal!.count).toBe('30 / 24'); }); + it('makes the headline and goal ring reflect the remaining books', () => { + const options = parseSleepOptions({}); + const progress = journalLayout({ ...stats, finished: 4, target: 6 }, options); + expect(progress.headline).toBe('2 books to go.'); + expect(progress.seal!.fraction).toBeCloseTo(2 / 3); + expect(progress.card.height).toBeLessThan(190); + expect(journalLayout({ ...stats, finished: 5, target: 6 }, options).headline).toBe('1 book to go.'); + const exceeded = journalLayout({ ...stats, finished: 7, target: 6 }, options); + expect(exceeded.headline).toBe('You did it!'); + expect(exceeded.seal!.fraction).toBe(1); + expect(journalLayout(stats, parseSleepOptions({ metrics: 'books,current' })).headline).toBe('Between chapters'); + expect(journalLayout({ ...stats, target: null }, options).headline).toBe('Between chapters'); + }); + + it('keeps a clear band of paper between goal text and the inner ring', () => { + const sample = { ...stats, finished: 4, target: 6 }; + const options = parseSleepOptions({}); + const { seal, scale } = journalLayout(sample, options); + const png = PNG.sync.read(renderSleepImage(sample, options).bytes); + const innerEdge = seal!.radius - 9.5 * scale; + let ink = 0; + for (let y = Math.floor(seal!.cy - seal!.radius); y <= seal!.cy + seal!.radius; y++) { + for (let x = Math.floor(seal!.cx - seal!.radius); x <= seal!.cx + seal!.radius; x++) { + const distance = Math.hypot(x + .5 - seal!.cx, y + .5 - seal!.cy); + if (distance >= innerEdge - 4 * scale && distance <= innerEdge - scale) { + const p = (y * png.width + x) * 4; + if (png.data[p+3] > 0 && png.data[p] < 170) ink++; + } + } + } + expect(ink).toBe(0); + }); + it('keeps cards and stamps in bounds at supported sizes and placements', () => { for (const [width, height] of [[240,320], [480,800], [1200,1600], [1200,240]]) { for (const mode of ['overlay','custom']) for (const placement of ['top','bottom']) { From 08ddb401a4a7e4fcb1eac9a6d45ff55782ff7404 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:36:31 -0500 Subject: [PATCH 8/8] fix: resolve historical timezone offsets for sleep image stats --- docs/API.md | 4 ++-- src/models/activity.ts | 12 ++++++++---- src/models/reading-goals.ts | 9 +++++---- src/models/sleep-image.ts | 27 +++++++++++++++++++-------- test/sleep-image.test.ts | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/docs/API.md b/docs/API.md index 6ac09d0..af0bba2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -992,12 +992,12 @@ Authenticated with the usual session or `x-auth-user` / `x-auth-key` headers. These routes require a server version including migration `0049_sleep_backgrounds.sql`. - `GET /api/v1/stats/sleep-image/status` → `{ "version": 1, "background": false }`. -- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full native 2-bit grayscale BMP using the account background, or white when none exists. Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. `preview=1` returns a browser-compatible PNG for Custom, with the same four native gray levels; omit it for the reader BMP. At the configured screen resolution, 2-bit BMP avoids per-refresh firmware conversion caching. Unknown options values return 400. +- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full native 2-bit grayscale BMP using the account background, or built-in journal artwork when none exists (an open-book illustration, with an achievement medallion when the goal is reached). Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. `preview=1` returns a browser-compatible PNG for Custom, with the same four native gray levels; omit it for the reader BMP. At the configured screen resolution, 2-bit BMP avoids per-refresh firmware conversion caching. Unknown options values return 400. - `X-Sleep-Percent: 42` supplies the current visible-page percentage (0–100). Missing, unresolved, or invalid values omit that metric. This does not write progress, finish books, or trust a queued event as a new completion. Account totals reflect already-synced history; annual goals reuse the existing account goal. - `PUT /api/v1/stats/sleep-image/background`, `Content-Type: image/png`: normalized PNG upload, at most 8 MB, 1600 pixels per axis and 2,000,000 decoded pixels. The app/plugin should resize and convert JPEG/other artwork to PNG before uploading. Invalid uploads preserve the previous background. PNG chunk structure is validated and IDAT inflation is bounded by the validated scanline budget before decoding, including interlaced input. - `GET /api/v1/stats/sleep-image/background` → the signed-in account's PNG (404 when absent). - `DELETE /api/v1/stats/sleep-image/background` → `{ "background": false }`. -All image/background responses are private and not cached. Backgrounds belong to one account, are cleared when its reading data/account is deleted, and are presentation data outside reading-history exports. No external image URL fetching is performed. IANA timezone rules are evaluated on each render, including seasonal offsets. These routes work under `BASE_PATH` using the same prefix as all other APIs. +All image/background responses are private and not cached. Backgrounds belong to one account, are cleared when its reading data/account is deleted, and are presentation data outside reading-history exports. No external image URL fetching is performed. IANA timezone rules are evaluated at each historical timestamp, including DST offsets. Dates already stored as local calendar days retain their original day. These routes work under `BASE_PATH` using the same prefix as all other APIs. The sleep images use a reading-journal design: a compact transparent bookplate with serif type and an annual-goal seal tilted approximately ten degrees, or a full-page Custom composition with framed account artwork. Without artwork, Custom draws an original open-book illustration. Top/bottom placement and metric omission work in both modes; short landscape screens use the compact composition. Fonts (Lora regular/semibold/bold) and their SIL OFL license are bundled under `assets/fonts/sleep-journal`; rendering uses pure JavaScript and no runtime downloads or native graphics libraries. diff --git a/src/models/activity.ts b/src/models/activity.ts index 27268a2..9330feb 100644 --- a/src/models/activity.ts +++ b/src/models/activity.ts @@ -21,6 +21,10 @@ import { readBookorbitStats } from './bookorbit-stats.js'; * since a book can be finished offline and only synced days later. * - Start and finish dates the user set by hand win over everything else. */ +/** Minutes west of UTC, fixed for legacy APIs or resolved per Unix timestamp (seconds). */ +export type ActivityTimezone = number | ((at: number) => number); +export const activityOffset = (timezone: ActivityTimezone, at: number) => typeof timezone === 'number' ? timezone : timezone(at); + export const FINISHED_AT = 0.98; export interface LogRow { @@ -84,13 +88,13 @@ export interface DayBook { export function computeActivity( rows: LogRow[], docs: Map, - tzOffsetMinutes = 0, + tzOffsetMinutes: ActivityTimezone = 0, finishedDates: Map = new Map(), startDates: Map = new Map() ): Activity { // Device dates are calendar dates: pin them to local noon in the client // timezone so they land on that same day however they're displayed. - const localNoon = (date: number | undefined) => (date ? date + 12 * 3600 + tzOffsetMinutes * 60 : null); + const localNoon = (date: number | undefined) => (date ? date + 12 * 3600 + activityOffset(tzOffsetMinutes, date + 12 * 3600) * 60 : null); const byDoc = new Map(); for (const r of rows) { const list = byDoc.get(r.document) ?? []; @@ -109,7 +113,7 @@ export function computeActivity( const readingDays = new Set(); const days = new Map }>(); // The day bucket for a sync, and this book's entry in it (opened at position `from`). - const dayKey = (at: number) => new Date((at - tzOffsetMinutes * 60) * 1000).toISOString().slice(0, 10); + const dayKey = (at: number) => new Date((at - activityOffset(tzOffsetMinutes, at) * 60) * 1000).toISOString().slice(0, 10); const dayOf = (at: number, document: string, from: number) => { const key = dayKey(at); readingDays.add(key); @@ -223,7 +227,7 @@ export function computeActivity( /** A user's activity from the database: progress history, plus finish and start * dates from device stats and reading days from a BookOrbit import. */ -export function readingActivity(db: DB, userId: number, tz: number, options: { includeStatsOnly?: boolean } = {}): Activity { +export function readingActivity(db: DB, userId: number, tz: ActivityTimezone, options: { includeStatsOnly?: boolean } = {}): Activity { const rows = db .prepare('SELECT document, percentage, at FROM progress_log WHERE user_id = ?') .all(userId) as unknown as LogRow[]; diff --git a/src/models/reading-goals.ts b/src/models/reading-goals.ts index ef58425..71c6e2b 100644 --- a/src/models/reading-goals.ts +++ b/src/models/reading-goals.ts @@ -1,5 +1,5 @@ import type { DB } from '../db/db.js'; -import { readingActivity } from './activity.js'; +import { readingActivity, activityOffset, type ActivityTimezone } from './activity.js'; import { aliasesByDocument } from './merge.js'; export const goalYear = (value: unknown): value is number => Number.isInteger(value) && Number(value) >= 2000 && Number(value) <= 9999; @@ -20,15 +20,16 @@ export function readingGoals(db: DB, userId: number, tz: number) { } /** Shared canonical completion calendar, including books with only uploaded stats. */ -export function readingYearSummary(db: DB, userId: number, tz: number) { - const today = new Date(Date.now() - tz * 60000).toISOString().slice(0, 10); +export function readingYearSummary(db: DB, userId: number, tz: ActivityTimezone) { + const now = Date.now(); + const today = new Date(now - activityOffset(tz, now / 1000) * 60000).toISOString().slice(0, 10); const year = Number(today.slice(0, 4)); const aliases = new Set([...aliasesByDocument(db, userId).values()].flat()); const counts = new Map(); const recent: { document: string; at: number }[] = []; for (const b of readingActivity(db, userId, tz, { includeStatsOnly: true }).books) { if (!b.finished_at || aliases.has(b.document)) continue; - const day = new Date((b.finished_at - tz * 60) * 1000).toISOString().slice(0, 10); + const day = new Date((b.finished_at - activityOffset(tz, b.finished_at) * 60) * 1000).toISOString().slice(0, 10); if (day > today) continue; const finishedYear = Number(day.slice(0, 4)); counts.set(finishedYear, (counts.get(finishedYear) ?? 0) + 1); diff --git a/src/models/sleep-image.ts b/src/models/sleep-image.ts index 179fb06..7dfb9bc 100644 --- a/src/models/sleep-image.ts +++ b/src/models/sleep-image.ts @@ -27,17 +27,26 @@ export interface SleepMetrics { streak: number; } -/** JS getTimezoneOffset sign, recalculated at render time for seasonal changes. */ -export function timezoneOffset(timezone: string, date = new Date()): number { +/** Reuse one IANA formatter per render, resolving the offset at each instant. */ +function timezoneResolver(timezone: string): (date: Date) => number { + let formatter: Intl.DateTimeFormat; try { - const parts = new Intl.DateTimeFormat('en-US', { + formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', - }).formatToParts(date); + }); + } catch { throw new SleepImageError('Choose a valid IANA timezone.'); } + return date => { + const parts = formatter.formatToParts(date); const get = (key: string) => Number(parts.find(p => p.type === key)!.value); const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')); return Math.round((Math.floor(date.getTime() / 1000) * 1000 - local) / 60000); - } catch { throw new SleepImageError('Choose a valid IANA timezone.'); } + }; +} + +/** JS getTimezoneOffset sign for a specific instant, including historical DST. */ +export function timezoneOffset(timezone: string, date = new Date()): number { + return timezoneResolver(timezone)(date); } export function parseSleepOptions(query: Record): SleepOptions { @@ -61,10 +70,12 @@ export function parseSleepOptions(query: Record): Sl } export function sleepMetrics(db: DB, userId: number, timezone: string, percent?: string): SleepMetrics { - const tz = timezoneOffset(timezone); - const { year, counts } = readingYearSummary(db, userId, tz); + const offset = timezoneResolver(timezone); + const tz = offset(new Date()); + const historicalOffset = (at: number) => offset(new Date(at * 1000)); + const { year, counts } = readingYearSummary(db, userId, historicalOffset); const goal = db.prepare('SELECT target FROM reading_goals WHERE user_id=? AND year=?').get(userId, year); - const activity = readingActivity(db, userId, tz); + const activity = readingActivity(db, userId, historicalOffset); const snapshots = db.prepare('SELECT payload FROM stats_device_global WHERE user_id=?').all(userId) .map(r => JSON.parse(r.payload as string) as GlobalStatsSnapshot); const imported = readBookorbitStats(db, userId); diff --git a/test/sleep-image.test.ts b/test/sleep-image.test.ts index 2f377ff..1ccf774 100644 --- a/test/sleep-image.test.ts +++ b/test/sleep-image.test.ts @@ -24,6 +24,40 @@ describe('sleep image data', () => { } finally { s.db.close(); } }); + it('keeps winter completions in their historical local year when rendered in summer', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2027-01-10T12:00:00Z')); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,1,?)') + .run(id, DOC, 'reader', Date.parse('2027-01-01T04:30:00Z') / 1000); + // A chosen calendar date must remain Jan 1 rather than being re-zoned as an instant. + s.db.prepare('INSERT INTO documents (user_id,document,status,finished_date,updated_at) VALUES (?,?,?,?,?)') + .run(id, 'manual-finish', 'finished', Date.parse('2027-01-01T00:00:00Z') / 1000, Date.now() / 1000); + expect(sleepMetrics(s.db, id, 'America/New_York').finished).toBe(1); + vi.setSystemTime(new Date('2027-07-01T12:00:00Z')); + expect(sleepMetrics(s.db, id, 'America/New_York').finished).toBe(1); + } finally { s.db.close(); } + }); + + it.each([ + ['2027-03-14T12:00:00Z', '2027-03-13T04:30:00Z', 0], + ['2026-11-02T12:00:00Z', '2026-11-01T04:30:00Z', 1], + ])('keeps near-midnight streak days on their historical side of DST (%s)', async (now, event, expected) => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(now)); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,.5,?)') + .run(id, DOC, 'reader', Date.parse(event) / 1000); + expect(sleepMetrics(s.db, id, 'America/New_York').streak).toBe(expected); + } finally { s.db.close(); } + }); + it.each([undefined, '', '{event.percent}', '-1', '101', 'NaN', 'Infinity', '4x'])('omits unavailable percentage %s', async value => { const s = makeTestApp(); try {