feat(draw-image): SVG template renderer for on-brand covers (#134)

* feat(draw-image): add SVG template render engine

* feat(draw-image): add opencode plugin and tool tests

* test(draw-image): add vitest unit integration e2e suite

* chore(draw-image): wire CI dependabot docs and ADR

* docs(handoff): set PR number

* docs(handoff): fix PR number placeholder in frontmatter

* fix(ci): use tempfile and trailing newline in draw-image tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-29 23:42:50 +03:00 committed by GitHub
parent e72d435b82
commit 56b67d2d5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 4107 additions and 2 deletions

View file

@ -6,7 +6,12 @@ updates:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: "npm"
directory: "/config"
directory: "/.opencode"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: "npm"
directory: "/.opencode/draw-image"
schedule:
interval: weekly
open-pull-requests-limit: 5

View file

@ -77,6 +77,10 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y ripgrep
- run: npm ci
working-directory: .opencode
- run: npm ci
working-directory: .opencode/draw-image
- run: npm test
working-directory: .opencode/draw-image
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run --python ${{ matrix.python }} pytest

5
.opencode/draw-image/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
.tmp-render.svg
icons/lucide/*
!icons/lucide/mic.svg
!icons/lucide/play.svg

View file

@ -0,0 +1,8 @@
{
"base": "#0a0a0a",
"surface": "#121212",
"fg": "#ededed",
"muted": "#a1a1aa",
"accent": "#ccff00",
"line": "rgba(255,255,255,0.06)"
}

View file

@ -0,0 +1,84 @@
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "./src/config.ts"
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
out[key] = val
i++
}
}
return out
}
function main() {
const argv = process.argv.slice(2)
if (argv.length < 2 || argv[0] !== "render") {
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n')
process.exit(2)
}
const template = argv[1]
const opts = parseArgs(argv.slice(2))
const title = opts.title ?? ""
const subtitle = opts.subtitle
const out = opts.out ?? "./assets/cover.png"
const slots: Record<string, string> = {}
if (opts.slots) {
for (const pair of opts.slots.split(",")) {
const eq = pair.indexOf("=")
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
}
}
const brand = loadBrand(__dirname)
const templateSvg = loadTemplate(__dirname, template)
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
const outResolved = path.resolve(out)
const metaPath = outResolved.replace(/\.png$/, ".meta.json")
if (existsSync(outResolved) && existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
if (meta.hash === hash) {
console.log(JSON.stringify({ path: outResolved, hash, status: "skipped" }))
return
}
} catch {
// stale meta, re-render
}
}
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
const tmpSvg = path.join(__dirname, ".tmp-render.svg")
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
encoding: "utf-8",
})
if (r.status !== 0) {
process.stderr.write(r.stderr || r.stdout || "render failed\n")
process.exit(1)
}
const meta = {
hash,
generatedAt: new Date().toISOString(),
size: 1024,
}
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
}
main()

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,17 @@
<!-- @license lucide-static v1.27.0 - ISC -->
<svg
class="lucide lucide-mic"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19v3" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<rect x="9" y="2" width="6" height="13" rx="3" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

View file

@ -0,0 +1,15 @@
<!-- @license lucide-static v1.27.0 - ISC -->
<svg
class="lucide lucide-play"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>

After

Width:  |  Height:  |  Size: 381 B

2577
.opencode/draw-image/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"name": "draw-image",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "SVG template renderer for on-brand covers (sharp + lucide-static)",
"scripts": {
"test": "vitest run",
"postinstall": "node scripts/sync-lucide.mjs"
},
"dependencies": {
"lucide-static": "^1.27.0",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"typescript": "^7.0.2",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,44 @@
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import sharp from "sharp"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function main() {
const args = process.argv.slice(2)
if (args.length < 2) {
process.stderr.write("usage: node render.mjs <svg-file> <out-path>\n")
process.exit(2)
}
const svgFile = args[0]
const outPath = args[1]
const svg = readFileSync(svgFile, "utf-8")
const outDir = path.dirname(outPath)
if (outDir && !existsSync(outDir)) {
mkdirSync(outDir, { recursive: true })
}
const fontDir = path.join(__dirname, "fonts")
const fontFiles = []
const sansTtf = path.join(fontDir, "Geist-Regular.ttf")
const sansBoldTtf = path.join(fontDir, "Geist-Bold.ttf")
if (existsSync(sansTtf)) fontFiles.push(sansTtf)
if (existsSync(sansBoldTtf)) fontFiles.push(sansBoldTtf)
const pngOpts = { compressionLevel: 9 }
if (fontFiles.length > 0) {
pngOpts.fontFiles = fontFiles
}
await sharp(Buffer.from(svg), { density: 96 })
.png(pngOpts)
.toFile(outPath)
}
main().catch((err) => {
process.stderr.write(`render failed: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

View file

@ -0,0 +1,23 @@
import { existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const drawImageDir = path.resolve(__dirname, "..")
const lucideSrc = path.join(drawImageDir, "node_modules", "lucide-static", "icons")
const lucideDst = path.join(drawImageDir, "icons", "lucide")
if (!existsSync(lucideSrc)) {
console.log("lucide-static not installed, skipping icon sync")
process.exit(0)
}
mkdirSync(lucideDst, { recursive: true })
let count = 0
for (const file of readdirSync(lucideSrc)) {
if (file.endsWith(".svg")) {
copyFileSync(path.join(lucideSrc, file), path.join(lucideDst, file))
count++
}
}
console.log(`synced ${count} lucide icons`)

View file

@ -0,0 +1,56 @@
import { readFileSync } from "node:fs"
import path from "node:path"
export type Brand = {
base: string
surface: string
fg: string
muted: string
accent: string
line: string
}
const HEX_RE = /^#[0-9a-fA-F]{6}$/
type BrandKey = keyof Brand
const REQUIRED_KEYS: BrandKey[] = ["base", "surface", "fg", "muted", "accent"]
export function validateBrand(raw: unknown): Brand {
if (typeof raw !== "object" || raw === null) {
throw new Error("brand.json must be a JSON object")
}
const obj = raw as Record<string, unknown>
for (const key of REQUIRED_KEYS) {
if (!(key in obj)) {
throw new Error(`brand.json missing required key: ${key}`)
}
const val = obj[key]
if (typeof val !== "string" || !HEX_RE.test(val)) {
throw new Error(`brand.json key '${key}' must be a 6-digit hex color (got: ${String(val)})`)
}
}
if ("line" in obj && typeof obj.line !== "string") {
throw new Error("brand.json key 'line' must be a string")
}
return {
base: obj.base as string,
surface: obj.surface as string,
fg: obj.fg as string,
muted: obj.muted as string,
accent: obj.accent as string,
line: (obj.line as string | undefined) ?? "rgba(255,255,255,0.06)",
}
}
export function loadBrand(dir: string): Brand {
const brandPath = path.join(dir, "brand.json")
const raw = JSON.parse(readFileSync(brandPath, "utf-8"))
return validateBrand(raw)
}
export function resolveColor(brand: Brand, name: string): string {
if (name === "none") return "none"
if (name in brand) return (brand as Record<string, string>)[name]
throw new Error(`unknown color alias: ${name}`)
}

View file

@ -0,0 +1,71 @@
import type { FitMode } from "./slot-parser.ts"
export type FitResult = {
x: number
y: number
width: number
height: number
viewBox: string
}
export function computeFit(
fit: FitMode,
slotX: number,
slotY: number,
slotW: number,
slotH: number,
contentW: number,
contentH: number,
): FitResult {
if (fit === "stretch") {
return {
x: slotX,
y: slotY,
width: slotW,
height: slotH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
if (fit === "cover") {
const scale = Math.max(slotW / contentW, slotH / contentH)
const renderedW = contentW * scale
const renderedH = contentH * scale
const offsetX = slotX + (slotW - renderedW) / 2
const offsetY = slotY + (slotH - renderedH) / 2
return {
x: offsetX,
y: offsetY,
width: renderedW,
height: renderedH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
const scale = Math.min(slotW / contentW, slotH / contentH)
const renderedW = contentW * scale
const renderedH = contentH * scale
const offsetX = slotX + (slotW - renderedW) / 2
const offsetY = slotY + (slotH - renderedH) / 2
return {
x: offsetX,
y: offsetY,
width: renderedW,
height: renderedH,
viewBox: `0 0 ${contentW} ${contentH}`,
}
}
export function parseViewBox(svg: string): { width: number; height: number } {
const m = svg.match(/viewBox=["']([^"']+)["']/)
if (m) {
const parts = m[1].split(/[\s,]+/).map(Number)
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
return { width: parts[2], height: parts[3] }
}
}
const wMatch = svg.match(/\swidth=["'](\d+)["']/)
const hMatch = svg.match(/\sheight=["'](\d+)["']/)
if (wMatch && hMatch) {
return { width: parseInt(wMatch[1], 10), height: parseInt(hMatch[1], 10) }
}
return { width: 24, height: 24 }
}

View file

@ -0,0 +1,26 @@
export function recolorSvg(svg: string, color: string): string {
if (color === "none") return svg
let out = svg
out = out.replace(/\bstroke=["'](?!none)([^"']+)["']/g, `stroke="${color}"`)
out = out.replace(/\bfill=["'](?!none)([^"']+)["']/g, (match, _val) => {
if (match.includes(`fill="none"`)) return match
return `fill="${color}"`
})
return out
}
export function stripSvgWrapper(svg: string): string {
const m = svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/)
return m ? m[1].trim() : svg
}
export function extractRootAttrs(svg: string): { stroke?: string; fill?: string; strokeWidth?: string } {
const attrs: { stroke?: string; fill?: string; strokeWidth?: string } = {}
const strokeMatch = svg.match(/<svg[^>]*\bstroke=["']([^"']+)["']/)
if (strokeMatch) attrs.stroke = strokeMatch[1]
const fillMatch = svg.match(/<svg[^>]*\bfill=["']([^"']+)["']/)
if (fillMatch) attrs.fill = fillMatch[1]
const swMatch = svg.match(/<svg[^>]*\bstroke-width=["']([^"']+)["']/)
if (swMatch) attrs.strokeWidth = swMatch[1]
return attrs
}

View file

@ -0,0 +1,81 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { createHash } from "node:crypto"
import { loadBrand, type Brand } from "./config.ts"
import { parseSlots, type SlotSpec } from "./slot-parser.ts"
import { resolveSlotContent, renderSlotSvg, renderSlotBackground } from "./resolve.ts"
export type RenderArgs = {
template: string
title?: string
subtitle?: string
slots?: Record<string, string>
out?: string
}
export type RenderResult = {
path: string
hash: string
status: "rendered" | "skipped"
}
const SLOT_COMMENT_RE = /<!--\s*slot:[^>]*?-->\n?/g
export function buildSvg(
templateSvg: string,
brand: Brand,
args: RenderArgs,
drawImageDir: string,
): string {
const slots = parseSlots(templateSvg)
let svg = templateSvg
const slotSvgs: string[] = []
for (const slot of slots) {
const bg = renderSlotBackground(slot, brand)
if (bg) slotSvgs.push(bg)
const value = args.slots?.[slot.name]
if (value) {
const resolved = resolveSlotContent(slot, brand, drawImageDir, value)
if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved))
}
}
svg = svg.replace(SLOT_COMMENT_RE, "")
svg = svg.replace(/\{\{base\}\}/g, brand.base)
svg = svg.replace(/\{\{surface\}\}/g, brand.surface)
svg = svg.replace(/\{\{fg\}\}/g, brand.fg)
svg = svg.replace(/\{\{muted\}\}/g, brand.muted)
svg = svg.replace(/\{\{accent\}\}/g, brand.accent)
svg = svg.replace(/\{\{line\}\}/g, brand.line)
svg = svg.replace(/\{\{title\}\}/g, escapeXml(args.title ?? ""))
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle ?? ""))
const insertPoint = svg.indexOf("</svg>")
if (insertPoint === -1) return svg
return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint)
}
export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs): string {
const data = JSON.stringify({
brand,
template: templateSvg,
template_name: args.template,
title: args.title ?? "",
subtitle: args.subtitle ?? "",
slots: args.slots ?? {},
out: args.out ?? "",
})
return createHash("sha256").update(data).digest("hex")
}
export function loadTemplate(drawImageDir: string, name: string): string {
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`)
return readFileSync(templatePath, "utf-8")
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}

View file

@ -0,0 +1,78 @@
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { recolorSvg, stripSvgWrapper, extractRootAttrs } from "./recolor.ts"
import { computeFit, parseViewBox } from "./fit.ts"
import type { SlotSpec } from "./slot-parser.ts"
import type { Brand } from "./config.ts"
import { resolveColor } from "./config.ts"
export type ResolveResult = {
inner: string
contentW: number
contentH: number
rootAttrs: { stroke?: string; fill?: string; strokeWidth?: string }
}
export function resolveSlotContent(
slot: SlotSpec,
brand: Brand,
drawImageDir: string,
slotValue: string,
): ResolveResult | null {
const raw = loadSlotSvg(slotValue, drawImageDir)
if (!raw) return null
const color = resolveColor(brand, slot.recolor)
const recolored = recolorSvg(raw, color)
const rootAttrs = extractRootAttrs(recolored)
const inner = stripSvgWrapper(recolored)
const { width, height } = parseViewBox(raw)
return { inner, contentW: width, contentH: height, rootAttrs }
}
export function loadSlotSvg(value: string, drawImageDir: string): string | null {
if (value.startsWith("./") || value.startsWith("/") || value.startsWith("../")) {
if (!existsSync(value)) return null
return readFileSync(value, "utf-8")
}
const lucidePath = path.join(drawImageDir, "icons", "lucide", `${value}.svg`)
if (existsSync(lucidePath)) {
return readFileSync(lucidePath, "utf-8")
}
const brandPath = path.join(drawImageDir, "brand-logos", `${value}.svg`)
if (existsSync(brandPath)) {
return readFileSync(brandPath, "utf-8")
}
return null
}
export function renderSlotSvg(slot: SlotSpec, resolved: ResolveResult): string {
const fit = computeFit(
slot.fit,
slot.x,
slot.y,
slot.w,
slot.h,
resolved.contentW,
resolved.contentH,
)
const attrs = resolved.rootAttrs
const strokeAttr = attrs.stroke ? ` stroke="${attrs.stroke}"` : ""
const fillAttr = attrs.fill ? ` fill="${attrs.fill}"` : ""
const swAttr = attrs.strokeWidth ? ` stroke-width="${attrs.strokeWidth}"` : ""
return `<svg x="${fit.x}" y="${fit.y}" width="${fit.width}" height="${fit.height}" viewBox="${fit.viewBox}" xmlns="http://www.w3.org/2000/svg"${strokeAttr}${fillAttr}${swAttr}>${resolved.inner}</svg>`
}
export function renderSlotBackground(slot: SlotSpec, brand: Brand): string {
const parts: string[] = []
if (slot.bg !== "none") {
const bg = resolveColor(brand, slot.bg)
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="${bg}" />`)
}
if (slot.border !== "none") {
const border = resolveColor(brand, slot.border)
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="none" stroke="${border}" stroke-width="4" />`)
}
return parts.join("\n")
}

View file

@ -0,0 +1,57 @@
export type FitMode = "contain" | "cover" | "stretch"
export type SlotSpec = {
name: string
x: number
y: number
w: number
h: number
fit: FitMode
recolor: string
bg: string
border: string
radius: number
}
const SLOT_RE = /<!--\s*slot:\s*([^>]*?)-->/g
export function parseSlots(svg: string): SlotSpec[] {
const slots: SlotSpec[] = []
let m: RegExpExecArray | null
while ((m = SLOT_RE.exec(svg)) !== null) {
const body = m[1].trim()
const spec = parseSlotBody(body)
if (spec) slots.push(spec)
}
return slots
}
function parseSlotBody(body: string): SlotSpec | null {
const fields = new Map<string, string>()
for (const part of body.split(",")) {
const eq = part.indexOf("=")
if (eq === -1) continue
const key = part.slice(0, eq).trim()
const val = part.slice(eq + 1).trim()
fields.set(key, val)
}
const name = fields.get("name")
if (!name) return null
const x = num(fields, "x")
const y = num(fields, "y")
const w = num(fields, "w")
const h = num(fields, "h")
if ([x, y, w, h].some((v) => Number.isNaN(v))) return null
const fit = (fields.get("fit") as FitMode) ?? "contain"
const recolor = fields.get("recolor") ?? "none"
const bg = fields.get("bg") ?? "none"
const border = fields.get("border") ?? "none"
const radius = num(fields, "radius") || 0
return { name, x, y, w, h, fit, recolor, bg, border, radius }
}
function num(fields: Map<string, string>, key: string): number {
const v = fields.get(key)
if (v === undefined) return NaN
return parseFloat(v)
}

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
<rect width="1024" height="1024" fill="{{base}}" />
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,57 @@
import { describe, test, expect } from "vitest"
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
out[key] = val
i++
}
}
return out
}
function parseSlotsArg(slotsStr: string | undefined): Record<string, string> {
const slots: Record<string, string> = {}
if (slotsStr) {
for (const pair of slotsStr.split(",")) {
const eq = pair.indexOf("=")
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
}
}
return slots
}
describe("cli — arg parsing", () => {
test("parses title and out", () => {
const opts = parseArgs(["--title", "Hello", "--out", "/tmp/x.png"])
expect(opts.title).toBe("Hello")
expect(opts.out).toBe("/tmp/x.png")
})
test("default out is ./assets/cover.png", () => {
const opts = parseArgs(["--title", "Test"])
const out = opts.out ?? "./assets/cover.png"
expect(out).toBe("./assets/cover.png")
})
test("parses slots string into object", () => {
const slots = parseSlotsArg("icon=mic,sub-icon=opencode,badge=./x.svg")
expect(slots.icon).toBe("mic")
expect(slots["sub-icon"]).toBe("opencode")
expect(slots.badge).toBe("./x.svg")
})
test("empty slots string yields empty object", () => {
expect(parseSlotsArg(undefined)).toEqual({})
expect(parseSlotsArg("")).toEqual({})
})
test("subtitle is optional", () => {
const opts = parseArgs(["--title", "Test"])
expect(opts.subtitle).toBeUndefined()
})
})

View file

@ -0,0 +1,42 @@
import { describe, test, expect } from "vitest"
import { validateBrand } from "../src/config"
describe("config — validateBrand", () => {
test("valid brand passes", () => {
const brand = validateBrand({
base: "#0a0a0a",
surface: "#121212",
fg: "#ededed",
muted: "#a1a1aa",
accent: "#ccff00",
line: "rgba(255,255,255,0.06)",
})
expect(brand.accent).toBe("#ccff00")
expect(brand.base).toBe("#0a0a0a")
})
test("missing accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
.toThrow(/accent/)
})
test("non-hex accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
.toThrow(/hex/)
})
test("short hex rejected", () => {
expect(() => validateBrand({ base: "#0a0", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" }))
.toThrow(/hex/)
})
test("non-object rejected", () => {
expect(() => validateBrand("not an object")).toThrow(/object/)
expect(() => validateBrand(null)).toThrow(/object/)
})
test("line defaults when missing", () => {
const brand = validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" })
expect(brand.line).toBe("rgba(255,255,255,0.06)")
})
})

View file

@ -0,0 +1,36 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, rmSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { validateBrand } from "../src/config"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-bad-input"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
describe("e2e — bad input (unit-level, no file mutation)", () => {
test("invalid brand.json rejected by validateBrand", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
.toThrow(/accent/)
})
test("non-hex accent rejected", () => {
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
.toThrow(/hex/)
})
test("cli exits non-zero with missing template file", () => {
const out = path.join(TMP, "bad.png")
const r = spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", "nonexistent", "--title", "Bad", "--out", out], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
expect(r.status).not.toBe(0)
})
})

View file

@ -0,0 +1,33 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-e2e-no-icon"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
}
describe("e2e — no icon render", () => {
test("exit 0 and valid PNG without icon slot", () => {
const out = path.join(TMP, "no-icon.png")
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(out)).toBe(true)
const buf = readFileSync(out)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
})
})

View file

@ -0,0 +1,60 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-e2e"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
}
function assertPng1024(filePath: string) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf.length).toBeGreaterThan(1000)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
describe("e2e — full CLI render", () => {
test("render cover with title and icon", () => {
const out = path.join(TMP, "e2e.png")
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const result = JSON.parse(r.stdout)
expect(result.status).toBe("rendered")
assertPng1024(out)
const metaPath = out.replace(/\.png$/, ".meta.json")
expect(existsSync(metaPath)).toBe(true)
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
expect(meta.hash).toHaveLength(64)
})
test("render without icon succeeds", () => {
const out = path.join(TMP, "e2e-no-icon.png")
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out)
})
test("render with subtitle", () => {
const out = path.join(TMP, "e2e-sub.png")
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out)
})
})

View file

@ -0,0 +1,64 @@
import { describe, test, expect } from "vitest"
import { computeFit, parseViewBox } from "../src/fit"
describe("fit — contain", () => {
test("square content into wider slot centers horizontally", () => {
const r = computeFit("contain", 100, 100, 400, 200, 100, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(200)
expect(r.x).toBe(200)
expect(r.y).toBe(100)
expect(r.viewBox).toBe("0 0 100 100")
})
test("square content into taller slot centers vertically", () => {
const r = computeFit("contain", 100, 100, 200, 400, 100, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(200)
expect(r.x).toBe(100)
expect(r.y).toBe(200)
})
test("horizontal content into square slot fits by width", () => {
const r = computeFit("contain", 0, 0, 200, 200, 400, 100)
expect(r.width).toBe(200)
expect(r.height).toBe(50)
expect(r.x).toBe(0)
expect(r.y).toBe(75)
})
})
describe("fit — cover", () => {
test("square content into wide slot fills height", () => {
const r = computeFit("cover", 0, 0, 400, 200, 100, 100)
expect(r.width).toBe(400)
expect(r.height).toBe(400)
expect(r.x).toBe(0)
expect(r.y).toBe(-100)
})
})
describe("fit — stretch", () => {
test("stretches to slot dimensions ignoring aspect", () => {
const r = computeFit("stretch", 10, 20, 300, 150, 100, 100)
expect(r.x).toBe(10)
expect(r.y).toBe(20)
expect(r.width).toBe(300)
expect(r.height).toBe(150)
expect(r.viewBox).toBe("0 0 100 100")
})
})
describe("fit — parseViewBox", () => {
test("extracts from viewBox attribute", () => {
expect(parseViewBox('<svg viewBox="0 0 24 24">')).toEqual({ width: 24, height: 24 })
})
test("extracts from width/height attributes", () => {
expect(parseViewBox('<svg width="48" height="48">')).toEqual({ width: 48, height: 48 })
})
test("defaults to 24x24 when nothing found", () => {
expect(parseViewBox("<svg></svg>")).toEqual({ width: 24, height: 24 })
})
})

View file

@ -0,0 +1,51 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg, computeHash } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-idempotency"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out]
if (slots) args.push("--slots", slots)
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
}
describe("integration — idempotency", () => {
test("re-render with same args skips", () => {
const out = path.join(TMP, "cover.png")
const r1 = renderCli("cover", "Same Title", out)
expect(r1.status).toBe(0)
const result1 = JSON.parse(r1.stdout)
expect(result1.status).toBe("rendered")
const r2 = renderCli("cover", "Same Title", out)
expect(r2.status).toBe(0)
const result2 = JSON.parse(r2.stdout)
expect(result2.status).toBe("skipped")
})
test("changed title re-renders", () => {
const out = path.join(TMP, "cover2.png")
const r1 = renderCli("cover", "Title A", out)
const result1 = JSON.parse(r1.stdout)
expect(result1.status).toBe("rendered")
const r2 = renderCli("cover", "Title B", out)
const result2 = JSON.parse(r2.stdout)
expect(result2.status).toBe("rendered")
const meta = JSON.parse(readFileSync(out.replace(/\.png$/, ".meta.json"), "utf-8"))
expect(meta.hash).toHaveLength(64)
})
})

View file

@ -0,0 +1,48 @@
import { describe, test, expect } from "vitest"
import { recolorSvg, stripSvgWrapper } from "../src/recolor"
const SAMPLE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19v3" /></svg>`
describe("recolor — recolorSvg", () => {
test("replaces stroke=currentColor with accent", () => {
const out = recolorSvg(SAMPLE, "#ccff00")
expect(out).toContain('stroke="#ccff00"')
expect(out).not.toContain('stroke="currentColor"')
})
test("replaces fill (non-none) with target color", () => {
const svg = `<svg fill="#000000" stroke="currentColor"><path fill="#ff0000" /></svg>`
const out = recolorSvg(svg, "#ccff00")
expect(out).toContain('fill="#ccff00"')
expect(out).not.toContain('fill="#000000"')
expect(out).not.toContain('fill="#ff0000"')
})
test("preserves fill=none", () => {
const svg = `<svg fill="none" stroke="currentColor"><path fill="none" /></svg>`
const out = recolorSvg(svg, "#ccff00")
expect(out).toContain('fill="none"')
})
test("none color returns unchanged", () => {
expect(recolorSvg(SAMPLE, "none")).toBe(SAMPLE)
})
test("replaces stroke with fg color", () => {
const out = recolorSvg(SAMPLE, "#ededed")
expect(out).toContain('stroke="#ededed"')
})
test("replaces stroke with muted color", () => {
const out = recolorSvg(SAMPLE, "#a1a1aa")
expect(out).toContain('stroke="#a1a1aa"')
})
})
describe("recolor — stripSvgWrapper", () => {
test("extracts inner content", () => {
const inner = stripSvgWrapper(SAMPLE)
expect(inner).toContain("<path")
expect(inner).not.toContain("<svg")
})
})

View file

@ -0,0 +1,57 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg, computeHash } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-integration"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function renderToPng(svg: string, outPath: string): void {
const tmpSvg = path.join(TMP, "input.svg")
require("node:fs").writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
encoding: "utf-8",
})
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
}
describe("integration — render pipeline", () => {
test("renders valid PNG 1024x1024", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
const hash = computeHash(brand, templateSvg, args)
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "cover.png")
renderToPng(svg, outPath)
expect(existsSync(outPath)).toBe(true)
const buf = readFileSync(outPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
expect(hash).toHaveLength(64)
})
test("creates output directory recursively", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "a", "b", "c", "cover.png")
renderToPng(svg, outPath)
expect(existsSync(outPath)).toBe(true)
})
})

View file

@ -0,0 +1,58 @@
import { describe, test, expect } from "vitest"
import { parseSlots } from "../src/slot-parser"
describe("slot-parser — parseSlots", () => {
test("parses icon slot with all defaults", () => {
const svg = `<!-- slot: name=icon, x=312, y=200, w=400, h=400, fit=contain, recolor=accent -->`
const slots = parseSlots(svg)
expect(slots).toHaveLength(1)
expect(slots[0].name).toBe("icon")
expect(slots[0].x).toBe(312)
expect(slots[0].y).toBe(200)
expect(slots[0].w).toBe(400)
expect(slots[0].h).toBe(400)
expect(slots[0].fit).toBe("contain")
expect(slots[0].recolor).toBe("accent")
expect(slots[0].bg).toBe("none")
expect(slots[0].border).toBe("none")
expect(slots[0].radius).toBe(0)
})
test("parses badge slot with bg+border+radius", () => {
const svg = `<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->`
const slots = parseSlots(svg)
expect(slots).toHaveLength(1)
expect(slots[0].name).toBe("badge")
expect(slots[0].recolor).toBe("none")
expect(slots[0].bg).toBe("surface")
expect(slots[0].border).toBe("accent")
expect(slots[0].radius).toBe(0.5)
})
test("parses multiple slots", () => {
const svg = `
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
`
const slots = parseSlots(svg)
expect(slots).toHaveLength(3)
expect(slots.map((s) => s.name)).toEqual(["icon", "sub-icon", "badge"])
})
test("defaults fit to contain when omitted", () => {
const svg = `<!-- slot: name=icon, x=0, y=0, w=100, h=100 -->`
const slots = parseSlots(svg)
expect(slots[0].fit).toBe("contain")
expect(slots[0].recolor).toBe("none")
})
test("returns empty for no slots", () => {
expect(parseSlots("<svg></svg>")).toEqual([])
})
test("skips slot without name", () => {
const svg = `<!-- slot: x=0, y=0, w=100, h=100 -->`
expect(parseSlots(svg)).toEqual([])
})
})

View file

@ -0,0 +1,75 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render"
import { parseSlots } from "../src/slot-parser"
import { resolveSlotContent } from "../src/resolve"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
beforeAll(() => {
const tmp = "/tmp/draw-image-slot"
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
mkdirSync(tmp, { recursive: true })
})
describe("integration — slot resolution", () => {
test("icon=mic resolves lucide icon and recolors to accent", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
expect(iconSlot).toBeDefined()
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "mic")
expect(resolved).not.toBeNull()
expect(resolved!.inner).toContain("<path")
expect(resolved!.rootAttrs.stroke).toBe("#ccff00")
expect(resolved!.contentW).toBe(24)
expect(resolved!.contentH).toBe(24)
})
test("icon=play resolves lucide icon", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")!
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
expect(resolved).not.toBeNull()
expect(resolved!.inner).toContain("<path")
})
test("badge slot has bg=surface and border=accent", () => {
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const slots = parseSlots(templateSvg)
const badgeSlot = slots.find((s) => s.name === "badge")!
expect(badgeSlot.bg).toBe("surface")
expect(badgeSlot.border).toBe("accent")
expect(badgeSlot.radius).toBe(0.5)
})
test("buildSvg inserts slot content into final SVG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Icon",
slots: { icon: "mic" },
}, DRAW_IMAGE_DIR)
expect(svg).toContain("<path")
expect(svg).toContain('stroke="#ccff00"')
expect(svg).not.toContain("<!-- slot:")
expect(svg).toContain("With Icon")
})
test("buildSvg without slots renders template defaults", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
expect(svg).toContain("No Icon")
expect(svg).not.toContain("<!-- slot:")
})
})

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "cli.ts", "tests/**/*.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
})

