* 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>
78 lines
No EOL
2.9 KiB
TypeScript
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")
|
|
} |