kasaframe 0.1.4 → 0.1.5
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/NOTICE +298 -0
- package/README.md +16 -3
- package/docs/AGENTS.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/commands/doctor.ts +15 -2
- package/packages/cli/src/commands/fonts.ts +118 -0
- package/packages/cli/src/commands/update.ts +66 -0
- package/packages/cli/src/fonts.ts +405 -0
- package/packages/cli/src/kf.ts +5 -0
- package/packages/cli/src/update.ts +250 -0
- package/packages/plan/src/asks.ts +1 -1
- package/packages/plan/src/themes.ts +8 -2
- package/packages/themes/kit/THEME-MAKE.md +2 -2
- package/packages/themes/kit/fonts/display.json +3517 -0
- package/packages/themes/kit/fonts/sans.json +5155 -0
- package/packages/themes/kit/fonts/serif.json +5229 -0
- package/packages/themes/kit/fonts.json +243 -79
- package/packages/themes/package.json +3 -1
- package/packages/themes/src/fontfile.ts +183 -0
- package/packages/themes/src/fonts.ts +296 -0
- package/packages/themes/src/index.ts +10 -2
- package/packages/themes/src/look.ts +34 -8
- package/packages/themes/src/types.ts +5 -0
- package/packages/themes/src/vary.ts +35 -34
- package/presets/kawaii-pastel.json +2 -2
- package/skills/kasaframe/SKILL.md +1 -0
- package/themes/blueprint/sample/style.json +5 -5
- package/themes/blueprint/style.json +5 -5
- package/themes/blueprint/theme.json +3 -1
- package/themes/blueprint/variants/deep-rose-suit.json +1 -1
- package/themes/blueprint/variants/navy-coral-2.json +1 -1
- package/themes/blueprint/variants/navy-coral-suit.json +1 -1
- package/themes/blueprint/variants/navy-orange-2.json +1 -1
- package/themes/blueprint/variants/navy-orange-suit.json +1 -1
- package/themes/cartography/sample/style.json +2 -2
- package/themes/cartography/style.json +2 -2
- package/themes/cartography/theme.json +3 -1
- package/themes/cartography/variants/deep-plum.json +1 -1
- package/themes/cartography/variants/frost-teal.json +1 -1
- package/themes/cartography/variants/navy-green.json +1 -1
- package/themes/circuit-board/sample/style.json +2 -2
- package/themes/circuit-board/style.json +2 -2
- package/themes/circuit-board/theme.json +3 -1
- package/themes/circuit-board/variants/dusk-gold-pretendard.json +1 -1
- package/themes/circuit-board/variants/navy-gold-grain.json +1 -1
- package/themes/circuit-board/variants/wine-gold-pretendard.json +1 -1
- package/themes/clay-toy/sample/style.json +2 -2
- package/themes/clay-toy/style.json +2 -2
- package/themes/clay-toy/theme.json +3 -1
- package/themes/clay-toy/variants/blush-coral-grain.json +1 -1
- package/themes/clay-toy/variants/chalk-teal.json +1 -1
- package/themes/clay-toy/variants/frost-sky.json +1 -1
- package/themes/clay-toy/variants/frost-teal.json +1 -1
- package/themes/clay-toy/variants/lilac-plum-round.json +1 -1
- package/themes/clay-toy/variants/lilac-plum.json +1 -1
- package/themes/clay-toy/variants/sage-indigo.json +1 -1
- package/themes/conveyor-factory/sample/style.json +3 -3
- package/themes/conveyor-factory/style.json +3 -3
- package/themes/conveyor-factory/theme.json +3 -1
- package/themes/conveyor-factory/variants/cocoa-gold.json +1 -1
- package/themes/conveyor-factory/variants/navy-gold.json +1 -1
- package/themes/conveyor-factory/variants/slate-amber-2.json +1 -1
- package/themes/conveyor-factory/variants/slate-amber-suit.json +1 -1
- package/themes/conveyor-factory/variants/slate-gold-bold.json +1 -1
- package/themes/data-journal/theme.json +3 -1
- package/themes/data-journal/variants/deep-coral.json +1 -1
- package/themes/data-journal/variants/navy-lime.json +1 -1
- package/themes/data-journal/variants/wine-indigo.json +1 -1
- package/themes/debug-hud/sample/style.json +1 -1
- package/themes/debug-hud/style.json +1 -1
- package/themes/debug-hud/theme.json +2 -1
- package/themes/debug-hud/variants/cocoa-teal.json +2 -2
- package/themes/debug-hud/variants/deep-teal-scan.json +2 -2
- package/themes/debug-hud/variants/dusk-plum.json +2 -2
- package/themes/debug-hud/variants/forest-lime.json +2 -2
- package/themes/debug-hud/variants/slate-green.json +2 -2
- package/themes/debug-hud/variants/wine-gold.json +2 -2
- package/themes/depth-flight/sample/style.json +1 -1
- package/themes/depth-flight/style.json +1 -1
- package/themes/depth-flight/theme.json +3 -1
- package/themes/depth-flight/variants/cocoa-cyan.json +1 -1
- package/themes/depth-flight/variants/cocoa-violet.json +1 -1
- package/themes/depth-flight/variants/deep-sky.json +1 -1
- package/themes/depth-flight/variants/forest-blue.json +1 -1
- package/themes/depth-flight/variants/navy-sky-grain.json +1 -1
- package/themes/depth-flight/variants/navy-sky.json +1 -1
- package/themes/depth-flight/variants/night-teal.json +1 -1
- package/themes/depth-flight/variants/slate-indigo.json +1 -1
- package/themes/depth-flight/variants/slate-violet.json +1 -1
- package/themes/film-reel/sample/style.json +2 -2
- package/themes/film-reel/style.json +2 -2
- package/themes/film-reel/theme.json +3 -1
- package/themes/film-reel/variants/chalk-pink.json +1 -1
- package/themes/film-reel/variants/lilac-gold.json +1 -1
- package/themes/film-reel/variants/lilac-violet.json +1 -1
- package/themes/glass-light/sample/style.json +1 -1
- package/themes/glass-light/style.json +1 -1
- package/themes/glass-light/theme.json +3 -1
- package/themes/glass-light/variants/blush-pink.json +1 -1
- package/themes/glass-light/variants/blush-plum.json +1 -1
- package/themes/glass-light/variants/cream-violet.json +1 -1
- package/themes/glass-light/variants/frost-sky-suit.json +1 -1
- package/themes/glass-light/variants/frost-sky.json +1 -1
- package/themes/glass-light/variants/sage-green.json +1 -1
- package/themes/ink-wash/sample/style.json +3 -3
- package/themes/ink-wash/style.json +3 -3
- package/themes/ink-wash/theme.json +3 -1
- package/themes/iso-diorama/sample/style.json +1 -1
- package/themes/iso-diorama/style.json +1 -1
- package/themes/iso-diorama/theme.json +3 -1
- package/themes/iso-diorama/variants/chalk-plum.json +1 -1
- package/themes/iso-diorama/variants/cocoa-indigo.json +1 -1
- package/themes/iso-diorama/variants/dusk-blue.json +1 -1
- package/themes/iso-diorama/variants/dusk-coral.json +1 -1
- package/themes/iso-diorama/variants/dusk-jade.json +1 -1
- package/themes/iso-diorama/variants/forest-cyan.json +1 -1
- package/themes/iso-diorama/variants/frost-green.json +1 -1
- package/themes/iso-diorama/variants/frost-teal.json +1 -1
- package/themes/iso-diorama/variants/navy-amber.json +1 -1
- package/themes/isotype/sample/style.json +2 -2
- package/themes/isotype/style.json +2 -2
- package/themes/isotype/theme.json +2 -1
- package/themes/isotype/variants/blush-rose.json +1 -1
- package/themes/isotype/variants/chalk-coral.json +1 -1
- package/themes/isotype/variants/chalk-orange.json +1 -1
- package/themes/isotype/variants/frost-amber.json +1 -1
- package/themes/isotype/variants/frost-coral.json +1 -1
- package/themes/isotype/variants/frost-orange.json +1 -1
- package/themes/isotype/variants/sand-amber.json +1 -1
- package/themes/kawaii-sticker/sample/style.json +2 -2
- package/themes/kawaii-sticker/style.json +2 -2
- package/themes/kawaii-sticker/theme.json +3 -1
- package/themes/kawaii-sticker/variants/cream-gold.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-green.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-indigo.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-sky.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-teal.json +1 -1
- package/themes/kawaii-sticker/variants/frost-blue.json +1 -1
- package/themes/kawaii-sticker/variants/frost-plum.json +1 -1
- package/themes/kawaii-sticker/variants/lilac-violet.json +1 -1
- package/themes/kawaii-sticker/variants/navy-gold.json +1 -1
- package/themes/kawaii-sticker/variants/navy-plum.json +1 -1
- package/themes/lab-notebook/sample/style.json +3 -3
- package/themes/lab-notebook/style.json +3 -3
- package/themes/lab-notebook/theme.json +2 -1
- package/themes/lab-notebook/variants/blush-orange.json +1 -1
- package/themes/lab-notebook/variants/chalk-coral.json +1 -1
- package/themes/lab-notebook/variants/chalk-orange.json +1 -1
- package/themes/lab-notebook/variants/chalk-rose.json +1 -1
- package/themes/lab-notebook/variants/lilac-rose.json +1 -1
- package/themes/lab-notebook/variants/sage-rose.json +1 -1
- package/themes/lab-notebook/variants/sand-orange.json +1 -1
- package/themes/lab-notebook/variants/snow-orange.json +1 -1
- package/themes/lab-notebook/variants/snow-rose.json +1 -1
- package/themes/manga-panel/sample/style.json +3 -3
- package/themes/manga-panel/style.json +3 -3
- package/themes/manga-panel/theme.json +3 -1
- package/themes/manga-panel/variants/chalk-cyan-dots.json +1 -1
- package/themes/manga-panel/variants/chalk-rose.json +1 -1
- package/themes/manga-panel/variants/snow-cyan.json +1 -1
- package/themes/manga-panel/variants/snow-magenta-dots.json +1 -1
- package/themes/marble-run/sample/style.json +2 -2
- package/themes/marble-run/style.json +2 -2
- package/themes/marble-run/theme.json +3 -1
- package/themes/marble-run/variants/slate-orange.json +1 -1
- package/themes/neon-sign/sample/style.json +2 -2
- package/themes/neon-sign/style.json +2 -2
- package/themes/neon-sign/theme.json +2 -1
- package/themes/neon-sign/variants/dusk-plum.json +1 -1
- package/themes/neon-sign/variants/forest-magenta.json +1 -1
- package/themes/neon-sign/variants/night-pink.json +1 -1
- package/themes/neon-sign/variants/night-violet.json +1 -1
- package/themes/neon-sign/variants/slate-violet.json +1 -1
- package/themes/newspaper/sample/style.json +2 -2
- package/themes/newspaper/style.json +2 -2
- package/themes/newspaper/theme.json +2 -1
- package/themes/newspaper/variants/blush-amber.json +1 -1
- package/themes/newspaper/variants/chalk-magenta.json +1 -1
- package/themes/newspaper/variants/frost-sky.json +1 -1
- package/themes/newspaper/variants/lilac-green.json +1 -1
- package/themes/newspaper/variants/sage-lime.json +1 -1
- package/themes/og-measure/THEME.md +32 -6
- package/themes/og-measure/effects.ts +219 -115
- package/themes/og-measure/sample/assets/brand/og-logo.svg +11 -11
- package/themes/og-measure/sample/lib/data.ts +6 -0
- package/themes/og-measure/sample/scenes/end.ts +14 -10
- package/themes/og-measure/sample/scenes/gather.ts +12 -1
- package/themes/og-measure/sample/scenes/models.ts +13 -2
- package/themes/og-measure/sample/scenes/one.ts +8 -6
- package/themes/og-measure/sample/scenes/price.ts +20 -3
- package/themes/og-measure/sample/style.json +30 -35
- package/themes/og-measure/slots.ts +25 -30
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/style.json +30 -35
- package/themes/og-measure/theme.json +64 -49
- package/themes/og-measure/variants/snow-blue.json +90 -0
- package/themes/orbit-system/sample/style.json +3 -3
- package/themes/orbit-system/style.json +3 -3
- package/themes/orbit-system/theme.json +3 -1
- package/themes/orbit-system/variants/cocoa-amber.json +1 -1
- package/themes/orbit-system/variants/cocoa-cyan.json +1 -1
- package/themes/orbit-system/variants/dusk-magenta.json +1 -1
- package/themes/orbit-system/variants/dusk-violet.json +1 -1
- package/themes/orbit-system/variants/forest-coral.json +1 -1
- package/themes/orbit-system/variants/wine-amber.json +1 -1
- package/themes/orbit-system/variants/wine-magenta.json +1 -1
- package/themes/origami-fold/sample/style.json +1 -1
- package/themes/origami-fold/style.json +1 -1
- package/themes/origami-fold/theme.json +3 -1
- package/themes/origami-fold/variants/deep-amber-sunflower.json +1 -1
- package/themes/origami-fold/variants/deep-orange.json +1 -1
- package/themes/origami-fold/variants/forest-coral.json +1 -1
- package/themes/origami-fold/variants/forest-pink.json +1 -1
- package/themes/origami-fold/variants/frost-amber-paper.json +1 -1
- package/themes/origami-fold/variants/sage-coral-sunflower.json +1 -1
- package/themes/origami-fold/variants/sage-coral.json +1 -1
- package/themes/origami-fold/variants/sage-pink-round.json +1 -1
- package/themes/origami-fold/variants/sage-pink.json +1 -1
- package/themes/paper-cutout/sample/style.json +2 -2
- package/themes/paper-cutout/style.json +2 -2
- package/themes/paper-cutout/theme.json +3 -1
- package/themes/paper-cutout/variants/blush-violet.json +1 -1
- package/themes/paper-cutout/variants/frost-lime.json +1 -1
- package/themes/paper-cutout/variants/lilac-gold.json +1 -1
- package/themes/paper-cutout/variants/sage-cyan.json +1 -1
- package/themes/paper-cutout/variants/sage-green.json +1 -1
- package/themes/paper-cutout/variants/sage-pink.json +1 -1
- package/themes/paper-cutout/variants/sand-blue.json +1 -1
- package/themes/pixel-quest/theme.json +2 -1
- package/themes/receipt/sample/style.json +2 -2
- package/themes/receipt/style.json +2 -2
- package/themes/riso-zine/sample/style.json +2 -2
- package/themes/riso-zine/style.json +2 -2
- package/themes/riso-zine/theme.json +3 -1
- package/themes/riso-zine/variants/chalk-lime.json +1 -1
- package/themes/riso-zine/variants/frost-green.json +1 -1
- package/themes/riso-zine/variants/lilac-plum.json +1 -1
- package/themes/riso-zine/variants/lilac-violet.json +1 -1
- package/themes/server-hall/sample/style.json +2 -2
- package/themes/server-hall/style.json +2 -2
- package/themes/server-hall/theme.json +3 -1
- package/themes/server-hall/variants/night-indigo.json +1 -1
- package/themes/server-hall/variants/wine-plum.json +1 -1
- package/themes/subway-map/sample/style.json +2 -2
- package/themes/subway-map/style.json +2 -2
- package/themes/subway-map/theme.json +3 -1
- package/themes/subway-map/variants/deep-green.json +1 -1
- package/themes/subway-map/variants/deep-teal.json +1 -1
- package/themes/subway-map/variants/frost-jade.json +1 -1
- package/themes/subway-map/variants/frost-teal.json +1 -1
- package/themes/subway-map/variants/slate-green.json +1 -1
- package/themes/subway-map/variants/snow-jade.json +1 -1
- package/themes/swiss-poster/sample/style.json +2 -2
- package/themes/swiss-poster/style.json +2 -2
- package/themes/swiss-poster/theme.json +3 -1
- package/themes/swiss-poster/variants/chalk-plum.json +1 -1
- package/themes/swiss-poster/variants/cocoa-cyan.json +1 -1
- package/themes/swiss-poster/variants/dusk-magenta.json +1 -1
- package/themes/swiss-poster/variants/frost-indigo.json +1 -1
- package/themes/swiss-poster/variants/slate-orange.json +1 -1
- package/themes/water-works/sample/style.json +1 -1
- package/themes/water-works/style.json +1 -1
- package/themes/water-works/theme.json +3 -1
- package/themes/water-works/variants/chalk-cyan.json +1 -1
- package/themes/water-works/variants/frost-indigo.json +1 -1
- package/themes/water-works/variants/sand-amber.json +1 -1
- package/themes/water-works/variants/sand-plum.json +1 -1
- package/themes/whiteboard/sample/style.json +2 -2
- package/themes/whiteboard/style.json +2 -2
- package/themes/whiteboard/theme.json +3 -1
- package/themes/whiteboard/variants/chalk-coral.json +1 -1
- package/themes/whiteboard/variants/cream-lime.json +1 -1
- package/themes/whiteboard/variants/frost-teal.json +1 -1
- package/themes/whiteboard/variants/lilac-indigo.json +1 -1
- package/themes/whiteboard/variants/sage-plum.json +1 -1
- package/themes/whiteboard/variants/snow-cyan.json +1 -1
- package/themes/whiteboard/variants/snow-green.json +1 -1
- package/themes/whiteboard/variants/snow-magenta.json +1 -1
- package/themes/og-measure/sample/assets/brand/og-logo-full.svg +0 -12
- package/themes/og-measure/variants/chalk-orange.json +0 -102
- package/themes/og-measure/variants/cocoa-amber.json +0 -138
- package/themes/og-measure/variants/cocoa-cyan.json +0 -138
- package/themes/og-measure/variants/cocoa-gold.json +0 -129
- package/themes/og-measure/variants/cream-lime.json +0 -137
- package/themes/og-measure/variants/deep-jade.json +0 -100
- package/themes/og-measure/variants/dusk-violet.json +0 -133
- package/themes/og-measure/variants/forest-cyan.json +0 -123
- package/themes/og-measure/variants/frost-amber.json +0 -129
- package/themes/og-measure/variants/frost-cyan.json +0 -133
- package/themes/og-measure/variants/lilac-plum.json +0 -127
- package/themes/og-measure/variants/lilac-violet.json +0 -139
- package/themes/og-measure/variants/sage-green.json +0 -137
- package/themes/og-measure/variants/wine-jade.json +0 -127
- package/themes/og-measure/variants/wine-plum.json +0 -127
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"./vary": "./src/vary.ts",
|
|
15
15
|
"./color": "./src/color.ts",
|
|
16
16
|
"./palette": "./src/palette.ts",
|
|
17
|
-
"./look": "./src/look.ts"
|
|
17
|
+
"./look": "./src/look.ts",
|
|
18
|
+
"./fonts": "./src/fonts.ts",
|
|
19
|
+
"./fontfile": "./src/fontfile.ts"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@kasaframe/core": "0.0.0",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// 글꼴 파일에서 짝 검사에 드는 표(cmap·OS/2·fvar·name)만 읽는다 — TTF/OTF·WOFF·WOFF2.
|
|
2
|
+
// fontTools 대신 직접 읽는 까닭: 드는 것이 글자 목록·굵기·이름뿐이고, WOFF2 도 cmap 은 변환 없이 brotli 한 줄기에 있어
|
|
3
|
+
// node zlib 만으로 풀린다. 파이썬 venv 를 깔 일이 없어 kf fonts check 가 vitest 안에서도, 처음 쓰는 기계에서도 바로 돈다.
|
|
4
|
+
import { brotliDecompressSync, inflateSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
export interface FontFileInfo {
|
|
7
|
+
format: "ttf" | "otf" | "woff" | "woff2";
|
|
8
|
+
/** cmap 이 글리프로 잇는 코드 포인트(글리프 0 은 뺀다) */
|
|
9
|
+
cps: Set<number>;
|
|
10
|
+
/** OS/2 usWeightClass */
|
|
11
|
+
weight: number;
|
|
12
|
+
/** 가변 글꼴의 wght 축 [최소, 최대] */
|
|
13
|
+
wght?: [number, number];
|
|
14
|
+
/** name 표 family(16·1) — 크롬이 찍은 글꼴 이름을 이것에 댄다 */
|
|
15
|
+
families: string[];
|
|
16
|
+
postScript: string;
|
|
17
|
+
/** name 13(라이선스 설명) — 배포처 페이지가 JS 로 그려져 글을 못 받을 때 파일 자체가 밝힌 라이선스 */
|
|
18
|
+
licenseText: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tag = (b: Buffer, o: number) => b.toString("latin1", o, o + 4);
|
|
22
|
+
|
|
23
|
+
// WOFF2 표 머리의 알려진 태그 63개(차례가 곧 번호)
|
|
24
|
+
const WOFF2_TAGS = "cmap head hhea hmtx maxp name OS/2 post cvt fpgm glyf loca prep CFF VORG EBDT EBLC gasp hdmx kern LTSH PCLT VDMX vhea vmtx BASE GDEF GPOS GSUB EBSC JSTF MATH CBDT CBLC COLR CPAL SVG sbix acnt avar bdat bloc bsln cvar fdsc feat fmtx fvar gvar hsty just lcar mort morx opbd prop trak Zapf Silf Glat Gloc Feat Sill"
|
|
25
|
+
.match(/.{4}(?: |$)/g)!
|
|
26
|
+
.map((s) => s.slice(0, 4));
|
|
27
|
+
|
|
28
|
+
function base128(b: Buffer, o: { at: number }): number {
|
|
29
|
+
let v = 0;
|
|
30
|
+
for (let i = 0; i < 5; i++) {
|
|
31
|
+
const x = b[o.at++];
|
|
32
|
+
v = v * 128 + (x & 0x7f);
|
|
33
|
+
if (!(x & 0x80)) return v;
|
|
34
|
+
}
|
|
35
|
+
throw new Error("WOFF2 UIntBase128 이 길다");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tablesOf(b: Buffer): { format: FontFileInfo["format"]; tables: Map<string, Buffer> } {
|
|
39
|
+
const sig = tag(b, 0);
|
|
40
|
+
const tables = new Map<string, Buffer>();
|
|
41
|
+
if (sig === "wOF2") {
|
|
42
|
+
if (tag(b, 4) === "ttcf") throw new Error("WOFF2 글꼴 모음(ttc)은 읽지 않는다");
|
|
43
|
+
const n = b.readUInt16BE(12);
|
|
44
|
+
const compressed = b.readUInt32BE(20);
|
|
45
|
+
const o = { at: 48 };
|
|
46
|
+
const dir: { tag: string; len: number }[] = [];
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
const flags = b[o.at++];
|
|
49
|
+
const t = (flags & 0x3f) === 63 ? tag(b, (o.at += 4) - 4) : WOFF2_TAGS[flags & 0x3f];
|
|
50
|
+
const version = flags >> 6;
|
|
51
|
+
const orig = base128(b, o);
|
|
52
|
+
const transformed = t === "glyf" || t === "loca" ? version !== 3 : version !== 0;
|
|
53
|
+
dir.push({ tag: t, len: transformed ? base128(b, o) : orig });
|
|
54
|
+
}
|
|
55
|
+
const data = brotliDecompressSync(b.subarray(o.at, o.at + compressed));
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const d of dir) {
|
|
58
|
+
tables.set(d.tag, data.subarray(at, at + d.len));
|
|
59
|
+
at += d.len;
|
|
60
|
+
}
|
|
61
|
+
return { format: "woff2", tables };
|
|
62
|
+
}
|
|
63
|
+
if (sig === "wOFF") {
|
|
64
|
+
const n = b.readUInt16BE(12);
|
|
65
|
+
for (let i = 0; i < n; i++) {
|
|
66
|
+
const r = 44 + i * 20;
|
|
67
|
+
const [off, comp, orig] = [b.readUInt32BE(r + 4), b.readUInt32BE(r + 8), b.readUInt32BE(r + 12)];
|
|
68
|
+
const raw = b.subarray(off, off + comp);
|
|
69
|
+
tables.set(tag(b, r), comp < orig ? inflateSync(raw) : raw);
|
|
70
|
+
}
|
|
71
|
+
return { format: "woff", tables };
|
|
72
|
+
}
|
|
73
|
+
const v = b.readUInt32BE(0);
|
|
74
|
+
if (v !== 0x00010000 && sig !== "OTTO" && sig !== "true") throw new Error(`글꼴 파일이 아니다(머리 ${JSON.stringify(sig)})`);
|
|
75
|
+
const n = b.readUInt16BE(4);
|
|
76
|
+
for (let i = 0; i < n; i++) {
|
|
77
|
+
const r = 12 + i * 16;
|
|
78
|
+
tables.set(tag(b, r), b.subarray(b.readUInt32BE(r + 8), b.readUInt32BE(r + 8) + b.readUInt32BE(r + 12)));
|
|
79
|
+
}
|
|
80
|
+
return { format: sig === "OTTO" ? "otf" : "ttf", tables };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cmapOf(t: Buffer): Set<number> {
|
|
84
|
+
const out = new Set<number>();
|
|
85
|
+
const n = t.readUInt16BE(2);
|
|
86
|
+
const seen = new Set<number>();
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
const [platform, encoding, off] = [t.readUInt16BE(4 + i * 8), t.readUInt16BE(6 + i * 8), t.readUInt32BE(8 + i * 8)];
|
|
89
|
+
if (!(platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10))) || seen.has(off)) continue;
|
|
90
|
+
seen.add(off);
|
|
91
|
+
const s = t.subarray(off);
|
|
92
|
+
const format = s.readUInt16BE(0);
|
|
93
|
+
if (format === 4) {
|
|
94
|
+
const seg = s.readUInt16BE(6) / 2;
|
|
95
|
+
const ends = 14, starts = 16 + seg * 2, deltas = starts + seg * 2, ranges = deltas + seg * 2;
|
|
96
|
+
for (let k = 0; k < seg; k++) {
|
|
97
|
+
const [end, start, delta, range] = [s.readUInt16BE(ends + k * 2), s.readUInt16BE(starts + k * 2), s.readInt16BE(deltas + k * 2), s.readUInt16BE(ranges + k * 2)];
|
|
98
|
+
for (let c = start; c <= end && c !== 0xffff; c++) {
|
|
99
|
+
let g: number;
|
|
100
|
+
if (range === 0) g = (c + delta) & 0xffff;
|
|
101
|
+
else {
|
|
102
|
+
const at = ranges + k * 2 + range + (c - start) * 2;
|
|
103
|
+
g = at + 2 <= s.length ? s.readUInt16BE(at) : 0;
|
|
104
|
+
if (g) g = (g + delta) & 0xffff;
|
|
105
|
+
}
|
|
106
|
+
if (g) out.add(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} else if (format === 12 || format === 13) {
|
|
110
|
+
const groups = s.readUInt32BE(12);
|
|
111
|
+
for (let k = 0; k < groups; k++) {
|
|
112
|
+
const r = 16 + k * 12;
|
|
113
|
+
const [start, end, g0] = [s.readUInt32BE(r), s.readUInt32BE(r + 4), s.readUInt32BE(r + 8)];
|
|
114
|
+
for (let c = start; c <= end; c++) if (format === 13 ? g0 : g0 + (c - start)) out.add(c);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** name 표에서 이 번호들의 글 — 플랫폼·언어별로 다른 것까지 모두, 겹치면 하나 */
|
|
122
|
+
function namesOf(t: Buffer | undefined, ids: number[]): string[] {
|
|
123
|
+
if (!t) return [];
|
|
124
|
+
const count = t.readUInt16BE(2);
|
|
125
|
+
const strings = t.readUInt16BE(4);
|
|
126
|
+
const out = new Set<string>();
|
|
127
|
+
for (const id of ids)
|
|
128
|
+
for (let i = 0; i < count; i++) {
|
|
129
|
+
const r = 6 + i * 12;
|
|
130
|
+
const [platform, , , nameId, len, off] = [0, 2, 4, 6, 8, 10].map((k) => t.readUInt16BE(r + k));
|
|
131
|
+
if (nameId !== id) continue;
|
|
132
|
+
const raw = t.subarray(strings + off, strings + off + len);
|
|
133
|
+
const s = platform === 1 ? raw.toString("latin1") : Buffer.from(raw).swap16().toString("utf16le");
|
|
134
|
+
if (s) out.add(s);
|
|
135
|
+
}
|
|
136
|
+
return [...out];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readFontFile(b: Buffer): FontFileInfo {
|
|
140
|
+
const { format, tables } = tablesOf(b);
|
|
141
|
+
const cmap = tables.get("cmap");
|
|
142
|
+
if (!cmap) throw new Error("cmap 표가 없다");
|
|
143
|
+
const os2 = tables.get("OS/2");
|
|
144
|
+
const fvar = tables.get("fvar");
|
|
145
|
+
let wght: [number, number] | undefined;
|
|
146
|
+
if (fvar) {
|
|
147
|
+
const [axes, count, size] = [fvar.readUInt16BE(4), fvar.readUInt16BE(8), fvar.readUInt16BE(10)];
|
|
148
|
+
for (let i = 0; i < count; i++) {
|
|
149
|
+
const r = axes + i * size;
|
|
150
|
+
if (tag(fvar, r) === "wght") wght = [Math.round(fvar.readInt32BE(r + 4) / 65536), Math.round(fvar.readInt32BE(r + 12) / 65536)];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
format,
|
|
155
|
+
cps: cmapOf(cmap),
|
|
156
|
+
weight: os2 ? os2.readUInt16BE(4) : 400,
|
|
157
|
+
...(wght ? { wght } : {}),
|
|
158
|
+
families: namesOf(tables.get("name"), [16, 1]),
|
|
159
|
+
postScript: namesOf(tables.get("name"), [6])[0] ?? "",
|
|
160
|
+
licenseText: namesOf(tables.get("name"), [13]).join("\n"),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const HANGUL = [0xac00, 0xd7a3] as const;
|
|
165
|
+
let ks: number[] | null = null;
|
|
166
|
+
/** KS X 1001 완성형 한글 2,350자 — EUC-KR 의 B0A1–C8FE */
|
|
167
|
+
export function ksHangul(): number[] {
|
|
168
|
+
if (ks) return ks;
|
|
169
|
+
const bytes: number[] = [];
|
|
170
|
+
for (let hi = 0xb0; hi <= 0xc8; hi++) for (let lo = 0xa1; lo <= 0xfe; lo++) bytes.push(hi, lo);
|
|
171
|
+
return (ks = [...new TextDecoder("euc-kr").decode(Uint8Array.from(bytes))].map((c) => c.codePointAt(0)!));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type HangulCover = "full" | "ks2350" | "partial" | "none";
|
|
175
|
+
|
|
176
|
+
/** 한글 음절 덮음 — full 은 11,172자 전부, ks2350 은 KS X 1001 2,350자를 다 가진 것 */
|
|
177
|
+
export function hangulCover(cps: Set<number>): { cover: HangulCover; syllables: number; ksMissing: number } {
|
|
178
|
+
let syllables = 0;
|
|
179
|
+
for (let c = HANGUL[0]; c <= HANGUL[1]; c++) if (cps.has(c)) syllables++;
|
|
180
|
+
const ksMissing = ksHangul().filter((c) => !cps.has(c)).length;
|
|
181
|
+
const cover = syllables === HANGUL[1] - HANGUL[0] + 1 ? "full" : syllables && !ksMissing ? "ks2350" : syllables ? "partial" : "none";
|
|
182
|
+
return { cover, syllables, ksMissing };
|
|
183
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// 글꼴 짝 — kit/fonts.json 과 kit/fonts/<무리>.json 을 합친 목록, 그리고 짝을 넣기 전에 거르는 판정(SPEC 9 변주의 글꼴 축).
|
|
2
|
+
// 판정은 순수 함수로 두고, 주소를 받고 크롬으로 찍어 보는 일은 kf fonts check(packages/cli/src/fonts.ts)가 한다.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { FontFace } from "@kasaframe/core";
|
|
7
|
+
import { hangulCover, type HangulCover } from "./fontfile.ts";
|
|
8
|
+
|
|
9
|
+
export interface PairFace extends FontFace {
|
|
10
|
+
/** OFL-1.1 · Apache-2.0 · 그만큼 열린 자체 라이선스 이름 */
|
|
11
|
+
license: string;
|
|
12
|
+
/** 라이선스 글 주소 — 판을 박은 것이면 kf fonts check 가 글까지 대 본다 */
|
|
13
|
+
source: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FontPair {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
ko: string;
|
|
20
|
+
tags: string[];
|
|
21
|
+
/** 한글 음절 덮음 — full 은 11,172자, ks2350 은 KS X 1001 2,350자(드문 음절은 다음 글꼴로 떨어져 섞여 보일 수 있다) */
|
|
22
|
+
hangul: "full" | "ks2350";
|
|
23
|
+
sans: string;
|
|
24
|
+
display: string;
|
|
25
|
+
mono: string;
|
|
26
|
+
faces: PairFace[];
|
|
27
|
+
weightDisplay: number;
|
|
28
|
+
weightBody: number;
|
|
29
|
+
tracking: string;
|
|
30
|
+
monoAll?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FontGroup {
|
|
34
|
+
/** kit/fonts.json 은 core, kit/fonts/<무리>.json 은 <무리> */
|
|
35
|
+
group: string;
|
|
36
|
+
file: string;
|
|
37
|
+
pairs: FontPair[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const kitDir = fileURLToPath(new URL("../kit/", import.meta.url));
|
|
41
|
+
|
|
42
|
+
export function fontGroups(dir = kitDir): FontGroup[] {
|
|
43
|
+
const sub = join(dir, "fonts");
|
|
44
|
+
const files = existsSync(sub) ? readdirSync(sub).filter((f) => f.endsWith(".json")).sort() : [];
|
|
45
|
+
return [{ group: "core", file: join(dir, "fonts.json") }, ...files.map((f) => ({ group: f.slice(0, -5), file: join(sub, f) }))].map((g) => {
|
|
46
|
+
const pairs: FontPair[] = JSON.parse(readFileSync(g.file, "utf8"));
|
|
47
|
+
if (!Array.isArray(pairs)) throw new Error(`${g.file}: 글꼴 짝 배열이 아니다`);
|
|
48
|
+
return { ...g, pairs };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 무리 파일은 학생마다 따로 채워서, 합칠 때 id 가 겹치면 어느 쪽이 이기는지 숨지 않게 멈춘다 */
|
|
53
|
+
export function loadFontPairs(dir = kitDir): FontPair[] {
|
|
54
|
+
const seen = new Map<string, string>();
|
|
55
|
+
return fontGroups(dir).flatMap((g) =>
|
|
56
|
+
g.pairs.map((p) => {
|
|
57
|
+
const prev = seen.get(p.id);
|
|
58
|
+
if (prev) throw new Error(`글꼴 짝 id ${p.id} 이 겹친다 — ${prev} · ${g.file}`);
|
|
59
|
+
seen.set(p.id, g.file);
|
|
60
|
+
return p;
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let pairs: FontPair[] | null = null;
|
|
66
|
+
export const fontPairs = (): FontPair[] => (pairs ??= loadFontPairs());
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 라이선스 글에서 찾을 문구 — 이름만 적고 글이 다르면 잡는다. 목록 밖 이름은 사람이 글을 읽는다.
|
|
70
|
+
* OFL 은 머리줄 없이 본문만 넣은 글꼴(배민 name 13)이 있어 1.1 본문에만 있는 낱말도 받는다 — 1.0 은 cooperative·distributed using
|
|
71
|
+
*/
|
|
72
|
+
export const LICENSE_TEXT: Record<string, RegExp> = {
|
|
73
|
+
"OFL-1.1": /SIL OPEN FONT LICENSE[\s\S]{0,40}Version 1\.1|collaborative\s+font\s+projects[\s\S]*distributed\s+entirely\s+under\s+this\s+license/i,
|
|
74
|
+
"Apache-2.0": /Apache License[\s\S]{0,40}Version 2\.0/i,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const COMMIT = /^[0-9a-f]{7,40}$/;
|
|
78
|
+
const VERSION = /^v?\d+(\.\d+)*([-+][\w.]+)?$/;
|
|
79
|
+
const EXACT = /^\d+\.\d+\.\d+([-+][\w.]+)?$/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 라이선스를 무엇으로 확인했나 — 라이선스 글(source)이나, 판 박힌 그 파일의 name 13 에 이름에 맞는 문구가 있으면 된다.
|
|
83
|
+
* 「OFL」 석 자만 적은 배포처 문구는 받지 않는다(판도 조건도 안 보인다). 목록 밖 이름은 사람이 읽을 것으로 남긴다
|
|
84
|
+
*/
|
|
85
|
+
export function licenseVerdict(f: Pick<PairFace, "family" | "weight" | "license" | "source">, sourceText: string, fileText: string): { problem?: string; note?: string } {
|
|
86
|
+
const where = `${f.family}(${f.weight})`;
|
|
87
|
+
const want = LICENSE_TEXT[f.license];
|
|
88
|
+
if (!want) return { note: `${where} 라이선스 ${f.license} — 목록 밖이라 사람이 글을 읽고 상업·영상 사용·출처 표기 의무를 확인한다` };
|
|
89
|
+
if (want.test(sourceText) || want.test(fileText)) return {};
|
|
90
|
+
return { problem: `${where} 라이선스 글(${f.source})에도 파일 name 13 에도 ${f.license} 문구가 없다 — 판 박힌 라이선스 글 주소를 찾거나, 배포처의 자체 표기뿐이면 license 를 목록 밖 이름으로 적어 사람 확인으로` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 주소가 판을 박았나 — @main·@latest·범위는 저쪽이 바뀌면 같은 영상이 다르게 구워진다 */
|
|
94
|
+
export function pinProblem(url: string): string | null {
|
|
95
|
+
let u: URL;
|
|
96
|
+
try {
|
|
97
|
+
u = new URL(url);
|
|
98
|
+
} catch {
|
|
99
|
+
return `주소가 아니다: ${url}`;
|
|
100
|
+
}
|
|
101
|
+
if (u.protocol !== "https:") return `https 가 아니다: ${url}`;
|
|
102
|
+
if (u.hostname === "fonts.googleapis.com") return "구글 폰트 CSS 는 unicode-range 조각이라 글이 투명하게 찍힌다 — 파일 하나 주소로";
|
|
103
|
+
if (u.hostname === "cdn.jsdelivr.net") {
|
|
104
|
+
const gh = /^\/gh\/([^/]+)\/([^/@]+)(?:@([^/]+))?\//.exec(u.pathname);
|
|
105
|
+
if (gh) {
|
|
106
|
+
const ref = gh[3];
|
|
107
|
+
if (!ref) return `판이 없다 — gh/${gh[1]}/${gh[2]}@<커밋|태그>`;
|
|
108
|
+
if (gh[1] === "google" && gh[2] === "fonts") return COMMIT.test(ref) && ref.length === 40 ? null : `google/fonts 는 커밋(40자)으로 박는다 — @${ref}`;
|
|
109
|
+
return COMMIT.test(ref) || VERSION.test(ref) ? null : `브랜치처럼 보인다 — @${ref}, 커밋이나 판 태그로`;
|
|
110
|
+
}
|
|
111
|
+
const npm = /^\/npm\/((?:@[^/]+\/)?[^/@]+)(?:@([^/]+))?\//.exec(u.pathname);
|
|
112
|
+
if (npm) {
|
|
113
|
+
if (!npm[2]) return `판이 없다 — npm/${npm[1]}@<x.y.z>`;
|
|
114
|
+
return EXACT.test(npm[2]) ? null : `판 범위 @${npm[2]} — 정확한 판(x.y.z)으로`;
|
|
115
|
+
}
|
|
116
|
+
return `jsdelivr 의 gh·npm 꼴이 아니다: ${u.pathname}`;
|
|
117
|
+
}
|
|
118
|
+
if (u.hostname === "raw.githubusercontent.com") {
|
|
119
|
+
const ref = u.pathname.split("/")[3] ?? "";
|
|
120
|
+
return COMMIT.test(ref) && ref.length === 40 ? null : `raw.githubusercontent 는 커밋(40자)으로 박는다 — ${ref}`;
|
|
121
|
+
}
|
|
122
|
+
return `판이 박혔는지 모르는 배포처 ${u.hostname} — jsdelivr gh@커밋·npm@판으로`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** CSS font-family 값 → 이름 차례(따옴표 벗김) */
|
|
126
|
+
export const stackFamilies = (stack: string): string[] => stack.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
127
|
+
|
|
128
|
+
const FALLBACK = "Pretendard Variable";
|
|
129
|
+
const fileExt = (src: string) => /\.(woff2|woff|ttf|otf)$/i.exec(new URL(src).pathname)?.[1];
|
|
130
|
+
|
|
131
|
+
/** 받지 않고 보는 것 — 칸·판·라이선스 이름·스택 */
|
|
132
|
+
export function pairProblems(p: FontPair): string[] {
|
|
133
|
+
const bad: string[] = [];
|
|
134
|
+
for (const k of ["id", "name", "ko", "sans", "display", "mono", "tracking"] as const) if (!p[k]) bad.push(`${k} 가 비었다`);
|
|
135
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.id)) bad.push(`id ${p.id} 은 소문자·숫자·하이픈`);
|
|
136
|
+
if (!p.tags?.length) bad.push("tags 가 비었다");
|
|
137
|
+
if (p.hangul !== "full" && p.hangul !== "ks2350") bad.push(`hangul 은 full·ks2350 — ${p.hangul}`);
|
|
138
|
+
for (const k of ["weightDisplay", "weightBody"] as const) if (!(p[k] >= 1 && p[k] <= 1000)) bad.push(`${k} ${p[k]} 이 1–1000 밖`);
|
|
139
|
+
if (!/^(0|-?\d*\.?\d+em)$/.test(p.tracking ?? "")) bad.push(`tracking ${p.tracking} 은 em 값`);
|
|
140
|
+
if (!p.faces?.length) return [...bad, "faces 가 비었다"];
|
|
141
|
+
const families = new Set(p.faces.map((f) => f.family));
|
|
142
|
+
for (const k of ["sans", "display", "mono"] as const) {
|
|
143
|
+
const st = stackFamilies(p[k] ?? "");
|
|
144
|
+
if (!families.has(st[0])) bad.push(`${k} 첫 글꼴 ${st[0]} 의 face 가 없다 — 기계마다 다른 시스템 글꼴로 찍힌다`);
|
|
145
|
+
if (!st.includes(FALLBACK)) bad.push(`${k} 가 ${FALLBACK} 로 끝나지 않는다 — 한글이 설 바닥이 없다`);
|
|
146
|
+
}
|
|
147
|
+
if (!families.has(FALLBACK)) bad.push(`${FALLBACK} face 가 없다`);
|
|
148
|
+
const used = new Set(["sans", "display", "mono"].flatMap((k) => stackFamilies(p[k as "sans"])));
|
|
149
|
+
for (const f of p.faces) {
|
|
150
|
+
const where = `${f.family}(${f.weight ?? "?"})`;
|
|
151
|
+
if (!used.has(f.family)) bad.push(`${where} 를 어느 스택도 안 쓴다`);
|
|
152
|
+
const pin = pinProblem(f.src);
|
|
153
|
+
if (pin) bad.push(`${where} 주소 — ${pin}`);
|
|
154
|
+
else if (!fileExt(f.src)) bad.push(`${where} 주소가 .woff2·.woff·.ttf·.otf 파일이 아니다`);
|
|
155
|
+
if (!f.weight || !/^\d+( \d+)?$/.test(f.weight)) bad.push(`${where} weight 는 "700" 이나 가변 범위 "100 900"`);
|
|
156
|
+
if (!f.license) bad.push(`${where} license 가 없다`);
|
|
157
|
+
if (!f.source) bad.push(`${where} source 가 없다`);
|
|
158
|
+
else if (!/^https:\/\/\S+$/.test(f.source)) bad.push(`${where} source 가 https 주소가 아니다`);
|
|
159
|
+
}
|
|
160
|
+
return bad;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 파일에서 읽은 것 중 판정에 드는 것 */
|
|
164
|
+
export interface FaceFile {
|
|
165
|
+
cps: Set<number>;
|
|
166
|
+
weight: number;
|
|
167
|
+
wght?: [number, number];
|
|
168
|
+
/** 파일 name 표의 family(1·16) — 크롬이 찍은 글꼴을 이 이름으로 댄다 */
|
|
169
|
+
names: string[];
|
|
170
|
+
licenseText?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rangeOf = (f: FontFace): [number, number] => {
|
|
174
|
+
const [a, b = a] = (f.weight ?? "100 900").split(" ").map(Number);
|
|
175
|
+
return [a, b];
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** CSS 글꼴 고르기의 굵기 차례 — 같은 이름 face 중 w 에 쓸 하나 */
|
|
179
|
+
export function faceFor(faces: PairFace[], family: string, w: number): PairFace | undefined {
|
|
180
|
+
const c = faces.filter((f) => f.family === family);
|
|
181
|
+
const hit = c.find((f) => rangeOf(f)[0] <= w && w <= rangeOf(f)[1]);
|
|
182
|
+
if (hit || !c.length) return hit;
|
|
183
|
+
const below = c.filter((f) => rangeOf(f)[1] < w).sort((a, b) => rangeOf(b)[1] - rangeOf(a)[1]);
|
|
184
|
+
const above = c.filter((f) => rangeOf(f)[0] > w).sort((a, b) => rangeOf(a)[0] - rangeOf(b)[0]);
|
|
185
|
+
if (w >= 400 && w <= 500) {
|
|
186
|
+
const upTo500 = above.filter((f) => rangeOf(f)[0] <= 500);
|
|
187
|
+
return upTo500[0] ?? below[0] ?? above[0];
|
|
188
|
+
}
|
|
189
|
+
return w < 400 ? (below[0] ?? above[0]) : (above[0] ?? below[0]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 이 굵기로 고른 face 가 실제로 내는 굵기 */
|
|
193
|
+
const servedWeight = (f: PairFace, w: number): number => {
|
|
194
|
+
const [a, b] = rangeOf(f);
|
|
195
|
+
return Math.min(b, Math.max(a, w));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** 글자 하나를 스택에서 누가 그리나 — 짝의 face 가 아무도 없으면 null(시스템 글꼴로 떨어짐) */
|
|
199
|
+
export function faceForChar(p: FontPair, stack: string, w: number, cp: number, files: Map<string, FaceFile>): PairFace | null {
|
|
200
|
+
for (const fam of stackFamilies(stack)) {
|
|
201
|
+
const f = faceFor(p.faces, fam, w);
|
|
202
|
+
if (f && files.get(f.src)?.cps.has(cp)) return f;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 견본 — 한글 줄은 KS X 1001 안 낱자만이라 ks2350 짝도 한 글꼴로 찍혀야 한다 */
|
|
208
|
+
export const FONT_SAMPLE = { ko: "다람쥐 헌 쳇바퀴에 타고파", latin: "Sphinx of black quartz, judge my vow", num: "₩12,000 · 45% → 0123456789" } as const;
|
|
209
|
+
export type SampleLine = keyof typeof FONT_SAMPLE;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 제목에 실제로 쓸 굵기 — 제목 스택 첫 글꼴(monoAll 이면 고정폭)의 가장 굵은 실제 단을 넘지 않게.
|
|
213
|
+
* 넘겨 두면 크롬이 가짜 굵기로 그리고, 도트 글꼴은 화소가 번진다(galmuri 짝의 GalmuriMono11 은 400 하나뿐)
|
|
214
|
+
*/
|
|
215
|
+
export function titleWeight(p: FontPair, monoAll = false): number {
|
|
216
|
+
const fam = stackFamilies(monoAll ? p.mono : p.display)[0];
|
|
217
|
+
const faces = p.faces.filter((f) => f.family === fam);
|
|
218
|
+
return faces.length ? Math.min(p.weightDisplay, Math.max(...faces.map((f) => rangeOf(f)[1]))) : p.weightDisplay;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type StackName = "display" | "sans" | "mono";
|
|
222
|
+
/** 짝이 실제로 쓰는 (스택, 굵기) — monoAll 짝은 고정폭이 제목·본문을 다 맡는다 */
|
|
223
|
+
export function stackUses(p: FontPair): { stack: StackName; css: string; weight: number }[] {
|
|
224
|
+
const out = [
|
|
225
|
+
{ stack: "display" as const, css: p.display, weight: titleWeight(p) },
|
|
226
|
+
{ stack: "sans" as const, css: p.sans, weight: p.weightBody },
|
|
227
|
+
{ stack: "mono" as const, css: p.mono, weight: p.weightBody },
|
|
228
|
+
];
|
|
229
|
+
if (p.monoAll) out.push({ stack: "mono", css: p.mono, weight: titleWeight(p, true) });
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface PairMeasure {
|
|
234
|
+
/** 스택마다 한글을 그리는 face 의 덮음 중 가장 나쁜 것 */
|
|
235
|
+
hangul: HangulCover;
|
|
236
|
+
syllables: number;
|
|
237
|
+
/** 제목 한글 글꼴의 가장 굵은 단 */
|
|
238
|
+
boldest: number;
|
|
239
|
+
problems: string[];
|
|
240
|
+
notes: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const RANK: Record<HangulCover, number> = { full: 3, ks2350: 2, partial: 1, none: 0 };
|
|
244
|
+
|
|
245
|
+
/** 스택에서 한글을 맡는 face — 음절을 하나라도 가진 첫 face(「가」 하나로 보면 그 글자만 빠진 글꼴을 놓친다) */
|
|
246
|
+
function hangulFace(p: FontPair, stack: string, w: number, files: Map<string, FaceFile>): PairFace | null {
|
|
247
|
+
for (const fam of stackFamilies(stack)) {
|
|
248
|
+
const f = faceFor(p.faces, fam, w);
|
|
249
|
+
if (f && hangulCover(files.get(f.src)?.cps ?? new Set()).syllables) return f;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 받은 파일로 보는 것 — 한글 덮음·굵기·견본 글자가 시스템 글꼴로 떨어지는지 */
|
|
255
|
+
export function measurePair(p: FontPair, files: Map<string, FaceFile>): PairMeasure {
|
|
256
|
+
const problems: string[] = [];
|
|
257
|
+
const notes: string[] = [];
|
|
258
|
+
for (const f of p.faces) {
|
|
259
|
+
const file = files.get(f.src);
|
|
260
|
+
if (!file) continue;
|
|
261
|
+
const [a, b] = rangeOf(f);
|
|
262
|
+
const where = `${f.family}(${f.weight})`;
|
|
263
|
+
// 한 굵기 제목체를 제목 굵기로 적은 것은 가짜 굵기를 막으려는 뜻이라 알리지 않는다
|
|
264
|
+
const meant = a === p.weightDisplay && p.faces.filter((x) => x.family === f.family).length === 1;
|
|
265
|
+
if (a !== b) {
|
|
266
|
+
if (!file.wght) problems.push(`${where} 는 가변이 아닌데 굵기 범위를 적었다 — 파일은 고정 ${file.weight}`);
|
|
267
|
+
else if (a < file.wght[0] || b > file.wght[1]) problems.push(`${where} 범위가 파일 wght ${file.wght.join("–")} 밖`);
|
|
268
|
+
} else if (!file.wght && !meant && Math.abs(file.weight - a) >= 100) notes.push(`${where} 파일 usWeightClass 는 ${file.weight}`);
|
|
269
|
+
}
|
|
270
|
+
let worst: HangulCover = "full";
|
|
271
|
+
let syllables = 11172;
|
|
272
|
+
for (const u of stackUses(p)) {
|
|
273
|
+
for (const [line, text] of Object.entries(FONT_SAMPLE)) {
|
|
274
|
+
const sys = [...new Set([...text].filter((ch) => ch !== " " && !faceForChar(p, u.css, u.weight, ch.codePointAt(0)!, files)))];
|
|
275
|
+
if (sys.length) problems.push(`${u.stack} ${line} 줄 ${sys.join("")} 를 짝의 글꼴이 못 그려 시스템 글꼴로 떨어진다`);
|
|
276
|
+
}
|
|
277
|
+
const ko = hangulFace(p, u.css, u.weight, files);
|
|
278
|
+
const cov = ko ? hangulCover(files.get(ko.src)!.cps) : { cover: "none" as const, syllables: 0 };
|
|
279
|
+
if (RANK[cov.cover] < RANK[worst] || (cov.cover === worst && cov.syllables < syllables)) [worst, syllables] = [cov.cover, cov.syllables];
|
|
280
|
+
for (const fam of new Set([ko?.family, stackFamilies(u.css)[0]])) {
|
|
281
|
+
const f = fam && faceFor(p.faces, fam, u.weight);
|
|
282
|
+
if (!f) continue;
|
|
283
|
+
const got = servedWeight(f, u.weight);
|
|
284
|
+
if (u.weight >= 600 && got < 600) problems.push(`${u.stack} ${fam} 에 굵기 ${u.weight} 단이 없어 크롬이 가짜 굵기로 그린다(있는 단 ${got})`);
|
|
285
|
+
else if (Math.abs(got - u.weight) >= 150) notes.push(`${u.stack} ${fam} 굵기 ${u.weight} 대신 ${got}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (worst === "partial" || worst === "none") problems.push(`한글 음절 ${syllables}자 — KS X 1001 2,350자를 다 덮지 못한다`);
|
|
289
|
+
else if (worst !== p.hangul) problems.push(`hangul 을 ${p.hangul} 로 적었는데 재 보면 ${worst}`);
|
|
290
|
+
const ko = hangulFace(p, p.display, titleWeight(p), files);
|
|
291
|
+
const boldest = ko ? Math.max(...p.faces.filter((f) => f.family === ko.family).map((f) => rangeOf(f)[1])) : 0;
|
|
292
|
+
if (titleWeight(p) < p.weightDisplay) notes.push(`제목 굵기 ${p.weightDisplay} → ${titleWeight(p)}(제목 첫 글꼴의 가장 굵은 단)`);
|
|
293
|
+
if (p.monoAll && titleWeight(p, true) < p.weightDisplay) notes.push(`monoAll 세계에서는 제목 굵기 ${p.weightDisplay} → ${titleWeight(p, true)}(고정폭 face 의 가장 굵은 단)`);
|
|
294
|
+
if (ko && boldest < 600 && !p.tags.includes("display")) notes.push(`제목 굵은 단이 없다 — 한글 제목 글꼴 ${ko.family} 의 가장 굵은 단 ${boldest}`);
|
|
295
|
+
return { hangul: worst, syllables, boldest, problems, notes };
|
|
296
|
+
}
|
|
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import type { KfStyle } from "@kasaframe/core";
|
|
6
6
|
import { splitThemeId } from "./name.ts";
|
|
7
7
|
import { THEME_STATUSES, type Theme, type ThemeCard, type ThemeManifest, type ThemeStatus, type VariantManifest } from "./types.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { fontPairs } from "./fonts.ts";
|
|
9
|
+
import { allowedFonts, mergeVariant } from "./vary.ts";
|
|
9
10
|
|
|
10
11
|
export type * from "./types.ts";
|
|
11
12
|
export { THEME_STATUSES } from "./types.ts";
|
|
@@ -45,9 +46,16 @@ export function checkTheme(dir: string): string[] {
|
|
|
45
46
|
if (s.name !== m.id) bad.push(`style.json name 이 테마 id 와 다르다`);
|
|
46
47
|
if (m.vary) {
|
|
47
48
|
const known = new Set(fontPairs().map((p) => p.id));
|
|
48
|
-
for (const f of m.vary.fonts ?? [])
|
|
49
|
+
for (const f of m.vary.fonts ?? []) {
|
|
50
|
+
if (f.startsWith("tag:") ? !fontPairs().some((p) => f.slice(4).split("+").every((t) => p.tags.includes(t))) : f !== "base" && !known.has(f)) bad.push(`vary.fonts 의 ${f} 에 맞는 글꼴 짝이 kit/fonts.json·kit/fonts/ 에 없다`);
|
|
51
|
+
}
|
|
49
52
|
if (m.vary.fonts && !allowedFonts(m.vary).length) bad.push("vary.fonts 에 맞는 글꼴 짝이 하나도 없다");
|
|
50
53
|
for (const k of [...(m.vary.keep ?? []), ...(m.vary.fixedHue ?? [])]) if (s.colors && !(k in s.colors)) bad.push(`vary 의 색 열쇠 ${k} 가 style.json colors 에 없다`);
|
|
54
|
+
for (const [scheme, pal] of Object.entries(m.vary.palettes ?? {})) {
|
|
55
|
+
const want = Object.keys(s.colors ?? {}).sort().join(",");
|
|
56
|
+
if (s.colors && Object.keys(pal).sort().join(",") !== want) bad.push(`vary.palettes.${scheme} 의 색 열쇠가 style.json colors 와 다르다`);
|
|
57
|
+
if (!(m.vary.scheme ?? [s.scheme]).includes(scheme as "light" | "dark")) bad.push(`vary.palettes.${scheme} 가 있는데 vary.scheme 에 ${scheme} 가 없다`);
|
|
58
|
+
}
|
|
51
59
|
}
|
|
52
60
|
if (m.tags && (!Array.isArray(m.tags.mood) || !Array.isArray(m.tags.use) || !["light", "dark"].includes(m.tags.light) || !["2D", "3D"].includes(m.tags.dims))) bad.push("theme.json tags 는 { mood: [], light: light|dark, use: [], dims: 2D|3D }");
|
|
53
61
|
if (!s.fonts?.sans || !s.colors?.bg || !s.motion) bad.push("style.json 이 KfStyle 꼴이 아니다(fonts·colors·motion)");
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { hueDist, labOf, parseColor } from "./color.ts";
|
|
4
4
|
import { lchOf } from "./palette.ts";
|
|
5
5
|
import type { TextureKind, Theme } from "./types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { fontPairs, stackFamilies } from "./fonts.ts";
|
|
7
|
+
import { baseParams } from "./vary.ts";
|
|
7
8
|
|
|
8
9
|
export interface LookWant {
|
|
9
10
|
scheme?: "light" | "dark";
|
|
@@ -65,14 +66,36 @@ const LEXICON: [Cue[], LookWant][] = [
|
|
|
65
66
|
[["따뜻", "따스", "포근", "warm", "노을", "가을", "선셋", "sunset", "햇살"], { warm: true }],
|
|
66
67
|
[["차가운", "시원", "청량", "cool", "겨울", "바다", "얼음", "냉철"], { cool: true }],
|
|
67
68
|
[["선명", "쨍", "강렬", "vivid", "네온", "neon", "팝", "pop", "원색", "화려"], { vivid: true }],
|
|
68
|
-
[["차분", "은은", "잔잔", "muted", "파스텔", "pastel", "미니멀", "minimal", "절제", "담백"], { muted: true }],
|
|
69
|
+
[["차분", "은은", "잔잔", "muted", "파스텔", "pastel", "미니멀", "minimal", "절제", "담백"], { muted: true, fontTags: ["calm", "minimal"] }],
|
|
69
70
|
[["종이", "아날로그", "인쇄", "빈티지", "vintage", "paper", "질감", "손맛", "잡지", "zine", "필름", "film"], { textures: ["paper", "grain", "halftone"] }],
|
|
70
|
-
[["레트로", "retro", "crt", "브라운관", "옛날", "80년대", "90년대", "오락실"], { textures: ["scan", "noise"], fontTags: ["pixel"] }],
|
|
71
|
+
[["레트로", "retro", "crt", "브라운관", "옛날", "80년대", "90년대", "오락실"], { textures: ["scan", "noise"], fontTags: ["pixel", "retro"] }],
|
|
71
72
|
[["픽셀", "pixel", "도트", "8비트", "8bit"], { fontTags: ["pixel"] }],
|
|
72
|
-
|
|
73
|
-
[["
|
|
73
|
+
// 글꼴 결 낱말 — 짝 태그(kit/fonts*.json)로 잇는다. 내용 낱말과 겹치는 조각은 뺐다(캘리포니아·일기예보·고급 기능·기계 학습·와이드 화면)
|
|
74
|
+
[["명조", "세리프", "serif", "클래식", "고전", "에디토리얼", "editorial", "문학"], { fontTags: ["serif", "editorial", "classic"] }],
|
|
75
|
+
[["우아", "elegant", "럭셔리", "luxury", "격조", "품격"], { fontTags: ["elegant", "classic"] }],
|
|
76
|
+
[["전통", "한옥", "사극", "traditional", "붓글씨", "서예", "캘리그라피", "캘리그래피", "brush", "수묵"], { fontTags: ["brush", "traditional"] }],
|
|
77
|
+
[["신문", "뉴스", "news", "newspaper", "기사", "보도", "헤드라인", "속보"], { fontTags: ["newspaper", "editorial"] }],
|
|
78
|
+
[["독서", "소설", "에세이", "book"], { fontTags: ["book", "serif"] }],
|
|
79
|
+
[["손글씨", "손편지", "필기", "메모", "낙서", "다이어리", "handwriting", "handwritten"], { fontTags: ["hand", "marker"] }],
|
|
80
|
+
[["귀여운", "귀엽", "cute", "카와이", "kawaii", "둥근", "말랑", "아기자기", "친근"], { fontTags: ["rounded", "friendly", "playful"] }],
|
|
81
|
+
[["발랄", "장난", "유쾌", "신나", "playful", "fun"], { fontTags: ["playful", "friendly"] }],
|
|
82
|
+
[["만화", "코믹", "comic", "웹툰", "말풍선"], { fontTags: ["comic", "playful"] }],
|
|
74
83
|
[["기술", "테크", "tech", "코딩", "개발자", "터미널", "로그", "해커", "hacker", "고정폭"], { fontTags: ["mono", "tech"] }],
|
|
75
|
-
[["
|
|
84
|
+
[["대시보드", "dashboard", "지표", "통계", "데이터"], { fontTags: ["data", "mono"] }],
|
|
85
|
+
[["모던", "modern", "현대적", "세련", "깔끔", "스마트"], { fontTags: ["modern", "clean", "geometric"] }],
|
|
86
|
+
[["제품", "프로덕트", "product", "saas", "스타트업"], { fontTags: ["product", "clean"] }],
|
|
87
|
+
[["산업", "공장", "industrial", "공업", "중공업"], { fontTags: ["industrial", "condensed", "square"] }],
|
|
88
|
+
[["미래", "futuristic", "sf", "사이버", "cyber", "우주"], { fontTags: ["futuristic", "wide"] }],
|
|
89
|
+
[["안내판", "표지판", "사이니지", "signage", "지하철", "교통", "공항", "길찾기", "wayfinding"], { fontTags: ["signage", "condensed"] }],
|
|
90
|
+
[["그로테스크", "grotesque", "스위스", "swiss", "헬베티카", "helvetica"], { fontTags: ["grotesque"] }],
|
|
91
|
+
[["기하", "geometric", "지오메트릭"], { fontTags: ["geometric"] }],
|
|
92
|
+
[["휴머니스트", "humanist", "인간적", "다정"], { fontTags: ["humanist", "friendly"] }],
|
|
93
|
+
[["넓적", "익스텐디드", "extended"], { fontTags: ["wide"] }],
|
|
94
|
+
[["콘덴스드", "condensed", "길쭉"], { fontTags: ["condensed"] }],
|
|
95
|
+
[["강한", "힘있", "파워", "묵직", "단단", "strong"], { fontTags: ["strong", "bold"] }],
|
|
96
|
+
[["굵은", "볼드", "bold", "임팩트", "포스터", "poster"], { fontTags: ["bold", "display", "poster"] }],
|
|
97
|
+
[["네온", "neon", "간판"], { fontTags: ["neon"] }],
|
|
98
|
+
[["낡은", "거친", "그런지", "grunge", "distressed"], { fontTags: ["distressed", "retro"] }],
|
|
76
99
|
[["입체", "3d", "깊이"], { dims: "3D" }],
|
|
77
100
|
[["평면", "2d", "플랫", "flat"], { dims: "2D" }],
|
|
78
101
|
];
|
|
@@ -163,7 +186,10 @@ function lookOf(t: Theme): Looked {
|
|
|
163
186
|
const p = t.variant?.params ?? baseParams(t.style);
|
|
164
187
|
const acc = lchOf(t.style.colors.accent);
|
|
165
188
|
const pair = fontPairs().find((x) => x.id === p.fonts);
|
|
166
|
-
// 세계 글꼴은 짝 목록에 없다 — 제목 글꼴 이름으로
|
|
189
|
+
// 세계 글꼴은 짝 목록에 없다 — 제목 첫 글꼴이 같은 짝의 태그를 빌리고, 그런 짝도 없으면 글꼴 이름으로 어림한다.
|
|
190
|
+
// Pretendard 는 모든 짝의 바닥이라 그 짝의 태그는 다른 글꼴(고정폭 등) 이야기다 — 빌리지 않는다
|
|
191
|
+
const first = stackFamilies(t.style.fonts.display)[0];
|
|
192
|
+
const like = first === "Pretendard Variable" ? undefined : fontPairs().find((x) => stackFamilies(x.display)[0] === first);
|
|
167
193
|
const display = t.style.fonts.display.toLowerCase();
|
|
168
194
|
const baseTags = [/mono|coding|galmuri/.test(display) && "mono", /myeongjo|batang|serif|hahmlet/.test(display) && "serif", /jua|dodum|dongle|bagel/.test(display) && "rounded", /black han|gasoek|do hyeon/.test(display) && "bold"].filter(Boolean) as string[];
|
|
169
195
|
return {
|
|
@@ -172,7 +198,7 @@ function lookOf(t: Theme): Looked {
|
|
|
172
198
|
chroma: acc.c,
|
|
173
199
|
bg: labOf(parseColor(t.style.colors.bg)),
|
|
174
200
|
texture: t.style.look?.texture?.kind ?? "none",
|
|
175
|
-
fontTags: pair?.tags ?? baseTags,
|
|
201
|
+
fontTags: pair?.tags ?? like?.tags ?? baseTags,
|
|
176
202
|
dims: t.tags?.dims ?? (t.effects.some((e) => e.kind === "rig") ? "3D" : "2D"),
|
|
177
203
|
};
|
|
178
204
|
}
|
|
@@ -57,6 +57,11 @@ export interface ThemeVary {
|
|
|
57
57
|
keep?: string[];
|
|
58
58
|
/** 색상은 두고 명도·채도만 따라가는 색 열쇠(풀·물 같은 재료 색) */
|
|
59
59
|
fixedHue?: string[];
|
|
60
|
+
/**
|
|
61
|
+
* 밝기마다 세계가 정해 둔 색 한 벌 — 브랜드가 라이트·다크를 따로 정한 세계(두 벌이 서로 거울상이 아니라 뒤집어 옮기면 정본 값이 안 나온다).
|
|
62
|
+
* 그 밝기 판은 이 벌을 원점으로 삼고, 안 적은 축(바탕 빛깔·글 명도·강조 색상)은 그 벌 값 그대로. 열쇠는 style.json colors 와 같다
|
|
63
|
+
*/
|
|
64
|
+
palettes?: Partial<Record<"light" | "dark", Record<string, string>>>;
|
|
60
65
|
/** 글꼴 짝 id(packages/themes/kit/fonts.json)·`tag:<태그>`·`base`(세계 글꼴) */
|
|
61
66
|
fonts?: string[];
|
|
62
67
|
/** 세 글꼴 스택을 짝의 고정폭 하나로(한 글꼴 HUD) — 그러면 monoAll 짝만 받는다 */
|