View file

@ -0,0 +1,32 @@
import { spawnSync } from "child_process"
import { tool } from "@opencode-ai/plugin"
import path from "path"
export default tool({
description: "Render an on-brand cover PNG from an SVG template. Resolves Lucide icons and brand logos into slots, applies brand palette, renders via sharp. Returns { path, hash, status }.",
args: {
template: tool.schema.string().describe("Template name (file in templates/ without .svg extension, e.g. 'cover')"),
title: tool.schema.string().describe("Title text rendered at the bottom of the cover"),
subtitle: tool.schema.string().optional().describe("Optional subtitle text below the title"),
slots: tool.schema.string().optional().describe("Comma-separated slot assignments, e.g. 'icon=mic,sub-icon=opencode,badge=./x.svg'"),
out: tool.schema.string().optional().describe("Output PNG path (default: ./assets/cover.png)"),
},
async execute(args, context) {
const drawImageDir = path.resolve(import.meta.dir, "..", "draw-image")
const cliPath = path.join(drawImageDir, "cli.ts")
const cliArgs = ["render", args.template, "--title", args.title]
if (args.subtitle) cliArgs.push("--subtitle", args.subtitle)
if (args.slots) cliArgs.push("--slots", args.slots)
if (args.out) cliArgs.push("--out", args.out)
const r = spawnSync("node", ["--experimental-strip-types", cliPath, ...cliArgs], {
encoding: "utf-8",
cwd: context.worktree,
})
if (r.status !== 0) {
return `⚠️ draw-image failed (exit ${r.status}): ${r.stderr || r.stdout}`
}
return r.stdout.trim()
},
})

