opencode-config/.opencode/draw-image/src/resolve.ts
Sergey 56b67d2d5c
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>
2026-07-29 23:42:50 +03:00

78 lines
No EOL
2.9 KiB
TypeScript

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")
}