From fa02ccb3fc2a85be38d8b1cd27c62155dc5d5bd8 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 22 Feb 2026 16:04:18 +0800 Subject: [PATCH] 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 --- src/canvas/canvas-object-factory.ts | 26 ++- src/canvas/canvas-object-sync.ts | 21 ++- src/canvas/use-canvas-sync.ts | 55 ++++++ src/services/ai/design-generator.ts | 272 ++++++++++++++++++++++++---- 4 files changed, 329 insertions(+), 45 deletions(-) diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index 44485d748..f2ff420c3 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -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 diff --git a/src/canvas/canvas-object-sync.ts b/src/canvas/canvas-object-sync.ts index e632f949b..59eb3c407 100644 --- a/src/canvas/canvas-object-sync.ts +++ b/src/canvas/canvas-object-sync.ts @@ -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) + } 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 diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 984b28855..08ed17b7e 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -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 diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index ed6478963..eddabd3de 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -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 = {} + 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) + 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) + } + + const hasCjk13 = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(txt13) + const targetLh13 = hasCjk13 ? 1.25 : 1.2 + const textUpdates13: Record = {} + 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) + } + + const iconColor13 = extractPrimaryColor(t13.fill) ?? '#4C72DF' + for (const icon13 of iconKids13) { + if (icon13.type !== 'path') continue + const iconUpdates13: Record = {} + 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) } } } @@ -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 = { 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,