Merge remote-tracking branch 'origin/master' into figma-compat-render-gaps

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Danila Poyarkov 2026-05-31 13:59:19 +03:00
commit 22ff3acdb5
17 changed files with 151 additions and 37 deletions

View file

@ -239,6 +239,20 @@ jobs:
tar -tf .npm-packages/open-pencil-cli-*.tgz | sed -n '1,40p' || true
tar -tf .npm-packages/open-pencil-mcp-*.tgz | sed -n '1,40p' || true
tar -tf .npm-packages/open-pencil-vue-*.tgz | sed -n '1,60p' || true
node --input-type=module <<'NODE'
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
for (const tarball of fs.readdirSync('.npm-packages').filter((name) => name.endsWith('.tgz'))) {
const entries = new Set(execFileSync('tar', ['-tf', `.npm-packages/${tarball}`], { encoding: 'utf8' }).trim().split('\n'))
const packageJson = JSON.parse(execFileSync('tar', ['-xOf', `.npm-packages/${tarball}`, 'package/package.json'], { encoding: 'utf8' }))
const bin = packageJson.bin
const targets = typeof bin === 'string' ? { [packageJson.name]: bin } : bin || {}
for (const [name, target] of Object.entries(targets)) {
const entry = `package/${String(target).replace(/^\.\//, '')}`
if (!entries.has(entry)) throw new Error(`${tarball}: bin ${name} target missing from tarball: ${entry}`)
}
}
NODE
- name: Publish packages to npm
run: |

View file

@ -2,13 +2,21 @@
## Unreleased
### Fixes
- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path.
- Preserve rotated Figma transform origins for imported vector nodes.
- Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints.
## 0.13.2 — 2026-05-30
### Changed
- Update the Homebrew install command to use the published `openpencil` cask.
### Fixes
- Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints.
- Fix the published MCP package so global installs include the `openpencil-mcp` and `openpencil-mcp-http` launchers required by desktop app integrations.
## 0.13.1 — 2026-05-29

View file

