Step 0 of OP Rust-ification (per kickoff spec v7 FROZEN):
- Cargo workspace at root (members = ["crates/*"], glob)
- 9 skeleton crates: openpencil-app, openpencil-shell-{core,web,native},
pen-{types,core,engine,codegen,figma}
- rust-toolchain.toml pinned 1.85 (forced from 1.80 → 1.82 → 1.85
due to crates.io ecosystem edition2024 requirements)
- deny.toml with kickoff §1.2 wasm32 ban invariant
- 2 GitHub Actions: rust-check.yml (3-platform native + cargo-deny)
and wasm-bundle-check.yml (wasm32 forward + reverse cargo-deny bans)
- vendor/agent submodule → github.com/ZSeven-W/agent-rs
- Bun script wrappers (cargo:check / :test / :wasm-check / :deny)
- README "Rust subsystem" section + Phase boundary note
§1.2 invariants live:
- Forward wasm32 check: shell-web + 5 bucket A crates compile
- Reverse cargo-deny check bans: native + wasm32 both clean
- compile_error guard: shell-native fails wasm32 build with explicit
message, validated by canary
Step 1+ owns real implementation; Phase 0 docs (snapshot / plan
patches / IPC inventory / parley-taffy matrix / cargo-deny validation)
in openpencil-docs.
409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
import type { PenNode, ContainerProps, RefNode } from '@zseven-w/pen-types';
|
|
import {
|
|
resolvePadding,
|
|
isNodeVisible,
|
|
getNodeWidth,
|
|
getNodeHeight,
|
|
computeLayoutPositions,
|
|
inferLayout,
|
|
parseSizing,
|
|
defaultLineHeight,
|
|
findNodeInTree,
|
|
cssFontFamily,
|
|
} from '@zseven-w/pen-core';
|
|
import { wrapLine } from './paint-utils.js';
|
|
import type { ClipInfo, RenderNode } from './types.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pre-measure text widths using Canvas 2D (browser fonts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _measureCtx: CanvasRenderingContext2D | null = null;
|
|
function getMeasureCtx(): CanvasRenderingContext2D {
|
|
if (!_measureCtx) {
|
|
const c = document.createElement('canvas');
|
|
_measureCtx = c.getContext('2d')!;
|
|
}
|
|
return _measureCtx;
|
|
}
|
|
|
|
/**
|
|
* Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
|
|
*
|
|
* Only targets fixed-width text with auto height — these are the cases where
|
|
* estimateTextHeight may underestimate because its width estimation differs
|
|
* from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
|
|
*
|
|
* IMPORTANT: This function never touches WIDTH or container-relative sizing
|
|
* strings (fill_container / fit_content). Changing widths breaks layout
|
|
* resolution in computeLayoutPositions.
|
|
*/
|
|
export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
|
|
return nodes.map((node) => {
|
|
let result = node;
|
|
|
|
if (node.type === 'text') {
|
|
const tNode = node as PenNode & {
|
|
width?: number | string;
|
|
height?: number | string;
|
|
fontSize?: number;
|
|
fontWeight?: string;
|
|
fontFamily?: string;
|
|
lineHeight?: number;
|
|
textAlign?: string;
|
|
textGrowth?: string;
|
|
content?: string | { text?: string }[];
|
|
};
|
|
const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0;
|
|
const isContainerHeight =
|
|
typeof tNode.height === 'string' &&
|
|
(tNode.height === 'fill_container' || tNode.height === 'fit_content');
|
|
const textGrowth = tNode.textGrowth;
|
|
const content =
|
|
typeof tNode.content === 'string'
|
|
? tNode.content
|
|
: Array.isArray(tNode.content)
|
|
? tNode.content.map((s) => s.text ?? '').join('')
|
|
: (((tNode as unknown as Record<string, unknown>).text as string) ?? '');
|
|
|
|
const textAlign = tNode.textAlign;
|
|
const isFixedWidthText =
|
|
textGrowth === 'fixed-width' ||
|
|
textGrowth === 'fixed-width-height' ||
|
|
(textGrowth !== 'auto' && textAlign != null && textAlign !== 'left');
|
|
if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
|
|
const fontSize = tNode.fontSize ?? 16;
|
|
const fontWeight = tNode.fontWeight ?? '400';
|
|
const fontFamily =
|
|
tNode.fontFamily ??
|
|
'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif';
|
|
const ctx = getMeasureCtx();
|
|
ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`;
|
|
|
|
const wrapWidth = (tNode.width as number) + fontSize * 0.2;
|
|
const rawLines = content.split('\n');
|
|
const wrappedLines: string[] = [];
|
|
for (const raw of rawLines) {
|
|
if (!raw) {
|
|
wrappedLines.push('');
|
|
continue;
|
|
}
|
|
wrapLine(ctx, raw, wrapWidth, wrappedLines);
|
|
}
|
|
const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize);
|
|
const lineHeight = lineHeightMul * fontSize;
|
|
const glyphH = fontSize * 1.13;
|
|
const measuredHeight = Math.ceil(
|
|
wrappedLines.length <= 1
|
|
? glyphH + 2
|
|
: (wrappedLines.length - 1) * lineHeight + glyphH + 2,
|
|
);
|
|
const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0;
|
|
const explicitLineCount = rawLines.length;
|
|
const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount;
|
|
if (needsHeight && measuredHeight > currentHeight) {
|
|
result = { ...node, height: measuredHeight } as unknown as PenNode;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recurse into children
|
|
if ('children' in result && result.children) {
|
|
const children = result.children;
|
|
const measured = premeasureTextHeights(children);
|
|
if (measured !== children) {
|
|
result = { ...result, children: measured } as unknown as PenNode;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Flatten document tree -> absolute-positioned RenderNode list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sizeToNumber(val: number | string | undefined, fallback: number): number {
|
|
if (typeof val === 'number') return val;
|
|
if (typeof val === 'string') {
|
|
const m = val.match(/\((\d+(?:\.\d+)?)\)/);
|
|
if (m) return parseFloat(m[1]);
|
|
const n = parseFloat(val);
|
|
if (!isNaN(n)) return n;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
|
|
if (cr === undefined) return 0;
|
|
if (typeof cr === 'number') return cr;
|
|
return cr[0];
|
|
}
|
|
|
|
export function flattenToRenderNodes(
|
|
nodes: PenNode[],
|
|
offsetX = 0,
|
|
offsetY = 0,
|
|
parentAvailW?: number,
|
|
parentAvailH?: number,
|
|
clipStack: ClipInfo[] = [],
|
|
depth = 0,
|
|
): RenderNode[] {
|
|
const result: RenderNode[] = [];
|
|
|
|
// Reverse order: children[0] = top layer = rendered last (frontmost)
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const node = nodes[i];
|
|
if (!isNodeVisible(node)) continue;
|
|
|
|
// Resolve fill_container / fit_content
|
|
let resolved = node;
|
|
if (parentAvailW !== undefined || parentAvailH !== undefined) {
|
|
let changed = false;
|
|
const r: Record<string, unknown> = { ...node };
|
|
if ('width' in node && typeof node.width !== 'number') {
|
|
const s = parseSizing(node.width);
|
|
if (s === 'fill' && parentAvailW) {
|
|
r.width = parentAvailW;
|
|
changed = true;
|
|
} else if (s === 'fit') {
|
|
r.width = getNodeWidth(node, parentAvailW);
|
|
changed = true;
|
|
}
|
|
}
|
|
if ('height' in node && typeof node.height !== 'number') {
|
|
const s = parseSizing(node.height);
|
|
if (s === 'fill' && parentAvailH) {
|
|
r.height = parentAvailH;
|
|
changed = true;
|
|
} else if (s === 'fit') {
|
|
r.height = getNodeHeight(node, parentAvailH, parentAvailW);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) resolved = r as unknown as PenNode;
|
|
}
|
|
|
|
// Compute height for frames without explicit numeric height
|
|
if (
|
|
node.type === 'frame' &&
|
|
'children' in node &&
|
|
node.children?.length &&
|
|
(!('height' in resolved) || typeof resolved.height !== 'number')
|
|
) {
|
|
const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW);
|
|
if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode;
|
|
}
|
|
|
|
const absX = (resolved.x ?? 0) + offsetX;
|
|
const absY = (resolved.y ?? 0) + offsetY;
|
|
|
|
// Compute authoritative dimensions once via getNodeWidth/getNodeHeight.
|
|
// Used for: RenderNode absW/absH, child available space, and clip rect.
|
|
// This replaces the prior split where absW/absH used sizeToNumber (raw
|
|
// parse + 100 fallback) while child layout used getNodeWidth/getNodeHeight,
|
|
// causing divergence when nodes lacked numeric dimensions.
|
|
const nodeW = getNodeWidth(resolved, parentAvailW);
|
|
const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW);
|
|
const absW = nodeW > 0 ? nodeW : 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100;
|
|
const absH =
|
|
nodeH > 0 ? nodeH : 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100;
|
|
|
|
result.push({
|
|
node: { ...resolved, x: absX, y: absY } as PenNode,
|
|
absX,
|
|
absY,
|
|
absW,
|
|
absH,
|
|
clipStack: clipStack.length > 0 ? clipStack.slice() : undefined,
|
|
});
|
|
|
|
// Recurse into children
|
|
const children = 'children' in node ? node.children : undefined;
|
|
if (children && children.length > 0) {
|
|
const pad = resolvePadding(
|
|
'padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined,
|
|
);
|
|
const childAvailW = Math.max(0, nodeW - pad.left - pad.right);
|
|
const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom);
|
|
|
|
const layout =
|
|
('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node);
|
|
const positioned =
|
|
layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
|
|
|
|
// Clipping — root frames always clip like artboards. Nested containers
|
|
// clip only when clipContent is enabled. Each level pushes its own
|
|
// ClipInfo onto the stack rather than intersecting into a single
|
|
// ClipInfo, so each ancestor's rounded corner can be enforced
|
|
// independently at paint time (a single rrect can't encode `(rrect ∩
|
|
// rrect)` faithfully when one rect cuts inside the other's corner).
|
|
let childClipStack = clipStack;
|
|
const isRootFrame = node.type === 'frame' && depth === 0;
|
|
const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
|
|
if (isRootFrame || explicitClip) {
|
|
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
|
|
const cr = Math.min(crRaw, nodeH / 2);
|
|
const own: ClipInfo = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };
|
|
childClipStack = [...clipStack, own];
|
|
}
|
|
|
|
const childRNs = flattenToRenderNodes(
|
|
positioned,
|
|
absX,
|
|
absY,
|
|
childAvailW,
|
|
childAvailH,
|
|
childClipStack,
|
|
depth + 1,
|
|
);
|
|
|
|
// Propagate parent flip to children
|
|
const parentFlipX = node.flipX === true;
|
|
const parentFlipY = node.flipY === true;
|
|
if (parentFlipX || parentFlipY) {
|
|
const pcx = absX + nodeW / 2;
|
|
const pcy = absY + nodeH / 2;
|
|
for (const crn of childRNs) {
|
|
const updates: Record<string, unknown> = {};
|
|
if (parentFlipX) {
|
|
const ccx = crn.absX + crn.absW / 2;
|
|
crn.absX = 2 * pcx - ccx - crn.absW / 2;
|
|
const childFlip = crn.node.flipX === true;
|
|
updates.flipX = !childFlip || undefined;
|
|
}
|
|
if (parentFlipY) {
|
|
const ccy = crn.absY + crn.absH / 2;
|
|
crn.absY = 2 * pcy - ccy - crn.absH / 2;
|
|
const childFlip = crn.node.flipY === true;
|
|
updates.flipY = !childFlip || undefined;
|
|
}
|
|
crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode;
|
|
}
|
|
}
|
|
|
|
// Propagate parent rotation to children
|
|
const parentRot = node.rotation ?? 0;
|
|
if (parentRot !== 0) {
|
|
const cx = absX + nodeW / 2;
|
|
const cy = absY + nodeH / 2;
|
|
const rad = (parentRot * Math.PI) / 180;
|
|
const cosA = Math.cos(rad);
|
|
const sinA = Math.sin(rad);
|
|
|
|
for (const crn of childRNs) {
|
|
const ccx = crn.absX + crn.absW / 2;
|
|
const ccy = crn.absY + crn.absH / 2;
|
|
const dx = ccx - cx;
|
|
const dy = ccy - cy;
|
|
const newCx = cx + dx * cosA - dy * sinA;
|
|
const newCy = cy + dx * sinA + dy * cosA;
|
|
crn.absX = newCx - crn.absW / 2;
|
|
crn.absY = newCy - crn.absH / 2;
|
|
const childRot = crn.node.rotation ?? 0;
|
|
crn.node = {
|
|
...crn.node,
|
|
x: crn.absX,
|
|
y: crn.absY,
|
|
rotation: childRot + parentRot,
|
|
} as PenNode;
|
|
}
|
|
}
|
|
|
|
result.push(...childRNs);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ref resolution — resolve RefNodes to their target components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
|
|
export function resolveRefs(
|
|
nodes: PenNode[],
|
|
rootNodes: PenNode[],
|
|
findInTree?: (nodes: PenNode[], id: string) => PenNode | null,
|
|
visited = new Set<string>(),
|
|
): PenNode[] {
|
|
const finder = findInTree ?? ((ns: PenNode[], id: string) => findNodeInTree(ns, id) ?? null);
|
|
return nodes.flatMap((node) => {
|
|
if (node.type !== 'ref') {
|
|
if ('children' in node && node.children) {
|
|
return [
|
|
{ ...node, children: resolveRefs(node.children, rootNodes, finder, visited) } as PenNode,
|
|
];
|
|
}
|
|
return [node];
|
|
}
|
|
if (visited.has(node.ref)) return [];
|
|
const component = finder(rootNodes, node.ref);
|
|
if (!component) return [];
|
|
visited.add(node.ref);
|
|
const resolved: Record<string, unknown> = { ...component };
|
|
for (const [key, val] of Object.entries(node)) {
|
|
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue;
|
|
if (val !== undefined) resolved[key] = val;
|
|
}
|
|
resolved.type = component.type;
|
|
if (!resolved.name) resolved.name = component.name;
|
|
delete resolved.reusable;
|
|
const resolvedNode = resolved as unknown as PenNode;
|
|
if ('children' in component && component.children) {
|
|
const refNode = node as RefNode;
|
|
(resolvedNode as PenNode & ContainerProps).children = remapIds(
|
|
component.children,
|
|
node.id,
|
|
refNode.descendants,
|
|
);
|
|
}
|
|
visited.delete(node.ref);
|
|
return [resolvedNode];
|
|
});
|
|
}
|
|
|
|
export function remapIds(
|
|
children: PenNode[],
|
|
refId: string,
|
|
overrides?: Record<string, Partial<PenNode>>,
|
|
): PenNode[] {
|
|
return children.map((child) => {
|
|
const virtualId = `${refId}__${child.id}`;
|
|
const ov = overrides?.[child.id] ?? {};
|
|
const mapped = { ...child, ...ov, id: virtualId } as PenNode;
|
|
if ('children' in mapped && mapped.children) {
|
|
(mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides);
|
|
}
|
|
return mapped;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component / instance ID collection (from raw tree, before ref resolution)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function collectReusableIds(nodes: PenNode[], result: Set<string>) {
|
|
for (const node of nodes) {
|
|
if (node.type === 'frame' && node.reusable === true) {
|
|
result.add(node.id);
|
|
}
|
|
if ('children' in node && node.children) {
|
|
collectReusableIds(node.children, result);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
|
|
for (const node of nodes) {
|
|
if (node.type === 'ref') {
|
|
result.add(node.id);
|
|
}
|
|
if ('children' in node && node.children) {
|
|
collectInstanceIds(node.children, result);
|
|
}
|
|
}
|
|
}
|