nx-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +11 -0
- package/README.md +168 -0
- package/config.json +7 -0
- package/package.json +27 -0
- package/src/index.js +343 -0
- package/src/providers/tongyi.js +153 -0
- package/src/tools/barcode.js +176 -0
- package/src/tools/basic.js +121 -0
- package/src/tools/colors.js +83 -0
- package/src/tools/compress.js +31 -0
- package/src/tools/convert.js +48 -0
- package/src/tools/qrcode.js +80 -0
- package/src/tools/smart.js +52 -0
- package/src/tools/stitch.js +68 -0
- package/src/tools/watermark.js +88 -0
- package/src/utils/gradient.js +27 -0
- package/src/utils/output.js +73 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import {readFileSync,existsSync} from 'fs';
|
|
3
|
+
import {dirname,join} from 'path';
|
|
4
|
+
import {fileURLToPath} from 'url';
|
|
5
|
+
|
|
6
|
+
// 通义万相 API Provider
|
|
7
|
+
// API Key 四种传入方式,按优先级排序:
|
|
8
|
+
// 1. apiKey 工具参数(每次调用动态传入)
|
|
9
|
+
// 2. DASHSCOPE_API_KEY 环境变量(shell 或 MCP settings.json env)
|
|
10
|
+
// 3. config.json 配置文件(项目根目录,适合本地开发)
|
|
11
|
+
// 4. 无 Key → 报错提示
|
|
12
|
+
// 参考文档: https://help.aliyun.com/zh/model-studio/
|
|
13
|
+
|
|
14
|
+
const BASE_URL='https://dashscope.aliyuncs.com/api/v1';
|
|
15
|
+
const __dirname=dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const CONFIG_PATH=join(__dirname,'..','..','config.json');
|
|
17
|
+
|
|
18
|
+
function getApiKey(){
|
|
19
|
+
// 优先级 2: 环境变量
|
|
20
|
+
const envKey=process.env.DASHSCOPE_API_KEY||'';
|
|
21
|
+
if(envKey) return envKey;
|
|
22
|
+
|
|
23
|
+
// 优先级 3: config.json
|
|
24
|
+
try{
|
|
25
|
+
if(existsSync(CONFIG_PATH)){
|
|
26
|
+
const cfg=JSON.parse(readFileSync(CONFIG_PATH,'utf-8'));
|
|
27
|
+
const cfgKey=cfg.dashscope_api_key||cfg.DASHSCOPE_API_KEY||'';
|
|
28
|
+
if(cfgKey) return cfgKey;
|
|
29
|
+
}
|
|
30
|
+
}catch(e){/* 配置文件不存在或格式错误,忽略 */}
|
|
31
|
+
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 风格预设映射到通义万相参数
|
|
36
|
+
const styleMap={
|
|
37
|
+
'photorealistic':'<photorealistic>',
|
|
38
|
+
'anime':'<anime>',
|
|
39
|
+
'oil-painting':'<oil painting>',
|
|
40
|
+
'watercolor':'<watercolor>',
|
|
41
|
+
'3d-render':'<3d render>',
|
|
42
|
+
'sketch':'<sketch>',
|
|
43
|
+
'flat-illustration':'<flat illustration>',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 智能生图:调用通义万相文本生成图像 API
|
|
47
|
+
export async function tongyiText2Image({
|
|
48
|
+
apiKey='',
|
|
49
|
+
prompt,
|
|
50
|
+
negativePrompt='',
|
|
51
|
+
width=1024,
|
|
52
|
+
height=1024,
|
|
53
|
+
style='',
|
|
54
|
+
model='wanx-v1',
|
|
55
|
+
numImages=1,
|
|
56
|
+
}){
|
|
57
|
+
const key=apiKey||getApiKey();
|
|
58
|
+
if(!key){
|
|
59
|
+
throw new Error('缺少通义千问 API Key。四种传入方式(按优先级):\n 1. 调用 generate_smart 时传入 apiKey 参数(动态)\n 2. 设置环境变量 DASHSCOPE_API_KEY\n 3. 项目根目录 config.json 中配置 dashscope_api_key\n 4. MCP settings.json 的 env 中注入 DASHSCOPE_API_KEY');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 拼接风格提示词
|
|
63
|
+
const styleTag=styleMap[style]||'';
|
|
64
|
+
const fullPrompt=styleTag?`${styleTag} ${prompt}`:prompt;
|
|
65
|
+
|
|
66
|
+
// 通义万相 v1 支持的尺寸
|
|
67
|
+
const validSizes=['1024*1024','720*1280','1280*720'];
|
|
68
|
+
let size='1024*1024';
|
|
69
|
+
if(width===720&&height===1280) size='720*1280';
|
|
70
|
+
else if(width===1280&&height===720) size='1280*720';
|
|
71
|
+
else if(width===1024&&height===1024) size='1024*1024';
|
|
72
|
+
else{
|
|
73
|
+
// 选择最接近的尺寸
|
|
74
|
+
const ratios=validSizes.map(s=>{
|
|
75
|
+
const [w,h]=s.split('*').map(Number);
|
|
76
|
+
return {size:s,diff:Math.abs(w-width)+Math.abs(h-height)};
|
|
77
|
+
});
|
|
78
|
+
size=ratios.sort((a,b)=>a.diff-b.diff)[0].size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tasks=[];
|
|
82
|
+
for(let i=0;i<numImages;i++){
|
|
83
|
+
tasks.push(submitTask(fullPrompt,negativePrompt,size,model,key));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const results=await Promise.all(tasks);
|
|
87
|
+
return {
|
|
88
|
+
model,
|
|
89
|
+
prompt:fullPrompt,
|
|
90
|
+
images:results.filter(Boolean),
|
|
91
|
+
count:results.filter(Boolean).length,
|
|
92
|
+
provider:'tongyi-wanxiang',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function submitTask(prompt,negativePrompt,size,model,apiKey){
|
|
97
|
+
try{
|
|
98
|
+
// 提交任务
|
|
99
|
+
const submitRes=await axios.post(
|
|
100
|
+
`${BASE_URL}/services/aigc/text2image/image-synthesis`,
|
|
101
|
+
{
|
|
102
|
+
model,
|
|
103
|
+
input:{prompt,negative_prompt:negativePrompt},
|
|
104
|
+
parameters:{size,n:1,watermark:false},
|
|
105
|
+
},
|
|
106
|
+
{headers:{'Authorization':`Bearer ${apiKey}`,'Content-Type':'application/json','X-DashScope-Async':'enable'},timeout:30000},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const taskId=submitRes.data?.output?.task_id;
|
|
110
|
+
if(!taskId) throw new Error(`任务提交失败: ${JSON.stringify(submitRes.data)}`);
|
|
111
|
+
|
|
112
|
+
// 轮询等待结果
|
|
113
|
+
const imageUrl=await pollTask(taskId,apiKey);
|
|
114
|
+
return imageUrl;
|
|
115
|
+
}catch(err){
|
|
116
|
+
if(err.response){
|
|
117
|
+
const msg=err.response.data?.message||err.message;
|
|
118
|
+
throw new Error(`通义万相 API 错误: ${msg}`);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function pollTask(taskId,apiKey,maxRetries=30,interval=2000){
|
|
125
|
+
for(let i=0;i<maxRetries;i++){
|
|
126
|
+
await sleep(interval);
|
|
127
|
+
try{
|
|
128
|
+
const res=await axios.get(
|
|
129
|
+
`${BASE_URL}/tasks/${taskId}`,
|
|
130
|
+
{headers:{'Authorization':`Bearer ${apiKey}`}},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const status=res.data?.output?.task_status;
|
|
134
|
+
if(status==='SUCCEEDED'){
|
|
135
|
+
const results=res.data?.output?.results;
|
|
136
|
+
if(results&&results.length>0) return results[0].url;
|
|
137
|
+
throw new Error('任务完成但无结果');
|
|
138
|
+
}
|
|
139
|
+
if(status==='FAILED'){
|
|
140
|
+
throw new Error(`任务失败: ${res.data?.output?.message||'未知错误'}`);
|
|
141
|
+
}
|
|
142
|
+
// PENDING / RUNNING → 继续等待
|
|
143
|
+
}catch(err){
|
|
144
|
+
if(err.message.includes('任务失败')) throw err;
|
|
145
|
+
// 网络错误继续重试
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`任务超时: ${taskId}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sleep(ms){
|
|
152
|
+
return new Promise(r=>setTimeout(r,ms));
|
|
153
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import {saveOutput} from '../utils/output.js';
|
|
3
|
+
|
|
4
|
+
// 纯 SVG 条形码生成,无需 canvas/jsBarcode
|
|
5
|
+
|
|
6
|
+
const ENCODINGS={
|
|
7
|
+
CODE128:encodeCode128,
|
|
8
|
+
CODE39:encodeCode39,
|
|
9
|
+
EAN13:encodeEAN,
|
|
10
|
+
EAN8:encodeEAN,
|
|
11
|
+
UPC:encodeEAN,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function generateBarcode(args){
|
|
15
|
+
const {content,format='CODE128',width=400,height=100,displayValue=true,fontSize=16,outputDir}=args;
|
|
16
|
+
|
|
17
|
+
const encode=ENCODINGS[format]||encodeCode128;
|
|
18
|
+
const bars=encode(content);
|
|
19
|
+
|
|
20
|
+
if(!bars||bars.length===0){
|
|
21
|
+
throw new Error(`无法编码内容 "${content}" 为 ${format}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 计算条码区域
|
|
25
|
+
const textHeight=displayValue?fontSize+10:0;
|
|
26
|
+
const barHeight=height-textHeight-10;
|
|
27
|
+
const totalBars=bars.reduce((s,b)=>s+(b.width||1),0);
|
|
28
|
+
const barUnitWidth=Math.max(1,Math.floor((width-20)/totalBars));
|
|
29
|
+
|
|
30
|
+
// 生成 SVG
|
|
31
|
+
let svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`;
|
|
32
|
+
svg+=`<rect width="${width}" height="${height}" fill="#ffffff"/>`;
|
|
33
|
+
|
|
34
|
+
let x=10;
|
|
35
|
+
for(const bar of bars){
|
|
36
|
+
const bw=bar.width*barUnitWidth;
|
|
37
|
+
if(bar.color==='black'){
|
|
38
|
+
svg+=`<rect x="${x}" y="5" width="${bw}" height="${barHeight}" fill="#000000"/>`;
|
|
39
|
+
}
|
|
40
|
+
x+=bw;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if(displayValue){
|
|
44
|
+
const textY=height-5;
|
|
45
|
+
svg+=`<text x="${width/2}" y="${textY}" font-size="${fontSize}" fill="#000000" text-anchor="middle" font-family="monospace">${content}</text>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
svg+='</svg>';
|
|
49
|
+
|
|
50
|
+
const buf=await sharp(Buffer.from(svg)).png().toBuffer();
|
|
51
|
+
const saved=await saveOutput(buf,'barcode.png',outputDir,'url');
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
success:true,
|
|
55
|
+
...saved,
|
|
56
|
+
content,
|
|
57
|
+
format,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ===== 编码实现 =====
|
|
62
|
+
|
|
63
|
+
// Code128 基础编码
|
|
64
|
+
function encodeCode128(text){
|
|
65
|
+
// 简化为 Code128B 子集(支持 ASCII 32-126)
|
|
66
|
+
const chars=[];
|
|
67
|
+
for(let i=0;i<text.length;i++){
|
|
68
|
+
const code=text.charCodeAt(i);
|
|
69
|
+
if(code<32||code>126){
|
|
70
|
+
throw new Error(`Code128B 不支持字符: ${text[i]}`);
|
|
71
|
+
}
|
|
72
|
+
chars.push(code-32);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let checksum=104;// Start B
|
|
76
|
+
for(let i=0;i<chars.length;i++){
|
|
77
|
+
checksum+=chars[i]*(i+1);
|
|
78
|
+
}
|
|
79
|
+
checksum%=103;
|
|
80
|
+
|
|
81
|
+
// 生成条码图案 (简化版: 每字符=11单元,条形=黑,空=白)
|
|
82
|
+
const bars=[];
|
|
83
|
+
// 起始符 (Start B)
|
|
84
|
+
bars.push(...code128Pattern(104));
|
|
85
|
+
for(const c of chars){
|
|
86
|
+
bars.push(...code128Pattern(c));
|
|
87
|
+
}
|
|
88
|
+
// 校验符
|
|
89
|
+
bars.push(...code128Pattern(checksum));
|
|
90
|
+
// 终止符
|
|
91
|
+
bars.push(...code128Pattern(106));
|
|
92
|
+
|
|
93
|
+
// 转换为 {color, width} 格式
|
|
94
|
+
const result=[];
|
|
95
|
+
for(let i=0;i<bars.length;i++){
|
|
96
|
+
const color=i%2===0?'black':'white';
|
|
97
|
+
result.push({color,width:bars[i]});
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function code128Pattern(code){
|
|
103
|
+
// 简化版 Code128 条码图案 (0-106)
|
|
104
|
+
const patterns=[
|
|
105
|
+
[2,1,2,2,2,2],[2,2,2,1,2,2],[2,2,2,2,2,1],[1,2,1,2,2,3],[1,2,1,3,2,2],
|
|
106
|
+
[1,3,1,2,2,2],[1,2,2,2,1,3],[1,2,2,3,1,2],[1,3,2,2,1,2],[2,2,1,2,1,3],
|
|
107
|
+
[2,2,1,3,1,2],[2,3,1,2,1,2],[1,1,2,2,3,2],[1,2,2,1,3,2],[1,2,2,2,3,1],
|
|
108
|
+
[1,1,3,2,2,2],[1,2,3,1,2,2],[1,2,3,2,2,1],[2,2,3,2,1,1],[2,2,1,1,3,2],
|
|
109
|
+
[2,2,1,2,3,1],[2,1,3,2,1,2],[2,2,3,1,1,2],[3,1,2,1,3,1],[3,1,1,2,2,2],
|
|
110
|
+
[3,2,1,1,2,2],[3,2,1,2,2,1],[3,1,2,2,1,2],[3,2,2,1,1,2],[3,2,2,2,1,1],
|
|
111
|
+
[2,1,2,1,2,3],[2,1,2,3,2,1],[2,3,2,1,2,1],[1,1,1,3,2,3],[1,3,1,1,2,3],
|
|
112
|
+
[1,3,1,3,2,1],[1,1,2,3,1,3],[1,3,2,1,1,3],[1,3,2,3,1,1],[2,1,1,3,1,3],
|
|
113
|
+
[2,3,1,1,1,3],[2,3,1,3,1,1],[1,1,2,1,3,3],[1,1,2,3,3,1],[1,3,2,1,3,1],
|
|
114
|
+
[1,1,3,1,2,3],[1,1,3,3,2,1],[1,3,3,1,2,1],[3,1,3,1,2,1],[2,1,1,3,3,1],
|
|
115
|
+
[2,3,1,1,3,1],[2,1,3,1,1,3],[2,1,3,3,1,1],[2,1,3,1,3,1],[3,1,1,1,2,3],
|
|
116
|
+
[3,1,1,3,2,1],[3,3,1,1,2,1],[3,1,2,1,1,3],[3,1,2,3,1,1],[3,3,2,1,1,1],
|
|
117
|
+
[3,1,4,1,1,1],[2,2,1,4,1,2],[2,2,1,2,1,4],[2,4,1,2,1,2],[2,2,1,2,3,2],
|
|
118
|
+
[2,2,3,2,1,2],[2,4,3,2,1,1],[2,2,1,1,2,4],[2,4,1,1,2,2],[2,2,1,2,2,4],
|
|
119
|
+
[2,4,1,2,2,2],[2,2,1,4,1,2],[2,2,1,4,2,1],[2,1,1,4,2,2],[2,4,1,1,2,2],
|
|
120
|
+
[2,1,4,2,1,2],[2,2,4,1,1,2],[2,2,4,2,1,1],[1,4,1,2,1,2],[1,4,1,2,2,1],
|
|
121
|
+
[1,4,1,2,1,2],[1,2,4,1,1,2],[1,2,4,2,1,1],[1,1,4,2,1,2],[1,2,1,4,2,1],
|
|
122
|
+
[2,1,1,2,1,4],[2,1,1,4,1,2],[2,1,1,2,3,2],[2,3,3,1,1,1,2],[1,2,4,1,1,1,2],
|
|
123
|
+
[4,1,1,1,1,2],[4,1,1,1,1,1,1],[4,3,1,1,1,1],[1,1,4,1,1,1,2],[1,1,4,1,1,1,1,1],
|
|
124
|
+
[4,1,1,1,3,1],[4,1,1,3,1,1],[1,1,4,1,3,1],[1,1,4,3,1,1],[4,1,1,1,1,3],
|
|
125
|
+
[4,1,1,3,1,1],[1,1,1,1,4,3],[1,1,1,3,4,1],[1,3,1,1,4,1],[1,1,4,1,1,3],
|
|
126
|
+
[1,1,4,3,1,1],[3,1,1,1,4,1],[4,1,1,1,1,3],[2,1,1,4,1,2],[2,1,1,2,1,4],
|
|
127
|
+
[2,1,1,2,3,2],[2,3,3,1,1,1,2],[2,3,3,1,1,1,2],[2,3,3,1,1,1,2],
|
|
128
|
+
];
|
|
129
|
+
return patterns[code%patterns.length]||patterns[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Code39 编码
|
|
133
|
+
function encodeCode39(text){
|
|
134
|
+
const map={'0':'101001101101','1':'110100101011','2':'101100101011','3':'110110010101','4':'101001101011','5':'110100110101','6':'101100110101','7':'101001011011','8':'110100101101','9':'101100101101','A':'110101001011','B':'101101001011','C':'110110100101','D':'101011001011','E':'110101100101','F':'101101100101','G':'101010011011','H':'110101001101','I':'101101001101','J':'101011001101','K':'110101010011','L':'101101010011','M':'110110101001','N':'101011010011','O':'110101101001','P':'101101101001','Q':'101010110011','R':'110101011001','S':'101101011001','T':'101011011001','U':'110010101011','V':'100110101011','W':'110011010101','X':'100101101011','Y':'110010110101','Z':'100110110101','-':'100101011011','.':'110010101101',' ':'100110101101','$':'100100100101','/':'100100101001','+':'100101001001','%':'101001001001','*':'100101101101'};
|
|
135
|
+
|
|
136
|
+
const s=`*${text.toUpperCase()}*`;
|
|
137
|
+
const result=[];
|
|
138
|
+
for(let i=0;i<s.length;i++){
|
|
139
|
+
const pattern=map[s[i]];
|
|
140
|
+
if(!pattern) throw new Error(`Code39 不支持字符: ${s[i]}`);
|
|
141
|
+
for(let j=0;j<pattern.length;j++){
|
|
142
|
+
result.push({color:j%2===0?'black':'white',width:pattern[j]==='1'?2:1});
|
|
143
|
+
}
|
|
144
|
+
// 字符间间隔
|
|
145
|
+
if(i<s.length-1) result.push({color:'white',width:1});
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// EAN-13 / EAN-8 编码
|
|
151
|
+
function encodeEAN(text){
|
|
152
|
+
const digits=text.replace(/[^0-9]/g,'');
|
|
153
|
+
// 简化实现:直接生成条码图案
|
|
154
|
+
const L=['0001101','0011001','0010011','0111101','0100011','0110001','0101111','0111011','0110111','0001011'];
|
|
155
|
+
const R=['1110010','1100110','1101100','1000010','1011100','1001110','1010000','1000100','1001000','1110100'];
|
|
156
|
+
|
|
157
|
+
const bars=[];
|
|
158
|
+
// 起始符
|
|
159
|
+
const start='101';
|
|
160
|
+
for(const c of start) bars.push({color:c==='1'?'black':'white',width:1});
|
|
161
|
+
|
|
162
|
+
for(let i=0;i<digits.length;i++){
|
|
163
|
+
const d=parseInt(digits[i]);
|
|
164
|
+
const pattern=i<7?L[d]:R[d];
|
|
165
|
+
for(const c of pattern) bars.push({color:c==='1'?'black':'white',width:1});
|
|
166
|
+
if(i===6){
|
|
167
|
+
// 中间分隔
|
|
168
|
+
for(const c of '01010') bars.push({color:c==='1'?'black':'white',width:1});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 终止符
|
|
173
|
+
for(const c of start) bars.push({color:c==='1'?'black':'white',width:1});
|
|
174
|
+
|
|
175
|
+
return bars;
|
|
176
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import {saveOutput,loadImage,parseColor} from '../utils/output.js';
|
|
3
|
+
import {gradientSvg,solidBgSvg} from '../utils/gradient.js';
|
|
4
|
+
|
|
5
|
+
export async function generateBasic(args){
|
|
6
|
+
const {width=800,height=600,background,shapes=[],texts=[],images=[],border,format='png',quality=90,outputDir}=args;
|
|
7
|
+
const layers=[];
|
|
8
|
+
|
|
9
|
+
// 1. 构建背景
|
|
10
|
+
if(typeof background==='string'&&background.startsWith('{')) return {}; // No content yet
|
|
11
|
+
let bgBuffer;
|
|
12
|
+
if(!background||background==='transparent'){
|
|
13
|
+
bgBuffer=Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="transparent"/></svg>`);
|
|
14
|
+
}else if(typeof background==='string'){
|
|
15
|
+
bgBuffer=solidBgSvg(width,height,background);
|
|
16
|
+
}else if(typeof background==='object'){
|
|
17
|
+
bgBuffer=gradientSvg(width,height,background)||solidBgSvg(width,height,'#ffffff');
|
|
18
|
+
}else{
|
|
19
|
+
bgBuffer=solidBgSvg(width,height,'#ffffff');
|
|
20
|
+
}
|
|
21
|
+
layers.push({input:bgBuffer});
|
|
22
|
+
|
|
23
|
+
// 2. 合成图形和文字为 SVG 叠加层
|
|
24
|
+
if(shapes.length>0||texts.length>0){
|
|
25
|
+
let svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`;
|
|
26
|
+
for(const s of shapes){
|
|
27
|
+
svg+=renderShape(s);
|
|
28
|
+
}
|
|
29
|
+
for(const t of texts){
|
|
30
|
+
svg+=renderText(t);
|
|
31
|
+
}
|
|
32
|
+
svg+='</svg>';
|
|
33
|
+
layers.push({input:Buffer.from(svg)});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3. 叠加外部图片
|
|
37
|
+
for(const img of images||[]){
|
|
38
|
+
const overlay=await loadImage(img.src);
|
|
39
|
+
let processed=overlay;
|
|
40
|
+
if(img.width||img.height){
|
|
41
|
+
processed=overlay.resize(img.width||null,img.height||null,{fit:'inside'});
|
|
42
|
+
}
|
|
43
|
+
const buf=await processed.png().toBuffer();
|
|
44
|
+
layers.push({
|
|
45
|
+
input:buf,
|
|
46
|
+
top:img.y||0,
|
|
47
|
+
left:img.x||0,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. 合并图层
|
|
52
|
+
let composite=sharp({create:{width,height,channels:4,background:parseColor(background==='transparent'?'transparent':'#ffffff')}});
|
|
53
|
+
if(layers.length>0){
|
|
54
|
+
composite=composite.composite(layers.map(l=>({...l,blend:'over'})));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let pipeline=composite;
|
|
58
|
+
|
|
59
|
+
// 5. 边框
|
|
60
|
+
if(border){
|
|
61
|
+
const bw=border.width||2;
|
|
62
|
+
const bc=border.color||'#000000';
|
|
63
|
+
const br=border.radius||0;
|
|
64
|
+
if(br>0){
|
|
65
|
+
const svg=Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect x="${bw/2}" y="${bw/2}" width="${width-bw}" height="${height-bw}" rx="${br}" ry="${br}" fill="none" stroke="${bc}" stroke-width="${bw}"/></svg>`);
|
|
66
|
+
pipeline=pipeline.composite([{input:svg,blend:'over'}]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 6. 输出
|
|
71
|
+
if(format==='jpeg'){
|
|
72
|
+
pipeline=pipeline.jpeg({quality});
|
|
73
|
+
}else if(format==='webp'){
|
|
74
|
+
pipeline=pipeline.webp({quality});
|
|
75
|
+
}else{
|
|
76
|
+
pipeline=pipeline.png();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const buffer=await pipeline.toBuffer();
|
|
80
|
+
const filename=`basic.${format}`;
|
|
81
|
+
const saved=await saveOutput(buffer,filename,outputDir,'url');
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
success:true,
|
|
85
|
+
...saved,
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
format,
|
|
89
|
+
shapesCount:shapes.length,
|
|
90
|
+
textsCount:texts.length,
|
|
91
|
+
imagesCount:(images||[]).length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderShape(s){
|
|
96
|
+
const attrs=[];
|
|
97
|
+
if(s.fill) attrs.push(`fill="${s.fill}"`);
|
|
98
|
+
if(s.stroke) attrs.push(`stroke="${s.stroke}" stroke-width="${s.strokeWidth||1}"`);
|
|
99
|
+
switch(s.type){
|
|
100
|
+
case'rect':
|
|
101
|
+
return `<rect x="${s.x||0}" y="${s.y||0}" width="${s.w||100}" height="${s.h||100}" rx="${s.r||0}" ${attrs.join(' ')}/>`;
|
|
102
|
+
case'circle':
|
|
103
|
+
return `<circle cx="${s.x||0}" cy="${s.y||0}" r="${s.r||50}" ${attrs.join(' ')}/>`;
|
|
104
|
+
case'ellipse':
|
|
105
|
+
return `<ellipse cx="${s.x||0}" cy="${s.y||0}" rx="${s.r||50}" ry="${s.ry||30}" ${attrs.join(' ')}/>`;
|
|
106
|
+
case'line':
|
|
107
|
+
return `<line x1="${s.x||0}" y1="${s.y||0}" x2="${s.x2||100}" y2="${s.y2||100}" stroke="${s.stroke||'#000'}" stroke-width="${s.strokeWidth||1}"/>`;
|
|
108
|
+
case'polygon':
|
|
109
|
+
return `<polygon points="${(s.points||[]).map(p=>`${p.x},${p.y}`).join(' ')}" ${attrs.join(' ')}/>`;
|
|
110
|
+
default:
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderText(t){
|
|
116
|
+
return `<text x="${t.x}" y="${t.y}" font-size="${t.fontSize||24}" fill="${t.color||'#000'}" font-family="${t.fontFamily||'sans-serif'}" text-anchor="${t.align||'start'}" ${t.maxWidth?`textLength="${t.maxWidth}" lengthAdjust="spacing"`:''}>${escapeXml(t.content)}</text>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function escapeXml(s){
|
|
120
|
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
121
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import {loadImage} from '../utils/output.js';
|
|
3
|
+
|
|
4
|
+
export async function extractColors(args){
|
|
5
|
+
const {src,count=5,format='hex'}={...args};
|
|
6
|
+
|
|
7
|
+
// 缩小图片提高取色速度
|
|
8
|
+
const img=await loadImage(src);
|
|
9
|
+
const meta=await img.metadata();
|
|
10
|
+
const smallBuf=await img.resize(200,200,{fit:'inside'}).raw().toBuffer({resolveWithObject:true});
|
|
11
|
+
const {data,info}=smallBuf;
|
|
12
|
+
|
|
13
|
+
// 简单颜色量化:使用 K-means 启发式
|
|
14
|
+
const pixels=[];
|
|
15
|
+
const step=Math.max(1,Math.floor((info.width*info.height)/(10000)));
|
|
16
|
+
for(let i=0;i<data.length;i+=info.channels*step){
|
|
17
|
+
const r=data[i],g=data[i+1],b=data[i+2],a=data[i+3]||255;
|
|
18
|
+
if(a<128) continue;// 跳过透明像素
|
|
19
|
+
pixels.push([r,g,b]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const palette=quantize(pixels,count);
|
|
23
|
+
|
|
24
|
+
const colors=palette.map(([r,g,b])=>{
|
|
25
|
+
if(format==='hsl'){
|
|
26
|
+
return rgbToHsl(r,g,b);
|
|
27
|
+
}
|
|
28
|
+
if(format==='rgb'){
|
|
29
|
+
return `rgb(${r},${g},${b})`;
|
|
30
|
+
}
|
|
31
|
+
// hex
|
|
32
|
+
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
success:true,
|
|
37
|
+
colors,
|
|
38
|
+
count:colors.length,
|
|
39
|
+
format,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 简易中位切分量化算法提取主色调
|
|
44
|
+
function quantize(pixels,k){
|
|
45
|
+
if(pixels.length<=k) return pixels;
|
|
46
|
+
|
|
47
|
+
// 用颜色频次直方图简化
|
|
48
|
+
const bins=new Map();
|
|
49
|
+
for(const p of pixels){
|
|
50
|
+
// 量化到 32 级减少 bin 数
|
|
51
|
+
const key=`${Math.floor(p[0]/32)},${Math.floor(p[1]/32)},${Math.floor(p[2]/32)}`;
|
|
52
|
+
if(!bins.has(key)){
|
|
53
|
+
bins.set(key,{sum:[0,0,0],count:0});
|
|
54
|
+
}
|
|
55
|
+
const b=bins.get(key);
|
|
56
|
+
b.sum=[b.sum[0]+p[0],b.sum[1]+p[1],b.sum[2]+p[2]];
|
|
57
|
+
b.count++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 按像素数排序取前 k 个
|
|
61
|
+
const sorted=[...bins.values()].sort((a,b)=>b.count-a.count).slice(0,k);
|
|
62
|
+
return sorted.map(b=>[
|
|
63
|
+
Math.round(b.sum[0]/b.count),
|
|
64
|
+
Math.round(b.sum[1]/b.count),
|
|
65
|
+
Math.round(b.sum[2]/b.count),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rgbToHsl(r,g,b){
|
|
70
|
+
const rf=r/255,gf=g/255,bf=b/255;
|
|
71
|
+
const max=Math.max(rf,gf,bf),min=Math.min(rf,gf,bf);
|
|
72
|
+
let h=0,s=0,l=(max+min)/2;
|
|
73
|
+
if(max!==min){
|
|
74
|
+
const d=max-min;
|
|
75
|
+
s=l>0.5?d/(2-max-min):d/(max+min);
|
|
76
|
+
switch(max){
|
|
77
|
+
case rf:h=((gf-bf)/d+(gf<bf?6:0))/6;break;
|
|
78
|
+
case gf:h=((bf-rf)/d+2)/6;break;
|
|
79
|
+
case bf:h=((rf-gf)/d+4)/6;break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return `hsl(${Math.round(h*360)},${Math.round(s*100)}%,${Math.round(l*100)}%)`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {saveOutput,loadImage} from '../utils/output.js';
|
|
2
|
+
|
|
3
|
+
export async function compressImage(args){
|
|
4
|
+
const {src,quality=80,maxWidth,maxHeight,format='jpeg',outputDir}=args;
|
|
5
|
+
|
|
6
|
+
let pipeline=await loadImage(src);
|
|
7
|
+
const meta=await pipeline.metadata();
|
|
8
|
+
|
|
9
|
+
// 等比缩放
|
|
10
|
+
if(maxWidth||maxHeight){
|
|
11
|
+
pipeline=pipeline.resize(maxWidth||null,maxHeight||null,{fit:'inside',withoutEnlargement:true});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 格式与质量
|
|
15
|
+
if(format==='jpeg') pipeline=pipeline.jpeg({quality});
|
|
16
|
+
else if(format==='webp') pipeline=pipeline.webp({quality});
|
|
17
|
+
else pipeline=pipeline.png({quality});
|
|
18
|
+
|
|
19
|
+
const buffer=await pipeline.toBuffer();
|
|
20
|
+
const filename=`compressed.${format}`;
|
|
21
|
+
const saved=await saveOutput(buffer,filename,outputDir,'url');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
success:true,
|
|
25
|
+
...saved,
|
|
26
|
+
compressedSize:buffer.length,
|
|
27
|
+
width:meta.width,
|
|
28
|
+
height:meta.height,
|
|
29
|
+
quality,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import {saveOutput,loadImage} from '../utils/output.js';
|
|
3
|
+
|
|
4
|
+
const FORMATS=['png','jpeg','webp','bmp','tiff','avif'];
|
|
5
|
+
|
|
6
|
+
export async function convertFormat(args){
|
|
7
|
+
const {src,format,quality=90,background='#ffffff',outputDir}=args;
|
|
8
|
+
|
|
9
|
+
if(!FORMATS.includes(format)){
|
|
10
|
+
throw new Error(`不支持的格式: ${format}。支持: ${FORMATS.join(', ')}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let pipeline=await loadImage(src);
|
|
14
|
+
const meta=await pipeline.metadata();
|
|
15
|
+
|
|
16
|
+
// 格式转换
|
|
17
|
+
if(format==='jpeg'){
|
|
18
|
+
// 处理透明背景
|
|
19
|
+
const hasAlpha=meta.hasAlpha||meta.channels===4;
|
|
20
|
+
if(hasAlpha){
|
|
21
|
+
pipeline=pipeline.flatten({background});
|
|
22
|
+
}
|
|
23
|
+
pipeline=pipeline.jpeg({quality});
|
|
24
|
+
}else if(format==='webp'){
|
|
25
|
+
pipeline=pipeline.webp({quality});
|
|
26
|
+
}else if(format==='png'){
|
|
27
|
+
pipeline=pipeline.png();
|
|
28
|
+
}else if(format==='bmp'){
|
|
29
|
+
pipeline=pipeline.bmp();
|
|
30
|
+
}else if(format==='tiff'){
|
|
31
|
+
pipeline=pipeline.tiff();
|
|
32
|
+
}else if(format==='avif'){
|
|
33
|
+
pipeline=pipeline.avif({quality});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const buffer=await pipeline.toBuffer();
|
|
37
|
+
const filename=`converted.${format}`;
|
|
38
|
+
const saved=await saveOutput(buffer,filename,outputDir,'url');
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
success:true,
|
|
42
|
+
...saved,
|
|
43
|
+
originalFormat:meta.format,
|
|
44
|
+
targetFormat:format,
|
|
45
|
+
width:meta.width,
|
|
46
|
+
height:meta.height,
|
|
47
|
+
};
|
|
48
|
+
}
|