Merge remote-tracking branch 'origin/master' into figma-compat-render-gaps
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
22ff3acdb5
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
12
bun.lock
12
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-pencil-app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-pencil/cli",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-pencil/core",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-pencil/vue",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue