fix(draw-image): use density 72 for 1024x1024 PNG output (#161)

* fix(draw-image): use density 72 for 1024x1024 PNG output

* test(draw-image): assert real PNG dimensions via IHDR decode

* chore(assets): regenerate cover.png at 1024x1024

* docs(handoff): add handoff and ADR for density fix

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-30 23:31:02 +03:00 committed by GitHub
parent d2cef94ac9
commit 746bb7438e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 61 additions and 2 deletions

View file

@ -33,7 +33,7 @@ async function main() {
pngOpts.fontFiles = fontFiles pngOpts.fontFiles = fontFiles
} }
await sharp(Buffer.from(svg), { density: 96 }) await sharp(Buffer.from(svg), { density: 72 })
.png(pngOpts) .png(pngOpts)
.toFile(outPath) .toFile(outPath)
} }

View file

@ -28,6 +28,10 @@ function assertPng1024(filePath: string) {
expect(buf[1]).toBe(0x50) expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e) expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47) expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
} }
describe("e2e — full CLI render", () => { describe("e2e — full CLI render", () => {

View file

@ -41,6 +41,10 @@ describe("integration — render pipeline", () => {
expect(buf[1]).toBe(0x50) expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e) expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47) expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
expect(hash).toHaveLength(64) expect(hash).toHaveLength(64)
}) })

View file

@ -31,6 +31,10 @@ function assertPng(filePath: string) {
expect(buf[1]).toBe(0x50) expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e) expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47) expect(buf[3]).toBe(0x47)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width, `PNG width should be 1024, got ${width}`).toBe(1024)
expect(height, `PNG height should be 1024, got ${height}`).toBe(1024)
} }
describe("integration — optional subtitle and badge", () => { describe("integration — optional subtitle and badge", () => {

View file

@ -1,5 +1,5 @@
{ {
"hash": "de8036758f935399699ef01446e3b9c8d23d211863dae052932d0ffc1a298120", "hash": "de8036758f935399699ef01446e3b9c8d23d211863dae052932d0ffc1a298120",
"generatedAt": "2026-07-30T20:02:11.450Z", "generatedAt": "2026-07-30T20:22:54.851Z",
"size": 1024 "size": 1024
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,20 @@
# ADR-068: Use sharp density 72 for pixel-perfect 1024×1024 PNG output
## Статус
Accepted (2026-07-30)
## Контекст
`render.mjs` рендерил SVG-шаблон cover (1024×1024 по `viewBox`) в PNG 1365×1365 вместо ожидаемых 1024×1024. Root cause: `sharp(Buffer.from(svg), { density: 96 })` — density в DPI, sharp интерпретирует `viewBox` 1024 (в user units, по умолчанию 1 unit = 1pt = 1/72in) и рендерит 1024pt × 96 DPI / 72 = 1365px. Тесты `assertPng1024` проверяли только PNG magic bytes (`0x89 0x50 0x4e 0x47`), не размеры — баг маскировался. `meta.size: 1024` в `cli.ts:78` был контрактом, но фактически нарушался.
Рассматривались варианты:
- density: 72 → 1024 × 72/72 = 1024px (точное совпадение).
- density: 96 + resize до 1024 → лишний resample, потеря качества.
- Явный `.resize(1024, 1024)` после density → double-pass, медленнее.
## Решение
Установить `density: 72` в `sharp()` вызове (`render.mjs:36`). 72 DPI = canonical SVG default (1pt = 1/72in), даёт pixel-perfect 1:1 маппинг `viewBox` → px. Дополнительно: assertions в тестах декодируют PNG IHDR chunk (width bytes 16-19, height bytes 20-23, big-endian uint32) и проверяют `=== 1024` — регрессия на размер теперь детектируется.
## Альтернативы
- **density 96 + explicit resize(1024)**: отвергнут — лишний resample снижает качество текста (subpixel), double-pass рендер.
- **density 96 + viewBox в px вместо pt**: потребовало бы правки всех SVG-шаблонов (`cover.svg` etc.), больший scope, риск regressions в layout.
- **assertions через `sharp().metadata()` вместо IHDR decode**: добавил бы зависимость от sharp в test-слой (сейчас тесты читают bytes напрямую, без dep). IHDR decode — zero-dependency, быстрее.

View file

@ -0,0 +1,27 @@
---
pr: 161
title: "fix(draw-image): use density 72 for 1024x1024 PNG output"
---
## Что сделано
Фикс размера PNG draw-image (issue #159) — три изменения:
1. **density 96 → 72**`.opencode/draw-image/render.mjs:36`: `sharp(Buffer.from(svg), { density: 72 })`. При density:96 SVG 1024×1024 (по `viewBox`, 1in=72pt) рендерился как 1024 × 96/72 = 1365px. При density:72 → 1024 × 72/72 = 1024px ровно.
2. **Реальные assertions размеров PNG** — пустые проверки заменены на decode PNG IHDR chunk (width bytes 16-19, height bytes 20-23, big-endian uint32):
- `tests/e2e.test.ts` `assertPng1024` — добавлены `expect(width).toBe(1024)` + `expect(height).toBe(1024)`.
- `tests/render.integration.test.ts` тест `"renders valid PNG 1024x1024"` — добавлены width/height assertions (были только magic bytes).
- `tests/render.optional.integration.test.ts` `assertPng` — добавлены width/height assertions.
3. **Регенерация assets/cover.png** — удалены старые `assets/cover.png` + `cover.meta.json`, перерендерены через `draw-image` tool (template=cover, icon=opencode, subtitle). Проверено через `sharp().metadata()`: 1024×1024 (было 1365×1365).
Тесты: `npx vitest run` в `.opencode/draw-image/` → 55/55 passed (5 прогонов подряд стабильно). Все обновлённые assertions реально проверяют 1024×1024 через IHDR decode.
## Почему
`density: 96` — историческое значение, дававшее 33% оверсайз (1365 вместо 1024). PNG cover используется в README и slaid098.dev showcase — 1365px ломал layout и не соответствовал контракту `meta.size: 1024` в `cli.ts:78`. Пустые assertions (только magic bytes, без width/height) маскировали баг — тесты «проходили» на 1365×1365, хотя в имени функции буквально `assertPng1024`. Density 72 — canonical значение для SVG-to-PNG при `viewBox` в pt (1pt = 1/72in), дающее pixel-perfect 1024×1024.
## Pending
## Watch out
- **Существующий race condition на `.tmp-render.svg`**`cli.ts:64` пишет shared temp SVG в `__dirname`. При параллельных CLI-вызовах (vitest concurrent) или leftover от ручных прогонов → `render.mjs` падает с `Input Buffer is empty`. Это **отдельный баг**, заведён issue #160 (вне scope #159). На стабильных прогонах (clean tmp) не проявляется. Не чинить в этом PR.
- Density-фикс **меняет хэш** всех PNG (`computeHash` не зависит от density, но `generatedAt` и пиксели differ) → все cached PNG будут перерендерены при следующем вызове CLI (meta.json hash не меняется, но если файл удалён — re-render). `cover.meta.json` перегенерирован.
- `buf.readUInt32BE(16)` / `buf.readUInt32BE(20)` — PNG IHDR fixed offset (8 signature + 4 length + 4 "IHDR" = 16 для width, 20 для height). Работает только для стандартных PNG (sharp всегда пишет IHDR первым chunk'ом).