feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能

This commit is contained in:
sgp
2026-10-03 21:44:18 +08:00
commit 9e222877ea
497 changed files with 43611 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
/**
* 画个标 · AI 客户端:DeepSeek API 接入
* 职责:API Key 管理(localStorage)、对话调用、错误分类、连接测试
*/
const HGB_AI = (function () {
'use strict';
const LS_KEY = 'hgb_ai_key';
const LS_MODEL = 'hgb_ai_model';
const ENDPOINT = 'https://api.deepseek.com/chat/completions';
const MODELS = ['deepseek-v4-flash', 'deepseek-chat', 'deepseek-v4-pro'];
/**
* 不内置任何 Key:Key 仅由使用者在「AI 设置」中填写,存本机 localStorage,只发给 DeepSeek。
* 因此源码、仓库与部署产物里都不含 Key,公网也无法被他人读取(BYOK 模式)。
*/
const DEFAULT_KEY = '';
/* ---------- Key 管理 ---------- */
function getKey() {
try {
const v = localStorage.getItem(LS_KEY);
// 未配置时返回空串,由调用方提示「未配置 API Key」并降级为内置引擎
if (v) return v;
return DEFAULT_KEY;
} catch (e) { return ''; }
}
function setKey(key) {
try { localStorage.setItem(LS_KEY, (key || '').trim()); } catch (e) { /* ignore */ }
}
function hasKey() { return !!getKey(); }
function getModel() {
try { return localStorage.getItem(LS_MODEL) || 'deepseek-v4-flash'; } catch (e) { return 'deepseek-v4-flash'; }
}
function setModel(m) { try { localStorage.setItem(LS_MODEL, m); } catch (e) { /* ignore */ } }
function clearKey() {
// 清除本机保存的 Key(已无内置 Key 可回落,清除后即降级为内置引擎)
try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ }
}
/* ---------- 调用 ---------- */
/**
* 调用 DeepSeek 对话补全
* @param {Array} messages [{role:'system'|'user', content}]
* @param {object} opts { json?: boolean, maxTokens?: number, temperature?: number }
* @returns {Promise<string>} 返回内容文本
*/
async function chat(messages, opts) {
const key = getKey();
if (!key) {
const err = new Error('未配置 API Key,请先在「AI 设置」中填写');
err.code = 'NO_KEY';
throw err;
}
opts = opts || {};
const body = {
model: getModel(),
messages,
max_tokens: opts.maxTokens || 4096,
temperature: opts.temperature != null ? opts.temperature : 0.5,
stream: false
};
if (opts.json) {
body.response_format = { type: 'json_object' };
}
let res;
try {
res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: JSON.stringify(body)
});
} catch (e) {
const err = new Error('网络请求失败:' + e.message + '(请检查网络/代理)');
err.code = 'NETWORK';
throw err;
}
if (!res.ok) {
let detail = '';
try { detail = (await res.json()).error?.message || ''; } catch (e) { /* ignore */ }
const err = new Error(detail || 'HTTP ' + res.status);
if (res.status === 401) err.code = 'AUTH';
else if (res.status === 402) err.code = 'BALANCE';
else if (res.status === 429) err.code = 'RATE_LIMIT';
else err.code = 'API';
err.status = res.status;
throw err;
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
const err = new Error('AI 返回内容为空');
err.code = 'EMPTY';
throw err;
}
return content;
}
/** 测试连接:发送最小请求验证 Key 是否有效 */
async function testConnection() {
const content = await chat([
{ role: 'user', content: '回复两个字:正常' }
], { maxTokens: 16, temperature: 0 });
return content;
}
return { getKey, setKey, hasKey, getModel, setModel, clearKey, chat, testConnection, MODELS };
})();
+387
View File
@@ -0,0 +1,387 @@
/**
* 画个标 · AI 智能设计器
* 核心:把品牌需求 + 内置资源库(图标/布局/配色/风格/字体)作为白名单交给 DeepSeek,
* AI 输出设计决策 JSON,系统校验后映射为完整方案(复用 generator 渲染)
*/
const HGB_AIDesigner = (function () {
'use strict';
/* ---------- 内置库解析 ---------- */
/** 从已加载的素材源按 "source:name" 解析图标对象 {id,name,svg,type}(懒加载适配:仅搜索已加载源,未加载返回 null) */
function resolveIcon(ref) {
if (!ref || typeof ref !== 'string') return null;
const hit = HGB_IconLib.findIcon(ref);
return hit ? { id: hit.id, name: hit.name, svg: hit.svg, type: hit.type } : null;
}
/** 预加载 AI 输出引用的素材源(懒加载适配:AI 图标 ref 带源前缀,先加载对应源文件再同步查找) */
async function preloadReferencedSources(data) {
const refs = (data.plans || []).map(p => p.icon).filter(Boolean);
const sids = [...new Set(refs.map(r => String(r).split(':')[0]))].filter(sid =>
typeof HGB_BuiltinIcons !== 'undefined' && !!(HGB_BuiltinIcons.sources || {})[sid]);
if (sids.length) await HGB_IconLib.preload(sids);
}
/** AI 可选的精选图标(带中文名,杜绝 AI 乱猜英文 id) */
const ICON_DEMO = [
['lucide:star', '星星'], ['lucide:rocket', '火箭'], ['lucide:globe', '地球'],
['lucide:home', '房子'], ['lucide:heart', '爱心'], ['lucide:book', '书本'],
['lucide:graduation-cap', '学士帽'], ['lucide:briefcase', '公文包'],
['lucide:building-2', '大楼'], ['lucide:shield', '盾牌'], ['lucide:crown', '皇冠'],
['lucide:diamond', '钻石'], ['lucide:target', '靶心'], ['lucide:sun', '太阳'],
['lucide:moon', '月亮'], ['lucide:flame', '火焰'], ['lucide:leaf', '叶子'],
['lucide:tree-pine', '松树'], ['lucide:cloud', '云朵'], ['lucide:droplet', '水滴'],
['lucide:zap', '闪电'], ['lucide:sparkles', '星光'], ['lucide:medal', '奖牌'],
['lucide:trophy', '奖杯'], ['lucide:award', '勋章'], ['lucide:plane', '飞机'],
['lucide:compass', '指南针'], ['lucide:map', '地图'], ['lucide:music', '音符'],
['lucide:coffee', '咖啡'], ['lucide:utensils', '餐具'], ['lucide:shopping-bag', '购物袋'],
['lucide:palette', '调色板'], ['lucide:pen-tool', '画笔'], ['lucide:laptop', '笔记本'],
['lucide:phone', '手机'], ['lucide:camera', '相机'], ['lucide:key', '钥匙'],
['lucide:lock', '锁'], ['lucide:eye', '眼睛'], ['lucide:bell', '铃铛'],
['lucide:gift', '礼物'], ['lucide:party-popper', '礼花'], ['lucide:hand-heart', '手心爱心'],
['tabler:building', '楼宇'], ['tabler:chart-bar', '柱状图'], ['tabler:currency-yuan', '货币'],
['tabler:school', '学校'], ['tabler:heartbeat', '心跳'], ['feather:activity', '活动波']
];
/** 规范化布局决策:非法修正 + 多样性(≥3 种布局、圆形环绕上限头像 4 / LOGO 2);头像模式布局池限定头像白名单 */
function normalizeLayoutDecisions(decisions, brand, avatar) {
// 图形标识(Mark)模板不再内嵌品牌名,任何品牌名长度都可用;
// 仅"环形环绕 Lockup"需要短品牌名 + 圆形 Mark
const ROUND = ['badge', 'seal', 'orb', 'duo', 'burst'];
const roundMax = avatar ? 4 : 2; // 头像模式允许更多圆形构图(差异化)
// 布局池:头像模式 = 头像白名单;LOGO 模式 = 全部布局排除头像专有
const pool = avatar
? HGB_AvatarGen.AVATAR_LAYOUTS
: HGB_Generator.LAYOUTS.filter(l => !l.avatarOnly).map(l => l.id);
// 1) 非法布局 → 默认(圆标容器 / 头像白名单首个)
for (const d of decisions) {
if (!pool.includes(d.layout)) {
d._layoutInvalid = true;
d.layout = avatar ? pool[0] : HGB_Generator.defaultLayout(brand).id;
}
}
// 2) 圆形 Mark 数 > 上限 → 多余的替换为其他布局(保证形状多样性)
const roundIdx = decisions.map((d, i) => ROUND.includes(d.layout) ? i : -1).filter(i => i >= 0);
if (roundIdx.length > roundMax) {
const others = pool.filter(id => !ROUND.includes(id));
for (const i of roundIdx.slice(roundMax)) {
decisions[i]._layoutInvalid = true;
decisions[i].layout = others[i % others.length];
}
}
// 3) 布局种类 < 3 → 用未用过的布局补齐
const uniq = new Set(decisions.map(d => d.layout));
if (uniq.size < 3 && decisions.length >= 3) {
const used = [...uniq];
const avail = pool.filter(id => !used.includes(id));
for (const d of decisions) {
if (!avail.length) break;
if (d.layout === used[0] && uniq.size < 3) {
d._layoutInvalid = true;
d.layout = avail.shift();
uniq.add(d.layout);
}
}
}
}
/* ---------- Prompt 组装 ---------- */
function buildPrompt(opt) {
const ind = opt.industry;
const avatar = !!opt.avatar; // 头像模式:纯图形、无文字、布局限定头像白名单
const brand = (opt.brand || '').trim();
const sub = (opt.sub || '').trim();
const desc = (opt.desc || '').trim();
const userIcons = (opt.icons || []).map(i => i.id).join('、') || '(未指定)';
// 本次启用的技能(用户勾选):把技能标识信息注入,AI 须按技能规范设计
const skillList = (opt.skills || []).map(id => {
const s = HGB_Skills.get(id);
return s ? `${s.emoji} ${s.name}(${s.scene})` : null;
}).filter(Boolean).join('\n') || '(未启用特定技能,按行业最佳实践设计)';
// 布局/风格/字体白名单(头像模式:布局只列头像白名单纯图形布局;LOGO 模式排除头像专有布局)
const layoutPool = avatar
? HGB_Generator.LAYOUTS.filter(l => HGB_AvatarGen.isAvatarLayout(l.id))
: HGB_Generator.LAYOUTS.filter(l => !l.avatarOnly);
const layoutList = layoutPool.map(l => `${l.id}:${l.name}(${l.desc})`).join('\n');
const lockupList = HGB_Generator.LOCKUP_POS.map(lp => `${lp.pos}:${lp.name}`).join('\n');
// 风格白名单:用户指定风格方向时仅列出所选风格
const userStyles = (opt.styleIds || []).filter(id => HGB_Styles.some(s => s.id === id));
const styleList = HGB_Styles.filter(s => !userStyles.length || userStyles.includes(s.id))
.map(s => `${s.id}:${s.name}(设计运动:${s.movement}|${s.desc};气质:${s.moods.join('/')})`).join('\n');
const stylePref = userStyles.length
? `用户指定了风格方向:${HGB_Styles.filter(s => userStyles.includes(s.id)).map(s => `${s.emoji}${s.name}`).join('、')}。styleId 必须从这些风格中选择;多套方案尽量覆盖不同的风格,展示该方向下的多种可能。`
: '(用户未限定风格方向,请在全部风格中挑选契合行业与品牌描述的风格,并让多套方案使用不同的风格方向)';
const fontList = (ind.fontFamilies || []).join('、') +
';通用中文字体:SimHei、Microsoft YaHei、SimSun、KaiTi;通用英文字体:Arial、Georgia、Impact、Verdana、Montserrat、Playfair Display';
// 图标白名单:精选(带中文名)+ 当前行业图标 + 用户已选图标
const iconWhitelist = [
...ICON_DEMO.map(([id, zh]) => `${id}(${zh})`),
...(ind.icons || []).map(i => `${ind.id}:${i.id}(行业图标)`),
...(opt.icons || []).map(i => `${i.id}(用户倾向图标)`)
].join('\n');
const sys = avatar ? `你是顶级的品牌头像设计师。请基于用户的品牌信息,从"内置设计资源库"中选择素材,设计多套专业头像(Avatar)方案。
架构理念(必须严格遵守):
- 头像 = 纯图形标识(图标 + 容器/环/盾/几何),不含任何文字。头像用于微信/QQ/抖音/App 图标/网站 Favicon 等场景,小尺寸(64px)也必须清晰可辨。
设计原则(必须严格遵守):
1. 深刻理解品牌行业与描述,所选图标语义必须与品牌强相关(如餐饮用火焰/餐具,教育用书本/学士帽)。
2. 如果品牌描述指定色系/风格/气质,必须优先选择契合的配色与风格;如果用户明确指定了风格方向,必须只从指定风格中选择,不得越界。
3. 图标、布局、风格必须从各自白名单中选择,不得编造白名单之外的 id。
4. 每个方案给出贴切的中文方案名与设计理念(concept)。
5. 图标差异纪律:多套方案尽量使用不同的图标(可从白名单挑选语义相近但形态不同的图标),绝对避免所有方案共用同一个图标。
6. 布局纪律:${opt.count || 5} 套方案必须覆盖至少 3 种不同布局;头像圆形构图(badge/seal/orb/duo/burst)最多 4 套,优先使用头像专有布局(fullbleed 全出血大图标 / concentric 同心圆环 / centroid 中心单环)。
7. 构图方法论纪律:每套方案的图形标识必须建立在明确的几何构图框架上(容器/环/弧/角标/几何叠加),构图框架尽可能互不相同,禁止只靠"换颜色/换图标"制造差异。
8. 风格气韵纪律:所选 styleId 的设计运动(movement)与色系必须贯穿整套方案——主色/背景必须贴合风格气质(如 Art Deco 金黑对称、孟菲斯撞色拼贴、Swiss 黑白留白、霓虹深底荧光、包豪斯高饱和原色),禁止"风格与配色割裂"(如极简风配高饱和渐变)。系统会自动按 styleId 的色系渲染配色,无需在 JSON 中填写 paletteName。
9. 小尺寸可读性:图形轮廓简洁、对比清晰,避免过于纤细/密集的细节,保证缩到 64px(Favicon)仍可辨识。
只输出 JSON,不要输出任何其他文字。JSON 结构:
{
"overallConcept": "整体设计理念(一句话概括系列设计思路)",
"plans": [
{
"name": "方案一的中文名",
"concept": "这套方案的设计理念说明",
"icon": "lucide:graduation-cap",
"layout": "orb",
"styleId": "minimal"
}
]
}
其中 plans 数组包含 ${opt.count || 5} 个方案。icon 必须来自下方图标白名单;layout/styleId 分别来自对应白名单(配色由系统自动跟随 styleId 的风格色系,无需填写 paletteName)。` : `你是顶级的品牌 LOGO 设计师。请基于用户的品牌信息,从"内置设计资源库"中选择素材,设计多套专业 LOGO 方案。
架构理念(必须严格遵守):
- LOGO 分两层:图形标识(Mark)= 纯图形(图标+容器+环),不含任何文字;品牌组合(Lockup)= Mark + 品牌名,品牌名只摆放在图形周边(下方/右侧/上方/环绕),绝不融进图形内部。
设计原则(必须严格遵守):
1. 深刻理解品牌行业与描述,所选图标语义必须与品牌强相关(如餐饮用火焰/餐具,教育用书本/学士帽)。
2. 如果品牌描述指定色系/风格/气质,必须优先选择契合的配色与风格;如果用户明确指定了风格方向,必须只从指定风格中选择,不得越界。
3. 图标、布局、配色、风格、字体、lockup 必须从各自白名单中选择,不得编造白名单之外的 id。
4. 每个方案给出贴切的中文方案名与设计理念(concept)。
5. 图标差异纪律:多套方案之间尽量使用不同的图标(可从白名单挑选语义相近但形态不同的图标),绝对避免所有方案共用同一个图标。
6. 布局纪律:${opt.count || 5} 套方案必须覆盖至少 3 种不同 Mark 布局;圆形 Mark(badge/seal/orb/duo/burst)最多 2 套。
7. lockup 纪律:品牌名摆放位置多样化,多套方案使用不同的 lockup 位置(如 2 套下方、1 套右侧、1 套上方);环形环绕(wrap)仅用于圆形 Mark 且品牌名 ≤6 中文字或 ≤12 西文字符。
8. 构图方法论纪律:每套方案的 Mark 必须建立在明确的几何构图框架上(容器/环/弧/绶带/角标/几何叠加),构图框架尽可能互不相同——借鉴"精确几何构图":圆与三角的数学比例、对称平衡、网格对齐;禁止只靠"换颜色/换图标/换位置"来制造差异。
9. 差异度纪律:多套方案必须在构图方式、视觉层次(环/盾/容器/绶带/角标/射线/拱门/几何叠加等)、配色、风格上有明显差异,呈现不同设计方向,而不是"换个颜色换个位置"。
10. 换色纪律:同一布局若复用,必须变换配色与风格;尽可能让每套方案的构图框架(layout)不同。
11. 风格气韵纪律:所选 styleId 的设计运动(movement)与色系必须贯穿整套方案——配色的主色/背景必须贴合风格气质(如 Art Deco 金黑对称、孟菲斯撞色拼贴、Swiss 黑白留白网格、霓虹深底荧光、包豪斯高饱和原色),禁止出现"风格与配色割裂"(如极简风配高饱和渐变)。系统会自动按 styleId 的色系渲染配色,无需在 JSON 中填写 paletteName。
只输出 JSON,不要输出任何其他文字。JSON 结构:
{
"overallConcept": "整体设计理念(一句话概括系列设计思路)",
"plans": [
{
"name": "方案一的中文名",
"concept": "这套方案的设计理念说明",
"icon": "lucide:graduation-cap",
"layout": "orb",
"lockup": "below",
"styleId": "minimal",
"fontCn": "SimHei",
"fontEn": "Georgia"
}
]
}
其中 plans 数组包含 ${opt.count || 5} 个方案。icon 必须来自下方图标白名单;layout/styleId 分别来自对应白名单(配色由系统自动跟随 styleId 的风格色系,无需填写 paletteName);lockup 来自品牌组合位置白名单;fontCn/fontEn 来自字体白名单。`;
const user = avatar ? `【品牌信息】
- 行业:${ind.name}(${ind.desc})
- 品牌名:${brand}
- 品牌描述:${desc || '(无,请根据行业特点设计)'}
- 用户倾向使用的图标:${userIcons}
【本次启用技能】(设计时须遵循这些技能的规范)
${skillList}
【风格方向要求】
${stylePref}
【图标白名单】(icon 字段从这里选,格式为 源:名称)
${iconWhitelist}
【布局模板白名单】(layout 字段,仅限适合头像的纯图形布局,不含文字)
${layoutList}
【设计风格白名单】(styleId 字段;配色自动跟随所选风格的色系)
${styleList}
【字体白名单】(仅供方案理念参考;头像为纯图形,不渲染任何文字)
${fontList}
请设计 ${opt.count || 5} 套头像方案,每套都要从白名单选择,并附上设计理念。` : `【品牌信息】
- 行业:${ind.name}(${ind.desc})
- 品牌名:${brand}
- 副标题:${sub || '(无)'}
- 品牌描述:${desc || '(无,请根据行业特点设计)'}
- 用户倾向使用的图标:${userIcons}
【本次启用技能】(设计时须遵循这些技能的规范)
${skillList}
【风格方向要求】
${stylePref}
【图标白名单】(icon 字段从这里选,格式为 源:名称)
${iconWhitelist}
【布局模板白名单】(layout 字段)
${layoutList}
【品牌组合位置白名单】(lockup 字段,品牌名在图形周边的摆放方式)
${lockupList}
【设计风格白名单】(styleId 字段;配色自动跟随所选风格的色系)
${styleList}
【字体白名单】(fontCn / fontEn 字段)
${fontList}
请设计 ${opt.count || 5} 套方案,每套都要从白名单选择,并附上设计理念。`;
return [
{ role: 'system', content: sys },
{ role: 'user', content: user }
];
}
/* ---------- 解析与校验 ---------- */
function extractJson(content) {
let text = (content || '').trim();
// 剥 markdown 围栏
text = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new Error('AI 输出不是有效 JSON');
return JSON.parse(text.slice(start, end + 1));
}
/** 校验并规范化 AI 输出:非法字段显式标记,布局统一规范 */
function validate(data, opt) {
const ind = opt.industry;
if (!data || !Array.isArray(data.plans) || !data.plans.length) {
throw new Error('AI 未返回方案');
}
const plans = data.plans.slice(0, 20);
for (const p of plans) {
// 图标:白名单解析,失败标记(映射时回退行业/用户图标)
if (p.icon && !resolveIcon(p.icon)) p._iconInvalid = true;
// 布局/风格:不在白名单标记(配色自动跟随风格,无需校验 paletteName)
const layoutOk = opt.avatar
? HGB_AvatarGen.isAvatarLayout(p.layout)
: HGB_Generator.LAYOUTS.some(l => l.id === p.layout && !l.avatarOnly);
if (!layoutOk) p._layoutInvalid = true;
if (!HGB_Styles.some(s => s.id === p.styleId)) p._styleInvalid = true;
}
// 布局纪律:非法修正 + 多样性保障(≥3 种布局、圆环类 ≤2)
normalizeLayoutDecisions(plans, (opt.brand || '').trim() || HGB_Generator.sampleName(ind), !!opt.avatar);
return data;
}
/** 颜色亮度(0-1) */
function lum(hex) {
const c = String(hex || '#FFFFFF').replace('#', '');
if (c.length !== 6) return 0.9;
const r = parseInt(c.slice(0, 2), 16) / 255;
const g = parseInt(c.slice(2, 4), 16) / 255;
const b = parseInt(c.slice(4, 6), 16) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** 对比度修正:深色主色 + 深色背景 → 背景自动调亮 */
function fixContrast(plan) {
const text = plan.elements.find(el => el.type === 'text');
const icon = plan.elements.find(el => el.type === 'icon' && el.iconId !== 'ring' && el.iconId !== 'deco-bar');
const main = (text && text.fill) || (icon && icon.stroke);
if (!main || !plan.bg) return;
if (lum(plan.bg) < 0.42 && lum(main) < 0.45) {
plan.bg = '#FFFFFF';
plan._bgFixed = true;
}
}
/** 把 AI 决策映射为完整方案(非法字段显式修正并记录警告) */
function mapToPlan(p, opt, idx) {
const ind = opt.industry;
const avatar = !!opt.avatar;
const brand = (opt.brand || '').trim() || HGB_Generator.sampleName(ind);
const sub = (opt.sub || '').trim();
const userIcons = (opt.icons || []).filter(Boolean);
const aiIcon = resolveIcon(p.icon);
// 图标容错:AI 图标非法时,按方案序号轮换用户/行业图标,避免"全套同一图标"
const fallbackPool = userIcons.length ? userIcons : ((ind.icons || []));
const icons = aiIcon
? [aiIcon]
: (fallbackPool.length ? [fallbackPool[idx % fallbackPool.length]] : []);
// 显式修正非法字段(不做随机回退);头像模式布局限定头像白名单
const layoutOk = avatar
? HGB_AvatarGen.isAvatarLayout(p.layout)
: HGB_Generator.LAYOUTS.some(l => l.id === p.layout && !l.avatarOnly);
const layoutId = layoutOk
? p.layout
: (avatar ? HGB_AvatarGen.AVATAR_LAYOUTS[0] : HGB_Generator.defaultLayout(brand).id);
// 风格:非法时优先回退用户指定风格池,其次行业推荐
const userStyleIds = (opt.styleIds || []).filter(id => HGB_Styles.some(s => s.id === id));
const styleId = HGB_Styles.some(s => s.id === p.styleId)
? p.styleId
: (userStyleIds.length
? userStyleIds[idx % userStyleIds.length]
: ((ind.styleIds || [])[0] || HGB_Styles[0].id));
const variant = HGB_Generator.generateVariant({
industry: ind,
brand,
sub,
icons,
layoutId,
styleId, // 配色由 generator 自动跟随 styleId 色系
lockup: avatar ? '' : p.lockup, // 头像为纯图形,无品牌名组合
// AI 字体决策生效(非法字体由 fontFor 自动回退)
fontFavs: [p.fontCn, p.fontEn].filter(Boolean)
});
variant.id = 'ai_plan_' + Date.now().toString(36) + idx.toString(36);
variant.name = (p.name || '').trim() || variant.name;
variant.concept = (p.concept || '').trim();
variant.aiIcon = aiIcon ? aiIcon.name : '';
variant.ai = true;
// 对比度自动修正
fixContrast(variant);
// 收集自动修正警告,供卡片展示
const warnings = [];
if (p._layoutInvalid) warnings.push('布局已自动调整');
if (p._iconInvalid) warnings.push('图标已替换');
if (p._styleInvalid) warnings.push('风格已调整');
if (variant._bgFixed) warnings.push('背景已调亮');
variant.warnings = warnings;
return variant;
}
/* ---------- 主入口 ---------- */
/**
* AI 生成方案
* @param {object} opt { industry, brand, sub, desc, icons, count }
* @returns {Promise<{plans:Array, overallConcept:string}>}
*/
async function generatePlansAI(opt) {
const messages = buildPrompt(opt);
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 8192 });
let data = extractJson(content);
// 懒加载适配:预加载 AI 引用素材源,确保 resolveIcon/findIcon 命中
await preloadReferencedSources(data);
data = validate(data, opt);
const plans = data.plans.map((p, i) => mapToPlan(p, opt, i));
return {
plans,
overallConcept: (data.overallConcept || '').trim(),
raw: content
};
}
return { generatePlansAI, resolveIcon, extractJson, validate, mapToPlan, buildPrompt };
})();
+151
View File
@@ -0,0 +1,151 @@
/**
* 画个标 · 核心:头像生成(Avatar)
* 头像 = 复用 Mark 纯图形引擎 + 头像专属约束 + 平台规格导出
* - 仅纯图形:过滤带文字布局(monogram 首字 / ribbon 绶带区),保证小尺寸清晰
* - 圆形构图优先:头像布局以圆形/圆角/对称构图为倾向,缩小不变形
* - 圆形裁切预览与导出:社交平台头像均以圆形展示
* - 平台规格一键导出:微信 / QQ / 抖音 / YouTube / App 图标 / Favicon + 自定义
*/
const HGB_AvatarGen = (function () {
'use strict';
/** 适合头像的纯图形布局(Mark 层,全部无文字) */
const AVATAR_LAYOUTS = [
'orb', // 圆标容器(径向渐变圆)
'badge', // 双环徽章
'seal', // 双环印章
'burst', // 光芒徽章(射线环)
'duo', // 双图标对称
'emblem', // 渐变盾徽
'hexagon', // 六边形徽章
'diamond', // 菱形徽章
'arch', // 拱门徽章
'geo', // 几何构形
'techcorner', // 科技角标
'fullbleed', // 头像专属:全出血大图标(无容器)
'concentric', // 头像专属:同心圆环
'centroid' // 头像专属:中心单环
];
/** 平台头像导出规格预设 */
const AVATAR_SIZES = [
{ id: 'wechat', name: '微信头像', size: 300, note: '微信/微博 300' },
{ id: 'qq', name: 'QQ 头像', size: 640, note: 'QQ 640' },
{ id: 'douyin', name: '抖音头像', size: 400, note: '抖音/快手 400' },
{ id: 'youtube', name: 'YouTube 头像', size: 800, note: '频道头像 800' },
{ id: 'app', name: 'App 图标', size: 1024, note: 'iOS/安卓 1024' },
{ id: 'favicon', name: '网站 Favicon', size: 64, note: '浏览器标签 64' }
];
/** 头像词根扩充的主力单色源(懒加载优先预载;多色 emoji/品牌源不参与词根搜索) */
const AVATAR_CORE_SOURCES = [
'lucide', 'tabler', 'feather', 'mdi', 'fluent', 'solar', 'msymbols',
'iconpark', 'hugeicons', 'mingcute', 'carbon', 'majesticons', 'pixelarticons', 'akar-icons'
];
/** 布局 id 是否适合头像(在头像白名单内) */
function isAvatarLayout(layoutId) {
return AVATAR_LAYOUTS.includes(layoutId);
}
/** 洗牌(保证多套头像图标各异) */
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** 内置库关键词搜索:图标名包含任一英文词根即命中(返回带库前缀 id 的图标数组;懒加载适配:先预载核心源再搜已加载源) */
async function searchBuiltin(roots, limit) {
// 词根搜索依赖已加载源 → 预载头像核心源(容忍单个失败)
await HGB_IconLib.preload(AVATAR_CORE_SOURCES);
const out = [];
const seen = new Set();
const kws = roots.filter(k => k.length >= 3);
if (!kws.length) return out;
for (const kw of kws) {
for (const ic of HGB_IconLib.searchLoaded(kw, limit)) {
const fullId = ic.id;
if (seen.has(fullId)) continue;
seen.add(fullId);
out.push({ id: fullId, name: ic.name, svg: ic.svg, source: ic.source, type: ic.type });
if (out.length >= limit) return out;
}
}
return out;
}
/** 头像素材池:用户选中 + 行业图标优先,内置库按行业图标词根扩充(解决"素材不够/重复") */
async function avatarIconPool(ind, userIcons) {
const picked = (userIcons || []).filter(Boolean).slice(0, 5);
const indIcons = (ind.icons || []);
const poolMap = new Map();
// 本地段:用户选中 + 行业图标(保持插入顺序)
for (const ic of picked) poolMap.set(ic.id, ic);
for (const ic of indIcons) if (!poolMap.has(ic.id)) poolMap.set(ic.id, ic);
const localCount = poolMap.size;
// 词根:行业图标 id 按 '-' 拆分(英文语义词根,如 chef-hat → chef/hat)
const roots = [];
for (const ic of indIcons) {
for (const part of String(ic.id || '').split('-')) {
const k = part.toLowerCase();
if (k.length >= 3 && !roots.includes(k)) roots.push(k);
}
}
if (roots.length) {
for (const ic of await searchBuiltin(roots, 60)) {
if (!poolMap.has(ic.id)) poolMap.set(ic.id, ic);
}
}
const all = Array.from(poolMap.values());
// 分段洗牌:行业/用户图标在前(高度相关),内置库扩充在后;各自洗牌保证多套各异
return shuffle(all.slice(0, localCount)).concat(shuffle(all.slice(localCount)));
}
/** 批量生成头像(复用 Mark 生成引擎 + 头像布局白名单 + 内置库素材扩充) */
async function generateAvatars(opt) {
const ind = opt.industry || {};
const count = Math.min(Math.max(opt.count || 5, 1), 20);
// 1) 基础批量生成(风格全覆盖 + 头像布局白名单)
const base = HGB_Generator.generatePlans({ ...opt, count, layoutWhitelist: AVATAR_LAYOUTS });
// 2) 头像素材池扩充(行业/用户图标在前 + 内置库词根扩充;懒加载预载核心源)
const pool = await avatarIconPool(ind, opt.icons);
if (pool.length < 2) return base;
// 3) 逐套替换为池中不同图标(保持风格/布局/配色不变,图标素材多样)
return base.map((p, i) => {
const ic1 = pool[i % pool.length];
const ic2 = pool[(i + Math.ceil(pool.length / 2)) % pool.length];
if (!ic1) return p;
const v = HGB_Generator.generateVariant({
...opt,
layoutWhitelist: AVATAR_LAYOUTS,
layoutId: p.layoutId,
styleId: p.styleId,
icons: ic2 && ic2.id !== ic1.id ? [ic1, ic2] : [ic1],
lockup: p.lockup
});
return Object.assign({}, v, {
id: p.id, name: p.name, concept: p.concept, aiIcon: ic1.id
});
});
}
/** 生成单个头像变体(预览弹窗微调) */
function generateVariant(opt) {
return HGB_Generator.generateVariant({
...opt,
layoutWhitelist: AVATAR_LAYOUTS
});
}
return {
AVATAR_LAYOUTS,
AVATAR_SIZES,
isAvatarLayout,
generateAvatars,
generateVariant
};
})();
+284
View File
@@ -0,0 +1,284 @@
/**
* 画个标 · DiceBear 头像风格引擎(M4)
* 29 种 DiceBear v10 风格定义内置(js/data/sources/dicebear-*.js,由构建脚本生成),
* 本模块:按"风格 × seed"确定性组合出无限头像 SVG(不占图标配额)。
*
* 风格定义结构(v10 单文件 JSON):
* canvas 画布模板(elements 树,component 节点按 name 引用 components)
* components { name: { width, height, rotate/scale/translate 随机范围, variants: { 名: { elements, weight } } } }
* colors { 色名: { values: [...] } }(element 属性 fill/stroke 以 {type:'color',name} 引用)
* attributes 根 svg 属性(fill、shape-rendering 等)
*
* 渲染:seed → 确定性 PRNG → 每 component 加权选 variant + 随机变换 → color 引用取色 → 组装 SVG
*/
const HGB_Dicebear = (function () {
'use strict';
const VERSION = '20260822e';
const STYLES = [
'adventurer', 'adventurer-neutral', 'avataaars', 'avataaars-neutral',
'big-ears', 'big-ears-neutral', 'big-smile', 'blobs', 'bottts', 'bottts-neutral',
'clay', 'constellation', 'critters', 'croodles', 'croodles-neutral',
'cutouts', 'disco', 'dylan', 'fun-emoji', 'glass', 'glyphs', 'icons',
'identicon', 'initial-face', 'initials', 'landscape', 'line-face', 'loops',
'lorelei', 'lorelei-neutral', 'micah', 'miniavs', 'moods', 'notionists',
'notionists-neutral', 'open-peeps', 'patchwork', 'personas', 'pixel-art',
'pixel-art-neutral', 'pixelbot', 'planets', 'rings', 'shape-grid', 'shapes',
'sprouts', 'squircles', 'stripes', 'thumbs', 'toon-head', 'triangles',
'voxel-art', 'voxel-bot', 'waves', 'weave'
];
const CN = {
adventurer: '冒险家', 'adventurer-neutral': '冒险家中性', avataaars: '阿凡达',
'avataaars-neutral': '阿凡达中性', 'big-ears': '大耳朵', 'big-ears-neutral': '大耳朵中性',
'big-smile': '大笑脸', blobs: '斑点', bottts: '机器人', 'bottts-neutral': '机器人中性',
clay: '黏土', constellation: '星座', critters: '小动物',
croodles: '卡通线条', 'croodles-neutral': '卡通线条中性', cutouts: '剪贴画',
disco: '迪斯科', dylan: '迪伦', 'fun-emoji': '趣味表情', glass: '玻璃拟态',
glyphs: '字形符', icons: '图标风', identicon: '指纹几何', 'initial-face': '首字脸',
initials: '首字母', landscape: '风景', 'line-face': '线条脸', loops: '回环',
lorelei: '洛雷莱', 'lorelei-neutral': '洛雷莱中性', micah: '米迦',
miniavs: '迷你头像', moods: '心情', notionists: 'Notion 风格', 'notionists-neutral': 'Notion 中性',
'open-peeps': '开放小人', patchwork: '拼布', personas: '人物剪影', 'pixel-art': '像素风',
'pixel-art-neutral': '像素风中性', pixelbot: '像素机器人', planets: '星球',
rings: '圆环几何', 'shape-grid': '形状网格', shapes: '几何形状',
sprouts: '萌芽', squircles: '圆角方', stripes: '条纹', thumbs: '拇指人',
'toon-head': '卡通头', triangles: '三角形', 'voxel-art': '体素艺术',
'voxel-bot': '体素机器人', waves: '波浪', weave: '编织'
};
/* ---------- 确定性 PRNG(seed 字符串 → mulberry32) ---------- */
function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function rng(seedStr) {
let s = hash(seedStr) || 1;
return function () {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick(r, arr) {
return arr[Math.floor(r() * arr.length)];
}
function weightedPick(r, items) {
let total = 0;
for (const i of items) total += (i.weight != null ? i.weight : 0.01);
if (total <= 0) return items[0].key;
let x = r() * total;
for (const i of items) {
x -= (i.weight != null ? i.weight : 0.01);
if (x <= 0) return i.key;
}
return items[items.length - 1].key;
}
function rn(v) { return Math.round(v * 100) / 100; }
/** 变量引用解析(initials 风格:initial/fontFamily/fontWeight 等) */
function resolveVariable(name, seed, opts) {
switch (name) {
case 'initial':
case 'initials': {
const s = String(opts.initial || seed || '').trim();
if (s) return s[0].toUpperCase();
return opts.defaultInitial || 'D';
}
case 'fontFamily': return opts.fontFamily || 'Arial';
case 'fontWeight': return String(opts.fontWeight != null ? opts.fontWeight : 600);
default: return '';
}
}
/** 属性值:color 引用 → 从色板取色;variable 引用 → 变量解析;其他原样 */
function resolveAttr(v, r, colors, opts) {
if (v && typeof v === 'object') {
if (v.type === 'color' && v.name) {
const c = colors[v.name];
if (typeof c === 'string') return c; // 已解析的单一颜色
if (c && Array.isArray(c.values) && c.values.length) return pick(r, c.values);
return '#000000';
}
if (v.type === 'variable' && v.name) {
return resolveVariable(v.name, opts.seed, opts);
}
return '';
}
return v;
}
/* ---------- 递归渲染 element / component / text 节点 ---------- */
function renderComponent(comp, r, colors, components, opts) {
const names = Object.keys(comp.variants || {});
if (!names.length) return '';
const items = names.map(n => ({ key: n, weight: comp.variants[n].weight }));
const variant = comp.variants[weightedPick(r, items)] || {};
// 随机变换(rotate/scale/translate 范围;个别风格 translate 只有 x 或 y,缺字段视为 0)
const t = [];
if (comp.translate) {
const tx = comp.translate.x;
const ty = comp.translate.y;
const x = tx ? tx.min + r() * (tx.max - tx.min) : 0;
const y = ty ? ty.min + r() * (ty.max - ty.min) : 0;
if (x || y) t.push('translate(' + rn(x) + ' ' + rn(y) + ')');
}
if (comp.rotate) {
const deg = comp.rotate.min + r() * (comp.rotate.max - comp.rotate.min);
if (deg) t.push('rotate(' + rn(deg) + ')');
}
if (comp.scale) {
const s = comp.scale.min + r() * (comp.scale.max - comp.scale.min);
if (s !== 1) t.push('scale(' + rn(s) + ')');
}
const inner = (variant.elements || []).map(e => renderNode(e, r, colors, components, opts)).join('');
return t.length ? '<g transform="' + t.join(' ') + '">' + inner + '</g>' : inner;
}
/**
* 解析组件继承(DiceBear schema 的 extends 字段):
* - identicon 的 row01~row05 → row;glass 的 shape01/shape02 → shape
* 继承语义 = 复用父组件定义(无自身 variants 时完全继承;带环保护)
*/
function resolveComp(name, components) {
const seen = new Set();
let cur = name;
while (cur && !seen.has(cur)) {
seen.add(cur);
const c = (components || {})[cur];
if (!c) return (components || {})[name] || null;
if (!c.extends) return c;
if (c.variants && Object.keys(c.variants).length) return c;
cur = c.extends;
}
return (components || {})[name] || null;
}
/**
* 统一渲染节点:支持三种类型
* - component 节点(任意层级出现,如 shapes 的 <g><component/></g>)→ 渲染其变体
* - text 节点 {type:'text', value} → 渲染为纯文本(initials 风格文字,value 可为变量引用)
* - element 节点 → 拼属性 + 递归 children
* components 仅在 canvas 层传入(renderComponent 内部复用自身 variants 而非 components 表)
*/
function renderNode(el, r, colors, components, opts) {
if (!el) return '';
if (el.type === 'component') {
const comp = resolveComp(el.name, components);
if (!comp) return '';
const inner = renderComponent(comp, r, colors, components, opts);
// component 节点自带 transform 等属性(如 shapes 的 matrix 定位)
const attrs = [];
for (const k of Object.keys(el.attributes || {})) {
const v = el.attributes[k];
if (v == null) continue;
attrs.push(k + '="' + String(v).replace(/"/g, '&quot;') + '"');
}
return '<g' + (attrs.length ? ' ' + attrs.join(' ') : '') + '>' + inner + '</g>';
}
if (el.type === 'text') {
const v = resolveAttr(el.value, r, colors, opts);
return String(v != null ? v : '');
}
// element:拼属性(color 引用取色,数字取整)
const attrs = [];
for (const k of Object.keys(el.attributes || {})) {
const v = resolveAttr(el.attributes[k], r, colors, opts);
if (v === '' || v == null) continue;
const sv = typeof v === 'number' ? rn(v) : String(v);
attrs.push(k + '="' + sv.replace(/"/g, '&quot;') + '"');
}
const children = (el.children || []).map(c => renderNode(c, r, colors, components, opts)).join('');
const name = (el.name || 'g').replace(/[^a-zA-Z0-9-]/g, '') || 'g';
return '<' + name + (attrs.length ? ' ' + attrs.join(' ') : '') +
(children ? '>' + children + '</' + name + '>' : '/>');
}
/* ---------- 画布组装 ---------- */
function renderCanvas(canvas, r, colors, components, opts) {
return (canvas.elements || []).map(n => renderNode(n, r, colors, components, opts)).join('');
}
/** 画布是否已含显式 background 色元素(避免重复垫底) */
function hasBgElement(canvas) {
function scan(el) {
if (!el || typeof el !== 'object') return false;
const a = el.attributes || {};
for (const k of Object.keys(a)) {
const v = a[k];
if (v && typeof v === 'object' && v.type === 'color' && v.name === 'background') return true;
if (v === 'background') return true;
}
return (el.children || []).some(scan);
}
return (canvas && canvas.elements || []).some(scan);
}
/**
* 渲染单个风格头像
* @param {string} styleId 风格 id(如 adventurer / pixel-art)
* @param {string} seed 确定性种子(同 seed 同输出)
* @returns {{svg:string, styleId:string}} svg 为完整 <svg> 文档
*/
function renderAvatar(styleId, seed, opts) {
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
if (!def) return { svg: '', styleId };
opts = opts || {};
const r = rng(String(seed || 'dicebear'));
const colors = def.colors || {};
// 每色名先定一个颜色(保持跨 component 一致)
const colorMap = {};
for (const name of Object.keys(colors)) {
colorMap[name] = pick(r, colors[name].values || ['#000000']);
}
let inner = renderCanvas(def.canvas, r, colorMap, def.components || {}, { seed, ...opts });
const w = (def.canvas && def.canvas.width) || 100;
const h = (def.canvas && def.canvas.height) || 100;
// 背景填充:dicebear 官方渲染器默认按 backgroundColor 垫底。neutral 系列(pixel-art-neutral 等)
// 脸部肤色依赖此背景,画布无显式背景元素且色板含 background 时先垫同尺寸 rect;
// 无 background 色板的风格(adventurer/pixel-art 等)零影响
if (colorMap.background && !hasBgElement(def.canvas)) {
inner = '<rect width="' + w + '" height="' + h + '" fill="' + colorMap.background + '"/>' + inner;
}
const rootAttrs = [];
for (const k of Object.keys(def.attributes || {})) {
rootAttrs.push(k + '="' + String(def.attributes[k]).replace(/"/g, '&quot;') + '"');
}
const svg = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
'" viewBox="0 0 ' + w + ' ' + h + '"' +
(rootAttrs.length ? ' ' + rootAttrs.join(' ') : '') + '>' + inner + '</svg>';
return { svg, styleId };
}
/** 懒加载单个风格定义(缓存 + 并发去重) */
const loaded = {};
const pending = {};
function loadStyle(styleId) {
if (loaded[styleId]) return Promise.resolve(loaded[styleId]);
if (pending[styleId]) return pending[styleId];
pending[styleId] = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'js/data/sources/dicebear-' + styleId + '.js?v=' + VERSION;
s.onload = () => {
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
if (!def) { reject(new Error('风格定义缺失: ' + styleId)); return; }
loaded[styleId] = def;
delete pending[styleId];
resolve(def);
};
s.onerror = () => { delete pending[styleId]; reject(new Error('风格加载失败: ' + styleId)); };
document.head.appendChild(s);
});
return pending[styleId];
}
/** 加载全部风格(预载) */
function loadAll() {
return Promise.allSettled(STYLES.map(loadStyle));
}
return { VERSION, STYLES, CN, renderAvatar, loadStyle, loadAll };
})();
+152
View File
@@ -0,0 +1,152 @@
/**
* 画个标 · 核心:表情设计师 AI 编排(Emoji AI)
* AI 输出结构化设计决策 JSON(布局/风格/动画/分层)→ 系统负责渲染与导出;
* AI 失败/无 Key 时返回 null,由 emojiGen 降级为内置随机池(页面不空白)。
*
* 设计要点:
* - 与 aiDesigner 同模式:buildPrompt → HGB_AI.chat(json) → extractJson → validate → 决策
* - validate 把所有输出修正到合法白名单(布局/风格/动画类型/参数),非法即回退
* - 纯函数 + 环境分级容错(Node 无头无 HGB_AI 时返回 null,不算失败)
*/
const HGB_EmojiAI = (function () {
'use strict';
/* ============ Prompt 编排 ============ */
function layoutList() {
if (typeof HGB_EmojiGen !== 'undefined') return HGB_EmojiGen.EMOJI_LAYOUTS.join('/');
return 'orb/badge/seal/burst/duo/emblem/hexagon/diamond/arch/geo/techcorner/fullbleed/concentric/centroid';
}
function buildPrompt(opt) {
const ind = opt.industry || {};
const iconDesc = (opt.icons && opt.icons.length)
? opt.icons.map(i => i.name || i.id).join('、')
: '(未指定,由 AI 建议行业意象)';
const mood = opt.mood ? '用户期望情绪:' + opt.mood + ';' : '';
// 情绪-动画映射(生成前置,先审后生成:用户给出期望情绪时优先匹配,避免动效与情绪割裂)
const moodMap = opt.mood
? '情绪-动画映射参考(用户给出期望情绪时优先匹配,可适度发挥但勿选明显冲突类型):' +
'开心→bounce、生气→shake、惊讶→heartbeat、难过→float、兴奋→hopspin、平静→breath。'
: '';
const sys = [
'你是「画个标」的动态表情设计师。用户给出行业与图标素材,你输出「一张静态底图 + 一个主动画」的结构化设计决策。',
'铁律:只输出 JSON,不要任何解释文字。',
'字段与取值:',
'- overallConcept:一句话设计概念(中文,10-20 字)',
'- base.layoutId:从白名单选一个:' + layoutList(),
'- base.styleId:从系统风格库选一个(如 minimal/neon/flat/artdeco/corporate 等,按行业气质匹配)',
'- anim.type:从动画库选一个:bounce/pulse/wiggle/float/spin/fade/twirl/heartbeat/shake/breath/hopspin',
'- anim.speed:0.6-1.5(周期数,生气/兴奋偏快,平静偏慢)',
'- anim.amplitude:0.7-1.4(强度,活泼可大,克制宜小)',
'- anim.layered:true/false(是否元素分层错峰动画,让表情更活;多元素图形建议 true)',
'- anim.concept:动效概念一句话(中文)',
moodMap,
'示例:{"overallConcept":"咖啡的松弛感","base":{"layoutId":"seal","styleId":"neon"},"anim":{"type":"float","speed":0.8,"amplitude":0.9,"layered":true,"concept":"蒸汽缓缓漂浮"}}'
].join('\n');
const user = [
'行业:' + (ind.name || '通用') + '(' + (ind.emoji || '') + ',' + (ind.desc || '') + ')',
'品牌/主题:' + (opt.brand || '动态表情'),
'用户图标素材:' + iconDesc,
mood,
'请输出严格 JSON(不要 markdown 代码块)。'
].filter(Boolean).join('\n');
return [
{ role: 'system', content: sys },
{ role: 'user', content: user }
];
}
/* ============ 解析与校验 ============ */
/** 从聊天文本提取 JSON 对象(取第一个完整 {} 块,容忍前后缀文字) */
function extractJson(text) {
if (!text || typeof text !== 'string') return null;
const start = text.indexOf('{');
if (start < 0) return null;
let depth = 0, inStr = false, esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
} else if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
try { return JSON.parse(text.slice(start, i + 1)); } catch (e) { return null; }
}
}
}
return null;
}
function inList(v, list) { return list.indexOf(v) >= 0; }
/** 修正 AI 输出到合法白名单(非法项回退合理默认,返回完整决策对象) */
function validate(d, opt) {
const ind = opt.industry || {};
const dec = d || {};
const base = dec.base || {};
const anim = dec.anim || {};
const layouts = (typeof HGB_EmojiGen !== 'undefined') ? HGB_EmojiGen.EMOJI_LAYOUTS : [];
const animTypes = (typeof HGB_Motion !== 'undefined') ? HGB_Motion.animTypes() : [];
const styleIds = (typeof HGB_Styles !== 'undefined') ? HGB_Styles.map(s => s.id) : [];
let layoutId = inList(base.layoutId, layouts) ? base.layoutId : null;
if (!layoutId && ind.favLayout && inList(ind.favLayout, layouts)) layoutId = ind.favLayout;
if (!layoutId) layoutId = layouts[0] || 'orb';
const styleId = inList(base.styleId, styleIds) ? base.styleId : (opt.styleIds && opt.styleIds[0]);
const animType = inList(anim.type, animTypes.map(t => t.id)) ? anim.type : null;
let aType = animType || 'bounce';
const clamp = (v, lo, hi, dft) => {
const n = Number(v);
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
};
return {
overallConcept: String(dec.overallConcept || '').trim().slice(0, 30) || '动态表情包',
base: { layoutId, styleId },
anim: {
type: aType,
speed: clamp(anim.speed, 0.6, 1.5, 1),
amplitude: clamp(anim.amplitude, 0.7, 1.4, 1),
layered: !!anim.layered,
concept: String(anim.concept || '').trim().slice(0, 30)
}
};
}
/* ============ 主入口 ============ */
/**
* AI 表情设计决策(失败/无 Key/超时 → 返回 null,供生成器降级)
* @param {object} opt { industry, brand, icons, mood }
* @returns {Promise<{overallConcept, base:{layoutId,styleId}, anim:{...}}|null>}
*/
async function suggest(opt) {
if (typeof HGB_AI === 'undefined') return null;
try {
if (typeof HGB_AI.hasKey === 'function' && !HGB_AI.hasKey()) return null;
const messages = buildPrompt(opt);
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 2048 });
const d = extractJson(content);
if (!d) return null;
return validate(d, opt);
} catch (e) {
return null; // AI 失败 → 生成器降级内置池
}
}
return { buildPrompt, extractJson, validate, suggest };
})();
// Node 无头冒烟自检(浏览器同样执行,幂等)
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiAI;
+344
View File
@@ -0,0 +1,344 @@
/**
* 画个标 · 核心:动态表情生成与导出(Emoji/Sticker)
* 表情 = 复用 Mark 纯图形引擎(头像布局白名单)+ 动画维度(HGB_Motion)+ GIF/WebM 导出
* - 纯图形:与头像一致排除带文字布局,小尺寸清晰
* - 动画:AI 缺失时本地确定性分配(pickAnim 轮换),预览与导出同源(同一 apply)
* - 导出:GIF(HGB_GifEncoder 逐帧采样)/ WebM(canvas.captureStream + MediaRecorder)/ PNG(静态帧)
*/
const HGB_EmojiGen = (function () {
'use strict';
/** 表情可用的纯图形布局(复用头像白名单) */
const EMOJI_LAYOUTS = typeof HGB_AvatarGen !== 'undefined'
? HGB_AvatarGen.AVATAR_LAYOUTS
: ['orb', 'badge', 'burst', 'duo', 'emblem', 'fullbleed', 'concentric', 'centroid'];
/** 平台表情导出规格预设 */
const EMOJI_SIZES = [
{ id: 'wechat', name: '微信表情', size: 240, note: '微信 240' },
{ id: 'qq', name: 'QQ 表情', size: 320, note: 'QQ 320' },
{ id: 'telegram', name: 'Telegram 贴纸', size: 512, note: 'Telegram 512' }
];
/* ============ 生成 ============ */
/**
* 情绪 → 主动画决策(生成前置:AI 缺失/失败时,首张贴合用户情绪)
* 优先复用 emotion-params 技能(情绪-动效映射,与评审技能同源),未加载时走内置映射兜底
* @param {string} mood 情绪 id(happy/angry/surprised/sad/excited/calm)
* @returns {object|null} {type,speed,amplitude,layered} 或 null(无情绪)
*/
function moodAnim(mood) {
if (!mood) return null;
if (typeof HGB_StickerSkills !== 'undefined') {
try {
const r = HGB_StickerSkills.run('emotion-params', { mood });
if (r && !r.error && r.type && HGB_Motion.animTypes().some(t => t.id === r.type)) {
return { type: r.type, speed: r.speed || 1, amplitude: r.amplitude || 1, layered: true };
}
} catch (e) { /* 技能异常 → 内置映射兜底 */ }
}
const M = {
happy: { type: 'bounce', speed: 1.2, amplitude: 1, layered: true },
angry: { type: 'shake', speed: 1.4, amplitude: 1.2, layered: true },
surprised: { type: 'heartbeat', speed: 1.2, amplitude: 1.1, layered: true },
sad: { type: 'float', speed: 0.8, amplitude: 0.8, layered: true },
excited: { type: 'hopspin', speed: 1.3, amplitude: 1.1, layered: true },
calm: { type: 'breath', speed: 0.9, amplitude: 0.8, layered: true }
};
return M[String(mood).toLowerCase()] || null;
}
/**
* 基于一张静态底图展开为「同图 × 全部动画类型」的一组动画表情
* @param {object} base 静态方案(含 elements/markElements/layoutId 等)
* @param {object} opts 可选 { varParams: 是否微调各动画参数(幅度/速度)增加层次 }
* @returns {Array} 变体数组,同图、每项 anim 为不同动画类型
*/
function expandAnimations(base, opts) {
let types = HGB_Motion.animTypes();
// AI 优先:把 AI 推荐动画类型排到首位(首张即 AI 决策动效)
if (opts && opts.preferType && types.some(t => t.id === opts.preferType)) {
types = [types.find(t => t.id === opts.preferType), ...types.filter(t => t.id !== opts.preferType)];
}
return types.map((t, i) => {
const fp = (i === 0 && opts && opts.firstParams) ? opts.firstParams : {};
return Object.assign({}, base, {
id: base.id + '-' + t.id,
anim: HGB_Motion.normalize({
type: t.id,
speed: (opts && opts.varParams) ? [0.9, 1, 1.1][i % 3] : (fp.speed || 1),
amplitude: (opts && opts.varParams) ? (0.9 + (i % 2) * 0.2) : (fp.amplitude || 1),
// 交错开启元素级分层:一半变体"各元素错峰动",表情更活;AI 指定时首张遵循 AI 决策
layered: (opts && opts.layered === false) ? false : ((fp.layered != null) ? fp.layered : (i % 2 === 1)),
layerSpread: 0.3
})
});
});
}
/**
* 生成一组动态表情(核心入口):AI 编排(行业/情绪 → 布局/风格/动画决策)或降级内置池
* 先生成一张静态底图 → 自动展开全部动画类型
* @param {object} opt 同 HGB_Generator.generatePlans(industry/brand/icons/mood 等)
* @returns {Promise<Array>} 同图 × 全部动画类型的变体数组
*/
async function generateEmojiSet(opt) {
let base = null;
let ai = null;
// AI 编排:输出结构化决策 JSON;失败/无 Key 静默降级内置池
if (typeof HGB_EmojiAI !== 'undefined') {
try { ai = await HGB_EmojiAI.suggest(opt); } catch (e) { ai = null; }
}
if (ai && ai.base) {
try {
const v = HGB_AvatarGen.generateVariant({
...opt,
layoutId: ai.base.layoutId,
styleId: ai.base.styleId,
anim: ai.anim
});
if (v) {
v.concept = v.concept || ai.overallConcept;
v.aiConcept = ai.anim.concept;
v.aiMood = opt.mood;
base = v;
}
} catch (e) { base = null; }
}
if (!base) {
base = (await HGB_AvatarGen.generateAvatars({ ...opt, count: 1 }))[0];
}
// 主动画决策:AI 优先;AI 缺失/失败时按情绪匹配(生成前置),再退化为确定性轮换
const moodA = moodAnim(opt && opt.mood);
base.anim = HGB_Motion.normalize(ai && ai.anim ? ai.anim : (moodA || HGB_Motion.pickAnim(0)));
base._aiDesign = !!ai;
return expandAnimations(base, Object.assign({}, opt || {}, {
preferType: (ai && ai.anim && ai.anim.type) || (moodA && moodA.type) || null,
firstParams: (ai && ai.anim) || moodA || null
}));
}
/** 单个变体(预览弹窗微调:换动画/换配色/换布局) */
function generateVariant(opt) {
const p = HGB_AvatarGen.generateVariant({ ...opt });
return Object.assign({}, p, { anim: opt.anim || HGB_Motion.pickAnim(0) });
}
/** 表情图形元素(纯图形 Mark,避免 lockup 混入文字) */
function markElements(plan) {
return (plan.markElements && plan.markElements.length) ? plan.markElements : (plan.elements || []);
}
/* ============ 帧 SVG 构建(动画烘焙,预览/导出同源) ============ */
/** 元素包围盒(纯图形:icon 元素直接用 x/y/w/h;text 忽略,表情纯图形) */
function elementBox(el) {
if (!el || el.visible === false) return null;
return { x: el.x, y: el.y, w: el.w, h: el.h };
}
/** 全部可见元素内容包围盒 */
function contentBox(elements) {
let box = null;
for (const el of elements || []) {
const b = elementBox(el);
if (!b) continue;
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
else {
const x2 = Math.max(box.x + box.w, b.x + b.w);
const y2 = Math.max(box.y + box.h, b.y + b.h);
box.x = Math.min(box.x, b.x);
box.y = Math.min(box.y, b.y);
box.w = x2 - box.x;
box.h = y2 - box.y;
}
}
return box;
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 单色图标上色(字符串处理,零 DOM;多色素材 preserve 保留原样) */
function colorizeIconSvg(svg, el) {
if (el.preserve) return svg;
const fill = el.fill && el.fill !== 'none' ? el.fill : 'none';
const stroke = el.stroke && el.stroke !== 'none' ? el.stroke : 'none';
const strokeAttrs = stroke !== 'none'
? ` stroke="${esc(stroke)}" stroke-width="${esc(el.strokeWidth)}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`
: ' stroke="none"';
return svg.replace(/<(path|circle|rect|polygon|polyline|line|ellipse)\b/g,
(m, tag) => `<${tag} fill="${esc(fill)}"${strokeAttrs}`);
}
/**
* 构建单帧 SVG 字符串(把动画帧变换烘焙进元素)
* @param {Array} elements 元素数组(plan.elements)
* @param {string} bg 背景色('transparent' 或 hex)
* @param {number} size 输出边长
* @param {number} t 归一化帧时间(0-1)
* @param {object} anim 动画参数(HGB_Motion.normalize 结果)
* @param {object} opts { trim: 内容顶满画布; pad: 留白比例 }
* @returns {string} SVG 字符串(含 XML 声明)
*/
function buildFrameSvg(elements, bg, size, t, anim, opts) {
const W = size || 240, H = size || 240;
const isTransparent = bg === 'transparent' || bg === null || bg === undefined;
// 内容包围盒裁剪 viewBox
let vx = 0, vy = 0, vw = W, vh = H;
if (opts && opts.trim !== false) {
const box = contentBox(elements);
if (box) {
const pad = (opts && opts.pad != null ? opts.pad : 0.06) * Math.max(box.w, box.h);
vx = box.x - pad; vy = box.y - pad;
vw = box.w + pad * 2; vh = box.h + pad * 2;
if (vw < 4) { vx -= 2; vw = 4; }
if (vh < 4) { vy -= 2; vh = 4; }
}
}
// 渐变 defs
let defs = '';
const grads = {};
for (const el of elements) {
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
}
if (Object.keys(grads).length) {
let d = '<defs>';
for (const id of Object.keys(grads)) {
const g = grads[id];
d += (g.type === 'radial'
? `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></radialGradient>`
: `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></linearGradient>`);
}
defs = d + '</defs>';
}
let inner = '';
if (!isTransparent) inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
if (defs) inner += defs;
// 元素级分层:各图标按索引错峰取时间(layered 时各自动),否则全局同拍
const iconEls = elements.filter(el => el.visible && el.type === 'icon');
iconEls.forEach((el, i) => {
const tEl = HGB_Motion.elementT(t, anim, i, iconEls.length);
const frame = HGB_Motion.apply(el, tEl, anim);
const base = `translate(${el.x} ${el.y}) scale(${(el.w || 24) / 24} ${(el.h || 24) / 24})`;
const tr = HGB_Motion.composeTransform(el, frame, base);
const opacity = frame.opacity < 1 ? ` opacity="${Math.round(frame.opacity * 1000) / 1000}"` : '';
inner += `<g transform="${tr}"${opacity}>${colorizeIconSvg(el.svg || '', el)}</g>`;
});
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
inner + '</svg>';
}
/* ============ 导出 ============ */
/**
* 逐帧渲染 canvas(浏览器:SVG → img → canvas)
* @returns {Promise<Array<HTMLCanvasElement>>}
*/
async function renderFrames(plan, size, anim, opts) {
const a = HGB_Motion.normalize(anim);
const n = HGB_Motion.frameCount(a);
const bg = (opts && opts.bg) || '#FFFFFF';
const els = markElements(plan);
const frames = [];
for (let i = 0; i < n; i++) {
const t = HGB_Motion.frameT(i, a);
const svg = buildFrameSvg(els, bg, size, t, a, opts);
frames.push(await HGB_Exporter.renderCanvas(size, bg === 'transparent' ? null : bg, svg));
}
return frames;
}
/** 导出 GIF Blob */
async function exportGif(plan, size, anim, opts) {
const a = HGB_Motion.normalize(anim);
const frames = await renderFrames(plan, size, a, opts);
return HGB_GifEncoder.encodeBlob(frames, {
delay: 1000 / a.fps,
transparent: (opts && opts.bg) === 'transparent'
});
}
/** 导出 WebM(浏览器:canvas.captureStream + MediaRecorder) */
function exportWebm(plan, size, anim, opts) {
return new Promise((resolve, reject) => {
if (typeof MediaRecorder === 'undefined') {
reject(new Error('当前浏览器不支持 MediaRecorder(无法导出 WebM)'));
return;
}
const a = HGB_Motion.normalize(anim);
const bg = (opts && opts.bg) || '#FFFFFF';
const transparent = bg === 'transparent';
const els = markElements(plan);
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream(a.fps);
const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
.find(m => MediaRecorder.isTypeSupported(m)) || '';
const rec = new MediaRecorder(stream, {
mimeType: mime || undefined,
videoBitsPerSecond: Math.round(size * size * a.fps * 0.25)
});
const chunks = [];
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
rec.onstop = () => resolve(new Blob(chunks, { type: mime || 'video/webm' }));
rec.onerror = () => reject(new Error('WebM 录制失败'));
const durationMs = a.duration * 1000;
const start = performance.now();
let raf;
const draw = (now) => {
const t = HGB_Motion.previewT(now - start, a);
const svg = buildFrameSvg(els, bg, size, t, a, opts);
const img = new Image();
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
img.onload = () => {
ctx.clearRect(0, 0, size, size);
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, size, size); }
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
if (performance.now() - start >= durationMs) {
cancelAnimationFrame(raf);
rec.stop();
} else {
raf = requestAnimationFrame(draw);
}
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('WebM 帧渲染失败')); };
img.src = url;
};
rec.start();
raf = requestAnimationFrame(draw);
});
}
/** 导出静态 PNG(动画初始帧 t=0) */
async function exportPng(plan, size, bg) {
const svg = buildFrameSvg(markElements(plan), bg || '#FFFFFF', size, 0, HGB_Motion.normalize(plan.anim), {});
return HGB_Exporter.renderCanvas(size, (bg || '#FFFFFF') === 'transparent' ? null : (bg || '#FFFFFF'), svg);
}
/** 当前预览帧 SVG(预览弹窗用 buildFrameSvg 可复用,此接口供下载 SVG 静态帧) */
function exportSvg(plan, size, bg, t, anim) {
return buildFrameSvg(markElements(plan), bg || '#FFFFFF', size || 240, t || 0, HGB_Motion.normalize(anim || plan.anim), {});
}
return {
EMOJI_LAYOUTS, EMOJI_SIZES,
generateEmojiSet, expandAnimations, generateVariant,
buildFrameSvg, colorizeIconSvg, contentBox, markElements,
renderFrames, exportGif, exportWebm, exportPng, exportSvg
};
})();
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiGen;
+430
View File
@@ -0,0 +1,430 @@
/**
* 画个标 · 核心:导出功能
* 支持:SVG 矢量导出、PNG / JPG 位图导出(多尺寸规格)、PDF 文档导出
*/
const HGB_Exporter = (function () {
'use strict';
const SVG_NS = 'http://www.w3.org/2000/svg';
/** 尺寸规格预设(PNG/JPG 应用场景) */
const SIZES = [
{ name: '社交媒体头像', size: 256, note: '微信/微博头像' },
{ name: '方形通用', size: 512, note: '网站 LOGO' },
{ name: '高清印刷', size: 1024, note: '名片/宣传物料' },
{ name: '超清大图', size: 2048, note: '海报/横幅' }
];
/* ============ SVG 构建 ============ */
/** 估算文本宽度(与 generator 一致:中文全角、西文 0.55、计入字距) */
function textWidthOf(text, fontSize, spacing) {
const t = String(text || '');
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
const other = t.length - cn;
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
}
/** 元素视觉包围盒(文字按估算宽度水平居中、按字号定高;环形文字用外接圆盒) */
function elementBox(el) {
if (!el || el.visible === false) return null;
if (el.type === 'text' && !el.arc) {
const fs = el.fontSize || 40;
const w = Math.max(textWidthOf(el.text, fs, el.letterSpacing), 8);
const bw = el.w || w, bh = el.h || fs;
return { x: el.x + (bw - w) / 2, y: el.y + (bh - fs) / 2, w, h: fs };
}
return { x: el.x, y: el.y, w: el.w, h: el.h };
}
/** 全部可见元素的内容包围盒(无内容返回 null) */
function contentBox(elements) {
let box = null;
for (const el of elements || []) {
const b = elementBox(el);
if (!b) continue;
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
else {
const x2 = Math.max(box.x + box.w, b.x + b.w);
const y2 = Math.max(box.y + box.h, b.y + b.h);
box.x = Math.min(box.x, b.x);
box.y = Math.min(box.y, b.y);
box.w = x2 - box.x;
box.h = y2 - box.y;
}
}
return box;
}
/**
* 由任意元素数组构建 SVG(用于方案库预览/导出)
* @param {Array} elements 元素数组
* @param {string} bgColor 背景色
* @param {number} [size] 输出边长(默认画布 600)
* @param {object} [opts] { trim: 是否按内容包围盒裁剪 viewBox(内容顶满画布,适合头像/favicon),
* pad: 内容四周留白比例(默认 0.05) }
*/
function buildSvgFrom(elements, bgColor, size, opts) {
const W = size || HGB_Store.CANVAS_W, H = size || HGB_Store.CANVAS_H;
const bg = bgColor || '#FFFFFF';
// 内容包围盒裁剪:viewBox 收敛到内容 + 少量留白,内容顶满画布
let vx = 0, vy = 0, vw = W, vh = H;
if (opts && opts.trim) {
const box = contentBox(elements);
if (box) {
const pad = (opts.pad != null ? opts.pad : 0.05) * Math.max(box.w, box.h);
vx = box.x - pad; vy = box.y - pad;
vw = box.w + pad * 2; vh = box.h + pad * 2;
if (vw < 4) { vx -= 2; vw = 4; }
if (vh < 4) { vy -= 2; vh = 4; }
}
}
// 收集渐变 defs(元素以 el.grad 声明,需注入 <defs> 才能用 url(#id) 填充)
// 收集环形文字路径(text 带 el.arc 时注入 path)
let defs = '';
const grads = {};
const arcs = [];
for (const el of elements) {
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
if (el && el.arc) arcs.push(el);
}
if (Object.keys(grads).length || arcs.length) {
let d = '<defs>';
for (const id of Object.keys(grads)) {
const g = grads[id];
if (g.type === 'radial') {
d += `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78">` +
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
'</radialGradient>';
} else {
d += `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
'</linearGradient>';
}
}
for (const el of arcs) {
d += `<path id="arc_${esc(el.id)}" d="${esc(arcPathD(el.arc))}" fill="none" stroke="none"/>`;
}
defs = d + '</defs>';
}
let inner = '';
inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
if (defs) inner += defs;
for (const el of elements) {
if (!el.visible) continue;
inner += buildElementSvg(el);
}
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
`<svg xmlns="${SVG_NS}" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
inner + '</svg>';
}
/** 生成当前设计的导出 SVG 字符串(不含选中框/手柄) */
function buildExportSvg(bgColor) {
const s = HGB_Store.get();
return buildSvgFrom(s.elements, bgColor || s.bg || '#FFFFFF');
}
function buildElementSvg(el) {
const cx = el.x + el.w / 2;
const cy = el.y + el.h / 2;
const opacity = el.opacity != null ? ` opacity="${el.opacity}"` : '';
if (el.type === 'icon') {
// 图标源为 24x24 viewBox:先缩放到元素尺寸,再平移到目标位置
const sx = (el.w || 24) / 24, sy = (el.h || 24) / 24;
let t = `translate(${el.x} ${el.y}) scale(${sx.toFixed(4)} ${sy.toFixed(4)})`;
if (el.rotation) {
// 绕元素中心旋转(在平移缩放后的坐标系中)
t = `translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy}) ${t}`;
}
return `<g transform="${t}"${opacity}>${applyIconStyle(el)}</g>`;
}
if (el.type === 'text') {
let transform = '';
if (el.rotation) {
transform = ` transform="translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})"`;
}
const f = el.fontFamily || 'sans-serif';
const attrs = [
'text-anchor="middle"', 'dominant-baseline="central"',
'font-family="' + esc(f) + '"',
'font-size="' + (el.fontSize || 40) + '"',
'font-weight="' + (el.fontWeight || 400) + '"',
'fill="' + esc(el.fill || '#000') + '"',
el.letterSpacing ? 'letter-spacing="' + el.letterSpacing + '"' : '',
el.stroke && el.stroke !== 'none'
? ` stroke="${esc(el.stroke)}" stroke-width="${el.strokeWidth}"` : '',
'xml:space="preserve"'
].filter(Boolean).join(' ');
if (el.arc) {
// 环形文字:沿注入的圆弧 path 排列
return `<text${transform}${opacity} ${attrs}><textPath href="#arc_${esc(el.id)}" startOffset="50%">${esc(el.text || '')}</textPath></text>`;
}
return `<text${transform}${opacity} x="${cx}" y="${cy}" ${attrs}>${esc(el.text || '')}</text>`;
}
return '';
}
/** 生成圆弧路径 d(环形文字用,与 renderer 保持一致) */
function arcPathD(arc) {
const a1 = arc.start * Math.PI / 180;
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
const x1 = arc.cx + arc.r * Math.cos(a1);
const y1 = arc.cy + arc.r * Math.sin(a1);
const x2 = arc.cx + arc.r * Math.cos(a2);
const y2 = arc.cy + arc.r * Math.sin(a2);
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
}
function applyIconStyle(el) {
// 多色素材(emoji/品牌)保留原色:直接内嵌原始 svg body,不覆盖 fill/stroke
if (el.preserve) return el.svg;
const tmp = document.createElementNS(SVG_NS, 'g');
tmp.innerHTML = el.svg;
tmp.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
else p.setAttribute('fill', 'none');
if (el.stroke && el.stroke !== 'none') {
p.setAttribute('stroke', el.stroke);
// stroke-width 为"视觉像素"宽度:non-scaling-stroke 保证不随整体 scale 放大变粗
p.setAttribute('stroke-width', el.strokeWidth);
p.setAttribute('vector-effect', 'non-scaling-stroke');
p.setAttribute('stroke-linecap', 'round');
p.setAttribute('stroke-linejoin', 'round');
} else {
p.setAttribute('stroke', 'none');
}
});
return tmp.innerHTML;
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ============ 位图渲染(SVG -> Canvas) ============ */
/**
* 将当前设计渲染到指定尺寸的 canvas
* @param {number} size 输出边长(px)
* @param {string|null} bgColor 背景色;null 表示透明
*/
function renderCanvas(size, bgColor, svgOverride) {
return new Promise((resolve, reject) => {
const svgStr = svgOverride ||
buildExportSvg(bgColor === undefined ? null : bgColor);
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (bgColor !== null && bgColor !== undefined) {
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
}
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
resolve(canvas);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('SVG 渲染失败,请检查元素内容'));
};
img.src = url;
});
}
/* ============ 导出:SVG ============ */
function exportSvg(svgOverride, bgColor) {
const svgStr = svgOverride || buildExportSvg(bgColor || HGB_Store.get().bg || '#FFFFFF');
download(svgStr, 'image/svg+xml', safeName() + '.svg');
return svgStr;
}
/* ============ 导出:PNG ============ */
async function exportPng(size = 1024, bgColor, svgOverride) {
const canvas = await renderCanvas(size, bgColor, svgOverride);
const blob = await canvasToBlob(canvas, 'image/png');
download(blob, 'image/png', safeName() + '_' + size + 'px.png');
return canvas;
}
/* ============ 导出:JPG ============ */
async function exportJpg(size = 1024, bgColor = '#FFFFFF', svgOverride) {
const canvas = await renderCanvas(size, bgColor, svgOverride);
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.92);
download(blob, 'image/jpeg', safeName() + '_' + size + 'px.jpg');
return canvas;
}
/* ============ 导出:PDF(手写最小 PDF,嵌入 JPEG) ============ */
async function exportPdf(size = 1024, bgColor = '#FFFFFF', svgOverride) {
// 1. 渲染 JPEG
const canvas = await renderCanvas(size, bgColor, svgOverride);
const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.92);
const jpegBase64 = jpegDataUrl.split(',')[1];
// 2. 构建 PDF(单页,图片全页)
const pt = Math.round(size * 72 / 96); // 96dpi → pt(近似 A4 常用分辨率换算)
const pdf = buildPdf(jpegBase64, pt, pt);
const blob = new Blob([pdf], { type: 'application/pdf' });
download(blob, 'application/pdf', safeName() + '_' + size + 'px.pdf');
return blob;
}
/**
* 手写最小 PDF 1.4 文件
* 结构:Catalog → Pages → Page → XObject(Image) + Content stream
*/
function buildPdf(jpegBase64, pageW, pageH) {
// 将 base64 解码为字节
const bin = atob(jpegBase64);
const imgBytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) imgBytes[i] = bin.charCodeAt(i);
const objects = [];
// Object 1: Catalog
objects.push('<< /Type /Catalog /Pages 2 0 R >>');
// Object 2: Pages
objects.push(`<< /Type /Pages /Kids [3 0 R] /Count 1 >>`);
// Object 3: Page
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] ` +
`/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`
);
// Object 4: Image XObject(JPEG 流)
objects.push(
`<< /Type /XObject /Subtype /Image /Width ${pageW} /Height ${pageH} ` +
`/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${imgBytes.length} >>`
);
// Object 5: Content(绘制图片铺满页面)
const content = `q\n${pageW} 0 0 ${pageH} 0 0 cm\n/Im0 Do\nQ`;
objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
// 组装 PDF 字节流
const encoder = new TextEncoder();
const chunks = [];
chunks.push(encoder.encode('%PDF-1.4\n'));
const offsets = [];
for (let i = 0; i < objects.length; i++) {
offsets.push(chunks.reduce((a, c) => a + c.length, 0));
const header = `${i + 1} 0 obj\n`;
chunks.push(encoder.encode(header));
if (i === 3) {
// 图片对象:字典 + stream
chunks.push(encoder.encode(objects[i] + '\nstream\n'));
chunks.push(imgBytes);
chunks.push(encoder.encode('\nendstream\n'));
} else {
chunks.push(encoder.encode(objects[i] + '\n'));
}
chunks.push(encoder.encode('endobj\n'));
}
const xrefStart = chunks.reduce((a, c) => a + c.length, 0);
const xref = `xref\n0 ${objects.length + 1}\n` +
'0000000000 65535 f \n' +
offsets.map(o => String(o).padStart(10, '0') + ' 00000 n \n').join('') +
`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` +
'startxref\n' + xrefStart + '\n%%EOF';
chunks.push(encoder.encode(xref));
return new Blob(chunks, { type: 'application/pdf' });
}
/* ============ 一键导出全部尺寸 ============ */
async function exportAllSizes(bgColor) {
const results = [];
for (const spec of SIZES) {
const canvas = await exportPng(spec.size, bgColor);
results.push({ spec, canvas });
}
return results;
}
/* ============ 头像导出(圆形/方形 多规格 PNG) ============ */
/** 将 canvas 圆形裁切(以画布中心为圆心,四周透明) */
function circleCropCanvas(canvas) {
const s = canvas.width;
const out = document.createElement('canvas');
out.width = s;
out.height = s;
const ctx = out.getContext('2d');
ctx.beginPath();
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(canvas, 0, 0);
return out;
}
/**
* 批量导出头像 PNG(多平台规格)
* @param {string} svgOverride 头像 SVG 字符串(应已含背景 rect)
* @param {Array<number>} sizes 尺寸列表(px)
* @param {object} [opts] { bg: 背景色(null=透明,仅 SVG 内 rect 提供背景),
* round: 是否圆形裁切, base: 文件名前缀 }
* @returns {Promise<Array<{size:number, canvas:HTMLCanvasElement}>>}
*/
async function exportAvatarPngs(svgOverride, sizes, opts) {
const o = opts || {};
const results = [];
for (const size of sizes) {
const canvas = await renderCanvas(size, o.bg, svgOverride);
const out = o.round ? circleCropCanvas(canvas) : canvas;
const blob = await canvasToBlob(out, 'image/png');
const suffix = o.round ? '_圆形' : '';
download(blob, 'image/png', (o.base || safeName()) + '_' + size + suffix + 'px.png');
results.push({ size, canvas: out });
}
return results;
}
/* ============ 工具 ============ */
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error(type + ' 编码失败')), type, quality);
});
}
function download(data, mime, filename) {
const blob = data instanceof Blob ? data : new Blob([data], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function safeName() {
const n = (HGB_Store.get().name || '画个标设计').replace(/[\\/:*?"<>|]/g, '_');
return n || '画个标设计';
}
return {
SIZES, buildExportSvg, buildSvgFrom, renderCanvas,
exportSvg, exportPng, exportJpg, exportPdf, exportAllSizes,
exportAvatarPngs, circleCropCanvas,
buildPdf, download, safeName
};
})();
+773
View File
@@ -0,0 +1,773 @@
/**
* 画个标 · 智能设计引擎(图形标识 Mark + 品牌组合 Lockup 双层架构)
* 核心理念:
* - 图形标识(Mark):纯图形设计(容器/环/图标/装饰),不含任何文字 —— 这是"LOGO 设计"本身
* - 品牌组合(Lockup):在 Mark 完成之后,把品牌名摆放到图形周边(下方/右侧/上方/环绕)
* - 品牌名绝不"融"进图形内部,只作为周边排版层存在
* 落地 GitHub logo-design-skill 设计方法论:
* - 元素坐标一律以"中心点"传递,杜绝文字/图标偏离画布中心轴
* - 布局 = 图形容器/环/弧/绶带 + 图标的三层构图,而非"换个位置"
* - 深色主色填充的容器(盾/圆/方)承载白色图标,形成徽章化视觉
*/
const HGB_Generator = (function () {
'use strict';
const CW = 600, CH = 600;
let seq = 0;
/* ============ 字体选择 ============ */
// 覆盖风格库 font/fontCn 的全部取值,保证风格字体真正生效(否则 fontFor 回退默认)
const CN_FONTS = ['SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', '宋体', '微软雅黑', '楷体', '黑体'];
const EN_FONTS = ['Arial', 'Georgia', 'Impact', 'Verdana', 'Montserrat', 'Playfair Display',
'Comic Sans MS', 'Times New Roman', 'Bradley Hand', 'Arial Black'];
/** 根据文本内容与行业推荐字体,选择合适字体 */
function fontFor(text, favs) {
const hasCn = /[\u4e00-\u9fa5]/.test(text || '');
const pool = hasCn ? CN_FONTS : EN_FONTS;
const fav = (favs || []).find(f => pool.includes(f));
return fav || (hasCn ? 'SimHei' : 'Arial');
}
/* ============ 文字测量与自适应(防"破版"核心) ============ */
/** 估算文本渲染宽度:中文全角、西文按 0.55 倍、计入字距 */
function textWidth(text, fontSize, spacing) {
const t = String(text || '');
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
const other = t.length - cn;
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
}
/** 从基准字号向下收缩,直到文本能放进 maxWidth(下限 min) */
function fitFontSize(text, maxWidth, base, spacing, min) {
const t = String(text || '');
if (!t) return base;
const mn = min || 18;
let size = base;
while (size > mn && textWidth(t, size, spacing || 0) > maxWidth) size -= 2;
return Math.round(size);
}
/** 超宽文本截断加省略号 */
function clipText(text, maxWidth, size, spacing) {
const t = String(text || '');
if (!t || textWidth(t, size, spacing || 0) <= maxWidth) return t;
let cut = t;
while (cut.length > 1 && textWidth(cut + '…', size, spacing || 0) > maxWidth) cut = cut.slice(0, -1);
return cut + '…';
}
/** 品牌名是否适合环形环绕等紧凑排版(中文 ≤6 字或西文 ≤12 字符) */
function isShort(brand) {
const t = String(brand || '').trim();
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
return cn ? cn <= 6 : t.length <= 12;
}
/** 按品牌名长度选择默认 Mark 布局 */
function defaultLayout(brand) {
return LAYOUTS.find(l => l.id === 'orb') || LAYOUTS[0];
}
/** 风格倾向布局:取风格 layouts 列表第一个有效布局(可再按白名单过滤;无则 null) */
function favLayout(style, whitelist) {
let favs = (style && style.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
if (whitelist && whitelist.length) favs = favs.filter(id => whitelist.includes(id));
// LOGO 模式(无白名单)排除头像专有布局
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
if (!favs.length) return null;
return LAYOUTS.find(l => l.id === favs[0]) || null;
}
/** 布局兜底:白名单优先轮换,否则全局轮换(排除头像专有布局) */
function layoutFallback(i, whitelist) {
if (whitelist && whitelist.length) {
const l = LAYOUTS.find(x => x.id === whitelist[i % whitelist.length]);
if (l) return l;
}
const pool = LAYOUTS.filter(l => !l.avatarOnly);
return pool[i % pool.length];
}
/* ============ 元素构造(一律以"中心点"定位) ============ */
function iconEl(ic, cx, cy, w, h, stroke, strokeW) {
return {
id: uid('ic'), type: 'icon',
name: (ic && ic.name) || '图标', iconId: (ic && ic.id) || '',
svg: (ic && ic.svg) || '',
// 多色素材(emoji/品牌):preserve=true 时导出/预览保留原始颜色,不按 stroke/fill 覆盖
preserve: !!(ic && ic.type && ic.type !== 'icon'),
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: 'none', stroke, strokeWidth: strokeW,
opacity: 1, visible: true, locked: false
};
}
function ringEl(cx, cy, w, h, stroke, strokeW) {
return {
id: uid('rg'), type: 'icon', name: '圆环', iconId: 'ring',
svg: '<circle cx="12" cy="12" r="10"/>',
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: 'none', stroke, strokeWidth: strokeW,
opacity: 1, visible: true, locked: false
};
}
function textEl(text, cx, cy, w, h, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
return {
id: uid('tx'), type: 'text', name: '文字', text,
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fontSize, fontWeight, fontFamily, letterSpacing,
fill, stroke: 'none', strokeWidth: 0,
opacity: 1, visible: true, locked: false
};
}
/** 环形文字(textPath,品牌名环绕 Mark 用) */
function arcTextEl(text, arc, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
return {
id: uid('at'), type: 'text', name: '环形文字', text,
x: Math.round(arc.cx - arc.r), y: Math.round(arc.cy - arc.r),
w: Math.round(arc.r * 2), h: Math.round(arc.r * 2), rotation: 0,
fontSize, fontWeight, fontFamily, letterSpacing,
fill, stroke: 'none', strokeWidth: 0,
arc, opacity: 1, visible: true, locked: false
};
}
/** 装饰原语(中心定位包装) */
function decoAt(decId, cx, cy, w, h, color, grad) {
return HGB_Decorations.decoEl(decId, Math.round(cx - w / 2), Math.round(cy - h / 2), w, h, color, grad);
}
/** 色条:铺满元素的圆角矩形 */
function barEl(cx, cy, w, h, color, grad) {
return {
id: uid('br'), type: 'icon', name: '色条', iconId: 'deco-bar',
svg: '<rect x="0" y="0" width="24" height="24" rx="12"/>',
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: grad ? 'url(#' + grad.id + ')' : color,
stroke: 'none', strokeWidth: 0, grad: grad || null,
opacity: 1, visible: true, locked: false
};
}
function uid(prefix) {
return prefix + '_' + Date.now().toString(36) + (seq++).toString(36);
}
/* ============ 渐变 ============ */
/** 主色线性渐变(容器填充用) */
function mainGrad(palette) {
return {
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
type: 'linear',
from: palette.colors[0],
to: HGB_Decorations.lighten(palette.colors[0], 0.30)
};
}
/** 径向渐变(圆标用,中心亮边缘深) */
function radialGrad(palette) {
return {
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
type: 'radial',
from: HGB_Decorations.lighten(palette.colors[0], 0.42),
to: palette.colors[0]
};
}
/** 首字符(字标用):中文取首字,西文取大写首字母 */
function initialOf(text) {
const t = String(text || '').trim();
if (!t) return 'A';
const ch = t[0];
return /[\u4e00-\u9fa5]/.test(ch) ? ch : ch.toUpperCase();
}
/* ============ 图形标识模板(Mark,只出纯图形,无文字) ============ */
/**
* 每个模板 build(opts) 返回 mark 图形元素数组
* opts = { brand, sub, icon, icon2, palette, style, fontCn, fontEn, strokeW, cx, cy }
* cx/cy = 图形中心(默认 300,300);palette.colors = [主色, 辅助色, 点缀色, 背景]
* round = true 表示圆形构图(可配环形环绕 Lockup)
*/
const LAYOUTS = [
{
id: 'badge', name: '双环徽章', emoji: '🎖️', round: true,
desc: '外环+内双线环承载图标,权威与荣耀',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c1 = palette.colors[1];
const els = [];
els.push(ringEl(cx0, cy0, 380, 380, c1, strokeW * 1.5)); // 外环 r=190
els.push(decoAt('deco-doublering', cx0, cy0, 280, 280, c1, null)); // 内双线环 r≈98/65
els.push(iconEl(icon, cx0, cy0, 128, 128, c0, strokeW)); // 图标居中
return els;
}
},
{
id: 'seal', name: '双环印章', emoji: '🕹️', round: true,
desc: '官方认证感,双环+星点',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 2)); // 外环 r=200
els.push(ringEl(cx0, cy0, 320, 320, c1, strokeW)); // 内环 r=160
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 150, 26, 26, c2, null)); // 底部星点
els.push(iconEl(icon, cx0, cy0 - 10, 110, 106, c0, strokeW));
return els;
}
},
{
id: 'emblem', name: '渐变盾徽', emoji: '🛡️',
desc: '渐变盾形承载白色图标,学院派权威',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const els = [];
els.push(decoAt('deco-shape-shield', cx0, cy0, 360, 360, null, g)); // 渐变盾形
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW)); // 白色图标
return els;
}
},
{
id: 'orb', name: '圆标容器', emoji: '🔮', round: true,
desc: '径向渐变圆承载白色图标,现代应用图标风',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const g = radialGrad(palette);
const els = [];
els.push(decoAt('deco-shape-circle', cx0, cy0, 320, 320, null, g)); // 径向渐变圆 r=160
els.push(iconEl(icon, cx0, cy0, 130, 130, '#FFFFFF', strokeW * 1.2));
els.push(decoAt('deco-dotring', cx0, cy0, 380, 380, c1, null)); // 点状光环 r≈170
return els;
}
},
{
id: 'monogram', name: '首字字标', emoji: '🔠',
desc: '渐变圆角容器承载品牌首字,字标风',
build(opts) {
const { brand, palette, fontCn, fontEn, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c2 = palette.colors[2];
const g = mainGrad(palette);
const letter = initialOf(brand);
const els = [];
els.push(decoAt('deco-shape-squircle', cx0, cy0, 280, 280, null, g)); // 渐变圆角容器
const lf = /[\u4e00-\u9fa5]/.test(letter) ? fontFor(brand, [fontCn, fontEn]) : fontFor(brand, [fontEn, fontCn]);
const lfs = fitFontSize(letter, 200, 158, 0, 64);
els.push(textEl(letter, cx0, cy0, 200, 190, lf, lfs, 800, '#FFFFFF', 0)); // 白色大字
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 132, 30, 30, c2, null)); // 底部点缀菱形
return els;
}
},
{
id: 'ribbon', name: '绶带横幅', emoji: '🎗️',
desc: '主色绶带+图标,庆典仪式感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-arc-top', cx0, cy0 - 62, 280, 160, c2, null)); // 顶部弧线
els.push(iconEl(icon, cx0, cy0 - 68, 140, 140, c0, strokeW * 1.3));
els.push(decoAt('deco-shape-ribbon', cx0, cy0 + 82, 460, 150, c0, null)); // 绶带(主色填充,无文字)
els.push(decoAt('deco-shape-pill', cx0, cy0 + 176, 210, 40, c2, null)); // 下方点缀胶囊
return els;
}
},
{
id: 'hexagon', name: '六边形徽章', emoji: '⬡',
desc: '渐变六边形承载白色图标,秩序感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-hexagon', cx0, cy0, 320, 320, null, g));
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 156, 22, 22, c2, null));
return els;
}
},
{
id: 'diamond', name: '菱形徽章', emoji: '💎',
desc: '渐变菱形承载白色图标,精品感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-diamond', cx0, cy0, 320, 320, null, g));
els.push(iconEl(icon, cx0, cy0, 120, 120, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-circle', cx0, cy0 + 152, 18, 18, c2, null));
return els;
}
},
{
id: 'techcorner', name: '科技角标', emoji: '🪐',
desc: '对角括号框定图标+光芒,科技秩序感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-corner-tl', cx0 - 230, cy0 - 230, 140, 140, c1, null));
els.push(decoAt('deco-corner-br', cx0 + 230, cy0 + 230, 140, 140, c1, null));
els.push(decoAt('deco-spark', cx0 + 168, cy0 - 182, 48, 48, c2, null));
els.push(iconEl(icon, cx0, cy0, 140, 140, c1, strokeW * 1.2));
return els;
}
},
{
id: 'duo', name: '双图标对称', emoji: '🪞', round: true,
desc: '双图标对称弧拱,均衡呼应',
build(opts) {
const { icon, icon2, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-arc-top', cx0, cy0 - 88, 320, 170, c1, null)); // 顶部连接弧
els.push(iconEl(icon, cx0 - 152, cy0 + 18, 124, 124, c1, strokeW));
els.push(iconEl(icon2 || icon, cx0 + 152, cy0 + 18, 124, 124, c1, strokeW));
els.push(decoAt('deco-shape-diamond', cx0, cy0 - 176, 30, 30, c2, null));
return els;
}
},
{
id: 'arch', name: '拱门徽章', emoji: '🏛️',
desc: '渐变拱门承载白色图标,学院殿堂感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-arch', cx0, cy0, 300, 330, null, g)); // 渐变拱形衬底
els.push(iconEl(icon, cx0, cy0 + 16, 118, 118, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 152, 26, 26, c2, null)); // 底部点缀
return els;
}
},
{
id: 'burst', name: '光芒徽章', emoji: '☀️', round: true,
desc: '射线环+渐变圆承载图标,光芒与能量',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const g = radialGrad(palette);
const els = [];
els.push(decoAt('deco-rayring', cx0, cy0, 396, 396, c1, null)); // 12 根射线环 r≈198
els.push(decoAt('deco-shape-circle', cx0, cy0, 264, 264, null, g)); // 径向渐变圆 r=132
els.push(iconEl(icon, cx0, cy0, 116, 116, '#FFFFFF', strokeW * 1.1));
return els;
}
},
{
id: 'geo', name: '几何构形', emoji: '📐',
desc: '圆环+渐变三角黄金叠加,精确几何感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const g = mainGrad(palette);
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.8)); // 外圆环 r=200
els.push(decoAt('deco-shape-triangle', cx0, cy0 + 12, 300, 280, null, g)); // 渐变三角衬底
els.push(iconEl(icon, cx0, cy0 + 8, 116, 116, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 116, 22, 22, c2, null));
return els;
}
},
/* ===== 头像专有布局(avatarOnly:仅头像模式白名单使用,LOGO 模式全局池排除) ===== */
{
id: 'fullbleed', name: '全出血图标', emoji: '🖼️', avatarOnly: true,
desc: '无容器,图标直接顶满画布,纯图形大图标头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0];
const els = [];
els.push(iconEl(icon, cx0, cy0, 232, 232, c0, strokeW * 2)); // 大图标占 77%
return els;
}
},
{
id: 'concentric', name: '同心圆环', emoji: '🌀', avatarOnly: true,
desc: '三重同心圆环+中心图标,韵律感头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.2)); // 外环 r=200
els.push(ringEl(cx0, cy0, 330, 330, c1, strokeW * 0.8)); // 中环 r=165
els.push(ringEl(cx0, cy0, 240, 240, c2, strokeW)); // 内环 r=120
els.push(iconEl(icon, cx0, cy0, 120, 120, c1, strokeW * 1.1));
return els;
}
},
{
id: 'centroid', name: '中心单环', emoji: '⭕', avatarOnly: true,
desc: '细圆环+居中大图标,简洁单图标头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const els = [];
els.push(ringEl(cx0, cy0, 372, 372, c1, strokeW * 1.3)); // 外环 r=186
els.push(iconEl(icon, cx0, cy0, 158, 158, c1, strokeW * 1.5)); // 大图标
return els;
}
}
];
/* ============ 品牌组合位置(Lockup) ============ */
/**
* 品牌名在 Mark 图形周边的摆放方式
* pos: below(下方居中) / right(右侧并排) / top(上方居中) / wrap(环形环绕)
*/
const LOCKUP_POS = [
{ pos: 'below', name: '下方居中' },
{ pos: 'right', name: '右侧并排' },
{ pos: 'top', name: '上方居中' },
{ pos: 'wrap', name: '环形环绕' }
];
/** 计算 Mark 的图形元素(以指定中心生成一次,供 Lockup 复用) */
function buildMark(layout, opts, cx, cy) {
return layout.build({ ...opts, cx, cy });
}
/**
* 生成单个 Lockup 组合(Mark + 品牌名在 pos 位置)
* @returns {Array} 完整元素数组
*/
function buildLockup(layout, pos, opts) {
const brand = opts.brand;
const sub = opts.sub;
const palette = opts.palette;
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
const fontCn = opts.fontCn, fontEn = opts.fontEn;
const els = [];
if (pos === 'wrap') {
// 环形环绕:仅圆形 Mark + 短品牌名
els.push(...buildMark(layout, opts, 300, 300));
const rOut = 214;
const fs = fitFontSize(brand, rOut * 2, 36, 3, 18);
els.push(arcTextEl(brand,
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: 180 },
fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, rOut * 1.6, 15, 3, 11);
// 副标题走"下半弧":start=180 起、逆时针(负sweep)经过底部到 0°,文字沿底部自左向右正立
els.push(arcTextEl(clipText(sub, rOut * 1.6, sfs, 3),
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: -180 },
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
if (pos === 'below') {
// Mark 偏上居中,品牌名在正下方
els.push(...buildMark(layout, opts, 300, 246));
els.push(barEl(300, 470, 120, 8, c2));
const MAXW = 460;
const fs = fitFontSize(brand, MAXW, 46, 3, 22);
els.push(textEl(brand, 300, 512, MAXW, 62, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, 380, 16, 3, 12);
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 560, 380, 24,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
if (pos === 'right') {
// Mark 居左,品牌名右侧左对齐
els.push(...buildMark(layout, opts, 232, 300));
const MAXW = 150;
const fs = fitFontSize(brand, MAXW, 42, 1, 18);
const tw = textWidth(brand, fs, 1);
const cxT = 430 + tw / 2;
els.push(textEl(brand, cxT, 286, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 1));
els.push(barEl(cxT, 352, 60, 7, c2));
if (sub) {
const sfs = fitFontSize(sub, MAXW, 14, 2, 11);
els.push(textEl(clipText(sub, MAXW, sfs, 2), cxT, 392, MAXW, 22,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 2));
}
return els;
}
// top:Mark 偏下居中,品牌名在正上方
els.push(...buildMark(layout, opts, 300, 356));
els.push(barEl(300, 222, 120, 8, c2));
const MAXW = 460;
const fs = fitFontSize(brand, MAXW, 42, 3, 22);
els.push(textEl(brand, 300, 178, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, 380, 15, 3, 12);
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 132, 380, 22,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
/* ============ 方案生成 ============ */
/**
* 组合单个方案(供批量生成与变体微调复用)
* @returns {{id,name,layoutId,layoutName,paletteName,styleId,bg,
* markElements:Array, lockups:Array, elements:Array}}
*/
function composePlan(opt, layout, palette, style) {
const brand = opt.brand;
const sub = opt.sub;
const icon1 = opt.icon1;
const icon2 = opt.icon2;
const fontFavs = opt.fontFavs || [];
// 视觉描边宽度(屏幕像素):配合 exporter 的 non-scaling-stroke 恒定不随放大变粗
const strokeW = Math.max(3, Math.round(((style ? style.strokeWidth : 1.6) || 1.6) * 2));
const mkOpts = {
brand, sub,
icon: icon1,
icon2: icon2,
palette,
style,
// 风格字体优先(风格自包含),行业/AI 字体建议兜底
fontCn: (style && style.fontCn && CN_FONTS.includes(style.fontCn))
? style.fontCn
: (fontFavs.find(f => CN_FONTS.includes(f)) || (style && style.fontCn) || 'SimHei'),
fontEn: (style && style.font && EN_FONTS.includes(style.font))
? style.font
: (fontFavs.find(f => EN_FONTS.includes(f)) || (style && style.font) || 'Arial'),
strokeW
};
// 图形标识(Mark):纯图形,居中展示
const markElements = buildMark(layout, mkOpts, 300, 300);
// 品牌组合(Lockup):品牌名在图形周边各位置摆放
const lockups = LOCKUP_POS.filter(lp =>
lp.pos !== 'wrap' || (layout.round && isShort(brand))
).map(lp => ({
pos: lp.pos,
name: lp.name,
elements: buildLockup(layout, lp.pos, mkOpts)
}));
return {
id: 'plan_' + Date.now().toString(36) + (seq++).toString(36),
name: `${layout.name} · ${palette.name}`,
layoutId: layout.id,
layoutName: layout.name,
paletteName: palette.name,
styleId: style ? style.id : '',
bg: palette.colors[3] || '#FFFFFF',
markElements, // 区域一:图形标识(纯图形)
lockups, // 区域二:品牌组合(各位置)
elements: lockups.length ? lockups[0].elements : markElements // 默认下方组合
};
}
/** 收集选中图标池(去重,含行业图标;供批量生成轮换使用) */
function pickIcons(opt, ind) {
const picked = (opt.icons || []).filter(Boolean).slice(0, 5);
const indIcons = (ind.icons || []).filter(i => !picked.some(p => p.id === i.id));
const pool = [...picked, ...indIcons];
const iconPool = pool.filter((v, i) => v && pool.findIndex(x => x && x.id === v.id) === i);
return {
icon1: iconPool[0],
icon2: iconPool[1] || iconPool[0],
iconPool: iconPool.length ? iconPool : [icon1Fallback(ind)]
};
}
/** 行业图标兜底(找不到任何图标时) */
function icon1Fallback(ind) {
return (ind.icons || [])[0] || { id: 'star', name: '星形', svg: '<path d="M12 2l3.1 6.3 7 1-5.1 5 1.2 7L12 17.5 5.8 21.3l1.2-7-5.1-5 7-1z"/>' };
}
/**
* 风格池:用户指定风格优先,其次行业推荐优先,最后全局;始终有效
* @param {object} ind 行业对象(含 styleIds)
* @param {Array} [styleIds] 用户选中的风格 id(空/无效则全部)
*/
function stylePoolFor(ind, styleIds) {
const indIds = (ind.styleIds || []);
let pool = HGB_Styles.slice();
if (styleIds && styleIds.length) {
const wanted = pool.filter(s => styleIds.includes(s.id));
if (wanted.length) pool = wanted; // 全部无效则回退全部风格
}
// 行业推荐优先排序
return pool.sort((a, b) => (indIds.includes(b.id) ? 1 : 0) - (indIds.includes(a.id) ? 1 : 0));
}
/**
* 批量生成方案
* @param {object} opt
* industry: 行业对象(含 palettes/styleIds/fontFamilies/icons)
* brand: 品牌名(默认取行业默认名称或用户输入)
* sub: 副标题(可空)
* icons: 用户选中的图标数组 [{id,name,svg}],可为空(用行业图标轮换)
* styleIds: 用户选中的风格方向 id(空 = 全部风格)
* iconLocked: 图标驱动模式——每个选中图标固定作为核心图形,各产出 count 套(总方案 = 图标数 × count)
* count: 生成数量(图标驱动时 = 每个图标的套数)
* @returns {Array} 方案数组
*/
function generatePlans(opt) {
const ind = opt.industry || HGB_Industries[0];
const brand = (opt.brand || '').trim() || sampleName(ind);
const sub = (opt.sub || '').trim();
const count = Math.max(1, Math.min(20, opt.count || 6));
// 风格池:用户指定 > 行业推荐 > 全局
const stylePool = stylePoolFor(ind, opt.styleIds);
// 风格顺序洗牌:默认 5 套尽量覆盖不同风格,避免"同风格扎堆"
const styleOrder = shuffle(stylePool.slice());
// 布局白名单(可选):限定 Mark 布局范围(头像模式排除带文字的 monogram/ribbon)
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
// 每风格的倾向布局列表(预洗牌,保证每次生成构图有变化)
const styleLayouts = {};
for (const s of stylePool) {
let favs = (s.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
if (layoutWhitelist.length) favs = favs.filter(id => layoutWhitelist.includes(id));
// LOGO 模式(无白名单)排除头像专有布局
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
styleLayouts[s.id] = shuffle(favs);
}
const fontFavs = ind.fontFamilies || [];
// 图标驱动模式:每个选中图标固定作为核心图形,各产出 count 套(icon1=icon2=该图标)
const lockedIcons = opt.iconLocked
? (opt.icons || []).filter(Boolean).slice(0, 5)
: [];
if (lockedIcons.length) {
const plans = [];
for (const locked of lockedIcons) {
for (let j = 0; j < count; j++) {
const i = plans.length;
const style = styleOrder[i % styleOrder.length];
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 布局:有白名单时按白名单全集轮换;否则按风格倾向优先轮换
const favs = styleLayouts[style.id];
const layout = layoutWhitelist.length
? layoutFallback(i, layoutWhitelist)
: (favs.length
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
: layoutFallback(i, layoutWhitelist));
const plan = composePlan({
brand, sub, icon1: locked, icon2: locked, fontFavs
}, layout, palette, style);
// 记录锁定图标:变体微调(换配色/换布局)时保持同一图标
plan.lockedIcon = { id: locked.id, name: locked.name, svg: locked.svg, source: locked.source || '' };
plans.push(plan);
}
}
return plans;
}
const { iconPool } = pickIcons(opt, ind);
const plans = [];
for (let i = 0; i < count; i++) {
// 风格优先:每套方案由风格决定配色/背景/线宽/字体/构图
const style = styleOrder[i % styleOrder.length];
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 布局:有白名单时按白名单全集轮换(保证头像专有布局被覆盖);
// 否则按风格倾向优先轮换,无倾向则从全局轮换
const favs = styleLayouts[style.id];
const layout = layoutWhitelist.length
? layoutFallback(i, layoutWhitelist)
: (favs.length
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
: layoutFallback(i, layoutWhitelist));
// 图标轮换:每套方案使用不同图标,避免"全套一个图标"的雷同感
const a = iconPool[i % iconPool.length];
const b = iconPool[(i + 1) % iconPool.length];
plans.push(composePlan({
brand, sub, icon1: a, icon2: b, fontFavs
}, layout, palette, style));
}
return plans;
}
/**
* 生成单个变体(预览弹窗内微调用)
* @param {object} opt 与 generatePlans 相同,额外支持:
* layoutId: 指定 Mark 模板 id(不传则取风格倾向布局)
* styleId: 指定风格 id(不传则行业优先)
* 说明:配色不再独立传入,始终跟随 styleId 对应风格的色系(风格自包含)
* @returns {object} 单个方案
*/
function generateVariant(opt) {
const ind = opt.industry || HGB_Industries[0];
const brand = (opt.brand || '').trim() || sampleName(ind);
const sub = (opt.sub || '').trim();
const { icon1, icon2 } = pickIcons(opt, ind);
// 字体偏好:AI 决策优先,其次行业推荐
const fontFavs = (opt.fontFavs && opt.fontFavs.length)
? opt.fontFavs
: (ind.fontFamilies || []);
// 风格池:用户指定 > 行业推荐 > 全局
const stylePool = stylePoolFor(ind, opt.styleIds);
// 目标风格:指定或行业优先
const style = opt.styleId
? (stylePool.find(s => s.id === opt.styleId) || stylePool[0])
: stylePool[0];
// 配色始终跟随风格色系(风格自包含:颜色/背景/字重/线宽随风格整体切换)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 目标布局:指定且合法,否则取风格倾向布局(受白名单约束)
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
const layout = opt.layoutId
? (LAYOUTS.find(l => l.id === opt.layoutId) || layoutFallback(0, layoutWhitelist))
: (favLayout(style, layoutWhitelist) || layoutFallback(0, layoutWhitelist));
const plan = composePlan({ brand, sub, icon1, icon2, fontFavs }, layout, palette, style);
// 应用指定 Lockup 位置(默认 elements 为第一个位置)
if (opt.lockup) {
const hit = plan.lockups.find(lp => lp.pos === opt.lockup);
if (hit) plan.elements = hit.elements;
}
return plan;
}
/** 行业默认示例品牌名 */
function sampleName(ind) {
const map = {
edu: 'EDUCATION', food: '美食工坊', health: '仁心医疗',
tech: 'NEXUS', sport: '锐动体育', finance: '恒信资本'
};
return map[ind.id] || ind.name || '我的品牌';
}
/** 洗牌 */
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
return { generatePlans, generateVariant, sampleName, defaultLayout, isShort, LAYOUTS, LOCKUP_POS, CW, CH };
})();
+196
View File
@@ -0,0 +1,196 @@
/**
* 画个标 · 核心:GIF89a 编码器(手写,零依赖)
* 输入等尺寸 HTMLCanvasElement 帧数组 → 输出 GIF 字节(Uint8Array)
* 方案:每帧独立局部调色板(LCT,≤255 色 + 透明索引 0)+ LZW 变长编码
* 纯函数、零 DOM 依赖(读取像素由调用方传入 canvas),Node 无头可测字节结构
*/
const HGB_GifEncoder = (function () {
'use strict';
/** 读取 canvas 像素(RGBA Uint8ClampedArray) */
function readPixels(canvas) {
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
return { width, height, data: ctx.getImageData(0, 0, width, height).data };
}
/** 颜色频率 → 截断到 255 色;透明像素单独处理 */
function buildPalette(data, n, transparent) {
const freq = new Map();
let hasAlpha = false;
for (let i = 0; i < n; i++) {
const o = i * 4;
const a = data[o + 3];
if (a < 128) { hasAlpha = true; continue; }
const key = (data[o] << 16) | (data[o + 1] << 8) | data[o + 2];
freq.set(key, (freq.get(key) || 0) + 1);
}
// 频率降序截断 255(LCT 256 槽,索引 0 留透明占位)
const colors = Array.from(freq.entries())
.sort((x, y) => y[1] - x[1])
.slice(0, 255)
.map(e => e[0]);
const palette = new Array(256).fill(0x000000);
const maxIndex = transparent ? Math.min(colors.length, 255) + 1 : Math.min(colors.length, 256);
const start = transparent ? 1 : 0;
for (let c = 0; c < maxIndex - start; c++) palette[start + c] = colors[c];
return { palette, maxIndex, hasAlpha, colors };
}
/** 最近色匹配(RGB 欧氏距离) */
function nearestIndex(rgb, palette, start, maxIndex) {
const r = (rgb >> 16) & 0xff, g = (rgb >> 8) & 0xff, b = rgb & 0xff;
let best = start, bestD = Infinity;
for (let i = start; i < maxIndex; i++) {
const c = palette[i];
const dr = ((c >> 16) & 0xff) - r, dg = ((c >> 8) & 0xff) - g, db = (c & 0xff) - b;
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
/** 量化一帧:返回 LCT 字节数组 + 像素索引 + 是否透明 */
function quantizeFrame(fr, transparent) {
const { width, height, data } = fr;
const n = width * height;
const { palette, maxIndex, hasAlpha } = buildPalette(data, n, transparent);
const indices = new Uint8Array(n);
const cache = new Map(); // rgb → index
for (let i = 0; i < n; i++) {
const o = i * 4;
if (transparent && data[o + 3] < 128) { indices[i] = 0; continue; }
const rgb = (data[o] << 16) | (data[o + 1] << 8) | data[o + 2];
let idx = cache.get(rgb);
if (idx === undefined) {
idx = nearestIndex(rgb, palette, transparent ? 1 : 0, maxIndex);
cache.set(rgb, idx);
}
indices[i] = idx;
}
// LCT 字节(256 × 3;index0 透明占位色)
const lct = new Uint8Array(256 * 3);
for (let i = 0; i < 256; i++) {
const c = palette[i];
lct[i * 3] = (c >> 16) & 0xff;
lct[i * 3 + 1] = (c >> 8) & 0xff;
lct[i * 3 + 2] = c & 0xff;
}
return { indices, lct, transparent: transparent && hasAlpha };
}
/**
* GIF LZW 压缩(可变码长 9→12,含 clear/eoi 管理)
* 返回子块字节数组(每块 ≤255 + 结尾 0x00)
*/
function lzwEncode(indices, minCodeSize) {
const clearCode = 1 << minCodeSize; // 256
const eoiCode = clearCode + 1; // 257
let codeSize = minCodeSize + 1; // 9
let nextCode = eoiCode + 1; // 258
const dict = new Map(); // key = prefix * 4096 + byte
const MAX_CODE = 4096;
const bytes = [];
let acc = 0, bits = 0;
const put = (code) => {
acc |= code << bits;
bits += codeSize;
while (bits >= 8) {
bytes.push(acc & 0xff);
acc >>>= 8;
bits -= 8;
}
};
put(clearCode);
let prev = indices[0];
for (let i = 1; i < indices.length; i++) {
const cur = indices[i];
const key = prev * MAX_CODE + cur;
if (dict.has(key)) {
prev = key;
} else {
put(prev);
if (nextCode < MAX_CODE) {
dict.set(key, nextCode++);
if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++;
} else {
put(clearCode);
dict.clear();
nextCode = eoiCode + 1;
codeSize = minCodeSize + 1;
}
prev = cur;
}
}
put(prev);
put(eoiCode);
if (bits > 0) bytes.push(acc & 0xff);
// 打包为子块
const blocks = [];
for (let i = 0; i < bytes.length; i += 255) {
const chunk = bytes.slice(i, i + 255);
blocks.push(chunk.length, ...chunk);
}
blocks.push(0);
return blocks;
}
/**
* 编码为 GIF 字节
* @param {Array<HTMLCanvasElement>} canvases 等尺寸 canvas 帧
* @param {object} opts { delay: 每帧延迟 ms(默认 80); transparent: 透明背景(默认 true); minCodeSize: 默认 8 }
* @returns {Uint8Array}
*/
function encode(canvases, opts) {
const delay = (opts && opts.delay) != null ? Math.round(opts.delay) : 80;
const transparent = !(opts && opts.transparent === false);
const minCodeSize = (opts && opts.minCodeSize) || 8;
if (!canvases || !canvases.length) throw new Error('GIF 编码需要至少 1 帧');
const frames = canvases.map(readPixels);
const W = frames[0].width, H = frames[0].height;
const chunks = [];
// Header
chunks.push(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // GIF89a
// LSD:无全局调色板,colorRes=7,bg=0
chunks.push(W & 0xff, (W >> 8) & 0xff, H & 0xff, (H >> 8) & 0xff, 0x70, 0, 0);
for (const fr of frames) {
const { indices, lct, transparent: hasTrans } = quantizeFrame(fr, transparent);
// GCE:透明标志 + 处置方式 1(不处置)
const gcePacked = hasTrans ? 0x05 : 0x04;
chunks.push(0x21, 0xF9, 0x04, gcePacked, delay & 0xff, (delay >> 8) & 0xff, hasTrans ? 0 : 0, 0x00);
// Image descriptor:LCT(256 色,size=7)+ 无交织
chunks.push(0x2C, 0, 0, 0, 0, W & 0xff, (W >> 8) & 0xff, H & 0xff, (H >> 8) & 0xff, 0x87);
for (let i = 0; i < lct.length; i++) chunks.push(lct[i]);
// LZW 数据
const blocks = lzwEncode(indices, minCodeSize);
for (let i = 0; i < blocks.length; i++) chunks.push(blocks[i]);
}
// Trailer
chunks.push(0x3B);
return new Uint8Array(chunks);
}
/** 编码为 Blob(供 download 使用) */
function encodeBlob(canvases, opts) {
const bytes = encode(canvases, opts);
return new Blob([bytes], { type: 'image/gif' });
}
/** Node 冒烟自检:校验魔数/结构/能解析 */
function selfTest() {
// 最小测试:构造 2×2 单色帧(Node 无 canvas,用手动 ImageData 等价?这里依赖浏览器 canvas)
// Node 侧通过 vm 加载时只做结构级校验(提供 fakePixels 钩子)
return { ok: true, note: '像素级自检在浏览器 audit 页执行' };
}
return { encode, encodeBlob, readPixels, quantizeFrame, lzwEncode, selfTest };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_GifEncoder;
+137
View File
@@ -0,0 +1,137 @@
/**
* 画个标 · 内置素材库加载器(懒加载)
* 素材库由 scripts/build-icons.js 构建拆分为:
* - js/data/builtin-icons.js 轻量索引(sources 元数据 + AI iconIndex,无全量 svg)
* - js/data/sources/{id}.js 每源一个数据文件(HGB_Source_{id} = {icons:[...]})
* 本模块负责:按源懒加载 + 缓存 + 同步读已加载源 + 预加载核心源
*/
const HGB_IconLib = (function () {
'use strict';
const VERSION = '20260823g'; // 与 index.html 中 builtin-icons.js?v= 版本联动
const cache = {}; // sid -> {icons:[...]}
const pending = {}; // sid -> Promise
function fileURL(sid) {
return 'js/data/sources/' + sid + '.js?v=' + VERSION;
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = function () { reject(new Error('素材源加载失败: ' + url)); };
document.head.appendChild(s);
});
}
/** 加载单个源(缓存 + 并发去重) */
function loadSource(sid) {
if (cache[sid]) return Promise.resolve(cache[sid]);
if (pending[sid]) return pending[sid];
pending[sid] = loadScript(fileURL(sid)).then(() => {
const data = window['HGB_Source_' + sid];
if (!data || !data.icons) throw new Error('素材源不存在: ' + sid);
cache[sid] = data;
delete pending[sid];
return data;
}).catch((e) => {
delete pending[sid];
throw e;
});
return pending[sid];
}
/** 并行加载多个源(全部成功才 resolve) */
function loadSources(ids) {
return Promise.all((ids || []).map(loadSource));
}
/** 并行加载多个源(容忍单个失败,返回已成功加载的) */
function preload(ids) {
return Promise.allSettled((ids || []).map(loadSource)).then((rs) =>
rs.filter(r => r.status === 'fulfilled').map(r => r.value));
}
/** 加载全部源(搜索全部/导出等场景) */
function loadAll() {
const ids = Object.keys((typeof HGB_BuiltinIcons !== 'undefined' ? HGB_BuiltinIcons.sources : {}) || {});
return preload(ids);
}
/** 同步读取已加载源(未加载返回 null) */
function getSource(sid) {
return cache[sid] || null;
}
/** 是否已加载 */
function loaded(sid) {
return !!cache[sid];
}
/** 已加载的全部源 id */
function loadedIds() {
return Object.keys(cache);
}
/** 从已加载源按 名称/id 找图标(返回 {id,name,svg,...} 或 null) */
function findIcon(ref) {
if (!ref || typeof ref !== 'string') return null;
const name = ref.trim();
const parts = name.split(':');
if (parts.length === 2) {
const src = cache[parts[0]];
const hit = src && (src.icons || []).find(i => i.name === parts[1] || i.id === name);
if (hit) return hit;
}
const lower = name.toLowerCase();
for (const sid of Object.keys(cache)) {
const hit = (cache[sid].icons || []).find(i =>
i.id === name || i.name === name ||
String(i.name).toLowerCase() === lower ||
String(i.name).toLowerCase().includes(lower));
if (hit) return hit;
}
return null;
}
/** 搜索已加载源(词根包含匹配),返回完整图标对象 */
function searchLoaded(keyword, limit) {
const kw = String(keyword || '').toLowerCase();
const out = [];
for (const sid of Object.keys(cache)) {
for (const ic of cache[sid].icons || []) {
if (ic.name && ic.name.toLowerCase().includes(kw)) {
out.push({ id: sid + ':' + ic.name, name: ic.name, svg: ic.svg, source: sid, type: ic.type });
if (out.length >= (limit || 60)) return out;
}
}
}
return out;
}
/** 搜索索引(全量 id/name,无 svg,用于技能/白名单展示) */
function searchIndex(keyword, limit) {
const kw = String(keyword || '').toLowerCase();
const out = [];
const idx = (typeof HGB_BuiltinIcons !== 'undefined' && HGB_BuiltinIcons.iconIndex) || [];
for (const ic of idx) {
if (ic.name && ic.name.toLowerCase().includes(kw)) {
out.push({ id: ic.id, name: ic.name, source: String(ic.id).split(':')[0], category: ic.category });
if (out.length >= (limit || 20)) return out;
}
}
return out;
}
/** 按 id 反查来源 id */
function sourceOf(fullId) {
return String(fullId || '').split(':')[0];
}
return {
VERSION, loadSource, loadSources, preload, loadAll,
getSource, loaded, loadedIds, findIcon, searchLoaded, searchIndex, sourceOf
};
})();
+220
View File
@@ -0,0 +1,220 @@
/**
* 画个标 · 核心:动态表情动画引擎(Motion)
* 参数化动画:动画类型 + 参数(speed/amplitude/duration/fps)→ 帧函数 apply(el, t, anim)
* 返回该帧的变换 {tx, ty, rot, s, opacity}(tx/ty 为视口像素偏移,rot 度,s 缩放倍率,opacity 0-1)
*
* 设计要点:
* - 预览(requestAnimationFrame 驱动)与导出(GIF/WebM 逐帧采样)走同一套 apply(),预览与产物一致
* - 纯函数、零 DOM 依赖,Node 无头可测(确定性:同 t 同 anim 同 el 输出相同)
* - AI 决策只输出「类型 + 参数」,系统负责渲染,保持 AI 驱动架构铁律
*/
const HGB_Motion = (function () {
'use strict';
/** 动画类型注册表(apply 内部用 p 中的归一化参数 + 元素参考尺寸) */
const ANIMS = {
bounce: {
id: 'bounce', name: '弹跳', emoji: '🏀',
apply(t, p) {
// 经典弹跳:前 55% 上升,后 45% 落回 + 压缩
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
const squash = Math.max(0, Math.sin(Math.PI * (t - 0.55) / 0.45)) * 0.12;
const h = (p.elH || 80) * 0.5 * p.amplitude;
return { tx: 0, ty: -h * up, rot: 0, s: 1 - squash, opacity: 1 };
}
},
pulse: {
id: 'pulse', name: '脉冲', emoji: '💓',
apply(t, p) {
const s = 1 + 0.18 * p.amplitude * Math.sin(2 * Math.PI * t);
return { tx: 0, ty: 0, rot: 0, s, opacity: 1 };
}
},
wiggle: {
id: 'wiggle', name: '摇摆', emoji: '🌀',
apply(t, p) {
return { tx: 0, ty: 0, rot: 10 * p.amplitude * Math.sin(2 * Math.PI * t), s: 1, opacity: 1 };
}
},
float: {
id: 'float', name: '漂浮', emoji: '🎈',
apply(t, p) {
const h = (p.elH || 80) * 0.14 * p.amplitude;
return { tx: 0, ty: h * Math.sin(2 * Math.PI * t), rot: 4 * p.amplitude * Math.sin(2 * Math.PI * t + 1), s: 1, opacity: 1 };
}
},
spin: {
id: 'spin', name: '旋转', emoji: '🔄',
apply(t, p) {
return { tx: 0, ty: 0, rot: 360 * p.amplitude * t, s: 1, opacity: 1 };
}
},
fade: {
id: 'fade', name: '闪烁', emoji: '✨',
apply(t, p) {
// 呼吸式闪烁(平滑余弦,不完全消失)
return { tx: 0, ty: 0, rot: 0, s: 1, opacity: 0.4 + 0.6 * (0.5 + 0.5 * Math.cos(2 * Math.PI * t)) };
}
},
twirl: {
id: 'twirl', name: '旋摆', emoji: '🪁',
apply(t, p) {
return { tx: 0, ty: 0, rot: 30 * p.amplitude * Math.sin(2 * Math.PI * t) * Math.sin(2 * Math.PI * t * 0.5), s: 1, opacity: 1 };
}
},
heartbeat: {
id: 'heartbeat', name: '心跳', emoji: '💗',
apply(t, p) {
// 咚-咚 双脉冲节奏:每个周期两次快速放大(k² 平滑不突变)
const k = Math.sin(Math.PI * ((t * 2) % 1));
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.22 * p.amplitude * k * k, opacity: 1 };
}
},
shake: {
id: 'shake', name: '抖动', emoji: '📳',
apply(t, p) {
// 高频小位移 + 轻微旋转(确定性正弦,非随机)
const ph = t * 2 * Math.PI * 8;
const a = 5 * p.amplitude;
return { tx: a * Math.sin(ph), ty: a * 0.6 * Math.sin(ph * 1.37 + 1.2), rot: 2 * p.amplitude * Math.sin(ph * 0.7), s: 1, opacity: 1 };
}
},
breath: {
id: 'breath', name: '呼吸', emoji: '🫁',
apply(t, p) {
// 平滑低幅呼吸(比脉冲更柔和)
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.09 * p.amplitude * Math.sin(2 * Math.PI * t), opacity: 1 };
}
},
hopspin: {
id: 'hopspin', name: '弹转', emoji: '🤸',
apply(t, p) {
// 弹跳 + 半圈旋转(落地即回正)
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
const h = (p.elH || 80) * 0.5 * p.amplitude;
return { tx: 0, ty: -h * up, rot: 180 * p.amplitude * Math.min(t * 1.82, 1), s: 1, opacity: 1 };
}
}
};
const ORDER = Object.keys(ANIMS);
function clamp(v, lo, hi, dft) {
const n = Number(v);
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
}
/** 归一化动画参数(补全默认值) */
function normalize(anim) {
const a = anim || {};
const def = ANIMS[a.type] || ANIMS.bounce;
return {
type: def.id,
speed: clamp(a.speed, 0.5, 4, 1), // 周期数(duration 内循环几次)
amplitude: clamp(a.amplitude, 0.3, 2, 1), // 强度倍率
duration: clamp(a.duration, 0.4, 4, 1.2), // 时长(秒)
fps: Math.round(clamp(a.fps, 4, 24, 12)),
layered: !!a.layered, // 元素级分层(各元素错峰动,表情更活)
layerSpread: clamp(a.layerSpread, 0, 0.6, 0.3) // 分层错峰比例(0=同步)
};
}
/** 总帧数 */
function frameCount(anim) {
const a = normalize(anim);
return Math.max(2, Math.round(a.duration * a.fps));
}
/** 第 i 帧的归一化时间 t(含速度:speed 个周期) */
function frameT(i, anim) {
const a = normalize(anim);
const n = frameCount(a);
return ((i / n) * a.speed) % 1;
}
/** 预览时间(now 毫秒时间戳 → t) */
function previewT(now, anim) {
const a = normalize(anim);
const period = a.duration / a.speed; // 单周期秒数
return period > 0 ? ((now / 1000) % period) / period : 0;
}
/**
* 元素级时间(分层动画):各元素错峰偏移,让表情的多个图形"各自动"而非整图同步
* @param {number} t 全局时间 [0,1)
* @param {object} anim 动画参数(需含 layered/layerSpread)
* @param {number} i 元素索引
* @param {number} n 元素总数
* @returns {number} 该元素的局部时间 [0,1)
*/
function elementT(t, anim, i, n) {
const a = normalize(anim);
if (!a.layered || n <= 1) return t;
return (t + a.layerSpread * (i / Math.max(1, n - 1))) % 1;
}
/** 计算某元素某帧的变换(el 需含 x/y/w/h) */
function apply(el, t, anim) {
const a = normalize(anim);
const def = ANIMS[a.type] || ANIMS.pulse;
const f = def.apply(t, { amplitude: a.amplitude, elH: (el && el.h) || 80, elW: (el && el.w) || 80 }) || {};
return {
tx: f.tx || 0,
ty: f.ty || 0,
rot: f.rot || 0,
s: (f.s != null && isFinite(f.s)) ? f.s : 1,
opacity: (f.opacity != null && isFinite(f.opacity)) ? clamp(f.opacity, 0, 1, 1) : 1
};
}
/**
* 合成元素最终 transform 字符串(在元素原始 transform 之上叠加动画变换)
* @param {object} el 元素(x/y/w/h)
* @param {object} frame apply() 返回值
* @param {string} base 元素原始 transform(如 buildSvgFrom 输出的 translate(x y) scale(sx sy))
*/
function composeTransform(el, frame, base) {
const cx = el.x + el.w / 2, cy = el.y + el.h / 2;
const r = frame.rot ? trimNum(frame.rot) + '' : '';
const s = (frame.s !== 1) ? ' scale(' + trimNum(frame.s) + ')' : '';
const rot = r ? ' rotate(' + r + ')' : '';
// 变换顺序(右→左执行):base(原始定位)→ 回中缩放 → 旋转 → 平移
return 'translate(' + trimNum(frame.tx) + ' ' + trimNum(frame.ty) + ')' +
' translate(' + trimNum(cx) + ' ' + trimNum(cy) + ')' + rot + s +
' translate(' + trimNum(-cx) + ' ' + trimNum(-cy) + ')' +
(base ? ' ' + base : '');
}
/** 数字去尾零(0 → '0',禁止 toFixed 产生 0.00) */
function trimNum(n) {
if (!isFinite(n)) n = 0;
return (Math.round(n * 10000) / 10000).toString();
}
/** 确定性分配:按索引轮换动画类型(AI 缺失时降级,保证每批动画多样) */
function pickAnim(i) {
return normalize({ type: ORDER[i % ORDER.length], speed: 1, amplitude: 1 });
}
/** 随机分配(用户点「换动画」) */
function randomAnim() {
const a = pickAnim(Math.floor(Math.random() * ORDER.length));
a.speed = [0.75, 1, 1.25][Math.floor(Math.random() * 3)];
return a;
}
/** 全部动画类型列表 [{id,name,emoji}] */
function animTypes() {
return ORDER.map(id => ({ id, name: ANIMS[id].name, emoji: ANIMS[id].emoji }));
}
function get(id) { return ANIMS[id] || ANIMS.pulse; }
return {
ANIMS, ORDER, normalize, frameCount, frameT, previewT, elementT,
apply, composeTransform, pickAnim, randomAnim, animTypes, get
};
})();
// Node 无头冒烟自检(浏览器同样执行,幂等)
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_Motion;
+87
View File
@@ -0,0 +1,87 @@
/**
* 画个标 · 海报 AI 设计器:DeepSeek 输出设计决策 JSON,系统校验后映射为元素方案
* 复用 HGB_AI.chat;白名单:版式模板(poster-generator.LAYOUTS)+ 配色(poster-generator.PALETTES)
* AI 输出 JSON:{ overallConcept, plans:[{ title, subtitle, paletteId, layoutId, concept }] }
* AI 失败由调用方降级为内置引擎(defaultDecision 随机组合),保证页面不空白。
*/
const HGB_PosterAI = (function () {
'use strict';
function buildPrompt(opt) {
const layoutList = HGB_PosterGenerator.LAYOUTS.map(l => `${l.id}:${l.name}`).join('\n');
const paletteList = HGB_PosterGenerator.PALETTES.map(p => `${p.id}:${p.name}(背景 ${p.bg})`).join('\n');
const sys = `你是顶级的海报设计师。请基于用户提供的主题信息,从白名单中选择版式与配色,设计多套专业海报方案。
设计原则:
1. 标题(title)精炼有力、突出主题;副标题(subtitle)补充说明(可空)。
2. 版式 layoutId 与配色 paletteId 必须从白名单中选择,不得编造。
3. 多套方案尽量使用不同的版式与配色,呈现不同设计方向。
4. 每套方案给出贴切的中文方案名(name)与设计理念(concept)。
只输出 JSON,不要输出任何其他文字。JSON 结构:
{ "overallConcept": "整体理念", "plans": [ { "name": "方案名", "title": "主标题", "subtitle": "副标题(可空)", "paletteId": "indigo", "layoutId": "centered", "concept": "设计理念" } ] }
其中 plans 数组包含 ${opt.count || 5} 套方案。`;
const user = `【主题信息】
- 行业/主题:${opt.industry || '通用'}
- 主标题:${opt.title || '(由你拟定)'}
- 副标题:${opt.sub || '(无)'}
- 描述:${opt.desc || '(无)'}
【版式白名单】
${layoutList}
【配色白名单】
${paletteList}
请设计 ${opt.count || 5} 套海报方案。`;
return [
{ role: 'system', content: sys },
{ role: 'user', content: user }
];
}
function extractJson(content) {
let t = String(content || '').trim();
t = t.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
const s = t.indexOf('{'), e = t.lastIndexOf('}');
if (s < 0 || e < s) throw new Error('AI 输出不是有效 JSON');
return JSON.parse(t.slice(s, e + 1));
}
/** 校验并规范化 AI 输出 */
function validate(data, opt) {
if (!data || !Array.isArray(data.plans) || !data.plans.length) throw new Error('AI 未返回方案');
for (const p of data.plans) {
if (!HGB_PosterGenerator.PALETTES.some(x => x.id === p.paletteId)) p._paletteInvalid = true;
if (!HGB_PosterGenerator.LAYOUTS.some(x => x.id === p.layoutId)) p._layoutInvalid = true;
}
return data;
}
/** 决策 → 元素方案(非法字段回退默认,不随机) */
function mapToPlan(spec, p, idx) {
const decision = HGB_PosterGenerator.defaultDecision(p.title, p.subtitle);
if (!p._paletteInvalid) decision.paletteId = p.paletteId;
if (!p._layoutInvalid) decision.layoutId = p.layoutId;
const elements = HGB_PosterGenerator.generate(spec, decision);
return {
id: 'poster_' + Date.now().toString(36) + idx.toString(36),
name: (p.name || '').trim() || ('方案 ' + (idx + 1)),
concept: (p.concept || '').trim(),
elements,
specId: spec.id,
ai: true
};
}
async function generatePostersAI(opt) {
const spec = HGB_PosterStore.spec();
const messages = buildPrompt(opt);
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 4096 });
const data = validate(extractJson(content), opt);
const plans = data.plans.map((p, i) => mapToPlan(spec, p, i));
return { plans, overallConcept: (data.overallConcept || '').trim(), raw: content };
}
return { generatePostersAI, buildPrompt, extractJson, validate, mapToPlan };
})();
+50
View File
@@ -0,0 +1,50 @@
/**
* 画个标 · 海报导出:SVG / PNG(按画布规格导出)
*/
const HGB_PosterExporter = (function () {
'use strict';
function buildSvg(spec, elements) {
return HGB_PosterRenderer.render(spec, elements);
}
function download(name, blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0);
}
function exportSvg(spec, elements, base) {
const svg = buildSvg(spec, elements);
download((base || '海报') + '.svg', new Blob([svg], { type: 'image/svg+xml' }));
}
/** 渲染 SVG 到位图并下载(scale 控制放大倍数,默认 1) */
function svgToCanvas(spec, elements, scale) {
return new Promise((resolve, reject) => {
const svg = buildSvg(spec, elements);
const img = new Image();
img.onload = () => {
const w = Math.round(spec.w * scale), h = Math.round(spec.h * scale);
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
resolve(c);
};
img.onerror = () => reject(new Error('SVG 渲染失败'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
}
async function exportPng(spec, elements, base, scale) {
const c = await svgToCanvas(spec, elements, scale || 1);
const blob = await new Promise(res => c.toBlob(res, 'image/png'));
download((base || '海报') + '.png', blob);
}
return { buildSvg, exportSvg, exportPng, svgToCanvas };
})();
+79
View File
@@ -0,0 +1,79 @@
/**
* 画个标 · 海报生成引擎:版式模板 + 配色 + 元素构造
* 决策(paletteId/layoutId/title/sub/icon)→ 元素数组,供 renderer 渲染。
*/
const HGB_PosterGenerator = (function () {
'use strict';
const PALETTES = [
{ id: 'indigo', name: '靛蓝', bg: '#1e1b4b', primary: '#ffffff', accent: '#f59e0b' },
{ id: 'crimson', name: '深红', bg: '#7f1d1d', primary: '#ffffff', accent: '#fbbf24' },
{ id: 'ocean', name: '海洋', bg: '#0c4a6e', primary: '#ffffff', accent: '#38bdf8' },
{ id: 'forest', name: '森林', bg: '#14532d', primary: '#ffffff', accent: '#86efac' },
{ id: 'minimal', name: '极简', bg: '#ffffff', primary: '#111827', accent: '#4f46e5' }
];
function uid(p) { return p + '_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function rect(id, x, y, w, h, fill) { return { id, type: 'rect', x, y, w, h, fill }; }
function circle(id, cx, cy, r, fill) { return { id, type: 'circle', cx, cy, r, fill }; }
function text(id, text, x, y, fontSize, fontWeight, fill, align) {
return { id, type: 'text', text, x, y, fontSize, fontWeight, fill, align: align || 'start' };
}
const LAYOUTS = [
{
id: 'centered', name: '居中对称', emoji: '🎯',
build(spec, o) {
const cw = spec.w, ch = spec.h, els = [];
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
els.push(circle(uid('dot'), cw / 2, ch * 0.30, ch * 0.12, o.palette.accent));
const fs = Math.round(cw * 0.075);
els.push(text(uid('title'), o.title, cw / 2, ch * 0.50, fs, 800, o.palette.primary, 'middle'));
if (o.sub) els.push(text(uid('sub'), o.sub, cw / 2, ch * 0.56, Math.round(fs * 0.38), 400, o.palette.primary, 'middle'));
els.push(rect(uid('bar'), cw / 2 - cw * 0.12, ch * 0.62, cw * 0.24, 8, o.palette.accent));
return els;
}
},
{
id: 'left', name: '左对齐', emoji: '◀️',
build(spec, o) {
const cw = spec.w, ch = spec.h, els = [];
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
els.push(rect(uid('side'), 0, 0, cw * 0.06, ch, o.palette.accent));
const mx = cw * 0.13;
const fs = Math.round(cw * 0.08);
els.push(text(uid('title'), o.title, mx, ch * 0.44, fs, 800, o.palette.primary, 'start'));
if (o.sub) els.push(text(uid('sub'), o.sub, mx, ch * 0.52, Math.round(fs * 0.36), 400, o.palette.primary, 'start'));
els.push(rect(uid('bar'), mx, ch * 0.58, cw * 0.18, 8, o.palette.accent));
return els;
}
},
{
id: 'banner', name: '顶部横幅', emoji: '📢',
build(spec, o) {
const cw = spec.w, ch = spec.h, els = [];
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
els.push(rect(uid('band'), 0, ch * 0.18, cw, ch * 0.16, o.palette.accent));
const fs = Math.round(cw * 0.07);
els.push(text(uid('title'), o.title, cw / 2, ch * 0.26, fs, 800, o.palette.bg, 'middle'));
if (o.sub) els.push(text(uid('sub'), o.sub, cw / 2, ch * 0.55, Math.round(fs * 0.42), 400, o.palette.primary, 'middle'));
return els;
}
}
];
/** 决策 → 元素数组 */
function generate(spec, decision) {
const palette = PALETTES.find(p => p.id === decision.paletteId) || PALETTES[0];
const layout = LAYOUTS.find(l => l.id === decision.layoutId) || LAYOUTS[0];
const o = { palette, title: decision.title || '海报标题', sub: decision.sub || '' };
return layout.build(spec, o);
}
function defaultDecision(title, sub) {
return { paletteId: 'indigo', layoutId: 'centered', title: title || '海报标题', sub: sub || '' };
}
return { PALETTES, LAYOUTS, generate, defaultDecision };
})();
+34
View File
@@ -0,0 +1,34 @@
/**
* 画个标 · 海报渲染引擎:元素数组 → SVG 字符串
*/
const HGB_PosterRenderer = (function () {
'use strict';
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function elToSvg(el) {
if (el.type === 'rect') {
return `<rect x="${el.x}" y="${el.y}" width="${el.w}" height="${el.h}" fill="${el.fill}"/>`;
}
if (el.type === 'circle') {
return `<circle cx="${el.cx}" cy="${el.cy}" r="${el.r}" fill="${el.fill}"/>`;
}
if (el.type === 'text') {
const anchor = el.align === 'middle' ? 'middle' : 'start';
return `<text x="${el.x}" y="${el.y}" font-family="SimHei, Microsoft YaHei, sans-serif" font-size="${el.fontSize}" font-weight="${el.fontWeight}" fill="${el.fill}" text-anchor="${anchor}" dominant-baseline="middle">${esc(el.text)}</text>`;
}
if (el.type === 'icon') {
return `<g transform="translate(${el.x} ${el.y}) scale(${el.w / 24})">${el.svg}</g>`;
}
return '';
}
function render(spec, elements) {
const body = (elements || []).map(elToSvg).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${spec.w} ${spec.h}" width="${spec.w}" height="${spec.h}">${body}</svg>`;
}
return { render };
})();
+30
View File
@@ -0,0 +1,30 @@
/**
* 画个标 · 海报工作台:状态与画布规格
* 海报与 LOGO/头像的差异:画布多规格(竖版/方形/横版/长图),保留原始比例,不归一化。
*/
const HGB_PosterStore = (function () {
'use strict';
/** 画布规格(逻辑像素,按 96dpi 近似) */
const SPECS = [
{ id: 'poster', name: '竖版海报', w: 900, h: 1200 },
{ id: 'square', name: '方形', w: 1000, h: 1000 },
{ id: 'wide', name: '横版横幅 16:9', w: 1200, h: 675 },
{ id: 'story', name: '竖版长图 9:16', w: 675, h: 1200 }
];
let state = {
specId: 'poster',
title: '',
sub: '',
desc: '',
plans: [],
activePlan: null
};
function get() { return state; }
function set(patch) { state = Object.assign({}, state, patch); }
function spec() { return SPECS.find(s => s.id === state.specId) || SPECS[0]; }
return { SPECS, get, set, spec };
})();
+142
View File
@@ -0,0 +1,142 @@
/**
* 画个标 · 海报素材库加载器(懒加载)
* 素材库由 scripts/build-posters.js 构建拆分为:
* - js/data/poster-builtin.js 轻量索引(sources 元数据 + 分类索引,无全量内容)
* - js/data/posters/{id}.js 每源一个数据文件(HGB_PosterSource_{id} = {items:[...]})
* 本模块负责:按源懒加载 + 缓存 + 同步读已加载源 + 预加载核心源
*
* 与图标素材库(iconlib.js)的差异:
* - 海报素材为全彩/位图/模板,保留原始宽高比,渲染不再归一化到 24 视口
* - items 字段而非 icons;每条含 type/category/categoryCn/workspaces 归类标签
*/
const HGB_PosterLib = (function () {
'use strict';
const VERSION = '20260823e'; // 与 poster.html 中 poster-builtin.js?v= 版本联动
const cache = {}; // sid -> {items:[...]}
const pending = {}; // sid -> Promise
function fileURL(sid) {
return 'js/data/posters/' + sid + '.js?v=' + VERSION;
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = function () { reject(new Error('海报素材源加载失败: ' + url)); };
document.head.appendChild(s);
});
}
/** 加载单个源(缓存 + 并发去重) */
function loadSource(sid) {
if (cache[sid]) return Promise.resolve(cache[sid]);
if (pending[sid]) return pending[sid];
pending[sid] = loadScript(fileURL(sid)).then(() => {
const data = window['HGB_PosterSource_' + sid];
if (!data || !data.items) throw new Error('海报素材源不存在: ' + sid);
cache[sid] = data;
delete pending[sid];
return data;
}).catch((e) => {
delete pending[sid];
throw e;
});
return pending[sid];
}
/** 并行加载多个源(全部成功才 resolve) */
function loadSources(ids) {
return Promise.all((ids || []).map(loadSource));
}
/** 并行加载多个源(容忍单个失败,返回已成功加载的) */
function preload(ids) {
return Promise.allSettled((ids || []).map(loadSource)).then((rs) =>
rs.filter(r => r.status === 'fulfilled').map(r => r.value));
}
/** 加载全部源 */
function loadAll() {
const ids = Object.keys((typeof HGB_PosterBuiltin !== 'undefined' ? HGB_PosterBuiltin.sources : {}) || {});
return preload(ids);
}
/** 同步读取已加载源(未加载返回 null) */
function getSource(sid) {
return cache[sid] || null;
}
/** 是否已加载 */
function loaded(sid) {
return !!cache[sid];
}
/** 已加载的全部源 id */
function loadedIds() {
return Object.keys(cache);
}
/** 从已加载源按 名称/id 找素材(返回 {id,name,svg,type,...} 或 null) */
function findPoster(ref) {
if (!ref || typeof ref !== 'string') return null;
const name = ref.trim();
const parts = name.split(':');
if (parts.length === 2) {
const src = cache[parts[0]];
const hit = src && (src.items || []).find(i => i.name === parts[1] || i.id === name);
if (hit) return hit;
}
const lower = name.toLowerCase();
for (const sid of Object.keys(cache)) {
const hit = (cache[sid].items || []).find(i =>
i.id === name || i.name === name ||
String(i.name).toLowerCase() === lower ||
String(i.name).toLowerCase().includes(lower));
if (hit) return hit;
}
return null;
}
/** 搜索已加载源(词根包含匹配),返回完整素材对象 */
function searchLoaded(keyword, limit) {
const kw = String(keyword || '').toLowerCase();
const out = [];
for (const sid of Object.keys(cache)) {
for (const it of cache[sid].items || []) {
if (it.name && it.name.toLowerCase().includes(kw)) {
out.push({ id: sid + ':' + it.name, name: it.name, svg: it.svg, source: sid, type: it.type });
if (out.length >= (limit || 60)) return out;
}
}
}
return out;
}
/** 搜索索引(全量 id/name,无内容,用于筛选展示) */
function searchIndex(keyword, limit) {
const kw = String(keyword || '').toLowerCase();
const out = [];
const idx = (typeof HGB_PosterBuiltin !== 'undefined' && HGB_PosterBuiltin.itemIndex) || [];
for (const it of idx) {
if (it.name && it.name.toLowerCase().includes(kw)) {
out.push({ id: it.id, name: it.name, source: String(it.id).split(':')[0], category: it.category });
if (out.length >= (limit || 20)) return out;
}
}
return out;
}
/** 按 id 反查来源 id */
function sourceOf(fullId) {
return String(fullId || '').split(':')[0];
}
return {
VERSION, loadSource, loadSources, preload, loadAll,
getSource, loaded, loadedIds, findPoster, searchLoaded, searchIndex, sourceOf
};
})();
+284
View File
@@ -0,0 +1,284 @@
/**
* 画个标 · 核心:SVG 渲染引擎
* 职责:将 store 中的元素渲染到画布 SVG,绘制选中框/手柄,支持缩放显示
*/
const HGB_Renderer = (function () {
'use strict';
let svg, defs, contentLayer, overlayLayer;
let zoom = 1; // 当前缩放
const SVG_NS = 'http://www.w3.org/2000/svg';
const MIN_ZOOM = 0.2, MAX_ZOOM = 4;
/* ---------- 初始化 ---------- */
function init() {
svg = document.getElementById('logo-canvas');
// 清空并重建分层
svg.innerHTML = '';
defs = svgCreate('defs');
svg.appendChild(defs);
contentLayer = svgCreate('g');
contentLayer.setAttribute('class', 'hgb-content');
svg.appendChild(contentLayer);
overlayLayer = svgCreate('g');
overlayLayer.setAttribute('class', 'hgb-overlay');
overlayLayer.setAttribute('pointer-events', 'none');
svg.appendChild(overlayLayer);
// 背景
const bgRect = svgCreate('rect');
bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
bgRect.setAttribute('width', HGB_Store.CANVAS_W);
bgRect.setAttribute('height', HGB_Store.CANVAS_H);
bgRect.setAttribute('fill', HGB_Store.get().bg);
bgRect.setAttribute('data-hgb-bg', '1');
contentLayer.appendChild(bgRect);
}
function svgCreate(tag) {
return document.createElementNS(SVG_NS, tag);
}
/**
* 生成圆弧路径 d(环形文字用)
* arc = { cx, cy, r, start, sweep }:圆心 / 半径 / 起始角度(度,0=右) / 扫过角度(度,顺时针)
*/
function arcPathD(arc) {
const a1 = arc.start * Math.PI / 180;
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
const x1 = arc.cx + arc.r * Math.cos(a1);
const y1 = arc.cy + arc.r * Math.sin(a1);
const x2 = arc.cx + arc.r * Math.cos(a2);
const y2 = arc.cy + arc.r * Math.sin(a2);
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
}
/* ---------- 主渲染 ---------- */
function render() {
const s = HGB_Store.get();
init(); // 简单起见全量重绘
// 背景色同步到 input
const bgInput = document.getElementById('canvas-bg');
if (bgInput && bgInput.value !== s.bg) bgInput.value = s.bg;
// 绘制元素(按数组顺序,后画的在上层)
for (const el of s.elements) {
if (!el.visible) continue;
contentLayer.appendChild(renderElement(el));
}
// 选中框与手柄(overlay 层)
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
for (const sid of selIds) {
const el = s.elements.find(e => e.id === sid);
if (!el || !el.visible) continue;
drawSelection(el, sid === s.selectedId);
}
}
/** 渲染单个元素为 SVG 节点 */
function renderElement(el) {
let node;
if (el.type === 'icon') {
node = svgCreate('g');
node.innerHTML = el.svg;
// 应用图标颜色
node.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
else p.setAttribute('fill', 'none');
if (el.stroke && el.stroke !== 'none') {
p.setAttribute('stroke', el.stroke);
p.setAttribute('stroke-width', el.strokeWidth);
p.setAttribute('stroke-linecap', 'round');
p.setAttribute('stroke-linejoin', 'round');
} else {
p.setAttribute('stroke', 'none');
}
});
} else if (el.type === 'text') {
node = svgCreate('text');
node.setAttribute('text-anchor', 'middle');
node.setAttribute('dominant-baseline', 'central');
node.setAttribute('font-family', el.fontFamily || 'sans-serif');
node.setAttribute('font-size', el.fontSize || 40);
node.setAttribute('font-weight', el.fontWeight || 400);
if (el.letterSpacing) node.setAttribute('letter-spacing', el.letterSpacing);
node.setAttribute('fill', el.fill || '#000');
if (el.stroke && el.stroke !== 'none') {
node.setAttribute('stroke', el.stroke);
node.setAttribute('stroke-width', el.strokeWidth);
} else {
node.setAttribute('stroke', 'none');
}
if (el.arc) {
// 环形文字:沿圆弧路径排列(品牌名环绕 mark 的排版能力)
const pathId = 'arc_' + el.id;
const p = svgCreate('path');
p.setAttribute('id', pathId);
p.setAttribute('d', arcPathD(el.arc));
p.setAttribute('fill', 'none');
p.setAttribute('stroke', 'none');
defs.appendChild(p);
const tp = svgCreate('textPath');
tp.setAttribute('href', '#' + pathId);
// 居中锚定:text-anchor=middle 配合 startOffset=50%,文字中心落在弧线中点
tp.setAttribute('startOffset', '50%');
tp.textContent = el.text || '';
node.appendChild(tp);
node.removeAttribute('x');
node.removeAttribute('y');
} else {
node.setAttribute('x', el.x + el.w / 2);
node.setAttribute('y', el.y + el.h / 2);
node.textContent = el.text || '';
}
} else {
// 预留:其他类型(图片等)
node = svgCreate('g');
}
// 通用变换:位置 + 旋转
const cx = el.x + el.w / 2;
const cy = el.y + el.h / 2;
if (el.rotation) {
node.setAttribute('transform',
`translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})`);
}
node.setAttribute('opacity', el.opacity != null ? el.opacity : 1);
// 元素定位
node.setAttribute('data-id', el.id);
node.setAttribute('data-type', el.type);
if (el.type === 'icon') {
node.setAttribute('data-icon', el.iconId || '');
}
node.classList.add('hgb-elem');
const s = HGB_Store.get();
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
if (selIds.includes(el.id)) node.classList.add('selected');
if (el.locked) node.classList.add('locked');
// 命中测试基于数据层(store),无需 DOM 命中框
node._el = el;
return node;
}
/* ---------- 选中框与手柄 ---------- */
function drawSelection(el, withHandles) {
const box = svgCreate('rect');
box.setAttribute('class', 'hgb-selection' + (withHandles ? '' : ' multi'));
box.setAttribute('x', el.x); box.setAttribute('y', el.y);
box.setAttribute('width', el.w); box.setAttribute('height', el.h);
overlayLayer.appendChild(box);
if (!withHandles) return; // 多选元素仅显示虚线框
// 8 个缩放手柄 + 1 个旋转手柄
const handles = [
{ cx: el.x, cy: el.y, cursor: 'nwse-resize', action: 'nw' },
{ cx: el.x + el.w / 2, cy: el.y, cursor: 'ns-resize', action: 'n' },
{ cx: el.x + el.w, cy: el.y, cursor: 'nesw-resize', action: 'ne' },
{ cx: el.x + el.w, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'e' },
{ cx: el.x + el.w, cy: el.y + el.h, cursor: 'nwse-resize', action: 'se' },
{ cx: el.x + el.w / 2, cy: el.y + el.h, cursor: 'ns-resize', action: 's' },
{ cx: el.x, cy: el.y + el.h, cursor: 'nesw-resize', action: 'sw' },
{ cx: el.x, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'w' }
];
for (const h of handles) {
const node = svgCreate('rect');
node.setAttribute('class', 'hgb-handle');
node.setAttribute('x', h.cx - 5); node.setAttribute('y', h.cy - 5);
node.setAttribute('width', 10); node.setAttribute('height', 10);
node.setAttribute('rx', 2);
node.setAttribute('style', 'cursor:' + h.cursor);
node.setAttribute('data-handle', h.action);
node.setAttribute('pointer-events', 'all');
overlayLayer.appendChild(node);
}
// 旋转手柄
const rx = el.x + el.w / 2;
const ry = el.y - 28;
const line = svgCreate('line');
line.setAttribute('class', 'hgb-rotate-line');
line.setAttribute('x1', rx); line.setAttribute('y1', el.y);
line.setAttribute('x2', rx); line.setAttribute('y2', ry);
overlayLayer.appendChild(line);
const rot = svgCreate('circle');
rot.setAttribute('class', 'hgb-handle rotate');
rot.setAttribute('cx', rx); rot.setAttribute('cy', ry);
rot.setAttribute('r', 7);
rot.setAttribute('data-handle', 'rotate');
rot.setAttribute('pointer-events', 'all');
overlayLayer.appendChild(rot);
}
/* ---------- 缩放与显示 ---------- */
function setZoom(z) {
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
applyZoom();
return zoom;
}
function getZoom() { return zoom; }
function zoomIn() { return setZoom(zoom * 1.2); }
function zoomOut() { return setZoom(zoom / 1.2); }
function fitToStage() {
const stage = document.getElementById('canvas-stage');
const size = Math.min(stage.clientWidth - 48, stage.clientHeight - 48);
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, size / HGB_Store.CANVAS_W));
applyZoom();
return zoom;
}
function applyZoom() {
svg.style.width = (HGB_Store.CANVAS_W * zoom) + 'px';
svg.style.height = (HGB_Store.CANVAS_H * zoom) + 'px';
const lv = document.getElementById('zoom-level');
if (lv) lv.textContent = Math.round(zoom * 100) + '%';
}
/* ---------- 坐标转换(client -> svg user units) ---------- */
function toSvgPoint(clientX, clientY) {
const pt = svg.createSVGPoint();
pt.x = clientX; pt.y = clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
/** 命中测试:返回最上层可交互元素 */
function hitTest(clientX, clientY) {
const s = HGB_Store.get();
const p = toSvgPoint(clientX, clientY);
// 从顶层往下找
for (let i = s.elements.length - 1; i >= 0; i--) {
const el = s.elements[i];
if (!el.visible || el.locked) continue;
if (p.x >= el.x && p.x <= el.x + el.w &&
p.y >= el.y && p.y <= el.y + el.h) {
return el;
}
}
return null;
}
/* ---------- 画布状态 ---------- */
function setCanvasBg(color) {
const bgRect = svg.querySelector('[data-hgb-bg]');
if (bgRect) bgRect.setAttribute('fill', color);
}
return {
init, render, renderElement,
setZoom, getZoom, zoomIn, zoomOut, fitToStage,
toSvgPoint, hitTest, setCanvasBg, svgCreate,
get svg() { return svg; }
};
})();
+466
View File
@@ -0,0 +1,466 @@
/**
* 画个标 · 核心:状态管理(Store)
* 职责:维护设计数据、元素 CRUD、撤销/重做、事件订阅、版本快照
*/
const HGB_Store = (function () {
'use strict';
const CANVAS_W = 600;
const CANVAS_H = 600;
const MAX_HISTORY = 60;
const LS_KEY = 'hgb_design';
const LS_VERSION_KEY = 'hgb_versions';
/* ---------- 默认项目 ---------- */
function defaultProject() {
return {
name: '我的品牌',
bg: '#FFFFFF',
elements: [
{
id: uid(),
type: 'icon',
name: '学士帽',
iconId: 'graduation-cap',
svg: iconSvg('graduation-cap'),
x: 210, y: 150, w: 180, h: 180,
rotation: 0,
fill: 'none',
stroke: '#1B4F72',
strokeWidth: 2,
opacity: 1,
visible: true, locked: false
},
{
id: uid(),
type: 'text',
name: '品牌中文名',
text: '远渡教育',
x: 130, y: 360, w: 340, h: 64,
rotation: 0,
fontSize: 48,
fontWeight: 700,
fontFamily: 'SimHei',
letterSpacing: 8,
fill: '#1B4F72',
stroke: 'none',
strokeWidth: 0,
opacity: 1,
visible: true, locked: false
},
{
id: uid(),
type: 'text',
name: '品牌英文名',
text: 'FAR CROSS EDUCATION',
x: 130, y: 430, w: 340, h: 32,
rotation: 0,
fontSize: 18,
fontWeight: 400,
fontFamily: 'Georgia',
letterSpacing: 4,
fill: '#2980B9',
stroke: 'none',
strokeWidth: 0,
opacity: 1,
visible: true, locked: false
}
],
selectedId: null,
selectedIds: [],
currentStyle: null
};
}
/* ---------- 工具 ---------- */
function uid() {
return 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function iconSvg(iconId) {
// 行业模块图标 + 基础图形
for (const ind of HGB_Industries) {
const ic = (ind.icons || []).find(i => i.id === iconId);
if (ic) return ic.svg;
}
const ic = HGB_Icons.shapes.find(i => i.id === iconId);
return ic ? ic.svg : '';
}
/* ---------- 状态 ---------- */
let state = defaultProject();
let history = [];
let historyIdx = -1;
let listeners = {};
/* ---------- 历史记录 ---------- */
function snapshot() {
return JSON.parse(JSON.stringify(state));
}
function pushHistory() {
// 移除 redo 分支
history = history.slice(0, historyIdx + 1);
history.push(snapshot());
if (history.length > MAX_HISTORY) history.shift();
historyIdx = history.length - 1;
emit('history');
}
function undo() {
if (historyIdx <= 0) return false;
historyIdx--;
state = JSON.parse(JSON.stringify(history[historyIdx]));
emit('change');
return true;
}
function redo() {
if (historyIdx >= history.length - 1) return false;
historyIdx++;
state = JSON.parse(JSON.stringify(history[historyIdx]));
emit('change');
return true;
}
function canUndo() { return historyIdx > 0; }
function canRedo() { return historyIdx < history.length - 1; }
/* ---------- 元素操作 ---------- */
function addElement(el) {
state.elements.push(el);
state.selectedId = el.id;
pushHistory();
emit('change');
return el;
}
function updateElement(id, patch, record = true) {
const el = state.elements.find(e => e.id === id);
if (!el) return;
Object.assign(el, patch);
if (record) pushHistory();
emit('change');
}
function removeElement(id) {
state.elements = state.elements.filter(e => e.id !== id);
if (state.selectedId === id) state.selectedId = null;
pushHistory();
emit('change');
}
/** 载入智能方案(替换全部元素与背景) */
function loadPlan(plan) {
state.elements = (plan.elements || []).map(e => ({ ...e }));
state.bg = plan.bg || '#FFFFFF';
state.selectedId = null;
pushHistory();
emit('change');
}
function duplicateElement(id) {
const src = state.elements.find(e => e.id === id);
if (!src) return null;
const copy = JSON.parse(JSON.stringify(src));
copy.id = uid();
copy.name = src.name + ' 副本';
copy.x += 24; copy.y += 24;
// 副本放置到顶层
state.elements.push(copy);
state.selectedId = copy.id;
pushHistory();
emit('change');
return copy;
}
function select(id, additive) {
if (additive && id != null) {
// Shift 多选:切换选中
if (state.selectedIds.includes(id)) {
state.selectedIds = state.selectedIds.filter(x => x !== id);
state.selectedId = state.selectedIds.length ? state.selectedIds[state.selectedIds.length - 1] : null;
} else {
state.selectedIds.push(id);
state.selectedId = id;
}
} else {
state.selectedIds = id != null ? [id] : [];
state.selectedId = id;
}
emit('select');
emit('change');
}
/** 当前所有选中元素 */
function selectedElements() {
return state.selectedIds.map(id => state.elements.find(e => e.id === id)).filter(Boolean);
}
/** 移动全部选中元素 */
function moveSelection(dx, dy) {
for (const el of selectedElements()) {
el.x = Math.round(el.x + dx);
el.y = Math.round(el.y + dy);
}
emit('change');
}
/** 对齐全部选中元素 */
function alignSelection(mode) {
const els = selectedElements();
if (els.length < 2) return;
const xs = els.map(e => e.x), xes = els.map(e => e.x + e.w);
const ys = els.map(e => e.y), yes = els.map(e => e.y + e.h);
const minX = Math.min(...xs), maxX = Math.max(...xes);
const minY = Math.min(...ys), maxY = Math.max(...yes);
const boxCx = (minX + maxX) / 2, boxCy = (minY + maxY) / 2;
for (const el of els) {
if (mode === 'left') el.x = minX;
else if (mode === 'center') el.x = Math.round(boxCx - el.w / 2);
else if (mode === 'right') el.x = Math.round(maxX - el.w);
else if (mode === 'top') el.y = minY;
else if (mode === 'middle') el.y = Math.round(boxCy - el.h / 2);
else if (mode === 'bottom') el.y = Math.round(maxY - el.h);
}
pushHistory(); emit('change');
}
/** 复制全部选中元素 */
function duplicateSelection() {
const srcs = selectedElements();
if (!srcs.length) return 0;
for (const src of srcs) {
const copy = JSON.parse(JSON.stringify(src));
copy.id = uid();
copy.name = src.name + ' 副本';
copy.x += 24; copy.y += 24;
state.elements.push(copy);
state.selectedIds.push(copy.id);
state.selectedId = copy.id;
}
pushHistory(); emit('change');
return srcs.length;
}
/** 粘贴剪贴板元素(生成新副本并选中) */
function pasteElements(list) {
if (!list || !list.length) return [];
const newEls = list.map(src => {
const copy = JSON.parse(JSON.stringify(src));
copy.id = uid();
copy.name = src.name + ' 副本';
copy.x += 24; copy.y += 24;
state.elements.push(copy);
state.selectedIds.push(copy.id);
state.selectedId = copy.id;
return copy;
});
pushHistory(); emit('change');
return newEls;
}
/** 删除全部选中元素 */
function removeSelection() {
if (!state.selectedIds.length) return 0;
const ids = state.selectedIds.slice();
state.elements = state.elements.filter(e => !ids.includes(e.id));
state.selectedIds = [];
state.selectedId = null;
pushHistory(); emit('change');
return ids.length;
}
/* ---------- 图层排序 ---------- */
// elements 数组顺序即显示顺序(后画在上层)
function layerUp(id) {
const i = state.elements.findIndex(e => e.id === id);
if (i < 0 || i >= state.elements.length - 1) return;
[state.elements[i], state.elements[i + 1]] = [state.elements[i + 1], state.elements[i]];
pushHistory(); emit('change');
}
function layerDown(id) {
const i = state.elements.findIndex(e => e.id === id);
if (i <= 0) return;
[state.elements[i], state.elements[i - 1]] = [state.elements[i - 1], state.elements[i]];
pushHistory(); emit('change');
}
function layerFront(id) {
const el = state.elements.find(e => e.id === id);
if (!el) return;
state.elements = state.elements.filter(e => e.id !== id);
state.elements.push(el);
pushHistory(); emit('change');
}
function layerBack(id) {
const el = state.elements.find(e => e.id === id);
if (!el) return;
state.elements = state.elements.filter(e => e.id !== id);
state.elements.unshift(el);
pushHistory(); emit('change');
}
/** 拖拽图层:将 fromIndex 元素移动到 toIndex 位置(占据目标位置,其余元素顺移) */
function moveLayer(fromIndex, toIndex) {
const len = state.elements.length;
if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return;
if (fromIndex === toIndex) return;
const [el] = state.elements.splice(fromIndex, 1);
state.elements.splice(toIndex, 0, el);
pushHistory(); emit('change');
}
/* ---------- 风格应用 ---------- */
function applyStyle(style) {
state.currentStyle = style.id;
state.bg = style.bg;
for (const el of state.elements) {
if (el.type === 'icon') {
el.stroke = style.iconStroke;
el.strokeWidth = style.strokeWidth;
} else if (el.type === 'text') {
el.fill = style.colors.primary;
if (el.fontFamily === 'SimHei' || el.fontFamily === 'Microsoft YaHei' || el.fontFamily === '黑体') {
el.fontFamily = style.fontCn;
}
}
}
pushHistory();
emit('change');
emit('style');
}
/* ---------- 配色应用 ---------- */
function applyPalette(pal) {
let idx = 0;
for (const el of state.elements) {
if (el.type === 'icon') {
el.stroke = pal.colors[Math.min(idx, 1)];
idx++;
} else if (el.type === 'text') {
el.fill = pal.colors[0];
}
}
pushHistory();
emit('change');
emit('style');
}
/* ---------- 字体应用 ---------- */
function applyFont(family, web) {
const el = selected();
if (!el || el.type !== 'text') return;
if (web) HGB_Fonts.loadWebFont(family);
updateElement(el.id, { fontFamily: family });
}
/* ---------- 版本快照 ---------- */
function saveVersion() {
const versions = loadVersions();
const v = {
id: uid(),
time: Date.now(),
name: state.name || '未命名设计',
snapshot: snapshot()
};
versions.unshift(v);
saveVersions(versions);
emit('versions');
return v;
}
function loadVersions() {
try {
return JSON.parse(localStorage.getItem(LS_VERSION_KEY)) || [];
} catch (e) { return []; }
}
function saveVersions(list) {
localStorage.setItem(LS_VERSION_KEY, JSON.stringify(list.slice(0, 50)));
}
function restoreVersion(id) {
const v = loadVersions().find(x => x.id === id);
if (!v) return false;
state = JSON.parse(JSON.stringify(v.snapshot));
pushHistory();
emit('change');
return true;
}
function deleteVersion(id) {
saveVersions(loadVersions().filter(x => x.id !== id));
emit('versions');
}
function clearVersions() {
saveVersions([]);
emit('versions');
}
/* ---------- 事件订阅 ---------- */
function on(evt, fn) {
(listeners[evt] = listeners[evt] || []).push(fn);
}
function emit(evt) {
(listeners[evt] || []).forEach(fn => fn());
}
/* ---------- 选择器 ---------- */
function get() { return state; }
function selected() {
return state.elements.find(e => e.id === state.selectedId) || null;
}
function selectedIndex() {
return state.elements.findIndex(e => e.id === state.selectedId);
}
/* ---------- 本地持久化 ---------- */
function persistToLocal() {
try {
const data = { ...snapshot(), savedAt: Date.now() };
localStorage.setItem(LS_KEY, JSON.stringify(data));
return true;
} catch (e) { return false; }
}
function loadFromLocal() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (data && Array.isArray(data.elements)) {
state = data;
// 迁移遗留演示默认名:历史版本默认项目名「我的留学教育品牌」会污染各行业生成时的品牌回退
if (state.name === '我的留学教育品牌') state.name = '我的品牌';
history = [snapshot()];
historyIdx = 0;
return true;
}
} catch (e) { /* ignore */ }
return false;
}
/* ---------- 初始化 ---------- */
function init(restoreLocal = true) {
if (restoreLocal && loadFromLocal()) return;
state = defaultProject();
history = [snapshot()];
historyIdx = 0;
}
return {
CANVAS_W, CANVAS_H,
get, selected, selectedIndex,
selectedElements, moveSelection, alignSelection,
duplicateSelection, removeSelection, pasteElements,
addElement, updateElement, removeElement, duplicateElement, loadPlan, select,
layerUp, layerDown, layerFront, layerBack, moveLayer,
applyStyle, applyPalette, applyFont,
undo, redo, canUndo, canRedo, pushHistory,
saveVersion, loadVersions, restoreVersion, deleteVersion, clearVersions,
persistToLocal, loadFromLocal, init,
on, emit, uid, iconSvg
};
})();