adyou 0.6.16 → 0.6.18
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.
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type FontRec = {
|
|
2
|
+
name: string;
|
|
3
|
+
data: ArrayBuffer;
|
|
4
|
+
weight: number;
|
|
5
|
+
};
|
|
6
|
+
/** 글꼴 등록 — render.loadFonts 가 satori 에 넘기는 목록을 그대로 넣는다(파싱은 첫 측정 때 굵기별로 게으르게) */
|
|
7
|
+
export declare function registerFonts(list: FontRec[]): void;
|
|
8
|
+
/** 어림 폭(글꼴이 없을 때만 · Pretendard 실측): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
9
|
+
export declare const estimateW: (t: string, fs: number) => number;
|
|
10
|
+
/** 실제 글꼴 폭 — 굵기가 같은 글꼴을 등록 순서(Pretendard → JP → CJK → Thai)로 훑어 글리프가 있는 첫 글꼴의 advance 를 더한다(satori 폴백과 같은 순서). letterSpacing 은 satori 처럼 (글자 수−1)배 */
|
|
11
|
+
export declare function measureText(t: string, fs: number, o?: {
|
|
12
|
+
weight?: number;
|
|
13
|
+
letterSpacing?: number;
|
|
14
|
+
}): number;
|
|
15
|
+
export declare function hasMeasureFonts(): boolean;
|
|
16
|
+
/** 어절 토큰화 + 한 글자 한글 어절 결속 — 「한 번」「안 팔아요」「수 있어요」「이 동네」는 한 덩어리 */
|
|
17
|
+
export declare function tokenize(text: string): string[];
|
|
18
|
+
/** 어절 i 뒤에서 끊는 비용(음수=좋은 자리) — i 는 앞 줄의 마지막 어절, next 는 다음 줄의 첫 어절 */
|
|
19
|
+
export declare function breakCost(prev: string, next: string): number;
|
|
20
|
+
export type BreakOpts = {
|
|
21
|
+
maxLines?: number;
|
|
22
|
+
weight?: number;
|
|
23
|
+
letterSpacing?: number; /** 폭 안전 여백(기본 2% · 글꼴 셰이핑 오차) */
|
|
24
|
+
margin?: number;
|
|
25
|
+
};
|
|
26
|
+
/** 줄나눔 — 한 줄에 들어가면 그대로 · 아니면 줄 수를 2부터 늘려가며 모든 어절 경계 조합 중 비용 최소(결속 규칙 + 줄 균형 + 고아 벌점)를 고른다. 어떤 조합도 폭 안에 못 들어가면(한 어절이 줄보다 김) 뒤에서부터 채운 줄을 돌려주고 linesEl 이 글자를 줄인다. */
|
|
27
|
+
export declare function breakLines(text: string, fs: number, maxW: number, o?: BreakOpts | number): string[];
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// 헤드라인·훅·자막 줄나눔 — 「오늘의 입술, / 틴트 한 번」·「나에게 맞는 / 컬러 찾기」처럼 읽기 자연스러운 어절 경계에서만 줄을 바꾼다(2026-09-18 사장님 · 티르티르 영상 엔드카드 「틴트 / 한 번」·「컬러 / 찾기」).
|
|
2
|
+
// 왜 규칙인가: LLM 은 비용·지연·재현성이 나쁘고, 한국어 헤드라인(≤30자·어절 ≤12개)은 전 조합을 다 재봐도 수백 가지라 규칙+비용 최소 탐색으로 충분하다.
|
|
3
|
+
// 두 층으로 지킨다 — ① 폭은 실제 글꼴 글리프 폭(opentype · satori 와 같은 계산)으로 잰다(어림 폭이 틀려 satori 가 임의 자리에서 접던 구멍을 막음) ② 고른 줄은 nowrap 행으로 그려 렌더러가 다시 접을 수 없게 한다(render.ts linesEl).
|
|
4
|
+
import { parse as parseFont } from '@shuding/opentype.js';
|
|
5
|
+
import { wlen } from './specs.js';
|
|
6
|
+
const registered = [];
|
|
7
|
+
const parsed = new Map();
|
|
8
|
+
/** 글꼴 등록 — render.loadFonts 가 satori 에 넘기는 목록을 그대로 넣는다(파싱은 첫 측정 때 굵기별로 게으르게) */
|
|
9
|
+
export function registerFonts(list) { for (const f of list)
|
|
10
|
+
if (!registered.some((r) => r.name === f.name && r.weight === f.weight))
|
|
11
|
+
registered.push(f); }
|
|
12
|
+
function fontOf(rec) {
|
|
13
|
+
if (parsed.has(rec))
|
|
14
|
+
return parsed.get(rec);
|
|
15
|
+
let f = null;
|
|
16
|
+
try {
|
|
17
|
+
f = parseFont(rec.data);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
f = null;
|
|
21
|
+
}
|
|
22
|
+
parsed.set(rec, f);
|
|
23
|
+
return f;
|
|
24
|
+
}
|
|
25
|
+
/** 어림 폭(글꼴이 없을 때만 · Pretendard 실측): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
26
|
+
export const estimateW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
|
|
27
|
+
/** 실제 글꼴 폭 — 굵기가 같은 글꼴을 등록 순서(Pretendard → JP → CJK → Thai)로 훑어 글리프가 있는 첫 글꼴의 advance 를 더한다(satori 폴백과 같은 순서). letterSpacing 은 satori 처럼 (글자 수−1)배 */
|
|
28
|
+
export function measureText(t, fs, o = {}) {
|
|
29
|
+
const weight = o.weight ?? 800;
|
|
30
|
+
const ls = o.letterSpacing ?? 0;
|
|
31
|
+
const chars = [...t];
|
|
32
|
+
if (!chars.length)
|
|
33
|
+
return 0;
|
|
34
|
+
const pool = registered.filter((r) => r.weight === weight);
|
|
35
|
+
const fonts = (pool.length ? pool : registered.filter((r) => r.weight === nearestWeight(weight))).map(fontOf).filter((f) => !!f);
|
|
36
|
+
if (!fonts.length)
|
|
37
|
+
return estimateW(t, fs) + ls * (chars.length - 1);
|
|
38
|
+
let w = 0;
|
|
39
|
+
for (const ch of chars) {
|
|
40
|
+
let done = false;
|
|
41
|
+
for (const f of fonts) {
|
|
42
|
+
const gi = f.charToGlyphIndex(ch);
|
|
43
|
+
if (gi > 0) {
|
|
44
|
+
w += ((f.glyphs.get(gi).advanceWidth ?? f.unitsPerEm * 0.5) / f.unitsPerEm) * fs;
|
|
45
|
+
done = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!done)
|
|
50
|
+
w += (ch === ' ' ? 0.3 : 0.86) * fs;
|
|
51
|
+
}
|
|
52
|
+
return w + ls * (chars.length - 1);
|
|
53
|
+
}
|
|
54
|
+
function nearestWeight(w) { const ws = [...new Set(registered.map((r) => r.weight))]; return ws.sort((a, b) => Math.abs(a - w) - Math.abs(b - w))[0] ?? w; }
|
|
55
|
+
export function hasMeasureFonts() { return registered.length > 0; }
|
|
56
|
+
// ── 한국어 결속 규칙 ────────────────────────────────────────────────────────────
|
|
57
|
+
/** 앞 어절에 붙어야 하는 어절(의존명사·후치사류) — 「색조 / 때문에」 금지 */
|
|
58
|
+
const KO_GLUE_PREV = new Set(['때문에', '때문이죠', '때문이에요', '때문입니다', '덕분에', '대신', '만큼', '정도', '이상', '이하', '미만', '이내', '동안', '사이', '이후', '이전', '전에', '후에', '위해', '위한', '통해', '대해', '대한', '관해', '관한', '따라', '처럼', '같이', '같은', '만에', '밖에', '뿐', '채', '척', '듯', '중', '등', '및', '또는', '혹은']);
|
|
59
|
+
/** 뒤 어절에 붙어야 하는 어절(관형사·부정/정도 부사) — 「이런 / 컬러」·「안 / 팔아요」 금지 */
|
|
60
|
+
const KO_GLUE_NEXT_STRONG = new Set(['이런', '그런', '저런', '어떤', '무슨', '모든', '여러', '다른', '각', '매', '약', '총', '무려', '단', '오직', '바로', '이', '그', '저', '새', '첫', '헌', '옛', '온', '안', '못', '한', '두', '세', '네', '몇', '여섯', '일곱', '여덟', '아홉', '열', '스무']);
|
|
61
|
+
const KO_GLUE_NEXT_WEAK = new Set(['더', '덜', '잘', '꼭', '딱', '아주', '매우', '정말', '진짜', '너무', '가장', '제일', '다시', '아직', '이미', '늘', '항상', '함께', '같이', '직접', '먼저', '왜', '어떻게', '뭐부터', '뭘']);
|
|
62
|
+
/** 절이 끝나는 연결어미 — 뒤에서 끊기 좋음(「컬러 못 골라서 / 3분째 고민」) */
|
|
63
|
+
// 🔴 「고·서·면·자」 한 글자 어미는 광고·순서·라면·사용자 같은 명사와 겹쳐 쓰지 않는다
|
|
64
|
+
const KO_CLAUSE_END = /(지만|는데|인데|니까|라서|려고|도록|다가|든지|거나|라면|다면|더니|어서|아서|해서|면서|으면|이면|하고|이고|하며|이며|하면|더라도)$/;
|
|
65
|
+
/** 부사격·보조사로 끝나는 어절 — 뒤에서 끊기 좋음(「항공부터 / 렌터카까지 / 한 곳에서」) */
|
|
66
|
+
const KO_ADVERBIAL_END = /(부터|까지|에서|에게|께|으로|로|처럼|보다|마다|조차|마저|밖에|에는|엔|에도|로도|으로도|에서도|께서|한테|더러|에서는)$/;
|
|
67
|
+
/** 주제·주격·목적격·관형형 등 — 약한 우선 */
|
|
68
|
+
const KO_MARKER_END = /(은|는|이|가|을|를|도|만|던)$/;
|
|
69
|
+
/** 소유격 「의」 뒤 끊기 억제(「오늘의 / 입술」) — 강의·회의·문의처럼 「의」로 끝나는 명사는 제외 */
|
|
70
|
+
const KO_GENITIVE_END = /[가-힣]의$/;
|
|
71
|
+
const KO_UI_NOUNS = new Set(['강의', '회의', '문의', '정의', '주의', '의의', '편의', '호의', '심의', '논의', '합의', '동의', '상의', '토의', '협의', '예의', '유의', '용의', '결의', '건의', '의']);
|
|
72
|
+
const EN_NO_END = new Set(['the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'with', 'and', 'or', 'but', 'your', 'our', 'my', 'its', 'their', 'not', 'just', 'at', 'by', 'from', 'into', 'is', 'are', 'be', 'that', 'this', 'very', 'so', 'you\'re', 'we\'re']);
|
|
73
|
+
const EN_NO_START = new Set(['too', 'either', 'yet']);
|
|
74
|
+
const isHangul = (s) => /[가-힣]/.test(s);
|
|
75
|
+
/** 부사격 조사로 끝나는 어절(장소·시간·수단·출발점) — 뒤의 짧은 관형형 용언을 꾸민다(「아침에 딴」·「마트에서 산」·「손으로 빚은」) · 「에」 하나짜리도 포함(일반 끊기 보너스엔 안 씀) */
|
|
76
|
+
const KO_ADVERB_FOR_MODIFIER = /[가-힣](에|에서|로|으로|부터|께|에게|한테|와|과|서|처럼|같이|보다)$/;
|
|
77
|
+
/** 자주 쓰는 2음절 관형형 용언(광고 문구) — 명사와 겹치지 않는 것만 */
|
|
78
|
+
const KO_MODIFIER_2 = new Set(['구운', '만든', '고른', '담은', '키운', '빚은', '캐낸', '뽑은', '볶은', '우린', '지은', '튀긴', '삶은', '말린', '재운', '절인', '쪄낸', '끓인', '내린', '갈아낸', '짜낸', '따낸', '건진', '골라낸', '추린', '모은', '익힌', '갓난', '길러낸', '빻은', '깎은', '썰은', '다진', '무친', '데친', '졸인', '지진', '부친', '섞은', '띄운', '숙성한', '엄선한', '수확한', '재배한', '직접', '갓']);
|
|
79
|
+
/** 짧은 관형형 용언인가 — 1음절은 받침 ㄴ·ㄹ(딴·산·쓴·든·본·준·큰·낼·쓸 …)이고 관형사·수사·부사 결속 표에 없는 것 · 2음절은 표에 있는 것 */
|
|
80
|
+
function isShortModifier(w) {
|
|
81
|
+
const chars = [...w.replace(/[^가-힣]/g, '')];
|
|
82
|
+
if (!chars.length || chars.length !== [...w].length)
|
|
83
|
+
return false;
|
|
84
|
+
if (chars.length === 1) {
|
|
85
|
+
if (KO_GLUE_NEXT_STRONG.has(w) || KO_GLUE_NEXT_WEAK.has(w))
|
|
86
|
+
return false;
|
|
87
|
+
const fin = (w.codePointAt(0) - 0xac00) % 28;
|
|
88
|
+
return fin === 4 || fin === 8;
|
|
89
|
+
}
|
|
90
|
+
if (chars.length === 2)
|
|
91
|
+
return KO_MODIFIER_2.has(w);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const isCJKNoSpace = (s) => /[-ヿ㐀-鿿]/.test(s) && !/[가-힣]/.test(s);
|
|
95
|
+
const PUNCT_END = /[,,、.!?!?::·—–\-…」』’”))]$/;
|
|
96
|
+
const HARD_PUNCT_END = /[,,、.!?!?::·…]$/;
|
|
97
|
+
/** 어절 토큰화 + 한 글자 한글 어절 결속 — 「한 번」「안 팔아요」「수 있어요」「이 동네」는 한 덩어리 */
|
|
98
|
+
export function tokenize(text) {
|
|
99
|
+
const raw = String(text || '').replace(/([,,、!?!?.])(?=[^\s\d,.!?])/g, '$1 ').trim().split(/\s+/).filter(Boolean);
|
|
100
|
+
const words = [];
|
|
101
|
+
for (let i = 0; i < raw.length; i++) {
|
|
102
|
+
const w = raw[i];
|
|
103
|
+
if ([...w].length === 1 && isHangul(w)) {
|
|
104
|
+
// 🔴 「아침에 딴 토마토」 — 한 글자 관형형 용언(딴·산·쓴)은 앞의 부사어(아침에·마트에서)가 꾸미는 말이라 앞에 붙인다(「아침에 / 딴 토마토」 금지 · 2026-09-18 사장님). 「하루에 한 번」의 「한」(수 관형사)은 예전처럼 뒤에 붙는다
|
|
105
|
+
if (words.length && i + 1 < raw.length && isShortModifier(w) && isHangul(raw[i - 1]) && KO_ADVERB_FOR_MODIFIER.test(raw[i - 1]) && !PUNCT_END.test(raw[i - 1])) {
|
|
106
|
+
words[words.length - 1] += ' ' + w;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (i + 1 < raw.length) {
|
|
110
|
+
words.push(w + ' ' + raw[i + 1]);
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (words.length) {
|
|
115
|
+
words[words.length - 1] += ' ' + w;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
words.push(w);
|
|
120
|
+
}
|
|
121
|
+
return words;
|
|
122
|
+
}
|
|
123
|
+
const lastWord = (tok) => tok.split(' ').pop() || tok;
|
|
124
|
+
const firstWord = (tok) => tok.split(' ')[0] || tok;
|
|
125
|
+
/** 어절 i 뒤에서 끊는 비용(음수=좋은 자리) — i 는 앞 줄의 마지막 어절, next 는 다음 줄의 첫 어절 */
|
|
126
|
+
export function breakCost(prev, next) {
|
|
127
|
+
const a = lastWord(prev), b = firstWord(next);
|
|
128
|
+
const al = a.toLowerCase().replace(/[^a-z']/g, ''), bl = b.toLowerCase().replace(/[^a-z']/g, '');
|
|
129
|
+
let c = 0;
|
|
130
|
+
// 부연(꾸밈) 관계는 한 어절처럼 — 「아침에 | 딴」(부사어 → 관형형) +0.8 · 「구운 | 빵」(관형형 → 명사) +0.5 · 공간이 허락하면 붙고, 없으면 다른 자리보다 먼저 지킨다
|
|
131
|
+
const bIsMod = isShortModifier(b) && isHangul(next.split(' ')[1] || b) && !PUNCT_END.test(b);
|
|
132
|
+
if (isHangul(a) && isHangul(b) && bIsMod && KO_ADVERB_FOR_MODIFIER.test(a) && !PUNCT_END.test(a))
|
|
133
|
+
return 0.8;
|
|
134
|
+
if (isShortModifier(a) && isHangul(b) && !PUNCT_END.test(a))
|
|
135
|
+
return 0.5;
|
|
136
|
+
if (HARD_PUNCT_END.test(a))
|
|
137
|
+
c -= 0.6;
|
|
138
|
+
else if (PUNCT_END.test(a))
|
|
139
|
+
c -= 0.3;
|
|
140
|
+
else if (isHangul(a)) {
|
|
141
|
+
if (KO_GENITIVE_END.test(a) && !KO_UI_NOUNS.has(a))
|
|
142
|
+
c += 0.3;
|
|
143
|
+
else if (KO_CLAUSE_END.test(a))
|
|
144
|
+
c -= 0.3;
|
|
145
|
+
else if (KO_ADVERBIAL_END.test(a))
|
|
146
|
+
c -= 0.25;
|
|
147
|
+
else if (KO_MARKER_END.test(a))
|
|
148
|
+
c -= 0.12;
|
|
149
|
+
}
|
|
150
|
+
else if (al && EN_NO_END.has(al))
|
|
151
|
+
c += 0.45;
|
|
152
|
+
if (isHangul(b) || isHangul(a)) {
|
|
153
|
+
if (KO_GLUE_PREV.has(b))
|
|
154
|
+
c += 0.8;
|
|
155
|
+
if (KO_GLUE_NEXT_STRONG.has(a))
|
|
156
|
+
c += 0.8;
|
|
157
|
+
else if (KO_GLUE_NEXT_WEAK.has(a))
|
|
158
|
+
c += 0.3;
|
|
159
|
+
}
|
|
160
|
+
if (bl && EN_NO_START.has(bl))
|
|
161
|
+
c += 0.3;
|
|
162
|
+
// 숫자·단위가 갈라지는 자리(「82,700 / 원」·「3 / 분」) 는 tokenize 가 붙이지 않으므로 여기서 막는다
|
|
163
|
+
if (/\d$/.test(a) && /^[%원명개분초시년월일건회번배]/.test(b))
|
|
164
|
+
c += 0.9;
|
|
165
|
+
return c;
|
|
166
|
+
}
|
|
167
|
+
/** 줄나눔 — 한 줄에 들어가면 그대로 · 아니면 줄 수를 2부터 늘려가며 모든 어절 경계 조합 중 비용 최소(결속 규칙 + 줄 균형 + 고아 벌점)를 고른다. 어떤 조합도 폭 안에 못 들어가면(한 어절이 줄보다 김) 뒤에서부터 채운 줄을 돌려주고 linesEl 이 글자를 줄인다. */
|
|
168
|
+
export function breakLines(text, fs, maxW, o = {}) {
|
|
169
|
+
const opt = typeof o === 'number' ? { maxLines: o } : o;
|
|
170
|
+
const maxLines = opt.maxLines ?? 3;
|
|
171
|
+
const limit = maxW * (1 - (opt.margin ?? 0.02));
|
|
172
|
+
const W = (s) => measureText(s, fs, { weight: opt.weight, letterSpacing: opt.letterSpacing });
|
|
173
|
+
const src = String(text || '').trim();
|
|
174
|
+
if (!src)
|
|
175
|
+
return [];
|
|
176
|
+
if (isCJKNoSpace(src) && !src.includes(' '))
|
|
177
|
+
return breakCJK(src, W, limit, maxLines);
|
|
178
|
+
const words = tokenize(src);
|
|
179
|
+
if (!words.length)
|
|
180
|
+
return [];
|
|
181
|
+
if (words.length === 1 || W(words.join(' ')) <= limit)
|
|
182
|
+
return [words.join(' ')];
|
|
183
|
+
const widths = new Map();
|
|
184
|
+
const lineW = (from, to) => { const k = `${from}:${to}`; let v = widths.get(k); if (v === undefined) {
|
|
185
|
+
v = W(words.slice(from, to).join(' '));
|
|
186
|
+
widths.set(k, v);
|
|
187
|
+
} return v; };
|
|
188
|
+
// 고아 — 한글은 짧은 한 어절(≤2자)만 · 라틴은 한 단어 줄 전부(「… Main / Character」)
|
|
189
|
+
const orphan = (from, to) => to - from === 1 && (isHangul(words[from]) ? wlen(words[from].replace(/ /g, '')) <= 4 : true);
|
|
190
|
+
const n = words.length;
|
|
191
|
+
// 줄 수 k 를 2부터 늘려가며 조합 비용 최소를 찾는다 · 줄이 하나 늘 때마다 +EXTRA_LINE(결속 위반(+0.8)을 무릅쓴 두 줄보다 깨끗한 세 줄을 고르되, 균형만 조금 나은 두 줄은 그대로) · maxLines 안에서 답이 없으면 그 밖에서도 찾고 linesEl 이 글자를 줄인다
|
|
192
|
+
const EXTRA_LINE = 0.7;
|
|
193
|
+
let global = null;
|
|
194
|
+
for (let k = 2; k <= Math.min(n, maxLines + 2); k++) {
|
|
195
|
+
if (global && k > maxLines)
|
|
196
|
+
break;
|
|
197
|
+
let best = null;
|
|
198
|
+
const rec = (start, cutsSoFar, cost) => {
|
|
199
|
+
const linesLeft = k - cutsSoFar.length;
|
|
200
|
+
if (linesLeft === 1) {
|
|
201
|
+
const w = lineW(start, n);
|
|
202
|
+
if (w > limit)
|
|
203
|
+
return;
|
|
204
|
+
const cuts = [...cutsSoFar, n];
|
|
205
|
+
const ws = cuts.map((c, i) => lineW(i ? cuts[i - 1] : 0, c));
|
|
206
|
+
const mean = ws.reduce((a, b) => a + b, 0) / ws.length;
|
|
207
|
+
// 줄 균형(폭 편차 합 / 한도) — 세 줄 이상(본문)은 결속 보너스가 균형을 이기지 않게 1.5배(「가벼운 발림으로 / 하루 종일 … / …」 첫 줄만 짧은 모양 방지)
|
|
208
|
+
let total = cost + (ws.reduce((a, b) => a + Math.abs(b - mean), 0) / limit) * (k >= 3 ? 1.5 : 1);
|
|
209
|
+
// 고아: 마지막(또는 첫) 줄 → 강한 벌점 · 가운데 줄은 중간 벌점
|
|
210
|
+
cuts.forEach((c, i) => { const from = i ? cuts[i - 1] : 0; if (orphan(from, c))
|
|
211
|
+
total += i === 0 || i === cuts.length - 1 ? 1 : 0.6; });
|
|
212
|
+
// 뒤 줄이 앞 줄보다 눈에 띄게 길면(역삼각형) 살짝 벌점 — 「오늘의 입술, / 틴트 한 번」 처럼 위가 길거나 비슷한 모양 선호
|
|
213
|
+
for (let i = 1; i < ws.length; i++)
|
|
214
|
+
if (ws[i] - ws[i - 1] > limit * 0.15)
|
|
215
|
+
total += 0.08;
|
|
216
|
+
if (!best || total < best.cost)
|
|
217
|
+
best = { cuts, cost: total };
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (let cut = start + 1; cut <= n - (linesLeft - 1); cut++) {
|
|
221
|
+
if (lineW(start, cut) > limit)
|
|
222
|
+
break;
|
|
223
|
+
rec(cut, [...cutsSoFar, cut], cost + breakCost(words[cut - 1], words[cut]));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
rec(0, [], 0);
|
|
227
|
+
if (best) {
|
|
228
|
+
const b = best;
|
|
229
|
+
const total = b.cost + EXTRA_LINE * (k - 2);
|
|
230
|
+
if (!global || total < global.cost)
|
|
231
|
+
global = { cuts: b.cuts, cost: total, k };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (global) {
|
|
235
|
+
const g = global;
|
|
236
|
+
return g.cuts.map((c, i) => words.slice(i ? g.cuts[i - 1] : 0, c).join(' '));
|
|
237
|
+
}
|
|
238
|
+
// 못 맞추는 경우(한 어절이 줄보다 김) — 뒤에서부터 채워 마지막 줄 고아를 피하고, 넘치는 줄은 linesEl 이 글자를 줄인다 · 🔴 어절을 버리지 않는다(예전엔 maxLines 로 잘라 뒷말이 사라졌다)
|
|
239
|
+
const lines = [];
|
|
240
|
+
let cur = [];
|
|
241
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
242
|
+
const next = [words[i], ...cur];
|
|
243
|
+
if (cur.length && W(next.join(' ')) > limit) {
|
|
244
|
+
lines.unshift(cur);
|
|
245
|
+
cur = [words[i]];
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
cur = next;
|
|
249
|
+
}
|
|
250
|
+
if (cur.length)
|
|
251
|
+
lines.unshift(cur);
|
|
252
|
+
return lines.map((l) => l.join(' '));
|
|
253
|
+
}
|
|
254
|
+
/** 일본어·중국어(띄어쓰기 없음) — 글자 사이 어디서든 끊되 금칙(줄 머리에 。、」ー っ 작은 가나 · 줄 끝에 「() 을 지키고 구두점·조사 뒤를 우선, 줄 균형으로 고른다 */
|
|
255
|
+
function breakCJK(src, W, limit, maxLines) {
|
|
256
|
+
const chars = [...src];
|
|
257
|
+
if (W(src) <= limit)
|
|
258
|
+
return [src];
|
|
259
|
+
// 금칙 + 조사(は・が・を・に・で・と・へ・も・の) 는 줄 머리에 두지 않고 그 뒤에서 끊는다 · 가타카나 낱말(ティント) 안은 끊지 않고 문자 종류가 바뀌는 자리를 우선
|
|
260
|
+
const noStart = /[。、,.・ー〜っゃゅょぁぃぅぇぉ」』)】!?!?はがをにでとへもの]/;
|
|
261
|
+
const noEnd = /[「『(【]/;
|
|
262
|
+
const goodAfter = /[。、,!?!?]/;
|
|
263
|
+
const particle = /[はがをにでとへもの]/;
|
|
264
|
+
const kata = /[\u30a0-\u30ff]/;
|
|
265
|
+
const cost = (i) => { const a = chars[i - 1], b = chars[i]; if (noStart.test(b) || noEnd.test(a))
|
|
266
|
+
return Infinity; if (goodAfter.test(a))
|
|
267
|
+
return -0.6; if (particle.test(a))
|
|
268
|
+
return /[\u3040-\u309f]/.test(b) ? 0.1 : -0.25 /* 조사 뒤가 히라가나면(ひ「と」つ) 낱말 속일 수 있어 보너스 없음 */; if (kata.test(a) && kata.test(b))
|
|
269
|
+
return 0.6; if (kata.test(a) !== kata.test(b))
|
|
270
|
+
return -0.2; return 0.35; /* 낱말 가운데일 가능성 — 조사·구두점·문자 종류 경계가 아닌 자리는 되도록 피한다 */ };
|
|
271
|
+
const n = chars.length;
|
|
272
|
+
for (let k = 2; k <= maxLines; k++) {
|
|
273
|
+
let best = null;
|
|
274
|
+
const rec = (start, cuts, c) => {
|
|
275
|
+
const left = k - cuts.length;
|
|
276
|
+
if (left === 1) {
|
|
277
|
+
const all = [...cuts, n];
|
|
278
|
+
const ws = all.map((x, i) => W(chars.slice(i ? all[i - 1] : 0, x).join('')));
|
|
279
|
+
if (ws.some((w) => w > limit))
|
|
280
|
+
return;
|
|
281
|
+
const mean = ws.reduce((a, b) => a + b, 0) / ws.length;
|
|
282
|
+
const total = c + ws.reduce((a, b) => a + Math.abs(b - mean), 0) / limit;
|
|
283
|
+
if (!best || total < best.cost)
|
|
284
|
+
best = { cuts: all, cost: total };
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (let cut = start + 1; cut <= n - (left - 1); cut++) {
|
|
288
|
+
if (W(chars.slice(start, cut).join('')) > limit)
|
|
289
|
+
break;
|
|
290
|
+
const bc = cost(cut);
|
|
291
|
+
if (bc === Infinity)
|
|
292
|
+
continue;
|
|
293
|
+
rec(cut, [...cuts, cut], c + bc);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
rec(0, [], 0);
|
|
297
|
+
if (best) {
|
|
298
|
+
const b = best;
|
|
299
|
+
return b.cuts.map((x, i) => chars.slice(i ? b.cuts[i - 1] : 0, x).join(''));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const out = [];
|
|
303
|
+
let cur = '';
|
|
304
|
+
for (const ch of chars) {
|
|
305
|
+
if (cur && W(cur + ch) > limit) {
|
|
306
|
+
out.push(cur);
|
|
307
|
+
cur = ch;
|
|
308
|
+
}
|
|
309
|
+
else
|
|
310
|
+
cur += ch;
|
|
311
|
+
}
|
|
312
|
+
if (cur)
|
|
313
|
+
out.push(cur);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Brief, Concept, CreativeAsset } from '../state.js';
|
|
2
2
|
import { type Size } from './specs.js';
|
|
3
|
+
export { breakLines, measureText } from './linebreak.js';
|
|
3
4
|
export declare const FONT_FAMILY = "Pretendard, PretendardJP, NotoCJKtc, NotoCJKsc, NotoThai";
|
|
4
5
|
export declare function loadFonts(lang?: string): Promise<{
|
|
5
6
|
name: string;
|
|
@@ -15,18 +16,15 @@ type El = {
|
|
|
15
16
|
type: string;
|
|
16
17
|
props: Record<string, unknown>;
|
|
17
18
|
};
|
|
18
|
-
|
|
19
|
+
export declare function bgLuminance(bg: string): number | null;
|
|
19
20
|
export declare function onColor(hex: string): string;
|
|
20
21
|
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
21
22
|
/** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
|
|
22
23
|
export declare function cutWords(text: string, limit: number): string;
|
|
23
|
-
/** 글자 폭
|
|
24
|
-
export declare const textW: (t: string, fs: number) => number;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
export declare function breakLines(text: string, fs: number, maxW: number, maxLines?: number): string[];
|
|
28
|
-
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
29
|
-
export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center'): El;
|
|
24
|
+
/** 글자 폭 — 실제 글꼴 글리프 폭(linebreak.measureText · 굵기 700 기준 · 글꼴 미등록이면 어림). 가로 포스터 세로 예산·알약 폭 계산에 쓴다 */
|
|
25
|
+
export declare const textW: (t: string, fs: number, weight?: number) => number;
|
|
26
|
+
/** 줄나눔이 계산된 글줄 — 줄마다 nowrap 행으로 그려 satori 가 다른 자리에서 접을 수 없게 한다(한 줄이어도 nowrap). 어절 하나가 줄보다 길어 어떤 조합도 폭 안에 못 들어가면 가장 넓은 줄이 들어가도록 글자를 줄인다(최소 0.7배 · 그 아래는 글자 단위 줄바꿈). */
|
|
27
|
+
export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center', maxLines?: number): El;
|
|
30
28
|
export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
|
|
31
29
|
/** 한 규격의 트리 */
|
|
32
30
|
export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
|
|
@@ -7,6 +7,8 @@ import satori from 'satori';
|
|
|
7
7
|
import { Resvg } from '@resvg/resvg-js';
|
|
8
8
|
import { HOME } from '../state.js';
|
|
9
9
|
import { SIZES, wlen, policyIssues } from './specs.js';
|
|
10
|
+
import { breakLines, measureText, registerFonts } from './linebreak.js';
|
|
11
|
+
export { breakLines, measureText } from './linebreak.js';
|
|
10
12
|
const FONT_CDN = 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/public/static';
|
|
11
13
|
const WEIGHTS = [['Pretendard-ExtraBold', 800], ['Pretendard-Bold', 700], ['Pretendard-Medium', 500]];
|
|
12
14
|
/** 언어별 글꼴 폴백 — Pretendard 는 한글·라틴만. 일본어=Pretendard JP(가나·한자) · 중국어=Noto Sans TC/SC · 태국어=Noto Sans Thai. 같은 family 이름으로 넣어 satori 가 빠진 글리프를 다음 글꼴에서 찾게 한다. */
|
|
@@ -36,8 +38,10 @@ async function fetchFont(dir, url, file) {
|
|
|
36
38
|
}
|
|
37
39
|
export async function loadFonts(lang) {
|
|
38
40
|
const key = lang ? (EXTRA_FONTS[lang] ? lang : EXTRA_FONTS[lang.split('-')[0]] ? lang.split('-')[0] : '') : '';
|
|
39
|
-
if (fontCache.has(key))
|
|
41
|
+
if (fontCache.has(key)) {
|
|
42
|
+
registerFonts(fontCache.get(key));
|
|
40
43
|
return fontCache.get(key);
|
|
44
|
+
} // 캐시 적중에도 측정용 등록(멱등) — 어림 폭으로 떨어지면 「아침에 / 딴 토마토,」 같은 줄나눔이 나온다
|
|
41
45
|
const dir = path.join(HOME, 'fonts');
|
|
42
46
|
fs.mkdirSync(dir, { recursive: true });
|
|
43
47
|
const out = [];
|
|
@@ -60,6 +64,7 @@ export async function loadFonts(lang) {
|
|
|
60
64
|
out.push({ name: family, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), weight: weight, style: 'normal' });
|
|
61
65
|
}
|
|
62
66
|
fontCache.set(key, out);
|
|
67
|
+
registerFonts(out);
|
|
63
68
|
return out;
|
|
64
69
|
}
|
|
65
70
|
/** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
|
|
@@ -96,6 +101,35 @@ async function fetchLogo(url, dir) {
|
|
|
96
101
|
}
|
|
97
102
|
const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
|
|
98
103
|
/** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
|
|
104
|
+
/** 배경 이미지의 평균 밝기(0~1) — data URI 를 resvg 로 8×8 로 축소 렌더해 픽셀 평균(상대 휘도). 글자 영역(위 65%)만 잰다. 실패하면 null */
|
|
105
|
+
const lumCache = new Map();
|
|
106
|
+
export function bgLuminance(bg) {
|
|
107
|
+
const key = bg.length + ':' + bg.slice(-80);
|
|
108
|
+
if (lumCache.has(key))
|
|
109
|
+
return lumCache.get(key);
|
|
110
|
+
let L = null;
|
|
111
|
+
try {
|
|
112
|
+
const n = 8;
|
|
113
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n}" height="${n}"><image xlink:href="${bg}" href="${bg}" width="${n}" height="${n}" preserveAspectRatio="xMidYMid slice"/></svg>`;
|
|
114
|
+
const img = new Resvg(svg, { fitTo: { mode: 'width', value: n } }).render();
|
|
115
|
+
const px = img.pixels;
|
|
116
|
+
let sum = 0, cnt = 0;
|
|
117
|
+
for (let y = 0; y < Math.ceil(n * 0.65); y++)
|
|
118
|
+
for (let x = 0; x < n; x++) {
|
|
119
|
+
const i = (y * n + x) * 4;
|
|
120
|
+
const a = px[i + 3] / 255;
|
|
121
|
+
const r = px[i] * a + 255 * (1 - a), g = px[i + 1] * a + 255 * (1 - a), b = px[i + 2] * a + 255 * (1 - a);
|
|
122
|
+
sum += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
123
|
+
cnt++;
|
|
124
|
+
}
|
|
125
|
+
L = cnt ? sum / cnt : null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
L = null;
|
|
129
|
+
}
|
|
130
|
+
lumCache.set(key, L);
|
|
131
|
+
return L;
|
|
132
|
+
}
|
|
99
133
|
export function onColor(hex) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; return L > 0.62 ? '#0b1526' : '#ffffff'; }
|
|
100
134
|
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
101
135
|
/** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
|
|
@@ -107,67 +141,31 @@ export function cutWords(text, limit) {
|
|
|
107
141
|
const sp = cut.lastIndexOf(' ');
|
|
108
142
|
return (sp > limit * 0.5 ? cut.slice(0, sp) : t.slice(0, limit)).replace(/[\s,·:;\-—]+$/, '');
|
|
109
143
|
}
|
|
110
|
-
/** 글자 폭
|
|
111
|
-
export const textW = (t, fs
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
words.push(raw[i]);
|
|
126
|
-
}
|
|
127
|
-
if (!words.length)
|
|
128
|
-
return [];
|
|
129
|
-
const W = (ws) => textW(ws.join(' '), fs);
|
|
130
|
-
if (words.length === 1 || W(words) <= maxW)
|
|
131
|
-
return [words.join(' ')];
|
|
132
|
-
const orphan = (ws) => ws.length === 1 && wlen(ws[0].replace(' ', '')) <= 4;
|
|
133
|
-
let best = null;
|
|
134
|
-
for (let i = 1; i < words.length; i++) {
|
|
135
|
-
const a = words.slice(0, i), b = words.slice(i);
|
|
136
|
-
const wa = W(a), wb = W(b);
|
|
137
|
-
if (wa > maxW || wb > maxW)
|
|
138
|
-
continue;
|
|
139
|
-
let cost = Math.abs(wa - wb) / maxW;
|
|
140
|
-
if (/[,,、.!?!?:·—-]$/.test(a[a.length - 1]))
|
|
141
|
-
cost -= 0.5;
|
|
142
|
-
if (orphan(b) || orphan(a))
|
|
143
|
-
cost += 1;
|
|
144
|
-
if (!best || cost < best.cost)
|
|
145
|
-
best = { lines: [a.join(' '), b.join(' ')], cost };
|
|
144
|
+
/** 글자 폭 — 실제 글꼴 글리프 폭(linebreak.measureText · 굵기 700 기준 · 글꼴 미등록이면 어림). 가로 포스터 세로 예산·알약 폭 계산에 쓴다 */
|
|
145
|
+
export const textW = (t, fs, weight = 700) => measureText(t, fs, { weight });
|
|
146
|
+
/** 줄나눔이 계산된 글줄 — 줄마다 nowrap 행으로 그려 satori 가 다른 자리에서 접을 수 없게 한다(한 줄이어도 nowrap). 어절 하나가 줄보다 길어 어떤 조합도 폭 안에 못 들어가면 가장 넓은 줄이 들어가도록 글자를 줄인다(최소 0.7배 · 그 아래는 글자 단위 줄바꿈). */
|
|
147
|
+
export function linesEl(text, style, maxW, align = 'left', maxLines = 3) {
|
|
148
|
+
let fs = Number(style.fontSize || 16);
|
|
149
|
+
const weight = Number(style.fontWeight || 800);
|
|
150
|
+
const letterSpacing = Number(style.letterSpacing || 0);
|
|
151
|
+
let lines = breakLines(text, fs, maxW, { maxLines, weight, letterSpacing });
|
|
152
|
+
if (!lines.length)
|
|
153
|
+
return h('div', {}, '');
|
|
154
|
+
// 줄 수가 한도를 넘으면(긴 헤드라인) 어절을 버리지 않고 글자를 조금씩 줄여 한도 안으로(최소 0.6배)
|
|
155
|
+
const fs0 = fs;
|
|
156
|
+
while (lines.length > maxLines && fs > fs0 * 0.6) {
|
|
157
|
+
fs = Math.floor(fs * 0.92);
|
|
158
|
+
lines = breakLines(text, fs, maxW, { maxLines, weight, letterSpacing });
|
|
146
159
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (let i = words.length - 1; i >= 0; i--) {
|
|
153
|
-
const next = [words[i], ...cur];
|
|
154
|
-
if (cur.length && W(next) > maxW) {
|
|
155
|
-
lines.unshift(cur);
|
|
156
|
-
cur = [words[i]];
|
|
157
|
-
}
|
|
160
|
+
const widest = Math.max(...lines.map((l) => measureText(l, fs, { weight, letterSpacing })));
|
|
161
|
+
if (widest > maxW) {
|
|
162
|
+
const shrunk = Math.floor(fs * maxW / widest);
|
|
163
|
+
if (shrunk >= fs * 0.7)
|
|
164
|
+
fs = shrunk;
|
|
158
165
|
else
|
|
159
|
-
|
|
166
|
+
return wordsEl(text, style, align); // 그래도 안 들어가면(띄어쓰기 없는 긴 어절) 렌더러 줄바꿈에 맡긴다
|
|
160
167
|
}
|
|
161
|
-
|
|
162
|
-
lines.unshift(cur);
|
|
163
|
-
return lines.slice(0, maxLines).map((l) => l.join(' '));
|
|
164
|
-
}
|
|
165
|
-
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
166
|
-
export function linesEl(text, style, maxW, align = 'left') {
|
|
167
|
-
const lines = breakLines(text, Number(style.fontSize || 16), maxW);
|
|
168
|
-
if (lines.length <= 1)
|
|
169
|
-
return wordsEl(text, style, align);
|
|
170
|
-
return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
|
|
168
|
+
return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style, fontSize: fs }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
|
|
171
169
|
}
|
|
172
170
|
export function wordsEl(text, style, align = 'left') {
|
|
173
171
|
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
@@ -181,7 +179,13 @@ function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.len
|
|
|
181
179
|
/** 한 규격의 트리 */
|
|
182
180
|
export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
183
181
|
const { w, h: hh } = size;
|
|
184
|
-
|
|
182
|
+
// 🔴 글자색은 콘셉트 테마가 아니라 실제 배경 밝기로 — 밝은 테마인데 AI 배경이 중간~어두운 톤이면 검은 글자가 배경에 죽는다(2026-09-18 사장님 「배경도 어두운데 텍스트도 어두우니」) → 밝기 0.5 미만이면 흰 글자+어두운 막, 막의 세기도 배경이 중간 톤일수록 강하게
|
|
183
|
+
const L = bg ? bgLuminance(bg) : null;
|
|
184
|
+
const dark = L === null ? concept.theme === 'dark' : L < 0.5;
|
|
185
|
+
const Leff = L ?? (dark ? 0.2 : 0.9);
|
|
186
|
+
// 막 세기(위쪽 · 헤드라인 자리): 어두운 처리는 배경이 밝아질수록(0.25→0.5) 0.15→0.45 · 밝은 처리는 배경이 어두워질수록(0.85→0.5) 0.10→0.55
|
|
187
|
+
const scrimTop = dark ? Math.min(0.5, 0.15 + Math.max(0, Leff - 0.25) * 1.3) : Math.min(0.55, 0.10 + Math.max(0, 0.85 - Leff) * 1.3);
|
|
188
|
+
const scrimBottom = dark ? Math.max(0.75, scrimTop + 0.3) : Math.max(0.55, scrimTop + 0.25);
|
|
185
189
|
const primary = brief.palette.primary;
|
|
186
190
|
const base = Math.min(w, hh);
|
|
187
191
|
const banner = size.kind === 'banner';
|
|
@@ -253,7 +257,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
253
257
|
const children = [];
|
|
254
258
|
if (bg)
|
|
255
259
|
children.push(h('img', { position: 'absolute', top: 0, left: 0, width: w, height: hh, objectFit: 'cover', filter: dark ? 'brightness(.66)' : 'brightness(1.02)' }, undefined, { src: bg, width: w, height: hh }));
|
|
256
|
-
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: dark ? `linear-gradient(180deg, ${hexA('#0b1526',
|
|
260
|
+
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: dark ? `linear-gradient(180deg, ${hexA('#0b1526', scrimTop)} 0%, ${hexA('#0b1526', scrimBottom)} 100%)` : `linear-gradient(180deg, rgba(255,255,255,${scrimTop.toFixed(2)}) 0%, rgba(255,255,255,${scrimBottom.toFixed(2)}) 100%)` }));
|
|
257
261
|
const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
|
|
258
262
|
logo ? h('img', { height: short ? Math.round(hh * 0.5) : Math.round(headFs * 0.9), borderRadius: 6 }, undefined, { src: logo }) : h('div', { fontSize: Math.round(headFs * 0.42), fontWeight: 800, color: fg, letterSpacing: -0.5 }, brief.company),
|
|
259
263
|
]);
|
|
@@ -271,7 +275,8 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
271
275
|
h('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, [brandRow, domain && !banner ? h('div', { fontSize: Math.round(headFs * 0.3), fontWeight: 500, color: sub }, domain) : h('div', {}, '')]),
|
|
272
276
|
h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * (banner ? 0.35 : 0.5)), maxWidth: isWide ? Math.round(w * 0.72) : w - pad * 2 }, [
|
|
273
277
|
linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }, isWide ? Math.round(w * 0.72) : w - pad * 2),
|
|
274
|
-
|
|
278
|
+
// 본문도 같은 규칙으로 균형 있게(「… 분들께 / 추천해요.」 한 어절 고아 방지 · 최대 4줄 · 배너는 폭이 좁아 렌더러 줄바꿈)
|
|
279
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : banner ? wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }) : linesEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }, isWide ? Math.round(w * 0.72) : w - pad * 2, 'left', 4),
|
|
275
280
|
...(chips ? [chips] : []),
|
|
276
281
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
277
282
|
]),
|
|
@@ -334,7 +339,7 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
334
339
|
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
335
340
|
const pill = o.kind === 'cta'
|
|
336
341
|
? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: onColor(o.primary), backgroundColor: o.primary, borderRadius: 999, padding: `${Math.round(fs1 * 0.55)}px ${Math.round(fs1 * 1.3)}px`, boxShadow: `0 10px 30px ${hexA(o.primary, 0.45)}` }, text)
|
|
337
|
-
: h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [
|
|
342
|
+
: h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [linesEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, Math.round(w * 0.86) - Math.round(fs1 * 0.6) * 2, 'center', 2)]);
|
|
338
343
|
// 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
|
|
339
344
|
const hook = o.kind === 'hook';
|
|
340
345
|
const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [linesEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, Math.round(w * 0.86) - Math.round(base * 0.05) * 2, 'center')]);
|
package/dist/core/plan.js
CHANGED
|
@@ -36,11 +36,11 @@ export function computePlan(i) {
|
|
|
36
36
|
const explain = [
|
|
37
37
|
`한 달 ${fmtKrw(monthly)} 을 하루 약 ${fmtKrw(daily)} 으로 나눠 씁니다.`,
|
|
38
38
|
...out.map((m) => `· ${MEDIUM_LABEL[m.medium]} 하루 ${fmtKrw(m.dailyBudgetKrw)}${m.currency !== 'KRW' ? ` (계정 통화 ${m.currency} → ${fmtMinor(m.dailyBudgetMinor, m.currency)})` : ''}${m.note ? ` — ${m.note}` : ''}`),
|
|
39
|
-
`보여 줄 사람: ${i.markets.map((m) => `${m.country}(${m.language})`).join(' · ')} · 18세 이상 · 매체가
|
|
39
|
+
`보여 줄 사람: ${i.markets.map((m) => `${m.country}(${m.language})`).join(' · ')} · 18세 이상 · 관심사는 매체가 자동으로 찾아요.`,
|
|
40
40
|
`광고 소재는 ${concepts.length}가지 방향(${concepts.join(', ')})으로 나눠서 어느 쪽이 잘 되는지 비교합니다${i.conceptKeys.length > concepts.length ? ` — 예산이 작아 ${i.conceptKeys.length - concepts.length}개는 뒤로 미뤘어요` : ''}.`,
|
|
41
41
|
`목표: ${goalWord}${i.hasTracking ? ' — 전환이 50건쯤 쌓이면 전환 최적화로 자동 전환' : ' — 추적 태그가 없어 방문 기준으로만 최적화돼요(추적 코드를 사이트에 넣어 주세요)'}.`,
|
|
42
42
|
...(i.markets.some((m) => m.language.split('-')[0] !== (i.briefLanguage || 'ko').split('-')[0]) ? [`⚠ 카피 언어가 ${i.briefLanguage} 인데 시장에 다른 언어(${[...new Set(i.markets.map((m) => m.language))].join(', ')})가 있어요 — 언어별 소재를 따로 만들어 주세요.`] : []),
|
|
43
|
-
|
|
43
|
+
`광고는 전부 「대기 상태(아직 안 보임)」로 만들어지고, 광고 시작 전엔 1원도 나가지 않아요.${i.endDate ? ` 종료일 ${i.endDate}.` : ''}`,
|
|
44
44
|
].join('\n');
|
|
45
45
|
return { monthlyKrw: monthly, dailyKrw: daily, media: out, concepts, markets: i.markets, goal: i.goal, endDate: i.endDate, explain, createdAt: new Date().toISOString() };
|
|
46
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adyou",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
4
4
|
"description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"@resvg/resvg-js": "^2.6.2",
|
|
30
30
|
"commander": "^15.0.0",
|
|
31
31
|
"satori": "^0.33.4",
|
|
32
|
-
"zod": "^4.4.3"
|
|
32
|
+
"zod": "^4.4.3",
|
|
33
|
+
"@shuding/opentype.js": "1.4.0-beta.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@types/node": "^22",
|