← 로고

Brand · Logo
로고 키트 · 기하 모노그램
코드 렌더 · 같은 결과
원 하나에서 이니셜을 도려내고 열린 줄기를 테두리까지 뻗어 면을 나눈 마크와 워드마크·파비콘·OG 를 한 벌로 만드세요.
마크·워드마크·락업 2종·어두운판·단색판·파비콘(16/32/48·ico)·앱 아이콘 180/1024·OG 1200×630·브랜드 시트

예시 · Northline (#1E3A8A · #F5F3EE) — 예시는 가상 브랜드로 레시피를 실제로 실행한 결과입니다. 같은 입력이면 같은 파일이 나옵니다.
실제 크기
- favicon 16 · 32 · 48 (1:1)






app icon 1024 · 180
og 1200×630
레시피 보기42,162자
# 먼저
- 괄호로 적힌 브랜드 정보(브랜드명·확인된 항목·말투·브랜드 색)는 이 대화나 기억에 있는 내 브랜드 정보로 채운다. 없으면 만들기 전에 빠진 것만 한 번에 물어본다. 브랜드 색을 모르면 본문의 기본색을, 기본색이 없으면 주제에 어울리는 차분한 색을 쓴다.
- 확인된 항목은 지어내지 않는다. 내가 알려 주거나 기억에 있는 사실만 쓴다.
# Duchamp 코드 레시피 · 기하 모노그램 로고 (원·스쿼클 음각 + 면 분할)
이 문서는 코딩 에이전트(Codex, Claude Code 등)에게 그대로 붙여 넣는 작업 지시서다. 이미지 모델을 쓰지 않는다. 글자는 OFL 폰트의 윤곽선을 opentype.js 로 읽어 **패스**로 바꾸고, 도형·컷은 다각형 불리언으로 만들고, SVG 를 직접 쓴 뒤 resvg 로 PNG 를 만든다. 브라우저도 필요 없다. 결과는 벡터 로고 키트 한 벌(마크·워드마크·락업 2종·어두운판·단색판·파비콘·앱 아이콘·OG·브랜드 시트)이다.
마크 = 원(또는 스쿼클) 하나에서 이니셜을 음각으로 도려낸 것. 이니셜의 세로 줄기 중 끝이 열린 줄기는 테두리까지 뻗어 도형을 면으로 나눈다(N → 왼쪽 아래·오른쪽 위로 뻗어 두 면, H → 네 끝이 모두 뻗어 네 면). 글자 크기는 글자와 테두리 사이 벽이 줄기 굵기의 1.15배가 되도록 코드가 잰다. 워드마크는 Geist 600, 자간 −2.2%. 참고 감각: Linear·Vercel·Raycast 의 절제.
원본 템플릿: https://duchamp.app/readymade/logos/logo-monogram-geometric
## 0. 채울 것 (사용자)
```yaml
브랜드명: (대화·기억에 있는 내 브랜드 이름) # 워드마크에 그대로. 한 글자도 바꾸지 않는다(대소문자·띄어쓰기 포함). 20자 이내
브랜드 색: (내 브랜드 색 — 주 색, 바탕 색 HEX 2개) # "주 색, 바탕 색" HEX 2개. 비우면 #141414, #F7F7F5
주제: (내 브랜드에 맞는 주제 하나) # 제품이 무엇인지 한 문장(90자 이내). OG 이미지의 한 줄로 그대로 쓴다
넣을 글자: (마크에 쓸 글자 한 자 — 따로 정한 글자가 없으면 비워 둔다) # 마크에 쓸 글자 1자. 비우면 브랜드명 첫 글자
변형: 자동 # 자동 | A | B | C — A = 원 · B = 스쿼클 · C = 스쿼클 크롭(글자가 아래 가장자리를 뚫고 나감). 자동 = 둥근 글자(O C G Q S D U 등)는 스쿼클, 나머지는 원
한글 이름: # (선택) 적으면 wordmark-ko.svg 도 만든다. 기본 키트·시트에는 넣지 않는다
```
## 1. 에이전트 규칙
- 위 빈칸이 `[ ]` 그대로 비어 있으면 작업하지 말고 무엇을 채워야 하는지 먼저 물어본다. 채워져 있으면 묻지 말고 끝까지 진행한다.
- 브랜드명·주제·넣을 글자는 사용자가 준 그대로 쓴다. 고치거나 줄이거나 번역하지 않는다. 길이 한도를 넘으면 맞춰 달라고 한다.
- 아래 코드 블록은 **한 글자도 고치지 말고** 그대로 파일로 저장한다. 바꾸는 것은 `props.json` 하나뿐이다. 크기·굵기·자간·여백·마크 비율·컷 위치는 코드가 재서 정한다. 감으로 바꾸지 않는다. 코드가 실패하면 오류 메시지를 그대로 보고하고, 고쳐야 했다면 무엇을 왜 고쳤는지 보고에 쓴다.
- 이미지 생성 모델·유료 API 를 쓰지 않는다. 비밀 값·.env 파일은 읽지 않는다.
- 필요한 것: Node 18 이상, 인터넷(`npm install` 때만 — 폰트도 npm 패키지로 받으므로 렌더는 인터넷 없이 된다). 설치는 이 폴더 안에서만 한다.
- `npm install` 이 `EPERM`(홈의 npm 캐시 권한)으로 실패하면 `npm install --no-audit --no-fund --cache .npm-cache` 로 이 폴더 안 캐시를 쓴다. 샌드박스 때문에 네트워크가 막히면 우회하지 말고 사용자에게 네트워크 허용을 요청한다.
- 이 작업 밖의 일(프로젝트 관리 카드·메모 갱신, 배포, 브라우저 자동화)은 하지 않는다.
## 2. 파일 구조 (현재 폴더에 만든다)
```
package.json logo-render.mjs logo-common.mjs style.mjs props.json
out/svg/ mark · wordmark · lockup-horizontal · lockup-stacked (.svg, -dark.svg) · mark-mono.svg (· wordmark-ko)
out/png/ favicon-16 · favicon-32 · favicon-48 · apple-touch-icon-180 · app-icon-1024 · og-1200x630
out/favicon.ico out/brand-sheet.png (1600×1000) out/check.json
```
## 3. props.json (빈칸 → 값)
```json
{
"brand": "<브랜드명 그대로>",
"colors": { "primary": "<주 색 HEX>", "background": "<바탕 색 HEX>" },
"topic": "<주제 그대로>",
"initial": "<넣을 글자, 비우면 \"\">",
"variant": "auto",
"brandKo": "<한글 이름, 없으면 \"\">"
}
```
- 브랜드 색을 비웠으면 `"colors": {}` 로 둔다(스타일 기본값). `변형: 자동` 은 `"auto"`, A·B·C 는 그 글자.
## 4. 코드 (그대로 저장)
`package.json`
```json
{
"name": "duchamp-logo-recipe",
"private": true,
"type": "module",
"dependencies": {
"@fontsource/geist": "5.3.0",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans-kr": "5.3.0",
"@resvg/resvg-js": "2.6.2",
"opentype.js": "2.0.0",
"polygon-clipping": "0.15.7"
}
}
```
`logo-render.mjs`
```js
// node logo-render.mjs — props.json → out/svg·png·favicon.ico·brand-sheet.png·check.json
// 종료 코드: 0 성공 · 1 입력/렌더 오류 · 2 자동 검사 실패
import fs from 'node:fs';
import * as C from './logo-common.mjs';
import style from './style.mjs';
const t0 = Date.now();
const fail = (msg) => { console.error('오류: ' + msg); process.exit(1); };
let P;
try { P = JSON.parse(fs.readFileSync('props.json', 'utf8')); } catch (e) { fail('props.json 을 읽지 못함 — ' + e.message); }
// ── 입력 ──
const brand = String(P.brand ?? '').normalize('NFC');
if (!brand.trim() || /^\[.*\]$/.test(brand.trim())) fail('brand(브랜드명)가 비어 있음');
if (brand !== brand.trim() || /\s{2,}/.test(brand)) fail('brand 앞뒤 공백·연속 공백은 지워 달라고 사용자에게 물어볼 것');
if (Array.from(brand).length > 20) fail('brand 가 20자를 넘음 — 워드마크는 20자 이내');
const topic = String(P.topic ?? '').trim();
if (!topic) fail('topic(주제)이 비어 있음 — OG 이미지 한 줄에 그대로 쓴다');
if (Array.from(topic).length > 90) fail('topic 이 90자를 넘음 — OG 한 줄은 90자 이내');
const initial = Array.from(String(P.initial ?? '').trim() || brand.replace(/\s/g, ''))[0];
if (Array.from(String(P.initial ?? '').trim()).length > 1) fail('initial(넣을 글자)은 한 글자');
const variant = String(P.variant ?? 'auto').trim().toUpperCase() === 'AUTO' ? 'auto' : String(P.variant).trim().toUpperCase();
if (!['auto', 'A', 'B', 'C'].includes(variant)) fail('variant 는 auto | A | B | C');
const brandKo = String(P.brandKo ?? '').trim().normalize('NFC');
// ── 색 ──
const warnings = [];
let primary = C.hex(P.colors?.primary) ?? style.defaults.primary;
let paper = C.hex(P.colors?.background) ?? style.defaults.background;
if ((P.colors?.primary && !C.hex(P.colors.primary)) || (P.colors?.background && !C.hex(P.colors.background))) fail('colors 는 #RRGGBB 형식');
const ratio = +C.contrast(primary, paper).toFixed(2);
let monoKit = false;
if (ratio < 3) { warnings.push(`주 색 ${primary} 과 바탕 색 ${paper} 의 대비 ${ratio}:1 < 3:1 → 단색(#111111/#FFFFFF) 키트로 만듦`); primary = '#111111'; paper = '#FFFFFF'; monoKit = true; }
const darkField = C.lum(primary) < 0.03 ? primary : C.mix(primary, '#000000', 0.84);
const darkInk = C.contrast(paper, darkField) >= 4.5 ? paper : '#FFFFFF';
const LIGHT = { fg: primary, under: paper, field: primary, glyph: paper };
const DARK = { fg: darkInk, under: darkField, field: darkInk, glyph: darkField };
const MONO = { fg: '#000000', under: '#FFFFFF', field: '#000000', glyph: '#FFFFFF' };
// ── 모양 (스타일 코드가 정한다) ──
let L;
try { L = style.build({ brand, initial, variant }); } catch (e) { fail(e.message); }
if (L.warnings) warnings.push(...L.warnings);
const layersOf = (ls, pal, { under = true } = {}) => ls.filter((l) => under || l.role !== 'under').map((l) => ({ mp: l.mp, fill: pal[l.role] }));
// 락업: 마크 높이 = 대문자 높이 × 비율, 마크 세로 중심 = 워드마크 잉크(기준선 위) 중심
const W = L.wordmark; W.mp = C.union(...W.mp.map((p) => [p])); // 겹친 글자·이음 막대가 홀짝 규칙에서 구멍이 되지 않게 하나로
const wb = C.bbox(W.mp);
const inkTop = Math.min(wb.y0, -W.cap), mid = inkTop / 2;
function lockup(kind) {
const mk = L.mark.layers, box = L.mark.box;
if (kind === 'h') {
const mh = W.cap * L.lockup.markToCap, s = mh / box, gap = mh * L.lockup.gap;
const mx = 0, my = mid - mh / 2;
const wx = mx + mh + gap - wb.x0;
const layers = [...mk.map((l) => ({ role: l.role, mp: C.move(l.mp, mx, my, s) })), { role: 'fg', mp: C.move(W.mp, wx, 0) }];
return frame(layers);
}
const mh = W.cap * L.lockup.stackMarkToCap, s = mh / box, gap = mh * L.lockup.stackGap;
const cx = (wb.x0 + wb.x1) / 2, my = inkTop - gap - mh;
const wordV = L.wordmarkPlain ? C.union(...L.wordmarkPlain.map((p) => [p])) : W.mp; // 스타일이 세로 락업용 워드마크를 따로 주면 그것
const layers = [...mk.map((l) => ({ role: l.role, mp: C.move(l.mp, cx - mh / 2, my, s) })), { role: 'fg', mp: wordV }];
return frame(layers);
}
function frame(layers) { // 잉크 상자에 딱 맞춘다(여백은 쓰는 쪽에서)
const all = layers.flatMap((l) => l.mp), b = C.bbox(all);
return { w: b.w, h: b.h, layers: layers.map((l) => ({ role: l.role, mp: C.move(l.mp, -b.x0, -b.y0) })) };
}
// 스타일이 가로 락업을 직접 주면(통합형: 마크가 워드마크 첫 글자 자리) 그것을 쓴다
const H = L.lockupH ? frame(L.lockupH(W)) : lockup('h'), V = lockup('v');
const WM = frame([{ role: 'fg', mp: W.mp }]);
const MK = { w: L.mark.box, h: L.mark.box, layers: L.mark.layers };
// ── 파일 ──
fs.rmSync('out', { recursive: true, force: true });
fs.mkdirSync('out/svg', { recursive: true }); fs.mkdirSync('out/png', { recursive: true });
const svgs = {};
const writeSvg = (name, o, pal, opt) => { const s = C.svgDoc(o.w, o.h, layersOf(o.layers, pal, opt), { title: brand }); fs.writeFileSync(`out/svg/${name}.svg`, s); svgs[name] = s; };
const P0 = monoKit ? MONO : LIGHT;
for (const [name, o] of [['mark', MK], ['wordmark', WM], ['lockup-horizontal', H], ['lockup-stacked', V]]) {
writeSvg(name, o, P0, { under: false }); writeSvg(name + '-dark', o, DARK, { under: false });
}
writeSvg('mark-mono', MK, MONO, { under: false });
// 선택: 한글 워드마크(IBM Plex Sans KR 700, 같은 자간 규칙) — 시트·락업에는 넣지 않는다
if (brandKo) {
const ko = C.setText([C.loadFace('ibm-plex-sans-kr', 700)], brandKo, 1000, -0.02);
const o = frame([{ role: 'fg', mp: C.union(...ko.glyphs.flatMap((g) => g.mp).map((p) => [p])) }]);
writeSvg('wordmark-ko', o, P0, { under: false }); writeSvg('wordmark-ko-dark', o, DARK, { under: false });
}
// 아이콘: 작은 크기는 스타일이 픽셀에 맞춘 모양(drawIcon)을 준다
const icon = (px, full, pal = P0) => C.svgDoc(1000, 1000, layersOf(L.drawIcon(px, full), pal));
const pngs = {};
const writePng = (name, svg, w, h) => { const r = C.png(svg, w, h); const buf = r.asPng(); fs.writeFileSync(`out/png/${name}.png`, buf); pngs[name] = { buf, r, w, h: h ?? r.height }; return r; };
for (const s of [16, 32, 48]) writePng(`favicon-${s}`, icon(s, false), s, s);
writePng('apple-touch-icon-180', icon(180, true), 180, 180);
writePng('app-icon-1024', icon(1024, true), 1024, 1024);
fs.writeFileSync('out/favicon.ico', C.ico([16, 32, 48].map((s) => ({ size: s, buf: pngs[`favicon-${s}`].buf }))));
// OG 1200×630: 락업 + 주제 한 줄(사용자 문장 그대로)
function ogSvg() {
const lineFaces = /[ㄱ-힣]/.test(topic) ? [C.loadFace('ibm-plex-sans-kr', 500)] : L.lineFaces;
const maxW = 1200 - 2 * 120;
let size = 44, lines;
const shape = (t, s) => C.setText(lineFaces, t, s, 0);
for (; size >= 30; size -= 2) { const t = shape(topic, size); if (t.width <= maxW) { lines = [t]; break; } }
if (!lines) { // 두 줄: 단어 경계에서 폭이 가장 고르게
size = 36; const words = topic.split(' ');
let best = null;
for (let s = 36; s >= 26 && !best; s -= 2) for (let i = 1; i < words.length; i++) {
const a = shape(words.slice(0, i).join(' '), s), b = shape(words.slice(i).join(' '), s);
if (a.width <= maxW && b.width <= maxW) { const sc = Math.max(a.width, b.width); if (!best || sc < best.sc) best = { sc, lines: [a, b], s }; }
}
if (!best) throw new Error('topic 이 OG 두 줄에 들어가지 않음 — 더 짧은 문장을 사용자에게 물어볼 것');
lines = best.lines; size = best.s;
}
const sc = Math.min(760 / H.w, 210 / H.h);
const lw = H.w * sc, lhh = H.h * sc, lineH = size * 1.32, gap = Math.max(56, lhh * 0.4);
const blockH = lhh + gap + lineH * (lines.length - 1) + size * 0.72;
const y0 = (630 - blockH) / 2 + 10, x0 = (1200 - lw) / 2; // 광학 중심은 기하 중심보다 조금 아래
const layers = H.layers.map((l) => ({ mp: C.move(l.mp, x0, y0, sc), fill: P0[l.role] }));
lines.forEach((t, i) => { const base = y0 + lhh + gap + size * 0.72 + lineH * i; layers.push({ mp: C.move(t.glyphs.flatMap((g) => g.mp), (1200 - t.width) / 2, base), fill: P0.fg }); });
return { svg: C.svgDoc(1200, 630, layers, { bg: P0.under }), lines: lines.length, size, lockupW: Math.round(lw) };
}
const og = ogSvg();
writePng('og-1200x630', og.svg, 1200, 630);
// ── 자동 검사 ──
const failures = [];
const problems = Object.fromEntries(Object.entries(svgs).map(([k, s]) => [k, C.svgProblems(s)]).filter(([, v]) => v.length));
if (Object.keys(problems).length) failures.push('svg: ' + JSON.stringify(problems));
// 가는 획·틈 (32px): 마크 자체와 32px 파비콘 글자 모두
const m32 = C.thinnest(L.mark.layers.filter((l) => l.role === 'fg').flatMap((l) => l.mp), 1000, 32);
const i32layers = L.drawIcon(32, false);
const i32glyph = i32layers.filter((l) => l.role === 'glyph').flatMap((l) => l.mp);
const i32 = C.thinnest(i32glyph.length ? i32glyph : i32layers.filter((l) => l.role === 'fg').flatMap((l) => l.mp), 1000, 32);
for (const [k, v] of [['mark32', m32], ['favicon32', i32]]) {
if (v.stroke != null && v.stroke < 1.5) failures.push(`${k}: 가장 가는 획 ${v.stroke}px < 1.5px (위치 ${v.strokeAt})`);
if (v.gap != null && v.gap < 1.5) failures.push(`${k}: 가장 좁은 틈 ${v.gap}px < 1.5px (위치 ${v.gapAt})`);
}
// 16px 판별: 마크(바탕 위)와 16px 파비콘의 잉크 비율 15~75%
const mark16r = C.png(C.svgDoc(1000, 1000, layersOf(L.mark.layers, P0, { under: false })), 16, 16);
const mark16 = C.inkStats(mark16r.pixels, 16, 16, P0.fg, P0.under);
const fav16 = C.inkStats(pngs['favicon-16'].r.pixels, 16, 16, P0.field, P0.glyph);
for (const [k, v] of [['mark16', mark16], ['favicon16', fav16]]) if (v.ink < 0.15 || v.ink > 0.75) failures.push(`${k}: 잉크 비율 ${(v.ink * 100).toFixed(1)}% (15~75% 밖 — 뭉개짐/빈 칸)`);
// 파일·크기
const expect = {
'svg/mark.svg': 0, 'svg/mark-dark.svg': 0, 'svg/mark-mono.svg': 0, 'svg/wordmark.svg': 0, 'svg/wordmark-dark.svg': 0,
'svg/lockup-horizontal.svg': 0, 'svg/lockup-horizontal-dark.svg': 0, 'svg/lockup-stacked.svg': 0, 'svg/lockup-stacked-dark.svg': 0,
'png/favicon-16.png': '16x16', 'png/favicon-32.png': '32x32', 'png/favicon-48.png': '48x48', 'png/apple-touch-icon-180.png': '180x180',
'png/app-icon-1024.png': '1024x1024', 'png/og-1200x630.png': '1200x630', 'favicon.ico': 0,
};
const files = {};
for (const [f, size] of Object.entries(expect)) {
const p = 'out/' + f; if (!fs.existsSync(p)) { failures.push('파일 없음: ' + f); continue; }
const buf = fs.readFileSync(p); files[f] = buf.length;
if (size && C.pngSize(buf) !== size) failures.push(`${f}: 크기 ${C.pngSize(buf)} ≠ ${size}`);
}
// 워드마크 글자 = 브랜드명 (공백 뺀 글자 수와 순서)
const drawn = W.chars.join('');
if (drawn !== brand.replace(/ /g, '')) failures.push(`워드마크 글자 "${drawn}" ≠ 브랜드명`);
// ── 브랜드 시트 1600×1000 ──
const sheet = brandSheet();
fs.writeFileSync('out/brand-sheet.png', C.png(sheet, 1600, 1000).asPng());
files['brand-sheet.png'] = fs.statSync('out/brand-sheet.png').size;
const check = {
ok: failures.length === 0, failures, warnings,
style: style.slug, variant: L.variant, variantNote: L.note, detail: L.detail ?? null,
brand, initial, topic,
colors: { primary, background: paper, contrast: ratio, monoKit, dark: { field: darkField, ink: darkInk } },
thin32: { mark: m32, favicon: i32, min: 1.5 },
ink16: { mark: mark16, favicon: fav16, range: [0.15, 0.75] },
og: { lines: og.lines, size: og.size },
files, ms: Date.now() - t0,
};
fs.writeFileSync('out/check.json', JSON.stringify(check, null, 2) + '\n');
console.log(JSON.stringify({ ok: check.ok, failures, warnings, variant: L.variant, note: L.note, thin32: { mark: m32.stroke + '/' + m32.gap, favicon: i32.stroke + '/' + i32.gap }, ink16: [mark16.ink, fav16.ink], ms: check.ms }));
process.exit(check.ok ? 0 : 2);
function brandSheet() {
const label = C.loadFace('ibm-plex-mono', 500);
const parts = [];
const add = (layers) => parts.push(...layers.filter((l) => l.mp.length).map((l) => `<path fill="${l.fill}" fill-rule="evenodd" d="${C.pathD(l.mp)}"/>`));
const panel = (x, y, w, h, fill, stroke) => parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="14" fill="${fill}"${stroke ? ` stroke="${stroke}" stroke-width="1"` : ''}/>`);
const text = (s, x, y, size, fill, anchor = 'start') => {
const t = C.setText([label], s, size, 0.02); const dx = anchor === 'end' ? -t.width : anchor === 'middle' ? -t.width / 2 : 0;
add([{ mp: C.move(t.glyphs.flatMap((g) => g.mp), x + dx, y), fill }]);
};
const place = (o, pal, x, y, w, h, maxW, maxH, opt) => { // 상자 가운데에 맞춰 넣기
const s = Math.min(maxW / o.w, maxH / o.h); const ox = x + (w - o.w * s) / 2, oy = y + (h - o.h * s) / 2;
add(layersOf(o.layers, pal, opt).map((l) => ({ ...l, mp: C.move(l.mp, ox, oy, s) })));
};
const img = (name, x, y, w, h, rx = 0) => {
const id = 'c' + parts.length;
if (rx) parts.push(`<clipPath id="${id}"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}"/></clipPath>`);
parts.push(`<image x="${x}" y="${y}" width="${w}" height="${h}"${rx ? ` clip-path="url(#${id})"` : ''} href="data:image/png;base64,${pngs[name].buf.toString('base64')}"/>`);
};
const page = '#F2F2F0', ink2 = '#5F5F5C', line = '#DADAD6';
const legible = (fg, bg) => { let t = 0.5; while (t > 0 && C.contrast(C.mix(fg, bg, t), bg) < 4.6) t -= 0.05; return C.mix(fg, bg, Math.max(0, t)); };
const lab = (s, x, y, dark) => text(s, x, y, 13, dark ? legible(darkInk, darkField) : legible(P0.fg, P0.under));
// 1줄: 가로 락업 · 세로 락업(어두운 판)
panel(40, 40, 1000, 380, P0.under, line); place(H, P0, 40, 40, 1000, 380, 700, 170, { under: false }); lab('lockup-horizontal', 64, 398);
panel(1060, 40, 500, 380, darkField); place(V, DARK, 1060, 30, 500, 380, 300, 250, { under: false }); lab('lockup-stacked-dark', 1084, 398, true);
// 2줄: 워드마크 · 워드마크(어두운) · 마크 · 단색 마크
panel(40, 440, 620, 240, P0.under, line); place(WM, P0, 40, 430, 620, 240, 440, 92, { under: false }); lab('wordmark', 64, 658);
panel(680, 440, 360, 240, darkField); place(WM, DARK, 680, 430, 360, 240, 260, 60, { under: false }); lab('wordmark-dark', 704, 658, true);
panel(1060, 440, 240, 240, P0.under, line); place(MK, P0, 1060, 430, 240, 240, 136, 136, { under: false }); lab('mark', 1084, 658);
panel(1320, 440, 240, 240, '#FFFFFF', line); place(MK, MONO, 1320, 430, 240, 240, 136, 136, { under: false }); lab('mark-mono', 1344, 658);
// 3줄: 실제 크기(16·32·48 밝은 탭·어두운 탭, 180) · OG · 앱 아이콘 · 색
panel(40, 700, 640, 280, '#FFFFFF', line); lab('actual size 1:1', 64, 728);
[[748, '#F1F3F4', ink2], [828, '#202124', '#B4B9BE']].forEach(([ty, bg, fc]) => {
parts.push(`<rect x="64" y="${ty}" width="340" height="64" rx="10" fill="${bg}"/>`);
let x = 84; for (const s of [16, 32, 48]) { img(`favicon-${s}`, x, ty + Math.round((64 - s) / 2), s, s); x += s + 28; }
text('16 · 32 · 48', 388, ty + 37, 12, fc, 'end');
});
text('favicon.ico', 64, 950, 12, ink2);
img('apple-touch-icon-180', 452, 740, 180, 180, 40); text('apple-touch 180', 452, 950, 12, ink2);
panel(700, 700, 500, 280, '#FFFFFF', line); img('og-1200x630', 720, 718, 460, 242); lab('og 1200×630', 724, 972);
panel(1220, 700, 340, 280, '#FFFFFF', line); img('app-icon-1024', 1244, 724, 128, 128, 28); lab('app 1024', 1244, 880);
const sw = [[primary, 'primary'], [paper, 'background'], [darkField, 'dark']];
sw.forEach(([c, n], i) => { parts.push(`<rect x="1396" y="${724 + i * 56}" width="36" height="36" rx="8" fill="${c}" stroke="${line}"/>`); text(c, 1444, 740 + i * 56, 14, '#2A2A28'); text(n, 1444, 756 + i * 56, 11, ink2); });
return `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><rect width="1600" height="1000" fill="${page}"/>${parts.join('')}</svg>`;
}
```
`logo-common.mjs`
```js
// Duchamp 로고 레시피 공용 코드 — 글자 윤곽선(opentype.js) → 다각형 불리언(polygon-clipping) → SVG → PNG(resvg).
// 브라우저를 쓰지 않는다. 같은 입력이면 같은 파일이 나온다(난수·시각 없음).
import fs from 'node:fs';
import path from 'node:path';
import opentype from 'opentype.js';
import pc from 'polygon-clipping';
import { Resvg } from '@resvg/resvg-js';
// ───────── 폰트 ─────────
const NM = path.resolve('node_modules');
function parseFile(file) {
const b = fs.readFileSync(file);
return opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
}
// @fontsource/<pkg>/files/<pkg>-<subset>-<weight>-normal.woff (woff2 는 opentype.js 가 못 읽는다)
export function loadFace(pkg, weight) {
const dir = path.join(NM, '@fontsource', pkg, 'files');
if (!fs.existsSync(dir)) throw new Error(`폰트 패키지 없음: @fontsource/${pkg} — npm install 을 먼저 실행`);
const files = fs.readdirSync(dir).filter((f) => f.endsWith(`-${weight}-normal.woff`)).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
if (!files.length) throw new Error(`폰트 파일 없음: @fontsource/${pkg} ${weight}`);
return { pkg, weight, files: files.map((f) => path.join(dir, f)), fonts: [] };
}
function rank(f) { return f.includes('-latin-') ? 0 : f.includes('-latin-ext-') ? 1 : 2; }
// 글자 하나에 맞는 (폰트, 글리프). faces 순서대로 찾고, 없으면 null.
const parsed = new Map();
function fontAt(file) { if (!parsed.has(file)) parsed.set(file, parseFile(file)); return parsed.get(file); }
export function glyphFor(faces, ch) {
const cp = ch.codePointAt(0);
for (const face of faces) for (const file of face.files) {
const font = fontAt(file);
const gi = font.charToGlyphIndex(ch);
if (gi > 0 && font.encoding.cmap?.glyphIndexMap?.[cp] !== undefined) return { font, glyph: font.glyphs.get(gi), face };
}
return null;
}
// ───────── 다각형 ─────────
// MultiPolygon = [ polygon = [ outerRing, ...holeRings ], ... ], ring = [[x,y], ...] (y 아래 방향)
const ringArea = (r) => { let a = 0; for (let i = 0, n = r.length; i < n; i++) { const p = r[i], q = r[(i + 1) % n]; a += p[0] * q[1] - q[0] * p[1]; } return a / 2; };
export const mpArea = (mp) => mp.reduce((s, poly) => s + poly.reduce((t, r, i) => t + (i ? -1 : 1) * Math.abs(ringArea(r)), 0), 0);
export const union = (...g) => { const v = g.filter((x) => x && x.length); return v.length ? pc.union(...v) : []; };
export const diff = (a, ...b) => { const v = b.filter((x) => x && x.length); return a.length && v.length ? pc.difference(a, ...v) : a; };
export const inter = (a, b) => (a.length && b.length ? pc.intersection(a, b) : []);
export function mapMP(mp, f) { return mp.map((poly) => poly.map((r) => r.map(([x, y]) => f(x, y)))); }
export const move = (mp, dx, dy, s = 1) => mapMP(mp, (x, y) => [x * s + dx, y * s + dy]);
export function bbox(mp) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const poly of mp) for (const [x, y] of poly[0]) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
return { x0, y0, x1, y1, w: x1 - x0, h: y1 - y0 };
}
export const rect = (x, y, w, h) => [[[[x, y], [x + w, y], [x + w, y + h], [x, y + h]]]];
export function circle(cx, cy, r, n = 360) { const ring = []; for (let i = 0; i < n; i++) { const t = (i / n) * Math.PI * 2; ring.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); } return [[ring]]; }
// 초타원(스쿼클): |x|^e + |y|^e = r^e
export function squircle(cx, cy, r, e = 5, n = 480) {
const ring = [];
for (let i = 0; i < n; i++) { const t = (i / n) * Math.PI * 2, c = Math.cos(t), s = Math.sin(t); ring.push([cx + r * Math.sign(c) * Math.abs(c) ** (2 / e), cy + r * Math.sign(s) * Math.abs(s) ** (2 / e)]); }
return [[ring]];
}
export function roundRect(x, y, w, h, rad, seg = 40) {
const ring = [], cs = [[x + w - rad, y + rad, -90], [x + w - rad, y + h - rad, 0], [x + rad, y + h - rad, 90], [x + rad, y + rad, 180]];
for (const [cx, cy, a0] of cs) for (let i = 0; i <= seg; i++) { const t = ((a0 + (90 * i) / seg) * Math.PI) / 180; ring.push([cx + rad * Math.cos(t), cy + rad * Math.sin(t)]); }
return [[ring]];
}
// 글리프 윤곽선 → MultiPolygon (곡선은 촘촘히 펴고, 0이 아닌 감김 규칙 = 바깥 합집합 − 안쪽 합집합)
export function glyphMP(glyph, x, y, size) {
const cmds = glyph.getPath(x, y, size).commands;
const rings = []; let ring = null, cx = 0, cy = 0;
const step = size / 220; // 1000px 글자에서 약 4.5px 마디
const push = (px, py) => { ring.push([px, py]); cx = px; cy = py; };
for (const c of cmds) {
if (c.type === 'M') { if (ring && ring.length > 2) rings.push(ring); ring = []; push(c.x, c.y); }
else if (c.type === 'L') push(c.x, c.y);
else if (c.type === 'Q') {
const n = Math.min(64, Math.max(4, Math.ceil((Math.hypot(c.x1 - cx, c.y1 - cy) + Math.hypot(c.x - c.x1, c.y - c.y1)) / step)));
const x0 = cx, y0 = cy;
for (let i = 1; i <= n; i++) { const t = i / n, u = 1 - t; push(u * u * x0 + 2 * u * t * c.x1 + t * t * c.x, u * u * y0 + 2 * u * t * c.y1 + t * t * c.y); }
} else if (c.type === 'C') {
const n = Math.min(64, Math.max(4, Math.ceil((Math.hypot(c.x1 - cx, c.y1 - cy) + Math.hypot(c.x2 - c.x1, c.y2 - c.y1) + Math.hypot(c.x - c.x2, c.y - c.y2)) / step)));
const x0 = cx, y0 = cy;
for (let i = 1; i <= n; i++) { const t = i / n, u = 1 - t; push(u * u * u * x0 + 3 * u * u * t * c.x1 + 3 * u * t * t * c.x2 + t * t * t * c.x, u * u * u * y0 + 3 * u * u * t * c.y1 + 3 * u * t * t * c.y2 + t * t * t * c.y); }
} else if (c.type === 'Z') { if (ring && ring.length > 2) rings.push(ring); ring = null; }
}
if (ring && ring.length > 2) rings.push(ring);
const clean = rings.map((r) => r.filter((p, i) => i === 0 || Math.hypot(p[0] - r[i - 1][0], p[1] - r[i - 1][1]) > 1e-6)).filter((r) => r.length > 2 && Math.abs(ringArea(r)) > 1e-3);
if (!clean.length) return [];
const big = clean.reduce((m, r) => (Math.abs(ringArea(r)) > Math.abs(ringArea(m)) ? r : m));
const sgn = Math.sign(ringArea(big));
const outers = clean.filter((r) => Math.sign(ringArea(r)) === sgn).map((r) => [r]);
const holes = clean.filter((r) => Math.sign(ringArea(r)) !== sgn).map((r) => [r]);
return diff(union(...outers), ...holes);
}
// 글자 줄: 커닝(GPOS) + 자간(em 비율). 글자마다 MultiPolygon 과 자리를 돌려준다. 기준선 y=0.
export function setText(faces, text, size, tracking = 0) {
const out = []; let x = 0, prev = null;
for (const ch of Array.from(text)) {
if (ch === ' ') { const g = glyphFor(faces, ' '); x += g ? (g.glyph.advanceWidth / g.font.unitsPerEm) * size : size * 0.26; prev = null; continue; }
const hit = glyphFor(faces, ch);
if (!hit) throw new Error(`글자를 그릴 폰트가 없음: "${ch}" (U+${ch.codePointAt(0).toString(16).toUpperCase()})`);
const { font, glyph } = hit, k = size / font.unitsPerEm;
if (prev && prev.font === font) {
try { const t = font.position.getKerningTables('latn'); x += font.position.getKerningValue(t, prev.glyph.index, glyph.index) * k; } catch { /* 커닝 표 없음 */ }
}
let mp = glyphMP(glyph, x, 0, size);
// 앞 글자와 겹치거나 0.6% em 보다 가까우면(La·To 같은 커닝 쌍) 닿지 않을 때까지 민다 — 합집합에서 두 글자가 붙지 않게
const last = out[out.length - 1];
if (last && mp.length) for (let i = 0; i < 40; i++) {
const hit = inter(last.mp, mp).length > 0 || inter(last.mp, move(mp, -size * 0.006, 0)).length > 0;
if (!hit) break;
x += size * 0.004; mp = move(mp, size * 0.004, 0);
}
out.push({ ch, mp, x, adv: glyph.advanceWidth * k, font });
x += glyph.advanceWidth * k + tracking * size; prev = hit;
}
return { glyphs: out, width: x - tracking * size };
}
export function fontMetrics(faces, size) {
const f = fontAt(faces[0].files[0]), k = size / f.unitsPerEm, os2 = f.tables.os2;
const cap = os2.sCapHeight || bbox(glyphMP(f.charToGlyph('H'), 0, 0, f.unitsPerEm)).h;
const xh = os2.sxHeight || bbox(glyphMP(f.charToGlyph('x'), 0, 0, f.unitsPerEm)).h;
return { cap: cap * k, xh: xh * k };
}
// ───────── SVG ─────────
const fmt = (v) => { const s = (Math.round(v * 100) / 100).toFixed(2); return s.replace(/\.?0+$/, '') || '0'; };
export function pathD(mp) {
let d = '';
for (const poly of mp) for (const r of poly) { d += 'M' + r.map(([x, y]) => fmt(x) + ' ' + fmt(y)).join('L') + 'Z'; }
return d;
}
// layers = [{ mp, fill }] — 겹치는 순서대로. 글자는 전부 path (text·image·외부 참조 없음)
export function svgDoc(w, h, layers, { bg = null, title = '' } = {}) {
const body = layers.filter((l) => l.mp && l.mp.length).map((l) => `<path fill="${l.fill}" fill-rule="evenodd" d="${pathD(l.mp)}"/>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${fmt(w)} ${fmt(h)}" width="${fmt(w)}" height="${fmt(h)}">${title ? `<title>${esc(title)}</title>` : ''}${bg ? `<rect width="100%" height="100%" fill="${bg}"/>` : ''}${body}</svg>\n`;
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
export function png(svg, width, height) {
const r = new Resvg(svg, { fitTo: { mode: 'width', value: width }, background: 'rgba(0,0,0,0)', font: { loadSystemFonts: false } }).render();
if (height && r.height !== height) throw new Error(`PNG 크기 불일치 ${r.width}×${r.height}`);
return r;
}
// ───────── 색 ─────────
export function hex(s) { const m = /^#?([0-9a-f]{6})$/i.exec(String(s || '').trim()); return m ? '#' + m[1].toUpperCase() : null; }
const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
export const lum = (h) => { const [r, g, b] = rgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
export const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
export const mix = (a, b, t) => '#' + rgb(a).map((v, i) => Math.round(v + (rgb(b)[i] - v) * t).toString(16).padStart(2, '0')).join('').toUpperCase();
// ───────── 측정 ─────────
// 0/1 마스크의 유클리드 거리 변환(Felzenszwalb): 각 1 픽셀에서 가장 가까운 0 픽셀까지 거리
function edt(mask, w, h) {
const INF = 1e20, f = new Float64Array(Math.max(w, h)), d = new Float64Array(Math.max(w, h)), v = new Int32Array(Math.max(w, h)), z = new Float64Array(Math.max(w, h) + 1);
const g = new Float64Array(w * h);
for (let i = 0; i < w * h; i++) g[i] = mask[i] ? INF : 0;
const pass = (n, get, set) => {
for (let q = 0; q < n; q++) f[q] = get(q);
let k = 0; v[0] = 0; z[0] = -INF; z[1] = INF;
for (let q = 1; q < n; q++) {
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
while (s <= z[k]) { k--; s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }
k++; v[k] = q; z[k] = s; z[k + 1] = INF;
}
k = 0;
for (let q = 0; q < n; q++) { while (z[k + 1] < q) k++; d[q] = (q - v[k]) ** 2 + f[v[k]]; }
for (let q = 0; q < n; q++) set(q, d[q]);
};
for (let x = 0; x < w; x++) pass(h, (q) => g[q * w + x], (q, val) => { g[q * w + x] = val; });
for (let y = 0; y < h; y++) pass(w, (q) => g[y * w + q], (q, val) => { g[y * w + q] = val; });
for (let i = 0; i < w * h; i++) g[i] = Math.sqrt(g[i]);
return g;
}
// 능선(거리 변환의 국소 최댓값)에서 가장 작은 값 × 2 = 가장 가는 폭
function minRidge(dt, mask, w, h, inBox) {
let best = Infinity, at = null;
for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
const i = y * w + x; if (!mask[i] || !inBox(x, y)) continue;
const v = dt[i]; if (v < 1.5) continue; // 경계 한 줄은 계단 잡음
// 반지름 8 창(32px 기준 1px) 안에서 v+0.35 보다 큰 값이 없으면 능선. 뾰족한 모서리(쐐기 끝)는 안쪽으로 갈수록 커지므로 빠진다
let max = true;
for (let dy = -8; dy <= 8 && max; dy++) for (let dx = -8; dx <= 8; dx++) {
const xx = x + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
if (dt[yy * w + xx] > v + 0.35) { max = false; break; }
}
if (max && v < best) { best = v; at = [x, y]; }
}
return { width: best === Infinity ? null : 2 * best, at };
}
// 마크(정사각 box 안의 MultiPolygon)를 px 크기로 줄였을 때 가장 가는 획·틈 (8배 해상도 래스터 + 거리 변환)
export function thinnest(mp, box, px, ss = 8) {
const S = px * ss, pad = Math.round(S * 0.25), W = S + pad * 2;
const svg = svgDoc(W, W, [{ mp: move(mp, pad, pad, S / box), fill: '#000' }]);
const r = new Resvg(svg, { fitTo: { mode: 'original' } }).render(), pix = r.pixels;
const ink = new Uint8Array(W * W), bg = new Uint8Array(W * W);
for (let i = 0; i < W * W; i++) { const a = pix[i * 4 + 3]; ink[i] = a >= 128 ? 1 : 0; bg[i] = 1 - ink[i]; }
let x0 = W, y0 = W, x1 = 0, y1 = 0;
for (let y = 0; y < W; y++) for (let x = 0; x < W; x++) if (ink[y * W + x]) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
const inBox = (x, y) => x > x0 + 1 && x < x1 - 1 && y > y0 + 1 && y < y1 - 1;
const s = minRidge(edt(ink, W, W), ink, W, W, () => true);
const g = minRidge(edt(bg, W, W), bg, W, W, inBox);
const conv = (o) => (o.width == null ? null : +(o.width / ss).toFixed(2));
const where = (o) => (o.at ? [+((o.at[0] - pad) / ss).toFixed(1), +((o.at[1] - pad) / ss).toFixed(1)] : null);
return { stroke: conv(s), gap: conv(g), strokeAt: where(s), gapAt: where(g) };
}
// 같은 크기의 RGBA 에서 전경색에 가까운 정도(0~1)의 평균 = 잉크 비율, 중간톤(0.3~0.7) 비율 = 뭉개짐
export function inkStats(pixels, w, h, fg, bgc) {
const F = rgb(fg), B = rgb(bgc); let sum = 0, mid = 0;
const den = Math.hypot(F[0] - B[0], F[1] - B[1], F[2] - B[2]) || 1;
for (let i = 0; i < w * h; i++) {
const a = pixels[i * 4 + 3] / 255;
const c = [0, 1, 2].map((k) => pixels[i * 4 + k] * a + B[k] * (1 - a)); // 바탕 위에 합성
const t = Math.max(0, Math.min(1, 1 - Math.hypot(c[0] - F[0], c[1] - F[1], c[2] - F[2]) / den));
sum += t; if (t > 0.3 && t < 0.7) mid++;
}
return { ink: +(sum / (w * h)).toFixed(3), mush: +(mid / (w * h)).toFixed(3) };
}
// ───────── 파일 ─────────
export function ico(pngs) { // [{size, buf}] → .ico (PNG 를 그대로 담는다)
const head = Buffer.alloc(6 + 16 * pngs.length); head.writeUInt16LE(0, 0); head.writeUInt16LE(1, 2); head.writeUInt16LE(pngs.length, 4);
let off = head.length;
pngs.forEach(({ size, buf }, i) => {
const o = 6 + i * 16; head.writeUInt8(size >= 256 ? 0 : size, o); head.writeUInt8(size >= 256 ? 0 : size, o + 1);
head.writeUInt16LE(1, o + 4); head.writeUInt16LE(32, o + 6); head.writeUInt32LE(buf.length, o + 8); head.writeUInt32LE(off, o + 12); off += buf.length;
});
return Buffer.concat([head, ...pngs.map((p) => p.buf)]);
}
export function pngSize(buf) { return buf.readUInt32BE(16) + 'x' + buf.readUInt32BE(20); }
export function svgProblems(svg) {
const bad = [];
if (/<text[\s>]/.test(svg)) bad.push('<text>');
if (/<image[\s>]/.test(svg)) bad.push('<image>');
if (/(href|src)\s*=\s*"(?!#)/.test(svg) || /url\((?!#)/.test(svg) || /@import/.test(svg)) bad.push('외부 참조');
if (/<foreignObject/.test(svg)) bad.push('<foreignObject>');
return bad;
}
// 면적 중심
export function centroid(mp) {
let A = 0, X = 0, Y = 0;
for (const poly of mp) poly.forEach((r, k) => {
const sg = (k ? -1 : 1) * Math.sign(ringArea(r) || 1);
for (let i = 0, n = r.length; i < n; i++) { const p = r[i], q = r[(i + 1) % n], c = p[0] * q[1] - q[0] * p[1]; A += sg * c; X += sg * (p[0] + q[0]) * c; Y += sg * (p[1] + q[1]) * c; }
});
return A ? [X / (3 * A), Y / (3 * A)] : [0, 0];
}
// 글자 하나(기준선 y=0, 대문자 높이 cap)를 boxPx 정사각 안에 놓고 1000 상자 좌표로 돌려준다.
// 64px 이하에서는 대문자 높이·기준선·왼쪽 끝을 정수 픽셀에 맞춘다(파비콘 선명도).
export function placeLetter(mp, cap, boxPx, capFrac, { cx = 0.5, cy = 0.5, optical = 0.5 } = {}) {
const snap = boxPx <= 64, capPx = snap ? Math.max(4, Math.round(capFrac * boxPx)) : capFrac * boxPx, k = capPx / cap;
const m = move(mp, 0, 0, k), b = bbox(m), [gx] = centroid(m);
const vx = optical * gx + (1 - optical) * (b.x0 + b.x1) / 2;
let left = cx * boxPx - (vx - b.x0); if (snap) left = Math.round(left);
const capLike = b.h >= capPx * 0.94 && b.h <= capPx * 1.07 && b.y1 <= capPx * 0.04;
let base = capLike ? cy * boxPx + capPx / 2 : cy * boxPx - (b.y0 + b.y1) / 2; if (snap) base = Math.round(base);
return move(m, left - b.x0, base, 1).map((poly) => poly.map((r) => r.map(([x, y]) => [x * 1000 / boxPx, y * 1000 / boxPx])));
}
// 가로줄 y(axis 'h') 또는 세로줄 x(axis 'v')가 잉크를 지나는 구간들 [[a,b], ...] (홀짝 규칙)
export function spans(mp, at, axis = 'h') {
const xs = [];
for (const poly of mp) for (const r of poly) for (let i = 0, n = r.length; i < n; i++) {
let [x1, y1] = r[i], [x2, y2] = r[(i + 1) % n];
if (axis === 'v') { [x1, y1] = [y1, x1]; [x2, y2] = [y2, x2]; }
if ((y1 <= at && y2 > at) || (y2 <= at && y1 > at)) xs.push(x1 + ((at - y1) / (y2 - y1)) * (x2 - x1));
}
xs.sort((a, b) => a - b);
const out = []; for (let i = 0; i + 1 < xs.length; i += 2) out.push([xs[i], xs[i + 1]]);
return out;
}
// 32px 에서 획·틈이 모두 min px 이상인가 (마크 fg 층과 작은 아이콘의 글자 층)
export function thinOk(layers, min = 1.5, ss = 8) {
const t = thinnest(layers.flatMap((l) => l.mp), 1000, 32, ss);
return (t.stroke == null || t.stroke >= min) && (t.gap == null || t.gap >= min);
}
// 글자 꼭짓점에서 상자(바깥 고리) 가장자리까지 가장 짧은 거리
export function wall(mp, box) {
const ring = box[0][0]; let m = Infinity;
for (const poly of mp) for (const r of poly) for (const [x, y] of r) for (let i = 0; i < ring.length; i++) {
const [ax, ay] = ring[i], [bx, by] = ring[(i + 1) % ring.length], dx = bx - ax, dy = by - ay;
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy || 1)));
const d = Math.hypot(x - ax - t * dx, y - ay - t * dy); if (d < m) m = d;
}
return m;
}
// 글꼴의 줄기 굵기(대문자 I 가운데 폭)
export function stemOf(faces, size = 1000) {
const g = setText(faces, 'I', size, 0).glyphs[0], cap = fontMetrics(faces, size).cap;
return spans(g.mp, -cap * 0.5).reduce((s, [a, b]) => s + b - a, 0);
}
// 세로 줄기의 '열린 끝'(다른 획이 붙지 않은 위·아래 끝)을 찾아, 그 끝에서 바깥으로 뻗는 직사각형들을 돌려준다(기준선 0, 높이 cap 글자)
export function freeStemRays(mp, cap, far = 4000) {
const b = bbox(mp), out = []; let cur = null;
const step = Math.max(1, cap / 400);
for (let x = b.x0; x <= b.x1 + step; x += step) {
const full = x <= b.x1 && spans(mp, x, 'v').some(([a, c]) => c - a >= cap * 0.9);
if (full) { if (!cur) cur = { x0: x, x1: x }; else cur.x1 = x; } else if (cur) { out.push(cur); cur = null; }
}
const rays = [];
for (const s of out) {
const w = s.x1 - s.x0; if (w < cap * 0.06) continue;
const plain = (y) => { const hit = spans(mp, y).filter(([a, c]) => c > s.x0 + 1 && a < s.x1 - 1); return hit.length === 1 && hit[0][1] - hit[0][0] <= w * 1.15; };
if (plain(-cap * 0.97) && plain(-cap * 0.85)) rays.push(rect(s.x0, -cap - far, w, far + cap * 0.5));
if (plain(-cap * 0.03) && plain(-cap * 0.15)) rays.push(rect(s.x0, -cap * 0.5, w, far + cap * 0.5));
}
return rays;
}
```
`style.mjs`
```js
// 스타일: 기하 모노그램 — 원·스쿼클 하나에서 이니셜을 음각으로 도려낸 마크 + 촘촘한 산세리프 워드마크(Geist)
// 면 분할: 이니셜의 세로 줄기 중 끝이 열린 줄기는 테두리까지 뻗어 도형을 면으로 나눈다(N → 왼쪽 아래·오른쪽 위, H → 위아래 모두).
// 변형 A = 원 · B = 스쿼클 · C = 스쿼클 크롭(글자가 아래 가장자리를 뚫고 나감). 자동 = 둥근 글자(O C G Q S D U …)는 스쿼클, 나머지는 원.
import * as C from './logo-common.mjs';
const KR = (w) => C.loadFace('ibm-plex-sans-kr', w);
const ROUND = new Set(Array.from('OCGQSDUocgqsdeaobp0369'));
export default {
slug: 'logo-monogram-geometric',
defaults: { primary: '#141414', background: '#F7F7F5' },
build({ brand, initial, variant }) {
const v = variant === 'auto' ? (ROUND.has(initial) ? 'B' : 'A') : variant;
const note = { A: '원 음각', B: '스쿼클 음각', C: '스쿼클 크롭 음각' }[v];
// 원·스쿼클(스쿼클은 원보다 면적이 커서(92.7%) 6% 안쪽으로 줄여 원과 같은 무게로)
const container = (n) => (v === 'A' ? C.circle(500, 500, 500, n) : C.squircle(500, 500, v === 'B' ? 470 : 500, 4, n));
const pos = v === 'C' ? { cy: 0.6 } : {};
let L, cap, weight, frac, rays = [];
// 글자(+ 뻗은 줄기)를 px 상자에 놓는다. 줄기 연장은 글자와 같은 변환을 받는다
const letterAt = (px, f = frac) => {
const placed = C.placeLetter(L.mp, cap, px, f, pos);
if (!rays.length) return placed;
const a = C.bbox(L.mp), b = C.bbox(placed), k = b.w / a.w; // placeLetter 와 같은 배율·이동
return C.union(placed, ...rays.map((r) => C.move(r, b.x0 - a.x0 * k, b.y0 - a.y0 * k, k)));
};
const markAt = (px) => { const box = container(360); return [{ role: 'under', mp: box }, { role: 'fg', mp: C.diff(box, letterAt(px)) }]; };
// 글자 크기 = 글자와 테두리 사이 벽이 줄기 굵기의 1.15배가 되는 가장 큰 크기(이분 탐색), 글자 높이는 상자의 66% 이하.
// 굵기는 워드마크와 같은 600 에서 시작해 32px 에서 속공간·획이 1.5px 아래면 한 단계씩 가늘게
for (const w of [600, 500, 400]) {
const faces = [C.loadFace('geist', w), KR(Math.min(w, 700))];
L = C.setText(faces, initial, 1000, 0).glyphs[0]; cap = C.fontMetrics(faces, 1000).cap; weight = w;
const stem = C.stemOf(faces), box = container(180), lh = C.bbox(L.mp).h;
rays = v === 'C' || !/[A-Za-z0-9]/.test(initial) ? [] : C.freeStemRays(L.mp, cap); // 줄기 연장은 라틴 글자·숫자만
if (v === 'C') frac = 0.86;
else {
let lo = 0.2, hi = 0.9;
for (let i = 0; i < 16; i++) {
const f = (lo + hi) / 2, mp = C.placeLetter(L.mp, cap, 1000, f);
const ok = C.mpArea(C.diff(mp, box)) < 1 && C.wall(mp, box) >= stem * (f * 1000 / cap) * 1.15 && lh * (f * 1000 / cap) <= 660;
if (ok) lo = f; else hi = f;
}
frac = lo;
}
if (C.thinOk(markAt(32).slice(1)) && C.thinOk(markAt(1000).slice(1))) break;
}
const word = [C.loadFace('geist', 600), KR(600)];
const W = C.setText(word, brand, 1000, -0.022);
const m = C.fontMetrics(word, 1000);
return {
variant: v, note: note + (rays.length ? ` · 면 분할(줄기 ${rays.length}개 연장)` : ''), detail: { frac: +frac.toFixed(3), markWeight: weight, rays: rays.length },
mark: { box: 1000, layers: markAt(1000) },
// 파비콘 = 마크(글자 자리는 바탕색). 앱 아이콘 = 바탕색 판 위에 마크 그대로(82%) — 색·도형이 마크와 같다
drawIcon: (px, full) => full
? [{ role: 'under', mp: C.rect(0, 0, 1000, 1000) }, ...markAt(1000).slice(1).map((l) => ({ ...l, mp: C.move(l.mp, 90, 90, 0.82) }))]
: markAt(px),
wordmark: { mp: W.glyphs.flatMap((g) => g.mp), cap: m.cap, xh: m.xh, chars: W.glyphs.map((g) => g.ch) },
lockup: { markToCap: v === 'A' ? 1.2 : 1.14, gap: 0.34, stackMarkToCap: v === 'A' ? 2.45 : 2.3, stackGap: 0.3 },
lineFaces: [C.loadFace('geist', 400), KR(400)],
};
},
};
```
## 5. 실행
```bash
npm install --no-audit --no-fund
node logo-render.mjs
```
- 1초 안팎이면 끝난다. 종료 코드 0 = 성공, 1 = 입력·렌더 오류(메시지에 고칠 것이 적혀 있다), 2 = 자동 검사 실패.
## 6. 자동 검사 (`out/check.json`, 실패하면 종료 코드 2)
- `ok: true`, `failures: []` 여야 한다. 검사 항목:
- SVG 안에 `<text>`·`<image>`·외부 참조가 0개(글자는 전부 패스 — 폰트가 없는 PC 에서도 똑같이 보인다).
- `thin32`: 마크와 32px 파비콘을 32px 기하로 줄여 8배 해상도로 래스터한 뒤 거리 변환으로 잰 가장 가는 획(`stroke`)·가장 좁은 틈(`gap`)이 모두 1.5px 이상.
- `ink16`: 16px 마크·파비콘의 잉크 비율이 15~75%(꽉 찬 사각·빈 칸으로 뭉개지지 않음).
- `colors.contrast`: 주 색과 바탕 색의 대비. 3:1 미만이면 `warnings` 에 남기고 단색(#111111/#FFFFFF) 키트로 만든다(`monoKit: true`) — 이때는 사용자에게 색을 바꿀지 물어본다.
- 모든 출력 파일이 있고 PNG 크기가 정확함, 워드마크 글자 = 브랜드명.
- 실패하면 `failures` 의 문장을 그대로 보고한다. 코드는 고치지 않는다. 변형을 바꿔 볼 수는 있다(`variant` 만 바꿔 다시 실행).
## 7. 직접 보는 확인 (반드시, 끝났다고 말하기 전에)
`out/brand-sheet.png` 와 `out/png/` 의 파일을 **직접 열어 본다**(이미지 보기 도구). 아래 로고 결함이 하나라도 보이면 보고에 적는다.
- 16px·32px(시트의 `actual size 1:1` 칸)에서 마크를 알아볼 수 없음
- 유명 로고와 닮음(Apple, Nike, Airbnb, Vercel 삼각형, Linear, Mastercard, Target 등 — 닮은 대상을 이름으로 적는다)
- 클리셰: 전구, 로켓, 뻔한 스우시, 지구본, 말풍선, 톱니
- 워드마크 글자가 브랜드명과 다름(디테일을 준 글자도 같은 글자로 읽혀야 한다)
- 획 굵기 불일치, 마크와 워드마크의 시각 무게 불균형
- 단색판(`mark-mono`)이 성립하지 않음
- 대각선 취소선 컷(Ø), 카운터 안 루프(눈알), 바깥 꼬리, 떨리는 선, 장식
- OG 한 줄이 `주제` 와 한 글자라도 다름
- 면 분할: 이니셜의 열린 줄기가 테두리까지 이어졌는지(`variantNote` 의 `줄기 n개 연장`). 줄기가 없는 글자(O S E B 등)는 면 분할 없이 가운데 음각만 된다 — 정상이다.
## 8. 보고
결과 폴더 경로, `check.json` 의 `variant`·`variantNote`·`thin32`·`ink16`·`colors`, 직접 본 확인 결과(위 결함 목록 기준), 걸린 시간, 고친 것이 있으면 무엇을 왜.
웹에 넣을 때: `out/favicon.ico` 와 `out/png/apple-touch-icon-180.png` 를 사이트 루트에, `<link rel="icon" href="/favicon.ico" sizes="any">` · `<link rel="apple-touch-icon" href="/apple-touch-icon-180.png">` · `<meta property="og:image" content="/og-1200x630.png">`.
폰트(전부 SIL OFL 1.1, 상업 사용 가능, 출력물에는 패스만 들어간다): Geist 400~600(마크·워드마크 600, OG 한 줄 400, @fontsource/geist), IBM Plex Mono 500(시트 이름표), IBM Plex Sans KR(한글 글자가 있을 때만)
만드는 법
- 복사
복사를 누르면 코딩 에이전트용 레시피가 복사돼요. 빈칸은 비워 둔 채여도 괜찮아요.
- 붙여 넣기
빈 폴더를 연 Codex·Claude Code 에 붙여 넣으세요. 브랜드명·색·주제가 없으면 에이전트가 먼저 물어봐요.
- 확인
out/brand-sheet.png 를 열어 16·32px 에서도 알아볼 수 있는지, 유명 로고와 닮지 않았는지 보세요. 쓰기 전에 상표 검색도 해 보세요.
빈칸 채우는 법
- [브랜드명]
- 워드마크에 그대로 들어갑니다. 대소문자·띄어쓰기까지 그대로, 20자 이내.
- [브랜드 색]
- 주 색, 바탕 색 HEX 두 개. 비우면 #141414, #F7F7F5 로 그립니다.
- [주제]
- 제품이 무엇인지 한 문장(90자 이내). OG 이미지의 한 줄로 그대로 씁니다.
- [넣을 글자]
- 마크에 쓸 글자 한 자. 비우면 브랜드명 첫 글자를 씁니다.
- 변형
- 자동(둥근 글자는 스쿼클, 나머지는 원) · A 원 · B 스쿼클 · C 스쿼클 크롭.
- 한글 이름
- 선택. 적으면 wordmark-ko.svg 도 만듭니다. 기본 키트·시트에는 넣지 않습니다.
알아 둘 것
- 면 분할은 세로 줄기가 있는 글자(F H I K L M N P R T U Y 등)에서 나옵니다. O S E B D A C G 같은 글자는 가운데 음각만 됩니다.
- 주 색과 바탕 색의 대비가 3:1 미만이면 단색(검정·흰색) 키트로 바뀌고 경고가 남습니다.
- 글자만으로 그리는 로고입니다. 쓰기 전에 상표 검색으로 비슷한 로고가 없는지 확인하세요.
예시 · Halden

#A63D24 · #FBF7F2 — Booking software for independent clinics
실제 크기
- favicon 16 · 32 · 48 (1:1)






app icon 1024 · 180
og 1200×630