dsh-visualkit 0.0.0-stage → 0.0.1
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/CHANGELOG.md +60 -0
- package/LICENSE +26 -0
- package/README.md +427 -2
- package/THIRD_PARTY.md +55 -0
- package/cordis.patch.yml +29 -0
- package/data/assets/README.md +38 -0
- package/data/assets/icons/brands/bilibili.svg +1 -0
- package/data/assets/icons/brands/discord.svg +1 -0
- package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
- package/data/assets/icons/brands/qq.svg +1 -0
- package/data/assets/icons/brands/sinaweibo.svg +1 -0
- package/data/assets/icons/brands/steam.svg +1 -0
- package/data/assets/icons/brands/telegram.svg +1 -0
- package/data/assets/icons/brands/tiktok.svg +1 -0
- package/data/assets/icons/brands/twitch.svg +1 -0
- package/data/assets/icons/brands/wechat.svg +1 -0
- package/data/assets/icons/brands/x.svg +1 -0
- package/data/assets/icons/brands/youtube.svg +1 -0
- package/data/assets/icons/lucide/bell.svg +1 -0
- package/data/assets/icons/lucide/calendar.svg +1 -0
- package/data/assets/icons/lucide/clock.svg +1 -0
- package/data/assets/icons/lucide/gamepad-2.svg +1 -0
- package/data/assets/icons/lucide/gift.svg +1 -0
- package/data/assets/icons/lucide/heart.svg +1 -0
- package/data/assets/icons/lucide/mic.svg +1 -0
- package/data/assets/icons/lucide/play.svg +1 -0
- package/data/assets/icons/lucide/star.svg +1 -0
- package/data/assets/icons/lucide/video.svg +1 -0
- package/data/assets/icons/phosphor/LICENSE +21 -0
- package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
- package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
- package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
- package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
- package/data/assets/icons/phosphor/code-duotone.svg +1 -0
- package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
- package/data/assets/icons/phosphor/database-duotone.svg +1 -0
- package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
- package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
- package/data/assets/icons/phosphor/function-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
- package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
- package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
- package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
- package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
- package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
- package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
- package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
- package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
- package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
- package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
- package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
- package/data/assets/icons/tabler/alarm.svg +1 -0
- package/data/assets/icons/tabler/badge-ad.svg +1 -0
- package/data/assets/icons/tabler/bell.svg +1 -0
- package/data/assets/icons/tabler/bolt.svg +1 -0
- package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
- package/data/assets/icons/tabler/brand-discord.svg +1 -0
- package/data/assets/icons/tabler/brand-qq.svg +1 -0
- package/data/assets/icons/tabler/brand-twitch.svg +1 -0
- package/data/assets/icons/tabler/brand-wechat.svg +1 -0
- package/data/assets/icons/tabler/brand-weibo.svg +1 -0
- package/data/assets/icons/tabler/brand-youtube.svg +1 -0
- package/data/assets/icons/tabler/broadcast.svg +1 -0
- package/data/assets/icons/tabler/calendar-event.svg +1 -0
- package/data/assets/icons/tabler/calendar-week.svg +1 -0
- package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
- package/data/assets/icons/tabler/coin.svg +1 -0
- package/data/assets/icons/tabler/crown.svg +1 -0
- package/data/assets/icons/tabler/currency-yen.svg +1 -0
- package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
- package/data/assets/icons/tabler/diamond.svg +1 -0
- package/data/assets/icons/tabler/flame.svg +1 -0
- package/data/assets/icons/tabler/gift.svg +1 -0
- package/data/assets/icons/tabler/headphones.svg +1 -0
- package/data/assets/icons/tabler/heart-filled.svg +1 -0
- package/data/assets/icons/tabler/heart.svg +1 -0
- package/data/assets/icons/tabler/live-view.svg +1 -0
- package/data/assets/icons/tabler/microphone.svg +1 -0
- package/data/assets/icons/tabler/mood-smile.svg +1 -0
- package/data/assets/icons/tabler/mood-tongue.svg +1 -0
- package/data/assets/icons/tabler/movie.svg +1 -0
- package/data/assets/icons/tabler/player-play.svg +1 -0
- package/data/assets/icons/tabler/playlist.svg +1 -0
- package/data/assets/icons/tabler/rocket.svg +1 -0
- package/data/assets/icons/tabler/sparkles.svg +1 -0
- package/data/assets/icons/tabler/star-filled.svg +1 -0
- package/data/assets/icons/tabler/star.svg +1 -0
- package/data/assets/icons/tabler/tag.svg +1 -0
- package/data/assets/icons/tabler/thumb-up.svg +1 -0
- package/data/assets/icons/tabler/ticket.svg +1 -0
- package/data/assets/icons/tabler/video.svg +1 -0
- package/data/assets/illustrations/openpeeps/LICENSE +8 -0
- package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
- package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
- package/data/assets/manifest.json +1 -0
- package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
- package/data/assets/parts//345/217/2211.png +0 -0
- package/data/assets/parts//345/217/2212.png +0 -0
- package/data/assets/parts//345/217/2213.png +0 -0
- package/data/assets/parts//345/217/221/345/234/210.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
- package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/217/263/346/211/213.png +0 -0
- package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/217/263/350/205/277.png +0 -0
- package/data/assets/parts//345/221/206/346/257/233.png +0 -0
- package/data/assets/parts//345/230/264/345/267/264.png +0 -0
- package/data/assets/parts//345/260/276/345/267/264.png +0 -0
- package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
- package/data/assets/parts//345/267/246/346/211/213.png +0 -0
- package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/267/246/350/205/277.png +0 -0
- package/data/assets/parts//346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//347/234/211/346/257/233.png +0 -0
- package/data/assets/parts//347/234/274/347/275/251.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
- package/data/assets/parts//350/204/221/345/243/263.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
- package/data/assets/parts//350/272/253/344/275/223.png +0 -0
- package/data/assets/parts//351/223/201/346/235/277.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
- package/data/assets/patterns/checker.svg +1 -0
- package/data/assets/patterns/circles-overlap.svg +1 -0
- package/data/assets/patterns/crosshatch.svg +1 -0
- package/data/assets/patterns/diagonal.svg +1 -0
- package/data/assets/patterns/dots.svg +1 -0
- package/data/assets/patterns/grid.svg +1 -0
- package/data/assets/patterns/halftone.svg +1 -0
- package/data/assets/patterns/plus.svg +1 -0
- package/data/assets/patterns/scales.svg +1 -0
- package/data/assets/patterns/triangles.svg +1 -0
- package/data/assets/patterns/waves.svg +1 -0
- package/data/assets/patterns/zigzag.svg +1 -0
- package/data/assets/stickers/openmoji/1F375.svg +12 -0
- package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
- package/data/assets/stickers/twemoji/1f389.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
- package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
- package/data/assets/stickers/twemoji/1f44b.svg +1 -0
- package/data/assets/stickers/twemoji/1f44d.svg +1 -0
- package/data/assets/stickers/twemoji/1f494.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
- package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
- package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
- package/data/assets/stickers/twemoji/1f525.svg +1 -0
- package/data/assets/stickers/twemoji/1f600.svg +1 -0
- package/data/assets/stickers/twemoji/1f602.svg +1 -0
- package/data/assets/stickers/twemoji/1f60d.svg +1 -0
- package/data/assets/stickers/twemoji/1f62d.svg +1 -0
- package/data/assets/stickers/twemoji/1f680.svg +1 -0
- package/data/assets/stickers/twemoji/1f979.svg +1 -0
- package/data/assets/stickers/twemoji/1f97a.svg +1 -0
- package/data/assets/stickers/twemoji/26a1.svg +1 -0
- package/data/assets/stickers/twemoji/2705.svg +1 -0
- package/data/assets/stickers/twemoji/274c.svg +1 -0
- package/data/assets/stickers/twemoji/2764.svg +1 -0
- package/data/assets/stickers/twemoji/2b50.svg +1 -0
- package/data/assets/stickers/twemoji_1f525.svg +1 -0
- package/data/assets/stickers/twemoji_1f600.svg +1 -0
- package/data/assets/stickers/twemoji_1f979.svg +1 -0
- package/data/assets/stickers/twemoji_2764.svg +1 -0
- package/data/assets/textures/grain-dark.png +0 -0
- package/data/assets/textures/grain-light.png +0 -0
- package/data/assets/textures/grain-warm.png +0 -0
- package/data/lucide-icons.json +1 -0
- package/data/packs/tabler.icons.json +1 -0
- package/icon.svg +26 -0
- package/lib/assets.js +228 -0
- package/lib/audit.js +284 -0
- package/lib/contrast-render.js +213 -0
- package/lib/css-rewrite.js +379 -0
- package/lib/detect.js +701 -0
- package/lib/diff.js +153 -0
- package/lib/emit.js +557 -0
- package/lib/html-context.js +315 -0
- package/lib/icon-render.js +107 -0
- package/lib/icondb.js +402 -0
- package/lib/image-normalize.js +140 -0
- package/lib/index.js +1289 -0
- package/lib/layouts/common.js +48 -0
- package/lib/layouts/image.js +1010 -0
- package/lib/layouts/index.js +10 -0
- package/lib/layouts/intent.js +66 -0
- package/lib/layouts/metrics-text.js +91 -0
- package/lib/layouts/png.js +75 -0
- package/lib/layouts/rules-contrast.js +575 -0
- package/lib/layouts/rules-punct.js +486 -0
- package/lib/layouts/rules-structure.js +182 -0
- package/lib/layouts/source.js +1961 -0
- package/lib/layouts/svg.js +1204 -0
- package/lib/morphcore.js +139 -0
- package/lib/preview.js +225 -0
- package/lib/render-html.js +255 -0
- package/lib/render.js +486 -0
- package/lib/rules.js +267 -0
- package/lib/shadow.js +83 -0
- package/lib/skip.js +56 -0
- package/lib/style.js +688 -0
- package/lib/svg.js +729 -0
- package/lib/taste-image.js +323 -0
- package/lib/taste.js +219 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +92 -4
- package/vendor/morphicons-adapters.iife.js +903 -0
- package/vendor/morphicons.iife.js +2441 -0
- package/vendor/resvg/LICENSE +373 -0
- package/vendor/resvg/index.d.ts +98 -0
- package/vendor/resvg/index.js +23 -0
- package/vendor/resvg/js-binding.d.ts +52 -0
- package/vendor/resvg/js-binding.js +1 -0
- package/vendor/resvg/package.json +138 -0
- package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
package/lib/morphcore.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* morphcore.js — 在 Node 侧装载 vendored morphicons 核心(可选能力)。
|
|
3
|
+
*
|
|
4
|
+
* vendor/morphicons.iife.js 是 morphicons 2.0.2 的浏览器 IIFE(var MorphIcons = ...),没有 ESM
|
|
5
|
+
* 导出;这里读取其源码、追加 default 导出后经 data: URL 动态 import。
|
|
6
|
+
* 核心是纯计算(弹簧/重采样/canonical 序列化),加载期不触 DOM ——
|
|
7
|
+
* createMorph 只需要一个带 setAttribute 的对象(本模块提供假元素),
|
|
8
|
+
* 因此 canonicalD / 中间态 seek 在 Node 里可直接算。
|
|
9
|
+
*
|
|
10
|
+
* 任何一步失败都静默降级(返回 null),调用方按能力缺失处理,
|
|
11
|
+
* 绝不让预览/体检工具因 vendor 问题而不可用。
|
|
12
|
+
*
|
|
13
|
+
* 信任边界(codereview #490):下方 data: URL 动态 import 等价于执行读入的字符串
|
|
14
|
+
* (eval 类模式),其安全性完全依赖「VENDOR_PATH / ADAPTERS_PATH 是模块相对的
|
|
15
|
+
* 构建期固定产物」这一前提——路径与源码内容都不得受用户/插件输入影响,后续
|
|
16
|
+
* 维护不得把这两个来源改成可注入的(若改为打包器分发,需同步补构建期
|
|
17
|
+
* checksum 校验并重审 dirname(import.meta.url) 的解析)。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const VENDOR_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "morphicons.iife.js");
|
|
24
|
+
const ADAPTERS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "morphicons-adapters.iife.js");
|
|
25
|
+
|
|
26
|
+
let api = undefined; // undefined=未尝试; null=不可用; object=可用
|
|
27
|
+
let adaptersApi = undefined; // 同上三态;adapters(svgToIcon/fitIcon),v0.3.0 起
|
|
28
|
+
// 审查修复 #285:装载中的 Promise(单飞缓存)——模块 import 即触发 adaptersWarmup,
|
|
29
|
+
// 工具在完成前再调用会重复 readFileSync 与动态 import(三态判断发生在任何
|
|
30
|
+
// await 之前,竞态窗口真实存在);缓存进行中的 Promise 并在体内同步回写结果
|
|
31
|
+
let corePromise = null;
|
|
32
|
+
let adaptersPromise = null;
|
|
33
|
+
|
|
34
|
+
/** 后台预热 adapters(import 本模块即触发;工具执行时基本已就绪)。 */
|
|
35
|
+
const adaptersWarmup = (async () => {
|
|
36
|
+
await loadAdapters();
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
/** 假 <path> 元素:记录最后一次 setAttribute('d'),供离线采样中间态。 */
|
|
40
|
+
class FakePathEl {
|
|
41
|
+
constructor() {
|
|
42
|
+
this.lastD = "";
|
|
43
|
+
}
|
|
44
|
+
setAttribute(name, value) {
|
|
45
|
+
if (name === "d") this.lastD = String(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* codereview #489:装载 vendored IIFE 的公共步骤(读取 → 追加 default 导出 →
|
|
51
|
+
* data: URL 动态 import → 形状校验)。loadMorphCore 与 loadAdapters 原先各自
|
|
52
|
+
* 手写一遍,补丁串格式(尾换行、charset)这类细节只改一处就会悄然分叉。
|
|
53
|
+
* 读取/导入/校验失败直接抛错,降级与三态缓存仍留在各自调用方。
|
|
54
|
+
*/
|
|
55
|
+
async function importVendedIife(path, globalName, validate) {
|
|
56
|
+
const source = readFileSync(path, "utf8");
|
|
57
|
+
const patched = `${source}\nexport default ${globalName};\n`;
|
|
58
|
+
const mod = await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(patched)}`);
|
|
59
|
+
const value = mod.default;
|
|
60
|
+
if (!validate(value)) throw new Error(`unexpected vendor shape: ${globalName}`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 单例装载;失败缓存(null),不反复重试 IO。 */
|
|
65
|
+
async function loadMorphCore() {
|
|
66
|
+
if (api !== undefined) return api;
|
|
67
|
+
if (corePromise === null) {
|
|
68
|
+
corePromise = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const M = await importVendedIife(VENDOR_PATH, "MorphIcons", (v) => v !== undefined && typeof v.canonicalD === "function" && typeof v.createMorph === "function");
|
|
71
|
+
api = {
|
|
72
|
+
/** canonical path 字符串(morphicons 的静止态快照) */
|
|
73
|
+
canonicalD: (node) => M.canonicalD(node),
|
|
74
|
+
/** fill 图标形变前需要 region 可见表面数据(2.0 新增);stroke 图标无事发生。 */
|
|
75
|
+
preloadRegion: async () => {
|
|
76
|
+
if (typeof M.preloadRegion === "function") await M.preloadRegion();
|
|
77
|
+
},
|
|
78
|
+
/** 离线采样:从 from 形变到 to,在进度 t 处的 path d 字符串(无弹簧、无 DOM) */
|
|
79
|
+
sampleD(from, to, t) {
|
|
80
|
+
const el = new FakePathEl();
|
|
81
|
+
const inst = M.createMorph(el, from, { reducedMotion: "always" });
|
|
82
|
+
try {
|
|
83
|
+
inst.seek(to, Math.min(Math.max(t, 0), 1));
|
|
84
|
+
// 审查修复 #286:删除「progress 兜底再 seek」死分支——vendor 的
|
|
85
|
+
// createMorph 构造时即写入 canonical d、seek→render 同步执行,
|
|
86
|
+
// lastD 为空只可能是渲染失败:按目标静止态返回对 t<1 是完全
|
|
87
|
+
// 错误的中间态数据,宁可响亮失败也不静默降级给错值
|
|
88
|
+
if (el.lastD === "") throw new Error("sampleD: seek 未产出中间态路径(vendor 行为变化?)");
|
|
89
|
+
return el.lastD;
|
|
90
|
+
} finally {
|
|
91
|
+
inst.destroy?.();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// 审查修复 #287:静默降级保留,但留一行可检索诊断——vendor 缺失/
|
|
97
|
+
// 权限错误/形状不符时能从宿主日志区分「装载失败」与「预热未完成」
|
|
98
|
+
console.warn(`[uilab] morphicons core 不可用(按能力缺失降级):${error?.message ?? error}`);
|
|
99
|
+
api = null;
|
|
100
|
+
}
|
|
101
|
+
return api;
|
|
102
|
+
})();
|
|
103
|
+
}
|
|
104
|
+
return corePromise;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 同法装载 adapters IIFE(svgToIcon + fitIcon,均无 DOM 依赖)。2.0 把 fitIcon
|
|
108
|
+
* 移到核心导出(adapters 子路径只剩 svgToIcon/canvasTarget/maskTarget),构建 entry
|
|
109
|
+
* 一并再导出 fitIcon,保持本模块「svgToIcon + fitIcon」的既有装载契约。 */
|
|
110
|
+
async function loadAdapters() {
|
|
111
|
+
if (adaptersApi !== undefined) return adaptersApi;
|
|
112
|
+
// 审查修复 #285:单飞缓存——adaptersWarmup 进行中的竞态调用原先会重复装载
|
|
113
|
+
if (adaptersPromise === null) {
|
|
114
|
+
adaptersPromise = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
const A = await importVendedIife(ADAPTERS_PATH, "MorphIconsAdapters", (v) => typeof v?.svgToIcon === "function" && typeof v?.fitIcon === "function");
|
|
117
|
+
adaptersApi = { svgToIcon: A.svgToIcon, fitIcon: A.fitIcon };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// 审查修复 #287:静默降级保留,但留一行可检索诊断(理由同 core 侧)
|
|
120
|
+
console.warn(`[uilab] morphicons adapters 不可用(引擎层判定降级为跳过):${error?.message ?? error}`);
|
|
121
|
+
adaptersApi = null;
|
|
122
|
+
}
|
|
123
|
+
return adaptersApi;
|
|
124
|
+
})();
|
|
125
|
+
}
|
|
126
|
+
return adaptersPromise;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 同步取已缓存的 adapters;未就绪/不可用返回 undefined(调用方按能力缺失处理)。
|
|
130
|
+
* codereview #488(取舍记录):这里刻意把「预热中」与「确认不可用」坍缩为同一个
|
|
131
|
+
* undefined——唯一需要区分二者的场景是「装载未完成 ≠ 能力缺失」,而那类调用方的
|
|
132
|
+
* 正确姿势是先 `await adaptersWarmup` 再判定(selftest 即此写法;adaptersWarmup 在
|
|
133
|
+
* 模块 import 时即触发,工具实际执行时早已 settle)。再导出 adaptersState() 之类
|
|
134
|
+
* 三态探针在本仓库没有消费者,只会新增死 API;需要时以此为锚补导出。 */
|
|
135
|
+
function adaptersSync() {
|
|
136
|
+
return adaptersApi === null ? undefined : adaptersApi;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { adaptersSync, adaptersWarmup, loadAdapters, loadMorphCore };
|
package/lib/preview.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preview.js — morph action=preview 的实现核心:生成自包含 HTML 动效预览页。
|
|
3
|
+
*
|
|
4
|
+
* 页面内嵌 vendored morphicons 2.0.2 IIFE 与两端
|
|
5
|
+
* 图标的 IconNode 数据,离线可开:单文件、无外链、无构建。
|
|
6
|
+
* 交互:点击切换(弹簧动画,snappy/smooth/bouncy 可选)+ 进度条拖动
|
|
7
|
+
* (seek 受控模式)+ 端点参照图。若 Node 侧 morphcore 可用,还服务端烘焙
|
|
8
|
+
* 一排中间态静帧(t=0…1),不点不动也能看形变过程。
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { loadAdapters, loadMorphCore } from "./morphcore.js";
|
|
16
|
+
import { checkIcon, extractInner, iconToInnerSvg, parseIconNodes } from "./svg.js";
|
|
17
|
+
import { getIcon } from "./icondb.js";
|
|
18
|
+
|
|
19
|
+
const VENDOR_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "morphicons.iife.js");
|
|
20
|
+
const SPRINGS = ["snappy", "smooth", "bouncy"];
|
|
21
|
+
|
|
22
|
+
/** fill 风格图标(包数据里的品牌/实心图标):2.0 的 region 数据需先 preload,
|
|
23
|
+
* 否则服务端烘焙的静帧会停留在起点形状(浏览器侧点击 morph 不受影响)。 */
|
|
24
|
+
function isFillDrawn(node) {
|
|
25
|
+
return node.some(([, attrs]) => typeof attrs?.fill === "string" && attrs.fill !== "none");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readVendor() {
|
|
29
|
+
return readFileSync(VENDOR_PATH, "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** JSON → 可内嵌 <script> 的字面量(防 </script> 提前闭合)。 */
|
|
33
|
+
function embedJson(value) {
|
|
34
|
+
return JSON.stringify(value).replace(/<\//g, "<\\/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function frameSvg(d, size, label) {
|
|
38
|
+
return (
|
|
39
|
+
`<div class="frame"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
|
|
40
|
+
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>` +
|
|
41
|
+
(label ? `<span>${label}</span>` : "") + `</div>`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sideSvg(inner, size, label) {
|
|
46
|
+
return (
|
|
47
|
+
`<div class="side"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
|
|
48
|
+
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg><span>${label}</span></div>`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 生成预览页 HTML。
|
|
54
|
+
* @param {object} p
|
|
55
|
+
* @param {{name:string,node:object[]}} p.from 起始图标
|
|
56
|
+
* @param {{name:string,node:object[]}} p.to 目标图标
|
|
57
|
+
*/
|
|
58
|
+
async function buildPreviewHtml({ from, to, spring = "snappy", size = 120 }) {
|
|
59
|
+
const preset = SPRINGS.includes(spring) ? spring : "snappy";
|
|
60
|
+
// 舞台尺寸:给边界(40–800),非法值回落 120;声明了就不能无效
|
|
61
|
+
const stageSize = typeof size === "number" && Number.isFinite(size) && size >= 40 && size <= 800 ? Math.round(size) : 120;
|
|
62
|
+
const vendor = readVendor();
|
|
63
|
+
const core = await loadMorphCore();
|
|
64
|
+
|
|
65
|
+
// 服务端烘焙中间态静帧(morphcore 不可用则整段省略)。
|
|
66
|
+
// 这里只算 **帧内容**,不拼 <section>/<h2> —— 标记写在主模板里,
|
|
67
|
+
// 否则源码顺序会变成「h2 在 h1 之前」:自测(layout_check ui)就报过
|
|
68
|
+
// structure-heading-first,虽然浏览器里渲染顺序是对的,但源码层确实误导人。
|
|
69
|
+
let framesHtml = "";
|
|
70
|
+
if (core !== null) {
|
|
71
|
+
if (isFillDrawn(from.node) || isFillDrawn(to.node)) {
|
|
72
|
+
try {
|
|
73
|
+
await core.preloadRegion?.();
|
|
74
|
+
} catch {
|
|
75
|
+
/* region 不可用:仍出静帧,但可能停在起点形状 */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const marks = [0, 0.25, 0.5, 0.75, 1];
|
|
79
|
+
const frames = marks.map((t) => frameSvg(core.sampleD(from.node, to.node, t), 44, t === 0 ? "t=0" : `t=${t}`));
|
|
80
|
+
framesHtml = `<div class="frames">${frames.join("")}</div>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const title = `${from.name} ⇄ ${to.name}`;
|
|
84
|
+
return `<!doctype html>
|
|
85
|
+
<html lang="zh-CN">
|
|
86
|
+
<head>
|
|
87
|
+
<meta charset="utf-8">
|
|
88
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
89
|
+
<title>morph 预览 · ${title}</title>
|
|
90
|
+
<style>
|
|
91
|
+
:root { color-scheme: light dark; }
|
|
92
|
+
/* 字阶 4 档:24 / 18 / 14 / 12(T 梯级 24/14 = 1.71×,过 1.6 线)。
|
|
93
|
+
原稿是 18 / 13 / 12,被自测(layout_check ui S1/S9)判为「梯级跮断、
|
|
94
|
+
h2 与正文分不开」——2px 的差在 100% 缩放下看不出来。 */
|
|
95
|
+
body { font: 14px/1.6 system-ui, "Segoe UI", sans-serif; margin: 0; padding: 24px; max-width: 640px; margin-inline: auto; }
|
|
96
|
+
h1 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; }
|
|
97
|
+
h2 { font-size: 18px; line-height: 1.3; opacity: .7; margin: 22px 0 8px; }
|
|
98
|
+
.meta { opacity: .6; font-size: 12px; margin-bottom: 16px; }
|
|
99
|
+
.stage-wrap { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
|
|
100
|
+
/* 用 #2563eb 而不是 #3b82f6:白字在 #3b82f6 上只有 3.68:1(低于 WCAG AA 4.5:1),
|
|
101
|
+
#2563eb 是 5.17:1,过 AA。这是自测(layout_check ui)在我们的自己的代码里查出来的。 */
|
|
102
|
+
.stage { color: #2563eb; }
|
|
103
|
+
.side { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 12px; opacity: .75; }
|
|
104
|
+
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 16px; }
|
|
105
|
+
button { font: inherit; padding: 6px 18px; border-radius: 8px; border: 1px solid currentColor; background: transparent; cursor: pointer; }
|
|
106
|
+
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
107
|
+
input[type=range] { width: 240px; }
|
|
108
|
+
fieldset { border: none; padding: 0; margin: 0; display: flex; gap: 8px; font-size: 14px; }
|
|
109
|
+
.strip .frames { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
110
|
+
.frame { display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 12px; opacity: .85; }
|
|
111
|
+
.hint { margin-top: 18px; font-size: 12px; opacity: .55; }
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<h1>morph 预览 · ${title}</h1>
|
|
116
|
+
<div class="meta">morphicons 2.0.2(vendored 上游)· spring=${preset} · 点击按钮看弹簧动画,拖动进度条看受控形变</div>
|
|
117
|
+
|
|
118
|
+
<div class="stage-wrap">
|
|
119
|
+
${sideSvg(iconToInnerSvg(from.node), 40, from.name)}
|
|
120
|
+
<svg id="stage" class="stage" width="${stageSize}" height="${stageSize}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path/></svg>
|
|
121
|
+
${sideSvg(iconToInnerSvg(to.node), 40, to.name)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="controls">
|
|
125
|
+
<button id="toggle" class="primary">morph → ${to.name}</button>
|
|
126
|
+
<label>进度 <input id="scrub" type="range" min="0" max="1" step="0.001" value="0"></label>
|
|
127
|
+
<fieldset id="springs">
|
|
128
|
+
<label><input type="radio" name="spring" value="snappy" ${preset === "snappy" ? "checked" : ""}>snappy</label>
|
|
129
|
+
<label><input type="radio" name="spring" value="smooth" ${preset === "smooth" ? "checked" : ""}>smooth</label>
|
|
130
|
+
<label><input type="radio" name="spring" value="bouncy" ${preset === "bouncy" ? "checked" : ""}>bouncy</label>
|
|
131
|
+
</fieldset>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
${framesHtml === "" ? "" : `<section class="strip"><h2>中间态静帧(服务端烘焙)</h2>${framesHtml}</section>`}
|
|
135
|
+
|
|
136
|
+
<p class="hint">进度条对应 seek(受控模式,无弹簧);按钮对应 morphTo(可中断弹簧,中途再点会从中间形重新规划)。reducedMotion 跟随系统设置。</p>
|
|
137
|
+
|
|
138
|
+
<script>
|
|
139
|
+
${vendor}
|
|
140
|
+
</script>
|
|
141
|
+
<script>
|
|
142
|
+
const FROM = ${embedJson(from.node)};
|
|
143
|
+
const TO = ${embedJson(to.node)};
|
|
144
|
+
const stageEl = document.querySelector('#stage path');
|
|
145
|
+
const inst = MorphIcons.createMorph(stageEl, FROM, { reducedMotion: 'user' });
|
|
146
|
+
let atFrom = true;
|
|
147
|
+
let spring = ${JSON.stringify(preset)};
|
|
148
|
+
const toggleBtn = document.querySelector('#toggle');
|
|
149
|
+
toggleBtn.addEventListener('click', () => {
|
|
150
|
+
atFrom = !atFrom;
|
|
151
|
+
inst.morphTo(atFrom ? FROM : TO, spring);
|
|
152
|
+
toggleBtn.textContent = 'morph → ' + (atFrom ? ${JSON.stringify(to.name)} : ${JSON.stringify(from.name)});
|
|
153
|
+
});
|
|
154
|
+
document.querySelector('#scrub').addEventListener('input', (e) => {
|
|
155
|
+
inst.seek(TO, Number(e.target.value)); // t=0 即 FROM、t=1 即 TO
|
|
156
|
+
});
|
|
157
|
+
document.querySelector('#springs').addEventListener('change', (e) => { spring = e.target.value; });
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 默认输出目录:config.previewDir → 系统临时目录下的 dsh-uilab。 */
|
|
165
|
+
function defaultPreviewDir(configured) {
|
|
166
|
+
if (configured && configured.trim() !== "") return configured;
|
|
167
|
+
return join(tmpdir(), "dsh-uilab");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 生成并落盘预览页;返回 { path, sizeBytes, spring }。 */
|
|
171
|
+
async function writePreview({ from, to, spring, outPath, previewDir, cwd, size }) {
|
|
172
|
+
const html = await buildPreviewHtml({ from, to, spring, size });
|
|
173
|
+
const target = outPath && outPath.trim() !== ""
|
|
174
|
+
? (isAbsolute(outPath) ? outPath : join(cwd ?? process.cwd(), outPath))
|
|
175
|
+
: join(defaultPreviewDir(previewDir), `morph-${from.name}-to-${to.name}.html`);
|
|
176
|
+
await mkdir(dirname(target), { recursive: true });
|
|
177
|
+
await writeFile(target, html, "utf8");
|
|
178
|
+
return { path: target, sizeBytes: Buffer.byteLength(html, "utf8"), file: basename(target), spring: SPRINGS.includes(spring) ? spring : "snappy" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** SVG 标记 → { name, node };非 24 网格用 vendored 引擎 fitIcon 归一化。 */
|
|
182
|
+
async function fromMarkup(raw, name, checkOnRaw) {
|
|
183
|
+
if (!checkOnRaw) return { name, node: [] };
|
|
184
|
+
const { inner, viewBox } = extractInner(raw);
|
|
185
|
+
let node = parseIconNodes(inner);
|
|
186
|
+
if (node === null) {
|
|
187
|
+
const check = checkIcon(raw);
|
|
188
|
+
throw new Error(`输入 SVG 未通过 morph 兼容体检:${check.issues.join(";") || "解析不出基本形节点"}(详见 morph action=check)`);
|
|
189
|
+
}
|
|
190
|
+
// 非 24 网格:preview 的舞台是 24 viewBox,不归一化会直接裁切(实测 48 网格矩形被框外切掉)
|
|
191
|
+
if (viewBox !== undefined && viewBox.replace(/\s+/g, "") !== "002424") {
|
|
192
|
+
try {
|
|
193
|
+
const adapters = await loadAdapters();
|
|
194
|
+
const fitted = adapters.fitIcon(node, viewBox, 24);
|
|
195
|
+
if (typeof fitted === "string" && fitted.length > 0) node = [["path", { d: fitted }]];
|
|
196
|
+
} catch {
|
|
197
|
+
/* 引擎不可用:保留原节点(check 会提示需归一化) */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { name, node };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 把「图标名 / .svg 文件路径 / 内联标记」解析为 { name, node };SVG 输入必须先过 morph action=check。 */
|
|
204
|
+
async function resolveIconInput(input, { checkOnRaw = true, cwd } = {}) {
|
|
205
|
+
const raw = String(input ?? "").trim();
|
|
206
|
+
if (raw === "") throw new Error("empty icon input");
|
|
207
|
+
if (!raw.includes("<")) {
|
|
208
|
+
const icon = getIcon(raw);
|
|
209
|
+
if (icon !== undefined) return { name: icon.name, node: icon.node };
|
|
210
|
+
if (raw.toLowerCase().endsWith(".svg")) {
|
|
211
|
+
const file = isAbsolute(raw) ? raw : join(cwd ?? process.cwd(), raw);
|
|
212
|
+
let svg;
|
|
213
|
+
try {
|
|
214
|
+
svg = await readFile(file, "utf8");
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`.svg 文件读不到:${raw}(解析为 ${file};相对路径按会话目录)`);
|
|
217
|
+
}
|
|
218
|
+
return fromMarkup(svg, basename(file), checkOnRaw);
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`unknown lucide icon "${raw}"(可先用 icon action=find 检索;也可传 .svg 文件路径或直接粘贴 SVG 标记)`);
|
|
221
|
+
}
|
|
222
|
+
return fromMarkup(raw, "custom-svg", checkOnRaw);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { SPRINGS, buildPreviewHtml, writePreview, resolveIconInput };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-html.js — HTML → PNG(调用本机已安装的浏览器无头模式)。
|
|
3
|
+
*
|
|
4
|
+
* **为什么必须有**:很多桌面软件的内部 UI 本身就是 web 版;不能渲染 HTML 就等于
|
|
5
|
+
* “看不到自己的输出”,排版/间距/密度的问题根本无法发现。实测:参照稿在静态体检
|
|
6
|
+
* 拿到 clean 之后,一渲染就看出头像权重、行高、字号全线偏大。
|
|
7
|
+
*
|
|
8
|
+
* **对“零外部进程”承诺的取舍**:核心渲染(SVG)依旧完全自包含(vendored resvg);
|
|
9
|
+
* 只有 HTML 这一项需要本机浏览器。这是有意为之:
|
|
10
|
+
* - 自己写 HTML/CSS 布局引擎去“验证”用户界面的布局,等于用可能错的尺子量东西,
|
|
11
|
+
* 结论不可信;真实浏览器的引擎才是唯一可信的参照;
|
|
12
|
+
* - 探测不到浏览器时**明确报错并给方案**,绝不静默回退成“看起来对”的假渲染。
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
/** 常见安装位置(Windows 优先,macOS/Linux 也列上)。按可靠性排序。 */
|
|
21
|
+
const CANDIDATES = [
|
|
22
|
+
// Windows
|
|
23
|
+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|
24
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
25
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
26
|
+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
27
|
+
// macOS
|
|
28
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
29
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
30
|
+
// Linux
|
|
31
|
+
"/usr/bin/google-chrome",
|
|
32
|
+
"/usr/bin/chromium",
|
|
33
|
+
"/usr/bin/chromium-browser",
|
|
34
|
+
"/usr/bin/microsoft-edge"
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 找一个可用的 Chromium 系浏览器。
|
|
39
|
+
* @param {string} [explicit] 配置里明确指定的路径(优先级最高)
|
|
40
|
+
* @returns {{path: string, source: "config"|"detected"}|null}
|
|
41
|
+
*/
|
|
42
|
+
export function findBrowser(explicit) {
|
|
43
|
+
if (typeof explicit === "string" && explicit.trim() !== "") {
|
|
44
|
+
const p = resolve(explicit.trim());
|
|
45
|
+
return existsSync(p) ? { path: p, source: "config" } : null;
|
|
46
|
+
}
|
|
47
|
+
for (const candidate of CANDIDATES) {
|
|
48
|
+
if (existsSync(candidate)) return { path: candidate, source: "detected" };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 探测不到时的错误文案:给可执行的下一步,而不是只说“没找到”。 */
|
|
54
|
+
export function missingBrowserError() {
|
|
55
|
+
return new Error(
|
|
56
|
+
"没有找到可用于渲染 HTML 的浏览器。\n" +
|
|
57
|
+
"HTML 渲染需要本机已安装 Chromium 系浏览器(Edge / Chrome / Chromium)。\n" +
|
|
58
|
+
"可选做法:1) 安装 Edge 或 Chrome;2) 在插件配置里指定 browserPath 指向浏览器可执行文件;" +
|
|
59
|
+
"3) 改用 action=render 处理 SVG(完全离线,不依赖浏览器)。"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 量出页面真实内容高(CSS px)。
|
|
65
|
+
*
|
|
66
|
+
* 为什么不用 `--screenshot` 自己“整页”:Chromium 无头模式的所有截图都只覆盖视口
|
|
67
|
+
* (`--screenshot` 不传 `--window-size` 时用默认窗口),拿不到内容高就必须先算出来。
|
|
68
|
+
* 这里在副本里插一段脚本,让**页面自己**把 scrollHeight 写进 title,用 `--dump-dom`
|
|
69
|
+
* 读回来——全程不依赖 CDP,也不依赖页面配合。
|
|
70
|
+
*
|
|
71
|
+
* 副本里同时插 `<base href>`:副本在临时目录,不补 base 的话相对路径的 CSS/图片全部
|
|
72
|
+
* 404,量到的是裸文本高度(会偏小得离谱)。
|
|
73
|
+
*/
|
|
74
|
+
function measureHeight({ browser, pageUrl, html, baseUrl, width, timeoutMs }) {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-measure-"));
|
|
76
|
+
const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
|
|
77
|
+
const file = join(dir, "measure.html");
|
|
78
|
+
const inject =
|
|
79
|
+
`<base href="${baseUrl}"><script>addEventListener("load",()=>{` +
|
|
80
|
+
`const d=document.documentElement;` +
|
|
81
|
+
`document.title="H:"+Math.max(d.scrollHeight,d.getBoundingClientRect().height)});</script>`;
|
|
82
|
+
// 插在 <head> 后面(base 必须先于其它相对 URL 出现);没有 head 就塞到 <html> 后
|
|
83
|
+
let copy = String(html);
|
|
84
|
+
const headAt = copy.search(/<head[^>]*>/i);
|
|
85
|
+
if (headAt === -1) copy = inject + copy;
|
|
86
|
+
else {
|
|
87
|
+
const end = headAt + copy.slice(headAt).indexOf(">") + 1;
|
|
88
|
+
copy = copy.slice(0, end) + inject + copy.slice(end);
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(file, copy, "utf8");
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const run = spawnSync(
|
|
94
|
+
browser.path,
|
|
95
|
+
[
|
|
96
|
+
"--headless=new",
|
|
97
|
+
"--disable-gpu",
|
|
98
|
+
"--hide-scrollbars",
|
|
99
|
+
"--no-first-run",
|
|
100
|
+
"--no-default-browser-check",
|
|
101
|
+
"--disable-extensions",
|
|
102
|
+
"--disable-lcd-text",
|
|
103
|
+
`--window-size=${width},800`,
|
|
104
|
+
"--virtual-time-budget=3000",
|
|
105
|
+
"--dump-dom",
|
|
106
|
+
`--user-data-dir=${profileDir}`,
|
|
107
|
+
pathToFileURL(file).href
|
|
108
|
+
],
|
|
109
|
+
{ windowsHide: true, timeout: timeoutMs, encoding: "utf8" }
|
|
110
|
+
);
|
|
111
|
+
const hit = /H:(\d+)/.exec(String(run.stdout ?? ""));
|
|
112
|
+
if (hit === null) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"整页截图需要先量内容高,但页面没有回报高度(--dump-dom 输出里没有 H: 标记,页面可能报了 JS 错)。\n" +
|
|
115
|
+
`改成给明确 height 的视口截图,或手动跑:\n "${browser.path}" --headless=new --dump-dom "${pageUrl}"`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
// 卡上限:防止某层 height:100vh 把“整页”拉成几万像素而截不出来
|
|
119
|
+
return Math.min(30_000, Math.max(1, Number.parseInt(hit[1], 10)));
|
|
120
|
+
} finally {
|
|
121
|
+
for (const d of [dir, profileDir]) rmSync(d, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 读 PNG 的 IHDR 取真实像素尺寸;解析失败返回 0(不因回报失败而连渲染一起报错)。 */
|
|
126
|
+
function pngSize(file) {
|
|
127
|
+
try {
|
|
128
|
+
const buf = readFileSync(file);
|
|
129
|
+
const sig = buf.subarray(0, 8).toString("hex");
|
|
130
|
+
if (sig !== "89504e470d0a1a0a") return { width: 0, height: 0 };
|
|
131
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
132
|
+
} catch {
|
|
133
|
+
return { width: 0, height: 0 };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 把 HTML 渲染成 PNG。
|
|
139
|
+
* @param {object} opts
|
|
140
|
+
* @param {string} opts.html HTML 文件路径,或内联 HTML 标记
|
|
141
|
+
* @param {string} opts.out 输出 .png 绝对路径
|
|
142
|
+
* @param {number} [opts.width] 视口宽(默认 1280)
|
|
143
|
+
* @param {number} [opts.height] 视口高(默认 800);传 "full" 语义用整页截图
|
|
144
|
+
* @param {number} [opts.scale] 设备像素比(默认 1;2 = 两倍图)
|
|
145
|
+
* @param {boolean} [opts.fullPage] 整页截图(两趟:先量出内容高,再按该高截图)
|
|
146
|
+
* @param {string} [opts.browserPath] 浏览器可执行文件
|
|
147
|
+
* @param {number} [opts.timeoutMs] 超时(默认 45s)
|
|
148
|
+
*/
|
|
149
|
+
export function renderHtml(opts) {
|
|
150
|
+
const browser = findBrowser(opts.browserPath);
|
|
151
|
+
if (browser === null) throw missingBrowserError();
|
|
152
|
+
|
|
153
|
+
const width = Number.isFinite(opts.width) ? Math.max(1, Math.round(opts.width)) : 1280;
|
|
154
|
+
const height = Number.isFinite(opts.height) ? Math.max(1, Math.round(opts.height)) : 800;
|
|
155
|
+
const scale = Number.isFinite(opts.scale) ? Math.min(4, Math.max(0.5, opts.scale)) : 1;
|
|
156
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 45_000;
|
|
157
|
+
|
|
158
|
+
// 内联 HTML 先落到临时文件(浏览器只接受 URL/路径)
|
|
159
|
+
let pageUrl;
|
|
160
|
+
let tempDir = null;
|
|
161
|
+
const raw = String(opts.html ?? "");
|
|
162
|
+
const looksLikePath = /[\\/]|\.html?$/i.test(raw) && !/[<>]/.test(raw);
|
|
163
|
+
if (looksLikePath && existsSync(resolve(raw))) {
|
|
164
|
+
pageUrl = pathToFileURL(resolve(raw)).href;
|
|
165
|
+
} else {
|
|
166
|
+
tempDir = mkdtempSync(join(tmpdir(), "dsh-html-"));
|
|
167
|
+
const file = join(tempDir, "page.html");
|
|
168
|
+
writeFileSync(file, raw, "utf8");
|
|
169
|
+
pageUrl = pathToFileURL(file).href;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 输出路径必须**绝对化**:浏览器以临时目录为 cwd 启动,相对路径会落到别处
|
|
173
|
+
// (实测报“Failed to write file ... 系统找不到指定的路径”)。
|
|
174
|
+
const outAbs = resolve(opts.out);
|
|
175
|
+
const profileDir = mkdtempSync(join(tmpdir(), "dsh-chrome-"));
|
|
176
|
+
|
|
177
|
+
// 整页截图必须**两趟**。
|
|
178
|
+
//
|
|
179
|
+
// 曾经直接写成 `--screenshot`(不带 --window-size)就当成整页——实际上浏览器会按
|
|
180
|
+
// 自己的默认窗口(实测 750×485)截,既不是整页也不是调用方传的宽高,
|
|
181
|
+
// 但返回值里又把 width/height 原样回填,于是“报 1100×1500、实际 750×485”,
|
|
182
|
+
// 看上去成功、图却是残的(靠人眼看图才发现)。
|
|
183
|
+
//
|
|
184
|
+
// 正确做法:先用 `--dump-dom` 让页面自己把 scrollHeight 写进 title,读出真实内容高,
|
|
185
|
+
// 再用 `--window-size=W,内容高` 截第二趟。测高副本里插 `<base>`,保证相对路径的
|
|
186
|
+
// CSS/图片仍按原目录解析(否则测高页面是裸文本,高度全错)。
|
|
187
|
+
let shotHeight = height;
|
|
188
|
+
let measured = false;
|
|
189
|
+
if (opts.fullPage === true) {
|
|
190
|
+
shotHeight = measureHeight({
|
|
191
|
+
browser, pageUrl, width, timeoutMs,
|
|
192
|
+
html: looksLikePath && existsSync(resolve(raw)) ? readFileSync(resolve(raw), "utf8") : raw,
|
|
193
|
+
baseUrl: new URL(".", pageUrl).href
|
|
194
|
+
});
|
|
195
|
+
measured = true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const args = [
|
|
199
|
+
"--headless=new",
|
|
200
|
+
"--disable-gpu",
|
|
201
|
+
"--hide-scrollbars",
|
|
202
|
+
"--no-first-run",
|
|
203
|
+
"--no-default-browser-check",
|
|
204
|
+
"--disable-extensions",
|
|
205
|
+
// 关掉 LCD 子像素渲染:否则文字边缘会出现橙/蓝彩色条纹,
|
|
206
|
+
// 按像素量对比度时会把它们当成“低对比文字”(实测报出 1.97:1 的假结果)。
|
|
207
|
+
// 灰度抗锯齿下边缘像素与文字同色,量出来的才是真正的对比度。
|
|
208
|
+
"--disable-lcd-text",
|
|
209
|
+
`--force-device-scale-factor=${scale}`,
|
|
210
|
+
`--window-size=${width},${shotHeight}`,
|
|
211
|
+
"--virtual-time-budget=3000",
|
|
212
|
+
`--screenshot=${outAbs}`,
|
|
213
|
+
`--user-data-dir=${profileDir}`,
|
|
214
|
+
pageUrl
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const run = spawnSync(browser.path, args, {
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
timeout: timeoutMs,
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
cwd: tempDir ?? undefined
|
|
223
|
+
});
|
|
224
|
+
// Chromium 系即使成功也常往 stderr 写噪音(如 QQBrowser 导入器),不能用 stderr 判成败
|
|
225
|
+
if (!existsSync(outAbs)) {
|
|
226
|
+
const stderr = String(run.stderr ?? "").trim().split("\n").slice(-4).join("\n");
|
|
227
|
+
throw new Error(
|
|
228
|
+
`HTML 渲染未产出文件(浏览器退出码 ${run.status ?? "null"})。\n` +
|
|
229
|
+
`可能的下一步:1) 确认路径可写;2) 加大 timeoutMs;3) 手动跑一次:\n` +
|
|
230
|
+
` "${browser.path}" ${args.join(" ")}\n` +
|
|
231
|
+
(stderr === "" ? "" : `浏览器输出:\n${stderr}`)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
// 回报**实测**尺寸而不是请求尺寸:读 PNG 的 IHDR,报假数字比报错更坏
|
|
235
|
+
const actual = pngSize(outAbs);
|
|
236
|
+
return {
|
|
237
|
+
path: outAbs,
|
|
238
|
+
bytes: statSync(outAbs).size,
|
|
239
|
+
width: actual.width === 0 ? width : Math.round(actual.width / scale),
|
|
240
|
+
height: actual.height === 0 ? shotHeight : Math.round(actual.height / scale),
|
|
241
|
+
pixelWidth: actual.width,
|
|
242
|
+
pixelHeight: actual.height,
|
|
243
|
+
scale,
|
|
244
|
+
fullPage: opts.fullPage === true,
|
|
245
|
+
measured,
|
|
246
|
+
browser: browser.path,
|
|
247
|
+
browserSource: browser.source
|
|
248
|
+
};
|
|
249
|
+
} finally {
|
|
250
|
+
for (const dir of [tempDir, profileDir]) {
|
|
251
|
+
if (dir !== null) rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|