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:
parent
d2cef94ac9
commit
746bb7438e
8 changed files with 61 additions and 2 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"hash": "de8036758f935399699ef01446e3b9c8d23d211863dae052932d0ffc1a298120",
|
||||
"generatedAt": "2026-07-30T20:02:11.450Z",
|
||||
"generatedAt": "2026-07-30T20:22:54.851Z",
|
||||
"size": 1024
|
||||
}
|
||||
|
|
|
|||
BIN
assets/cover.png
BIN
assets/cover.png
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
20
docs/decisions/068-pr-161-density-1024.md
Normal file
20
docs/decisions/068-pr-161-density-1024.md
Normal 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, быстрее.
|
||||
27
docs/handoff/pr-161-density-1024.md
Normal file
27
docs/handoff/pr-161-density-1024.md
Normal 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'ом).
|
||||
Loading…
Add table
Reference in a new issue