paintress 0.5.2 → 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 +73 -18
- package/README.zh-CN.md +74 -17
- package/dist/cli.js +843 -78
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CCxYNL6A.js → render-DWs1iQ0v.js} +887 -59
- package/dist/server-DBRmwE_v.js +3462 -0
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +64 -25
- package/ui/dist/assets/index-CSUM68ms.js +8 -0
- package/ui/dist/assets/{index-CP8lZ8lp.css → index-CXBgFia_.css} +1 -1
- package/ui/dist/assets/marked.esm-DZCVybOJ.js +72 -0
- package/ui/dist/index.html +2 -2
- package/dist/server-ssrAxbTR.js +0 -976
- package/ui/dist/assets/index-BnDj5nL8.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,8 +912,16 @@ 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
|
-
function validateDoc(input) {
|
|
924
|
+
function validateDoc(input, opts = {}) {
|
|
601
925
|
const errors = [];
|
|
602
926
|
const warnings = [];
|
|
603
927
|
const parsed = DocumentSchema.safeParse(input);
|
|
@@ -614,6 +938,11 @@ function validateDoc(input) {
|
|
|
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,12 +972,60 @@ function validateDoc(input) {
|
|
|
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");
|
|
651
|
-
const
|
|
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
|
+
}
|
|
1022
|
+
const localNames = new Set(doc.variables ? Object.keys(doc.variables) : []);
|
|
1023
|
+
const varNames = /* @__PURE__ */ new Set([...localNames, ...opts.knownVars ?? []]);
|
|
1024
|
+
for (const name of opts.presetVars ?? []) if (localNames.has(name)) warnings.push({
|
|
1025
|
+
level: "warning",
|
|
1026
|
+
path: "<variables>",
|
|
1027
|
+
message: `本地变量 $${name} 覆盖了项目预设(在查看器里调预设不会影响这份文件)`
|
|
1028
|
+
});
|
|
652
1029
|
const varRefs = /* @__PURE__ */ new Set();
|
|
653
1030
|
const scan = (obj) => {
|
|
654
1031
|
if (typeof obj === "string" && VARIABLE_NAME.test(obj)) varRefs.add(obj.slice(1));
|
|
@@ -656,11 +1033,40 @@ function validateDoc(input) {
|
|
|
656
1033
|
else if (obj && typeof obj === "object") Object.values(obj).forEach(scan);
|
|
657
1034
|
};
|
|
658
1035
|
scan(doc.children);
|
|
1036
|
+
for (const group of Object.values(presetGroups)) for (const value of Object.values(group.props)) scan(value);
|
|
659
1037
|
for (const name of varRefs) if (!varNames.has(name)) warnings.push({
|
|
660
1038
|
level: "warning",
|
|
661
1039
|
path: "<variables>",
|
|
662
1040
|
message: `字段引用了未定义的变量 $${name}`
|
|
663
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
|
+
}
|
|
664
1070
|
return {
|
|
665
1071
|
ok: errors.length === 0,
|
|
666
1072
|
errors,
|
|
@@ -927,7 +1333,7 @@ function batch(doc, ops) {
|
|
|
927
1333
|
return clone;
|
|
928
1334
|
}
|
|
929
1335
|
// src/render/vars.ts
|
|
930
|
-
function isRecord$
|
|
1336
|
+
function isRecord$5(v) {
|
|
931
1337
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
932
1338
|
}
|
|
933
1339
|
function asVarValue(v) {
|
|
@@ -941,7 +1347,7 @@ function asVarValue(v) {
|
|
|
941
1347
|
*/
|
|
942
1348
|
function resolveThemed(v, theme) {
|
|
943
1349
|
if (Array.isArray(v)) {
|
|
944
|
-
const entries = v.filter(isRecord$
|
|
1350
|
+
const entries = v.filter(isRecord$5);
|
|
945
1351
|
if (entries.length === 0) return void 0;
|
|
946
1352
|
let best = null;
|
|
947
1353
|
for (const entry of entries) {
|
|
@@ -953,7 +1359,7 @@ function resolveThemed(v, theme) {
|
|
|
953
1359
|
};
|
|
954
1360
|
continue;
|
|
955
1361
|
}
|
|
956
|
-
if (!isRecord$
|
|
1362
|
+
if (!isRecord$5(t)) continue;
|
|
957
1363
|
const keys = Object.keys(t);
|
|
958
1364
|
if (!keys.every((k) => theme[k] === String(t[k]))) continue;
|
|
959
1365
|
if (best === null || keys.length > best.score) best = {
|
|
@@ -970,7 +1376,7 @@ function makeVarResolver(doc, warnings, theme = {}) {
|
|
|
970
1376
|
const make = (scope) => {
|
|
971
1377
|
const lookup = (name) => {
|
|
972
1378
|
const def = vars[name];
|
|
973
|
-
if (!isRecord$
|
|
1379
|
+
if (!isRecord$5(def)) return void 0;
|
|
974
1380
|
return resolveThemed(def["value"], scope);
|
|
975
1381
|
};
|
|
976
1382
|
return {
|
|
@@ -1021,7 +1427,7 @@ function makeVarResolver(doc, warnings, theme = {}) {
|
|
|
1021
1427
|
return make(theme);
|
|
1022
1428
|
}
|
|
1023
1429
|
// src/render/expand.ts
|
|
1024
|
-
function isRecord$
|
|
1430
|
+
function isRecord$4(v) {
|
|
1025
1431
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1026
1432
|
}
|
|
1027
1433
|
function brokenRefPlaceholder(refNode, reason, warnings) {
|
|
@@ -1042,7 +1448,7 @@ function indexById(children) {
|
|
|
1042
1448
|
const map = /* @__PURE__ */ new Map();
|
|
1043
1449
|
const walk = (nodes) => {
|
|
1044
1450
|
for (const n of nodes) {
|
|
1045
|
-
if (!isRecord$
|
|
1451
|
+
if (!isRecord$4(n)) continue;
|
|
1046
1452
|
const id = n["id"];
|
|
1047
1453
|
if (typeof id === "string") map.set(id, n);
|
|
1048
1454
|
const kids = n["children"];
|
|
@@ -1059,7 +1465,7 @@ function findByPath(root, segments) {
|
|
|
1059
1465
|
for (const seg of segments) {
|
|
1060
1466
|
const kids = container["children"];
|
|
1061
1467
|
if (!Array.isArray(kids)) return null;
|
|
1062
|
-
found = kids.find((k) => isRecord$
|
|
1468
|
+
found = kids.find((k) => isRecord$4(k) && k["id"] === seg) ?? null;
|
|
1063
1469
|
if (!found) return null;
|
|
1064
1470
|
container = found;
|
|
1065
1471
|
}
|
|
@@ -1070,7 +1476,7 @@ function findByPath(root, segments) {
|
|
|
1070
1476
|
}
|
|
1071
1477
|
function applyDescendants(expandedRoot, descendants, warnings, refId) {
|
|
1072
1478
|
for (const [path, overrideRaw] of Object.entries(descendants)) {
|
|
1073
|
-
if (!isRecord$
|
|
1479
|
+
if (!isRecord$4(overrideRaw)) continue;
|
|
1074
1480
|
const hit = findByPath(expandedRoot, path.split("/").filter((s) => s !== ""));
|
|
1075
1481
|
if (!hit) {
|
|
1076
1482
|
warnings.push(`ref ${refId} 的 descendants 路径 "${path}" 未命中,已忽略`);
|
|
@@ -1090,7 +1496,7 @@ function applyDescendants(expandedRoot, descendants, warnings, refId) {
|
|
|
1090
1496
|
/** 递归展开一个节点(就地修改克隆树) */
|
|
1091
1497
|
function expandNode(node, index, stack, warnings) {
|
|
1092
1498
|
const kids = node["children"];
|
|
1093
|
-
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);
|
|
1094
1500
|
if (node["type"] !== "ref") return node;
|
|
1095
1501
|
const refId = typeof node["id"] === "string" ? node["id"] : "?";
|
|
1096
1502
|
const target = typeof node["ref"] === "string" ? node["ref"] : "";
|
|
@@ -1102,13 +1508,13 @@ function expandNode(node, index, stack, warnings) {
|
|
|
1102
1508
|
const clone = structuredClone(component);
|
|
1103
1509
|
clone["id"] = node["id"];
|
|
1104
1510
|
if (typeof node["name"] === "string") clone["name"] = node["name"];
|
|
1105
|
-
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);
|
|
1106
1512
|
for (const [k, v] of Object.entries(node)) {
|
|
1107
1513
|
if (k === "type" || k === "ref" || k === "descendants" || k === "id" || k === "name") continue;
|
|
1108
1514
|
clone[k] = structuredClone(v);
|
|
1109
1515
|
}
|
|
1110
1516
|
const desc = node["descendants"];
|
|
1111
|
-
if (isRecord$
|
|
1517
|
+
if (isRecord$4(desc)) applyDescendants(clone, desc, warnings, refId);
|
|
1112
1518
|
return clone;
|
|
1113
1519
|
} finally {
|
|
1114
1520
|
stack.delete(target);
|
|
@@ -1120,21 +1526,72 @@ function expandRefs(doc, warnings) {
|
|
|
1120
1526
|
return structuredClone(doc.children).map((n) => expandNode(n, index, /* @__PURE__ */ new Set(), warnings));
|
|
1121
1527
|
}
|
|
1122
1528
|
// src/render/style.ts
|
|
1123
|
-
function isRecord$
|
|
1529
|
+
function isRecord$3(v) {
|
|
1124
1530
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1125
1531
|
}
|
|
1126
1532
|
function colorFromFillEntry(entry, vars) {
|
|
1127
1533
|
if (typeof entry === "string") return vars.resolve(entry);
|
|
1128
|
-
if (isRecord$
|
|
1534
|
+
if (isRecord$3(entry)) {
|
|
1129
1535
|
const c = entry["color"];
|
|
1130
1536
|
if (typeof c === "string") return vars.resolve(c);
|
|
1131
1537
|
}
|
|
1132
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
|
+
}
|
|
1133
1590
|
function stopsToCssList(colors, vars) {
|
|
1134
1591
|
if (!Array.isArray(colors) || colors.length === 0) return void 0;
|
|
1135
1592
|
const parts = [];
|
|
1136
1593
|
for (const c of colors) {
|
|
1137
|
-
if (!isRecord$
|
|
1594
|
+
if (!isRecord$3(c)) continue;
|
|
1138
1595
|
const col = colorFromFillEntry(c["color"], vars);
|
|
1139
1596
|
const pos = vars.resolveNum(c["position"]);
|
|
1140
1597
|
if (col === void 0) continue;
|
|
@@ -1156,7 +1613,7 @@ function penAngleToCss(deg) {
|
|
|
1156
1613
|
return `${(((90 - deg) % 360 + 360) % 360).toFixed(1)}deg`;
|
|
1157
1614
|
}
|
|
1158
1615
|
function normCenter(c, vars) {
|
|
1159
|
-
if (isRecord$
|
|
1616
|
+
if (isRecord$3(c)) {
|
|
1160
1617
|
const rx = vars?.resolveNum(c["x"]) ?? (typeof c["x"] === "number" ? c["x"] : void 0);
|
|
1161
1618
|
const ry = vars?.resolveNum(c["y"]) ?? (typeof c["y"] === "number" ? c["y"] : void 0);
|
|
1162
1619
|
if (rx !== void 0 && ry !== void 0) return {
|
|
@@ -1175,7 +1632,7 @@ function normCenter(c, vars) {
|
|
|
1175
1632
|
* 另一边按正方形处理(与 Pencil 的径向渐变一致)。
|
|
1176
1633
|
*/
|
|
1177
1634
|
function normSize(size, vars) {
|
|
1178
|
-
if (!isRecord$
|
|
1635
|
+
if (!isRecord$3(size)) return null;
|
|
1179
1636
|
const rw = vars.resolveNum(size["width"]);
|
|
1180
1637
|
const rh = vars.resolveNum(size["height"]);
|
|
1181
1638
|
if (rw === void 0 && rh === void 0) return null;
|
|
@@ -1190,11 +1647,11 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1190
1647
|
const bgImages = [];
|
|
1191
1648
|
let bgColor;
|
|
1192
1649
|
for (const entry of entries) {
|
|
1193
|
-
if (typeof entry === "string" || isRecord$
|
|
1650
|
+
if (typeof entry === "string" || isRecord$3(entry) && entry["type"] === void 0) {
|
|
1194
1651
|
bgColor = colorFromFillEntry(entry, vars);
|
|
1195
1652
|
continue;
|
|
1196
1653
|
}
|
|
1197
|
-
if (!isRecord$
|
|
1654
|
+
if (!isRecord$3(entry)) continue;
|
|
1198
1655
|
if (vars.resolveBool(entry["enabled"]) === false) continue;
|
|
1199
1656
|
const kind = entry["type"];
|
|
1200
1657
|
if (kind === "color") bgColor = colorFromFillEntry(entry, vars);
|
|
@@ -1225,11 +1682,17 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1225
1682
|
decls.push(["background-repeat", "no-repeat"]);
|
|
1226
1683
|
} else warnings.push(`${what}: 图像填充缺少 url,已跳过`);
|
|
1227
1684
|
} else if (kind === "mesh_gradient") {
|
|
1228
|
-
const
|
|
1229
|
-
if (
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
} 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
|
+
}
|
|
1233
1696
|
} else if (kind === "shader") warnings.push(`${what}: shader 填充暂不支持渲染,已跳过`);
|
|
1234
1697
|
}
|
|
1235
1698
|
if (bgImages.length > 0) decls.unshift(["background-image", bgImages.join(", ")]);
|
|
@@ -1250,7 +1713,7 @@ function fillsToCss(fillsRaw, vars, warnings, what) {
|
|
|
1250
1713
|
function strokeToCss(node, vars, warnings, what) {
|
|
1251
1714
|
const stroke = node["stroke"];
|
|
1252
1715
|
if (stroke === void 0) return [];
|
|
1253
|
-
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));
|
|
1254
1717
|
if (enabled.length === 0) return [];
|
|
1255
1718
|
const firstColor = enabled.map((e) => colorFromFillEntry(e, vars)).find((c) => c !== void 0);
|
|
1256
1719
|
if (firstColor === void 0) return [];
|
|
@@ -1259,8 +1722,8 @@ function strokeToCss(node, vars, warnings, what) {
|
|
|
1259
1722
|
const alignRaw = node["strokeAlignment"];
|
|
1260
1723
|
const align = alignRaw === "outer" || alignRaw === "center" ? alignRaw : "inner";
|
|
1261
1724
|
const widthRaw = node["strokeWidth"];
|
|
1262
|
-
if (isRecord$
|
|
1263
|
-
if (!isRecord$
|
|
1725
|
+
if (isRecord$3(widthRaw) && align !== "inner") warnings.push(`${what}: 逐边描边宽度 + strokeAlignment=${align} 暂按 inner(CSS border)渲染`);
|
|
1726
|
+
if (!isRecord$3(widthRaw) && align !== "inner") {
|
|
1264
1727
|
const w = vars.resolveNum(widthRaw) ?? 1;
|
|
1265
1728
|
decls.push(["outline", `${w}px solid ${firstColor}`]);
|
|
1266
1729
|
decls.push(["outline-offset", align === "outer" ? "0" : `${-w / 2}px`]);
|
|
@@ -1270,7 +1733,7 @@ function strokeToCss(node, vars, warnings, what) {
|
|
|
1270
1733
|
if (typeof widthRaw === "number") {
|
|
1271
1734
|
decls.push(["border-width", `${widthRaw}px`]);
|
|
1272
1735
|
decls.push(["border-style", "solid"]);
|
|
1273
|
-
} else if (isRecord$
|
|
1736
|
+
} else if (isRecord$3(widthRaw)) {
|
|
1274
1737
|
const sides = [];
|
|
1275
1738
|
for (const [k, cssName] of [
|
|
1276
1739
|
["top", "border-top"],
|
|
@@ -1300,10 +1763,10 @@ function effectsToCss(effectsRaw, vars, warnings, what) {
|
|
|
1300
1763
|
let blurPx;
|
|
1301
1764
|
let bgBlurPx;
|
|
1302
1765
|
for (const e of entries) {
|
|
1303
|
-
if (!isRecord$
|
|
1766
|
+
if (!isRecord$3(e)) continue;
|
|
1304
1767
|
if (e["type"] === "shadow") {
|
|
1305
1768
|
const inner = e["shadowType"] === "inner";
|
|
1306
|
-
const off = isRecord$
|
|
1769
|
+
const off = isRecord$3(e["offset"]) ? e["offset"] : {};
|
|
1307
1770
|
const x = vars.resolveNum(off["x"]) ?? 0;
|
|
1308
1771
|
const y = vars.resolveNum(off["y"]) ?? 0;
|
|
1309
1772
|
const b = vars.resolveNum(e["blur"]) ?? 0;
|
|
@@ -1319,12 +1782,12 @@ function effectsToCss(effectsRaw, vars, warnings, what) {
|
|
|
1319
1782
|
return decls;
|
|
1320
1783
|
}
|
|
1321
1784
|
/** 文本样式;文本颜色来自 fill(实文件如此:text 节点 fill 即字色) */
|
|
1322
|
-
function textToCss(node, vars, warnings) {
|
|
1785
|
+
function textToCss(node, vars, warnings, fontPreviews) {
|
|
1323
1786
|
const decls = [];
|
|
1324
1787
|
const num = (k) => vars.resolveNum(node[k]);
|
|
1325
1788
|
const str = (k) => typeof node[k] === "string" ? vars.resolve(node[k]) : void 0;
|
|
1326
1789
|
const ff = str("fontFamily");
|
|
1327
|
-
if (ff) decls.push(["font-family", ff]);
|
|
1790
|
+
if (ff) decls.push(["font-family", fontPreviews === void 0 ? ff : stackWithPreviews(ff, fontPreviews)]);
|
|
1328
1791
|
const fs = num("fontSize");
|
|
1329
1792
|
if (fs !== void 0) decls.push(["font-size", `${fs}px`]);
|
|
1330
1793
|
const fw = str("fontWeight");
|
|
@@ -1501,6 +1964,198 @@ function resolveIconName(library, name) {
|
|
|
1501
1964
|
if (direct !== void 0) return direct;
|
|
1502
1965
|
return name;
|
|
1503
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
|
+
}
|
|
1504
2159
|
// src/render/html.ts
|
|
1505
2160
|
function escapeHtml(s) {
|
|
1506
2161
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -1508,7 +2163,7 @@ function escapeHtml(s) {
|
|
|
1508
2163
|
function escapeAttr(s) {
|
|
1509
2164
|
return escapeHtml(s).replace(/"/g, """);
|
|
1510
2165
|
}
|
|
1511
|
-
function isRecord(v) {
|
|
2166
|
+
function isRecord$1(v) {
|
|
1512
2167
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1513
2168
|
}
|
|
1514
2169
|
/** 'fit_content(120)' → 120(sizing 字符串内的回退值) */
|
|
@@ -1655,9 +2310,75 @@ function boardAttrs(node, vars) {
|
|
|
1655
2310
|
`data-board-h="${numStr(box.h)}"`
|
|
1656
2311
|
];
|
|
1657
2312
|
}
|
|
1658
|
-
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) {
|
|
1659
2380
|
const type = String(node["type"] ?? "?");
|
|
1660
|
-
const scoped = isRecord(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
|
|
2381
|
+
const scoped = isRecord$1(node["theme"]) ? vars.withTheme(node["theme"]) : vars;
|
|
1661
2382
|
if (scoped.resolveBool(node["enabled"]) === false) return "";
|
|
1662
2383
|
const label = typeof node["name"] === "string" && node["name"] !== "" ? `${type}: ${String(node["name"])}` : type;
|
|
1663
2384
|
const positioned = parentLayout === "none" || node["layoutPosition"] === "absolute";
|
|
@@ -1674,13 +2395,15 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1674
2395
|
if (parentLayout !== "none" && !positioned) css.push(["flex-shrink", "0"]);
|
|
1675
2396
|
css.push(...sizeDecls(node["width"], node["height"], positioned, parentLayout, scoped, warnings, label));
|
|
1676
2397
|
css.push(...commonToCss(node, scoped));
|
|
1677
|
-
|
|
1678
|
-
|
|
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));
|
|
1679
2402
|
css.push(...effectsToCss(node["effect"], scoped, warnings, label));
|
|
1680
2403
|
let inner = "";
|
|
1681
2404
|
switch (type) {
|
|
1682
2405
|
case "text":
|
|
1683
|
-
css.push(...textToCss(node, scoped, warnings));
|
|
2406
|
+
css.push(...textToCss(node, scoped, warnings, fonts?.previews));
|
|
1684
2407
|
inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
|
|
1685
2408
|
break;
|
|
1686
2409
|
case "icon":
|
|
@@ -1704,19 +2427,28 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1704
2427
|
inner = escapeHtml(typeof node["content"] === "string" ? scoped.resolve(node["content"]) : "");
|
|
1705
2428
|
break;
|
|
1706
2429
|
}
|
|
1707
|
-
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}`);
|
|
1708
2439
|
css.push(["border-radius", "50%"]);
|
|
1709
|
-
const inner2 = scoped.resolveNum(node["innerRadius"]);
|
|
1710
|
-
if (inner2 !== void 0 && inner2 > 0) warnings.push(`${label}: innerRadius 环形暂按实心椭圆渲染`);
|
|
1711
|
-
const sweep = scoped.resolveNum(node["sweepAngle"]);
|
|
1712
|
-
if (sweep !== void 0 && sweep !== 360) warnings.push(`${label}: 弧形(sweepAngle=${sweep})暂按整圆渲染`);
|
|
1713
2440
|
break;
|
|
1714
|
-
}
|
|
1715
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
|
+
}
|
|
1716
2448
|
const count = scoped.resolveNum(node["polygonCount"]);
|
|
1717
2449
|
const n = count ?? 3;
|
|
1718
2450
|
if (count === void 0) warnings.push(`${label}: polygonCount 缺省按 3 渲染`);
|
|
1719
|
-
if (
|
|
2451
|
+
if (plan !== null && "reason" in plan) warnings.push(`${label}: ${plan.reason}`);
|
|
1720
2452
|
css.push(["clip-path", polygonClip(n)]);
|
|
1721
2453
|
break;
|
|
1722
2454
|
}
|
|
@@ -1738,7 +2470,7 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
|
|
|
1738
2470
|
if (type === "group" && node["effect"] !== void 0) {}
|
|
1739
2471
|
}
|
|
1740
2472
|
const kidsRaw = node["children"];
|
|
1741
|
-
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("") : "";
|
|
1742
2474
|
return openTag(node, css.map(([p, v]) => `${p}:${v}`).join(";"), extraAttrs) + inner + childrenHtml + "</div>";
|
|
1743
2475
|
}
|
|
1744
2476
|
const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -1760,7 +2492,7 @@ function computeCanvas(nodes, vars) {
|
|
|
1760
2492
|
let maxW = 0;
|
|
1761
2493
|
let maxH = 0;
|
|
1762
2494
|
for (const n of nodes) {
|
|
1763
|
-
if (!isRecord(n)) continue;
|
|
2495
|
+
if (!isRecord$1(n)) continue;
|
|
1764
2496
|
const box = nodeBox(n, vars);
|
|
1765
2497
|
maxW = Math.max(maxW, box.x + box.w);
|
|
1766
2498
|
maxH = Math.max(maxH, box.y + box.h);
|
|
@@ -1773,7 +2505,7 @@ function computeCanvas(nodes, vars) {
|
|
|
1773
2505
|
function boardBoxes(nodes, vars) {
|
|
1774
2506
|
const out = [];
|
|
1775
2507
|
for (const n of nodes) {
|
|
1776
|
-
if (!isRecord(n)) continue;
|
|
2508
|
+
if (!isRecord$1(n)) continue;
|
|
1777
2509
|
const box = nodeBox(n, vars);
|
|
1778
2510
|
const name = typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : void 0;
|
|
1779
2511
|
out.push({
|
|
@@ -1788,12 +2520,93 @@ function boardBoxes(nodes, vars) {
|
|
|
1788
2520
|
return out;
|
|
1789
2521
|
}
|
|
1790
2522
|
/** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
|
|
1791
|
-
function renderTree(nodes, vars, warnings) {
|
|
1792
|
-
return nodes.filter(isRecord).map((n) => {
|
|
1793
|
-
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);
|
|
1794
2526
|
}).join("");
|
|
1795
2527
|
}
|
|
1796
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
|
+
}
|
|
1797
2610
|
/**
|
|
1798
2611
|
* 渲染入口(决策 2/3/4/5 的兑现):
|
|
1799
2612
|
* 变量内联解析 → ref 展开 → 树 → HTML(每节点 data-node-id)。
|
|
@@ -1802,11 +2615,23 @@ function renderTree(nodes, vars, warnings) {
|
|
|
1802
2615
|
function renderDocument(doc, opts = {}) {
|
|
1803
2616
|
const warnings = [];
|
|
1804
2617
|
const vars = makeVarResolver(doc, warnings);
|
|
1805
|
-
const
|
|
1806
|
-
const
|
|
1807
|
-
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);
|
|
1808
2629
|
const title = opts.title ?? "paintress render";
|
|
1809
|
-
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)) : "";
|
|
1810
2635
|
return {
|
|
1811
2636
|
html: [
|
|
1812
2637
|
"<!DOCTYPE html>",
|
|
@@ -1816,14 +2641,17 @@ function renderDocument(doc, opts = {}) {
|
|
|
1816
2641
|
`<title>${escapeAttr(title)}</title>`,
|
|
1817
2642
|
"<style>",
|
|
1818
2643
|
"* { box-sizing: border-box; margin: 0; padding: 0; }",
|
|
1819
|
-
|
|
1820
|
-
|
|
2644
|
+
`body { margin: 0; background: ${opts.transparent === true ? "transparent" : "#ffffff"}; }`,
|
|
2645
|
+
`.ptr-canvas { position: relative; font-family: ${defaultStack}; }`,
|
|
2646
|
+
...faces,
|
|
1821
2647
|
"</style>",
|
|
1822
2648
|
"</head>",
|
|
1823
2649
|
"<body>",
|
|
1824
2650
|
`<div class="ptr-canvas" style="width:${canvas.width}px;height:${canvas.height}px">`,
|
|
1825
2651
|
body,
|
|
1826
2652
|
"</div>",
|
|
2653
|
+
fontLoaderScript(links),
|
|
2654
|
+
probe,
|
|
1827
2655
|
"</body>",
|
|
1828
2656
|
"</html>",
|
|
1829
2657
|
""
|
|
@@ -1833,4 +2661,4 @@ function renderDocument(doc, opts = {}) {
|
|
|
1833
2661
|
boards
|
|
1834
2662
|
};
|
|
1835
2663
|
}
|
|
1836
|
-
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 };
|