Spaces:
Sleeping
Sleeping
| /** | |
| * src/figma/mapper.js | |
| * Converts the annotated DOM tree (with z-index) into | |
| * a Figma node tree JSON that the Figma plugin can execute. | |
| * | |
| * Output format: array of FigmaNode instructions | |
| * that the plugin reads and calls figma.create* for each. | |
| */ | |
| import { | |
| mapFlexLayout, | |
| mapPadding, | |
| mapOverflow, | |
| mapBorderRadius, | |
| mapBackgroundColor, | |
| mapBorder, | |
| mapBoxShadow, | |
| mapDropShadowFilter, | |
| mapTypography, | |
| mapTextStroke, | |
| shouldTruncateText, | |
| parseGradientLayers, | |
| splitCssLayers, | |
| } from './css-to-figma.js'; | |
| import { cssColorToFigma, solidPaint as colorSolidPaint } from '../utils/color.js'; | |
| import { parsePx } from '../utils/units.js'; | |
| /** | |
| * @param {{ annotated: object, sortedFlat: object[] }} sorted | |
| * @param {{ pseudoElements, gridStrategies, hoverSpecs, fontMap }} extras | |
| * @returns {FigmaNode[]} | |
| */ | |
| export function buildFigmaTree({ annotated }, { pseudoElements = [], gridStrategies = {}, hoverSpecs = {}, fontMap = {} } = {}) { | |
| attachPseudoElements(annotated, pseudoElements); | |
| const normalizedRoot = normalizeRootStructure(annotated); | |
| // Build the main node tree | |
| return [buildNode(normalizedRoot, null, { fontMap, gridStrategies, hoverSpecs, surfaceFills: [] }, '0')]; | |
| } | |
| function buildNode(node, parentContext, ctx, path) { | |
| const { computed, rect, tag, text, textRuns = [], children = [], classList, isTextContainer, _pageLayout, _role, svgMarkup, imageData } = node; | |
| let liIndex; | |
| if (tag === 'li' && parentContext?.sourceNode?.children) { | |
| const siblings = parentContext.sourceNode.children.filter(c => c.tag === 'li'); | |
| const idx = siblings.indexOf(node); | |
| if (idx !== -1) { | |
| liIndex = idx + 1; | |
| } | |
| } | |
| const rawResolvedRect = resolveRenderedRect(node, parentContext); | |
| const parentResolvedRect = parentContext?.resolvedRect ?? null; | |
| const isLeafText = Boolean(text) && children.length === 0; | |
| const isText = isLeafText && Boolean(isTextContainer); | |
| const usesTableCellAutoWidth = Boolean(parentContext?.tableCellAutoWidth) || isTableCellNode(node) || isTableCellNode(parentContext?.sourceNode); | |
| const inheritedTextTruncationContext = usesTableCellAutoWidth ? null : getInheritedTextTruncationContext(parentContext); | |
| const resolvedRect = isText && inheritedTextTruncationContext | |
| ? clampRectToTextTruncationContext(rawResolvedRect, inheritedTextTruncationContext) | |
| : rawResolvedRect; | |
| const isSvg = tag === 'svg' && Boolean(svgMarkup); | |
| const isImage = Boolean(imageData?.src) && (tag === 'img' || tag === 'canvas'); | |
| const isAbsolute = isAbsoluteLikeNode(node) || node._layoutPositioning === 'ABSOLUTE'; | |
| const childLayoutSizing = mapChildLayoutSizing(node, parentContext, resolvedRect); | |
| const transform = computed.transform || 'none'; | |
| let { rotation } = parseRotationAndScale(transform); | |
| const writingMode = computed.writingMode || 'horizontal-tb'; | |
| const isVerticalWritingMode = writingMode.startsWith('vertical-'); | |
| let unrotatedWidth = resolvedRect.width; | |
| let unrotatedHeight = resolvedRect.height; | |
| if (isVerticalWritingMode && Math.abs(rotation) <= 0.01) { | |
| rotation = -90; | |
| unrotatedWidth = resolvedRect.offsetHeight || resolvedRect.height; | |
| unrotatedHeight = resolvedRect.offsetWidth || resolvedRect.width; | |
| } else if (Math.abs(rotation) > 0.01) { | |
| if (resolvedRect.offsetWidth && resolvedRect.offsetHeight) { | |
| unrotatedWidth = resolvedRect.offsetWidth; | |
| unrotatedHeight = resolvedRect.offsetHeight; | |
| } else if (Math.abs(Math.abs(rotation) - 90) < 5) { | |
| unrotatedWidth = resolvedRect.height; | |
| unrotatedHeight = resolvedRect.width; | |
| } | |
| } | |
| let figmaX = resolvedRect.x - (parentResolvedRect?.x ?? 0); | |
| let figmaY = resolvedRect.y - (parentResolvedRect?.y ?? 0); | |
| if (Math.abs(rotation) > 0.01) { | |
| const rad = (rotation * Math.PI) / 180; | |
| const cosVal = Math.cos(rad); | |
| const sinVal = Math.sin(rad); | |
| const cx = resolvedRect.x + resolvedRect.width / 2; | |
| const cy = resolvedRect.y + resolvedRect.height / 2; | |
| const absoluteFigmaX = cx - (unrotatedWidth / 2) * cosVal + (unrotatedHeight / 2) * sinVal; | |
| const absoluteFigmaY = cy - (unrotatedWidth / 2) * sinVal - (unrotatedHeight / 2) * cosVal; | |
| figmaX = absoluteFigmaX - (parentResolvedRect?.x ?? 0); | |
| figmaY = absoluteFigmaY - (parentResolvedRect?.y ?? 0); | |
| } | |
| const base = { | |
| id: buildStableId(tag, classList, path), | |
| name: buildName(tag, classList), | |
| type: isSvg ? 'SVG' : isImage ? 'IMAGE' : (isText && text ? 'TEXT' : 'FRAME'), | |
| x: Math.round(figmaX), | |
| y: Math.round(figmaY), | |
| width: Math.round(unrotatedWidth), | |
| height: Math.round(unrotatedHeight), | |
| ...(Math.abs(rotation) > 0.01 ? { rotation: roundFloat(rotation, 2) } : {}), | |
| ...(isAbsolute ? { layoutPositioning: 'ABSOLUTE' } : {}), | |
| ...childLayoutSizing, | |
| }; | |
| if (isSvg) { | |
| return { | |
| ...base, | |
| _svgMarkup: svgMarkup, | |
| opacity: roundFloat(parseFloat(computed.opacity ?? 1)), | |
| ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? { | |
| blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'), | |
| } : {}), | |
| }; | |
| } | |
| if (isImage) { | |
| return { | |
| ...base, | |
| _image: imageData, | |
| opacity: roundFloat(parseFloat(computed.opacity ?? 1)), | |
| ...mapBorderRadius(computed, rect), | |
| ...mapBorder(computed), | |
| effects: mapVisualEffects(computed), | |
| ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? { | |
| blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'), | |
| } : {}), | |
| ...(computed.objectFit ? { _objectFit: computed.objectFit } : {}), | |
| ...(computed.objectPosition ? { _objectPosition: computed.objectPosition } : {}), | |
| }; | |
| } | |
| if (base.type === 'TEXT') { | |
| const typography = mapTypography(computed, ctx.fontMap, parentContext?.sourceNode?.computed); | |
| if (usesTableCellAutoWidth) { | |
| forceAutoWidthTableCellText(typography); | |
| } else if (inheritedTextTruncationContext && !typography.textTruncation) { | |
| typography.textTruncation = 'ENDING'; | |
| } | |
| const textNode = { | |
| ...base, | |
| characters: text, | |
| ...typography, | |
| ...mapFlexTextAlignment(computed), | |
| ...mapTextStroke(computed), | |
| textRuns: buildTextRuns(textRuns, ctx.fontMap), | |
| opacity: roundFloat(parseFloat(computed.opacity ?? 1)), | |
| }; | |
| if (parentContext?.sourceNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') { | |
| textNode._forceAutoWidth = true; | |
| } | |
| return textNode; | |
| } | |
| // Frame node | |
| const isGrid = computed.display === 'grid'; | |
| const isFlex = computed.display === 'flex' || computed.display === 'inline-flex'; | |
| const isInlineBlock = computed.display === 'inline-block'; | |
| const flexLayoutInfo = isFlex ? getRenderableFlexLayout(node) : null; | |
| let layout = isFlex | |
| ? flexLayoutInfo?.layout | |
| : isInlineBlock | |
| ? getRenderableInlineLayout(node) | |
| : null; | |
| if (!layout && tag === 'li' && (node.pseudo?.before || node.pseudoChildren?.some(p => p.name && (p.name === 'before' || p.name.endsWith('::before'))))) { | |
| const children = Array.isArray(node.children) ? node.children.filter(Boolean) : []; | |
| const hasOnlyInlineOrSimpleChildren = children.every(child => | |
| child.tag === 'span' || | |
| child.tag === 'a' || | |
| child.tag === 'code' || | |
| child.tag === 'strong' || | |
| child.tag === 'em' || | |
| child.tag === 'i' || | |
| child.tag === 'b' || | |
| child.tag === 'u' || | |
| child.tag === 'small' || | |
| child.tag === 'p' || | |
| child.isTextContainer || | |
| !child.tag | |
| ); | |
| if (hasOnlyInlineOrSimpleChildren) { | |
| layout = { | |
| layoutMode: 'HORIZONTAL', | |
| primaryAxisAlignItems: 'MIN', | |
| counterAxisAlignItems: 'CENTER', | |
| itemSpacing: 12, | |
| }; | |
| } | |
| } | |
| const flexAutoMarginLayout = isFlex ? getFlexAutoMarginLayoutOverride(node, layout) : null; | |
| const nativeControlLayout = getNativeControlLayout(node); | |
| // Check if a grid strategy was provided for this element | |
| const gridClass = classList?.find(c => ctx.gridStrategies?.[`.${c}`]); | |
| const gridStrategy = gridClass ? ctx.gridStrategies[`.${gridClass}`] : null; | |
| // Check hover spec | |
| const hoverClass = classList?.find(c => ctx.hoverSpecs?.[`.${c}`]); | |
| const hoverSpec = hoverClass ? ctx.hoverSpecs[`.${hoverClass}`] : null; | |
| // Background fills | |
| let fills = mapBackgroundColor(computed); | |
| const backgroundPattern = detectBackgroundPattern(computed); | |
| // Handle supported CSS gradient layers in backgroundImage. | |
| if (!backgroundPattern && computed.backgroundImage) { | |
| try { | |
| fills.push(...parseGradientLayers(computed.backgroundImage, rect)); | |
| } catch { /* skip malformed gradients */ } | |
| } | |
| if (fills.length === 0 && isPaginationNode(node) && Array.isArray(parentContext?.surfaceFills) && parentContext.surfaceFills.length > 0) { | |
| fills = clonePaints(parentContext.surfaceFills); | |
| } | |
| const nextSurfaceFills = fills.length > 0 ? clonePaints(fills) : (parentContext?.surfaceFills || []); | |
| const frameNode = { | |
| ...base, | |
| ...(_pageLayout ? { _pageLayout: true } : {}), | |
| ...(_role ? { _role } : {}), | |
| fills, | |
| ...mapPadding(computed), | |
| ...mapOverflow(computed), | |
| ...mapBorderRadius(computed, rect), | |
| ...mapBorder(computed), | |
| effects: mapVisualEffects(computed), | |
| opacity: roundFloat(parseFloat(computed.opacity ?? 1)), | |
| ...(layout || {}), | |
| ...(flexAutoMarginLayout || {}), | |
| ...(nativeControlLayout || {}), | |
| ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? { | |
| blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'), | |
| } : {}), | |
| }; | |
| if (layout && tag === 'li') { | |
| const pseudoBefore = node.pseudo?.before || node.pseudoChildren?.find(p => p.name === 'before' || p.name?.endsWith('::before')); | |
| const isAbsolutePseudo = pseudoBefore?.computed?.position === 'absolute'; | |
| if (isAbsolutePseudo) { | |
| const originalPaddingLeft = frameNode.paddingLeft || 0; | |
| frameNode.paddingLeft = Math.max(0, originalPaddingLeft - 24); | |
| } | |
| } | |
| if (_pageLayout || tag === 'body') { | |
| frameNode.clipsContent = true; | |
| } | |
| // Apply grid strategy when a renderable fallback is available | |
| const renderableGridStrategy = isGrid ? getRenderableGridStrategy(node, gridStrategy) : null; | |
| if (renderableGridStrategy) { | |
| frameNode._gridStrategy = renderableGridStrategy; | |
| frameNode._gridNotes = gridStrategy.notes; | |
| } | |
| // Attach hover spec for Figma plugin to create variants | |
| if (hoverSpec) { | |
| frameNode._hoverSpec = hoverSpec; | |
| } | |
| if (backgroundPattern) { | |
| frameNode._backgroundPattern = backgroundPattern; | |
| } | |
| // Recurse | |
| const childNodes = []; | |
| const childTextTruncationContext = usesTableCellAutoWidth ? null : getChildTextTruncationContext(node, resolvedRect, inheritedTextTruncationContext); | |
| if (isLeafText) { | |
| const textNode = buildEmbeddedTextNode(node, ctx, `${path}.text`, resolvedRect, 'text', childTextTruncationContext, usesTableCellAutoWidth); | |
| if (parentContext?.sourceNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') { | |
| textNode._forceAutoWidth = true; | |
| } | |
| childNodes.push(textNode); | |
| } | |
| const controlTextNode = buildFormControlTextNode(node, ctx, `${path}.control`, resolvedRect, childTextTruncationContext, usesTableCellAutoWidth); | |
| if (controlTextNode) { | |
| childNodes.push(controlTextNode); | |
| } | |
| childNodes.push(...buildFormControlDecorationNodes(node, `${path}.control`)); | |
| const pseudoChildren = (node.pseudoChildren || []).concat(getNativePseudoChildren(node)); | |
| const mergeablePseudoBackgrounds = []; | |
| const renderablePseudoChildren = []; | |
| for (const pseudo of pseudoChildren) { | |
| if (shouldMergePseudoIntoParent(node, pseudo)) { | |
| mergeablePseudoBackgrounds.push(...buildMergedPseudoBackgrounds(pseudo)); | |
| continue; | |
| } | |
| renderablePseudoChildren.push(pseudo); | |
| } | |
| const pseudoBefore = renderablePseudoChildren | |
| .filter((pseudo) => pseudo.zOrder !== 'top') | |
| .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudo.${index}`, ctx, { | |
| participatesInLayout: shouldPseudoParticipateInParentLayout(node, pseudo, layout), | |
| liIndex, | |
| })) | |
| .filter(Boolean); | |
| const pseudoTop = renderablePseudoChildren | |
| .filter((pseudo) => pseudo.zOrder === 'top') | |
| .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudoTop.${index}`, ctx, { | |
| participatesInLayout: shouldPseudoParticipateInParentLayout(node, pseudo, layout), | |
| liIndex, | |
| })) | |
| .filter(Boolean); | |
| const orderedChildren = getOrderedChildren(children); | |
| const builtChildPairs = orderedChildren | |
| .map((child, index) => ({ | |
| source: child, | |
| built: buildNode(child, { | |
| sourceRect: rect, | |
| resolvedRect, | |
| sourceNode: node, | |
| textTruncationContext: childTextTruncationContext, | |
| tableCellAutoWidth: usesTableCellAutoWidth, | |
| surfaceFills: nextSurfaceFills, | |
| parentLayout: layout, | |
| }, ctx, `${path}.${index}`), | |
| })) | |
| .filter((pair) => Boolean(pair.built)); | |
| frameNode.children = pseudoBefore | |
| .concat(childNodes) | |
| .concat(withFlexAutoMarginGroups(node, builtChildPairs, layout, path)) | |
| .concat(pseudoTop); | |
| if (mergeablePseudoBackgrounds.length > 0) { | |
| frameNode.fills = frameNode.fills.concat(mergeablePseudoBackgrounds); | |
| } | |
| return frameNode; | |
| } | |
| function mapChildLayoutSizing(node, parentContext, resolvedRect) { | |
| const parentNode = parentContext?.sourceNode; | |
| const parentComputed = parentNode?.computed; | |
| if (parentNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') { | |
| const isPseudo = node._isPseudo || node.name === 'before' || node.name === 'after' || node.name?.startsWith('[pseudo]'); | |
| if (!isPseudo) { | |
| return { | |
| layoutSizingHorizontal: 'FILL', | |
| }; | |
| } | |
| } | |
| if (!node || !resolvedRect || !parentContext?.resolvedRect || !isFlexDisplay(parentComputed?.display) || isAbsoluteLikeNode(node)) { | |
| return {}; | |
| } | |
| const result = {}; | |
| const parentRect = parentContext.resolvedRect; | |
| const parentInnerWidth = Math.max(parentRect.width - parsePx(parentComputed.paddingLeft) - parsePx(parentComputed.paddingRight), 0); | |
| const parentInnerHeight = Math.max(parentRect.height - parsePx(parentComputed.paddingTop) - parsePx(parentComputed.paddingBottom), 0); | |
| const axis = isRowFlexDirection(parentComputed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL'; | |
| const flexGrow = parseFloat(node.computed?.flexGrow); | |
| if (axis === 'VERTICAL' && fillsAxis(resolvedRect.width, parentInnerWidth)) { | |
| result.layoutSizingHorizontal = 'FILL'; | |
| } | |
| if (axis === 'HORIZONTAL' && fillsAxis(resolvedRect.height, parentInnerHeight)) { | |
| result.layoutSizingVertical = 'FILL'; | |
| } | |
| if (Number.isFinite(flexGrow) && flexGrow > 0) { | |
| if (axis === 'HORIZONTAL') { | |
| if (!shouldHugSingleTextFlexChild(parentNode, node, axis)) { | |
| result.layoutSizingHorizontal = 'FILL'; | |
| } | |
| } else { | |
| result.layoutSizingVertical = 'FILL'; | |
| } | |
| } | |
| return result; | |
| } | |
| function fillsAxis(childSize, parentInnerSize) { | |
| if (!Number.isFinite(childSize) || !Number.isFinite(parentInnerSize) || parentInnerSize <= 0) { | |
| return false; | |
| } | |
| return Math.abs(childSize - parentInnerSize) <= Math.max(2, parentInnerSize * 0.02); | |
| } | |
| function resolveRenderedRect(node, parentContext) { | |
| const sourceRect = node?.rect || { x: 0, y: 0, width: 0, height: 0 }; | |
| if (!parentContext?.sourceRect || !parentContext?.resolvedRect) { | |
| return sourceRect; | |
| } | |
| const resolved = reprojectRectWithinParent(sourceRect, parentContext.sourceRect, parentContext.resolvedRect); | |
| if (shouldStretchAspectWrapper(node, parentContext)) { | |
| return { | |
| ...resolved, | |
| width: parentContext.resolvedRect.width, | |
| height: parentContext.resolvedRect.height, | |
| x: parentContext.resolvedRect.x + (sourceRect.x - parentContext.sourceRect.x), | |
| y: parentContext.resolvedRect.y + (sourceRect.y - parentContext.sourceRect.y), | |
| }; | |
| } | |
| return resolved; | |
| } | |
| function reprojectRectWithinParent(childRect, sourceParentRect, resolvedParentRect) { | |
| const rect = childRect || { x: 0, y: 0, width: 0, height: 0 }; | |
| const sourceParent = sourceParentRect || { x: 0, y: 0, width: 0, height: 0 }; | |
| const resolvedParent = resolvedParentRect || sourceParent; | |
| const tolerance = 1.5; | |
| if (isSameRect(sourceParent, resolvedParent)) { | |
| return rect; | |
| } | |
| const leftOffset = (rect.x ?? 0) - (sourceParent.x ?? 0); | |
| const topOffset = (rect.y ?? 0) - (sourceParent.y ?? 0); | |
| const rightOffset = (sourceParent.x ?? 0) + (sourceParent.width ?? 0) - ((rect.x ?? 0) + (rect.width ?? 0)); | |
| const bottomOffset = (sourceParent.y ?? 0) + (sourceParent.height ?? 0) - ((rect.y ?? 0) + (rect.height ?? 0)); | |
| const fillsHorizontal = isClose(leftOffset, 0, tolerance) | |
| && isClose(rightOffset, 0, tolerance) | |
| && isClose(rect.width ?? 0, sourceParent.width ?? 0, tolerance); | |
| const fillsVertical = isClose(topOffset, 0, tolerance) | |
| && isClose(bottomOffset, 0, tolerance) | |
| && isClose(rect.height ?? 0, sourceParent.height ?? 0, tolerance); | |
| const width = fillsHorizontal ? resolvedParent.width : rect.width; | |
| const height = fillsVertical ? resolvedParent.height : rect.height; | |
| const x = fillsHorizontal | |
| ? resolvedParent.x + leftOffset | |
| : (rightOffset < leftOffset | |
| ? resolvedParent.x + resolvedParent.width - rightOffset - width | |
| : resolvedParent.x + leftOffset); | |
| const y = fillsVertical | |
| ? resolvedParent.y + topOffset | |
| : (bottomOffset < topOffset | |
| ? resolvedParent.y + resolvedParent.height - bottomOffset - height | |
| : resolvedParent.y + topOffset); | |
| return { | |
| x, | |
| y, | |
| width, | |
| height, | |
| }; | |
| } | |
| function shouldStretchAspectWrapper(node, parentContext) { | |
| if (!node?.rect || !parentContext?.sourceRect || !parentContext?.resolvedRect) { | |
| return false; | |
| } | |
| if (node.computed?.position === 'absolute' || node.computed?.position === 'fixed') { | |
| return false; | |
| } | |
| if (parsePx(node.computed?.paddingBottom) <= 0) { | |
| return false; | |
| } | |
| if (!Array.isArray(node.children) || node.children.length === 0) { | |
| return false; | |
| } | |
| if (node.children.some((child) => !isAbsoluteLikeNode(child))) { | |
| return false; | |
| } | |
| if (node.pseudoChildren?.length > 0 || node?.pseudo?.before || node?.pseudo?.after) { | |
| return false; | |
| } | |
| const sourceRect = node.rect; | |
| const parentRect = parentContext.sourceRect; | |
| const widthMatches = isClose(sourceRect.width, parentRect.width, 2); | |
| const xMatches = isClose(sourceRect.x, parentRect.x, 2); | |
| const yMatches = isClose(sourceRect.y, parentRect.y, 2); | |
| const isShorter = sourceRect.height + 2 < parentRect.height; | |
| return widthMatches && xMatches && yMatches && isShorter; | |
| } | |
| function isClose(a, b, tolerance = 1.5) { | |
| return Math.abs((a ?? 0) - (b ?? 0)) <= tolerance; | |
| } | |
| function isSameRect(a, b, tolerance = 0.01) { | |
| return isClose(a?.x, b?.x, tolerance) | |
| && isClose(a?.y, b?.y, tolerance) | |
| && isClose(a?.width, b?.width, tolerance) | |
| && isClose(a?.height, b?.height, tolerance); | |
| } | |
| function getNativePseudoChildren(node) { | |
| const result = []; | |
| const pseudo = node?.pseudo || {}; | |
| const rect = node?.rect || { x: 0, y: 0 }; | |
| for (const type of ['before', 'after']) { | |
| const entry = pseudo[type]; | |
| if (!entry?.rect) continue; | |
| result.push({ | |
| ...entry, | |
| x: entry.rect.x - rect.x, | |
| y: entry.rect.y - rect.y, | |
| width: entry.rect.width, | |
| height: entry.rect.height, | |
| zOrder: entry.zOrder || (type === 'before' ? 'bottom' : 'top'), | |
| }); | |
| } | |
| return result; | |
| } | |
| function buildPseudoNode(pseudo, path, ctx = {}, options = {}) { | |
| const pseudoId = `pseudo-${path}-${pseudo.name.replace(/\s+/g, '-').toLowerCase()}`; | |
| let pseudoContent = pseudo.content; | |
| if (pseudoContent && pseudoContent.includes('counter(')) { | |
| pseudoContent = resolveCounterText(pseudoContent, options.liIndex || 1); | |
| } | |
| const isTextPseudo = pseudo.type === 'text' && Boolean(pseudoContent); | |
| const pseudoBackgrounds = isTextPseudo ? [] : buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo); | |
| const pseudoBackgroundPattern = isTextPseudo ? null : detectBackgroundPattern(pseudo.computed); | |
| const pseudoEffects = pseudo.computed ? mapVisualEffects(pseudo.computed) : []; | |
| const pseudoStrokes = pseudo.computed ? mapBorder(pseudo.computed) : {}; | |
| const textTypography = pseudo.computed | |
| ? { | |
| ...mapTypography(pseudo.computed, ctx.fontMap), | |
| ...mapTextStroke(pseudo.computed), | |
| } | |
| : { | |
| fontName: { | |
| family: 'Inter', | |
| style: 'Regular', | |
| }, | |
| fontSize: Math.max(Math.min(Math.round(pseudo.height || 16), 48), 12), | |
| fills: pseudo.fillColor && pseudo.fillColor !== 'noise-texture' | |
| ? [colorSolidPaint(pseudo.fillColor)] | |
| : [colorSolidPaint('#ffffff')], | |
| }; | |
| let pseudoWidth = pseudo.width; | |
| let pseudoHeight = pseudo.height; | |
| if (isTextPseudo && pseudoContent && options.liIndex) { | |
| const fontSize = textTypography.fontSize || 16; | |
| const estimatedWidth = Math.ceil(pseudoContent.length * fontSize * 0.65) + 4; | |
| const estimatedHeight = Math.ceil(fontSize * 1.2); | |
| if (pseudoWidth > estimatedWidth) { | |
| pseudoWidth = estimatedWidth; | |
| } | |
| if (pseudoHeight > estimatedHeight) { | |
| pseudoHeight = estimatedHeight; | |
| } | |
| } | |
| return { | |
| id: pseudoId, | |
| name: `[pseudo] ${pseudo.name}`, | |
| type: 'FRAME', | |
| x: Math.round(pseudo.x), | |
| y: Math.round(pseudo.y), | |
| width: Math.round(pseudoWidth), | |
| height: Math.round(pseudoHeight), | |
| ...(!options.participatesInLayout ? { layoutPositioning: 'ABSOLUTE' } : {}), | |
| opacity: roundFloat(pseudo.opacity ?? 1), | |
| fills: pseudoBackgrounds, | |
| ...(pseudoBackgroundPattern ? { _backgroundPattern: pseudoBackgroundPattern } : {}), | |
| ...(isTextPseudo ? { clipsContent: false } : {}), | |
| ...pseudoStrokes, | |
| effects: pseudoEffects, | |
| _isPseudo: true, | |
| _pseudoType: pseudo.type, | |
| _pseudoPosition: pseudo.position, | |
| children: pseudoContent ? [{ | |
| id: `${pseudoId}-content`, | |
| name: 'content', | |
| type: 'TEXT', | |
| characters: pseudoContent, | |
| x: 0, y: 0, | |
| width: pseudoWidth, | |
| height: pseudoHeight, | |
| ...textTypography, | |
| }] : [], | |
| }; | |
| } | |
| function shouldPseudoParticipateInParentLayout(node, pseudo, layout = null) { | |
| const hasAutoLayout = isFlexDisplay(node.computed?.display) || Boolean(layout); | |
| if (!node || !pseudo || !hasAutoLayout) { | |
| return false; | |
| } | |
| if (node.tag === 'li' && layout) { | |
| return true; | |
| } | |
| const position = pseudo.position || pseudo.computed?.position || 'static'; | |
| return position !== 'absolute' && position !== 'fixed'; | |
| } | |
| function buildFormControlTextNode(node, ctx, path, resolvedRect = null, textTruncationContext = null, tableCellAutoWidth = false) { | |
| const rendered = resolveFormControlText(node.formControl); | |
| if (!rendered) { | |
| return null; | |
| } | |
| const computed = rendered.kind === 'placeholder' | |
| ? mergeFormControlTextStyles(node.computed, node.formControl?.placeholderComputed) | |
| : node.computed; | |
| return buildEmbeddedTextNode( | |
| { | |
| ...node, | |
| text: rendered.text, | |
| textRuns: [{ | |
| text: rendered.text, | |
| lineIndex: 0, | |
| computed, | |
| }], | |
| computed, | |
| }, | |
| ctx, | |
| path, | |
| resolvedRect, | |
| rendered.kind, | |
| textTruncationContext, | |
| tableCellAutoWidth | |
| ); | |
| } | |
| function buildFormControlDecorationNodes(node, path) { | |
| if (!isNativeSelectControl(node) || node.formControl.hasChevron === false) { | |
| return []; | |
| } | |
| return [buildSelectChevronNode(node, path)]; | |
| } | |
| function getNativeControlLayout(node) { | |
| if (!isNativeSelectControl(node) || node.formControl.hasChevron === false) { | |
| return null; | |
| } | |
| return { | |
| layoutMode: 'HORIZONTAL', | |
| primaryAxisAlignItems: 'SPACE_BETWEEN', | |
| counterAxisAlignItems: 'CENTER', | |
| itemSpacing: 0, | |
| primaryAxisSizingMode: 'FIXED', | |
| counterAxisSizingMode: 'FIXED', | |
| }; | |
| } | |
| function isNativeSelectControl(node) { | |
| return node?.tag === 'select' && node?.formControl?.type === 'select'; | |
| } | |
| function buildSelectChevronNode(node, path) { | |
| const computed = node.computed || {}; | |
| const rect = node.rect || { width: 0, height: 0 }; | |
| const fontSize = parsePx(computed.fontSize) || 16; | |
| const width = Math.max(Math.round(fontSize * 0.72), 10); | |
| const height = Math.max(Math.round(fontSize * 0.44), 6); | |
| const rightInset = parsePx(computed.paddingRight); | |
| const color = computed.color || 'rgb(0, 0, 0)'; | |
| return { | |
| id: buildStableId(node.tag, node.classList, `${path}-chevron`), | |
| name: `${buildName(node.tag, node.classList)} / chevron`, | |
| type: 'SVG', | |
| x: Math.max(Math.round((rect.width || 0) - rightInset - width), 0), | |
| y: Math.max(Math.round(((rect.height || 0) - height) / 2), 0), | |
| width, | |
| height, | |
| opacity: roundFloat(parseFloat(computed.opacity ?? 1)), | |
| _svgMarkup: makeChevronDownSvg(width, height, color), | |
| }; | |
| } | |
| function makeChevronDownSvg(width, height, color) { | |
| const stroke = escapeSvgAttribute(color || 'rgb(0, 0, 0)'); | |
| const strokeWidth = Math.max(Math.round(Math.min(width, height) * 0.22), 2); | |
| const left = strokeWidth / 2; | |
| const right = width - strokeWidth / 2; | |
| const top = Math.max(strokeWidth / 2, 1); | |
| const bottom = height - strokeWidth / 2; | |
| const mid = width / 2; | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none"><path d="M${left} ${top} L${mid} ${bottom} L${right} ${top}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/></svg>`; | |
| } | |
| function escapeSvgAttribute(value) { | |
| return String(value || '') | |
| .replace(/&/g, '&') | |
| .replace(/"/g, '"') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| function resolveFormControlText(formControl) { | |
| if (!formControl) { | |
| return null; | |
| } | |
| const value = normalizeControlText(formControl.value); | |
| if (value) { | |
| return { kind: 'value', text: value }; | |
| } | |
| const placeholder = normalizeControlText(formControl.placeholder); | |
| if (placeholder) { | |
| return { kind: 'placeholder', text: placeholder }; | |
| } | |
| return null; | |
| } | |
| function normalizeControlText(value) { | |
| return String(value || '').replace(/\r/g, '').trim(); | |
| } | |
| function mergeFormControlTextStyles(baseComputed, overrideComputed) { | |
| if (!overrideComputed) { | |
| return baseComputed; | |
| } | |
| const merged = { ...baseComputed }; | |
| const textKeys = [ | |
| 'fontFamily', | |
| 'fontSize', | |
| 'fontWeight', | |
| 'fontStyle', | |
| 'lineHeight', | |
| 'letterSpacing', | |
| 'textAlign', | |
| 'textTransform', | |
| 'color', | |
| 'opacity', | |
| 'textDecoration', | |
| 'webkitTextStrokeWidth', | |
| 'webkitTextStrokeColor', | |
| ]; | |
| for (const key of textKeys) { | |
| if (overrideComputed[key] !== undefined && overrideComputed[key] !== null && overrideComputed[key] !== '') { | |
| merged[key] = overrideComputed[key]; | |
| } | |
| } | |
| return merged; | |
| } | |
| function buildPseudoBackgrounds(computed, fallbackFillColor, rect = null) { | |
| if (!computed) { | |
| return fallbackFillColor && fallbackFillColor !== 'noise-texture' | |
| ? [colorSolidPaint(fallbackFillColor)] | |
| : []; | |
| } | |
| if (detectBackgroundPattern(computed)) { | |
| return mapBackgroundColor(computed); | |
| } | |
| const fills = mapBackgroundColor(computed); | |
| if (computed.backgroundImage) { | |
| fills.push(...parseGradientLayers(computed.backgroundImage, rect)); | |
| } | |
| // A pseudo box inherits `color` from its parent, but that text color is not a | |
| // background paint. Unsupported decorative backgrounds should stay transparent. | |
| return fills; | |
| } | |
| function buildMergedPseudoBackgrounds(pseudo) { | |
| const paints = buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo); | |
| const opacity = Number.isFinite(pseudo.opacity) ? pseudo.opacity : 1; | |
| return paints.map((paint) => applyPaintOpacity(paint, opacity)); | |
| } | |
| function shouldMergePseudoIntoParent(node, pseudo) { | |
| if (!node?.computed || !pseudo || pseudo.type === 'text' || pseudo.zOrder !== 'bottom') { | |
| return false; | |
| } | |
| if (detectBackgroundPattern(pseudo.computed)) { | |
| return false; | |
| } | |
| const position = pseudo.position; | |
| if (position !== 'absolute' && position !== 'fixed') { | |
| return false; | |
| } | |
| if (!isTransparentCssBackground(node.computed) || !pseudo.rect || !node.rect) { | |
| return false; | |
| } | |
| const parent = node.rect; | |
| const child = pseudo.rect; | |
| const tolerance = 1.5; | |
| const coversParent = | |
| Math.abs((child.x ?? 0) - (parent.x ?? 0)) <= tolerance && | |
| Math.abs((child.y ?? 0) - (parent.y ?? 0)) <= tolerance && | |
| Math.abs((child.width ?? 0) - (parent.width ?? 0)) <= tolerance && | |
| Math.abs((child.height ?? 0) - (parent.height ?? 0)) <= tolerance; | |
| if (!coversParent) { | |
| return false; | |
| } | |
| return buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo).length > 0; | |
| } | |
| function isTransparentCssBackground(computed) { | |
| const backgroundColor = computed?.backgroundColor || ''; | |
| const backgroundImage = computed?.backgroundImage || ''; | |
| return isTransparentCssColor(backgroundColor) && backgroundImage === 'none'; | |
| } | |
| function isTransparentCssColor(value) { | |
| if (!value || value === 'transparent' || value === 'none') { | |
| return true; | |
| } | |
| return cssColorToFigma(value).a === 0; | |
| } | |
| function applyPaintOpacity(paint, opacity) { | |
| if (!paint || opacity === 1 || !Number.isFinite(opacity)) { | |
| return paint; | |
| } | |
| const copy = JSON.parse(JSON.stringify(paint)); | |
| const existing = Number.isFinite(copy.opacity) ? copy.opacity : 1; | |
| copy.opacity = existing * opacity; | |
| return copy; | |
| } | |
| function clonePaints(paints) { | |
| return (paints || []).map((paint) => JSON.parse(JSON.stringify(paint))); | |
| } | |
| function mapVisualEffects(computed = {}) { | |
| return [ | |
| ...mapBoxShadow(computed), | |
| ...mapDropShadowFilter(computed), | |
| ]; | |
| } | |
| function isPaginationNode(node) { | |
| if (!node) { | |
| return false; | |
| } | |
| const haystack = `${node.tag || ''} ${(node.classList || []).join(' ')} ${node.id || ''} ${node.name || ''}`.toLowerCase(); | |
| return /(?:^|\s)(pagination|paginator|pager|page-nav|page-control)(?:\s|$)/.test(haystack) | |
| || /pagination|paginator|pager|page-nav|page-control/.test(haystack); | |
| } | |
| function buildName(tag, classList) { | |
| if (classList?.length > 0) return `${tag}.${classList.slice(0, 2).join('.')}`; | |
| return tag; | |
| } | |
| function buildStableId(tag, classList, path) { | |
| const slug = (classList?.slice(0, 2).join('-') || 'el') | |
| .replace(/[^a-zA-Z0-9_-]+/g, '-') | |
| .replace(/-+/g, '-') | |
| .replace(/^-|-$/g, '') | |
| || 'el'; | |
| return `${tag}-${slug}-${path.replace(/\./g, '-')}`; | |
| } | |
| function roundFloat(value, precision = 4) { | |
| const factor = 10 ** precision; | |
| return Math.round((value + Number.EPSILON) * factor) / factor; | |
| } | |
| function buildEmbeddedTextNode(node, ctx, path, resolvedRect = null, nameSuffix = 'text', textTruncationContext = null, tableCellAutoWidth = false) { | |
| const { computed, rect, tag, text, textRuns = [], classList } = node; | |
| const insetX = parsePx(computed.paddingLeft); | |
| const insetY = parsePx(computed.paddingTop); | |
| const sourceRect = resolvedRect || rect; | |
| const initialTextRect = { | |
| x: sourceRect.x + insetX, | |
| y: sourceRect.y + insetY, | |
| width: Math.max(sourceRect.width - insetX - parsePx(computed.paddingRight), 1), | |
| height: Math.max(sourceRect.height - insetY - parsePx(computed.paddingBottom), 1), | |
| }; | |
| const textRect = textTruncationContext | |
| ? clampRectToTextTruncationContext(initialTextRect, textTruncationContext) | |
| : initialTextRect; | |
| const typography = mapTypography(computed, ctx.fontMap, node.computed); | |
| if (tableCellAutoWidth) { | |
| forceAutoWidthTableCellText(typography); | |
| } else if (textTruncationContext && !typography.textTruncation) { | |
| typography.textTruncation = 'ENDING'; | |
| } | |
| return { | |
| id: buildStableId(tag, classList, `${path}-inner`), | |
| name: `${buildName(tag, classList)} / ${nameSuffix}`, | |
| type: 'TEXT', | |
| x: Math.round(textRect.x - sourceRect.x), | |
| y: Math.round(textRect.y - sourceRect.y), | |
| width: Math.max(Math.round(textRect.width), 1), | |
| height: Math.max(Math.round(textRect.height), 1), | |
| characters: text, | |
| ...typography, | |
| ...mapFlexTextAlignment(computed), | |
| ...mapTextStroke(computed), | |
| textRuns: buildTextRuns(textRuns, ctx.fontMap), | |
| }; | |
| } | |
| function forceAutoWidthTableCellText(typography) { | |
| if (!typography) { | |
| return; | |
| } | |
| delete typography.textTruncation; | |
| typography.whiteSpace = 'nowrap'; | |
| } | |
| function isTableCellNode(node) { | |
| const tag = String(node?.tag || '').toLowerCase(); | |
| if (tag === 'td' || tag === 'th') { | |
| return true; | |
| } | |
| return String(node?.computed?.display || '').toLowerCase() === 'table-cell'; | |
| } | |
| function getInheritedTextTruncationContext(parentContext) { | |
| if (parentContext?.textTruncationContext) { | |
| return parentContext.textTruncationContext; | |
| } | |
| if (parentContext?.sourceNode && shouldTruncateText(parentContext.sourceNode.computed, null)) { | |
| return createTextTruncationContext(parentContext.resolvedRect, parentContext.sourceNode.computed); | |
| } | |
| return null; | |
| } | |
| function getChildTextTruncationContext(node, resolvedRect, inheritedContext) { | |
| if (node && shouldTruncateText(node.computed, null)) { | |
| return createTextTruncationContext(resolvedRect, node.computed); | |
| } | |
| return inheritedContext; | |
| } | |
| function createTextTruncationContext(rect, computed = {}) { | |
| if (!rect) { | |
| return null; | |
| } | |
| const left = rect.x + parsePx(computed.paddingLeft); | |
| const right = rect.x + rect.width - parsePx(computed.paddingRight); | |
| const top = rect.y + parsePx(computed.paddingTop); | |
| const bottom = rect.y + rect.height - parsePx(computed.paddingBottom); | |
| return { | |
| left, | |
| right: Math.max(right, left + 1), | |
| top, | |
| bottom: Math.max(bottom, top + 1), | |
| }; | |
| } | |
| function clampRectToTextTruncationContext(rect, context) { | |
| if (!rect || !context) { | |
| return rect; | |
| } | |
| const left = Math.max(rect.x, context.left); | |
| const right = Math.min(rect.x + rect.width, context.right); | |
| const width = Math.max(right - left, 1); | |
| return { | |
| ...rect, | |
| x: left, | |
| width, | |
| }; | |
| } | |
| function getOrderedChildren(children) { | |
| const items = (children || []) | |
| .filter(Boolean) | |
| .map((child, index) => ({ | |
| child, | |
| index, | |
| layerZ: getLayerZ(child), | |
| })); | |
| if (items.length <= 1) { | |
| return items.map((item) => item.child); | |
| } | |
| const hasLayering = items.some((item) => Number.isFinite(item.layerZ)); | |
| if (!hasLayering) { | |
| return items.map((item) => item.child); | |
| } | |
| return items | |
| .sort((a, b) => { | |
| const zA = Number.isFinite(a.layerZ) ? a.layerZ : 0; | |
| const zB = Number.isFinite(b.layerZ) ? b.layerZ : 0; | |
| if (zA !== zB) { | |
| return zA - zB; | |
| } | |
| return a.index - b.index; | |
| }) | |
| .map((item) => item.child); | |
| } | |
| function getLayerZ(node) { | |
| if (!node) { | |
| return null; | |
| } | |
| if (Number.isFinite(node.effectiveZ)) { | |
| return node.effectiveZ; | |
| } | |
| const zIndex = parseFloat(node.computed?.zIndex); | |
| return Number.isFinite(zIndex) ? zIndex : null; | |
| } | |
| function attachPseudoElements(root, pseudoElements) { | |
| if (!root || !Array.isArray(pseudoElements) || pseudoElements.length === 0) return; | |
| for (const pseudo of pseudoElements) { | |
| const target = findBestPseudoParent(root, pseudo) || root; | |
| const relative = { | |
| ...pseudo, | |
| x: Math.round(pseudo.x - (target.rect?.x ?? 0)), | |
| y: Math.round(pseudo.y - (target.rect?.y ?? 0)), | |
| }; | |
| if (!target.pseudoChildren) { | |
| target.pseudoChildren = []; | |
| } | |
| target.pseudoChildren.push(relative); | |
| } | |
| } | |
| function normalizeRootStructure(root) { | |
| if (!root || root.tag !== 'body' || !Array.isArray(root.children) || root.children.length === 0) { | |
| return root; | |
| } | |
| const headerChildren = root.children.filter((child) => isTopHeaderChild(child, root.rect)); | |
| if (headerChildren.length === 0 || headerChildren.length === root.children.length) { | |
| return { | |
| ...root, | |
| _pageLayout: true, | |
| }; | |
| } | |
| const otherChildren = root.children.filter((child) => !isTopHeaderChild(child, root.rect)); | |
| const syntheticHeader = buildSyntheticGroup('header', headerChildren); | |
| return { | |
| ...root, | |
| _pageLayout: true, | |
| children: [syntheticHeader].concat(otherChildren), | |
| }; | |
| } | |
| function isTopHeaderChild(node, rootRect) { | |
| if (!node?.rect || !node?.computed) return false; | |
| const position = node.computed.position; | |
| if (position !== 'fixed' && position !== 'absolute') { | |
| return false; | |
| } | |
| const nearTop = Math.abs((node.rect.y ?? 0) - (rootRect?.y ?? 0)) <= 8; | |
| const wideEnough = (node.rect.width ?? 0) >= Math.max((rootRect?.width ?? 0) * 0.6, 320); | |
| const shortEnough = (node.rect.height ?? 0) <= Math.max((rootRect?.height ?? 0) * 0.2, 220); | |
| return nearTop && wideEnough && shortEnough; | |
| } | |
| function buildSyntheticGroup(tag, children) { | |
| const rect = unionRects(children.map((child) => child.rect).filter(Boolean)); | |
| const maxZ = Math.max(...children.map((child) => child.effectiveZ ?? 0), 0); | |
| return { | |
| tag, | |
| id: null, | |
| classList: [], | |
| _role: 'header', | |
| text: null, | |
| textRuns: [], | |
| isTextContainer: false, | |
| rect, | |
| computed: { | |
| display: 'block', | |
| position: 'static', | |
| zIndex: String(maxZ), | |
| flexDirection: 'row', | |
| justifyContent: 'flex-start', | |
| alignItems: 'stretch', | |
| flexWrap: 'nowrap', | |
| gap: '0px', | |
| columnGap: '0px', | |
| rowGap: '0px', | |
| gridTemplateColumns: 'none', | |
| gridTemplateRows: 'none', | |
| gridRow: 'auto', | |
| gridColumn: 'auto', | |
| width: `${rect.width}px`, | |
| height: `${rect.height}px`, | |
| minWidth: '0px', | |
| maxWidth: 'none', | |
| minHeight: '0px', | |
| paddingTop: '0px', | |
| paddingRight: '0px', | |
| paddingBottom: '0px', | |
| paddingLeft: '0px', | |
| marginTop: '0px', | |
| marginRight: '0px', | |
| marginBottom: '0px', | |
| marginLeft: '0px', | |
| backgroundColor: 'rgba(0, 0, 0, 0)', | |
| backgroundImage: 'none', | |
| backgroundSize: 'auto', | |
| backgroundPosition: '0% 0%', | |
| color: 'rgba(0, 0, 0, 0)', | |
| opacity: '1', | |
| borderRadius: '0px', | |
| borderTopLeftRadius: '0px', | |
| borderTopRightRadius: '0px', | |
| borderBottomRightRadius: '0px', | |
| borderBottomLeftRadius: '0px', | |
| border: '0px none rgba(0, 0, 0, 0)', | |
| borderWidth: '0px', | |
| borderColor: 'rgba(0, 0, 0, 0)', | |
| borderStyle: 'none', | |
| boxShadow: 'none', | |
| overflow: 'visible', | |
| overflowX: 'visible', | |
| overflowY: 'visible', | |
| mixBlendMode: 'normal', | |
| transform: 'none', | |
| fontFamily: 'Inter', | |
| fontSize: '16px', | |
| fontWeight: '400', | |
| fontStyle: 'normal', | |
| lineHeight: 'normal', | |
| letterSpacing: 'normal', | |
| textAlign: 'left', | |
| textTransform: 'none', | |
| whiteSpace: 'normal', | |
| textDecoration: 'none', | |
| webkitTextStrokeWidth: '0px', | |
| webkitTextStrokeColor: 'rgba(0, 0, 0, 0)', | |
| top: 'auto', | |
| right: 'auto', | |
| bottom: 'auto', | |
| left: 'auto', | |
| inset: 'auto', | |
| content: 'none', | |
| }, | |
| pseudo: { | |
| before: null, | |
| after: null, | |
| }, | |
| children, | |
| effectiveZ: maxZ, | |
| }; | |
| } | |
| function unionRects(rects) { | |
| if (!Array.isArray(rects) || rects.length === 0) { | |
| return { x: 0, y: 0, width: 0, height: 0 }; | |
| } | |
| const left = Math.min(...rects.map((rect) => rect.x)); | |
| const top = Math.min(...rects.map((rect) => rect.y)); | |
| const right = Math.max(...rects.map((rect) => rect.x + rect.width)); | |
| const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); | |
| return { | |
| x: left, | |
| y: top, | |
| width: Math.max(right - left, 0), | |
| height: Math.max(bottom - top, 0), | |
| }; | |
| } | |
| function getRenderableGridStrategy(node, gridStrategy) { | |
| if (!node || !gridStrategy?.outerFrame || !Array.isArray(node.children) || node.children.length < 2) { | |
| return null; | |
| } | |
| const axis = detectLinearChildAxis(node.children); | |
| if (!axis) { | |
| return null; | |
| } | |
| return { | |
| ...gridStrategy.outerFrame, | |
| layoutMode: axis, | |
| itemSpacing: measureAxisSpacing(node.children, axis), | |
| }; | |
| } | |
| function getRenderableInlineLayout(node) { | |
| if (!node?.computed || node.computed.display !== 'inline-block') { | |
| return null; | |
| } | |
| const children = Array.isArray(node.children) ? node.children.filter(Boolean) : []; | |
| if (children.length === 0) { | |
| return null; | |
| } | |
| if (children.some((child) => !child?.rect || isAbsoluteLikeNode(child))) { | |
| return null; | |
| } | |
| const detectedAxis = detectLinearChildAxis(children); | |
| if (detectedAxis === 'VERTICAL') { | |
| return null; | |
| } | |
| return { | |
| layoutMode: 'HORIZONTAL', | |
| primaryAxisAlignItems: 'MIN', | |
| counterAxisAlignItems: 'MIN', | |
| itemSpacing: measureAxisSpacing(children, 'HORIZONTAL'), | |
| }; | |
| } | |
| function getRenderableFlexLayout(node) { | |
| if (!node?.computed) { | |
| return null; | |
| } | |
| const children = getPresentChildren(node); | |
| const layout = mapFlexLayout(node.computed); | |
| if (children.length === 0) { | |
| return { layout: withFlexSizing(node, [], layout) }; | |
| } | |
| const flowChildren = getFlowChildren(node); | |
| if (shouldStartAlignSingleTextFlexRow(node, flowChildren, layout)) { | |
| layout.primaryAxisAlignItems = 'MIN'; | |
| } | |
| if (flowChildren.length === 0) { | |
| return { layout: withFlexSizing(node, flowChildren, layout) }; | |
| } | |
| if (flowChildren.some((child) => !child?.rect)) { | |
| return { layout: withFlexSizing(node, flowChildren, layout) }; | |
| } | |
| const axis = isRowFlexDirection(node.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL'; | |
| const measuredSpacing = measureAxisSpacing(flowChildren, axis); | |
| const cssSpacing = layout.itemSpacing || 0; | |
| if (layout.primaryAxisAlignItems !== 'SPACE_BETWEEN' && measuredSpacing > cssSpacing) { | |
| layout.itemSpacing = measuredSpacing; | |
| } | |
| return { | |
| layout: withFlexSizing(node, flowChildren, layout), | |
| }; | |
| } | |
| function getFlexAutoMarginLayoutOverride(node, layout) { | |
| const autoMargin = getDominantFlexAutoMargin(node, layout); | |
| if (!autoMargin) { | |
| return null; | |
| } | |
| return { | |
| primaryAxisAlignItems: 'SPACE_BETWEEN', | |
| itemSpacing: 0, | |
| primaryAxisSizingMode: 'FIXED', | |
| }; | |
| } | |
| function withFlexAutoMarginGroups(parentNode, childPairs, layout, path) { | |
| if (!isFlexDisplay(parentNode?.computed?.display) || !Array.isArray(childPairs) || childPairs.length === 0) { | |
| return childPairs.map((pair) => pair.built); | |
| } | |
| const autoMargin = getDominantFlexAutoMargin(parentNode, layout); | |
| if (!autoMargin) { | |
| return childPairs.map((pair) => pair.built); | |
| } | |
| const splitIndex = childPairs.findIndex((pair) => pair.source === autoMargin.child); | |
| if (splitIndex < 0) { | |
| return childPairs.map((pair) => pair.built); | |
| } | |
| if (autoMargin.edge === 'start') { | |
| const before = childPairs.slice(0, splitIndex); | |
| const after = childPairs.slice(splitIndex); | |
| return compactFlexAutoMarginGroups(parentNode, before, after, autoMargin.axis, path); | |
| } | |
| const before = childPairs.slice(0, splitIndex + 1); | |
| const after = childPairs.slice(splitIndex + 1); | |
| return compactFlexAutoMarginGroups(parentNode, before, after, autoMargin.axis, path); | |
| } | |
| function compactFlexAutoMarginGroups(parentNode, beforePairs, afterPairs, axis, path) { | |
| const result = []; | |
| const beforeGroup = buildFlexAutoMarginGroup(parentNode, beforePairs, axis, `${path}.autoMargin.before`, 'start'); | |
| const afterGroup = buildFlexAutoMarginGroup(parentNode, afterPairs, axis, `${path}.autoMargin.after`, 'end'); | |
| if (beforeGroup) { | |
| result.push(beforeGroup); | |
| } | |
| if (afterGroup) { | |
| result.push(afterGroup); | |
| } | |
| return result.length > 0 ? result : beforePairs.concat(afterPairs).map((pair) => pair.built); | |
| } | |
| function buildFlexAutoMarginGroup(parentNode, pairs, axis, path, side) { | |
| const visiblePairs = (pairs || []).filter((pair) => pair?.built); | |
| if (visiblePairs.length === 0) { | |
| return null; | |
| } | |
| if (visiblePairs.length === 1) { | |
| return visiblePairs[0].built; | |
| } | |
| const groupRect = unionRects(visiblePairs.map((pair) => specToRect(pair.built)).filter(Boolean)); | |
| const children = visiblePairs.map((pair) => rebaseSpecToGroup(pair.built, groupRect)); | |
| const itemSpacing = measureSpecAxisSpacing(visiblePairs.map((pair) => pair.built), axis); | |
| const parentCounterAlign = mapFlexLayout(parentNode?.computed || {}).counterAxisAlignItems || 'MIN'; | |
| const sideName = axis === 'HORIZONTAL' | |
| ? side === 'start' ? 'left' : 'right' | |
| : side === 'start' ? 'top' : 'bottom'; | |
| const parentName = buildName(parentNode?.tag || 'div', parentNode?.classList || []); | |
| return { | |
| id: `flex-auto-margin-group-${path}`, | |
| name: `${parentName} / ${sideName}`, | |
| type: 'FRAME', | |
| x: Math.round(groupRect.x), | |
| y: Math.round(groupRect.y), | |
| width: Math.round(groupRect.width), | |
| height: Math.round(groupRect.height), | |
| fills: [], | |
| strokes: [], | |
| effects: [], | |
| opacity: 1, | |
| paddingTop: 0, | |
| paddingRight: 0, | |
| paddingBottom: 0, | |
| paddingLeft: 0, | |
| layoutMode: axis, | |
| primaryAxisAlignItems: 'MIN', | |
| counterAxisAlignItems: parentCounterAlign, | |
| itemSpacing, | |
| primaryAxisSizingMode: 'FIXED', | |
| counterAxisSizingMode: 'FIXED', | |
| children, | |
| _isLayoutGroup: true, | |
| }; | |
| } | |
| function getDominantFlexAutoMargin(parentNode, layout) { | |
| if (!isFlexDisplay(parentNode?.computed?.display)) { | |
| return null; | |
| } | |
| const children = getFlowChildren(parentNode); | |
| if (children.length < 2) { | |
| return null; | |
| } | |
| const axis = isRowFlexDirection(parentNode.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL'; | |
| const freeSpace = measureFlexFreeSpaceBySum(parentNode, children, axis, layout?.itemSpacing || 0); | |
| if (freeSpace <= 8) { | |
| return null; | |
| } | |
| let best = null; | |
| for (let index = 0; index < children.length; index++) { | |
| for (const edge of ['start', 'end']) { | |
| const margin = getMainAxisMargin(children[index].computed, parentNode.computed, axis, edge); | |
| const dominantThreshold = Math.max(16, freeSpace * 0.45); | |
| if (margin < dominantThreshold) { | |
| continue; | |
| } | |
| if (!best || margin > best.margin) { | |
| best = { child: children[index], edge, axis, margin }; | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| function getMainAxisMargin(childComputed = {}, parentComputed = {}, axis, edge) { | |
| const direction = String(parentComputed?.flexDirection || 'row').toLowerCase(); | |
| if (axis === 'HORIZONTAL') { | |
| const useRight = direction === 'row-reverse'; | |
| const prop = edge === 'start' | |
| ? useRight ? 'marginRight' : 'marginLeft' | |
| : useRight ? 'marginLeft' : 'marginRight'; | |
| return parsePx(childComputed?.[prop]); | |
| } | |
| const useBottom = direction === 'column-reverse'; | |
| const prop = edge === 'start' | |
| ? useBottom ? 'marginBottom' : 'marginTop' | |
| : useBottom ? 'marginTop' : 'marginBottom'; | |
| return parsePx(childComputed?.[prop]); | |
| } | |
| function specToRect(spec) { | |
| if (!spec) { | |
| return null; | |
| } | |
| return { | |
| x: Number.isFinite(spec.x) ? spec.x : 0, | |
| y: Number.isFinite(spec.y) ? spec.y : 0, | |
| width: Number.isFinite(spec.width) ? spec.width : 0, | |
| height: Number.isFinite(spec.height) ? spec.height : 0, | |
| }; | |
| } | |
| function rebaseSpecToGroup(spec, groupRect) { | |
| return { | |
| ...spec, | |
| x: Math.round((Number.isFinite(spec.x) ? spec.x : 0) - groupRect.x), | |
| y: Math.round((Number.isFinite(spec.y) ? spec.y : 0) - groupRect.y), | |
| }; | |
| } | |
| function measureSpecAxisSpacing(specs, axis) { | |
| const gaps = measureSpecAxisGaps(specs, axis); | |
| let minGap = null; | |
| for (let index = 0; index < gaps.length; index++) { | |
| if (minGap === null || gaps[index] < minGap) { | |
| minGap = gaps[index]; | |
| } | |
| } | |
| return Math.max(Math.round(minGap ?? 0), 0); | |
| } | |
| function measureSpecAxisGaps(specs, axis) { | |
| const items = [...(specs || [])] | |
| .filter((spec) => spec && Number.isFinite(spec.x) && Number.isFinite(spec.y)) | |
| .sort((a, b) => axis === 'HORIZONTAL' ? a.x - b.x : a.y - b.y); | |
| const gaps = []; | |
| for (let index = 1; index < items.length; index++) { | |
| const prev = specToRect(items[index - 1]); | |
| const current = specToRect(items[index]); | |
| const gap = axis === 'HORIZONTAL' | |
| ? current.x - (prev.x + prev.width) | |
| : current.y - (prev.y + prev.height); | |
| if (gap >= 0) { | |
| gaps.push(gap); | |
| } | |
| } | |
| return gaps; | |
| } | |
| function withFlexSizing(node, flowChildren, layout) { | |
| const axis = isRowFlexDirection(node.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL'; | |
| const result = { ...layout }; | |
| const primaryFreeSpace = measureFlexFreeSpace(node, flowChildren, axis); | |
| const counterFreeSpace = measureFlexFreeSpace(node, flowChildren, axis === 'HORIZONTAL' ? 'VERTICAL' : 'HORIZONTAL'); | |
| const primaryAlign = String(result.primaryAxisAlignItems || 'MIN').toUpperCase(); | |
| const counterAlign = String(result.counterAxisAlignItems || 'MIN').toUpperCase(); | |
| const wraps = result.layoutWrap === 'WRAP'; | |
| if (wraps || primaryFreeSpace > 2 || primaryAlign === 'CENTER' || primaryAlign === 'MAX' || primaryAlign === 'SPACE_BETWEEN') { | |
| result.primaryAxisSizingMode = 'FIXED'; | |
| } | |
| if (wraps || counterFreeSpace > 2 || counterAlign === 'CENTER' || counterAlign === 'MAX' || counterAlign === 'STRETCH') { | |
| result.counterAxisSizingMode = 'FIXED'; | |
| } | |
| return result; | |
| } | |
| function measureFlexFreeSpaceBySum(node, children, axis, itemSpacing = 0) { | |
| const rect = node?.rect; | |
| if (!rect) { | |
| return 0; | |
| } | |
| const computed = node.computed || {}; | |
| const renderedSize = axis === 'HORIZONTAL' ? rect.width : rect.height; | |
| const startPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingLeft) : parsePx(computed.paddingTop); | |
| const endPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingRight) : parsePx(computed.paddingBottom); | |
| const items = (children || []).filter((child) => child?.rect); | |
| const itemSize = items.reduce((total, child) => { | |
| return total + (axis === 'HORIZONTAL' ? child.rect.width : child.rect.height); | |
| }, 0); | |
| const gaps = Math.max(items.length - 1, 0) * Math.max(itemSpacing || 0, 0); | |
| return Math.max(renderedSize - startPadding - endPadding - itemSize - gaps, 0); | |
| } | |
| function measureFlexFreeSpace(node, children, axis) { | |
| const rect = node?.rect; | |
| if (!rect) { | |
| return 0; | |
| } | |
| const computed = node.computed || {}; | |
| const renderedSize = axis === 'HORIZONTAL' ? rect.width : rect.height; | |
| const startPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingLeft) : parsePx(computed.paddingTop); | |
| const endPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingRight) : parsePx(computed.paddingBottom); | |
| const items = (children || []).filter((child) => child?.rect); | |
| if (items.length === 0) { | |
| return Math.max(renderedSize - startPadding - endPadding, 0); | |
| } | |
| if (axis === 'HORIZONTAL') { | |
| const left = Math.min(...items.map((child) => child.rect.x)); | |
| const right = Math.max(...items.map((child) => child.rect.x + child.rect.width)); | |
| return Math.max(renderedSize - startPadding - endPadding - (right - left), 0); | |
| } | |
| const top = Math.min(...items.map((child) => child.rect.y)); | |
| const bottom = Math.max(...items.map((child) => child.rect.y + child.rect.height)); | |
| return Math.max(renderedSize - startPadding - endPadding - (bottom - top), 0); | |
| } | |
| function isRowFlexDirection(flexDirection) { | |
| return flexDirection !== 'column' && flexDirection !== 'column-reverse'; | |
| } | |
| function isAbsoluteLikeNode(node) { | |
| const position = node?.computed?.position; | |
| return position === 'absolute' || position === 'fixed'; | |
| } | |
| function getPresentChildren(node) { | |
| return Array.isArray(node?.children) ? node.children.filter(Boolean) : []; | |
| } | |
| function getFlowChildren(node) { | |
| return getPresentChildren(node).filter((child) => !isAbsoluteLikeNode(child)); | |
| } | |
| function shouldStartAlignSingleTextFlexRow(node, flowChildren, layout) { | |
| const axis = isRowFlexDirection(node?.computed?.flexDirection) ? 'HORIZONTAL' : 'VERTICAL'; | |
| if (axis !== 'HORIZONTAL' || flowChildren.length !== 1 || !isTextLikeNode(flowChildren[0])) { | |
| return false; | |
| } | |
| if (!singleTextChildUsesPrimaryStretch(node, flowChildren[0])) { | |
| return false; | |
| } | |
| if (hasVisibleFrameSurface(node?.computed)) { | |
| return false; | |
| } | |
| const primaryAlign = String(layout?.primaryAxisAlignItems || 'MIN').toUpperCase(); | |
| return primaryAlign === 'CENTER' || primaryAlign === 'MAX' || primaryAlign === 'SPACE_BETWEEN'; | |
| } | |
| function singleTextChildUsesPrimaryStretch(parentNode, childNode) { | |
| const flexGrow = parseFloat(childNode?.computed?.flexGrow); | |
| if (Number.isFinite(flexGrow) && flexGrow > 0) { | |
| return true; | |
| } | |
| const parentRect = parentNode?.rect; | |
| const childRect = childNode?.rect; | |
| if (!parentRect || !childRect) { | |
| return false; | |
| } | |
| const computed = parentNode.computed || {}; | |
| const parentInnerWidth = Math.max(parentRect.width - parsePx(computed.paddingLeft) - parsePx(computed.paddingRight), 0); | |
| return fillsAxis(childRect.width, parentInnerWidth); | |
| } | |
| function shouldHugSingleTextFlexChild(parentNode, childNode, axis) { | |
| if (axis !== 'HORIZONTAL' || !parentNode || !childNode) { | |
| return false; | |
| } | |
| const flowChildren = getFlowChildren(parentNode); | |
| if (flowChildren.length !== 1 || flowChildren[0] !== childNode || !isTextLikeNode(childNode)) { | |
| return false; | |
| } | |
| return shouldStartAlignSingleTextFlexRow(parentNode, flowChildren, mapFlexLayout(parentNode.computed || {})); | |
| } | |
| function isTextLikeNode(node) { | |
| return Boolean(node?.text && node?.isTextContainer); | |
| } | |
| function hasVisibleFrameSurface(computed = {}) { | |
| if (!isTransparentCssColor(computed.backgroundColor)) { | |
| return true; | |
| } | |
| const backgroundImage = String(computed.backgroundImage || 'none').trim().toLowerCase(); | |
| if (backgroundImage && backgroundImage !== 'none') { | |
| return true; | |
| } | |
| const boxShadow = String(computed.boxShadow || 'none').trim().toLowerCase(); | |
| if (boxShadow && boxShadow !== 'none') { | |
| return true; | |
| } | |
| return hasVisibleBorder(computed); | |
| } | |
| function hasVisibleBorder(computed = {}) { | |
| const sides = ['Top', 'Right', 'Bottom', 'Left']; | |
| return sides.some((side) => { | |
| const width = parsePx(computed[`border${side}Width`] ?? computed.borderWidth); | |
| const style = String(computed[`border${side}Style`] ?? computed.borderStyle ?? 'none').toLowerCase(); | |
| const color = computed[`border${side}Color`] ?? computed.borderColor ?? computed.color; | |
| return width > 0 && style !== 'none' && style !== 'hidden' && !isTransparentCssColor(color); | |
| }); | |
| } | |
| function hasSignificantFlexChildMargins(children, axis) { | |
| return children.some((child) => { | |
| const computed = child?.computed || {}; | |
| if (axis === 'HORIZONTAL') { | |
| return Math.abs(parsePx(computed.marginLeft)) > 0.5 || Math.abs(parsePx(computed.marginRight)) > 0.5; | |
| } | |
| return Math.abs(parsePx(computed.marginTop)) > 0.5 || Math.abs(parsePx(computed.marginBottom)) > 0.5; | |
| }); | |
| } | |
| function hasUnevenFlexChildGaps(children, axis) { | |
| const gaps = measureAxisGaps(children, axis); | |
| if (gaps.length <= 1) { | |
| return false; | |
| } | |
| const minGap = Math.min(...gaps); | |
| const maxGap = Math.max(...gaps); | |
| const tolerance = Math.max(8, Math.round(Math.abs(minGap) * 0.25)); | |
| return maxGap - minGap > tolerance; | |
| } | |
| function isFlexDisplay(display) { | |
| return display === 'flex' || display === 'inline-flex'; | |
| } | |
| function detectLinearChildAxis(children) { | |
| const tolerance = 8; | |
| const xs = groupAxisValues(children.map((child) => child.rect?.x ?? 0), tolerance); | |
| const ys = groupAxisValues(children.map((child) => child.rect?.y ?? 0), tolerance); | |
| if (ys.length === 1 && xs.length > 1) { | |
| return 'HORIZONTAL'; | |
| } | |
| if (xs.length === 1 && ys.length > 1) { | |
| return 'VERTICAL'; | |
| } | |
| return null; | |
| } | |
| function groupAxisValues(values, tolerance) { | |
| const sorted = [...values].sort((a, b) => a - b); | |
| const groups = []; | |
| for (const value of sorted) { | |
| const prev = groups[groups.length - 1]; | |
| if (prev === undefined || Math.abs(value - prev) > tolerance) { | |
| groups.push(value); | |
| } | |
| } | |
| return groups; | |
| } | |
| function measureAxisGaps(children, axis) { | |
| const items = [...children] | |
| .filter((child) => child?.rect) | |
| .sort((a, b) => axis === 'HORIZONTAL' ? a.rect.x - b.rect.x : a.rect.y - b.rect.y); | |
| const gaps = []; | |
| for (let index = 1; index < items.length; index++) { | |
| const prev = items[index - 1].rect; | |
| const current = items[index].rect; | |
| const gap = axis === 'HORIZONTAL' | |
| ? current.x - (prev.x + prev.width) | |
| : current.y - (prev.y + prev.height); | |
| if (gap >= 0) { | |
| gaps.push(gap); | |
| } | |
| } | |
| return gaps; | |
| } | |
| function measureAxisSpacing(children, axis) { | |
| const gaps = measureAxisGaps(children, axis); | |
| let minGap = null; | |
| for (let index = 0; index < gaps.length; index++) { | |
| if (minGap === null || gaps[index] < minGap) { | |
| minGap = gaps[index]; | |
| } | |
| } | |
| return Math.max(Math.round(minGap ?? 0), 0); | |
| } | |
| function findBestPseudoParent(node, pseudo) { | |
| let best = null; | |
| function walk(current, depth = 0) { | |
| if (!current || !current.rect || current.isTextContainer) return; | |
| const score = scorePseudoParent(current, pseudo, depth); | |
| if (score > 0 && (!best || score > best.score)) { | |
| best = { node: current, score }; | |
| } | |
| for (const child of current.children || []) { | |
| walk(child, depth + 1); | |
| } | |
| } | |
| walk(node, 0); | |
| return best?.node ?? null; | |
| } | |
| function scorePseudoParent(node, pseudo, depth) { | |
| const rect = node.rect; | |
| if (!rect) return 0; | |
| const nodeArea = Math.max(rect.width * rect.height, 1); | |
| const pseudoArea = Math.max((pseudo.width || 0) * (pseudo.height || 0), 1); | |
| const contains = | |
| pseudo.x >= rect.x - 8 && | |
| pseudo.y >= rect.y - 8 && | |
| pseudo.x + pseudo.width <= rect.x + rect.width + 8 && | |
| pseudo.y + pseudo.height <= rect.y + rect.height + 8; | |
| const intersects = | |
| pseudo.x < rect.x + rect.width && | |
| pseudo.x + pseudo.width > rect.x && | |
| pseudo.y < rect.y + rect.height && | |
| pseudo.y + pseudo.height > rect.y; | |
| if (!contains && !intersects) { | |
| return 0; | |
| } | |
| const haystack = `${node.tag ?? ''} ${(node.classList || []).join(' ')} ${node.name ?? ''}`.toLowerCase(); | |
| const tokens = String(pseudo.name || '') | |
| .toLowerCase() | |
| .split(/[^a-z0-9]+/g) | |
| .filter((token) => token.length > 2); | |
| let tokenHits = 0; | |
| for (const token of tokens) { | |
| if (haystack.includes(token)) tokenHits++; | |
| } | |
| if (tokenHits === 0 && depth > 0) { | |
| const nearSizedContainer = nodeArea <= pseudoArea * 64; | |
| if (!nearSizedContainer) { | |
| return 0; | |
| } | |
| } | |
| let score = tokenHits * 1000; | |
| if (contains) score += 500; | |
| else if (intersects) score += 120; | |
| score += Math.min(400, Math.round(100000 / nodeArea)); | |
| score += Math.min(100, depth * 5); | |
| score += Math.min(80, Math.round(100000 / pseudoArea)); | |
| return score; | |
| } | |
| function buildTextRuns(runs, fontMap) { | |
| return (runs || []) | |
| .filter((run) => run && run.text) | |
| .map((run) => ({ | |
| text: run.text, | |
| lineIndex: run.lineIndex || 0, | |
| ...mapTypography(run.computed, fontMap), | |
| ...mapTextStroke(run.computed), | |
| })); | |
| } | |
| function mapFlexTextAlignment(computed) { | |
| if (!computed || (computed.display !== 'flex' && computed.display !== 'inline-flex')) { | |
| return {}; | |
| } | |
| const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse'; | |
| const primary = mapFlexTextAxisAlignment(computed.justifyContent, 'primary'); | |
| const counter = mapFlexTextAxisAlignment(computed.alignItems, 'counter'); | |
| const result = {}; | |
| if (isRow) { | |
| if (primary.horizontal) result.textAlignHorizontal = primary.horizontal; | |
| if (counter.vertical) result.textAlignVertical = counter.vertical; | |
| } else { | |
| if (counter.horizontal) result.textAlignHorizontal = counter.horizontal; | |
| if (primary.vertical) result.textAlignVertical = primary.vertical; | |
| } | |
| return result; | |
| } | |
| function mapFlexTextAxisAlignment(value, axisRole) { | |
| const normalized = String(value || '').toLowerCase(); | |
| const horizontalMap = { | |
| center: 'CENTER', | |
| 'flex-start': 'LEFT', | |
| start: 'LEFT', | |
| left: 'LEFT', | |
| 'flex-end': 'RIGHT', | |
| end: 'RIGHT', | |
| right: 'RIGHT', | |
| }; | |
| const verticalMap = { | |
| center: 'CENTER', | |
| 'flex-start': 'TOP', | |
| start: 'TOP', | |
| 'flex-end': 'BOTTOM', | |
| end: 'BOTTOM', | |
| }; | |
| return { | |
| horizontal: horizontalMap[normalized] || null, | |
| vertical: verticalMap[normalized] || null, | |
| axisRole, | |
| }; | |
| } | |
| function detectBackgroundPattern(computed) { | |
| if (!computed) { | |
| return null; | |
| } | |
| const backgroundImage = computed.backgroundImage || ''; | |
| const backgroundSize = computed.backgroundSize || ''; | |
| if (!backgroundImage.includes('linear-gradient')) { | |
| return null; | |
| } | |
| const repeatingPattern = detectRepeatingLinearGridPattern(backgroundImage); | |
| if (repeatingPattern) { | |
| return repeatingPattern; | |
| } | |
| return detectSizedLinearGridPattern(backgroundImage, backgroundSize); | |
| } | |
| function detectSizedLinearGridPattern(backgroundImage, backgroundSize) { | |
| if (!String(backgroundSize || '').includes('px')) { | |
| return null; | |
| } | |
| const layers = splitCssLayers(backgroundImage) | |
| .filter((layer) => /^linear-gradient\(/i.test(layer.trim())); | |
| if (layers.length < 2) { | |
| return null; | |
| } | |
| const size = parseBackgroundGridSize(backgroundSize); | |
| const color = findVisibleCssColor(backgroundImage); | |
| if (!size || !color) { | |
| return null; | |
| } | |
| return { | |
| kind: 'grid', | |
| cellWidth: size.width, | |
| cellHeight: size.height, | |
| strokeWeight: detectGridStrokeWeight(layers) || 1, | |
| paint: colorSolidPaint(color), | |
| verticalLines: true, | |
| horizontalLines: true, | |
| }; | |
| } | |
| function detectRepeatingLinearGridPattern(backgroundImage) { | |
| const layers = splitCssLayers(backgroundImage) | |
| .map((layer) => parseRepeatingLinearGridLayer(layer)) | |
| .filter(Boolean); | |
| if (layers.length === 0) { | |
| return null; | |
| } | |
| const vertical = layers.find((layer) => layer.axis === 'x'); | |
| const horizontal = layers.find((layer) => layer.axis === 'y'); | |
| if (!vertical && !horizontal) { | |
| return null; | |
| } | |
| const first = vertical || horizontal; | |
| return { | |
| kind: 'grid', | |
| cellWidth: Math.max(Math.round(vertical?.cellSize || horizontal?.cellSize || 1), 1), | |
| cellHeight: Math.max(Math.round(horizontal?.cellSize || vertical?.cellSize || 1), 1), | |
| strokeWeight: Math.max(Math.round(Math.min( | |
| vertical?.strokeWeight || horizontal?.strokeWeight || 1, | |
| horizontal?.strokeWeight || vertical?.strokeWeight || 1 | |
| )), 1), | |
| paint: colorSolidPaint((vertical || horizontal).color), | |
| verticalLines: Boolean(vertical), | |
| horizontalLines: Boolean(horizontal), | |
| }; | |
| } | |
| function parseRepeatingLinearGridLayer(layer) { | |
| const source = String(layer || '').trim(); | |
| if (!/^repeating-linear-gradient\(/i.test(source)) { | |
| return null; | |
| } | |
| const color = findVisibleCssColor(source); | |
| const positions = extractPxPositions(source); | |
| if (!color || positions.length < 2) { | |
| return null; | |
| } | |
| const axis = getLinearGradientAxis(source); | |
| const unique = Array.from(new Set(positions.map((value) => roundFloat(value, 3)))).sort((a, b) => a - b); | |
| const cellSize = Math.max(...unique); | |
| const strokeWeight = getSmallestPositiveGap(unique) || 1; | |
| if (!Number.isFinite(cellSize) || cellSize <= 0) { | |
| return null; | |
| } | |
| return { axis, cellSize, strokeWeight, color }; | |
| } | |
| function parseBackgroundGridSize(backgroundSize) { | |
| const values = extractPxPositions(String(backgroundSize || '').split(',')[0] || ''); | |
| if (values.length === 0) { | |
| return null; | |
| } | |
| return { | |
| width: Math.max(Math.round(values[0]), 1), | |
| height: Math.max(Math.round(values[1] || values[0]), 1), | |
| }; | |
| } | |
| function detectGridStrokeWeight(layers) { | |
| const weights = []; | |
| for (const layer of layers) { | |
| const positions = Array.from(new Set(extractPxPositions(layer))).sort((a, b) => a - b); | |
| const gap = getSmallestPositiveGap(positions); | |
| if (gap) weights.push(gap); | |
| } | |
| return weights.length ? Math.max(Math.round(Math.min(...weights)), 1) : 1; | |
| } | |
| function getSmallestPositiveGap(values) { | |
| let best = null; | |
| for (let index = 1; index < values.length; index++) { | |
| const gap = values[index] - values[index - 1]; | |
| if (gap > 0 && (best === null || gap < best)) { | |
| best = gap; | |
| } | |
| } | |
| return best; | |
| } | |
| function extractPxPositions(value) { | |
| return (String(value || '').match(/-?[\d.]+px/g) || []) | |
| .map((part) => parseFloat(part)) | |
| .filter((number) => Number.isFinite(number)); | |
| } | |
| function findVisibleCssColor(value) { | |
| const matches = String(value || '').match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []; | |
| for (const match of matches) { | |
| if (cssColorToFigma(match).a > 0) { | |
| return match; | |
| } | |
| } | |
| return null; | |
| } | |
| function getLinearGradientAxis(layer) { | |
| const lower = String(layer || '').toLowerCase(); | |
| if (/repeating-linear-gradient\(\s*(90deg|270deg|to\s+(right|left))/.test(lower)) { | |
| return 'x'; | |
| } | |
| return 'y'; | |
| } | |
| function toRoman(num, uppercase = true) { | |
| const romanLookup = [ | |
| ['M', 1000], | |
| ['CM', 900], | |
| ['D', 500], | |
| ['CD', 400], | |
| ['C', 100], | |
| ['XC', 90], | |
| ['L', 50], | |
| ['XL', 40], | |
| ['X', 10], | |
| ['IX', 9], | |
| ['V', 5], | |
| ['IV', 4], | |
| ['I', 1] | |
| ]; | |
| let res = ''; | |
| let val = num; | |
| for (const [str, amount] of romanLookup) { | |
| while (val >= amount) { | |
| res += str; | |
| val -= amount; | |
| } | |
| } | |
| return uppercase ? res : res.toLowerCase(); | |
| } | |
| function toAlpha(num, uppercase = true) { | |
| let res = ''; | |
| let val = num; | |
| while (val > 0) { | |
| const mod = (val - 1) % 26; | |
| res = String.fromCharCode((uppercase ? 65 : 97) + mod) + res; | |
| val = Math.floor((val - 1) / 26); | |
| } | |
| return res; | |
| } | |
| function resolveCounterText(content, index) { | |
| if (!content) return ''; | |
| const trimmed = content.trim(); | |
| // Format: counter(name, style) | |
| const counterMatch = trimmed.match(/counter\([^,]+,\s*([^)]+)\)/); | |
| if (counterMatch) { | |
| const style = counterMatch[1].trim().toLowerCase(); | |
| if (style === 'decimal-leading-zero') { | |
| return String(index).padStart(2, '0'); | |
| } | |
| if (style === 'upper-roman') { | |
| return toRoman(index, true); | |
| } | |
| if (style === 'lower-roman') { | |
| return toRoman(index, false); | |
| } | |
| if (style === 'lower-alpha' || style === 'lower-latin') { | |
| return toAlpha(index, false); | |
| } | |
| if (style === 'upper-alpha' || style === 'upper-latin') { | |
| return toAlpha(index, true); | |
| } | |
| return String(index); | |
| } | |
| // Format: counter(name) | |
| if (trimmed.includes('counter(')) { | |
| return String(index); | |
| } | |
| return content; | |
| } | |
| function parseRotationAndScale(transform) { | |
| if (!transform || transform === 'none') { | |
| return { rotation: 0, scaleX: 1, scaleY: 1 }; | |
| } | |
| const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/i); | |
| if (matrixMatch) { | |
| const values = matrixMatch[1].split(',').map((part) => parseFloat(part.trim())); | |
| if (values.length === 6 && values.every(Number.isFinite)) { | |
| const [a, b, c, d] = values; | |
| const angleRad = Math.atan2(b, a); | |
| const rotation = angleRad * (180 / Math.PI); | |
| const scaleX = Math.hypot(a, b); | |
| const scaleY = Math.hypot(c, d); | |
| return { rotation, scaleX, scaleY }; | |
| } | |
| } | |
| const matrix3dMatch = transform.match(/^matrix3d\(([^)]+)\)$/i); | |
| if (matrix3dMatch) { | |
| const values = matrix3dMatch[1].split(',').map((part) => parseFloat(part.trim())); | |
| if (values.length === 16 && values.every(Number.isFinite)) { | |
| const a = values[0]; | |
| const b = values[1]; | |
| const c = values[4]; | |
| const d = values[5]; | |
| const angleRad = Math.atan2(b, a); | |
| const rotation = angleRad * (180 / Math.PI); | |
| const scaleX = Math.hypot(a, b); | |
| const scaleY = Math.hypot(c, d); | |
| return { rotation, scaleX, scaleY }; | |
| } | |
| } | |
| return { rotation: 0, scaleX: 1, scaleY: 1 }; | |
| } | |