diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5190121a..4f12052d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 743b5c74f..f7c8d4bca 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/bun.lock b/bun.lock index ce87b9d29..522602c26 100644 --- a/bun.lock +++ b/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", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 0cdc7db8a..90acdcaa4 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 50cb08bfe..f8623eb65 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -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", diff --git a/package.json b/package.json index 918142b08..4781c221d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-pencil-app", - "version": "0.13.1", + "version": "0.13.2", "private": true, "license": "MIT", "workspaces": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index 8fa854b6f..18d8c8e84 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/cli", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "type": "module", "imports": { diff --git a/packages/core/package.json b/packages/core/package.json index 90779bd41..5c36d11c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/core", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "type": "module", "imports": { diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts index 769ea79e1..a8bcd1fe7 100644 --- a/packages/core/src/canvas/boolean.ts +++ b/packages/core/src/canvas/boolean.ts @@ -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( diff --git a/packages/core/src/kiwi/fig/codec/index.ts b/packages/core/src/kiwi/fig/codec/index.ts index 6b4a0113c..648cb7068 100644 --- a/packages/core/src/kiwi/fig/codec/index.ts +++ b/packages/core/src/kiwi/fig/codec/index.ts @@ -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 }> diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 02d8c7893..b7f5cd42f 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 70f68990c..dd1bc0248 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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": { diff --git a/packages/vue/package.json b/packages/vue/package.json index f7cbd1230..b84b52f60 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/vue", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "type": "module", "imports": { diff --git a/scripts/check-package-metadata.ts b/scripts/check-package-metadata.ts index 068f52751..b55928a6e 100644 --- a/scripts/check-package-metadata.ts +++ b/scripts/check-package-metadata.ts @@ -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 ?? []) } } diff --git a/tests/engine/io/fig/import/boolean-operation.test.ts b/tests/engine/io/fig/import/boolean-operation.test.ts index 47323b8e1..0396a1e98 100644 --- a/tests/engine/io/fig/import/boolean-operation.test.ts +++ b/tests/engine/io/fig/import/boolean-operation.test.ts @@ -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( { diff --git a/tests/engine/io/fig/import/legacy/transforms.test.ts b/tests/engine/io/fig/import/legacy/transforms.test.ts index 95cef49b1..f1bfea76c 100644 --- a/tests/engine/io/fig/import/legacy/transforms.test.ts +++ b/tests/engine/io/fig/import/legacy/transforms.test.ts @@ -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) + }) }) diff --git a/tests/engine/render/canvas/boolean.test.ts b/tests/engine/render/canvas/boolean.test.ts index 62f1b02cf..907bf6572 100644 --- a/tests/engine/render/canvas/boolean.test.ts +++ b/tests/engine/render/canvas/boolean.test.ts @@ -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()