diff --git a/.opencode/draw-image/render.mjs b/.opencode/draw-image/render.mjs index 1053d1c..cd2552b 100644 --- a/.opencode/draw-image/render.mjs +++ b/.opencode/draw-image/render.mjs @@ -33,7 +33,7 @@ async function main() { pngOpts.fontFiles = fontFiles } - await sharp(Buffer.from(svg), { density: 96 }) + await sharp(Buffer.from(svg), { density: 72 }) .png(pngOpts) .toFile(outPath) } diff --git a/.opencode/draw-image/tests/e2e.test.ts b/.opencode/draw-image/tests/e2e.test.ts index af0143f..65af9cd 100644 --- a/.opencode/draw-image/tests/e2e.test.ts +++ b/.opencode/draw-image/tests/e2e.test.ts @@ -28,6 +28,10 @@ function assertPng1024(filePath: string) { expect(buf[1]).toBe(0x50) expect(buf[2]).toBe(0x4e) 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", () => { diff --git a/.opencode/draw-image/tests/render.integration.test.ts b/.opencode/draw-image/tests/render.integration.test.ts index 8b4a9a7..d513049 100644 --- a/.opencode/draw-image/tests/render.integration.test.ts +++ b/.opencode/draw-image/tests/render.integration.test.ts @@ -41,6 +41,10 @@ describe("integration — render pipeline", () => { expect(buf[1]).toBe(0x50) expect(buf[2]).toBe(0x4e) 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) }) diff --git a/.opencode/draw-image/tests/render.optional.integration.test.ts b/.opencode/draw-image/tests/render.optional.integration.test.ts index 111f1ab..57a3d41 100644 --- a/.opencode/draw-image/tests/render.optional.integration.test.ts +++ b/.opencode/draw-image/tests/render.optional.integration.test.ts @@ -31,6 +31,10 @@ function assertPng(filePath: string) { expect(buf[1]).toBe(0x50) expect(buf[2]).toBe(0x4e) 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", () => { diff --git a/assets/cover.meta.json b/assets/cover.meta.json index 07981b4..2c3121b 100644 --- a/assets/cover.meta.json +++ b/assets/cover.meta.json @@ -1,5 +1,5 @@ { "hash": "de8036758f935399699ef01446e3b9c8d23d211863dae052932d0ffc1a298120", - "generatedAt": "2026-07-30T20:02:11.450Z", + "generatedAt": "2026-07-30T20:22:54.851Z", "size": 1024 } diff --git a/assets/cover.png b/assets/cover.png index 6b3380d..acd763f 100644 Binary files a/assets/cover.png and b/assets/cover.png differ diff --git a/docs/decisions/068-pr-161-density-1024.md b/docs/decisions/068-pr-161-density-1024.md new file mode 100644 index 0000000..ddca66d --- /dev/null +++ b/docs/decisions/068-pr-161-density-1024.md @@ -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, быстрее. \ No newline at end of file diff --git a/docs/handoff/pr-161-density-1024.md b/docs/handoff/pr-161-density-1024.md new file mode 100644 index 0000000..f8cbff4 --- /dev/null +++ b/docs/handoff/pr-161-density-1024.md @@ -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'ом). \ No newline at end of file