feat(canvas): improve CJK text rendering, badge styling, and path safety

- Add splitByGrapheme for CJK text to enable natural character-level wrapping
- Add optical vertical centering offset for single-line text in horizontal layouts
- Use fallback path data when SVG d attribute is empty to prevent render errors
- Fix stroke-only path detection to check for visible stroke (non-zero width)
- Add isBadgeLikeFrame heuristic for badge/chip/tag detection
- Enhance badge sizing: auto corner-radius, icon normalization, CJK-aware padding
- Fix fill-style icon resolution to always apply visible fill color
- Add dot/bullet/point/circlefill to icon path map
This commit is contained in:
Fini 2026-02-22 16:04:18 +08:00
parent 4a82605322
commit fa02ccb3fc
4 changed files with 329 additions and 45 deletions

View file

@ -133,6 +133,14 @@ function resolveTextContent(
return content.map((s) => s.text).join('')
}
function shouldSplitByGrapheme(text: string): boolean {
// Mixed CJK content (especially with a long CJK run) wraps poorly with word-based splitting.
// Enable grapheme splitting so Textbox can break between CJK characters naturally.
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
const hasLongCjkRun = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]{4,}/.test(text)
return hasCjk && hasLongCjkRun
}
function sizeToNumber(
val: number | string | undefined,
fallback: number,
@ -275,20 +283,26 @@ export function createFabricObject(
case 'path': {
const pw = sizeToNumber(node.width, 0)
const ph = sizeToNumber(node.height, 0)
const safePathData =
typeof node.d === 'string' && node.d.trim().length > 0
? node.d
: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
const hasExplicitFill = node.fill && node.fill.length > 0
const hasStroke = !!node.stroke
const strokeColor = resolveStrokeColor(node.stroke)
const strokeWidth = resolveStrokeWidth(node.stroke)
const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
// Stroke-only icons (e.g. Lucide-style) must not get a default fill.
// Use 'transparent' (not 'none' — Fabric.js ignores 'none' and falls back to black).
const pathFill = hasExplicitFill
? resolveFill(node.fill, pw || 100, ph || 100)
: hasStroke
: hasVisibleStroke
? 'transparent'
: DEFAULT_FILL
obj = new fabric.Path(node.d, {
obj = new fabric.Path(safePathData, {
...baseProps,
fill: pathFill,
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
stroke: hasVisibleStroke ? strokeColor : undefined,
strokeWidth: hasVisibleStroke ? strokeWidth : 0,
strokeUniform: true,
fillRule: 'evenodd', // Compound paths: inner sub-paths become transparent cutouts
}) as FabricObjectWithPenId
@ -305,6 +319,7 @@ export function createFabricObject(
case 'text': {
const textContent = resolveTextContent(node.content)
const w = sizeToNumber(node.width, 0)
const splitByGrapheme = shouldSplitByGrapheme(textContent)
const textProps = {
...baseProps,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
@ -328,6 +343,7 @@ export function createFabricObject(
obj = new fabric.Textbox(textContent, {
...textProps,
width: w > 0 ? w : 200,
splitByGrapheme,
}) as FabricObjectWithPenId
} else {
obj = new fabric.IText(textContent, textProps) as FabricObjectWithPenId

View file

@ -1,3 +1,4 @@
import * as fabric from 'fabric'
import type { PenNode } from '@/types/pen'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import {
@ -30,6 +31,12 @@ function cornerRadiusValue(
return cr[0]
}
function shouldSplitByGrapheme(text: string): boolean {
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
const hasLongCjkRun = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]{4,}/.test(text)
return hasCjk && hasLongCjkRun
}
export function syncFabricObject(
obj: FabricObjectWithPenId,
node: PenNode,
@ -116,6 +123,7 @@ export function syncFabricObject(
: node.content.map((s) => s.text).join('')
const w = sizeToNumber(node.width, 0)
const fontSize = node.fontSize ?? 16
const splitByGrapheme = shouldSplitByGrapheme(content)
obj.set({
text: content,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
@ -129,6 +137,9 @@ export function syncFabricObject(
? (node.letterSpacing / fontSize) * 1000
: 0,
})
if (obj instanceof fabric.Textbox) {
obj.set({ splitByGrapheme } as Partial<fabric.Textbox>)
}
if (w > 0) obj.set({ width: w })
break
}
@ -137,15 +148,17 @@ export function syncFabricObject(
const w = sizeToNumber('width' in node ? node.width : undefined, 100)
const h = sizeToNumber('height' in node ? node.height : undefined, 100)
const hasExplicitFill = node.type === 'path' && 'fill' in node && node.fill && node.fill.length > 0
const hasStroke = 'stroke' in node && !!node.stroke
const strokeColor = resolveStrokeColor('stroke' in node ? node.stroke : undefined)
const strokeWidth = resolveStrokeWidth('stroke' in node ? node.stroke : undefined)
const hasVisibleStroke = node.type === 'path' && strokeWidth > 0 && !!strokeColor
// For path nodes: stroke-only icons must not get a default fill
const fill = node.type === 'path' && !hasExplicitFill && hasStroke
const fill = node.type === 'path' && !hasExplicitFill && hasVisibleStroke
? 'transparent'
: resolveFill('fill' in node ? node.fill : undefined, w, h)
obj.set({
fill,
stroke: resolveStrokeColor('stroke' in node ? node.stroke : undefined),
strokeWidth: resolveStrokeWidth('stroke' in node ? node.stroke : undefined),
stroke: hasVisibleStroke ? strokeColor : undefined,
strokeWidth: hasVisibleStroke ? strokeWidth : 0,
...(node.type === 'path' ? { strokeUniform: true, fillRule: 'evenodd' } : {}),
})
// Use cached native dimensions (from path/points data) to compute correct

View file

@ -148,6 +148,47 @@ function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0): n
return maxLine
}
function resolveTextContent(node: PenNode): string {
if (node.type !== 'text') return ''
return typeof node.content === 'string'
? node.content
: node.content.map((s) => s.text).join('')
}
function countExplicitTextLines(text: string): number {
if (!text) return 1
return Math.max(1, text.split(/\r?\n/).length)
}
function hasCjkText(text: string): boolean {
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0
if (isCjkCodePoint(code)) return true
}
return false
}
/**
* Optical vertical correction for centered single-line text.
* Font line boxes are mathematically centered but glyph ink tends to look
* slightly top-heavy, especially for CJK, so we nudge down by 1-3px.
*/
function getTextOpticalCenterYOffset(node: PenNode): number {
if (node.type !== 'text') return 0
const text = resolveTextContent(node).trim()
if (!text) return 0
if (countExplicitTextLines(text) > 1) return 0
const fontSize = node.fontSize ?? 16
const lineHeight = node.lineHeight ?? 1.2
const hasCjk = hasCjkText(text)
const ratio = hasCjk ? 0.16 : 0.1
const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.7
const offset = fontSize * ratio * compactLineBoost
return Math.max(1, Math.min(5, Math.round(offset)))
}
/** Compute fit-content width from children. */
function fitContentWidth(node: PenNode): number {
if (!('children' in node) || !node.children?.length) return 0
@ -380,6 +421,11 @@ function computeLayoutPositions(
switch (align) {
case 'center':
crossPos = (crossAvail - effectiveChildCross) / 2
// Optical correction: centered text in horizontal layouts tends to
// look slightly too high; nudge it down a bit for visual centering.
if (!isVertical && child.type === 'text') {
crossPos += getTextOpticalCenterYOffset(child)
}
break
case 'end':
crossPos = crossAvail - childCross
@ -388,6 +434,15 @@ function computeLayoutPositions(
break
}
// Keep child within cross-axis bounds after optical correction.
const clampCrossSize =
(!isVertical && align === 'center' && child.type === 'text')
? effectiveChildCross
: childCross
if (crossAvail >= clampCrossSize) {
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
}
const computedX = isVertical ? pad.left + crossPos : pad.left + mainPos
const computedY = isVertical ? pad.top + mainPos : pad.top + crossPos

View file

@ -925,7 +925,7 @@ function applyTreeFixesRecursive(
// --- Fix 1: Text in layout frames → Fill Width + Auto Height ---
// Skip if parent is fit_content (hug) — fill_container child breaks hug parent layout
const parentIsHug1 = node.width === 'fit_content'
if (node.layout && node.layout !== 'none') {
if (node.layout && node.layout !== 'none' && !isBadgeLikeFrame(node)) {
// Compute parent content width for accurate text height estimation
const nodeW = toSizeNumber(node.width, 0)
const nodePad = parsePaddingValues('padding' in node ? node.padding : undefined)
@ -1070,16 +1070,75 @@ function applyTreeFixesRecursive(
if (t13.type === 'text' && typeof t13.content === 'string') {
const txt13 = t13.content.trim()
const fs13 = t13.fontSize ?? 14
if (txt13.length > 0 && txt13.length <= 20 && fs13 <= 16) {
if (txt13.length > 0 && txt13.length <= 28 && fs13 <= 18 && isBadgeLikeFrame(node)) {
const frameUpdates13: Record<string, unknown> = {}
if (!node.layout || node.layout === 'none') frameUpdates13.layout = 'horizontal'
if (node.alignItems !== 'center') frameUpdates13.alignItems = 'center'
if (!node.justifyContent) frameUpdates13.justifyContent = 'center'
const iconKids13 = children.filter((c: PenNode) =>
c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle',
)
const minGap13 = iconKids13.length > 0 ? 6 : 4
const currentGap13 = toGapNumber('gap' in node ? node.gap : undefined)
if (currentGap13 < minGap13) frameUpdates13.gap = minGap13
const pad13 = parsePaddingValues('padding' in node ? node.padding : undefined)
const minPadH13 = iconKids13.length > 0 ? 14 : 12
if (pad13.top < 6 || pad13.bottom < 6 || pad13.left < minPadH13 || pad13.right < minPadH13) {
frameUpdates13.padding = [
Math.max(pad13.top, 6), Math.max(pad13.right, minPadH13),
Math.max(pad13.bottom, 6), Math.max(pad13.left, minPadH13),
] as [number, number, number, number]
}
const h13 = toSizeNumber(node.height, 0)
if (h13 > 0 && h13 <= 48) {
const pad13 = parsePaddingValues('padding' in node ? node.padding : undefined)
if (pad13.top < 4 || pad13.bottom < 4 || pad13.left < 10 || pad13.right < 10) {
const newPad13: [number, number, number, number] = [
Math.max(pad13.top, 4), Math.max(pad13.right, 10),
Math.max(pad13.bottom, 4), Math.max(pad13.left, 10),
]
updateNode(node.id, { padding: newPad13 } as Partial<PenNode>)
if (h13 > 0) {
const minCr13 = Math.max(6, Math.round(h13 * 0.22))
const maxCr13 = Math.max(minCr13, Math.min(14, Math.round(h13 * 0.4)))
const cr13 = toCornerRadiusNumber(node.cornerRadius, 0)
if (cr13 <= 0) frameUpdates13.cornerRadius = maxCr13
else if (cr13 > maxCr13) frameUpdates13.cornerRadius = maxCr13
else if (cr13 < minCr13) frameUpdates13.cornerRadius = minCr13
}
if (Object.keys(frameUpdates13).length > 0) {
updateNode(node.id, frameUpdates13 as Partial<PenNode>)
}
const hasCjk13 = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(txt13)
const targetLh13 = hasCjk13 ? 1.25 : 1.2
const textUpdates13: Record<string, unknown> = {}
if (!t13.lineHeight || t13.lineHeight > 1.35 || t13.lineHeight < 1.05) {
textUpdates13.lineHeight = targetLh13
}
if (t13.textAlignVertical !== 'middle') textUpdates13.textAlignVertical = 'middle'
if (!t13.textGrowth || t13.textGrowth === 'fixed-width') textUpdates13.textGrowth = 'auto'
if (Object.keys(textUpdates13).length > 0) {
updateNode(t13.id, textUpdates13 as Partial<PenNode>)
}
const iconColor13 = extractPrimaryColor(t13.fill) ?? '#4C72DF'
for (const icon13 of iconKids13) {
if (icon13.type !== 'path') continue
const iconUpdates13: Record<string, unknown> = {}
const iw13 = toSizeNumber(icon13.width, 0)
const ih13 = toSizeNumber(icon13.height, 0)
const targetIcon13 = clamp(toSizeNumber(icon13.width, toSizeNumber(icon13.height, Math.round(fs13 * 0.95))), 10, 16)
if (iw13 <= 0 || iw13 > 20) iconUpdates13.width = targetIcon13
if (ih13 <= 0 || ih13 > 20) iconUpdates13.height = targetIcon13
const strokeW13 = toStrokeThicknessNumber(icon13.stroke, 0)
const hasFill13 = Array.isArray(icon13.fill) && icon13.fill.length > 0
if (!hasFill13 && strokeW13 <= 0) {
iconUpdates13.fill = [{ type: 'solid', color: iconColor13 }]
}
if (icon13.stroke && strokeW13 <= 0) {
iconUpdates13.stroke = { thickness: 1.8, fill: [{ type: 'solid', color: iconColor13 }] }
}
if (Object.keys(iconUpdates13).length > 0) {
updateNode(icon13.id, iconUpdates13 as Partial<PenNode>)
}
}
}
@ -1095,7 +1154,7 @@ function applyTreeFixesRecursive(
const hasTextChild = children.some(
(c: PenNode) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
)
if (nodeH > 0 && nodeH <= 72 && (isHorizontal || isCentered) && hasTextChild) {
if (nodeH > 0 && nodeH <= 72 && (isHorizontal || isCentered) && hasTextChild && !isBadgeLikeFrame(node)) {
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const minV = 8
const minH2 = 16
@ -1315,6 +1374,7 @@ function applyTextFillContainerInLayout(parent: PenNode): void {
// NEVER convert children to fill_container when parent is fit_content (hug width).
// fill_container child + fit_content parent = circular dependency → layout breaks.
const parentIsHug = parent.width === 'fit_content'
const badgeLikeParent = isBadgeLikeFrame(parent)
// Compute parent's actual content width for accurate text height estimation
const parentW = toSizeNumber(parent.width, 0)
@ -1323,6 +1383,25 @@ function applyTextFillContainerInLayout(parent: PenNode): void {
for (const child of parent.children) {
if (child.type === 'text') {
if (badgeLikeParent) {
const fs = child.fontSize ?? 14
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(
typeof child.content === 'string' ? child.content : '',
)
if (!child.textGrowth || child.textGrowth === 'fixed-width') child.textGrowth = 'auto'
if (!child.lineHeight || child.lineHeight > 1.35 || child.lineHeight < 1.05) {
child.lineHeight = hasCjk ? 1.25 : 1.2
}
child.textAlignVertical = 'middle'
if (typeof child.height === 'number') {
const targetTextH = Math.round(fs * (child.lineHeight ?? 1.2) * 1.08)
if (child.height > targetTextH * 1.6 || child.height < targetTextH * 0.85) {
child.height = targetTextH
}
}
continue
}
// Convert fixed-pixel text to fill_container, BUT NOT if parent is fit_content
if (typeof child.width === 'number' && !parentIsHug) {
child.width = 'fill_container'
@ -1515,6 +1594,33 @@ function applyNavbarHeuristic(node: PenNode): void {
if (!node.justifyContent) node.justifyContent = 'space_between'
}
function isBadgeLikeFrame(node: PenNode): boolean {
if (node.type !== 'frame') return false
if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 4) return false
const h = toSizeNumber(node.height, 0)
if (h > 0 && h > 60) return false
const textChildren = node.children.filter(
(c) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
)
if (textChildren.length !== 1) return false
const textNode = textChildren[0]
if (textNode.type !== 'text' || typeof textNode.content !== 'string') return false
const label = textNode.content.trim()
const fontSize = textNode.fontSize ?? 14
if (label.length > 28 || fontSize > 18) return false
const allowedChildren = node.children.every((c) =>
c.type === 'text' || c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle',
)
if (!allowedChildren) return false
return node.layout === 'horizontal'
|| node.alignItems === 'center'
|| node.justifyContent === 'center'
}
/**
* Horizontal inline rows (icon + text, badges, tag rows) should default to
* alignItems="center" for vertical centering. Without this, AI sometimes
@ -1572,29 +1678,45 @@ function applyIconButtonSizing(node: PenNode): void {
*/
function applyBadgeSizing(node: PenNode): void {
if (node.type !== 'frame') return
if (!isBadgeLikeFrame(node)) return
if (!Array.isArray(node.children)) return
// Badge = small frame with 1 short text child
const textChildren = node.children.filter(
(c) => c.type === 'text' && typeof c.content === 'string',
const textNode = node.children.find(
(c: PenNode) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
)
if (textChildren.length !== 1) return
const textNode = textChildren[0]
if (textNode.type !== 'text' || typeof textNode.content !== 'string') return
if (!textNode || textNode.type !== 'text' || typeof textNode.content !== 'string') return
const text = textNode.content.trim()
// Only for short text (badges, tags) — max ~20 chars
if (text.length === 0 || text.length > 20) return
const fontSize = textNode.fontSize ?? 14
// Only for small text (badge-sized)
if (fontSize > 16) return
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(text)
const textColor = extractPrimaryColor(textNode.fill) ?? '#4C72DF'
const iconChildren = node.children.filter((c: PenNode) =>
c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle',
)
const h = toSizeNumber(node.height, 0)
// Frame should be small (badge-like), not a full section
if (h > 48) return
if (!node.layout || node.layout === 'none') node.layout = 'horizontal'
node.alignItems = 'center'
if (!node.justifyContent) node.justifyContent = 'center'
// Ensure minimum padding so text isn't clipped
const currentGap = toGapNumber('gap' in node ? node.gap : undefined)
const minGap = iconChildren.length > 0 ? 6 : 4
if (currentGap < minGap) node.gap = minGap
else if (currentGap > 14) node.gap = 8
// Badge text should remain single-line and optically centered.
const targetLineHeight = hasCjk ? 1.25 : 1.2
if (!textNode.lineHeight || textNode.lineHeight < 1.05 || textNode.lineHeight > 1.35) {
textNode.lineHeight = targetLineHeight
}
textNode.textAlignVertical = 'middle'
if (!textNode.textGrowth || textNode.textGrowth === 'fixed-width') {
textNode.textGrowth = 'auto'
}
// Ensure minimum padding so text/icon never clips in capsules.
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const minV = 4
const minH = 10
const minV = 6
const minH = iconChildren.length > 0 ? 14 : 12
if (pad.top < minV || pad.bottom < minV || pad.left < minH || pad.right < minH) {
node.padding = [
Math.max(pad.top, minV),
@ -1604,20 +1726,67 @@ function applyBadgeSizing(node: PenNode): void {
]
}
// Ensure text has proper sizing — don't clip
// Ensure text has proper sizing — don't clip.
const estimatedW = estimateSingleLineTextWidth(text, fontSize)
const frameW = toSizeNumber(node.width, 0)
const newPad = parsePaddingValues(node.padding)
const minFrameW = Math.round(estimatedW + newPad.left + newPad.right + 4)
const minFrameW = Math.round(estimatedW + newPad.left + newPad.right + 8)
if (typeof node.width === 'number' && frameW > 0 && frameW < minFrameW) {
node.width = minFrameW
}
// Ensure minimum height for badge
const minFrameH = Math.round(fontSize * 1.4 + newPad.top + newPad.bottom)
// Keep badge height compact and stable.
const h = toSizeNumber(node.height, 0)
const minFrameH = Math.round(fontSize * (textNode.lineHeight ?? targetLineHeight) + newPad.top + newPad.bottom)
if (typeof node.height === 'number' && h > 0 && h < minFrameH) {
node.height = minFrameH
}
// Avoid over-rounded pill corners on small badges.
const effectiveH = toSizeNumber(node.height, h > 0 ? h : minFrameH)
if (effectiveH > 0) {
const minCr = Math.max(6, Math.round(effectiveH * 0.22))
const maxCr = Math.max(minCr, Math.min(14, Math.round(effectiveH * 0.4)))
const currentCr = toCornerRadiusNumber(node.cornerRadius, 0)
if (currentCr <= 0) node.cornerRadius = maxCr
else if (currentCr > maxCr) node.cornerRadius = maxCr
else if (currentCr < minCr) node.cornerRadius = minCr
}
// Normalize icon children so they always render and stay visually balanced.
for (const child of iconChildren) {
if (child.type === 'path') {
const target = clamp(
toSizeNumber(child.width, toSizeNumber(child.height, Math.round(fontSize * 0.95))),
10,
16,
)
const cw = toSizeNumber(child.width, 0)
const ch = toSizeNumber(child.height, 0)
if (cw <= 0 || cw > 20) child.width = target
if (ch <= 0 || ch > 20) child.height = target
const strokeWidth = toStrokeThicknessNumber(child.stroke, 0)
const hasFill = Array.isArray(child.fill) && child.fill.length > 0
if (!hasFill && strokeWidth <= 0) {
child.fill = [{ type: 'solid', color: textColor }]
}
if (child.stroke && strokeWidth <= 0) {
child.stroke = { thickness: 1.8, fill: [{ type: 'solid', color: textColor }] }
} else if (child.stroke && !extractPrimaryColor(child.stroke.fill)) {
child.stroke.fill = [{ type: 'solid', color: textColor }]
}
} else {
const cw = toSizeNumber('width' in child ? child.width : undefined, 0)
const ch = toSizeNumber('height' in child ? child.height : undefined, 0)
const dotSize = clamp(Math.round(fontSize * 0.72), 8, 12)
if ('width' in child && (cw <= 0 || cw > 16)) child.width = dotSize
if ('height' in child && (ch <= 0 || ch > 16)) child.height = dotSize
if (!('fill' in child) || !Array.isArray(child.fill) || child.fill.length === 0) {
;(child as unknown as { fill?: Array<{ type: 'solid'; color: string }> }).fill = [{ type: 'solid', color: textColor }]
}
}
}
}
/**
@ -1628,6 +1797,8 @@ function applyBadgeSizing(node: PenNode): void {
function applyButtonSpacingHeuristic(node: PenNode): void {
if (node.type !== 'frame') return
if (!Array.isArray(node.children) || node.children.length === 0) return
// Badge chips use tighter spacing; button defaults make them look oversized.
if (isBadgeLikeFrame(node)) return
const h = toSizeNumber(node.height, 0)
// Only target small frames that look like buttons/badges (height ≤ 72px)
if (h <= 0 || h > 72) return
@ -2108,6 +2279,10 @@ const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill' }> = {
helpCircle: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01', style: 'stroke' },
apple: { d: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.81-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z', style: 'fill' },
googleplay: { d: 'M3 20.5V3.5c0-.85.54-1.23 1.09-.81L20 12 4.09 21.31c-.55.42-1.09.04-1.09-.81zM14.5 12L4 3.5M14.5 12L4 20.5M14.5 12l6-3.5M14.5 12l6 3.5', style: 'stroke' },
dot: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' },
bullet: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' },
point: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' },
circlefill: { d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', style: 'fill' },
}
/**
@ -2129,12 +2304,16 @@ function applyIconPathResolution(node: PenNode): void {
// Apply correct styling (stroke-based vs fill-based)
if (match.style === 'stroke') {
// Ensure stroke is set for line icons
if (!node.stroke) {
const existingColor = extractPrimaryColor('fill' in node ? node.fill : undefined)
const existingColor = extractPrimaryColor('fill' in node ? node.fill : undefined)
?? extractPrimaryColor(node.stroke?.fill)
?? '#64748B'
const strokeWidth = toStrokeThicknessNumber(node.stroke, 0)
const strokeColor = extractPrimaryColor(node.stroke?.fill)
// Ensure stroke is renderable for line icons
if (!node.stroke || strokeWidth <= 0 || !strokeColor) {
node.stroke = {
thickness: 2,
fill: [{ type: 'solid', color: existingColor ?? '#64748B' }],
thickness: strokeWidth > 0 ? strokeWidth : 2,
fill: [{ type: 'solid', color: existingColor }],
}
}
// Line icons should NOT have opaque fill (transparent to show stroke only)
@ -2146,6 +2325,16 @@ function applyIconPathResolution(node: PenNode): void {
}
node.fill = []
}
} else {
// Fill icons must always keep a visible fill.
const fillColor = extractPrimaryColor('fill' in node ? node.fill : undefined)
?? extractPrimaryColor(node.stroke?.fill)
?? '#64748B'
node.fill = [{ type: 'solid', color: fillColor }]
// Remove non-renderable stroke definitions to avoid transparent-only paths.
if (node.stroke && toStrokeThicknessNumber(node.stroke, 0) <= 0) {
node.stroke = undefined
}
}
}
@ -2486,6 +2675,17 @@ function toGapNumber(value: number | string | undefined): number {
return 0
}
function toStrokeThicknessNumber(
stroke: { thickness?: number | [number, number, number, number] } | undefined,
fallback: number,
): number {
if (!stroke) return fallback
const t = stroke.thickness
if (typeof t === 'number' && Number.isFinite(t)) return t
if (Array.isArray(t) && t.length > 0 && Number.isFinite(t[0])) return t[0]
return fallback
}
function toCornerRadiusNumber(
value: number | [number, number, number, number] | undefined,
fallback: number,