paintress 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/README.zh-CN.md +24 -3
- package/dist/cli.js +342 -16
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CbYe9rII.js → render-CzpO1cOe.js} +65 -8
- package/dist/{server-C4TC8Bw3.js → server-vPylVJZf.js} +163 -18
- package/package.json +3 -2
- package/skills/paintress/SKILL.md +35 -8
- package/ui/dist/assets/index-Dyrrkh92.css +1 -0
- package/ui/dist/assets/index-kPX0SDa0.js +8 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-WQnLS4sD.js +0 -6
- package/ui/dist/assets/index-lyiZ9W-B.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
@@ -410,6 +410,19 @@ const IconSchema = z.object({
|
|
|
410
410
|
weight: NumOrVar.optional(),
|
|
411
411
|
fill: z.unknown().optional()
|
|
412
412
|
}).passthrough();
|
|
413
|
+
/**
|
|
414
|
+
* icon_font:上游设计工具的图标节点写法(`iconFontName` + `iconFontFamily`)。
|
|
415
|
+
* 语义与 `icon`(`icon` + `library`)等价,仅字段名不同;渲染与校验按同一套处理。
|
|
416
|
+
*/
|
|
417
|
+
const IconFontSchema = z.object({
|
|
418
|
+
type: z.literal("icon_font"),
|
|
419
|
+
...NodeBase,
|
|
420
|
+
...sizeBlock,
|
|
421
|
+
iconFontFamily: z.string().optional(),
|
|
422
|
+
iconFontName: StringOrVar.optional(),
|
|
423
|
+
weight: NumOrVar.optional(),
|
|
424
|
+
fill: z.unknown().optional()
|
|
425
|
+
}).passthrough();
|
|
413
426
|
const ScriptSchema = z.object({
|
|
414
427
|
type: z.literal("script"),
|
|
415
428
|
...NodeBase,
|
|
@@ -458,11 +471,12 @@ const KNOWN_NODE_TYPES = [
|
|
|
458
471
|
"text",
|
|
459
472
|
"note",
|
|
460
473
|
"icon",
|
|
474
|
+
"icon_font",
|
|
461
475
|
"script",
|
|
462
476
|
"browser",
|
|
463
477
|
"ref"
|
|
464
478
|
];
|
|
465
|
-
/** 完整节点 union:已知
|
|
479
|
+
/** 完整节点 union:已知 13 种 + 未知兜底。 */
|
|
466
480
|
const UnknownNodeSchema = z.object({
|
|
467
481
|
type: z.string(),
|
|
468
482
|
id: z.string().optional()
|
|
@@ -477,6 +491,7 @@ const NodeSchema = z.union([
|
|
|
477
491
|
TextSchema,
|
|
478
492
|
NoteSchema,
|
|
479
493
|
IconSchema,
|
|
494
|
+
IconFontSchema,
|
|
480
495
|
ScriptSchema,
|
|
481
496
|
BrowserSchema,
|
|
482
497
|
RefSchema,
|
|
@@ -493,6 +508,7 @@ const SCHEMA_BY_TYPE = {
|
|
|
493
508
|
text: TextSchema,
|
|
494
509
|
note: NoteSchema,
|
|
495
510
|
icon: IconSchema,
|
|
511
|
+
icon_font: IconFontSchema,
|
|
496
512
|
script: ScriptSchema,
|
|
497
513
|
browser: BrowserSchema,
|
|
498
514
|
ref: RefSchema
|
|
@@ -531,6 +547,12 @@ const XPaintress = z.object({
|
|
|
531
547
|
}).passthrough();
|
|
532
548
|
const X_PAINTRESS_KEY = "x-paintress";
|
|
533
549
|
const PTR_SPEC_VERSION = "1";
|
|
550
|
+
/**
|
|
551
|
+
* 新文档写入的 `version` 字段值。
|
|
552
|
+
* 必须与 `format/spec.yaml§meta.layers.pen_profile.observed_document_version` 同步
|
|
553
|
+
* (`test/spec-conformance.spec.ts` 会断言两者相等)。
|
|
554
|
+
*/
|
|
555
|
+
const PEN_DOC_VERSION = "2.22";
|
|
534
556
|
/** 文档根。passthrough:fileToken 与未来未知顶层键全部透传。 */
|
|
535
557
|
const DocumentObjectSchema = z.object({
|
|
536
558
|
version: z.string(),
|
|
@@ -715,7 +737,18 @@ function saveDoc(path, doc, opts = {}) {
|
|
|
715
737
|
const text = JSON.stringify(out, null, 2);
|
|
716
738
|
const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`);
|
|
717
739
|
writeFileSync(tmp, text, "utf8");
|
|
718
|
-
|
|
740
|
+
if (opts.createOnly) {
|
|
741
|
+
try {
|
|
742
|
+
linkSync(tmp, path);
|
|
743
|
+
} catch (cause) {
|
|
744
|
+
try {
|
|
745
|
+
unlinkSync(tmp);
|
|
746
|
+
} catch {}
|
|
747
|
+
if (cause.code === "EEXIST") throw new Error(`文件已存在,拒绝覆盖:${path}`);
|
|
748
|
+
throw cause;
|
|
749
|
+
}
|
|
750
|
+
unlinkSync(tmp);
|
|
751
|
+
} else renameSync(tmp, path);
|
|
719
752
|
return statSync(path).mtimeMs;
|
|
720
753
|
}
|
|
721
754
|
/** round-trip 序列化(不落盘):用于 diff、快照与测试 */
|
|
@@ -1486,11 +1519,15 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1486
1519
|
css.push(...textToCss(node, vars, warnings));
|
|
1487
1520
|
inner = escapeHtml(typeof node["content"] === "string" ? vars.resolve(node["content"]) : "");
|
|
1488
1521
|
break;
|
|
1489
|
-
case "icon":
|
|
1522
|
+
case "icon":
|
|
1523
|
+
case "icon_font": {
|
|
1490
1524
|
const colorCss = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color");
|
|
1491
1525
|
if (colorCss) css.push(["color", colorCss[1]], ["background", "transparent"]);
|
|
1492
|
-
const
|
|
1493
|
-
const
|
|
1526
|
+
const fontVariant = type === "icon_font";
|
|
1527
|
+
const libField = fontVariant ? node["iconFontFamily"] : node["library"];
|
|
1528
|
+
const nameField = fontVariant ? node["iconFontName"] : node["icon"];
|
|
1529
|
+
const lib = typeof libField === "string" && libField !== "" ? libField : "lucide";
|
|
1530
|
+
const iconName = typeof nameField === "string" ? vars.resolve(nameField) : "";
|
|
1494
1531
|
let svg = null;
|
|
1495
1532
|
if (lib === "lucide" || lib === "feather") svg = getLucideIcon(iconName, warnings);
|
|
1496
1533
|
else warnings.push(`${label}: 图标库 "${lib}" 暂只支持 lucide/feather,渲染为占位框`);
|
|
@@ -1550,6 +1587,7 @@ const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
|
|
|
1550
1587
|
"text",
|
|
1551
1588
|
"note",
|
|
1552
1589
|
"icon",
|
|
1590
|
+
"icon_font",
|
|
1553
1591
|
"script",
|
|
1554
1592
|
"browser"
|
|
1555
1593
|
]);
|
|
@@ -1568,6 +1606,23 @@ function computeCanvas(nodes, vars) {
|
|
|
1568
1606
|
height: Math.max(1, Math.ceil(maxH))
|
|
1569
1607
|
};
|
|
1570
1608
|
}
|
|
1609
|
+
function boardBoxes(nodes, vars) {
|
|
1610
|
+
const out = [];
|
|
1611
|
+
for (const n of nodes) {
|
|
1612
|
+
if (!isRecord(n)) continue;
|
|
1613
|
+
const box = nodeBox(n, vars);
|
|
1614
|
+
const name = typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : void 0;
|
|
1615
|
+
out.push({
|
|
1616
|
+
id: typeof n["id"] === "string" ? n["id"] : "",
|
|
1617
|
+
...name !== void 0 ? { name } : {},
|
|
1618
|
+
x: box.x,
|
|
1619
|
+
y: box.y,
|
|
1620
|
+
w: box.w,
|
|
1621
|
+
h: box.h
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return out;
|
|
1625
|
+
}
|
|
1571
1626
|
/** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
|
|
1572
1627
|
function renderTree(nodes, vars, warnings) {
|
|
1573
1628
|
return nodes.filter(isRecord).map((n) => renderNode(n, "none", vars, warnings, boardAttrs(n, vars))).join("");
|
|
@@ -1583,6 +1638,7 @@ function renderDocument(doc, opts = {}) {
|
|
|
1583
1638
|
const vars = makeVarResolver(doc, warnings);
|
|
1584
1639
|
const expanded = expandRefs(doc, warnings);
|
|
1585
1640
|
const canvas = computeCanvas(expanded, vars);
|
|
1641
|
+
const boards = boardBoxes(expanded, vars);
|
|
1586
1642
|
const title = opts.title ?? "paintress render";
|
|
1587
1643
|
const body = renderTree(expanded, vars, warnings);
|
|
1588
1644
|
return {
|
|
@@ -1607,7 +1663,8 @@ function renderDocument(doc, opts = {}) {
|
|
|
1607
1663
|
""
|
|
1608
1664
|
].join("\n"),
|
|
1609
1665
|
warnings,
|
|
1610
|
-
canvas
|
|
1666
|
+
canvas,
|
|
1667
|
+
boards
|
|
1611
1668
|
};
|
|
1612
1669
|
}
|
|
1613
|
-
export {
|
|
1670
|
+
export { RefSchema as $, genId as A, StrokeAlign as At, EllipseSchema as B, CornerRadius as Bt, update as C, GradientType as Ct, serializeDoc as D, MainAxis as Dt, saveDoc as E, LayoutDir as Et, PtrDocumentSchema as F, TextGrowth as Ft, KNOWN_NODE_TYPES as G, VARIABLE_NAME as Gt, GroupSchema as H, Padding as Ht, VariableDef as I, Affine as It, NodeSchema as J, LayoutBlock as K, VariableRef as Kt, XPaintress as L, BoolOrVar as Lt, DocumentSchema as M, StrokeJoin as Mt, PEN_DOC_VERSION as N, TextAlign as Nt, validateDoc as O, PositionMode as Ot, PTR_SPEC_VERSION as P, TextAlignVertical as Pt, RectangleSchema as Q, X_PAINTRESS_KEY as R, Color as Rt, remove as S, FillRule as St, loadDoc as T, ImageFit as Tt, IconFontSchema as U, Sizing as Ut, FrameSchema as V, NumOrVar as Vt, IconSchema as W, StringOrVar as Wt, PathSchema as X, NoteSchema as Y, PolygonSchema as Z, buildIndex as _, SolidFill as _t, escapeHtml as a, BlurEffect as at, move as b, BlendMode as bt, commonToCss as c, FillEntry as ct, fillsToCss as d, GraphicsBlock as dt, ScriptSchema as et, strokeToCss as f, ImageFill as ft, batch as g, ShadowEffect as gt, makeVarResolver as h, ShaderFill as ht, escapeAttr as i, BackgroundBlurEffect as it, DocumentObjectSchema as j, StrokeCap as jt, collectIds as k, ShadowKind as kt, cornerRadiusToCss as l, Fills as lt, expandRefs as m, ShaderEffect as mt, boardBoxes as n, UnknownNodeSchema as nt, renderTree as o, EffectEntry as ot, textToCss as p, MeshGradientFill as pt, NodeBase as q, VERSION as qt, computeCanvas as r, schemaForType as rt, getLucideIcon as s, Effects as st, renderDocument as t, TextSchema as tt, effectsToCss as u, GradientFill as ut, get as v, StrokeBlock as vt, FileChangedError as w, IconLibrary as wt, query as x, CrossAxis as xt, insert as y, StrokeWidth as yt, BrowserSchema as z, ColorOrVar as zt };
|
|
@@ -1,13 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as genId, C as update, E as saveDoc, M as DocumentSchema, N as PEN_DOC_VERSION, O as validateDoc, S as remove, T as loadDoc, b as move, g as batch, k as collectIds, qt as VERSION, t as renderDocument, v as get, x as query, y as insert } from "./render-CzpO1cOe.js";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { chromium } from "playwright-core";
|
|
7
8
|
import { connect } from "node:net";
|
|
8
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
// src/doc/create.ts
|
|
13
|
+
function createDoc(opts = {}) {
|
|
14
|
+
const boardId = opts.boardId ?? genId(/* @__PURE__ */ new Set());
|
|
15
|
+
const board = {
|
|
16
|
+
type: "frame",
|
|
17
|
+
id: boardId,
|
|
18
|
+
name: opts.name ?? "Board",
|
|
19
|
+
x: 0,
|
|
20
|
+
y: 0,
|
|
21
|
+
width: opts.width ?? 900,
|
|
22
|
+
height: opts.height ?? 600,
|
|
23
|
+
fill: opts.fill ?? "#FFFFFF"
|
|
24
|
+
};
|
|
25
|
+
const layout = opts.layout ?? "vertical";
|
|
26
|
+
if (layout !== "none") {
|
|
27
|
+
board["layout"] = layout;
|
|
28
|
+
board["gap"] = opts.gap ?? 0;
|
|
29
|
+
board["padding"] = opts.padding ?? 0;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
doc: DocumentSchema.parse({
|
|
33
|
+
version: PEN_DOC_VERSION,
|
|
34
|
+
fileToken: opts.fileToken ?? randomUUID(),
|
|
35
|
+
variables: {},
|
|
36
|
+
children: [board]
|
|
37
|
+
}),
|
|
38
|
+
boardId
|
|
39
|
+
};
|
|
40
|
+
}
|
|
11
41
|
// src/shot/screenshot.ts
|
|
12
42
|
/**
|
|
13
43
|
* 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
|
|
@@ -36,12 +66,51 @@ function findBrowser() {
|
|
|
36
66
|
}
|
|
37
67
|
return null;
|
|
38
68
|
}
|
|
39
|
-
|
|
69
|
+
function boardLabel(b) {
|
|
70
|
+
return b.name !== void 0 && b.name !== "" ? `${b.name}(id=${b.id})` : b.id;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 画板选择子:id 精确 → name 精确 → name/id 模糊(不区分大小写)。
|
|
74
|
+
* 模糊命中多个时报歧义并列出候选,不猜——猜错会让 AI 以为看到了另一页。
|
|
75
|
+
*/
|
|
76
|
+
function selectBoard(boards, query) {
|
|
77
|
+
if (boards.length === 0) throw new Error("设计稿没有画板(顶层节点为空),无法按画板截图");
|
|
78
|
+
const q = query.trim();
|
|
79
|
+
if (q === "") throw new Error("board 参数不能为空");
|
|
80
|
+
const byId = boards.filter((b) => b.id === q);
|
|
81
|
+
if (byId.length === 1) return byId[0];
|
|
82
|
+
const byName = boards.filter((b) => b.name === q);
|
|
83
|
+
if (byName.length === 1) return byName[0];
|
|
84
|
+
const lower = q.toLowerCase();
|
|
85
|
+
const fuzzy = boards.filter((b) => b.id.toLowerCase().includes(lower) || (b.name ?? "").toLowerCase().includes(lower));
|
|
86
|
+
if (fuzzy.length === 1) return fuzzy[0];
|
|
87
|
+
const all = boards.map(boardLabel).join("、");
|
|
88
|
+
if (fuzzy.length === 0) throw new Error(`没有匹配「${q}」的画板;可选:${all}`);
|
|
89
|
+
throw new Error(`「${q}」命中 ${fuzzy.length} 个画板,请写更精确的名字或 id:${fuzzy.map(boardLabel).join("、")}`);
|
|
90
|
+
}
|
|
91
|
+
/** 渲染并截图整张画布;指定 board 时只裁该画板(走同一套渲染与浏览器) */
|
|
40
92
|
async function screenshotPen(penPath, outPng, opts = {}) {
|
|
41
93
|
const browserPath = findBrowser();
|
|
42
94
|
if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
|
|
43
95
|
const loaded = loadDoc(penPath);
|
|
44
96
|
const rendered = renderDocument(loaded.doc);
|
|
97
|
+
const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
|
|
98
|
+
if (target !== void 0 && (target.w <= 0 || target.h <= 0)) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${target.w}×${target.h}),无法截图`);
|
|
99
|
+
const viewport = {
|
|
100
|
+
width: Math.max(rendered.canvas.width, target !== void 0 ? Math.ceil(target.x + target.w) : 0),
|
|
101
|
+
height: Math.max(rendered.canvas.height, target !== void 0 ? Math.ceil(target.y + target.h) : 0)
|
|
102
|
+
};
|
|
103
|
+
const clip = target !== void 0 ? {
|
|
104
|
+
x: target.x,
|
|
105
|
+
y: target.y,
|
|
106
|
+
width: Math.ceil(target.w),
|
|
107
|
+
height: Math.ceil(target.h)
|
|
108
|
+
} : {
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0,
|
|
111
|
+
width: rendered.canvas.width,
|
|
112
|
+
height: rendered.canvas.height
|
|
113
|
+
};
|
|
45
114
|
const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
|
|
46
115
|
const htmlPath = join(tmpDir, "page.html");
|
|
47
116
|
writeFileSync(htmlPath, rendered.html, "utf8");
|
|
@@ -52,22 +121,14 @@ async function screenshotPen(penPath, outPng, opts = {}) {
|
|
|
52
121
|
});
|
|
53
122
|
try {
|
|
54
123
|
const page = await browser.newPage({
|
|
55
|
-
viewport
|
|
56
|
-
width: rendered.canvas.width,
|
|
57
|
-
height: rendered.canvas.height
|
|
58
|
-
},
|
|
124
|
+
viewport,
|
|
59
125
|
deviceScaleFactor: opts.scale ?? 2
|
|
60
126
|
});
|
|
61
127
|
await page.goto(`file://${htmlPath}`);
|
|
62
128
|
await page.waitForTimeout(opts.settleMs ?? 120);
|
|
63
129
|
await page.screenshot({
|
|
64
130
|
path: outPng,
|
|
65
|
-
clip
|
|
66
|
-
x: 0,
|
|
67
|
-
y: 0,
|
|
68
|
-
width: rendered.canvas.width,
|
|
69
|
-
height: rendered.canvas.height
|
|
70
|
-
}
|
|
131
|
+
clip
|
|
71
132
|
});
|
|
72
133
|
} finally {
|
|
73
134
|
await browser.close();
|
|
@@ -79,12 +140,21 @@ async function screenshotPen(penPath, outPng, opts = {}) {
|
|
|
79
140
|
});
|
|
80
141
|
}
|
|
81
142
|
if (statSync(outPng).size === 0) throw new Error("截图产物为空文件");
|
|
82
|
-
|
|
143
|
+
const result = {
|
|
83
144
|
out: outPng,
|
|
84
145
|
canvas: rendered.canvas,
|
|
85
146
|
warnings: rendered.warnings,
|
|
86
147
|
browser: browserPath
|
|
87
148
|
};
|
|
149
|
+
if (target !== void 0) result.board = {
|
|
150
|
+
id: target.id,
|
|
151
|
+
...target.name !== void 0 ? { name: target.name } : {},
|
|
152
|
+
x: target.x,
|
|
153
|
+
y: target.y,
|
|
154
|
+
width: target.w,
|
|
155
|
+
height: target.h
|
|
156
|
+
};
|
|
157
|
+
return result;
|
|
88
158
|
}
|
|
89
159
|
// src/web/protocol.ts
|
|
90
160
|
function encodeLine(msg) {
|
|
@@ -357,7 +427,75 @@ function countByType(children) {
|
|
|
357
427
|
walk(children);
|
|
358
428
|
return counts;
|
|
359
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* 新建文档。path 不带扩展名时补 `.ptr`:新建一律默认标准格式,
|
|
432
|
+
* `.pen` 只有在调用方显式写扩展名时才产出(兼容旧工具链)。
|
|
433
|
+
*/
|
|
434
|
+
function newDoc(args) {
|
|
435
|
+
const raw = reqStr(args, "path");
|
|
436
|
+
const path = /\.(pen|ptr)$/i.test(raw) ? raw : `${raw}.ptr`;
|
|
437
|
+
const flavor = path.toLowerCase().endsWith(".pen") ? "pen" : "ptr";
|
|
438
|
+
if (existsSync(path) && optBool(args, "overwrite") !== true) throw new Error(`文件已存在,未覆盖:${path}(确需覆盖传 overwrite=true)`);
|
|
439
|
+
const width = optNum(args, "width");
|
|
440
|
+
const height = optNum(args, "height");
|
|
441
|
+
if (width !== void 0 && width <= 0) throw new Error(`width 必须大于 0,收到:${width}`);
|
|
442
|
+
if (height !== void 0 && height <= 0) throw new Error(`height 必须大于 0,收到:${height}`);
|
|
443
|
+
const layoutArg = optStr(args, "layout");
|
|
444
|
+
if (layoutArg !== void 0 && ![
|
|
445
|
+
"vertical",
|
|
446
|
+
"horizontal",
|
|
447
|
+
"none"
|
|
448
|
+
].includes(layoutArg)) throw new Error(`layout 仅支持 vertical / horizontal / none,收到:${layoutArg}`);
|
|
449
|
+
const { doc, boardId } = createDoc({
|
|
450
|
+
name: optStr(args, "name"),
|
|
451
|
+
width,
|
|
452
|
+
height,
|
|
453
|
+
fill: optStr(args, "fill"),
|
|
454
|
+
layout: layoutArg === "vertical" || layoutArg === "horizontal" || layoutArg === "none" ? layoutArg : void 0,
|
|
455
|
+
padding: optNum(args, "padding"),
|
|
456
|
+
gap: optNum(args, "gap")
|
|
457
|
+
});
|
|
458
|
+
const check = validateDoc(doc);
|
|
459
|
+
if (!check.ok) throw new Error(`新建文档结构校验失败,未落盘:\n${check.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
460
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
461
|
+
saveDoc(path, doc, {
|
|
462
|
+
flavor,
|
|
463
|
+
createOnly: optBool(args, "overwrite") !== true
|
|
464
|
+
});
|
|
465
|
+
const board = doc.children[0];
|
|
466
|
+
return {
|
|
467
|
+
ok: true,
|
|
468
|
+
path,
|
|
469
|
+
flavor,
|
|
470
|
+
version: doc.version,
|
|
471
|
+
boardId,
|
|
472
|
+
canvas: {
|
|
473
|
+
width: Number(board?.["width"] ?? 0),
|
|
474
|
+
height: Number(board?.["height"] ?? 0)
|
|
475
|
+
},
|
|
476
|
+
warnings: check.warnings
|
|
477
|
+
};
|
|
478
|
+
}
|
|
360
479
|
const TOOLS = {
|
|
480
|
+
paintress_new: {
|
|
481
|
+
description: "新建文档(.ptr 是 paintress 的标准格式;.pen 仅作兼容)。创建一个带空 board 的最小可用文档,可直接 render/screenshot 再逐层 insert;已存在同名文件时报错,除非 overwrite=true。path 不带扩展名时自动补 .ptr",
|
|
482
|
+
inputSchema: {
|
|
483
|
+
path: z.string().describe("新文档路径;不带扩展名时自动补 .ptr"),
|
|
484
|
+
name: z.string().optional().describe("board 名称(默认 Board)"),
|
|
485
|
+
width: z.number().positive().optional().describe("board 宽(默认 900)"),
|
|
486
|
+
height: z.number().positive().optional().describe("board 高(默认 600)"),
|
|
487
|
+
fill: z.string().optional().describe("board 背景色(默认 #FFFFFF)"),
|
|
488
|
+
layout: z.enum([
|
|
489
|
+
"vertical",
|
|
490
|
+
"horizontal",
|
|
491
|
+
"none"
|
|
492
|
+
]).optional().describe("子节点布局(默认 vertical)"),
|
|
493
|
+
padding: z.number().optional().describe("board 内边距(默认 0)"),
|
|
494
|
+
gap: z.number().optional().describe("主轴向间距(默认 0)"),
|
|
495
|
+
overwrite: z.boolean().optional().describe("已存在时覆盖(默认 false)")
|
|
496
|
+
},
|
|
497
|
+
run: (a) => newDoc(a)
|
|
498
|
+
},
|
|
361
499
|
paintress_doc_info: {
|
|
362
500
|
description: "文档摘要:版本、格式风味、节点统计、变量表、顶层节点",
|
|
363
501
|
inputSchema: { path: z.string().describe("设计稿路径") },
|
|
@@ -540,19 +678,25 @@ const TOOLS = {
|
|
|
540
678
|
}
|
|
541
679
|
},
|
|
542
680
|
paintress_screenshot: {
|
|
543
|
-
description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER
|
|
681
|
+
description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER 指向的可执行文件。指定 board 时只截该画板(顶层 frame,即“一页”),而不是整张画布",
|
|
544
682
|
inputSchema: {
|
|
545
683
|
path: z.string(),
|
|
546
684
|
out: z.string().describe("输出 .png 路径"),
|
|
547
|
-
scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2")
|
|
685
|
+
scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2"),
|
|
686
|
+
board: z.string().optional().describe("只截某个画板:id 或 name;模糊命中多个时会报错并列出候选")
|
|
548
687
|
},
|
|
549
688
|
run: async (a) => {
|
|
550
689
|
if (!findBrowser()) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER");
|
|
551
|
-
const
|
|
690
|
+
const board = typeof a["board"] === "string" ? a["board"] : "";
|
|
691
|
+
const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), {
|
|
692
|
+
...typeof a["scale"] === "number" ? { scale: a["scale"] } : {},
|
|
693
|
+
...board !== "" ? { board } : {}
|
|
694
|
+
});
|
|
552
695
|
return {
|
|
553
696
|
ok: true,
|
|
554
697
|
out: res.out,
|
|
555
698
|
canvas: res.canvas,
|
|
699
|
+
...res.board !== void 0 ? { board: res.board } : {},
|
|
556
700
|
browser: res.browser,
|
|
557
701
|
warnings: res.warnings
|
|
558
702
|
};
|
|
@@ -614,12 +758,13 @@ async function ensureDaemon(opts = {}) {
|
|
|
614
758
|
async function autoRegister(cwd = process.cwd()) {
|
|
615
759
|
if (process.env["PAINTRESS_NO_AUTO_WEB"] === "1") return null;
|
|
616
760
|
try {
|
|
617
|
-
const { started } = await ensureDaemon();
|
|
761
|
+
const { started } = await ensureDaemon({ idleTimeoutMs: 36e5 });
|
|
618
762
|
const project = deriveProjectName(cwd);
|
|
619
763
|
const watched = await sendRequest({
|
|
620
764
|
kind: "watch",
|
|
621
765
|
project,
|
|
622
|
-
dir: cwd
|
|
766
|
+
dir: cwd,
|
|
767
|
+
owner: "agent"
|
|
623
768
|
}, 8e3);
|
|
624
769
|
if (!watched.ok) throw new Error(watched.error);
|
|
625
770
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paintress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Design-document operation layer for AI agents: read, query, edit, render",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"verify:pkg": "node scripts/verify-package.mjs",
|
|
39
39
|
"prepack": "pnpm build && pnpm verify:pkg",
|
|
40
40
|
"build:ui": "pnpm --dir ui build",
|
|
41
|
-
"dev:ui": "pnpm --dir ui dev"
|
|
41
|
+
"dev:ui": "pnpm --dir ui dev",
|
|
42
|
+
"release": "node ./scripts/release.mjs"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: paintress
|
|
3
|
-
description:
|
|
3
|
+
description: Create, read, query, edit, render and screenshot `.ptr` (paintress standard format) and `.pen` (compatible) design documents through MCP tools, and drive the local viewer. Use when the user refers to a design file, wants a new design file created, a node in it, a board screenshot, or shorthand ids like #1.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# paintress
|
|
7
7
|
|
|
8
|
-
paintress is a design-document operation layer: JSON design files
|
|
8
|
+
paintress is a design-document operation layer: JSON design files in, MCP tools and a CLI out. It does not draw anything itself — it reads and writes the file, and renders it to HTML for a browser.
|
|
9
|
+
|
|
10
|
+
## Formats
|
|
11
|
+
|
|
12
|
+
- **`.ptr` is paintress's standard format.** Create this by default for anything new. It carries the `x-paintress` block (`spec`, `generator`, timestamps) and is the layer that evolves.
|
|
13
|
+
- **`.pen` is compatible**: read, queried, edited and rendered exactly the same way. Do not convert a `.pen` the user is still working with — edit it in place.
|
|
14
|
+
- Both render identically; the difference is only which layer owns future format changes.
|
|
9
15
|
|
|
10
16
|
## Check before installing
|
|
11
17
|
|
|
@@ -20,6 +26,7 @@ If either prints something, **do not install again**. Use the existing binary. `
|
|
|
20
26
|
|
|
21
27
|
| Tool | What it does |
|
|
22
28
|
| --- | --- |
|
|
29
|
+
| `paintress_new` | **create a document.** Writes a valid `.ptr` with one empty board; returns the `boardId`. Refuses to overwrite; `path` without an extension gets `.ptr` |
|
|
23
30
|
| `paintress_doc_info` | document summary: version, format flavor, node counts, variables, top-level nodes |
|
|
24
31
|
| `paintress_query` | find nodes by type / name (exact or regex) / id / reusable |
|
|
25
32
|
| `paintress_get` | fetch one node's subtree; `depth` controls how far down |
|
|
@@ -30,16 +37,21 @@ If either prints something, **do not install again**. Use the existing binary. `
|
|
|
30
37
|
| `paintress_batch` | several operations in one call |
|
|
31
38
|
| `paintress_validate` | check the document against the format spec |
|
|
32
39
|
| `paintress_render` | write the canvas as a standalone HTML file |
|
|
33
|
-
| `paintress_screenshot` | render to PNG (headless Chromium) |
|
|
40
|
+
| `paintress_screenshot` | render to PNG (headless Chromium); pass `board` (id or name) to capture one board instead of the whole canvas |
|
|
34
41
|
| `paintress_marks` | read the user's click markers (`#1 → node id`) |
|
|
35
42
|
|
|
36
43
|
## Working rules
|
|
37
44
|
|
|
38
|
-
1. **
|
|
39
|
-
2.
|
|
40
|
-
3.
|
|
41
|
-
4. **
|
|
42
|
-
5. **
|
|
45
|
+
1. **Nothing to edit? Create it.** `paintress_new` is the only way to make a file; there is no "empty document" state to work around, and no need to hand-write JSON.
|
|
46
|
+
2. **Query before you edit.** Node ids come from the file; never invent them.
|
|
47
|
+
3. **`#N` means a marker.** When the user writes `#1`, call `paintress_marks` and map `#1` to a real node id. Markers are per viewer session.
|
|
48
|
+
4. **Top-level nodes are boards.** Use `paintress_doc_info` to list them before guessing what "the second screen" means.
|
|
49
|
+
5. **After a batch, validate.** `paintress_validate` catches structural mistakes before the user opens the file.
|
|
50
|
+
6. **Screenshots are for you.** `paintress_screenshot` is how you look at the design; the HTML viewer is for the human. When a document has several boards, pass `board` to get just that page — an ambiguous name errors out and lists the candidates rather than guessing.
|
|
51
|
+
|
|
52
|
+
## Node fields worth knowing
|
|
53
|
+
|
|
54
|
+
`type` and `id` are immutable after creation. Text nodes hold their string in **`content`** (not `text`). `update` is a shallow merge and does **not** validate field names — a typo silently creates a new key and the render does not change. When unsure of a field name, `paintress_get` the node and read the real one.
|
|
43
55
|
|
|
44
56
|
## Viewer
|
|
45
57
|
|
|
@@ -51,3 +63,18 @@ paintress watch <name> <dir> # register a directory
|
|
|
51
63
|
```
|
|
52
64
|
|
|
53
65
|
Ask the user to open the URL and click the nodes they mean; then read `paintress_marks` to learn which ids those clicks became.
|
|
66
|
+
|
|
67
|
+
## Bind the project yourself — do not wait to be asked
|
|
68
|
+
|
|
69
|
+
When the user refers to a design, asks to see one, or asks to draw something in this project, **you** make it viewable. The MCP server auto-registers the working directory at startup (its name is derived from the last two path segments, e.g. `acme-shop`); if that already covers the request, just hand over the viewer URL:
|
|
70
|
+
|
|
71
|
+
- viewer: `http://localhost:3316/`
|
|
72
|
+
- deep link to a file: `http://localhost:3316/?project=<name>&file=<relative/path>`
|
|
73
|
+
|
|
74
|
+
If the design lives elsewhere (a `.pen`/`.ptr` outside the working directory, or a second project), bind it yourself:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
paintress watch <name> <dir> [--depth 1..6]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Prefer the same auto-derived name when it applies, so the URL stays stable. Never ask the user to run `watch` — the goal is that "look at / draw a design" resolves to a viewable URL with zero extra steps. The project list in any open viewer window refreshes on its own once the registration lands.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#f6f1ea;--panel:#fffcf8;--border:#e6ded2;--text:#1f2a37;--muted:#7a7166;--accent:#205080;--accent-soft:#20508014;--gold:#908030;--gold-soft:#90803073;--radius:8px}*{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%}body{color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased;font:14px/1.55 system-ui,-apple-system,Segoe UI,Noto Sans SC,sans-serif}.app{grid-template-columns:280px 1fr;height:100%;display:grid}.sidebar{background:var(--panel);border-right:1px solid var(--border);flex-direction:column;min-height:0;display:flex}.sidebar-head{border-bottom:1px solid var(--border);align-items:center;gap:8px;padding:16px 16px 12px;display:flex}.sidebar-head h1{letter-spacing:-.01em;color:var(--accent);font-size:15px;font-weight:650}.badge{color:var(--muted);background:var(--bg);border-radius:999px;padding:1px 6px;font-size:11px}.badge-stack{font:inherit;cursor:pointer;border:1px solid #0000;flex-direction:column;align-items:flex-start;gap:1px;font-size:11px;line-height:1.2;display:inline-flex}.badge-stack:hover{border-color:#b4402a59}.badge-new{color:#b4402a;font-weight:700}.prompt{background:var(--bg);border:1px solid var(--border);font:inherit;white-space:pre-wrap;word-break:break-word;-webkit-user-select:all;user-select:all;border-radius:6px;margin:10px 0 8px;padding:8px 10px;font-size:12px;line-height:1.6}a.ghost{text-decoration:none;display:inline-block}.section{min-height:0;padding:12px 8px 4px;overflow-y:auto}.section.files{border-top:1px solid var(--border);flex:1;padding-top:12px}.section h2{text-transform:uppercase;letter-spacing:.06em;color:var(--muted);justify-content:space-between;gap:8px;padding:0 8px 8px;font-size:11px;font-weight:600;display:flex}.hint{color:var(--muted);text-transform:none;letter-spacing:0;font-size:11px;font-weight:400}.list{list-style:none}.item{text-align:left;cursor:pointer;width:100%;font:inherit;color:inherit;background:0 0;border:0;border-radius:6px;flex-direction:column;gap:1px;padding:7px 8px;display:flex}.item:hover{background:var(--bg)}.item.active{background:var(--accent-soft);color:var(--accent)}.item-name{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.item-meta{color:var(--muted);font-size:11px}.item.active .item-meta{color:var(--accent);opacity:.75}.empty{color:var(--muted);padding:4px 8px 12px;font-size:12px;line-height:1.7}.empty code{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:1px 5px;font-size:11px}.sidebar-foot{border-top:1px solid var(--border);color:var(--muted);flex-wrap:wrap;align-items:center;gap:8px;padding:10px 16px;font-size:12px;display:flex}.toggle{cursor:pointer;align-items:center;gap:5px;display:inline-flex}.ghost{background:var(--bg);border:1px solid var(--border);font:inherit;color:var(--text);cursor:pointer;border-radius:6px;padding:3px 9px;font-size:12px}.ghost:hover{border-color:var(--accent);color:var(--accent)}.stage{background:var(--canvas-bg,var(--bg));flex-direction:column;min-width:0;min-height:0;display:flex}.toolbar{border-bottom:1px solid var(--border);background:var(--panel);justify-content:space-between;align-items:center;gap:12px;padding:8px 14px;display:flex}.toolbar code{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.preview{border:1px solid var(--border);background:var(--canvas-bg,#fff);border-radius:10px;flex:1;width:calc(100% - 24px);min-height:0;margin:12px;box-shadow:0 1px 2px #1f2a370d,0 8px 24px #1f2a370d}.stage[data-canvas-bg=dark] .preview{border-color:#ffffff1f;box-shadow:0 1px 2px #00000059,0 10px 30px #00000052}.placeholder{color:var(--muted);flex:1;place-items:center;display:grid}.error{border-radius:var(--radius);color:#b91c1c;white-space:pre-wrap;background:#fef2f2;border:1px solid #fecaca;margin:12px;padding:10px 12px;font-size:12px}.section.marks{border-top:1px solid var(--border);flex:0 auto;max-height:34vh}.mark-no{background:var(--accent-soft);min-width:20px;color:var(--accent);font-variant-numeric:tabular-nums;border-radius:4px;margin-right:4px;padding:0 4px;font-size:11px;display:inline-block}.item.active .mark-no{background:#fff}.ghost.tiny{text-transform:none;letter-spacing:0;padding:1px 6px;font-size:11px}.crumbs{flex-wrap:wrap;align-items:center;gap:3px;min-width:0;display:flex;overflow:hidden}.crumbs code{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.crumb{font:inherit;color:var(--muted);cursor:pointer;text-overflow:ellipsis;white-space:nowrap;background:0 0;border:0;border-radius:4px;max-width:220px;padding:2px 5px;font-size:12px;overflow:hidden}.crumb:hover{background:var(--bg);color:var(--text)}.crumb.active{color:var(--accent);background:var(--accent-soft)}.sep{color:var(--border);font-size:11px}.brand-mark{flex:none;width:auto;height:32px}.canvas-bg{-webkit-user-select:none;user-select:none;flex:none;align-items:center;gap:8px;display:inline-flex}.canvas-bg-label{letter-spacing:.04em;text-transform:uppercase;color:var(--muted);text-align:right;min-width:34px;font-size:11px;font-weight:500}.canvas-bg-track{width:84px;height:calc(var(--thumb) + var(--pad) * 2);cursor:pointer;touch-action:none;background:linear-gradient(#e7e0d5,#f3eee6 60%,#f7f2ea);border-radius:999px;position:relative;box-shadow:inset 0 1px 2px #1f2a373d,inset 0 -1px #ffffffe6,inset 0 0 0 1px #1f2a3712,0 1px #fff}.canvas-bg-track:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.canvas-bg-thumb{top:var(--pad);left:var(--pad);width:var(--thumb);height:var(--thumb);transform:translateX(calc(var(--pos) * var(--step)));background:linear-gradient(#fff,#f1ebe1);border-radius:50%;justify-content:center;align-items:center;transition:transform .17s cubic-bezier(.22,.61,.36,1);display:flex;position:absolute;box-shadow:0 1px 2px #1f2a374d,0 0 0 1px #1f2a3714,inset 0 1px #fff}.canvas-bg-track.dragging .canvas-bg-thumb{transition:none}.canvas-bg-dot{background:var(--dot,#fff);border-radius:50%;width:11px;height:11px;transition:background .17s;box-shadow:0 0 0 1px #1f2a3742,0 1px 1px #1f2a371f}.lang-switch{background:linear-gradient(#ece6dc,#f6f1e9);border-radius:999px;flex:none;align-items:center;gap:2px;margin-left:4px;padding:2px;display:inline-flex;box-shadow:inset 0 1px 2px #1f2a3729,inset 0 0 0 1px #1f2a370d}.lang-opt{font:inherit;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:2px 7px;font-size:11px;line-height:16px}.lang-opt:hover{color:var(--text)}.lang-opt.active{color:var(--accent);background:linear-gradient(#fffdf9,#f3ede4);font-weight:600;box-shadow:0 1px 2px #1f2a372e,inset 0 1px #fff}.tool-sep{background:var(--border);flex:none;width:1px;height:18px;margin:0 2px}.tools{flex:none;align-items:center;gap:6px;display:flex}.zoom{font-variant-numeric:tabular-nums;text-align:right;min-width:40px}.boardbar{border-bottom:1px solid var(--border);background:var(--panel);align-items:center;gap:6px;padding:6px 14px;display:flex;overflow-x:auto}.board{background:var(--bg);border:1px solid var(--border);font:inherit;color:var(--text);cursor:pointer;border-radius:6px;flex:none;align-items:baseline;gap:6px;padding:3px 9px;font-size:12px;display:inline-flex}.board:hover{border-color:var(--accent);color:var(--accent)}.board.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.board-size{color:var(--muted);font-variant-numeric:tabular-nums;font-size:10px}.board.active .board-size{color:var(--accent);opacity:.75}.row{align-items:center;display:flex;position:relative}.row .item{flex:1;min-width:0}.row-action{background:var(--panel);width:20px;height:20px;color:var(--muted);font:inherit;cursor:pointer;opacity:0;border:1px solid #0000;border-radius:5px;place-items:center;padding:0;font-size:13px;line-height:1;transition:opacity .12s;display:grid;position:absolute;top:50%;right:4px;transform:translateY(-50%)}.row:hover .row-action,.row-action:focus-visible{opacity:1}.row-action:hover{color:#a33;border-color:#aa333359}@media (hover:none){.row-action{opacity:1}}.modal-backdrop{z-index:50;background:#1f2a3747;place-items:center;display:grid;position:fixed;inset:0}.modal{background:var(--panel);border:1px solid var(--border);border-radius:10px;width:min(380px,100vw - 32px);padding:16px 18px 14px;box-shadow:0 12px 40px #1f2a372e}.modal h3{color:var(--accent);margin-bottom:6px;font-size:14px;font-weight:650}.modal-body{color:var(--muted);font-size:12.5px;line-height:1.65}.modal-actions{justify-content:flex-end;gap:8px;margin-top:14px;display:flex}.ghost.danger{color:#a33}.ghost.danger:hover,.ghost.danger:focus-visible{color:#a33;background:#aa33330f;border-color:#a33}
|