paintress 0.5.0 → 0.5.2

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.
@@ -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 读取,避免在多处硬编码后忘记同步。
@@ -933,59 +933,92 @@ function isRecord$3(v) {
933
933
  function asVarValue(v) {
934
934
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
935
935
  }
936
- /** themed 列表 → 无主题项优先,否则首项;字面量直通 */
937
- function resolveThemed(v) {
936
+ /**
937
+ * themed 列表 → 按当前主题匹配。
938
+ * 规则:主题键全匹配的条目优先(越具体的胜出),其次无主题项,最后首项。
939
+ * 这样 `{value:'#202226', theme:{mode:'dark'}}` 在 dark 下胜出,
940
+ * 而只写了 `{density:'loose'}` 的尺寸条目在 compact 下不会误命中。
941
+ */
942
+ function resolveThemed(v, theme) {
938
943
  if (Array.isArray(v)) {
939
944
  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;
945
+ if (entries.length === 0) return void 0;
946
+ let best = null;
947
+ for (const entry of entries) {
948
+ const t = entry["theme"];
949
+ if (t === void 0) {
950
+ if (best === null) best = {
951
+ entry,
952
+ score: 0
953
+ };
954
+ continue;
955
+ }
956
+ if (!isRecord$3(t)) continue;
957
+ const keys = Object.keys(t);
958
+ if (!keys.every((k) => theme[k] === String(t[k]))) continue;
959
+ if (best === null || keys.length > best.score) best = {
960
+ entry,
961
+ score: keys.length
962
+ };
963
+ }
964
+ return best === null ? asVarValue(entries[0]?.["value"]) : asVarValue(best.entry["value"]);
942
965
  }
943
966
  return asVarValue(v);
944
967
  }
945
- function makeVarResolver(doc, warnings) {
968
+ function makeVarResolver(doc, warnings, theme = {}) {
946
969
  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";
970
+ const make = (scope) => {
971
+ const lookup = (name) => {
972
+ const def = vars[name];
973
+ if (!isRecord$3(def)) return void 0;
974
+ return resolveThemed(def["value"], scope);
975
+ };
976
+ return {
977
+ resolve(input) {
978
+ if (!VARIABLE_NAME.test(input)) return input;
979
979
  const val = lookup(input.slice(1));
980
- if (typeof val === "boolean") return val;
981
- warnings.push(`布尔变量 ${input} 解析失败,按缺省处理`);
982
- return;
980
+ if (val === void 0) {
981
+ warnings.push(`未定义变量 ${input},按原样输出`);
982
+ return input;
983
+ }
984
+ return String(val);
985
+ },
986
+ resolveNum(input) {
987
+ if (typeof input === "number") return input;
988
+ if (typeof input === "string") {
989
+ if (!VARIABLE_NAME.test(input)) {
990
+ const n = Number(input);
991
+ return Number.isFinite(n) ? n : void 0;
992
+ }
993
+ const val = lookup(input.slice(1));
994
+ if (typeof val === "number") return val;
995
+ warnings.push(`数字变量 ${input} 解析失败,按缺省处理`);
996
+ return;
997
+ }
998
+ },
999
+ resolveBool(input) {
1000
+ if (typeof input === "boolean") return input;
1001
+ if (typeof input === "string") {
1002
+ if (!VARIABLE_NAME.test(input)) return input === "true";
1003
+ const val = lookup(input.slice(1));
1004
+ if (typeof val === "boolean") return val;
1005
+ warnings.push(`布尔变量 ${input} 解析失败,按缺省处理`);
1006
+ return;
1007
+ }
1008
+ },
1009
+ has(name) {
1010
+ return lookup(name) !== void 0;
1011
+ },
1012
+ theme: () => scope,
1013
+ withTheme(patch) {
1014
+ if (patch === void 0) return make(scope);
1015
+ const next = { ...scope };
1016
+ for (const [k, v] of Object.entries(patch)) if (typeof v === "string") next[k] = v;
1017
+ return make(next);
983
1018
  }
984
- },
985
- has(name) {
986
- return lookup(name) !== void 0;
987
- }
1019
+ };
988
1020
  };
1021
+ return make(theme);
989
1022
  }
990
1023
  // src/render/expand.ts
991
1024
  function isRecord$2(v) {
@@ -1109,16 +1142,26 @@ function stopsToCssList(colors, vars) {
1109
1142
  }
1110
1143
  return parts.length > 0 ? parts.join(", ") : void 0;
1111
1144
  }
1112
- /** .pen 逆时针度(0=上,90=左)→ CSS 角(0=上,顺时针正) */
1145
+ /**
1146
+ * .pen/.ptr 渐变角度 → CSS 角(度)。
1147
+ *
1148
+ * 基准:`0` = 从左往右(渐变方向指向右),正值顺时针(Figma 那套手感)。
1149
+ * 所以 0 → 90deg(to right)、90 → 0deg(to top)、180 → 270deg(to left)、270 → 180deg(to bottom)。
1150
+ *
1151
+ * 为什么不用「0 指向上」:设计稿里可见的渐变只剩「流光带」(透明→近白→透明,
1152
+ * 从左往右飘的光带),它只有按 0=向右 才是一条竖光带;按 0=向上 会变成横着的一大块。
1153
+ * 深色「渐隐覆盖」类节点是「同色→自身透明」,两条约定看不出差别,不必为它们拗。
1154
+ */
1113
1155
  function penAngleToCss(deg) {
1114
- return `${((360 - (deg % 360 + 360) % 360) % 360).toFixed(1)}deg`;
1156
+ return `${(((90 - deg) % 360 + 360) % 360).toFixed(1)}deg`;
1115
1157
  }
1116
- function normCenter(c) {
1158
+ function normCenter(c, vars) {
1117
1159
  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)}%`
1160
+ const rx = vars?.resolveNum(c["x"]) ?? (typeof c["x"] === "number" ? c["x"] : void 0);
1161
+ const ry = vars?.resolveNum(c["y"]) ?? (typeof c["y"] === "number" ? c["y"] : void 0);
1162
+ if (rx !== void 0 && ry !== void 0) return {
1163
+ x: `${(rx * 100).toFixed(2)}%`,
1164
+ y: `${(ry * 100).toFixed(2)}%`
1122
1165
  };
1123
1166
  }
1124
1167
  return {
@@ -1126,6 +1169,20 @@ function normCenter(c) {
1126
1169
  y: "50%"
1127
1170
  };
1128
1171
  }
1172
+ /**
1173
+ * 径向渐变的椭圆直径(归一化到自身包围盒)→ CSS `ellipse <rx> <ry>`。
1174
+ * 缺省返回 null(用 CSS 默认的 farthest-corner);`{height:1}` 这类只写一边的,
1175
+ * 另一边按正方形处理(与 Pencil 的径向渐变一致)。
1176
+ */
1177
+ function normSize(size, vars) {
1178
+ if (!isRecord$1(size)) return null;
1179
+ const rw = vars.resolveNum(size["width"]);
1180
+ const rh = vars.resolveNum(size["height"]);
1181
+ if (rw === void 0 && rh === void 0) return null;
1182
+ const w = rw ?? rh ?? 1;
1183
+ const h = rh ?? rw ?? 1;
1184
+ return `${(w * 100).toFixed(2)}% ${(h * 100).toFixed(2)}%`;
1185
+ }
1129
1186
  /** fill 集 → background 系列声明。纯色取「最后绘制的」作 background-color(顶层语义) */
1130
1187
  function fillsToCss(fillsRaw, vars, warnings, what) {
1131
1188
  const entries = Array.isArray(fillsRaw) ? fillsRaw : fillsRaw !== void 0 ? [fillsRaw] : [];
@@ -1138,6 +1195,7 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1138
1195
  continue;
1139
1196
  }
1140
1197
  if (!isRecord$1(entry)) continue;
1198
+ if (vars.resolveBool(entry["enabled"]) === false) continue;
1141
1199
  const kind = entry["type"];
1142
1200
  if (kind === "color") bgColor = colorFromFillEntry(entry, vars);
1143
1201
  else if (kind === "gradient") {
@@ -1150,8 +1208,9 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1150
1208
  const rot = vars.resolveNum(entry["rotation"]) ?? 0;
1151
1209
  if (gType === "linear") bgImages.push(`linear-gradient(${penAngleToCss(rot)}, ${stops})`);
1152
1210
  else if (gType === "radial") {
1153
- const c = normCenter(entry["center"]);
1154
- bgImages.push(`radial-gradient(ellipse at ${c.x} ${c.y}, ${stops})`);
1211
+ const c = normCenter(entry["center"], vars);
1212
+ const sz = normSize(entry["size"], vars);
1213
+ bgImages.push(sz === null ? `radial-gradient(ellipse at ${c.x} ${c.y}, ${stops})` : `radial-gradient(ellipse ${sz} at ${c.x} ${c.y}, ${stops})`);
1155
1214
  } else if (gType === "angular") bgImages.push(`conic-gradient(from ${penAngleToCss(rot)} at 50% 50%, ${stops})`);
1156
1215
  else bgImages.push(`linear-gradient(${penAngleToCss(rot)}, ${stops})`);
1157
1216
  } else if (kind === "image") {
@@ -1177,16 +1236,37 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
1177
1236
  if (bgColor !== void 0) decls.unshift(["background-color", bgColor]);
1178
1237
  return decls;
1179
1238
  }
1180
- /** stroke → border 系列。v1 只支持纯色、均匀或四边宽度;alignment 非 inner 时警告 */
1239
+ /**
1240
+ * stroke → CSS。
1241
+ *
1242
+ * 对齐方式决定用哪套 CSS:
1243
+ * - `inner`(默认):`border` 画在盒子内侧 —— 与 Pencil 的 inner 一致;
1244
+ * - `outer`:`outline` 画在外侧,且不占布局(不会把内容挤小);
1245
+ * - `center`:`outline` + 负 offset 半个线宽,线心落在盒子边界上。
1246
+ *
1247
+ * 旧实现一律按 border 画并只发警告,`outer` 的线会整体偏进 1–2px;
1248
+ * 设计稿里 outer 比 inner 还多(editor.pen 76 : 26),值得按真实语义画。
1249
+ */
1181
1250
  function strokeToCss(node, vars, warnings, what) {
1182
1251
  const stroke = node["stroke"];
1183
1252
  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);
1253
+ const enabled = (Array.isArray(stroke) ? stroke : [stroke]).filter((e) => !(isRecord$1(e) && e["enabled"] === false));
1254
+ if (enabled.length === 0) return [];
1255
+ const firstColor = enabled.map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
1256
+ if (firstColor === void 0) return [];
1186
1257
  const decls = [];
1187
- if (firstColor !== void 0) decls.push(["border-color", firstColor]);
1188
- if (entries.length > 1) warnings.push(`${what}: 多层描边仅渲染第一层`);
1258
+ if (enabled.length > 1) warnings.push(`${what}: 多层描边仅渲染第一层`);
1259
+ const alignRaw = node["strokeAlignment"];
1260
+ const align = alignRaw === "outer" || alignRaw === "center" ? alignRaw : "inner";
1189
1261
  const widthRaw = node["strokeWidth"];
1262
+ if (isRecord$1(widthRaw) && align !== "inner") warnings.push(`${what}: 逐边描边宽度 + strokeAlignment=${align} 暂按 inner(CSS border)渲染`);
1263
+ if (!isRecord$1(widthRaw) && align !== "inner") {
1264
+ const w = vars.resolveNum(widthRaw) ?? 1;
1265
+ decls.push(["outline", `${w}px solid ${firstColor}`]);
1266
+ decls.push(["outline-offset", align === "outer" ? "0" : `${-w / 2}px`]);
1267
+ return decls;
1268
+ }
1269
+ decls.push(["border-color", firstColor]);
1190
1270
  if (typeof widthRaw === "number") {
1191
1271
  decls.push(["border-width", `${widthRaw}px`]);
1192
1272
  decls.push(["border-style", "solid"]);
@@ -1205,12 +1285,10 @@ function strokeToCss(node, vars, warnings, what) {
1205
1285
  }
1206
1286
  }
1207
1287
  decls.push(...sides);
1208
- } else if (firstColor !== void 0) {
1288
+ } else {
1209
1289
  decls.push(["border-width", "1px"]);
1210
1290
  decls.push(["border-style", "solid"]);
1211
1291
  }
1212
- const align = node["strokeAlignment"];
1213
- if (align === "outer" || align === "center") warnings.push(`${what}: strokeAlignment=${String(align)} 暂按 inner(CSS border)语义渲染`);
1214
1292
  return decls;
1215
1293
  }
1216
1294
  /** effect → box-shadow / filter / backdrop-filter */
@@ -1263,10 +1341,20 @@ function textToCss(node, vars, warnings) {
1263
1341
  if (vars.resolveBool(node["underline"])) deco.push("underline");
1264
1342
  if (vars.resolveBool(node["strikethrough"])) deco.push("line-through");
1265
1343
  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]);
1344
+ const vaRaw = node["textAlignVertical"];
1345
+ const va = typeof vaRaw === "string" ? vars.resolve(vaRaw) : void 0;
1346
+ if (va === "middle" || va === "bottom") decls.push(["display", "flex"], ["flex-direction", "column"], ["justify-content", va === "middle" ? "center" : "flex-end"]);
1347
+ else if (va !== void 0 && va !== "top") warnings.push(`${node["id"]}: textAlignVertical=${va} 不在 top/middle/bottom 内,按 top 渲染`);
1348
+ const fillDecls = fillsToCss(node["fill"], vars, warnings, "text");
1349
+ const image = fillDecls.find(([p]) => p === "background-image");
1350
+ const color = fillDecls.find(([p]) => p === "background-color")?.[1];
1351
+ if (image !== void 0) {
1352
+ decls.push(image);
1353
+ decls.push(["-webkit-background-clip", "text"]);
1354
+ decls.push(["background-clip", "text"]);
1355
+ decls.push(["color", "transparent"]);
1356
+ decls.push(["-webkit-text-fill-color", "transparent"]);
1357
+ } else if (color !== void 0) decls.push(["color", color]);
1270
1358
  const growth = node["textGrowth"];
1271
1359
  if (growth === "auto") decls.push(["white-space", "nowrap"]);
1272
1360
  if (growth === "fixed-width-height") decls.push(["overflow", "visible"]);
@@ -1342,6 +1430,77 @@ function getLucideIcon(name, warnings) {
1342
1430
  cache.set(name, result);
1343
1431
  return result;
1344
1432
  }
1433
+ /**
1434
+ * 常见图标库 → lucide 的别名表。
1435
+ *
1436
+ * 设计稿里除 lucide 外最常出现的是 Material Symbols(名字是 `snake_case`),
1437
+ * 不映射就整块渲染成虚线占位框 —— 一个界面里几个关键操作图标全是框,
1438
+ * 可读性比「图标形状略有出人」差得多。
1439
+ */
1440
+ const ALIASES = {
1441
+ check_circle: "circle-check",
1442
+ check: "check",
1443
+ delete: "trash-2",
1444
+ delete_forever: "trash-2",
1445
+ delete_sweep: "trash-2",
1446
+ do_not_disturb_on: "circle-slash",
1447
+ download: "download",
1448
+ upload: "upload",
1449
+ filter_alt: "filter",
1450
+ info: "info",
1451
+ schedule: "clock",
1452
+ history: "history",
1453
+ settings_backup_restore: "history",
1454
+ settings: "settings",
1455
+ zoom_in: "zoom-in",
1456
+ zoom_out: "zoom-out",
1457
+ close: "x",
1458
+ search: "search",
1459
+ visibility: "eye",
1460
+ visibility_off: "eye-off",
1461
+ favorite: "heart",
1462
+ star: "star",
1463
+ play_arrow: "play",
1464
+ pause: "pause",
1465
+ stop: "square",
1466
+ refresh: "rotate-cw",
1467
+ restore: "rotate-ccw",
1468
+ keyboard_arrow_down: "chevron-down",
1469
+ keyboard_arrow_up: "chevron-up",
1470
+ keyboard_arrow_left: "chevron-left",
1471
+ keyboard_arrow_right: "chevron-right",
1472
+ expand_more: "chevron-down",
1473
+ expand_less: "chevron-up",
1474
+ more_vert: "more-vertical",
1475
+ more_horiz: "more-horizontal",
1476
+ folder: "folder",
1477
+ folder_open: "folder-open",
1478
+ image: "image",
1479
+ photo: "image",
1480
+ edit: "pencil",
1481
+ crop: "crop",
1482
+ rotate_left: "rotate-ccw",
1483
+ rotate_right: "rotate-cw",
1484
+ fullscreen: "maximize",
1485
+ fullscreen_exit: "minimize",
1486
+ lock: "lock",
1487
+ lock_open: "unlock",
1488
+ warning: "triangle-alert",
1489
+ error: "circle-alert",
1490
+ help: "circle-help"
1491
+ };
1492
+ /**
1493
+ * 把任意图标库里的名字归一成 lucide 名。
1494
+ * lucide 自己的名字直接返回;Material Symbols 走别表;
1495
+ * 其它库(Font Awesome、自定义字体)也先试别名,再不中就由调用方降级占位。
1496
+ */
1497
+ function resolveIconName(library, name) {
1498
+ if (name === "") return name;
1499
+ if (library === "lucide" || library === "feather") return name;
1500
+ const direct = ALIASES[name];
1501
+ if (direct !== void 0) return direct;
1502
+ return name;
1503
+ }
1345
1504
  // src/render/html.ts
1346
1505
  function escapeHtml(s) {
1347
1506
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1437,7 +1596,9 @@ function layoutDecls(node, vars) {
1437
1596
  else if (jc === "space_between") decls.push(["justify-content", "space-between"]);
1438
1597
  else if (jc === "space_around") decls.push(["justify-content", "space-around"]);
1439
1598
  const ai = node["alignItems"];
1440
- if (ai === "start" || ai === "center" || ai === "end") decls.push(["align-items", ai]);
1599
+ if (ai === "start") decls.push(["align-items", "flex-start"]);
1600
+ else if (ai === "center" || ai === "end") decls.push(["align-items", ai === "end" ? "flex-end" : "center"]);
1601
+ else decls.push(["align-items", "flex-start"]);
1441
1602
  }
1442
1603
  return {
1443
1604
  decls,
@@ -1496,60 +1657,63 @@ function boardAttrs(node, vars) {
1496
1657
  }
1497
1658
  function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1498
1659
  const type = String(node["type"] ?? "?");
1660
+ const scoped = isRecord(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
1661
+ if (scoped.resolveBool(node["enabled"]) === false) return "";
1499
1662
  const label = typeof node["name"] === "string" && node["name"] !== "" ? `${type}: ${String(node["name"])}` : type;
1500
1663
  const positioned = parentLayout === "none" || node["layoutPosition"] === "absolute";
1501
1664
  if (type === "script" || type === "browser" || !KNOWN_RENDER_TYPES.has(type)) return openTag(node, "", extraAttrs) + placeholderBox(label, []) + "</div>";
1502
1665
  const css = [];
1503
1666
  if (positioned) {
1504
1667
  css.push(["position", "absolute"]);
1505
- const x = numericSize(node["x"], vars);
1506
- const y = numericSize(node["y"], vars);
1668
+ const x = numericSize(node["x"], scoped);
1669
+ const y = numericSize(node["y"], scoped);
1507
1670
  css.push(["left", `${x ?? 0}px`], ["top", `${y ?? 0}px`]);
1508
- }
1509
- const { decls: layoutCss, effective } = layoutDecls(node, vars);
1671
+ } else css.push(["position", "relative"]);
1672
+ const { decls: layoutCss, effective } = layoutDecls(node, scoped);
1510
1673
  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));
1674
+ if (parentLayout !== "none" && !positioned) css.push(["flex-shrink", "0"]);
1675
+ css.push(...sizeDecls(node["width"], node["height"], positioned, parentLayout, scoped, warnings, label));
1676
+ css.push(...commonToCss(node, scoped));
1677
+ if (type !== "text") css.push(...fillsToCss(node["fill"], scoped, warnings, label));
1678
+ css.push(...strokeToCss(node, scoped, warnings, label));
1679
+ css.push(...effectsToCss(node["effect"], scoped, warnings, label));
1516
1680
  let inner = "";
1517
1681
  switch (type) {
1518
1682
  case "text":
1519
- css.push(...textToCss(node, vars, warnings));
1520
- inner = escapeHtml(typeof node["content"] === "string" ? vars.resolve(node["content"]) : "");
1683
+ css.push(...textToCss(node, scoped, warnings));
1684
+ inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
1521
1685
  break;
1522
1686
  case "icon":
1523
1687
  case "icon_font": {
1524
- const colorCss = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color");
1688
+ const colorCss = fillsToCss(node["fill"], scoped, warnings, label).find(([p]) => p === "background-color");
1525
1689
  if (colorCss) css.push(["color", colorCss[1]], ["background", "transparent"]);
1526
1690
  const fontVariant = type === "icon_font";
1527
1691
  const libField = fontVariant ? node["iconFontFamily"] : node["library"];
1528
1692
  const nameField = fontVariant ? node["iconFontName"] : node["icon"];
1529
1693
  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,渲染为占位框`);
1694
+ const iconName = typeof nameField === "string" ? scoped.resolve(nameField) : "";
1695
+ const lucideName = resolveIconName(lib, iconName);
1696
+ const svg = iconName === "" ? null : getLucideIcon(lucideName, warnings);
1697
+ if (svg === null && lucideName === iconName && lib !== "lucide" && lib !== "feather") warnings.push(`${label}: 图标库 "${lib}" 的 "${iconName}" 没有可用的 lucide 等价图标,渲染为占位框`);
1534
1698
  inner = svg ? svg.replace("<svg", "<svg style=\"width:100%;height:100%;display:block\"") : placeholderBox(`${lib}:${iconName}`, []);
1535
1699
  break;
1536
1700
  }
