paintress 0.5.1 → 0.6.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/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { It as Affine, it as BackgroundBlurEffect, bt as BlendMode, at as BlurEffect, Lt as BoolOrVar, z as BrowserSchema, Rt as Color, zt as ColorOrVar, Bt as CornerRadius, xt as CrossAxis, j as DocumentObjectSchema, M as DocumentSchema, ot as EffectEntry, st as Effects, B as EllipseSchema, w as FileChangedError, ct as FillEntry, St as FillRule, lt as Fills, V as FrameSchema, ut as GradientFill, Ct as GradientType, dt as GraphicsBlock, H as GroupSchema, U as IconFontSchema, wt as IconLibrary, W as IconSchema, ft as ImageFill, Tt as ImageFit, G as KNOWN_NODE_TYPES, K as LayoutBlock, Et as LayoutDir, Dt as MainAxis, pt as MeshGradientFill, q as NodeBase, J as NodeSchema, Y as NoteSchema, Vt as NumOrVar, N as PEN_DOC_VERSION, P as PTR_SPEC_VERSION, Ht as Padding, X as PathSchema, Z as PolygonSchema, Ot as PositionMode, F as PtrDocumentSchema, Q as RectangleSchema, $ as RefSchema, et as ScriptSchema, mt as ShaderEffect, ht as ShaderFill, gt as ShadowEffect, kt as ShadowKind, Ut as Sizing, _t as SolidFill, Wt as StringOrVar, At as StrokeAlign, vt as StrokeBlock, jt as StrokeCap, Mt as StrokeJoin, yt as StrokeWidth, Nt as TextAlign, Pt as TextAlignVertical, Ft as TextGrowth, tt as TextSchema, nt as UnknownNodeSchema, Gt as VARIABLE_NAME, I as VariableDef, Kt as VariableRef, L as XPaintress, R as X_PAINTRESS_KEY, g as batch, n as boardBoxes, _ as buildIndex, k as collectIds, c as commonToCss, r as computeCanvas, l as cornerRadiusToCss, u as effectsToCss, i as escapeAttr, a as escapeHtml, m as expandRefs, d as fillsToCss, A as genId, v as get, s as getLucideIcon, y as insert, T as loadDoc, h as makeVarResolver, b as move, x as query, S as remove, t as renderDocument, o as renderTree, E as saveDoc, rt as schemaForType, D as serializeDoc, f as strokeToCss, p as textToCss, C as update, O as validateDoc } from "./render-CzpO1cOe.js";
1
+ export { It as Affine, it as BackgroundBlurEffect, bt as BlendMode, at as BlurEffect, Lt as BoolOrVar, z as BrowserSchema, Rt as Color, zt as ColorOrVar, Bt as CornerRadius, xt as CrossAxis, j as DocumentObjectSchema, M as DocumentSchema, ot as EffectEntry, st as Effects, B as EllipseSchema, w as FileChangedError, ct as FillEntry, St as FillRule, lt as Fills, V as FrameSchema, ut as GradientFill, Ct as GradientType, dt as GraphicsBlock, H as GroupSchema, U as IconFontSchema, wt as IconLibrary, W as IconSchema, ft as ImageFill, Tt as ImageFit, G as KNOWN_NODE_TYPES, K as LayoutBlock, Et as LayoutDir, Dt as MainAxis, pt as MeshGradientFill, q as NodeBase, J as NodeSchema, Y as NoteSchema, Vt as NumOrVar, N as PEN_DOC_VERSION, P as PTR_SPEC_VERSION, Ht as Padding, X as PathSchema, Z as PolygonSchema, Ot as PositionMode, F as PtrDocumentSchema, Q as RectangleSchema, $ as RefSchema, et as ScriptSchema, mt as ShaderEffect, ht as ShaderFill, gt as ShadowEffect, kt as ShadowKind, Ut as Sizing, _t as SolidFill, Wt as StringOrVar, At as StrokeAlign, vt as StrokeBlock, jt as StrokeCap, Mt as StrokeJoin, yt as StrokeWidth, Nt as TextAlign, Pt as TextAlignVertical, Ft as TextGrowth, tt as TextSchema, nt as UnknownNodeSchema, Gt as VARIABLE_NAME, I as VariableDef, Kt as VariableRef, L as XPaintress, R as X_PAINTRESS_KEY, g as batch, n as boardBoxes, _ as buildIndex, k as collectIds, c as commonToCss, r as computeCanvas, l as cornerRadiusToCss, u as effectsToCss, i as escapeAttr, a as escapeHtml, m as expandRefs, d as fillsToCss, A as genId, v as get, s as getLucideIcon, y as insert, T as loadDoc, h as makeVarResolver, b as move, x as query, S as remove, t as renderDocument, o as renderTree, E as saveDoc, rt as schemaForType, D as serializeDoc, f as strokeToCss, p as textToCss, C as update, O as validateDoc } from "./render-BSf62388.js";
@@ -1 +1 @@
1
- export { t as createMcpServer, n as runStdioServer } from "../server-vPylVJZf.js";
1
+ export { t as createMcpServer, n as runStdioServer } from "../server-DZRNNWac.js";
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { copyFileSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- import { z } from "zod";
5
4
  import { basename, dirname, join } from "node:path";
5
+ import { z } from "zod";
6
6
  // src/shared/version.ts
7
7
  /**
8
8
  * 包版本:运行时从 package.json 读取,避免在多处硬编码后忘记同步。
@@ -597,7 +597,7 @@ function collectIds(children, out = []) {
597
597
  }
598
598
  // src/doc/validate.ts
599
599
  /** 结构校验(schema)+ 语义校验(重复 id、悬空 ref、未知类型、未解析变量) */
600
- function validateDoc(input) {
600
+ function validateDoc(input, opts = {}) {
601
601
  const errors = [];
602
602
  const warnings = [];
603
603
  const parsed = DocumentSchema.safeParse(input);
@@ -648,7 +648,13 @@ function validateDoc(input) {
648
648
  }
649
649
  };
650
650
  walk(doc.children, "children");
651
- const varNames = new Set(doc.variables ? Object.keys(doc.variables) : []);
651
+ const localNames = new Set(doc.variables ? Object.keys(doc.variables) : []);
652
+ const varNames = /* @__PURE__ */ new Set([...localNames, ...opts.knownVars ?? []]);
653
+ for (const name of opts.presetVars ?? []) if (localNames.has(name)) warnings.push({
654
+ level: "warning",
655
+ path: "<variables>",
656
+ message: `本地变量 $${name} 覆盖了项目预设(在查看器里调预设不会影响这份文件)`
657
+ });
652
658
  const varRefs = /* @__PURE__ */ new Set();
653
659
  const scan = (obj) => {
654
660
  if (typeof obj === "string" && VARIABLE_NAME.test(obj)) varRefs.add(obj.slice(1));
@@ -933,59 +939,92 @@ function isRecord$3(v) {
933
939
  function asVarValue(v) {
934
940
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
935
941
  }
936
- /** themed 列表 → 无主题项优先,否则首项;字面量直通 */
937
- function resolveThemed(v) {
942
+ /**
943
+ * themed 列表 → 按当前主题匹配。
944
+ * 规则:主题键全匹配的条目优先(越具体的胜出),其次无主题项,最后首项。
945
+ * 这样 `{value:'#202226', theme:{mode:'dark'}}` 在 dark 下胜出,
946
+ * 而只写了 `{density:'loose'}` 的尺寸条目在 compact 下不会误命中。
947
+ */
948
+ function resolveThemed(v, theme) {
938
949
  if (Array.isArray(v)) {
939
950
  const entries = v.filter(isRecord$3);
940
- const plain = entries.find((e) => e["theme"] === void 0) ?? entries[0];
941
- return plain ? asVarValue(plain["value"]) : void 0;
951
+ if (entries.length === 0) return void 0;
952
+ let best = null;
953
+ for (const entry of entries) {
954
+ const t = entry["theme"];
955
+ if (t === void 0) {
956
+ if (best === null) best = {
957
+ entry,
958
+ score: 0
959
+ };
960
+ continue;
961
+ }
962
+ if (!isRecord$3(t)) continue;
963
+ const keys = Object.keys(t);
964
+ if (!keys.every((k) => theme[k] === String(t[k]))) continue;
965
+ if (best === null || keys.length > best.score) best = {
966
+ entry,
967
+ score: keys.length
968
+ };
969
+ }
970
+ return best === null ? asVarValue(entries[0]?.["value"]) : asVarValue(best.entry["value"]);
942
971
  }
943
972
  return asVarValue(v);
944
973
  }
945
- function makeVarResolver(doc, warnings) {
974
+ function makeVarResolver(doc, warnings, theme = {}) {
946
975
  const vars = doc.variables ?? {};
947
- const lookup = (name) => {
948
- const def = vars[name];
949
- if (!isRecord$3(def)) return void 0;
950
- return resolveThemed(def["value"]);
951
- };
952
- return {
953
- resolve(input) {
954
- if (!VARIABLE_NAME.test(input)) return input;
955
- const val = lookup(input.slice(1));
956
- if (val === void 0) {
957
- warnings.push(`未定义变量 ${input},按原样输出`);
958
- return input;
959
- }
960
- return String(val);
961
- },
962
- resolveNum(input) {
963
- if (typeof input === "number") return input;
964
- if (typeof input === "string") {
965
- if (!VARIABLE_NAME.test(input)) {
966
- const n = Number(input);
967
- return Number.isFinite(n) ? n : void 0;
968
- }
969
- const val = lookup(input.slice(1));
970
- if (typeof val === "number") return val;
971
- warnings.push(`数字变量 ${input} 解析失败,按缺省处理`);
972
- return;
973
- }
974
- },
975
- resolveBool(input) {
976
- if (typeof input === "boolean") return input;
977
- if (typeof input === "string") {
978
- if (!VARIABLE_NAME.test(input)) return input === "true";
976
+ const make = (scope) => {
977
+ const lookup = (name) => {
978
+ const def = vars[name];
979
+ if (!isRecord$3(def)) return void 0;
980
+ return resolveThemed(def["value"], scope);
981
+ };
982
+ return {
983
+ resolve(input) {
984
+ if (!VARIABLE_NAME.test(input)) return input;
979
985
  const val = lookup(input.slice(1));
980
- if (typeof val === "boolean") return val;
981
- warnings.push(`布尔变量 ${input} 解析失败,按缺省处理`);
982
- return;
986
+ if (val === void 0) {
987
+ warnings.push(`未定义变量 ${input},按原样输出`);
988
+ return input;
989
+ }
990
+ return String(val);
991
+ },
992
+ resolveNum(input) {
993
+ if (typeof input === "number") return input;
994
+ if (typeof input === "string") {
995
+ if (!VARIABLE_NAME.test(input)) {
996
+ const n = Number(input);
997
+ return Number.isFinite(n) ? n : void 0;
998
+ }
999
+ const val = lookup(input.slice(1));
1000
+ if (typeof val === "number") return val;
1001
+ warnings.push(`数字变量 ${input} 解析失败,按缺省处理`);
1002
+ return;
1003
+ }
1004
+ },
1005
+ resolveBool(input) {
1006
+ if (typeof input === "boolean") return input;
1007
+ if (typeof input === "string") {
1008
+ if (!VARIABLE_NAME.test(input)) return input === "true";
1009
+ const val = lookup(input.slice(1));
1010
+ if (typeof val === "boolean") return val;
1011
+ warnings.push(`布尔变量 ${input} 解析失败,按缺省处理`);
1012
+ return;
1013
+ }
1014
+ },
1015
+ has(name) {
1016
+ return lookup(name) !== void 0;
1017
+ },
1018
+ theme: () => scope,
1019
+ withTheme(patch) {
1020
+ if (patch === void 0) return make(scope);
1021
+ const next = { ...scope };
1022
+ for (const [k, v] of Object.entries(patch)) if (typeof v === "string") next[k] = v;
1023
+ return make(next);
983
1024
  }
984
- },
985
- has(name) {
986
- return lookup(name) !== void 0;
987
- }
1025
+ };
988
1026
  };
1027
+ return make(theme);
989
1028
  }
990
1029
  // src/render/expand.ts
991
1030
  function isRecord$2(v) {
@@ -1109,16 +1148,26 @@ function stopsToCssList(colors, vars) {
1109
1148
  }
1110
1149
  return parts.length > 0 ? parts.join(", ") : void 0;
1111
1150
  }
1112
- /** .pen 逆时针度(0=上,90=左)→ CSS 角(0=上,顺时针正) */
1151
+ /**
1152
+ * .pen/.ptr 渐变角度 → CSS 角(度)。
1153
+ *
1154
+ * 基准:`0` = 从左往右(渐变方向指向右),正值顺时针(Figma 那套手感)。
1155
+ * 所以 0 → 90deg(to right)、90 → 0deg(to top)、180 → 270deg(to left)、270 → 180deg(to bottom)。
1156
+ *
1157
+ * 为什么不用「0 指向上」:设计稿里可见的渐变只剩「流光带」(透明→近白→透明,
1158
+ * 从左往右飘的光带),它只有按 0=向右 才是一条竖光带;按 0=向上 会变成横着的一大块。
1159
+ * 深色「渐隐覆盖」类节点是「同色→自身透明」,两条约定看不出差别,不必为它们拗。
1160
+ */
1113
1161
  function penAngleToCss(deg) {
1114
- return `${((360 - (deg % 360 + 360) % 360) % 360).toFixed(1)}deg`;
1162
+ return `${(((90 - deg) % 360 + 360) % 360).toFixed(1)}deg`;
1115
1163
  }
1116
- function normCenter(c) {
1164
+ function normCenter(c, vars) {
1117
1165
  if (isRecord$1(c)) {
1118
- const x = c["x"], y = c["y"];
1119
- if (typeof x === "number" && typeof y === "number") return {
1120
- x: `${(x * 100).toFixed(2)}%`,
1121
- y: `${(y * 100).toFixed(2)}%`
1166
+ const rx = vars?.resolveNum(c["x"]) ?? (typeof c["x"] === "number" ? c["x"] : void 0);
1167
+ const ry = vars?.resolveNum(c["y"]) ?? (typeof c["y"] === "number" ? c["y"] : void 0);
1168
+ if (rx !== void 0 && ry !== void 0) return {
1169
+ x: `${(rx * 100).toFixed(2)}%`,
1170
+ y: `${(ry * 100).toFixed(2)}%`
1122
1171
  };
1123
1172
  }
1124
1173
  return {
@@ -1126,6 +1175,20 @@ function normCenter(c) {
1126
1175
  y: "50%"
1127
1176
  };
1128
1177
  }
1178
+ /**
1179
+ * 径向渐变的椭圆直径(归一化到自身包围盒)→ CSS `ellipse <rx> <ry>`。
1180
+ * 缺省返回 null(用 CSS 默认的 farthest-corner);`{height:1}` 这类只写一边的,
1181
+ * 另一边按正方形处理(与 Pencil 的径向渐变一致)。
1182
+ */
1183
+ function normSize(size, vars) {
1184
+ if (!isRecord$1(size)) return null;
1185
+ const rw = vars.resolveNum(size["width"]);
1186
+ const rh = vars.resolveNum(size["height"]);
1187
+ if (rw === void 0 && rh === void 0) return null;
1188
+ const w = rw ?? rh ?? 1;
1189
+ const h = rh ?? rw ?? 1;
1190
+ return `${(w * 100).toFixed(2)}% ${(h * 100).toFixed(2)}%`;
1191
+ }
1129
1192
  /** fill 集 → background 系列声明。纯色取「最后绘制的」作 background-color(顶层语义) */
1130
1193
  function fillsToCss(fillsRaw, vars, warnings, what) {
1131
1194
  const entries = Array.isArray(fillsRaw) ? fillsRaw : fillsRaw !== void 0 ? [fillsRaw] : [];
@@ -1138,6 +1201,7 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1138
1201
  continue;
1139
1202
  }
1140
1203
  if (!isRecord$1(entry)) continue;
1204
+ if (vars.resolveBool(entry["enabled"]) === false) continue;
1141
1205
  const kind = entry["type"];
1142
1206
  if (kind === "color") bgColor = colorFromFillEntry(entry, vars);
1143
1207
  else if (kind === "gradient") {
@@ -1150,8 +1214,9 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1150
1214
  const rot = vars.resolveNum(entry["rotation"]) ?? 0;
1151
1215
  if (gType === "linear") bgImages.push(`linear-gradient(${penAngleToCss(rot)}, ${stops})`);
1152
1216
  else if (gType === "radial") {
1153
- const c = normCenter(entry["center"]);
1154
- bgImages.push(`radial-gradient(ellipse at ${c.x} ${c.y}, ${stops})`);
1217
+ const c = normCenter(entry["center"], vars);
1218
+ const sz = normSize(entry["size"], vars);
1219
+ bgImages.push(sz === null ? `radial-gradient(ellipse at ${c.x} ${c.y}, ${stops})` : `radial-gradient(ellipse ${sz} at ${c.x} ${c.y}, ${stops})`);
1155
1220
  } else if (gType === "angular") bgImages.push(`conic-gradient(from ${penAngleToCss(rot)} at 50% 50%, ${stops})`);
1156
1221
  else bgImages.push(`linear-gradient(${penAngleToCss(rot)}, ${stops})`);
1157
1222
  } else if (kind === "image") {
@@ -1177,16 +1242,37 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1177
1242
  if (bgColor !== void 0) decls.unshift(["background-color", bgColor]);
1178
1243
  return decls;
1179
1244
  }
1180
- /** stroke → border 系列。v1 只支持纯色、均匀或四边宽度;alignment 非 inner 时警告 */
1245
+ /**
1246
+ * stroke → CSS。
1247
+ *
1248
+ * 对齐方式决定用哪套 CSS:
1249
+ * - `inner`(默认):`border` 画在盒子内侧 —— 与 Pencil 的 inner 一致;
1250
+ * - `outer`:`outline` 画在外侧,且不占布局(不会把内容挤小);
1251
+ * - `center`:`outline` + 负 offset 半个线宽,线心落在盒子边界上。
1252
+ *
1253
+ * 旧实现一律按 border 画并只发警告,`outer` 的线会整体偏进 1–2px;
1254
+ * 设计稿里 outer 比 inner 还多(editor.pen 76 : 26),值得按真实语义画。
1255
+ */
1181
1256
  function strokeToCss(node, vars, warnings, what) {
1182
1257
  const stroke = node["stroke"];
1183
1258
  if (stroke === void 0) return [];
1184
- const entries = Array.isArray(stroke) ? stroke : [stroke];
1185
- const firstColor = entries.map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
1259
+ const enabled = (Array.isArray(stroke) ? stroke : [stroke]).filter((e) => !(isRecord$1(e) && e["enabled"] === false));
1260
+ if (enabled.length === 0) return [];
1261
+ const firstColor = enabled.map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
1262
+ if (firstColor === void 0) return [];
1186
1263
  const decls = [];
1187
- if (firstColor !== void 0) decls.push(["border-color", firstColor]);
1188
- if (entries.length > 1) warnings.push(`${what}: 多层描边仅渲染第一层`);
1264
+ if (enabled.length > 1) warnings.push(`${what}: 多层描边仅渲染第一层`);
1265
+ const alignRaw = node["strokeAlignment"];
1266
+ const align = alignRaw === "outer" || alignRaw === "center" ? alignRaw : "inner";
1189
1267
  const widthRaw = node["strokeWidth"];
1268
+ if (isRecord$1(widthRaw) && align !== "inner") warnings.push(`${what}: 逐边描边宽度 + strokeAlignment=${align} 暂按 inner(CSS border)渲染`);
1269
+ if (!isRecord$1(widthRaw) && align !== "inner") {
1270
+ const w = vars.resolveNum(widthRaw) ?? 1;
1271
+ decls.push(["outline", `${w}px solid ${firstColor}`]);
1272
+ decls.push(["outline-offset", align === "outer" ? "0" : `${-w / 2}px`]);
1273
+ return decls;
1274
+ }
1275
+ decls.push(["border-color", firstColor]);
1190
1276
  if (typeof widthRaw === "number") {
1191
1277
  decls.push(["border-width", `${widthRaw}px`]);
1192
1278
  decls.push(["border-style", "solid"]);
@@ -1205,12 +1291,10 @@ function strokeToCss(node, vars, warnings, what) {
1205
1291
  }
1206
1292
  }
1207
1293
  decls.push(...sides);
1208
- } else if (firstColor !== void 0) {
1294
+ } else {
1209
1295
  decls.push(["border-width", "1px"]);
1210
1296
  decls.push(["border-style", "solid"]);
1211
1297
  }
1212
- const align = node["strokeAlignment"];
1213
- if (align === "outer" || align === "center") warnings.push(`${what}: strokeAlignment=${String(align)} 暂按 inner(CSS border)语义渲染`);
1214
1298
  return decls;
1215
1299
  }
1216
1300
  /** effect → box-shadow / filter / backdrop-filter */
@@ -1263,10 +1347,20 @@ function textToCss(node, vars, warnings) {
1263
1347
  if (vars.resolveBool(node["underline"])) deco.push("underline");
1264
1348
  if (vars.resolveBool(node["strikethrough"])) deco.push("line-through");
1265
1349
  if (deco.length > 0) decls.push(["text-decoration", deco.join(" ")]);
1266
- if (node["textAlignVertical"] !== void 0 && node["textAlignVertical"] !== "top") warnings.push("textAlignVertical 暂按 top 渲染");
1267
- const fillsRaw = node["fill"];
1268
- const color = (Array.isArray(fillsRaw) ? fillsRaw : fillsRaw !== void 0 ? [fillsRaw] : []).map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
1269
- if (color !== void 0) decls.push(["color", color]);
1350
+ const vaRaw = node["textAlignVertical"];
1351
+ const va = typeof vaRaw === "string" ? vars.resolve(vaRaw) : void 0;
1352
+ if (va === "middle" || va === "bottom") decls.push(["display", "flex"], ["flex-direction", "column"], ["justify-content", va === "middle" ? "center" : "flex-end"]);
1353
+ else if (va !== void 0 && va !== "top") warnings.push(`${node["id"]}: textAlignVertical=${va} 不在 top/middle/bottom 内,按 top 渲染`);
1354
+ const fillDecls = fillsToCss(node["fill"], vars, warnings, "text");
1355
+ const image = fillDecls.find(([p]) => p === "background-image");
1356
+ const color = fillDecls.find(([p]) => p === "background-color")?.[1];
1357
+ if (image !== void 0) {
1358
+ decls.push(image);
1359
+ decls.push(["-webkit-background-clip", "text"]);
1360
+ decls.push(["background-clip", "text"]);
1361
+ decls.push(["color", "transparent"]);
1362
+ decls.push(["-webkit-text-fill-color", "transparent"]);
1363
+ } else if (color !== void 0) decls.push(["color", color]);
1270
1364
  const growth = node["textGrowth"];
1271
1365
  if (growth === "auto") decls.push(["white-space", "nowrap"]);
1272
1366
  if (growth === "fixed-width-height") decls.push(["overflow", "visible"]);
@@ -1342,6 +1436,77 @@ function getLucideIcon(name, warnings) {
1342
1436
  cache.set(name, result);
1343
1437
  return result;
1344
1438
  }
1439
+ /**
1440
+ * 常见图标库 → lucide 的别名表。
1441
+ *
1442
+ * 设计稿里除 lucide 外最常出现的是 Material Symbols(名字是 `snake_case`),
1443
+ * 不映射就整块渲染成虚线占位框 —— 一个界面里几个关键操作图标全是框,
1444
+ * 可读性比「图标形状略有出人」差得多。
1445
+ */
1446
+ const ALIASES = {
1447
+ check_circle: "circle-check",
1448
+ check: "check",
1449
+ delete: "trash-2",
1450
+ delete_forever: "trash-2",
1451
+ delete_sweep: "trash-2",
1452
+ do_not_disturb_on: "circle-slash",
1453
+ download: "download",
1454
+ upload: "upload",
1455
+ filter_alt: "filter",
1456
+ info: "info",
1457
+ schedule: "clock",
1458
+ history: "history",
1459
+ settings_backup_restore: "history",
1460
+ settings: "settings",
1461
+ zoom_in: "zoom-in",
1462
+ zoom_out: "zoom-out",
1463
+ close: "x",
1464
+ search: "search",
1465
+ visibility: "eye",
1466
+ visibility_off: "eye-off",
1467
+ favorite: "heart",
1468
+ star: "star",
1469
+ play_arrow: "play",
1470
+ pause: "pause",
1471
+ stop: "square",
1472
+ refresh: "rotate-cw",
1473
+ restore: "rotate-ccw",
1474
+ keyboard_arrow_down: "chevron-down",
1475
+ keyboard_arrow_up: "chevron-up",
1476
+ keyboard_arrow_left: "chevron-left",
1477
+ keyboard_arrow_right: "chevron-right",
1478
+ expand_more: "chevron-down",
1479
+ expand_less: "chevron-up",
1480
+ more_vert: "more-vertical",
1481
+ more_horiz: "more-horizontal",
1482
+ folder: "folder",
1483
+ folder_open: "folder-open",
1484
+ image: "image",
1485
+ photo: "image",
1486
+ edit: "pencil",
1487
+ crop: "crop",
1488
+ rotate_left: "rotate-ccw",
1489
+ rotate_right: "rotate-cw",
1490
+ fullscreen: "maximize",
1491
+ fullscreen_exit: "minimize",
1492
+ lock: "lock",
1493
+ lock_open: "unlock",
1494
+ warning: "triangle-alert",
1495
+ error: "circle-alert",
1496
+ help: "circle-help"
1497
+ };
1498
+ /**
1499
+ * 把任意图标库里的名字归一成 lucide 名。
1500
+ * lucide 自己的名字直接返回;Material Symbols 走别表;
1501
+ * 其它库(Font Awesome、自定义字体)也先试别名,再不中就由调用方降级占位。
1502
+ */
1503
+ function resolveIconName(library, name) {
1504
+ if (name === "") return name;
1505
+ if (library === "lucide" || library === "feather") return name;
1506
+ const direct = ALIASES[name];
1507
+ if (direct !== void 0) return direct;
1508
+ return name;
1509
+ }
1345
1510
  // src/render/html.ts
1346
1511
  function escapeHtml(s) {
1347
1512
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1437,7 +1602,9 @@ function layoutDecls(node, vars) {
1437
1602
  else if (jc === "space_between") decls.push(["justify-content", "space-between"]);
1438
1603
  else if (jc === "space_around") decls.push(["justify-content", "space-around"]);
1439
1604
  const ai = node["alignItems"];
1440
- if (ai === "start" || ai === "center" || ai === "end") decls.push(["align-items", ai]);
1605
+ if (ai === "start") decls.push(["align-items", "flex-start"]);
1606
+ else if (ai === "center" || ai === "end") decls.push(["align-items", ai === "end" ? "flex-end" : "center"]);
1607
+ else decls.push(["align-items", "flex-start"]);
1441
1608
  }
1442
1609
  return {
1443
1610
  decls,
@@ -1496,60 +1663,63 @@ function boardAttrs(node, vars) {
1496
1663
  }
1497
1664
  function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1498
1665
  const type = String(node["type"] ?? "?");
1666
+ const scoped = isRecord(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
1667
+ if (scoped.resolveBool(node["enabled"]) === false) return "";
1499
1668
  const label = typeof node["name"] === "string" && node["name"] !== "" ? `${type}: ${String(node["name"])}` : type;
1500
1669
  const positioned = parentLayout === "none" || node["layoutPosition"] === "absolute";
1501
1670
  if (type === "script" || type === "browser" || !KNOWN_RENDER_TYPES.has(type)) return openTag(node, "", extraAttrs) + placeholderBox(label, []) + "</div>";
1502
1671
  const css = [];
1503
1672
  if (positioned) {
1504
1673
  css.push(["position", "absolute"]);
1505
- const x = numericSize(node["x"], vars);
1506
- const y = numericSize(node["y"], vars);
1674
+ const x = numericSize(node["x"], scoped);
1675
+ const y = numericSize(node["y"], scoped);
1507
1676
  css.push(["left", `${x ?? 0}px`], ["top", `${y ?? 0}px`]);
1508
- }
1509
- const { decls: layoutCss, effective } = layoutDecls(node, vars);
1677
+ } else css.push(["position", "relative"]);
1678
+ const { decls: layoutCss, effective } = layoutDecls(node, scoped);
1510
1679
  css.push(...layoutCss);
1511
- css.push(...sizeDecls(node["width"], node["height"], positioned, parentLayout, vars, warnings, label));
1512
- css.push(...commonToCss(node, vars));
1513
- if (type !== "text") css.push(...fillsToCss(node["fill"], vars, warnings, label));
1514
- css.push(...strokeToCss(node, vars, warnings, label));
1515
- css.push(...effectsToCss(node["effect"], vars, warnings, label));
1680
+ if (parentLayout !== "none" && !positioned) css.push(["flex-shrink", "0"]);
1681
+ css.push(...sizeDecls(node["width"], node["height"], positioned, parentLayout, scoped, warnings, label));
1682
+ css.push(...commonToCss(node, scoped));
1683
+ if (type !== "text") css.push(...fillsToCss(node["fill"], scoped, warnings, label));
1684
+ css.push(...strokeToCss(node, scoped, warnings, label));
1685
+ css.push(...effectsToCss(node["effect"], scoped, warnings, label));
1516
1686
  let inner = "";
1517
1687
  switch (type) {
1518
1688
  case "text":
1519
- css.push(...textToCss(node, vars, warnings));
1520
- inner = escapeHtml(typeof node["content"] === "string" ? vars.resolve(node["content"]) : "");
1689
+ css.push(...textToCss(node, scoped, warnings));
1690
+ inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
1521
1691
  break;
1522
1692
  case "icon":
1523
1693
  case "icon_font": {
1524
- const colorCss = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color");
1694
+ const colorCss = fillsToCss(node["fill"], scoped, warnings, label).find(([p]) => p === "background-color");
1525
1695
  if (colorCss) css.push(["color", colorCss[1]], ["background", "transparent"]);
1526
1696
  const fontVariant = type === "icon_font";
1527
1697
  const libField = fontVariant ? node["iconFontFamily"] : node["library"];
1528
1698
  const nameField = fontVariant ? node["iconFontName"] : node["icon"];
1529
1699
  const lib = typeof libField === "string" && libField !== "" ? libField : "lucide";
1530
- const iconName = typeof nameField === "string" ? vars.resolve(nameField) : "";
1531
- let svg = null;
1532
- if (lib === "lucide" || lib === "feather") svg = getLucideIcon(iconName, warnings);
1533
- else warnings.push(`${label}: 图标库 "${lib}" 暂只支持 lucide/feather,渲染为占位框`);
1700
+ const iconName = typeof nameField === "string" ? scoped.resolve(nameField) : "";
1701
+ const lucideName = resolveIconName(lib, iconName);
1702
+ const svg = iconName === "" ? null : getLucideIcon(lucideName, warnings);
1703
+ if (svg === null && lucideName === iconName && lib !== "lucide" && lib !== "feather") warnings.push(`${label}: 图标库 "${lib}" 的 "${iconName}" 没有可用的 lucide 等价图标,渲染为占位框`);
1534
1704
  inner = svg ? svg.replace("<svg", "<svg style=\"width:100%;height:100%;display:block\"") : placeholderBox(`${lib}:${iconName}`, []);
1535
1705
  break;
1536
1706
  }
1537
1707
  case "note": {
1538
- const bg = fillsToCss(node["fill"] ?? node["color"] ?? "#FEE484", vars, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#FEE484";
1708
+ const bg = fillsToCss(node["fill"] ?? node["color"] ?? "#FEE484", scoped, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#FEE484";
1539
1709
  css.push(["background", bg], ["padding", "8px 10px"], ["color", "#292524"], ["font", "12px/1.5 system-ui,sans-serif"], ["border-radius", "4px"], ["overflow", "hidden"]);
1540
- inner = escapeHtml(typeof node["content"] === "string" ? vars.resolve(node["content"]) : "");
1710
+ inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
1541
1711
  break;
1542
1712
  }
1543
1713
  case "ellipse": {
1544
1714
  css.push(["border-radius", "50%"]);
1545
- const inner2 = vars.resolveNum(node["innerRadius"]);
1715
+ const inner2 = scoped.resolveNum(node["innerRadius"]);
1546
1716
  if (inner2 !== void 0 && inner2 > 0) warnings.push(`${label}: innerRadius 环形暂按实心椭圆渲染`);
1547
- const sweep = vars.resolveNum(node["sweepAngle"]);
1717
+ const sweep = scoped.resolveNum(node["sweepAngle"]);
1548
1718
  if (sweep !== void 0 && sweep !== 360) warnings.push(`${label}: 弧形(sweepAngle=${sweep})暂按整圆渲染`);
1549
1719
  break;
1550
1720
  }
1551
1721
  case "polygon": {
1552
- const count = vars.resolveNum(node["polygonCount"]);
1722
+ const count = scoped.resolveNum(node["polygonCount"]);
1553
1723
  const n = count ?? 3;
1554
1724
  if (count === void 0) warnings.push(`${label}: polygonCount 缺省按 3 渲染`);
1555
1725
  if (node["cornerRadius"] !== void 0) warnings.push(`${label}: 多边形圆角暂忽略`);
@@ -1557,9 +1727,9 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1557
1727
  break;
1558
1728
  }
1559
1729
  case "path": {
1560
- css.push(...cornerRadiusToCss(node["cornerRadius"], vars));
1730
+ css.push(...cornerRadiusToCss(node["cornerRadius"], scoped));
1561
1731
  const d = typeof node["geometry"] === "string" ? node["geometry"] : "";
1562
- const color = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#000000";
1732
+ const color = fillsToCss(node["fill"], scoped, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#000000";
1563
1733
  const vbRaw = node["viewBox"];
1564
1734
  inner = `<svg viewBox="${(Array.isArray(vbRaw) && vbRaw.length === 4 && vbRaw.every((v) => typeof v === "number") ? vbRaw : [
1565
1735
  0,
@@ -1570,11 +1740,11 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1570
1740
  break;
1571
1741
  }
1572
1742
  default:
1573
- css.push(...cornerRadiusToCss(node["cornerRadius"], vars));
1743
+ css.push(...cornerRadiusToCss(node["cornerRadius"], scoped));
1574
1744
  if (type === "group" && node["effect"] !== void 0) {}
1575
1745
  }
1576
1746
  const kidsRaw = node["children"];
1577
- const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord).map((k) => renderNode(k, effective, vars, warnings)).join("") : "";
1747
+ const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord).map((k) => renderNode(k, effective, scoped, warnings)).join("") : "";
1578
1748
  return openTag(node, css.map(([p, v]) => `${p}:${v}`).join(";"), extraAttrs) + inner + childrenHtml + "</div>";
1579
1749
  }
1580
1750
  const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
@@ -1625,7 +1795,9 @@ function boardBoxes(nodes, vars) {
1625
1795
  }
1626
1796
  /** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
1627
1797
  function renderTree(nodes, vars, warnings) {
1628
- return nodes.filter(isRecord).map((n) => renderNode(n, "none", vars, warnings, boardAttrs(n, vars))).join("");
1798
+ return nodes.filter(isRecord).map((n) => {
1799
+ return renderNode(n, "none", vars, warnings, boardAttrs(n, isRecord(n["theme"]) ? vars.withTheme(n["theme"]) : vars));
1800
+ }).join("");
1629
1801
  }
1630
1802
  // src/render/entry.ts
1631
1803
  /**
@@ -1650,7 +1822,7 @@ function renderDocument(doc, opts = {}) {
1650
1822
  `<title>${escapeAttr(title)}</title>`,
1651
1823
  "<style>",
1652
1824
  "* { box-sizing: border-box; margin: 0; padding: 0; }",
1653
- "body { margin: 0; background: #ffffff; }",
1825
+ `body { margin: 0; background: ${opts.transparent === true ? "transparent" : "#ffffff"}; }`,
1654
1826
  ".ptr-canvas { position: relative; }",
1655
1827
  "</style>",
1656
1828
  "</head>",