View file

@ -0,0 +1,24 @@
# ADR-060: SVG template renderer for on-brand covers
## Статус
Accepted (2026-07-29)
## Контекст
В продуктовых репо slaid098 нет ни одного cover-ассета. Обложки для slaid098.dev рисовались вручную или генерировались fallback-функцией (svgFallback в route.ts — HSL-градиент + буква, off-brand). Нужна утилита, которая берёт SVG-шаблон со слотами, подставляет палитру из brand.json + контент из args, рендерит PNG через sharp с bundled Geist TTF — always on-brand.
Архитектурный выбор: размещение в `.opencode/` (opencode plugin + render-движок) через volume bind-mount → видна во всех воркспейсах глобально. Отдельный package.json в `.opencode/draw-image/` (deps: sharp, lucide-static) — не загрязняет `.opencode/package.json` (используется для tools/*.ts).
## Решение
1. **opencode plugin** (`.opencode/tools/draw-image.ts`) — spawnSync wrapper вокруг `node cli.ts render ...`, возвращает `{ path, hash, status }`
2. **Слот-система** — HTML-комментарии в SVG-шаблоне парсятся в SlotSpec (name/x/y/w/h/fit/recolor/bg/border/radius). Контент резолвится: Lucide lookup → brand-logos lookup → file path
3. **Render pipeline** — buildSvg (assemble) → render.mjs (sharp → PNG 1024×1024, fontFiles: Geist TTF)
4. **Идемпотентность** — cover.meta.json с sha256(brand+template+args), skip при совпадении хеша
5. **Lucide-иконки** — npm-пакет lucide-static, postinstall sync в icons/lucide/, recolor stroke → accent
6. **Шрифты** — Geist Sans TTF bundled в fonts/, переданы в sharp через fontFiles (без этого fallback = off-brand)
7. **Тесты** — vitest (48: unit + integration + e2e) + pytest (5: tool wrapper via _ts_loader.mjs)
## Альтернативы
- **@resvg/resvg-js вместо sharp** — sharp уже используется в экосистеме, fontFiles опция покрывает потребность. resvg был бы отдельной dep.
- **Шрифты через fontconfig (системная установка)** — негибко, требует root в CI. Bundle TTF в fonts/ + fontFiles опция sharp — self-contained.
- **Шаблоны в коде (не SVG-файлы)** — менее гибко, требует правок кода для нового шаблона. SVG-файлы в templates/ — ноль правок кода.
- **Отдельный package.json vs расширить .opencode/package.json** — отдельный package.json изолирует deps (sharp тяжёлая native dep) от tools/*.ts.

View file

@ -0,0 +1,29 @@
---
pr: 134
title: feat(draw-image): SVG template renderer for on-brand covers
---
## Что сделано
Реализован плагин `draw-image` для генерации on-brand обложек (covers) в стиле slaid098.dev. Структура:
- `.opencode/tools/draw-image.ts` — opencode plugin (spawnSync → cli.ts)
- `.opencode/draw-image/` — render-движок: render.mjs (sharp → PNG), cli.ts, src/ (config/slot-parser/fit/recolor/resolve/render), templates/cover.svg, brand.json, fonts/ (Geist TTF), icons/lucide/ (2007 иконок), brand-logos/ (пусто v1)
- Слот-система: HTML-комментарии в SVG → SlotSpec (name/x/y/w/h/fit/recolor/bg/border/radius)
- Lucide-иконки: npm-пакет lucide-static, postinstall sync в icons/lucide/, recolor stroke → accent
- Шрифты: Geist Sans TTF bundled, переданы в sharp через fontFiles
- Идемпотентность: cover.meta.json с sha256(brand+template+args)
- Тесты: 48 vitest (unit + integration + e2e) + 5 pytest (tool wrapper via _ts_loader.mjs)
- CI: npm ci + npm test в .opencode/draw-image добавлены в test job
- dependabot.yml: битый `/config``/.opencode` + `/.opencode/draw-image`
- docs/project-map/README.md обновлён
## Почему
В продуктовых репо slaid098 нет ни одного cover-ассета — обложки рисовались вручную или отсутствовали. Утилита обеспечивает always on-brand генерацию: цвета захардкожены в шаблоне, Geist TTF bundled, Lucide line-icons recolor в accent. Эталон — covers из slaid098-dev/src/apps/.
## Pending
## Watch out
- `import.meta.dir` в draw-image.ts (не `import.meta.url`) — _ts_loader.mjs заменяет только `.dir`
- CLI использует `--experimental-strip-types` (Node 22+) для запуска .ts напрямую
- lucide-static postinstall копирует 2007 SVG в icons/lucide/ (committим только mic/play, остальные через npm)
- sharp fontFiles требует Geist TTF в fonts/ (committим Regular + Bold)

View file

@ -46,9 +46,10 @@ opencode-config/
│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates format+labels; optional repo?: string) — PR#38, PR#65
│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65
│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, include_clone/development_en/ru optional params, RU heading 'Русский' + anchor checks, 4 delimiter pairs for slaid098.dev; local fs + remote gh api) — PR#112, PR#116, PR#118, PR#130
│ │ ├── draw-image.ts # draw-image tool wrapper (opencode plugin, 5 args: template/title/subtitle?/slots?/out?; spawnSync node cli.ts render → sharp PNG) — PR#133
│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65
│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2, npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2; npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124
@ -57,6 +58,37 @@ opencode-config/
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
│ │ ├── spec-status.ts # spec-status tool wrapper
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133
│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle)
│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out
│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line)
│ │ ├── templates/cover.svg # 1024×1024 cover template (slots: icon + sub-icon + badge, {{title}}/{{subtitle}})
│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles
│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall)
│ │ ├── brand-logos/ # brand SVG logos (empty in v1, .gitkeep)
│ │ ├── scripts/sync-lucide.mjs # postinstall: copy icons from node_modules/lucide-static → icons/lucide/
│ │ ├── src/
│ │ │ ├── config.ts # loadBrand + validateBrand (zod-style hex validation)
│ │ │ ├── slot-parser.ts # parseSlots: HTML comments → SlotSpec objects
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency)
│ │ └── tests/ # vitest: 48 tests (unit + integration + e2e)
│ │ ├── config.test.ts
│ │ ├── slot-parser.test.ts
│ │ ├── fit.test.ts
│ │ ├── recolor.test.ts
│ │ ├── cli.test.ts
│ │ ├── render.integration.test.ts
│ │ ├── idempotency.test.ts
│ │ ├── slot.test.ts
│ │ ├── e2e.test.ts
│ │ ├── e2e.no-icon.test.ts
│ │ └── e2e.bad-input.test.ts
│ ├── scripts/
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
@ -94,6 +126,8 @@ opencode-config/
│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
│ ├── test_draw_image_tool.py # .opencode/tools/draw-image.ts (via _ts_loader.mjs exec_stub_json; 5 tests: load/valid/failure/cwd/all-args) — PR#133
│ ├── test_draw_image_tool.ts # TS wrapper test (mjs loader; 3 tests: valid/failure/cwd) — PR#133
│ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL)
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)

View file

@ -0,0 +1,132 @@
"""Tests for .opencode/tools/draw-image.ts — the draw-image custom tool.
Mirrors tests/test_commit_tool.py: exercises the tool's ``execute()`` function
via ``tests/_ts_loader.mjs`` using the ``exec_stub_json`` mode (multi-arg tools).
The loader is parameterized via the ``TS_FILE`` env var. These tests set
``TS_FILE=.opencode/tools/draw-image.ts``.
Modes used:
- ``load`` sanity-check that the tool loads and declares args.
- ``exec_stub_json`` call execute with a stubbed spawnSync to verify:
(a) success path: valid args + spawnSync exit 0 JSON result string,
(b) failure path: spawnSync exit 1 error string with "draw-image failed",
(c) cwd propagation: spawnSync opts contain cwd=context.worktree.
draw-image.ts makes 1 spawnSync call (node cli.ts render ...). The stub
sequencer returns responses in order per call.
"""
import json
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "draw-image.ts"
TS_FILE_REL = ".opencode/tools/draw-image.ts"
TMP_OUT = os.path.join(tempfile.gettempdir(), "out.png")
TMP_CUSTOM = os.path.join(tempfile.gettempdir(), "custom.png")
OK_RESPONSE = {
"status": 0,
"stdout": '{"path":"/tmp/out.png","hash":"abc123","status":"rendered"}',
"stderr": "",
}
FAIL_RESPONSE = {"status": 1, "stdout": "", "stderr": "render failed"}
def _run_loader(*args: str) -> dict:
"""Invoke the loader with TS_FILE env set to draw-image.ts and parse JSON stdout."""
env = {**os.environ, "TS_FILE": TS_FILE_REL}
proc = subprocess.run(
["node", str(LOADER), *args],
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
timeout=60,
env=env,
)
if proc.returncode != 0:
raise RuntimeError(
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
)
return json.loads(proc.stdout)
def _run_exec(args: dict, responses: list[dict]) -> dict:
"""Helper: exec_stub_json mode with JSON args + sequential stub responses."""
return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses))
def test_loader_can_load_tool():
"""Sanity: draw-image.ts loads and declares the expected arguments."""
if not TS_FILE.exists():
pytest.skip("draw-image.ts not present")
out = _run_loader("load")
assert "description" in out
assert "template" in out["args"]
assert "title" in out["args"]
assert "subtitle" in out["args"]
assert "slots" in out["args"]
assert "out" in out["args"]
def test_valid_render():
"""execute() with valid args + spawnSync exit 0 returns JSON result string.
draw-image.ts makes 1 spawnSync call: node cli.ts render ...
"""
out = _run_exec({"template": "cover", "title": "Test"}, [OK_RESPONSE])
result = out["result"]
assert "rendered" in result, f"expected rendered status, got: {result!r}"
assert TMP_OUT in result, f"expected path in result, got: {result!r}"
def test_render_failure():
"""execute() with spawnSync exit 1 returns error string."""
out = _run_exec({"template": "cover", "title": "Test"}, [FAIL_RESPONSE])
result = out["result"]
assert "draw-image failed" in result, f"expected failure error, got: {result!r}"
def test_execute_uses_cwd_from_context():
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern)."""
out = _run_exec({"template": "cover", "title": "Test"}, [OK_RESPONSE])
calls = out["calls"]
assert len(calls) >= 1, f"expected >=1 spawnSync call, got {len(calls)}"
opts = calls[0]["opts"]
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
assert opts["cwd"] == str(REPO_ROOT), (
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
)
def test_passes_all_args_to_cli():
"""execute builds CLI args from all provided fields."""
args = {
"template": "cover",
"title": "My Title",
"subtitle": "Sub",
"slots": "icon=mic",
"out": TMP_CUSTOM,
}
out = _run_exec(args, [OK_RESPONSE])
calls = out["calls"]
assert len(calls) >= 1
cli_args = calls[0]["args"]
cli_args_str = " ".join(cli_args)
assert "render" in cli_args_str
assert "cover" in cli_args_str
assert "My Title" in cli_args_str
assert "Sub" in cli_args_str
assert "icon=mic" in cli_args_str
assert TMP_CUSTOM in cli_args_str

View file

@ -0,0 +1,70 @@
/**
* Tests for .opencode/tools/draw-image.ts the draw-image custom tool.
*
* Mirror of tests/test_commit_tool.ts: the tool is a spawnSync wrapper
* around `node cli.ts render ...`. Runtime note: opencode ships a standalone
* binary with Bun bundled inside; there is no separate `bun` CLI on the host
* (CI runner uses node + pytest). The CI runs the equivalent Python tests in
* tests/test_draw_image_tool.py via the JS loader tests/_ts_loader.mjs
* (exec_stub_json mode for multi-arg tools). This file documents the intended
* TS-side test cases and is runnable under `bun test` once a bun runtime is
* available on the host.
*
* Test cases (mirror tests/test_draw_image_tool.py):
* - test_loader_can_load_tool tool loads, declares args
* - test_valid_render valid args + spawnSync exit 0 JSON result
* - test_render_failure spawnSync exit 1 error string
* - test_execute_uses_cwd_from_context cwd=context.worktree propagated
*/
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
import { spawnSync } from "child_process"
import path from "path"
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "draw-image.ts")
function ctx() {
return {
sessionID: "t", messageID: "t", agent: "t",
directory: ".", worktree: ".",
abort: new AbortController().signal,
metadata() {}, async ask() {},
}
}
describe("draw-image tool", () => {
test("test_valid_render — valid args + spawnSync exit 0 returns JSON", async () => {
const okResponse = '{"path":"/tmp/out.png","hash":"abc123","status":"rendered"}'
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: okResponse, stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({ template: "cover", title: "Test" }, ctx())
expect(result).toContain("rendered")
expect(result).toContain("/tmp/out.png")
})
test("test_render_failure — spawnSync exit 1 returns error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 1, stdout: "", stderr: "render failed" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({ template: "cover", title: "Test" }, ctx())
expect(result).toContain("draw-image failed")
})
test("test_execute_uses_cwd_from_context — cwd propagated to spawnSync", async () => {
const okResponse = '{"path":"./out.png","hash":"abc","status":"rendered"}'
let capturedOpts: any = null
mock.module("child_process", () => ({
spawnSync: (_cmd: string, _args: string[], opts: any) => {
capturedOpts = opts
return { status: 0, stdout: okResponse, stderr: "" }
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({ template: "cover", title: "Test" }, ctx())
expect(capturedOpts).not.toBeNull()
expect(capturedOpts.cwd).toBe(".")
})
})