1537
1701
  case "note": {
1538
- const bg = fillsToCss(node["fill"] ?? node["color"] ?? "#FEE484", vars, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#FEE484";
1702
+ const bg = fillsToCss(node["fill"] ?? node["color"] ?? "#FEE484", scoped, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#FEE484";
1539
1703
  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"]) : "");
1704
+ inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
1541
1705
  break;
1542
1706
  }
1543
1707
  case "ellipse": {
1544
1708
  css.push(["border-radius", "50%"]);
1545
- const inner2 = vars.resolveNum(node["innerRadius"]);
1709
+ const inner2 = scoped.resolveNum(node["innerRadius"]);
1546
1710
  if (inner2 !== void 0 && inner2 > 0) warnings.push(`${label}: innerRadius 环形暂按实心椭圆渲染`);
1547
- const sweep = vars.resolveNum(node["sweepAngle"]);
1711
+ const sweep = scoped.resolveNum(node["sweepAngle"]);
1548
1712
  if (sweep !== void 0 && sweep !== 360) warnings.push(`${label}: 弧形(sweepAngle=${sweep})暂按整圆渲染`);
1549
1713
  break;
1550
1714
  }
1551
1715
  case "polygon": {
1552
- const count = vars.resolveNum(node["polygonCount"]);
1716
+ const count = scoped.resolveNum(node["polygonCount"]);
1553
1717
  const n = count ?? 3;
1554
1718
  if (count === void 0) warnings.push(`${label}: polygonCount 缺省按 3 渲染`);
1555
1719
  if (node["cornerRadius"] !== void 0) warnings.push(`${label}: 多边形圆角暂忽略`);
@@ -1557,9 +1721,9 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1557
1721
  break;
1558
1722
  }
1559
1723
  case "path": {
1560
- css.push(...cornerRadiusToCss(node["cornerRadius"], vars));
1724
+ css.push(...cornerRadiusToCss(node["cornerRadius"], scoped));
1561
1725
  const d = typeof node["geometry"] === "string" ? node["geometry"] : "";
1562
- const color = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#000000";
1726
+ const color = fillsToCss(node["fill"], scoped, warnings, label).find(([p]) => p === "background-color")?.[1] ?? "#000000";
1563
1727
  const vbRaw = node["viewBox"];
1564
1728
  inner = `<svg viewBox="${(Array.isArray(vbRaw) && vbRaw.length === 4 && vbRaw.every((v) => typeof v === "number") ? vbRaw : [
1565
1729
  0,
@@ -1570,11 +1734,11 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1570
1734
  break;
1571
1735
  }
1572
1736
  default:
1573
- css.push(...cornerRadiusToCss(node["cornerRadius"], vars));
1737
+ css.push(...cornerRadiusToCss(node["cornerRadius"], scoped));
1574
1738
  if (type === "group" && node["effect"] !== void 0) {}
1575
1739
  }
1576
1740
  const kidsRaw = node["children"];
1577
- const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord).map((k) => renderNode(k, effective, vars, warnings)).join("") : "";
1741
+ const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord).map((k) => renderNode(k, effective, scoped, warnings)).join("") : "";
1578
1742
  return openTag(node, css.map(([p, v]) => `${p}:${v}`).join(";"), extraAttrs) + inner + childrenHtml + "</div>";
1579
1743
  }
1580
1744
  const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
@@ -1625,7 +1789,9 @@ function boardBoxes(nodes, vars) {
1625
1789
  }
1626
1790
  /** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
1627
1791
  function renderTree(nodes, vars, warnings) {
1628
- return nodes.filter(isRecord).map((n) => renderNode(n, "none", vars, warnings, boardAttrs(n, vars))).join("");
1792
+ return nodes.filter(isRecord).map((n) => {
1793
+ return renderNode(n, "none", vars, warnings, boardAttrs(n, isRecord(n["theme"]) ? vars.withTheme(n["theme"]) : vars));
1794
+ }).join("");
1629
1795
  }
1630
1796
  // src/render/entry.ts
1631
1797
  /**