import type { IDomFacade } from 'fontoxpath' import type { SceneGraph, SceneNode } from './scene-graph' const NODE_TYPES = { ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, DOCUMENT_NODE: 9 } const QUERYABLE_ATTRS = [ 'name', 'width', 'height', 'x', 'y', 'visible', 'opacity', 'cornerRadius', 'fontSize', 'fontFamily', 'fontWeight', 'textDirection', 'layoutMode', 'layoutDirection', 'itemSpacing', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'strokeWeight', 'rotation', 'locked', 'blendMode', 'text', 'lineHeight', 'letterSpacing' ] interface XPathDocument { nodeType: number nodeName: string documentElement: XPathNode | null _children?: XPathNode[] } interface XPathAttr { nodeType: number nodeName: string name: string localName: string namespaceURI: null prefix: null value: string ownerElement: XPathNode } interface XPathNode { nodeType: number nodeName: string localName: string namespaceURI: null prefix: null _sceneNode: SceneNode _attrs?: XPathAttr[] _parent?: XPathNode | XPathDocument | null _children?: XPathNode[] } function wrapNode( _graph: SceneGraph, node: SceneNode, parent?: XPathNode | XPathDocument | null ): XPathNode { const wrapped: XPathNode = { nodeType: NODE_TYPES.ELEMENT_NODE, nodeName: node.type, localName: node.type, namespaceURI: null, prefix: null, _sceneNode: node, _parent: parent } return wrapped } function createDocument(graph: SceneGraph, rootNode: SceneNode): XPathDocument { const doc: XPathDocument = { nodeType: NODE_TYPES.DOCUMENT_NODE, nodeName: '#document', documentElement: null } const root = wrapNode(graph, rootNode, doc) doc.documentElement = root doc._children = [root] return doc } function getAttrs(wrapped: XPathNode): XPathAttr[] { if (wrapped._attrs) return wrapped._attrs const node = wrapped._sceneNode const attrs: XPathAttr[] = [] for (const attrName of QUERYABLE_ATTRS) { if (attrName in node) { const value = Reflect.get(node, attrName) if (value === undefined || value === null || typeof value === 'symbol') continue const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value as string | number | boolean) attrs.push({ nodeType: NODE_TYPES.ATTRIBUTE_NODE, nodeName: attrName, name: attrName, localName: attrName, namespaceURI: null, prefix: null, value: stringValue, ownerElement: wrapped }) } } wrapped._attrs = attrs return attrs } function getChildren(graph: SceneGraph, wrapped: XPathNode): XPathNode[] { if (wrapped._children) return wrapped._children const node = wrapped._sceneNode wrapped._children = node.childIds .map((id) => graph.getNode(id)) .filter((n): n is SceneNode => n !== undefined) .map((child) => wrapNode(graph, child, wrapped)) return wrapped._children } function isDocument(node: unknown): node is XPathDocument { return (node as XPathDocument).nodeType === NODE_TYPES.DOCUMENT_NODE } function siblingNode( graph: SceneGraph, node: XPathNode | XPathDocument, offset: -1 | 1 ): XPathNode | null { if (isDocument(node)) return null const parent = node._parent if (!parent || isDocument(parent)) return null const siblings = getChildren(graph, parent) const index = siblings.indexOf(node) if (index === -1) return null return siblings[index + offset] ?? null } function createDomFacade(graph: SceneGraph) { return { getAllAttributes(node: XPathNode | XPathDocument): XPathAttr[] { if (isDocument(node)) return [] return getAttrs(node) }, getAttribute(node: XPathNode | XPathDocument, attributeName: string): string | null { if (isDocument(node)) return null const sceneNode = node._sceneNode if (attributeName in sceneNode) { const value = Reflect.get(sceneNode, attributeName) if (value === undefined || value === null || typeof value === 'symbol') return null return typeof value === 'object' ? JSON.stringify(value) : String(value as string | number | boolean) } return null }, getChildNodes(node: XPathNode | XPathDocument): XPathNode[] { if (isDocument(node)) return node._children ?? [] return getChildren(graph, node) }, getData(node: XPathAttr): string { return node.value }, getFirstChild(node: XPathNode | XPathDocument): XPathNode | null { if (isDocument(node)) return node.documentElement const children = getChildren(graph, node) return children[0] ?? null }, getLastChild(node: XPathNode | XPathDocument): XPathNode | null { if (isDocument(node)) return node.documentElement const children = getChildren(graph, node) return children[children.length - 1] ?? null }, getNextSibling(node: XPathNode | XPathDocument): XPathNode | null { return siblingNode(graph, node, 1) }, getParentNode(node: XPathNode | XPathDocument): XPathNode | XPathDocument | null { if (isDocument(node)) return null return node._parent ?? null }, getPreviousSibling(node: XPathNode | XPathDocument): XPathNode | null { return siblingNode(graph, node, -1) } } } export interface XPathQueryOptions { limit?: number page?: string } export async function queryByXPath( graph: SceneGraph, selector: string, options: XPathQueryOptions = {} ): Promise { const { limit = 1000 } = options const pages = graph.getPages() const targetPages = options.page ? pages.filter((p) => p.name === options.page) : pages if (targetPages.length === 0) return [] const { evaluateXPathToNodes } = await import('fontoxpath') const domFacade = createDomFacade(graph) as IDomFacade const results: SceneNode[] = [] for (const page of targetPages) { const doc = createDocument(graph, page) const nodes = evaluateXPathToNodes(selector, doc, domFacade) for (const node of nodes) { if (results.length >= limit) break const sceneNode = (node as XPathNode)._sceneNode if (sceneNode.type !== 'CANVAS') { results.push(sceneNode) } } if (results.length >= limit) break } return results } /** * Build an XPath selector that uniquely identifies a node in its page. * * Strategy: walk from the node up to the page root, building path segments. * Each segment uses the node type as the element name. If the name is * unique among siblings of the same type, use `[@name='...']`. * Otherwise fall back to a positional predicate `[n]`. */ export function nodeToXPath(graph: SceneGraph, nodeId: string): string | null { const node = graph.getNode(nodeId) if (!node) return null const segments: string[] = [] let current = node for (;;) { if (current.type === 'CANVAS') break const parentId: string | null = current.parentId const parent: SceneNode | undefined = parentId ? graph.getNode(parentId) : undefined segments.unshift(buildSegment(graph, current, parent)) if (!parent || parent.type === 'CANVAS') break current = parent } return segments.length > 0 ? '//' + segments.join('/') : null } function buildSegment(graph: SceneGraph, node: SceneNode, parent: SceneNode | undefined): string { const tag = node.type const escaped = escapeXPathName(node.name) if (!parent) return `${tag}[@name=${escaped}]` const siblings = parent.childIds .map((id) => graph.getNode(id)) .filter((n): n is SceneNode => n !== undefined && n.type === tag) const sameNameCount = siblings.filter((s) => s.name === node.name).length if (sameNameCount <= 1) return `${tag}[@name=${escaped}]` const index = siblings.findIndex((s) => s.id === node.id) + 1 return `${tag}[${index}]` } function escapeXPathName(s: string): string { if (!s.includes("'")) return `'${s}'` if (!s.includes('"')) return `"${s}"` const parts = s.split("'").map((p) => `'${p}'`) return `concat(${parts.join(', "\'", ')})` } export async function matchByXPath( graph: SceneGraph, selector: string, node: SceneNode ): Promise { const { evaluateXPathToBoolean } = await import('fontoxpath') const domFacade = createDomFacade(graph) as IDomFacade const wrapped = wrapNode(graph, node) try { return evaluateXPathToBoolean(`self::*[${selector}]`, wrapped, domFacade) } catch { return false } }