paintress 0.6.0 → 0.7.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 +53 -14
- package/README.zh-CN.md +54 -13
- package/dist/cli.js +492 -31
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-BSf62388.js → render-DWs1iQ0v.js} +878 -56
- package/dist/server-DBRmwE_v.js +3462 -0
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +37 -24
- package/ui/dist/assets/index-CSUM68ms.js +8 -0
- package/ui/dist/assets/{index-BFcvu7wr.css → index-CXBgFia_.css} +1 -1
- package/ui/dist/index.html +2 -2
- package/dist/server-DZRNNWac.js +0 -1599
- package/ui/dist/assets/index-CEhK1JXE.js +0 -8
|
@@ -573,6 +573,322 @@ const DocumentSchema = DocumentObjectSchema.superRefine((doc, ctx) => {
|
|
|
573
573
|
});
|
|
574
574
|
/** .ptr 文档:pen_profile + x-paintress 必填 */
|
|
575
575
|
const PtrDocumentSchema = DocumentSchema.and(z.object({ [X_PAINTRESS_KEY]: XPaintress }).passthrough());
|
|
576
|
+
// src/doc/presets.ts
|
|
577
|
+
const PRESET_TYPES = [
|
|
578
|
+
"color",
|
|
579
|
+
"radius",
|
|
580
|
+
"spacing",
|
|
581
|
+
"fontSize",
|
|
582
|
+
"font"
|
|
583
|
+
];
|
|
584
|
+
function isPresetType(v) {
|
|
585
|
+
return typeof v === "string" && PRESET_TYPES.includes(v);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* 属性 → 期望的 token 种类。**唯一映射表**:audit、establish、validate、写入提示都从这里取,
|
|
589
|
+
* 各处不得再抄一份(2026-11-15:`fontSize` 曾因两处映射不一致而无法 token 化)。
|
|
590
|
+
*
|
|
591
|
+
* 刻意不含 `lineHeight`(它是 fontSize 的倍数,塞进任何数字种类都是错语义);
|
|
592
|
+
* `fontWeight` 同理(等独立种类,见 `.agents/memory/FUTURE.md`)。
|
|
593
|
+
*/
|
|
594
|
+
const PROP_TOKEN_KIND = {
|
|
595
|
+
fill: "color",
|
|
596
|
+
stroke: "color",
|
|
597
|
+
color: "color",
|
|
598
|
+
effect: "color",
|
|
599
|
+
cornerRadius: "radius",
|
|
600
|
+
gap: "spacing",
|
|
601
|
+
padding: "spacing",
|
|
602
|
+
strokeWidth: "spacing",
|
|
603
|
+
letterSpacing: "spacing",
|
|
604
|
+
fontSize: "fontSize",
|
|
605
|
+
fontFamily: "font"
|
|
606
|
+
};
|
|
607
|
+
/** 种类的人读名(校验与审计的提示语用) */
|
|
608
|
+
const PRESET_TYPE_LABEL = {
|
|
609
|
+
color: "颜色",
|
|
610
|
+
radius: "倒角",
|
|
611
|
+
spacing: "间距",
|
|
612
|
+
fontSize: "字号",
|
|
613
|
+
font: "字体族"
|
|
614
|
+
};
|
|
615
|
+
/** 值是不是一个数字字串(老 `font` token 靠它识别字号;纯字体族名不会全是数字) */
|
|
616
|
+
function isNumericPresetValue(value) {
|
|
617
|
+
return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
|
|
618
|
+
}
|
|
619
|
+
function isRecord$7(v) {
|
|
620
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
621
|
+
}
|
|
622
|
+
/** 文档里携带的预设组(`x-paintress.presets.groups`);坏结构当没有 */
|
|
623
|
+
function presetGroupsOf(doc) {
|
|
624
|
+
const meta = isRecord$7(doc) ? doc["x-paintress"] : void 0;
|
|
625
|
+
const presets = isRecord$7(meta) ? meta["presets"] : void 0;
|
|
626
|
+
const groups = isRecord$7(presets) ? presets["groups"] : void 0;
|
|
627
|
+
if (!isRecord$7(groups)) return {};
|
|
628
|
+
const out = {};
|
|
629
|
+
for (const [name, raw] of Object.entries(groups)) {
|
|
630
|
+
if (!isRecord$7(raw)) continue;
|
|
631
|
+
const target = typeof raw["target"] === "string" ? raw["target"] : "";
|
|
632
|
+
const props = {};
|
|
633
|
+
const rawProps = raw["props"];
|
|
634
|
+
if (isRecord$7(rawProps)) {
|
|
635
|
+
for (const [k, v] of Object.entries(rawProps)) if (typeof v === "string" && v.trim() !== "") props[k] = v;
|
|
636
|
+
}
|
|
637
|
+
if (target === "" || Object.keys(props).length === 0) continue;
|
|
638
|
+
out[name] = {
|
|
639
|
+
target,
|
|
640
|
+
props
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* 渲染前把节点上的 `preset: '<name>'` 铺开:组属性打底,**节点自己的属性优先**。
|
|
647
|
+
* 返回新树(不改输入,保存永远写原文档);缺失/类型不符的引用只警告、不猜。
|
|
648
|
+
*/
|
|
649
|
+
function applyPresetRefs(nodes, groups, warnings) {
|
|
650
|
+
if (Object.keys(groups).length === 0) return nodes;
|
|
651
|
+
const walk = (list, where) => list.map((raw, i) => {
|
|
652
|
+
if (!isRecord$7(raw)) return raw;
|
|
653
|
+
let node = raw;
|
|
654
|
+
const name = node["preset"];
|
|
655
|
+
const label = `${String(node["type"] ?? "?")}${typeof node["name"] === "string" && node["name"] !== "" ? `: ${String(node["name"])}` : ""}`;
|
|
656
|
+
if (typeof name === "string" && name !== "") {
|
|
657
|
+
const group = groups[name];
|
|
658
|
+
if (group === void 0) warnings.push(`${label}: preset "${name}" 不存在`);
|
|
659
|
+
else if (group.target !== node["type"]) {
|
|
660
|
+
if (!Object.entries(group.props).every(([key, value]) => node[key] === value)) warnings.push(`${label}: preset "${name}" 适用于 ${group.target},节点是 ${String(node["type"])}`);
|
|
661
|
+
} else node = {
|
|
662
|
+
...group.props,
|
|
663
|
+
...node
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const kids = node["children"];
|
|
667
|
+
if (Array.isArray(kids)) node = {
|
|
668
|
+
...node,
|
|
669
|
+
children: walk(kids, `${where}/${i}`)
|
|
670
|
+
};
|
|
671
|
+
return node;
|
|
672
|
+
});
|
|
673
|
+
return walk(nodes, "children");
|
|
674
|
+
}
|
|
675
|
+
// src/doc/fonts.ts
|
|
676
|
+
const FONT_SOURCE_KINDS = [
|
|
677
|
+
"web",
|
|
678
|
+
"css",
|
|
679
|
+
"file",
|
|
680
|
+
"system",
|
|
681
|
+
"bundle"
|
|
682
|
+
];
|
|
683
|
+
function isFontSourceKind(v) {
|
|
684
|
+
return typeof v === "string" && FONT_SOURCE_KINDS.includes(v);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* paintress 自己的字体观:不带字体文件,只给 jsDelivr 上的 fontsource CSS。
|
|
688
|
+
* 版本钉死,换镜像/自托管只改这里与 seed 数据。
|
|
689
|
+
*/
|
|
690
|
+
const BUILTIN_FONT_LINKS = ["https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5.3.0/wght.css", "https://cdn.jsdelivr.net/npm/@fontsource-variable/noto-sans-sc@5.3.1/wght.css"];
|
|
691
|
+
/** 内置网络字体 CSS;`PAINTRESS_NO_WEB_FONTS=1` 时跳过(CI / 纯离线) */
|
|
692
|
+
function webFontLinks() {
|
|
693
|
+
return process.env["PAINTRESS_NO_WEB_FONTS"] === "1" ? [] : [...BUILTIN_FONT_LINKS];
|
|
694
|
+
}
|
|
695
|
+
/** 无项目默认时的内置栈;系统字体只是栈尾兵底 */
|
|
696
|
+
const BUILTIN_FONT_STACK = "\"Inter Variable\", \"Noto Sans SC Variable\", system-ui, -apple-system, \"Segoe UI\", sans-serif";
|
|
697
|
+
const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
|
|
698
|
+
"sans-serif",
|
|
699
|
+
"serif",
|
|
700
|
+
"monospace",
|
|
701
|
+
"cursive",
|
|
702
|
+
"fantasy",
|
|
703
|
+
"system-ui",
|
|
704
|
+
"ui-monospace",
|
|
705
|
+
"ui-sans-serif",
|
|
706
|
+
"ui-serif",
|
|
707
|
+
"ui-rounded",
|
|
708
|
+
"emoji",
|
|
709
|
+
"math",
|
|
710
|
+
"fangsong"
|
|
711
|
+
]);
|
|
712
|
+
function isGenericFamily(family) {
|
|
713
|
+
return GENERIC_FAMILIES.has(family.trim().toLowerCase());
|
|
714
|
+
}
|
|
715
|
+
function isRecord$6(v) {
|
|
716
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
717
|
+
}
|
|
718
|
+
/** `'Inter, "Noto Sans SC"'` → `['Inter', 'Noto Sans SC']`(去引号、去空项) */
|
|
719
|
+
function splitFontStack(stack) {
|
|
720
|
+
return stack.split(",").map((part) => part.trim().replace(/^["']|["']$/g, "").trim()).filter((part) => part !== "");
|
|
721
|
+
}
|
|
722
|
+
function quoteFamily(family) {
|
|
723
|
+
return /[\s]/.test(family) ? `"${family}"` : family;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 真字体在前、替代在后、`sans-serif` 兜底。替代值可以是 stack。
|
|
727
|
+
* 已经出现过的 family 不重复加(先出现的那一层优先,语义与 CSS 一致)。
|
|
728
|
+
*/
|
|
729
|
+
function stackWithPreviews(stack, previews) {
|
|
730
|
+
const production = splitFontStack(stack);
|
|
731
|
+
const seen = /* @__PURE__ */ new Set();
|
|
732
|
+
const out = [];
|
|
733
|
+
const push = (family) => {
|
|
734
|
+
const key = family.toLowerCase();
|
|
735
|
+
if (seen.has(key)) return;
|
|
736
|
+
seen.add(key);
|
|
737
|
+
out.push(quoteFamily(family));
|
|
738
|
+
};
|
|
739
|
+
for (const family of production) {
|
|
740
|
+
push(family);
|
|
741
|
+
const preview = previews[family];
|
|
742
|
+
if (preview === void 0) continue;
|
|
743
|
+
for (const entry of splitFontStack(preview)) push(entry);
|
|
744
|
+
}
|
|
745
|
+
if (!production.some((family) => isGenericFamily(family))) out.push("sans-serif");
|
|
746
|
+
return out.join(", ");
|
|
747
|
+
}
|
|
748
|
+
/** 节点显式写了的字体族(不展开 `$token`;token 的解析由渲染器完成) */
|
|
749
|
+
function stackFamilies(stack) {
|
|
750
|
+
return splitFontStack(stack).filter((family) => !isGenericFamily(family));
|
|
751
|
+
}
|
|
752
|
+
/** 解析 `x-paintress.fonts`;坏结构当空,不让一份手写文件把渲染搞崩 */
|
|
753
|
+
function fontRegistryOf(doc) {
|
|
754
|
+
const meta = isRecord$6(doc) ? doc["x-paintress"] : void 0;
|
|
755
|
+
const fonts = isRecord$6(meta) ? meta["fonts"] : void 0;
|
|
756
|
+
if (!isRecord$6(fonts)) return { families: {} };
|
|
757
|
+
const rawFamilies = isRecord$6(fonts["families"]) ? fonts["families"] : {};
|
|
758
|
+
const families = {};
|
|
759
|
+
for (const [family, raw] of Object.entries(rawFamilies)) {
|
|
760
|
+
if (!isRecord$6(raw)) continue;
|
|
761
|
+
const sourceRaw = isRecord$6(raw["source"]) ? raw["source"] : void 0;
|
|
762
|
+
if (sourceRaw === void 0 || !isFontSourceKind(sourceRaw["kind"])) continue;
|
|
763
|
+
const target = [
|
|
764
|
+
sourceRaw["target"],
|
|
765
|
+
sourceRaw["url"],
|
|
766
|
+
sourceRaw["path"]
|
|
767
|
+
].find((v) => typeof v === "string");
|
|
768
|
+
const source = {
|
|
769
|
+
kind: sourceRaw["kind"],
|
|
770
|
+
...target !== void 0 ? { target } : {}
|
|
771
|
+
};
|
|
772
|
+
const metricsRaw = isRecord$6(raw["metrics"]) ? raw["metrics"] : void 0;
|
|
773
|
+
const metrics = {};
|
|
774
|
+
if (metricsRaw) {
|
|
775
|
+
for (const key of [
|
|
776
|
+
"sizeAdjust",
|
|
777
|
+
"ascentOverride",
|
|
778
|
+
"descentOverride",
|
|
779
|
+
"lineGapOverride"
|
|
780
|
+
]) if (typeof metricsRaw[key] === "string" && metricsRaw[key] !== "") metrics[key] = metricsRaw[key];
|
|
781
|
+
}
|
|
782
|
+
families[family] = {
|
|
783
|
+
source,
|
|
784
|
+
...typeof raw["preview"] === "string" && raw["preview"] !== "" ? { preview: raw["preview"] } : {},
|
|
785
|
+
...Object.keys(metrics).length > 0 ? { metrics } : {},
|
|
786
|
+
...typeof raw["note"] === "string" && raw["note"] !== "" ? { note: raw["note"] } : {}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
...typeof fonts["default"] === "string" && fonts["default"] !== "" ? { default: fonts["default"] } : {},
|
|
791
|
+
families
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function varString(variables, key) {
|
|
795
|
+
const def = variables?.[key];
|
|
796
|
+
if (!isRecord$6(def)) return void 0;
|
|
797
|
+
const value = def["value"];
|
|
798
|
+
return typeof value === "string" ? value : void 0;
|
|
799
|
+
}
|
|
800
|
+
/** 默认栈:`default` 是 token key 就解析成值,否则按 family 用;未配置用内置栈 */
|
|
801
|
+
function resolveDefaultStack(registry, doc) {
|
|
802
|
+
const raw = registry.default;
|
|
803
|
+
if (raw === void 0 || raw === "") return BUILTIN_FONT_STACK;
|
|
804
|
+
const fromToken = varString(doc?.variables, raw);
|
|
805
|
+
if (fromToken !== void 0) return fromToken;
|
|
806
|
+
return raw;
|
|
807
|
+
}
|
|
808
|
+
const FORMAT_BY_EXT = {
|
|
809
|
+
".woff2": "woff2",
|
|
810
|
+
".woff": "woff",
|
|
811
|
+
".ttf": "truetype",
|
|
812
|
+
".otf": "opentype"
|
|
813
|
+
};
|
|
814
|
+
function fontFormat(target) {
|
|
815
|
+
const dot = target.lastIndexOf(".");
|
|
816
|
+
if (dot < 0) return void 0;
|
|
817
|
+
return FORMAT_BY_EXT[target.slice(dot).toLowerCase()];
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* 由注册表生成渲染计划。`resolveUrl` 决定 URL 的可达形式:
|
|
821
|
+
* 查看器给 `/files/<project>/<path>`,截图给 `file:///<abs>`,导出可以给 data URI。
|
|
822
|
+
*/
|
|
823
|
+
function planFonts(registry, resolveUrl, doc) {
|
|
824
|
+
const links = webFontLinks();
|
|
825
|
+
const faces = [];
|
|
826
|
+
const previews = {};
|
|
827
|
+
for (const [family, entry] of Object.entries(registry.families)) {
|
|
828
|
+
const { source } = entry;
|
|
829
|
+
if (source.kind === "web" && source.target !== void 0) links.push(source.target);
|
|
830
|
+
else if (source.kind === "css" && source.target !== void 0) {
|
|
831
|
+
const url = resolveUrl(source);
|
|
832
|
+
if (url !== null) links.push(url);
|
|
833
|
+
} else if (source.kind === "file" && source.target !== void 0) {
|
|
834
|
+
const url = resolveUrl(source);
|
|
835
|
+
if (url !== null) {
|
|
836
|
+
const format = fontFormat(source.target);
|
|
837
|
+
faces.push({
|
|
838
|
+
family,
|
|
839
|
+
src: url,
|
|
840
|
+
...format !== void 0 ? { format } : {}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (entry.preview !== void 0) {
|
|
845
|
+
const first = splitFontStack(entry.preview)[0];
|
|
846
|
+
if (entry.metrics !== void 0 && first !== void 0 && !isGenericFamily(first)) {
|
|
847
|
+
faces.push({
|
|
848
|
+
family: `Preview: ${family}`,
|
|
849
|
+
src: `local("${first}")`,
|
|
850
|
+
metrics: entry.metrics
|
|
851
|
+
});
|
|
852
|
+
previews[family] = `"Preview: ${family}", ${entry.preview}`;
|
|
853
|
+
} else previews[family] = entry.preview;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
links,
|
|
858
|
+
faces,
|
|
859
|
+
previews,
|
|
860
|
+
defaultStack: resolveDefaultStack(registry, doc)
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/** 字体缺口:没写 family 的文本、没登记的 family —— MCP 的 doc_info / validate / hints 共用 */
|
|
864
|
+
function textFontGaps(doc, registry) {
|
|
865
|
+
const ids = [];
|
|
866
|
+
const families = /* @__PURE__ */ new Set();
|
|
867
|
+
let textNodes = 0;
|
|
868
|
+
const walk = (nodes) => {
|
|
869
|
+
for (const raw of nodes) {
|
|
870
|
+
if (!isRecord$6(raw)) continue;
|
|
871
|
+
if (raw["type"] === "text") {
|
|
872
|
+
const ff = raw["fontFamily"];
|
|
873
|
+
if (typeof ff !== "string" || ff.trim() === "") {
|
|
874
|
+
textNodes += 1;
|
|
875
|
+
if (ids.length < 10 && typeof raw["id"] === "string") ids.push(raw["id"]);
|
|
876
|
+
} else if (!ff.startsWith("$")) for (const family of stackFamilies(ff)) families.add(family);
|
|
877
|
+
}
|
|
878
|
+
const kids = raw["children"];
|
|
879
|
+
if (Array.isArray(kids)) walk(kids);
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
walk(doc.children);
|
|
883
|
+
const list = [...families];
|
|
884
|
+
return {
|
|
885
|
+
textNodes,
|
|
886
|
+
ids,
|
|
887
|
+
families: list,
|
|
888
|
+
unregistered: list.filter((family) => registry.families[family] === void 0),
|
|
889
|
+
defaultStack: resolveDefaultStack(registry, doc)
|
|
890
|
+
};
|
|
891
|
+
}
|
|
576
892
|
// src/doc/id.ts
|
|
577
893
|
/** id 生成:5 位 base62,禁 '/'。与既有文档 id 风格一致(如 zae8o、bi8Au)。 */
|
|
578
894
|
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
@@ -596,6 +912,14 @@ function collectIds(children, out = []) {
|
|
|
596
912
|
return out;
|
|
597
913
|
}
|
|
598
914
|
// src/doc/validate.ts
|
|
915
|
+
/** 一个属性值里出现的所有 `$token` 引用(结构化填充、数组、逐边对象都要覆盖) */
|
|
916
|
+
function refsIn(value, out = []) {
|
|
917
|
+
if (typeof value === "string") {
|
|
918
|
+
if (VARIABLE_NAME.test(value)) out.push(value.slice(1));
|
|
919
|
+
} else if (Array.isArray(value)) for (const entry of value) refsIn(entry, out);
|
|
920
|
+
else if (value !== null && typeof value === "object") for (const entry of Object.values(value)) refsIn(entry, out);
|
|
921
|
+
return out;
|
|
922
|
+
}
|
|
599
923
|
/** 结构校验(schema)+ 语义校验(重复 id、悬空 ref、未知类型、未解析变量) */
|
|
600
924
|
function validateDoc(input, opts = {}) {
|
|
601
925
|
const errors = [];
|
|
@@ -614,6 +938,11 @@ function validateDoc(input, opts = {}) {
|
|
|
614
938
|
};
|
|
615
939
|
}
|
|
616
940
|
const doc = parsed.data;
|
|
941
|
+
const presetGroups = {
|
|
942
|
+
...opts.presetGroups ?? {},
|
|
943
|
+
...presetGroupsOf(doc)
|
|
944
|
+
};
|
|
945
|
+
const presetKinds = opts.presetKinds ?? {};
|
|
617
946
|
const allIds = collectIds(doc.children);
|
|
618
947
|
const seen = /* @__PURE__ */ new Set();
|
|
619
948
|
const dup = /* @__PURE__ */ new Set();
|
|
@@ -643,11 +972,53 @@ function validateDoc(input, opts = {}) {
|
|
|
643
972
|
path: here,
|
|
644
973
|
message: `ref 指向不存在的组件 id "${String(rec["ref"])}"`
|
|
645
974
|
});
|
|
975
|
+
const presetName = rec["preset"];
|
|
976
|
+
if (typeof presetName === "string" && presetName !== "") {
|
|
977
|
+
const group = presetGroups[presetName];
|
|
978
|
+
if (group === void 0) warnings.push({
|
|
979
|
+
level: "warning",
|
|
980
|
+
path: here,
|
|
981
|
+
message: `preset "${presetName}" 不存在`
|
|
982
|
+
});
|
|
983
|
+
else if (group.target !== type) warnings.push({
|
|
984
|
+
level: "warning",
|
|
985
|
+
path: here,
|
|
986
|
+
message: `preset "${presetName}" 适用于 ${group.target},这里是 ${String(type)}`
|
|
987
|
+
});
|
|
988
|
+
}
|
|
646
989
|
const kids = rec["children"];
|
|
647
990
|
if (Array.isArray(kids)) walk(kids, `${here}/children`);
|
|
991
|
+
for (const [prop, value] of Object.entries(rec)) {
|
|
992
|
+
if (prop === "children") continue;
|
|
993
|
+
const expected = PROP_TOKEN_KIND[prop];
|
|
994
|
+
if (expected === void 0) continue;
|
|
995
|
+
for (const ref of refsIn(value)) {
|
|
996
|
+
const actual = presetKinds[ref];
|
|
997
|
+
if (actual === void 0 || actual === expected) continue;
|
|
998
|
+
warnings.push({
|
|
999
|
+
level: "warning",
|
|
1000
|
+
path: here,
|
|
1001
|
+
message: `${prop} 需要${PRESET_TYPE_LABEL[expected]} token,$${ref} 是${PRESET_TYPE_LABEL[actual]}(类型 ${actual})`
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
648
1005
|
}
|
|
649
1006
|
};
|
|
650
1007
|
walk(doc.children, "children");
|
|
1008
|
+
for (const [name, group] of Object.entries(presetGroups)) for (const [prop, value] of Object.entries(group.props)) if (!VARIABLE_NAME.test(value)) warnings.push({
|
|
1009
|
+
level: "warning",
|
|
1010
|
+
path: `<preset:${name}>`,
|
|
1011
|
+
message: `preset 组里的 ${prop} 是字面量 "${value}",应当写成 $token 引用`
|
|
1012
|
+
});
|
|
1013
|
+
else {
|
|
1014
|
+
const expected = PROP_TOKEN_KIND[prop];
|
|
1015
|
+
const actual = presetKinds[value.slice(1)];
|
|
1016
|
+
if (expected !== void 0 && actual !== void 0 && actual !== expected) warnings.push({
|
|
1017
|
+
level: "warning",
|
|
1018
|
+
path: `<preset:${name}>`,
|
|
1019
|
+
message: `${prop} 需要${PRESET_TYPE_LABEL[expected]} token,${value} 是${PRESET_TYPE_LABEL[actual]}(类型 ${actual})`
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
651
1022
|
const localNames = new Set(doc.variables ? Object.keys(doc.variables) : []);
|
|
652
1023
|
const varNames = /* @__PURE__ */ new Set([...localNames, ...opts.knownVars ?? []]);
|
|
653
1024
|
for (const name of opts.presetVars ?? []) if (localNames.has(name)) warnings.push({
|
|
@@ -662,11 +1033,40 @@ function validateDoc(input, opts = {}) {
|
|
|
662
1033
|
else if (obj && typeof obj === "object") Object.values(obj).forEach(scan);
|
|
663
1034
|
};
|
|
664
1035
|
scan(doc.children);
|
|
1036
|
+
for (const group of Object.values(presetGroups)) for (const value of Object.values(group.props)) scan(value);
|
|
665
1037
|
for (const name of varRefs) if (!varNames.has(name)) warnings.push({
|
|
666
1038
|
level: "warning",
|
|
667
1039
|
path: "<variables>",
|
|
668
1040
|
message: `字段引用了未定义的变量 $${name}`
|
|
669
1041
|
});
|
|
1042
|
+
if (opts.fontRegistry !== void 0) {
|
|
1043
|
+
const gaps = textFontGaps(doc, opts.fontRegistry);
|
|
1044
|
+
if (gaps.textNodes > 0) {
|
|
1045
|
+
const sample = gaps.ids.length > 0 ? `(如 ${gaps.ids.join("、")})` : "";
|
|
1046
|
+
warnings.push({
|
|
1047
|
+
level: "warning",
|
|
1048
|
+
path: "<fonts>",
|
|
1049
|
+
message: `${gaps.textNodes} 个 text 节点没有 fontFamily,当前用默认字体 ${gaps.defaultStack}${sample}`
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
for (const family of gaps.unregistered) warnings.push({
|
|
1053
|
+
level: "warning",
|
|
1054
|
+
path: "<fonts>",
|
|
1055
|
+
message: `字体未登记:${family}(paintress_font add)`
|
|
1056
|
+
});
|
|
1057
|
+
for (const [family, entry] of Object.entries(opts.fontRegistry.families)) if ((entry.source.kind === "system" || entry.source.kind === "bundle") && entry.preview === void 0) warnings.push({
|
|
1058
|
+
level: "warning",
|
|
1059
|
+
path: `<font:${family}>`,
|
|
1060
|
+
message: `${family} 是 ${entry.source.kind} 来源,没有 preview:截图侧可能不可用`
|
|
1061
|
+
});
|
|
1062
|
+
const def = opts.fontRegistry.default;
|
|
1063
|
+
const known = new Set(opts.knownVars ?? []);
|
|
1064
|
+
if (def !== void 0 && opts.fontRegistry.families[def] === void 0 && !known.has(def)) warnings.push({
|
|
1065
|
+
level: "warning",
|
|
1066
|
+
path: "<fonts>",
|
|
1067
|
+
message: `默认字体 ${def} 没有登记`
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
670
1070
|
return {
|
|
671
1071
|
ok: errors.length === 0,
|
|
672
1072
|
errors,
|
|
@@ -933,7 +1333,7 @@ function batch(doc, ops) {
|
|
|
933
1333
|
return clone;
|
|
934
1334
|
}
|
|
935
1335
|
// src/render/vars.ts
|
|
936
|
-
function isRecord$
|
|
1336
|
+
function isRecord$5(v) {
|
|
937
1337
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
938
1338
|
}
|
|
939
1339
|
function asVarValue(v) {
|
|
@@ -947,7 +1347,7 @@ function asVarValue(v) {
|
|
|
947
1347
|
*/
|
|
948
1348
|
function resolveThemed(v, theme) {
|
|
949
1349
|
if (Array.isArray(v)) {
|
|
950
|
-
const entries = v.filter(isRecord$
|
|
1350
|
+
const entries = v.filter(isRecord$5);
|
|
951
1351
|
if (entries.length === 0) return void 0;
|
|
952
1352
|
let best = null;
|
|
953
1353
|
for (const entry of entries) {
|
|
@@ -959,7 +1359,7 @@ function resolveThemed(v, theme) {
|
|
|
959
1359
|
};
|
|
960
1360
|
continue;
|
|
961
1361
|
}
|
|
962
|
-
if (!isRecord$
|
|
1362
|
+
if (!isRecord$5(t)) continue;
|
|
963
1363
|
const keys = Object.keys(t);
|
|
964
1364
|
if (!keys.every((k) => theme[k] === String(t[k]))) continue;
|
|
965
1365
|
if (best === null || keys.length > best.score) best = {
|
|
@@ -976,7 +1376,7 @@ function makeVarResolver(doc, warnings, theme = {}) {
|
|
|
976
1376
|
const make = (scope) => {
|
|
977
1377
|
const lookup = (name) => {
|
|
978
1378
|
const def = vars[name];
|
|
979
|
-
if (!isRecord$
|
|
1379
|
+
if (!isRecord$5(def)) return void 0;
|
|
980
1380
|
return resolveThemed(def["value"], scope);
|
|
981
1381
|
};
|
|
982
1382
|
return {
|
|
@@ -1027,7 +1427,7 @@ function makeVarResolver(doc, warnings, theme = {}) {
|
|
|
1027
1427
|
return make(theme);
|
|
1028
1428
|
}
|
|
1029
1429
|
// src/render/expand.ts
|
|
1030
|
-
function isRecord$
|
|
1430
|
+
function isRecord$4(v) {
|
|
1031
1431
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1032
1432
|
}
|
|
1033
1433
|
function brokenRefPlaceholder(refNode, reason, warnings) {
|
|
@@ -1048,7 +1448,7 @@ function indexById(children) {
|
|
|
1048
1448
|
const map = /* @__PURE__ */ new Map();
|
|
1049
1449
|
const walk = (nodes) => {
|
|
1050
1450
|
for (const n of nodes) {
|
|
1051
|
-
if (!isRecord$
|
|
1451
|
+
if (!isRecord$4(n)) continue;
|
|
1052
1452
|
const id = n["id"];
|
|
1053
1453
|
if (typeof id === "string") map.set(id, n);
|
|
1054
1454
|
const kids = n["children"];
|
|
@@ -1065,7 +1465,7 @@ function findByPath(root, segments) {
|
|
|
1065
1465
|
for (const seg of segments) {
|
|
1066
1466
|
const kids = container["children"];
|
|
1067
1467
|
if (!Array.isArray(kids)) return null;
|
|
1068
|
-
found = kids.find((k) => isRecord$
|
|
1468
|
+
found = kids.find((k) => isRecord$4(k) && k["id"] === seg) ?? null;
|
|
1069
1469
|
if (!found) return null;
|
|
1070
1470
|
container = found;
|
|
1071
1471
|
}
|
|
@@ -1076,7 +1476,7 @@ function findByPath(root, segments) {
|
|
|
1076
1476
|
}
|
|
1077
1477
|
function applyDescendants(expandedRoot, descendants, warnings, refId) {
|
|
1078
1478
|
for (const [path, overrideRaw] of Object.entries(descendants)) {
|
|
1079
|
-
if (!isRecord$
|
|
1479
|
+
if (!isRecord$4(overrideRaw)) continue;
|
|
1080
1480
|
const hit = findByPath(expandedRoot, path.split("/").filter((s) => s !== ""));
|
|
1081
1481
|
if (!hit) {
|
|
1082
1482
|
warnings.push(`ref ${refId} 的 descendants 路径 "${path}" 未命中,已忽略`);
|
|
@@ -1096,7 +1496,7 @@ function applyDescendants(expandedRoot, descendants, warnings, refId) {
|
|
|
1096
1496
|
/** 递归展开一个节点(就地修改克隆树) */
|
|
1097
1497
|
function expandNode(node, index, stack, warnings) {
|
|
1098
1498
|
const kids = node["children"];
|
|
1099
|
-
if (Array.isArray(kids)) node["children"] = kids.map((k) => isRecord$
|
|
1499
|
+
if (Array.isArray(kids)) node["children"] = kids.map((k) => isRecord$4(k) ? expandNode(k, index, stack, warnings) : k);
|
|
1100
1500
|
if (node["type"] !== "ref") return node;
|
|
1101
1501
|
const refId = typeof node["id"] === "string" ? node["id"] : "?";
|
|
1102
1502
|
const target = typeof node["ref"] === "string" ? node["ref"] : "";
|
|
@@ -1108,13 +1508,13 @@ function expandNode(node, index, stack, warnings) {
|
|
|
1108
1508
|
const clone = structuredClone(component);
|
|
1109
1509
|
clone["id"] = node["id"];
|
|
1110
1510
|
if (typeof node["name"] === "string") clone["name"] = node["name"];
|
|
1111
|
-
if (Array.isArray(clone["children"])) clone["children"] = clone["children"].map((k) => isRecord$
|
|
1511
|
+
if (Array.isArray(clone["children"])) clone["children"] = clone["children"].map((k) => isRecord$4(k) ? expandNode(k, index, stack, warnings) : k);
|
|
1112
1512
|
for (const [k, v] of Object.entries(node)) {
|
|
1113
1513
|
if (k === "type" || k === "ref" || k === "descendants" || k === "id" || k === "name") continue;
|
|
1114
1514
|
clone[k] = structuredClone(v);
|
|
1115
1515
|
}
|
|
1116
1516
|
const desc = node["descendants"];
|
|
1117
|
-
if (isRecord$
|
|
1517
|
+
if (isRecord$4(desc)) applyDescendants(clone, desc, warnings, refId);
|
|
1118
1518
|
return clone;
|
|
1119
1519
|
} finally {
|
|
1120
1520
|
stack.delete(target);
|
|
@@ -1126,21 +1526,72 @@ function expandRefs(doc, warnings) {
|
|
|
1126
1526
|
return structuredClone(doc.children).map((n) => expandNode(n, index, /* @__PURE__ */ new Set(), warnings));
|
|
1127
1527
|
}
|
|
1128
1528
|
// src/render/style.ts
|
|
1129
|
-
function isRecord$
|
|
1529
|
+
function isRecord$3(v) {
|
|
1130
1530
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1131
1531
|
}
|
|
1132
1532
|
function colorFromFillEntry(entry, vars) {
|
|
1133
1533
|
if (typeof entry === "string") return vars.resolve(entry);
|
|
1134
|
-
if (isRecord$
|
|
1534
|
+
if (isRecord$3(entry)) {
|
|
1135
1535
|
const c = entry["color"];
|
|
1136
1536
|
if (typeof c === "string") return vars.resolve(c);
|
|
1137
1537
|
}
|
|
1138
1538
|
}
|
|
1539
|
+
/** 平均色:网格渐变的底色(解析不了就回落首个色) */
|
|
1540
|
+
function averageColor(colors) {
|
|
1541
|
+
let r = 0;
|
|
1542
|
+
let g = 0;
|
|
1543
|
+
let b = 0;
|
|
1544
|
+
let n = 0;
|
|
1545
|
+
for (const c of colors) {
|
|
1546
|
+
const hex = /^#((?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))$/.exec(c.trim());
|
|
1547
|
+
if (hex === null) continue;
|
|
1548
|
+
let raw = hex[1];
|
|
1549
|
+
if (raw.length === 3) raw = raw.split("").map((ch) => ch + ch).join("");
|
|
1550
|
+
r += parseInt(raw.slice(0, 2), 16);
|
|
1551
|
+
g += parseInt(raw.slice(2, 4), 16);
|
|
1552
|
+
b += parseInt(raw.slice(4, 6), 16);
|
|
1553
|
+
n += 1;
|
|
1554
|
+
}
|
|
1555
|
+
if (n === 0) return colors[0] ?? "#000000";
|
|
1556
|
+
return `rgb(${Math.round(r / n)} ${Math.round(g / n)} ${Math.round(b / n)})`;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* mesh_gradient 近似:每个网格点一层径向渐变(该点颜色 → 透明),
|
|
1560
|
+
* 底下垫一层平均色。浏览器按预乘 alpha 插值,叠出来就是平滑的网格混色。
|
|
1561
|
+
* `columns × rows` 缺省、颜色数不足或网格过大时返回 null(调用方按首个顶点色降级)。
|
|
1562
|
+
*/
|
|
1563
|
+
function meshLayers(entry, vars) {
|
|
1564
|
+
const cols = vars.resolveNum(entry["columns"]);
|
|
1565
|
+
const rows = vars.resolveNum(entry["rows"]);
|
|
1566
|
+
const raw = entry["colors"];
|
|
1567
|
+
if (cols === void 0 || rows === void 0 || !Array.isArray(raw)) return null;
|
|
1568
|
+
const c = Math.round(cols);
|
|
1569
|
+
const r = Math.round(rows);
|
|
1570
|
+
if (c < 1 || r < 1) return null;
|
|
1571
|
+
const colors = raw.map((x) => colorFromFillEntry(x, vars));
|
|
1572
|
+
if (colors.length < c * r || colors.some((x) => x === void 0)) return null;
|
|
1573
|
+
const cells = c * r;
|
|
1574
|
+
if (cells > 144) return null;
|
|
1575
|
+
const images = [];
|
|
1576
|
+
for (let row = 0; row < r; row++) for (let col = 0; col < c; col++) {
|
|
1577
|
+
const color = colors[row * c + col];
|
|
1578
|
+
const cx = (col + .5) / c * 100;
|
|
1579
|
+
const cy = (row + .5) / r * 100;
|
|
1580
|
+
const sx = 100 / c * 1.4;
|
|
1581
|
+
const sy = 100 / r * 1.4;
|
|
1582
|
+
images.push(`radial-gradient(ellipse ${sx.toFixed(2)}% ${sy.toFixed(2)}% at ${cx.toFixed(2)}% ${cy.toFixed(2)}%, ${color} 0%, transparent 72%)`);
|
|
1583
|
+
}
|
|
1584
|
+
return {
|
|
1585
|
+
images,
|
|
1586
|
+
base: averageColor(colors.filter((x) => x !== void 0)),
|
|
1587
|
+
cells
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1139
1590
|
function stopsToCssList(colors, vars) {
|
|
1140
1591
|
if (!Array.isArray(colors) || colors.length === 0) return void 0;
|
|
1141
1592
|
const parts = [];
|
|
1142
1593
|
for (const c of colors) {
|
|
1143
|
-
if (!isRecord$
|
|
1594
|
+
if (!isRecord$3(c)) continue;
|
|
1144
1595
|
const col = colorFromFillEntry(c["color"], vars);
|
|
1145
1596
|
const pos = vars.resolveNum(c["position"]);
|
|
1146
1597
|
if (col === void 0) continue;
|
|
@@ -1162,7 +1613,7 @@ function penAngleToCss(deg) {
|
|
|
1162
1613
|
return `${(((90 - deg) % 360 + 360) % 360).toFixed(1)}deg`;
|
|
1163
1614
|
}
|
|
1164
1615
|
function normCenter(c, vars) {
|
|
1165
|
-
if (isRecord$
|
|
1616
|
+
if (isRecord$3(c)) {
|
|
1166
1617
|
const rx = vars?.resolveNum(c["x"]) ?? (typeof c["x"] === "number" ? c["x"] : void 0);
|
|
1167
1618
|
const ry = vars?.resolveNum(c["y"]) ?? (typeof c["y"] === "number" ? c["y"] : void 0);
|
|
1168
1619
|
if (rx !== void 0 && ry !== void 0) return {
|
|
@@ -1181,7 +1632,7 @@ function normCenter(c, vars) {
|
|
|
1181
1632
|
* 另一边按正方形处理(与 Pencil 的径向渐变一致)。
|
|
1182
1633
|
*/
|
|
1183
1634
|
function normSize(size, vars) {
|
|
1184
|
-
if (!isRecord$
|
|
1635
|
+
if (!isRecord$3(size)) return null;
|
|
1185
1636
|
const rw = vars.resolveNum(size["width"]);
|
|
1186
1637
|
const rh = vars.resolveNum(size["height"]);
|
|
1187
1638
|
if (rw === void 0 && rh === void 0) return null;
|
|
@@ -1196,11 +1647,11 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1196
1647
|
const bgImages = [];
|
|
1197
1648
|
let bgColor;
|
|
1198
1649
|
for (const entry of entries) {
|
|
1199
|
-
if (typeof entry === "string" || isRecord$
|
|
1650
|
+
if (typeof entry === "string" || isRecord$3(entry) && entry["type"] === void 0) {
|
|
1200
1651
|
bgColor = colorFromFillEntry(entry, vars);
|
|
1201
1652
|
continue;
|
|
1202
1653
|
}
|
|
1203
|
-
if (!isRecord$
|
|
1654
|
+
if (!isRecord$3(entry)) continue;
|
|
1204
1655
|
if (vars.resolveBool(entry["enabled"]) === false) continue;
|
|
1205
1656
|
const kind = entry["type"];
|
|
1206
1657
|
if (kind === "color") bgColor = colorFromFillEntry(entry, vars);
|
|
@@ -1231,11 +1682,17 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1231
1682
|
decls.push(["background-repeat", "no-repeat"]);
|
|
1232
1683
|
} else warnings.push(`${what}: 图像填充缺少 url,已跳过`);
|
|
1233
1684
|
} else if (kind === "mesh_gradient") {
|
|
1234
|
-
const
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
} else
|
|
1685
|
+
const mesh = meshLayers(entry, vars);
|
|
1686
|
+
if (mesh !== null) {
|
|
1687
|
+
bgImages.push(...mesh.images);
|
|
1688
|
+
if (bgColor === void 0) bgColor = mesh.base;
|
|
1689
|
+
} else {
|
|
1690
|
+
const first = colorFromFillEntry(entry["colors"]?.[0], vars);
|
|
1691
|
+
if (first) {
|
|
1692
|
+
bgColor = first;
|
|
1693
|
+
warnings.push(`${what}: mesh_gradient 网格不完整(或超过 144 格),降级为首个顶点色`);
|
|
1694
|
+
} else warnings.push(`${what}: mesh_gradient 无法降级,已跳过`);
|
|
1695
|
+
}
|
|
1239
1696
|
} else if (kind === "shader") warnings.push(`${what}: shader 填充暂不支持渲染,已跳过`);
|
|
1240
1697
|
}
|
|
1241
1698
|
if (bgImages.length > 0) decls.unshift(["background-image", bgImages.join(", ")]);
|
|
@@ -1256,7 +1713,7 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1256
1713
|
function strokeToCss(node, vars, warnings, what) {
|
|
1257
1714
|
const stroke = node["stroke"];
|
|
1258
1715
|
if (stroke === void 0) return [];
|
|
1259
|
-
const enabled = (Array.isArray(stroke) ? stroke : [stroke]).filter((e) => !(isRecord$
|
|
1716
|
+
const enabled = (Array.isArray(stroke) ? stroke : [stroke]).filter((e) => !(isRecord$3(e) && e["enabled"] === false));
|
|
1260
1717
|
if (enabled.length === 0) return [];
|
|
1261
1718
|
const firstColor = enabled.map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
|
|
1262
1719
|
if (firstColor === void 0) return [];
|
|
@@ -1265,8 +1722,8 @@ function strokeToCss(node, vars, warnings, what) {
|
|
|
1265
1722
|
const alignRaw = node["strokeAlignment"];
|
|
1266
1723
|
const align = alignRaw === "outer" || alignRaw === "center" ? alignRaw : "inner";
|
|
1267
1724
|
const widthRaw = node["strokeWidth"];
|
|
1268
|
-
if (isRecord$
|
|
1269
|
-
if (!isRecord$
|
|
1725
|
+
if (isRecord$3(widthRaw) && align !== "inner") warnings.push(`${what}: 逐边描边宽度 + strokeAlignment=${align} 暂按 inner(CSS border)渲染`);
|
|
1726
|
+
if (!isRecord$3(widthRaw) && align !== "inner") {
|
|
1270
1727
|
const w = vars.resolveNum(widthRaw) ?? 1;
|
|
1271
1728
|
decls.push(["outline", `${w}px solid ${firstColor}`]);
|
|
1272
1729
|
decls.push(["outline-offset", align === "outer" ? "0" : `${-w / 2}px`]);
|
|
@@ -1276,7 +1733,7 @@ function strokeToCss(node, vars, warnings, what) {
|
|
|
1276
1733
|
if (typeof widthRaw === "number") {
|
|
1277
1734
|
decls.push(["border-width", `${widthRaw}px`]);
|
|
1278
1735
|
decls.push(["border-style", "solid"]);
|
|
1279
|
-
} else if (isRecord$
|
|
1736
|
+
} else if (isRecord$3(widthRaw)) {
|
|
1280
1737
|
const sides = [];
|
|
1281
1738
|
for (const [k, cssName] of [
|
|
1282
1739
|
["top", "border-top"],
|
|
@@ -1306,10 +1763,10 @@ function effectsToCss(effectsRaw, vars, warnings, what) {
|
|
|
1306
1763
|
let blurPx;
|
|
1307
1764
|
let bgBlurPx;
|
|
1308
1765
|
for (const e of entries) {
|
|
1309
|
-
if (!isRecord$
|
|
1766
|
+
if (!isRecord$3(e)) continue;
|
|
1310
1767
|
if (e["type"] === "shadow") {
|
|
1311
1768
|
const inner = e["shadowType"] === "inner";
|
|
1312
|
-
const off = isRecord$
|
|
1769
|
+
const off = isRecord$3(e["offset"]) ? e["offset"] : {};
|
|
1313
1770
|
const x = vars.resolveNum(off["x"]) ?? 0;
|
|
1314
1771
|
const y = vars.resolveNum(off["y"]) ?? 0;
|
|
1315
1772
|
const b = vars.resolveNum(e["blur"]) ?? 0;
|
|
@@ -1325,12 +1782,12 @@ function effectsToCss(effectsRaw, vars, warnings, what) {
|
|
|
1325
1782
|
return decls;
|
|
1326
1783
|
}
|
|
1327
1784
|
/** 文本样式;文本颜色来自 fill(实文件如此:text 节点 fill 即字色) */
|
|
1328
|
-
function textToCss(node, vars, warnings) {
|
|
1785
|
+
function textToCss(node, vars, warnings, fontPreviews) {
|
|
1329
1786
|
const decls = [];
|
|
1330
1787
|
const num = (k) => vars.resolveNum(node[k]);
|
|
1331
1788
|
const str = (k) => typeof node[k] === "string" ? vars.resolve(node[k]) : void 0;
|
|
1332
1789
|
const ff = str("fontFamily");
|
|
1333
|
-
if (ff) decls.push(["font-family", ff]);
|
|
1790
|
+
if (ff) decls.push(["font-family", fontPreviews === void 0 ? ff : stackWithPreviews(ff, fontPreviews)]);
|
|
1334
1791
|
const fs = num("fontSize");
|
|
1335
1792
|
if (fs !== void 0) decls.push(["font-size", `${fs}px`]);
|
|
1336
1793
|
const fw = str("fontWeight");
|
|
@@ -1507,6 +1964,198 @@ function resolveIconName(library, name) {
|
|
|
1507
1964
|
if (direct !== void 0) return direct;
|
|
1508
1965
|
return name;
|
|
1509
1966
|
}
|
|
1967
|
+
// src/render/geometry.ts
|
|
1968
|
+
/**
|
|
1969
|
+
* 圆弧 / 圆环 / 圆角多边形的几何(C1、C2)。
|
|
1970
|
+
*
|
|
1971
|
+
* 只产出**路径字符串**,坐标系是画板像素(viewBox = `0 0 w h`),
|
|
1972
|
+
* 这样描边宽度不会被非等比缩放拉变形。尺寸拿不到数值时调用方退回旧渲染(+ 警告),
|
|
1973
|
+
* 不在几何层猜尺寸。
|
|
1974
|
+
*
|
|
1975
|
+
* 角度约定(format/spec.yaml §nodes.ellipse):0° 在正右、逆时针为正、sweep 正逆负顺;
|
|
1976
|
+
* 而 SVG 的 y 轴向下、sweep-flag=1 是顺时针,所以正向扇形的 flag 是 0。
|
|
1977
|
+
*/
|
|
1978
|
+
function isRecord$2(v) {
|
|
1979
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1980
|
+
}
|
|
1981
|
+
/** 角度 → 椭圆上的点(度,0° 正右,逆时针为正;渲染坐标系 y 向下) */
|
|
1982
|
+
function ellipsePoint(cx, cy, rx, ry, deg) {
|
|
1983
|
+
const a = deg * Math.PI / 180;
|
|
1984
|
+
return {
|
|
1985
|
+
x: cx + rx * Math.cos(a),
|
|
1986
|
+
y: cy - ry * Math.sin(a)
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
function fmt(n) {
|
|
1990
|
+
return String(Math.round(n * 1e3) / 1e3);
|
|
1991
|
+
}
|
|
1992
|
+
function arcTo(rx, ry, large, ccw, p) {
|
|
1993
|
+
return `A ${fmt(rx)} ${fmt(ry)} 0 ${large ? 1 : 0} ${ccw ? 0 : 1} ${fmt(p.x)} ${fmt(p.y)}`;
|
|
1994
|
+
}
|
|
1995
|
+
/** 是否需要走 SVG(整圆实心仍旧用 div 渲染,保证既有产物像素不变) */
|
|
1996
|
+
function needsEllipseSvg(input) {
|
|
1997
|
+
const sweep = input.sweepAngle ?? 360;
|
|
1998
|
+
return (input.innerRadius ?? 0) > 0 || Math.abs(sweep) < 360;
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* 椭圆形状的 SVG path(px 坐标)。
|
|
2002
|
+
* - inner=0 + 整圆:不在本函数处理(调用方用 div)
|
|
2003
|
+
* - 整圆 + inner>0:外圆 + 内圆(evenodd)
|
|
2004
|
+
* - 部分弧:扇形(inner=0)或扇环(inner>0)
|
|
2005
|
+
*/
|
|
2006
|
+
function ellipsePath(input) {
|
|
2007
|
+
const w = Math.max(1, input.width);
|
|
2008
|
+
const h = Math.max(1, input.height);
|
|
2009
|
+
const cx = w / 2;
|
|
2010
|
+
const cy = h / 2;
|
|
2011
|
+
const rx = w / 2;
|
|
2012
|
+
const ry = h / 2;
|
|
2013
|
+
const start = input.startAngle ?? 0;
|
|
2014
|
+
const sweepRaw = input.sweepAngle ?? 360;
|
|
2015
|
+
const sweep = Math.max(-360, Math.min(360, sweepRaw));
|
|
2016
|
+
const inner = Math.max(0, Math.min(1, input.innerRadius ?? 0));
|
|
2017
|
+
const full = Math.abs(sweep) >= 360;
|
|
2018
|
+
if (sweep === 0) return "";
|
|
2019
|
+
if (full) {
|
|
2020
|
+
const rix = rx * inner;
|
|
2021
|
+
const riy = ry * inner;
|
|
2022
|
+
if (inner <= 0) return `M ${fmt(cx - rx)} ${fmt(cy)} ${arcTo(rx, ry, false, true, {
|
|
2023
|
+
x: cx + rx,
|
|
2024
|
+
y: cy
|
|
2025
|
+
})} ${arcTo(rx, ry, false, true, {
|
|
2026
|
+
x: cx - rx,
|
|
2027
|
+
y: cy
|
|
2028
|
+
})} Z`;
|
|
2029
|
+
return `${`M ${fmt(cx - rx)} ${fmt(cy)} ${arcTo(rx, ry, false, true, {
|
|
2030
|
+
x: cx + rx,
|
|
2031
|
+
y: cy
|
|
2032
|
+
})} ${arcTo(rx, ry, false, true, {
|
|
2033
|
+
x: cx - rx,
|
|
2034
|
+
y: cy
|
|
2035
|
+
})} Z`} ${`M ${fmt(cx - rix)} ${fmt(cy)} ${arcTo(rix, riy, false, true, {
|
|
2036
|
+
x: cx + rix,
|
|
2037
|
+
y: cy
|
|
2038
|
+
})} ${arcTo(rix, riy, false, true, {
|
|
2039
|
+
x: cx - rix,
|
|
2040
|
+
y: cy
|
|
2041
|
+
})} Z`}`;
|
|
2042
|
+
}
|
|
2043
|
+
const a0 = start;
|
|
2044
|
+
const a1 = start + sweep;
|
|
2045
|
+
const large = Math.abs(sweep) > 180;
|
|
2046
|
+
const ccw = sweep > 0;
|
|
2047
|
+
const p0 = ellipsePoint(cx, cy, rx, ry, a0);
|
|
2048
|
+
const p1 = ellipsePoint(cx, cy, rx, ry, a1);
|
|
2049
|
+
if (inner <= 0) return `M ${fmt(cx)} ${fmt(cy)} L ${fmt(p0.x)} ${fmt(p0.y)} ${arcTo(rx, ry, large, ccw, p1)} Z`;
|
|
2050
|
+
const rix = rx * inner;
|
|
2051
|
+
const riy = ry * inner;
|
|
2052
|
+
const q0 = ellipsePoint(cx, cy, rix, riy, a0);
|
|
2053
|
+
const q1 = ellipsePoint(cx, cy, rix, riy, a1);
|
|
2054
|
+
return [
|
|
2055
|
+
`M ${fmt(p0.x)} ${fmt(p0.y)}`,
|
|
2056
|
+
arcTo(rx, ry, large, ccw, p1),
|
|
2057
|
+
`L ${fmt(q1.x)} ${fmt(q1.y)}`,
|
|
2058
|
+
arcTo(rix, riy, large, !ccw, q0),
|
|
2059
|
+
"Z"
|
|
2060
|
+
].join(" ");
|
|
2061
|
+
}
|
|
2062
|
+
/** 多边形顶点:−90° 起、按屏幕顺时针排,和旧 clip-path 的位置一致 */
|
|
2063
|
+
function polygonPoints(input) {
|
|
2064
|
+
const w = Math.max(1, input.width);
|
|
2065
|
+
const h = Math.max(1, input.height);
|
|
2066
|
+
const n = Math.max(3, Math.round(input.count ?? 3));
|
|
2067
|
+
const cx = w / 2;
|
|
2068
|
+
const cy = h / 2;
|
|
2069
|
+
const rx = w / 2;
|
|
2070
|
+
const ry = h / 2;
|
|
2071
|
+
const pts = [];
|
|
2072
|
+
for (let i = 0; i < n; i++) {
|
|
2073
|
+
const a = (-90 + 360 / n * i) * Math.PI / 180;
|
|
2074
|
+
pts.push({
|
|
2075
|
+
x: cx + rx * Math.cos(a),
|
|
2076
|
+
y: cy + ry * Math.sin(a)
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
return pts;
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* 圆角多边形:每个顶点按内切圆半径 r 退让 d = r / tan(内角/2),
|
|
2083
|
+
* 用二次贝塞尔过顶点圆角。d 被限制在相邻边长的 45% 以内,避免小边被吃穿。
|
|
2084
|
+
*/
|
|
2085
|
+
function polygonPath(input) {
|
|
2086
|
+
const pts = polygonPoints(input);
|
|
2087
|
+
const n = pts.length;
|
|
2088
|
+
const r = Math.max(0, input.cornerRadius ?? 0);
|
|
2089
|
+
if (r <= 0) return `M ${pts.map((p) => `${fmt(p.x)} ${fmt(p.y)}`).join(" L ")} Z`;
|
|
2090
|
+
const parts = [];
|
|
2091
|
+
for (let i = 0; i < n; i++) {
|
|
2092
|
+
const prev = pts[(i - 1 + n) % n];
|
|
2093
|
+
const cur = pts[i];
|
|
2094
|
+
const next = pts[(i + 1) % n];
|
|
2095
|
+
const v0 = {
|
|
2096
|
+
x: prev.x - cur.x,
|
|
2097
|
+
y: prev.y - cur.y
|
|
2098
|
+
};
|
|
2099
|
+
const v1 = {
|
|
2100
|
+
x: next.x - cur.x,
|
|
2101
|
+
y: next.y - cur.y
|
|
2102
|
+
};
|
|
2103
|
+
const len0 = Math.hypot(v0.x, v0.y) || 1;
|
|
2104
|
+
const len1 = Math.hypot(v1.x, v1.y) || 1;
|
|
2105
|
+
const cos = Math.max(-1, Math.min(1, (v0.x * v1.x + v0.y * v1.y) / (len0 * len1)));
|
|
2106
|
+
const interior = Math.acos(cos);
|
|
2107
|
+
const dWant = interior > 1e-6 ? r / Math.tan(interior / 2) : Number.POSITIVE_INFINITY;
|
|
2108
|
+
const d = Math.min(dWant, len0 * .45, len1 * .45);
|
|
2109
|
+
const a = {
|
|
2110
|
+
x: cur.x + v0.x / len0 * d,
|
|
2111
|
+
y: cur.y + v0.y / len0 * d
|
|
2112
|
+
};
|
|
2113
|
+
const b = {
|
|
2114
|
+
x: cur.x + v1.x / len1 * d,
|
|
2115
|
+
y: cur.y + v1.y / len1 * d
|
|
2116
|
+
};
|
|
2117
|
+
parts.push(i === 0 ? `M ${fmt(a.x)} ${fmt(a.y)}` : `L ${fmt(a.x)} ${fmt(a.y)}`);
|
|
2118
|
+
parts.push(`Q ${fmt(cur.x)} ${fmt(cur.y)} ${fmt(b.x)} ${fmt(b.y)}`);
|
|
2119
|
+
}
|
|
2120
|
+
parts.push("Z");
|
|
2121
|
+
return parts.join(" ");
|
|
2122
|
+
}
|
|
2123
|
+
/** 顶层实色(填充列表里最后一层生效的 color);有渐变/图片/关闭层里夹着色时返回只认纯色的那一层 */
|
|
2124
|
+
function solidFillColor(fill, vars) {
|
|
2125
|
+
const entries = Array.isArray(fill) ? fill : fill === void 0 ? [] : [fill];
|
|
2126
|
+
let color = null;
|
|
2127
|
+
for (const entry of entries) {
|
|
2128
|
+
if (typeof entry === "string") {
|
|
2129
|
+
color = vars.resolve(entry);
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
if (!isRecord$2(entry)) continue;
|
|
2133
|
+
if (vars.resolveBool(entry["enabled"]) === false) continue;
|
|
2134
|
+
if (entry["type"] !== "color") return null;
|
|
2135
|
+
const c = entry["color"];
|
|
2136
|
+
if (typeof c === "string") color = vars.resolve(c);
|
|
2137
|
+
}
|
|
2138
|
+
return color;
|
|
2139
|
+
}
|
|
2140
|
+
/** 单层、单色、非逐边的描边(否则返回 null,调用方降级) */
|
|
2141
|
+
function simpleStroke(node, vars) {
|
|
2142
|
+
const raw = node["stroke"];
|
|
2143
|
+
if (raw === void 0) return null;
|
|
2144
|
+
const enabled = (Array.isArray(raw) ? raw : [raw]).filter((e) => !(isRecord$2(e) && vars.resolveBool(e["enabled"]) === false));
|
|
2145
|
+
if (enabled.length !== 1) return null;
|
|
2146
|
+
const entry = enabled[0];
|
|
2147
|
+
const widthRaw = node["strokeWidth"];
|
|
2148
|
+
if (isRecord$2(widthRaw)) return null;
|
|
2149
|
+
let color = null;
|
|
2150
|
+
if (typeof entry === "string") color = vars.resolve(entry);
|
|
2151
|
+
else if (isRecord$2(entry) && entry["type"] === "color" && typeof entry["color"] === "string") color = vars.resolve(entry["color"]);
|
|
2152
|
+
if (color === null) return null;
|
|
2153
|
+
const width = vars.resolveNum(widthRaw) ?? 1;
|
|
2154
|
+
return {
|
|
2155
|
+
color,
|
|
2156
|
+
width
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
1510
2159
|
// src/render/html.ts
|
|
1511
2160
|
function escapeHtml(s) {
|
|
1512
2161
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -1514,7 +2163,7 @@ function escapeHtml(s) {
|
|
|
1514
2163
|
function escapeAttr(s) {
|
|
1515
2164
|
return escapeHtml(s).replace(/"/g, """);
|
|
1516
2165
|
}
|
|
1517
|
-
function isRecord(v) {
|
|
2166
|
+
function isRecord$1(v) {
|
|
1518
2167
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1519
2168
|
}
|
|
1520
2169
|
/** 'fit_content(120)' → 120(sizing 字符串内的回退值) */
|
|
@@ -1661,9 +2310,75 @@ function boardAttrs(node, vars) {
|
|
|
1661
2310
|
`data-board-h="${numStr(box.h)}"`
|
|
1662
2311
|
];
|
|
1663
2312
|
}
|
|
1664
|
-
function
|
|
2313
|
+
function planSvgShape(node, type, vars) {
|
|
2314
|
+
if (type === "ellipse") {
|
|
2315
|
+
const sweep = vars.resolveNum(node["sweepAngle"]);
|
|
2316
|
+
const inner = vars.resolveNum(node["innerRadius"]);
|
|
2317
|
+
const start = vars.resolveNum(node["startAngle"]);
|
|
2318
|
+
if (!needsEllipseSvg({
|
|
2319
|
+
width: 1,
|
|
2320
|
+
height: 1,
|
|
2321
|
+
...start !== void 0 ? { startAngle: start } : {},
|
|
2322
|
+
...sweep !== void 0 ? { sweepAngle: sweep } : {},
|
|
2323
|
+
...inner !== void 0 ? { innerRadius: inner } : {}
|
|
2324
|
+
})) return null;
|
|
2325
|
+
const w = numericSize(node["width"], vars);
|
|
2326
|
+
const h = numericSize(node["height"], vars);
|
|
2327
|
+
if (w === void 0 || h === void 0) return { reason: "弧形/圆环需要显式宽高,暂按整圆渲染" };
|
|
2328
|
+
const rawFill = node["fill"];
|
|
2329
|
+
const fill = rawFill === void 0 ? null : solidFillColor(rawFill, vars);
|
|
2330
|
+
if (rawFill !== void 0 && fill === null) return { reason: "弧形/圆环只支持纯色填充,暂按整圆/实心渲染" };
|
|
2331
|
+
const stroke = simpleStroke(node, vars);
|
|
2332
|
+
return { shape: {
|
|
2333
|
+
path: ellipsePath({
|
|
2334
|
+
width: w,
|
|
2335
|
+
height: h,
|
|
2336
|
+
...start !== void 0 ? { startAngle: start } : {},
|
|
2337
|
+
...sweep !== void 0 ? { sweepAngle: sweep } : {},
|
|
2338
|
+
...inner !== void 0 ? { innerRadius: inner } : {}
|
|
2339
|
+
}),
|
|
2340
|
+
width: w,
|
|
2341
|
+
height: h,
|
|
2342
|
+
fill,
|
|
2343
|
+
stroke,
|
|
2344
|
+
evenodd: (inner ?? 0) > 0
|
|
2345
|
+
} };
|
|
2346
|
+
}
|
|
2347
|
+
if (type === "polygon") {
|
|
2348
|
+
const radius = vars.resolveNum(node["cornerRadius"]);
|
|
2349
|
+
if (radius === void 0 || radius <= 0) return null;
|
|
2350
|
+
const w = numericSize(node["width"], vars);
|
|
2351
|
+
const h = numericSize(node["height"], vars);
|
|
2352
|
+
if (w === void 0 || h === void 0) return { reason: "多边形圆角需要显式宽高,暂忽略圆角" };
|
|
2353
|
+
const rawFill = node["fill"];
|
|
2354
|
+
const fill = rawFill === void 0 ? null : solidFillColor(rawFill, vars);
|
|
2355
|
+
if (rawFill !== void 0 && fill === null) return { reason: "多边形圆角只支持纯色填充,暂忽略圆角" };
|
|
2356
|
+
const count = vars.resolveNum(node["polygonCount"]);
|
|
2357
|
+
return { shape: {
|
|
2358
|
+
path: polygonPath({
|
|
2359
|
+
width: w,
|
|
2360
|
+
height: h,
|
|
2361
|
+
...count !== void 0 ? { count } : {},
|
|
2362
|
+
cornerRadius: radius
|
|
2363
|
+
}),
|
|
2364
|
+
width: w,
|
|
2365
|
+
height: h,
|
|
2366
|
+
fill,
|
|
2367
|
+
stroke: simpleStroke(node, vars),
|
|
2368
|
+
evenodd: false
|
|
2369
|
+
} };
|
|
2370
|
+
}
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
function svgShapeHtml(shape) {
|
|
2374
|
+
const fill = shape.fill === null ? " fill=\"none\"" : ` fill="${escapeAttr(shape.fill)}"`;
|
|
2375
|
+
const rule = shape.evenodd ? " fill-rule=\"evenodd\"" : "";
|
|
2376
|
+
const stroke = shape.stroke === null ? "" : ` stroke="${escapeAttr(shape.stroke.color)}" stroke-width="${numStr(shape.stroke.width)}"`;
|
|
2377
|
+
return `<svg viewBox="0 0 ${numStr(shape.width)} ${numStr(shape.height)}" width="100%" height="100%" preserveAspectRatio="none" style="display:block;width:100%;height:100%"><path d="${escapeAttr(shape.path)}"${fill}${rule}${stroke}/></svg>`;
|
|
2378
|
+
}
|
|
2379
|
+
function renderNode(node, parentLayout, vars, warnings, extraAttrs = [], fonts) {
|
|
1665
2380
|
const type = String(node["type"] ?? "?");
|
|
1666
|
-
const scoped = isRecord(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
|
|
2381
|
+
const scoped = isRecord$1(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
|
|
1667
2382
|
if (scoped.resolveBool(node["enabled"]) === false) return "";
|
|
1668
2383
|
const label = typeof node["name"] === "string" && node["name"] !== "" ? `${type}: ${String(node["name"])}` : type;
|
|
1669
2384
|
const positioned = parentLayout === "none" || node["layoutPosition"] === "absolute";
|
|
@@ -1680,13 +2395,15 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1680
2395
|
if (parentLayout !== "none" && !positioned) css.push(["flex-shrink", "0"]);
|
|
1681
2396
|
css.push(...sizeDecls(node["width"], node["height"], positioned, parentLayout, scoped, warnings, label));
|
|
1682
2397
|
css.push(...commonToCss(node, scoped));
|
|
1683
|
-
|
|
1684
|
-
|
|
2398
|
+
const plan = planSvgShape(node, type, scoped);
|
|
2399
|
+
const svgShape = plan !== null && "shape" in plan ? plan.shape : null;
|
|
2400
|
+
if (type !== "text" && svgShape === null) css.push(...fillsToCss(node["fill"], scoped, warnings, label));
|
|
2401
|
+
if (svgShape === null) css.push(...strokeToCss(node, scoped, warnings, label));
|
|
1685
2402
|
css.push(...effectsToCss(node["effect"], scoped, warnings, label));
|
|
1686
2403
|
let inner = "";
|
|
1687
2404
|
switch (type) {
|
|
1688
2405
|
case "text":
|
|
1689
|
-
css.push(...textToCss(node, scoped, warnings));
|
|
2406
|
+
css.push(...textToCss(node, scoped, warnings, fonts?.previews));
|
|
1690
2407
|
inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
|
|
1691
2408
|
break;
|
|
1692
2409
|
case "icon":
|
|
@@ -1710,19 +2427,28 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1710
2427
|
inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
|
|
1711
2428
|
break;
|
|
1712
2429
|
}
|
|
1713
|
-
case "ellipse":
|
|
2430
|
+
case "ellipse":
|
|
2431
|
+
if (svgShape !== null) {
|
|
2432
|
+
inner = svgShapeHtml(svgShape);
|
|
2433
|
+
if (scoped.resolveNum(node["sweepAngle"]) === 0) warnings.push(`${label}: sweepAngle=0,空弧不绘制`);
|
|
2434
|
+
const alignment = node["strokeAlignment"];
|
|
2435
|
+
if (svgShape.stroke !== null && (alignment === "inner" || alignment === "outer")) warnings.push(`${label}: strokeAlignment=${alignment} 在 SVG 形状上暂按 center 渲染`);
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
if (plan !== null && "reason" in plan) warnings.push(`${label}: ${plan.reason}`);
|
|
1714
2439
|
css.push(["border-radius", "50%"]);
|
|
1715
|
-
const inner2 = scoped.resolveNum(node["innerRadius"]);
|
|
1716
|
-
if (inner2 !== void 0 && inner2 > 0) warnings.push(`${label}: innerRadius 环形暂按实心椭圆渲染`);
|
|
1717
|
-
const sweep = scoped.resolveNum(node["sweepAngle"]);
|
|
1718
|
-
if (sweep !== void 0 && sweep !== 360) warnings.push(`${label}: 弧形(sweepAngle=${sweep})暂按整圆渲染`);
|
|
1719
2440
|
break;
|
|
1720
|
-
}
|
|
1721
2441
|
case "polygon": {
|
|
2442
|
+
if (svgShape !== null) {
|
|
2443
|
+
inner = svgShapeHtml(svgShape);
|
|
2444
|
+
const alignment = node["strokeAlignment"];
|
|
2445
|
+
if (svgShape.stroke !== null && (alignment === "inner" || alignment === "outer")) warnings.push(`${label}: strokeAlignment=${alignment} 在 SVG 形状上暂按 center 渲染`);
|
|
2446
|
+
break;
|
|
2447
|
+
}
|
|
1722
2448
|
const count = scoped.resolveNum(node["polygonCount"]);
|
|
1723
2449
|
const n = count ?? 3;
|
|
1724
2450
|
if (count === void 0) warnings.push(`${label}: polygonCount 缺省按 3 渲染`);
|
|
1725
|
-
if (
|
|
2451
|
+
if (plan !== null && "reason" in plan) warnings.push(`${label}: ${plan.reason}`);
|
|
1726
2452
|
css.push(["clip-path", polygonClip(n)]);
|
|
1727
2453
|
break;
|
|
1728
2454
|
}
|
|
@@ -1744,7 +2470,7 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1744
2470
|
if (type === "group" && node["effect"] !== void 0) {}
|
|
1745
2471
|
}
|
|
1746
2472
|
const kidsRaw = node["children"];
|
|
1747
|
-
const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord).map((k) => renderNode(k, effective, scoped, warnings)).join("") : "";
|
|
2473
|
+
const childrenHtml = Array.isArray(kidsRaw) ? kidsRaw.filter(isRecord$1).map((k) => renderNode(k, effective, scoped, warnings, [], fonts)).join("") : "";
|
|
1748
2474
|
return openTag(node, css.map(([p, v]) => `${p}:${v}`).join(";"), extraAttrs) + inner + childrenHtml + "</div>";
|
|
1749
2475
|
}
|
|
1750
2476
|
const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -1766,7 +2492,7 @@ function computeCanvas(nodes, vars) {
|
|
|
1766
2492
|
let maxW = 0;
|
|
1767
2493
|
let maxH = 0;
|
|
1768
2494
|
for (const n of nodes) {
|
|
1769
|
-
if (!isRecord(n)) continue;
|
|
2495
|
+
if (!isRecord$1(n)) continue;
|
|
1770
2496
|
const box = nodeBox(n, vars);
|
|
1771
2497
|
maxW = Math.max(maxW, box.x + box.w);
|
|
1772
2498
|
maxH = Math.max(maxH, box.y + box.h);
|
|
@@ -1779,7 +2505,7 @@ function computeCanvas(nodes, vars) {
|
|
|
1779
2505
|
function boardBoxes(nodes, vars) {
|
|
1780
2506
|
const out = [];
|
|
1781
2507
|
for (const n of nodes) {
|
|
1782
|
-
if (!isRecord(n)) continue;
|
|
2508
|
+
if (!isRecord$1(n)) continue;
|
|
1783
2509
|
const box = nodeBox(n, vars);
|
|
1784
2510
|
const name = typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : void 0;
|
|
1785
2511
|
out.push({
|
|
@@ -1794,12 +2520,93 @@ function boardBoxes(nodes, vars) {
|
|
|
1794
2520
|
return out;
|
|
1795
2521
|
}
|
|
1796
2522
|
/** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
|
|
1797
|
-
function renderTree(nodes, vars, warnings) {
|
|
1798
|
-
return nodes.filter(isRecord).map((n) => {
|
|
1799
|
-
return renderNode(n, "none", vars, warnings, boardAttrs(n, isRecord(n["theme"]) ? vars.withTheme(n["theme"]) : vars));
|
|
2523
|
+
function renderTree(nodes, vars, warnings, fonts) {
|
|
2524
|
+
return nodes.filter(isRecord$1).map((n) => {
|
|
2525
|
+
return renderNode(n, "none", vars, warnings, boardAttrs(n, isRecord$1(n["theme"]) ? vars.withTheme(n["theme"]) : vars), fonts);
|
|
1800
2526
|
}).join("");
|
|
1801
2527
|
}
|
|
1802
2528
|
// src/render/entry.ts
|
|
2529
|
+
function metricsCss(metrics) {
|
|
2530
|
+
if (metrics === void 0) return "";
|
|
2531
|
+
const parts = [];
|
|
2532
|
+
if (metrics.sizeAdjust !== void 0) parts.push(`size-adjust:${metrics.sizeAdjust}`);
|
|
2533
|
+
if (metrics.ascentOverride !== void 0) parts.push(`ascent-override:${metrics.ascentOverride}`);
|
|
2534
|
+
if (metrics.descentOverride !== void 0) parts.push(`descent-override:${metrics.descentOverride}`);
|
|
2535
|
+
if (metrics.lineGapOverride !== void 0) parts.push(`line-gap-override:${metrics.lineGapOverride}`);
|
|
2536
|
+
return parts.length > 0 ? `${parts.join(";")};` : "";
|
|
2537
|
+
}
|
|
2538
|
+
function faceCss(face) {
|
|
2539
|
+
const src = face.src.startsWith("local(") ? face.src : `url("${face.src}")`;
|
|
2540
|
+
const format = face.format !== void 0 ? ` format("${face.format}")` : "";
|
|
2541
|
+
return `@font-face{font-family:${JSON.stringify(face.family)};src:${src}${format};${metricsCss(face.metrics)}}`;
|
|
2542
|
+
}
|
|
2543
|
+
function isRecord(v) {
|
|
2544
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2545
|
+
}
|
|
2546
|
+
/** 探针要检查的 family:注册表里的、默认栈里的、文档里写死的 */
|
|
2547
|
+
function collectProbeFamilies(nodes, plan) {
|
|
2548
|
+
const set = /* @__PURE__ */ new Set();
|
|
2549
|
+
for (const family of Object.keys(plan?.previews ?? {})) set.add(family);
|
|
2550
|
+
for (const family of splitFontStack(plan?.defaultStack ?? "\"Inter Variable\", \"Noto Sans SC Variable\", system-ui, -apple-system, \"Segoe UI\", sans-serif")) if (!isGenericFamily(family)) set.add(family);
|
|
2551
|
+
const walk = (list) => {
|
|
2552
|
+
for (const raw of list) {
|
|
2553
|
+
if (!isRecord(raw)) continue;
|
|
2554
|
+
if (raw["type"] === "text" && typeof raw["fontFamily"] === "string" && !raw["fontFamily"].startsWith("$")) {
|
|
2555
|
+
for (const family of splitFontStack(raw["fontFamily"])) if (!isGenericFamily(family)) set.add(family);
|
|
2556
|
+
}
|
|
2557
|
+
const kids = raw["children"];
|
|
2558
|
+
if (Array.isArray(kids)) walk(kids);
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
walk(nodes);
|
|
2562
|
+
return [...set];
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* 字体 CSS 异步加载:外链样式表会阻塞 load(测试与首屏都不该等 CDN),
|
|
2566
|
+
* 于是用脚本注入;`window.__paintressFontsReady` 给自己人(探针 / 截图)一个等待点。
|
|
2567
|
+
*/
|
|
2568
|
+
function fontLoaderScript(links) {
|
|
2569
|
+
if (links.length === 0) return "";
|
|
2570
|
+
return [
|
|
2571
|
+
"<script>",
|
|
2572
|
+
"(function(){",
|
|
2573
|
+
`var hrefs=${JSON.stringify(links).replace(/</g, "\\u003c")};`,
|
|
2574
|
+
"var loads=hrefs.map(function(href){return new Promise(function(resolve){",
|
|
2575
|
+
"var l=document.createElement(\"link\");l.rel=\"stylesheet\";l.href=href;l.onload=resolve;l.onerror=resolve;document.head.appendChild(l);",
|
|
2576
|
+
"});});",
|
|
2577
|
+
"window.__paintressFontsReady=Promise.all(loads).then(function(){",
|
|
2578
|
+
"return document.fonts&&document.fonts.ready?document.fonts.ready:null;});",
|
|
2579
|
+
"})();",
|
|
2580
|
+
"<\/script>"
|
|
2581
|
+
].join("\n");
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* 字体可用性探针(宽度法):`document.fonts.check()` 对缺失的系统字体也返回 true,
|
|
2585
|
+
* 只有「候选 family 与缺失基线的宽度是否相同」能区分。
|
|
2586
|
+
*/
|
|
2587
|
+
function probeScript(families) {
|
|
2588
|
+
if (families.length === 0) return "";
|
|
2589
|
+
return [
|
|
2590
|
+
"<script type=\"application/json\" id=\"ptr-fonts\">",
|
|
2591
|
+
JSON.stringify(families).replace(/</g, "\\u003c").replace(/[\u2028\u2029]/g, (ch) => "\\u" + ch.charCodeAt(0).toString(16)),
|
|
2592
|
+
"<\/script>",
|
|
2593
|
+
"<script>",
|
|
2594
|
+
"(function(){",
|
|
2595
|
+
"var el=document.getElementById(\"ptr-fonts\");if(!el||window.parent===window)return;",
|
|
2596
|
+
"var families=[];try{families=JSON.parse(el.textContent||\"[]\")}catch(e){return}",
|
|
2597
|
+
"if(!families.length)return;",
|
|
2598
|
+
"var c=document.createElement(\"canvas\").getContext(\"2d\");",
|
|
2599
|
+
"var text=\"漢字Abc123 光伴\";",
|
|
2600
|
+
"function w(f,b){c.font='32px \"'+f+'\", '+b;return c.measureText(text).width}",
|
|
2601
|
+
"function missing(){var sb=w(\"__paintress_missing__\",\"serif\"),mb=w(\"__paintress_missing__\",\"monospace\");",
|
|
2602
|
+
"var out=[];for(var i=0;i<families.length;i++){var f=families[i];",
|
|
2603
|
+
"if(Math.abs(w(f,\"serif\")-sb)<0.5&&Math.abs(w(f,\"monospace\")-mb)<0.5)out.push(f)}return out}",
|
|
2604
|
+
"function send(){var m=missing();var data={type:\"paintress-fonts\",checked:families,missing:m};window.parent.postMessage(data,\"*\");if(window.PAINTRESS_FONT_URL){fetch(window.PAINTRESS_FONT_URL,{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({checked:families,missing:m})}).catch(function(){})}}",
|
|
2605
|
+
"Promise.resolve(window.__paintressFontsReady).then(function(){setTimeout(send,50)});",
|
|
2606
|
+
"})();",
|
|
2607
|
+
"<\/script>"
|
|
2608
|
+
].join("\n");
|
|
2609
|
+
}
|
|
1803
2610
|
/**
|
|
1804
2611
|
* 渲染入口(决策 2/3/4/5 的兑现):
|
|
1805
2612
|
* 变量内联解析 → ref 展开 → 树 → HTML(每节点 data-node-id)。
|
|
@@ -1808,11 +2615,23 @@ function renderTree(nodes, vars, warnings) {
|
|
|
1808
2615
|
function renderDocument(doc, opts = {}) {
|
|
1809
2616
|
const warnings = [];
|
|
1810
2617
|
const vars = makeVarResolver(doc, warnings);
|
|
1811
|
-
const
|
|
1812
|
-
const
|
|
1813
|
-
const
|
|
2618
|
+
const groups = presetGroupsOf(doc);
|
|
2619
|
+
const seeded = applyPresetRefs(doc.children, groups, warnings);
|
|
2620
|
+
const expanded = expandRefs({
|
|
2621
|
+
...doc,
|
|
2622
|
+
children: seeded
|
|
2623
|
+
}, warnings);
|
|
2624
|
+
const postWarnings = [];
|
|
2625
|
+
const withPresets = applyPresetRefs(expanded, groups, postWarnings);
|
|
2626
|
+
for (const message of postWarnings) if (!warnings.includes(message)) warnings.push(message);
|
|
2627
|
+
const canvas = computeCanvas(withPresets, vars);
|
|
2628
|
+
const boards = boardBoxes(withPresets, vars);
|
|
1814
2629
|
const title = opts.title ?? "paintress render";
|
|
1815
|
-
const body = renderTree(
|
|
2630
|
+
const body = renderTree(withPresets, vars, warnings, { previews: opts.fonts?.previews ?? {} });
|
|
2631
|
+
const links = [...new Set(opts.fonts?.links ?? [])];
|
|
2632
|
+
const defaultStack = stackWithPreviews(opts.fonts?.defaultStack ?? "\"Inter Variable\", \"Noto Sans SC Variable\", system-ui, -apple-system, \"Segoe UI\", sans-serif", opts.fonts?.previews ?? {});
|
|
2633
|
+
const faces = (opts.fonts?.faces ?? []).map(faceCss);
|
|
2634
|
+
const probe = opts.probeFonts === true ? probeScript(collectProbeFamilies(withPresets, opts.fonts)) : "";
|
|
1816
2635
|
return {
|
|
1817
2636
|
html: [
|
|
1818
2637
|
"<!DOCTYPE html>",
|
|
@@ -1823,13 +2642,16 @@ function renderDocument(doc, opts = {}) {
|
|
|
1823
2642
|
"<style>",
|
|
1824
2643
|
"* { box-sizing: border-box; margin: 0; padding: 0; }",
|
|
1825
2644
|
`body { margin: 0; background: ${opts.transparent === true ? "transparent" : "#ffffff"}; }`,
|
|
1826
|
-
|
|
2645
|
+
`.ptr-canvas { position: relative; font-family: ${defaultStack}; }`,
|
|
2646
|
+
...faces,
|
|
1827
2647
|
"</style>",
|
|
1828
2648
|
"</head>",
|
|
1829
2649
|
"<body>",
|
|
1830
2650
|
`<div class="ptr-canvas" style="width:${canvas.width}px;height:${canvas.height}px">`,
|
|
1831
2651
|
body,
|
|
1832
2652
|
"</div>",
|
|
2653
|
+
fontLoaderScript(links),
|
|
2654
|
+
probe,
|
|
1833
2655
|
"</body>",
|
|
1834
2656
|
"</html>",
|
|
1835
2657
|
""
|
|
@@ -1839,4 +2661,4 @@ function renderDocument(doc, opts = {}) {
|
|
|
1839
2661
|
boards
|
|
1840
2662
|
};
|
|
1841
2663
|
}
|
|
1842
|
-
export {
|
|
2664
|
+
export { IconFontSchema as $, Sizing as $t, genId as A, BlendMode as At, presetGroupsOf as B, StrokeAlign as Bt, update as C, MeshGradientFill as Ct, serializeDoc as D, SolidFill as Dt, saveDoc as E, ShadowEffect as Et, splitFontStack as F, ImageFit as Ft, PtrDocumentSchema as G, TextGrowth as Gt, DocumentSchema as H, StrokeJoin as Ht, textFontGaps as I, LayoutDir as It, X_PAINTRESS_KEY as J, Color as Jt, VariableDef as K, Affine as Kt, PROP_TOKEN_KIND as L, MainAxis as Lt, isFontSourceKind as M, FillRule as Mt, isGenericFamily as N, GradientType as Nt, validateDoc as O, StrokeBlock as Ot, planFonts as P, IconLibrary as Pt, GroupSchema as Q, Padding as Qt, isNumericPresetValue as R, PositionMode as Rt, remove as S, ImageFill as St, loadDoc as T, ShaderFill as Tt, PEN_DOC_VERSION as U, TextAlign as Ut, DocumentObjectSchema as V, StrokeCap as Vt, PTR_SPEC_VERSION as W, TextAlignVertical as Wt, EllipseSchema as X, CornerRadius as Xt, BrowserSchema as Y, ColorOrVar as Yt, FrameSchema as Z, NumOrVar as Zt, buildIndex as _, Effects as _t, escapeHtml as a, NoteSchema as at, move as b, GradientFill as bt, commonToCss as c, RectangleSchema as ct, fillsToCss as d, TextSchema as dt, StringOrVar as en, IconSchema as et, strokeToCss as f, UnknownNodeSchema as ft, batch as g, EffectEntry as gt, makeVarResolver as h, BlurEffect as ht, escapeAttr as i, NodeSchema as it, fontRegistryOf as j, CrossAxis as jt, collectIds as k, StrokeWidth as kt, cornerRadiusToCss as l, RefSchema as lt, expandRefs as m, BackgroundBlurEffect as mt, boardBoxes as n, VariableRef as nn, LayoutBlock as nt, renderTree as o, PathSchema as ot, textToCss as p, schemaForType as pt, XPaintress as q, BoolOrVar as qt, computeCanvas as r, VERSION as rn, NodeBase as rt, getLucideIcon as s, PolygonSchema as st, renderDocument as t, VARIABLE_NAME as tn, KNOWN_NODE_TYPES as tt, effectsToCss as u, ScriptSchema as ut, get as v, FillEntry as vt, FileChangedError as w, ShaderEffect as wt, query as x, GraphicsBlock as xt, insert as y, Fills as yt, isPresetType as z, ShadowKind as zt };
|