Skip to content

Commit 4a5c832

Browse files
committed
fix: svg dimensions not properly resolving in runtime instances
Fixes #497
1 parent 61f8fb3 commit 4a5c832

12 files changed

Lines changed: 210 additions & 55 deletions

File tree

pnpm-lock.yaml

Lines changed: 47 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ catalog:
104104
vitest: ^4.1.0
105105
vue: ^3.5.30
106106
vue-router: ^5.0.3
107-
vue-tsc: ^3.2.5
107+
vue-tsc: ^3.2.6
108108
wrangler: ^4.74.0
109109
yoga-wasm-web: ^0.3.3
110110
catalogs:

src/runtime/server/og-image/core/vnodes.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,48 @@ const SVG_CAMEL_ATTRS: Record<string, string> = {
7878

7979
export const SVG_CAMEL_ATTR_VALUES = new Set(Object.values(SVG_CAMEL_ATTRS))
8080

81+
const RE_PERCENT = /^\d+%$/
82+
83+
/**
84+
* Resolve an SVG element's width or height from multiple sources:
85+
* 1. Numeric HTML attribute (e.g. width="24")
86+
* 2. Style property (e.g. style.width = 48 or "48px")
87+
* 3. viewBox (3rd/4th value for width/height)
88+
*
89+
* Percentage attributes are skipped (returned as undefined) so callers
90+
* can fall back to ancestor or renderer-specific resolution.
91+
*/
92+
export function resolveSvgDimension(
93+
props: Record<string, any>,
94+
style: Record<string, any> | undefined,
95+
key: 'width' | 'height',
96+
): number | undefined {
97+
// 1. HTML attribute (skip percentages)
98+
const attr = props[key]
99+
if (attr != null && !RE_PERCENT.test(String(attr))) {
100+
const n = Number(attr)
101+
if (!Number.isNaN(n))
102+
return n
103+
}
104+
// 2. Style property
105+
const sv = style?.[key]
106+
if (sv != null) {
107+
const n = typeof sv === 'number' ? sv : Number.parseInt(String(sv))
108+
if (!Number.isNaN(n))
109+
return n
110+
}
111+
// 3. viewBox fallback
112+
const vb = props.viewBox || props.viewbox
113+
if (typeof vb === 'string') {
114+
const parts = vb.split(/[\s,]+/)
115+
if (parts.length === 4) {
116+
const n = Number(parts[key === 'width' ? 2 : 3])
117+
if (!Number.isNaN(n))
118+
return n
119+
}
120+
}
121+
}
122+
81123
export function parseStyleAttr(style: string | null | undefined): Record<string, any> | undefined {
82124
if (!style)
83125
return undefined

src/runtime/server/og-image/satori/vnodes.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { FontConfig, OgImageRenderEventContext, VNode } from '../../../types'
22
import resolvedFonts from '#og-image/fonts'
33
import { walkTree } from '../core/plugins'
4-
import { createVNodes as coreCreateVNodes } from '../core/vnodes'
4+
import { createVNodes as coreCreateVNodes, resolveSvgDimension } from '../core/vnodes'
55
import classes from './plugins/classes'
66
import emojis from './plugins/emojis'
77
import flex from './plugins/flex'
88
import nuxtIcon from './plugins/nuxt-icon'
99

1010
// Re-export shared utilities for external consumers
11-
export { htmlToVNode, SVG_CAMEL_ATTR_VALUES, warnUnsupportedSvgElements } from '../core/vnodes'
11+
export { htmlToVNode, resolveSvgDimension, SVG_CAMEL_ATTR_VALUES, warnUnsupportedSvgElements } from '../core/vnodes'
1212

1313
// Get default font family from resolved fonts
1414
function getDefaultFontFamily(): string {
@@ -20,12 +20,12 @@ function getDefaultFontFamily(): string {
2020
}
2121

2222
const RE_PX = /^(\d+(?:\.\d+)?)px$/
23-
const RE_PERCENT = /^\d+%$/
2423

2524
/**
2625
* Satori embeds inline <svg> as data URI <image> elements, resolving width/height
2726
* attributes to pixel values. Percentage values (width="100%") become NaN.
28-
* This walk finds the nearest ancestor with explicit pixel dimensions and resolves.
27+
* This walk uses the shared resolveSvgDimension (attrs → style → viewBox),
28+
* then falls back to the nearest ancestor with explicit pixel dimensions.
2929
*/
3030
function findAncestorPxDim(ancestors: VNode[], prop: 'width' | 'height'): number | undefined {
3131
for (let i = ancestors.length - 1; i >= 0; i--) {
@@ -38,17 +38,20 @@ function findAncestorPxDim(ancestors: VNode[], prop: 'width' | 'height'): number
3838
}
3939
}
4040

41+
const RE_PERCENT = /^\d+%$/
42+
4143
function resolveSvgPercentDimensions(node: VNode, ancestors: VNode[] = []) {
4244
if (node.type === 'svg') {
43-
if (RE_PERCENT.test(node.props?.width || '')) {
44-
const px = findAncestorPxDim(ancestors, 'width')
45-
if (px)
46-
node.props.width = px
47-
}
48-
if (RE_PERCENT.test(node.props?.height || '')) {
49-
const px = findAncestorPxDim(ancestors, 'height')
50-
if (px)
51-
node.props.height = px
45+
for (const dim of ['width', 'height'] as const) {
46+
const val = String(node.props?.[dim] || '')
47+
const isPercent = RE_PERCENT.test(val)
48+
if (isPercent) {
49+
// Percentage → prefer ancestor container px, fall back to viewBox
50+
const resolved = findAncestorPxDim(ancestors, dim) ?? resolveSvgDimension(node.props, node.props?.style, dim)
51+
if (resolved)
52+
node.props[dim] = resolved
53+
}
54+
// Don't resolve relative units (em/rem) — satori handles these natively
5255
}
5356
}
5457
if (Array.isArray(node.props?.children)) {

src/runtime/server/og-image/takumi/nodes.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { ContainerNode, ImageNode, Node, TextNode } from '@takumi-rs/core'
22
import type { OgImageRenderEventContext, VNode } from '../../../types'
3-
import { createVNodes, SVG_CAMEL_ATTR_VALUES } from '../core/vnodes'
3+
import { createVNodes, resolveSvgDimension, SVG_CAMEL_ATTR_VALUES } from '../core/vnodes'
4+
5+
const RE_RELATIVE_UNIT = /^([\d.]+)(em|rem)$/
6+
const RE_TW_TEXT_ARBITRARY = /(?:^|\s)text-\[(\d+(?:\.\d+)?)(px|rem|em)\]/
7+
const RE_FONT_SIZE_PX = /^(\d+(?:\.\d+)?)(px)?$/
8+
9+
const DEFAULT_FONT_SIZE = 16
410

511
const RE_UPPERCASE = /[A-Z]/g
612
const RE_DQUOTE = /"/g
@@ -10,7 +16,7 @@ const RE_GT = />/g
1016

1117
export async function createTakumiNodes(ctx: OgImageRenderEventContext): Promise<Node> {
1218
const vnodeTree = await createVNodes(ctx)
13-
return await vnodeToTakumiNode(vnodeTree)
19+
return await vnodeToTakumiNode(vnodeTree, DEFAULT_FONT_SIZE)
1420
}
1521

1622
// Extract numeric width/height from HTML attributes
@@ -22,7 +28,41 @@ function pickNumericDimension(props: Record<string, any>, key: 'width' | 'height
2228
return Number.isNaN(n) ? undefined : n
2329
}
2430

25-
async function vnodeToTakumiNode(vnode: VNode): Promise<Node> {
31+
/**
32+
* Resolve an em/rem value to pixels given the inherited font size.
33+
*/
34+
function resolveRelativeUnit(value: string | number | undefined, inheritedFontSize: number): number | undefined {
35+
if (value == null)
36+
return undefined
37+
const match = String(value).match(RE_RELATIVE_UNIT)
38+
if (!match)
39+
return undefined
40+
const n = Number.parseFloat(match[1]!)
41+
return match[2] === 'rem' ? n * DEFAULT_FONT_SIZE : n * inheritedFontSize
42+
}
43+
44+
/**
45+
* Extract font size in px from a vnode's style or tailwind classes.
46+
*/
47+
function extractFontSize(props: Record<string, any>, style: Record<string, any> | undefined): number | undefined {
48+
// 1. Inline style fontSize
49+
if (style?.fontSize != null) {
50+
const m = String(style.fontSize).match(RE_FONT_SIZE_PX)
51+
if (m)
52+
return Number.parseFloat(m[1]!)
53+
}
54+
// 2. Tailwind arbitrary text-[Npx] from class or tw
55+
const twStr = props.tw || props.class || ''
56+
const twMatch = twStr.match(RE_TW_TEXT_ARBITRARY)
57+
if (twMatch) {
58+
const val = Number.parseFloat(twMatch[1]!)
59+
if (twMatch[2] === 'px')
60+
return val
61+
}
62+
return undefined
63+
}
64+
65+
async function vnodeToTakumiNode(vnode: VNode, inheritedFontSize: number): Promise<Node> {
2666
const { style, children, class: cls, tw, src, ...rest } = vnode.props
2767

2868
const baseMetadata = {
@@ -32,12 +72,25 @@ async function vnodeToTakumiNode(vnode: VNode): Promise<Node> {
3272

3373
// SVG elements → convert to SVG string
3474
if (vnode.type === 'svg') {
75+
// Only resolve em/rem to pixels when we have an explicit font size from a parent
76+
// (e.g. text-[80px]). When using the default 16px, leave dimensions unset so
77+
// takumi can handle the SVG's native 1em sizing and keep inline layout.
78+
const hasExplicitFontSize = inheritedFontSize !== DEFAULT_FONT_SIZE
79+
const isRelativeW = RE_RELATIVE_UNIT.test(String(rest.width ?? ''))
80+
const isRelativeH = RE_RELATIVE_UNIT.test(String(rest.height ?? ''))
3581
return {
3682
...baseMetadata,
3783
type: 'image',
3884
src: vnodeToHtmlString(vnode),
39-
width: pickNumericDimension(rest, 'width'),
40-
height: pickNumericDimension(rest, 'height'),
85+
// When em/rem + explicit parent font size → resolve to px.
86+
// When em/rem + default font size → leave undefined (takumi handles natively).
87+
// Otherwise → use standard resolution chain (numeric attrs → style → viewBox).
88+
width: isRelativeW
89+
? (hasExplicitFontSize ? resolveRelativeUnit(rest.width, inheritedFontSize) : undefined)
90+
: resolveSvgDimension(rest, style, 'width'),
91+
height: isRelativeH
92+
? (hasExplicitFontSize ? resolveRelativeUnit(rest.height, inheritedFontSize) : undefined)
93+
: resolveSvgDimension(rest, style, 'height'),
4194
} satisfies ImageNode
4295
}
4396

@@ -51,6 +104,10 @@ async function vnodeToTakumiNode(vnode: VNode): Promise<Node> {
51104
} satisfies ImageNode
52105
}
53106

107+
// Compute inherited font size for children
108+
const nodeFontSize = extractFontSize(vnode.props, style)
109+
const childFontSize = nodeFontSize ?? inheritedFontSize
110+
54111
// For non-image nodes, merge any explicit width/height into style
55112
const containerStyle = { ...style }
56113
const w = pickNumericDimension(rest, 'width')
@@ -87,7 +144,7 @@ async function vnodeToTakumiNode(vnode: VNode): Promise<Node> {
87144
const takumiChildren: Node[] = []
88145
for (const child of children) {
89146
if (child && typeof child === 'object')
90-
takumiChildren.push(await vnodeToTakumiNode(child))
147+
takumiChildren.push(await vnodeToTakumiNode(child, childFontSize))
91148
else if (typeof child === 'string' && child.trim())
92149
takumiChildren.push({ type: 'text', text: child.trim() })
93150
}
109 KB
Loading
18.8 KB
Loading
13.3 KB
Loading

test/fixtures/basic/components/OgImage/ComplexTest.satori.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ withDefaults(defineProps<{
1111
</script>
1212

1313
<template>
14-
<div class="w-full h-full flex flex-col bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 p-12">
14+
<div class="w-full h-full flex flex-col p-12" style="background: linear-gradient(to bottom right, #312e81, #581c87, #9d174d);">
1515
<!-- Header -->
1616
<div class="flex justify-between items-start mb-8">
1717
<div class="flex items-center gap-4">
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup>
2+
import { defineOgImage } from '#imports'
3+
4+
defineOgImage('BlogPost.takumi', {
5+
title: 'Hi! 👋',
6+
})
7+
</script>
8+
9+
<template>
10+
<div>
11+
<h1>BlogPost Emoji Test</h1>
12+
<p>Tests emoji rendering in BlogPost template title prop via takumi</p>
13+
</div>
14+
</template>

0 commit comments

Comments
 (0)