@ -98,7 +98,7 @@
},
"packages/cli": {
"name": "@open-pencil/cli",
"version": "0.12.2",
"version": "0.13.2",
"bin": {
"openpencil": "./bin/openpencil.js",
},
@ -114,7 +114,7 @@
},
"packages/core": {
"name": "@open-pencil/core",
"version": "0.12.2",
"version": "0.13.2",
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
@ -157,10 +157,10 @@
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.12.2",
"version": "0.13.2",
"bin": {
"openpencil-mcp": "./bin/openpencil-mcp.js",
"openpencil-mcp-http": "./bin/openpencil-mcp-http.js",
"openpencil-mcp": "./dist/stdio.mjs",
"openpencil-mcp-http": "./dist/index.mjs",
},
"dependencies": {
"@hono/node-server": "^1.19.9",
@ -178,7 +178,7 @@
},
"packages/vue": {
"name": "@open-pencil/vue",
"version": "0.12.2",
"version": "0.13.2",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",

View file

@ -1,6 +1,6 @@
[package]
name = "open_pencil"
version = "0.13.1"
version = "0.13.2"
description = "OpenPencil desktop app"
authors = ["Danila Poyarkov"]
edition = "2021"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenPencil",
"version": "0.13.1",
"version": "0.13.2",
"identifier": "net.dannote.open-pencil",
"build": {
"beforeDevCommand": "bun run generate:tauri-menu && bun run dev",

View file

@ -1,6 +1,6 @@
{
"name": "open-pencil-app",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"license": "MIT",
"workspaces": [

View file

@ -1,6 +1,6 @@
{
"name": "@open-pencil/cli",
"version": "0.13.1",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {

View file

@ -1,6 +1,6 @@
{
"name": "@open-pencil/core",
"version": "0.13.1",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {

View file

@ -246,6 +246,15 @@ function operationForNode(r: SkiaRenderer, node: SceneNode): PathOp {
return r.ck.PathOp[BOOLEAN_PATH_OP[operation]]
}
function makeImportedFillGeometryPath(r: SkiaRenderer, node: SceneNode): Path | null {
if (typeof r.getFillGeometry !== 'function') return null
const fillGeometry = r.getFillGeometry(node)
if (!fillGeometry) return null
const result = new r.ck.Path()
for (const path of fillGeometry) result.addPath(path)
return result
}
export function makeBooleanOperationPath(
r: SkiaRenderer,
node: SceneNode,
@ -259,14 +268,21 @@ export function makeBooleanOperationPath(
if (path) childPaths.push(path)
}
if (childPaths.length === 0) return null
if (childPaths.length === 0) return makeImportedFillGeometryPath(r, node)
const first = childPaths[0]
for (const path of childPaths.slice(1)) {
first.op(path, operationForNode(r, node))
const result = childPaths[0]
const operation = operationForNode(r, node)
for (let index = 1; index < childPaths.length; index++) {
const path = childPaths[index]
const didApply = result.op(path, operation)
path.delete()
if (!didApply) {
result.delete()
for (const remaining of childPaths.slice(index + 1)) remaining.delete()
return makeImportedFillGeometryPath(r, node)
}
}
return first
return result
}
export function renderBooleanOperation(

View file

@ -330,7 +330,7 @@ export interface NodeChange {
frameMaskDisabled?: boolean
resizeToFit?: boolean
// Vector
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' | 'XOR'
vectorData?: unknown
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>

View file

@ -112,8 +112,10 @@ function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] {
switch (nc.booleanOperation) {
case 'SUBTRACT':
case 'INTERSECT':
case 'EXCLUDE':
return nc.booleanOperation
case 'EXCLUDE':
case 'XOR':
return 'EXCLUDE'
default:
return 'UNION'
}
@ -229,17 +231,25 @@ function convertTransformProps(
const sx = flipX ? -1 : 1
rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI)
const corners = [
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: 0, y: height },
{ x: width, y: height }
].map((point) => ({
x: t.m00 * point.x + t.m01 * point.y + t.m02,
y: t.m10 * point.x + t.m11 * point.y + t.m12
}))
x = Math.min(...corners.map((point) => point.x))
y = Math.min(...corners.map((point) => point.y))
if (rotation !== 0 && !flipX) {
const radians = (rotation * Math.PI) / 180
const cos = Math.cos(radians)
const sin = Math.sin(radians)
x = t.m02 - (width / 2) * (1 - cos) - sin * (height / 2)
y = t.m12 - (height / 2) * (1 - cos) + sin * (width / 2)
} else {
const corners = [
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: 0, y: height },
{ x: width, y: height }
].map((point) => ({
x: t.m00 * point.x + t.m01 * point.y + t.m02,
y: t.m10 * point.x + t.m11 * point.y + t.m12
}))
x = Math.min(...corners.map((point) => point.x))
y = Math.min(...corners.map((point) => point.y))
}
}
return { x, y, width, height, rotation, flipX, flipY: false }

View file

@ -1,6 +1,6 @@
{
"name": "@open-pencil/mcp",
"version": "0.13.1",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {
@ -24,11 +24,10 @@
}
},
"bin": {
"openpencil-mcp": "./bin/openpencil-mcp.js",
"openpencil-mcp-http": "./bin/openpencil-mcp-http.js"
"openpencil-mcp": "./dist/stdio.mjs",
"openpencil-mcp-http": "./dist/index.mjs"
},
"files": [
"bin",
"dist"
],
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@open-pencil/vue",
"version": "0.13.1",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {

View file

@ -28,6 +28,22 @@ function checkRuntimePath(packageName: string, field: string, value: string): vo
}
}
function checkIncludedRuntimePath(
packageName: string,
field: string,
value: string,
files: string[]
): void {
checkRuntimePath(packageName, field, value)
const normalized = value.replace(/^\.\//, '')
const topLevelDir = normalized.split('/')[0]
if (topLevelDir && !files.includes(topLevelDir)) {
errors.push(
`${packageName}: ${field} points to ${value}, but files does not include ${topLevelDir}`
)
}
}
function walkExports(packageName: string, value: unknown, path: string[] = []): void {
if (typeof value === 'string') {
const key = path.at(-1)
@ -51,10 +67,10 @@ for (const packageDir of publicPackages) {
if (pkg.main) checkRuntimePath(pkg.name, 'main', pkg.main)
if (typeof pkg.bin === 'string') {
checkRuntimePath(pkg.name, 'bin', pkg.bin)
checkIncludedRuntimePath(pkg.name, 'bin', pkg.bin, pkg.files ?? [])
} else if (pkg.bin) {
for (const [name, target] of Object.entries(pkg.bin)) {
checkRuntimePath(pkg.name, `bin.${name}`, target)
checkIncludedRuntimePath(pkg.name, `bin.${name}`, target, pkg.files ?? [])
}
}

View file

@ -69,6 +69,20 @@ describe('Figma boolean operation import', () => {
expect(children.map((child) => child.type)).toEqual(['RECTANGLE', 'ELLIPSE'])
})
test('maps Kiwi XOR boolean operations to scene graph exclude', () => {
const props = nodeChangeToProps(
{
type: 'BOOLEAN_OPERATION',
name: 'Imported boolean',
booleanOperation: 'XOR'
} as NodeChange,
[]
)
expect(props.nodeType).toBe('BOOLEAN_OPERATION')
expect(props.booleanOperation).toBe('EXCLUDE')
})
test('defaults missing boolean operations to union', () => {
const props = nodeChangeToProps(
{

View file

@ -20,4 +20,20 @@ describe('fig-import: transforms', () => {
expect(imported.y).toBe(7)
expect(imported.flipX).toBe(true)
})
test('rotated transforms preserve the Figma matrix origin', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('VECTOR', 10, 1, {
size: { x: 100, y: 20 },
transform: { m00: 0, m01: -1, m02: 10, m10: 1, m11: 0, m12: 20 }
})
])
const imported = graph.getChildren(graph.getPages()[0].id)[0]
expect(imported.x).toBeCloseTo(-50)
expect(imported.y).toBeCloseTo(60)
expect(imported.rotation).toBeCloseTo(90)
})
})

View file

@ -5,6 +5,7 @@ import { makeBooleanOperationPath } from '#core/canvas/boolean'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes'
import { BLACK } from '#core/constants'
import type { SceneNode } from '#core/scene-graph'
import { createAPI } from '#tests/engine/figma/api/helpers'
@ -164,6 +165,26 @@ describe('boolean operation paths', () => {
path?.delete()
})
test('uses imported fill geometry when child paths cannot produce a boolean path', async () => {
const r = await createRenderer()
const importedPath = new r.ck.Path()
importedPath.addRect(r.ck.LTRBRect(5, 6, 25, 36))
r.getFillGeometry = () => [importedPath]
const node = {
id: 'boolean',
type: 'BOOLEAN_OPERATION',
childIds: [],
booleanOperation: 'UNION'
} as SceneNode
const api = createAPI()
const path = makeBooleanOperationPath(r, node, api.graph)
expect(path?.getBounds()).toEqual(new Float32Array([5, 6, 25, 36]))
path?.delete()
importedPath.delete()
})
test('supports nested boolean operation children', async () => {
const r = await createRenderer()
const api = createAPI()