bindery-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Bindery 封面生成器</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ :root {
13
+ --paper: #ffffff;
14
+ --panel: #ffffff;
15
+ --ink: #262220;
16
+ --ink-soft: #7a7266;
17
+ --rule: #d8d1bd;
18
+ --seal: #a23b2e;
19
+ --seal-dark: #7e2c22;
20
+ --seal-tint: rgba(162, 59, 46, 0.14);
21
+ }
22
+
23
+ html,
24
+ body {
25
+ height: 100%;
26
+ }
27
+
28
+ body {
29
+ font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
30
+ margin: 0;
31
+ display: flex;
32
+ flex-direction: column;
33
+ overflow: hidden;
34
+ background: var(--paper);
35
+ color: var(--ink);
36
+ }
37
+
38
+ ::selection {
39
+ background: var(--seal-tint);
40
+ }
41
+
42
+ :focus-visible {
43
+ outline: 2px solid var(--seal);
44
+ outline-offset: 2px;
45
+ }
46
+
47
+ .layout {
48
+ flex: 1;
49
+ min-height: 0;
50
+ display: flex;
51
+ gap: 32px;
52
+ padding: 24px 28px;
53
+ max-width: 1100px;
54
+ width: 100%;
55
+ margin: 0 auto;
56
+ align-items: stretch;
57
+ }
58
+
59
+ .panel {
60
+ background: transparent;
61
+ padding: 0;
62
+ }
63
+
64
+ .controls {
65
+ flex: 3 1 0;
66
+ min-width: 0;
67
+ overflow-y: auto;
68
+ padding-right: 28px;
69
+ border-right: 1px solid var(--rule);
70
+ }
71
+
72
+ .preview {
73
+ flex: 2 1 0;
74
+ min-width: 0;
75
+ display: flex;
76
+ flex-direction: column;
77
+ align-items: center;
78
+ justify-content: center;
79
+ gap: 16px;
80
+ }
81
+
82
+ .section {
83
+ margin-bottom: 16px;
84
+ }
85
+
86
+ .section:last-of-type {
87
+ margin-bottom: 0;
88
+ }
89
+
90
+ .row {
91
+ display: flex;
92
+ gap: 16px;
93
+ }
94
+
95
+ .row + .row {
96
+ margin-top: 16px;
97
+ }
98
+
99
+ .row > .field {
100
+ flex: 1;
101
+ min-width: 0;
102
+ }
103
+
104
+ .row > .field-options {
105
+ flex: 0 0 110px;
106
+ min-width: 0;
107
+ }
108
+
109
+ label {
110
+ display: block;
111
+ font-size: 12.5px;
112
+ color: var(--ink-soft);
113
+ margin: 16px 0 6px;
114
+ }
115
+
116
+ label:first-child {
117
+ margin-top: 0;
118
+ }
119
+
120
+ input[type="text"],
121
+ select {
122
+ width: 100%;
123
+ padding: 7px 2px 8px;
124
+ border: none;
125
+ border-bottom: 1px solid var(--rule);
126
+ border-radius: 0;
127
+ font-size: 14px;
128
+ background: transparent;
129
+ color: var(--ink);
130
+ font-family: inherit;
131
+ transition:
132
+ border-color 0.15s,
133
+ box-shadow 0.15s;
134
+ }
135
+
136
+ input[type="text"]::placeholder {
137
+ color: #b7ae9c;
138
+ }
139
+
140
+ select {
141
+ appearance: none;
142
+ -webkit-appearance: none;
143
+ background-image:
144
+ linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%),
145
+ linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%);
146
+ background-position:
147
+ calc(100% - 6px) center,
148
+ calc(100% - 1px) center;
149
+ background-size:
150
+ 6px 6px,
151
+ 6px 6px;
152
+ background-repeat: no-repeat;
153
+ padding-right: 18px;
154
+ cursor: pointer;
155
+ }
156
+
157
+ input[type="text"]:focus,
158
+ select:focus {
159
+ outline: none;
160
+ border-bottom: 1px solid var(--accent, var(--seal));
161
+ box-shadow: 0 1px 0 0 var(--accent, var(--seal));
162
+ }
163
+
164
+ .swatches {
165
+ display: flex;
166
+ flex-wrap: wrap;
167
+ gap: 14px;
168
+ padding: 6px;
169
+ }
170
+
171
+ .swatch {
172
+ width: 30px;
173
+ height: 30px;
174
+ border-radius: 50%;
175
+ cursor: pointer;
176
+ border: 2px solid transparent;
177
+ position: relative;
178
+ transition:
179
+ box-shadow 0.15s,
180
+ transform 0.1s;
181
+ }
182
+
183
+ .swatch:hover {
184
+ transform: translateY(-1px);
185
+ }
186
+
187
+ .swatch.active {
188
+ box-shadow:
189
+ 0 0 0 2px var(--panel),
190
+ 0 0 0 4px var(--ink);
191
+ }
192
+
193
+ .swatch.active::after {
194
+ content: none;
195
+ }
196
+
197
+ .swatch.custom {
198
+ border: none;
199
+ margin: 0;
200
+ }
201
+
202
+ .swatch.custom::before {
203
+ content: "";
204
+ position: absolute;
205
+ inset: 0;
206
+ border-radius: 50%;
207
+ background: conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
208
+ }
209
+
210
+ .swatch.custom.active {
211
+ box-shadow:
212
+ 0 0 0 2px var(--panel),
213
+ 0 0 0 4px var(--ink);
214
+ }
215
+
216
+ .swatch.custom input[type="color"] {
217
+ position: absolute;
218
+ inset: 0;
219
+ width: 100%;
220
+ height: 100%;
221
+ opacity: 0;
222
+ cursor: pointer;
223
+ border: none;
224
+ padding: 0;
225
+ margin: 0;
226
+ }
227
+
228
+ canvas {
229
+ max-width: 100%;
230
+ max-height: 100%;
231
+ width: auto;
232
+ height: auto;
233
+ display: block;
234
+ border-radius: 2px;
235
+ border: 1px solid var(--rule);
236
+ }
237
+
238
+ .actions {
239
+ display: flex;
240
+ gap: 10px;
241
+ }
242
+
243
+ button {
244
+ padding: 10px 18px;
245
+ border: none;
246
+ border-radius: 3px;
247
+ font-size: 13.5px;
248
+ cursor: pointer;
249
+ font-weight: 600;
250
+ font-family: inherit;
251
+ transition:
252
+ transform 0.08s,
253
+ background 0.15s,
254
+ opacity 0.15s;
255
+ }
256
+
257
+ .primary {
258
+ background: var(--accent, var(--seal));
259
+ color: #fff;
260
+ }
261
+
262
+ .primary:hover {
263
+ filter: brightness(0.85);
264
+ }
265
+
266
+ .primary:active {
267
+ transform: translateY(1px);
268
+ }
269
+
270
+ .secondary {
271
+ background: transparent;
272
+ color: var(--ink);
273
+ border: 1px solid var(--rule);
274
+ }
275
+
276
+ .secondary:hover {
277
+ border-color: var(--ink-soft);
278
+ }
279
+
280
+ button:disabled {
281
+ opacity: 0.45;
282
+ cursor: not-allowed;
283
+ transform: none;
284
+ }
285
+
286
+ #status {
287
+ font-size: 12.5px;
288
+ color: var(--ink-soft);
289
+ min-height: 18px;
290
+ }
291
+
292
+ @media (max-width: 860px) {
293
+ .layout {
294
+ flex-direction: column;
295
+ overflow-y: auto;
296
+ padding: 20px;
297
+ }
298
+
299
+ .controls {
300
+ flex: none;
301
+ width: 100%;
302
+ border-right: none;
303
+ border-bottom: 1px solid var(--rule);
304
+ padding-right: 0;
305
+ padding-bottom: 24px;
306
+ overflow-y: visible;
307
+ }
308
+
309
+ .preview {
310
+ padding: 24px 12px;
311
+ }
312
+ }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <div class="layout">
317
+ <div class="panel controls">
318
+ <div class="section">
319
+ <label for="book">选择书籍</label>
320
+ <select id="book">
321
+ <option value="">(无书籍)</option>
322
+ </select>
323
+ </div>
324
+
325
+ <div class="section">
326
+ <div class="row">
327
+ <div class="field">
328
+ <label for="title">书名</label>
329
+ <input type="text" id="title" placeholder="书名" />
330
+ </div>
331
+ <div class="field-options">
332
+ <label for="titleAlign">书名对齐</label>
333
+ <select id="titleAlign">
334
+ <option value="center">居中</option>
335
+ <option value="left">左对齐</option>
336
+ <option value="right">右对齐</option>
337
+ </select>
338
+ </div>
339
+ </div>
340
+ <div class="row">
341
+ <div class="field">
342
+ <label for="subtitle">副标题</label>
343
+ <input type="text" id="subtitle" placeholder="副标题" />
344
+ </div>
345
+ <div class="field-options">
346
+ <label for="placement">副标题位置</label>
347
+ <select id="placement">
348
+ <option value="bottom_right">下方 · 右</option>
349
+ <option value="bottom_left">下方 · 左</option>
350
+ <option value="top_right">上方 · 右</option>
351
+ <option value="top_left">上方 · 左</option>
352
+ </select>
353
+ </div>
354
+ </div>
355
+ <label for="topText">顶部文字</label>
356
+ <input type="text" id="topText" placeholder="一句话介绍这本书" />
357
+ <label for="author">作者</label>
358
+ <input type="text" id="author" placeholder="作者" />
359
+ </div>
360
+
361
+ <div class="section">
362
+ <label for="font">字体</label>
363
+ <select id="font"></select>
364
+ <label for="logo">LOGO</label>
365
+ <input type="text" id="logo" value="再读经典" />
366
+ <label>配色</label>
367
+ <div class="swatches" id="colors"></div>
368
+ </div>
369
+ </div>
370
+
371
+ <div class="panel preview">
372
+ <canvas id="cover" width="1000" height="1600"></canvas>
373
+ <div class="actions">
374
+ <button class="primary" id="saveBtn" onclick="saveToBook()">
375
+ 保存到书籍
376
+ </button>
377
+ <button class="secondary" onclick="downloadPng()">下载 PNG</button>
378
+ </div>
379
+ <div id="status"></div>
380
+ </div>
381
+ </div>
382
+
383
+ <script>
384
+ const COLORS = [
385
+ ["Gray", "#6b7280"],
386
+ ["Red", "#ff0000"],
387
+ ["Orange", "#e66a21"],
388
+ ["Yellow", "#f8b824"],
389
+ ["Lime", "#8c9d1a"],
390
+ ["Green", "#449429"],
391
+ ["Teal", "#3a9e9f"],
392
+ ["Cyan", "#06b6d4"],
393
+ ["Sky", "#0ea5e9"],
394
+ ["Blue", "#103fa4"],
395
+ ["Indigo", "#6167b3"],
396
+ ["Violet", "#480d71"],
397
+ ["Fuchsia", "#b8459c"],
398
+ ];
399
+ const FONTS = [
400
+ ["宋体(衬线)", '"Songti SC", "STSong", "SimSun", serif'],
401
+ [
402
+ "黑体(无衬线)",
403
+ '"PingFang SC", "Heiti SC", "Microsoft YaHei", sans-serif',
404
+ ],
405
+ ["楷体", '"Kaiti SC", "KaiTi", "STKaiti", serif'],
406
+ ["仿宋", '"FangSong", "STFangsong", serif'],
407
+ ["Georgia(西文)", "Georgia, serif"],
408
+ ["Helvetica(西文)", "Helvetica, Arial, sans-serif"],
409
+ ["系统默认", "system-ui, -apple-system, sans-serif"],
410
+ ];
411
+ const canvas = document.getElementById("cover");
412
+ const ctx = canvas.getContext("2d");
413
+ const S = 2; // 500x800 逻辑尺寸,2 倍缩放输出
414
+
415
+ let color = COLORS[5][1]; // 默认 Green
416
+ let font = FONTS[0][1]; // 默认宋体
417
+
418
+ function initSwatches() {
419
+ const box = document.getElementById("colors");
420
+ COLORS.forEach(([name, hex], i) => {
421
+ const s = document.createElement("div");
422
+ s.className = "swatch" + (hex === color ? " active" : "");
423
+ s.title = name;
424
+ s.style.background = hex;
425
+ s.onclick = () => {
426
+ color = hex;
427
+ box
428
+ .querySelectorAll(".swatch")
429
+ .forEach((el) => el.classList.remove("active"));
430
+ s.classList.add("active");
431
+ render();
432
+ };
433
+ box.appendChild(s);
434
+ });
435
+
436
+ // 自定义颜色入口(放最后)
437
+ const custom = document.createElement("label");
438
+ custom.className = "swatch custom";
439
+ custom.title = "自定义颜色";
440
+ const input = document.createElement("input");
441
+ input.type = "color";
442
+ input.value = color;
443
+ custom.appendChild(input);
444
+ custom.addEventListener("click", (e) => {
445
+ if (e.target === custom) input.click();
446
+ });
447
+ input.addEventListener("input", () => {
448
+ color = input.value;
449
+ box
450
+ .querySelectorAll(".swatch")
451
+ .forEach((el) => el.classList.remove("active"));
452
+ custom.classList.add("active");
453
+ render();
454
+ });
455
+ box.appendChild(custom);
456
+ }
457
+
458
+ function initFonts() {
459
+ const sel = document.getElementById("font");
460
+ FONTS.forEach(([name, family]) => {
461
+ const opt = document.createElement("option");
462
+ opt.value = family;
463
+ opt.textContent = name;
464
+ sel.appendChild(opt);
465
+ });
466
+ sel.value = font;
467
+ }
468
+
469
+ function wrapText(text, maxWidth, font) {
470
+ ctx.font = font;
471
+ if (!text) return [""];
472
+ const words = text.split(/\s+/).filter(Boolean);
473
+ const lines = [];
474
+ if (words.length <= 1) {
475
+ let line = "";
476
+ for (const ch of text) {
477
+ const test = line + ch;
478
+ if (line && ctx.measureText(test).width > maxWidth) {
479
+ lines.push(line);
480
+ line = ch;
481
+ } else line = test;
482
+ }
483
+ if (line) lines.push(line);
484
+ } else {
485
+ let line = "";
486
+ for (const w of words) {
487
+ const test = line ? line + " " + w : w;
488
+ if (line && ctx.measureText(test).width > maxWidth) {
489
+ lines.push(line);
490
+ line = w;
491
+ } else line = test;
492
+ }
493
+ if (line) lines.push(line);
494
+ }
495
+ return lines;
496
+ }
497
+
498
+ // 书名自动适应字号
499
+ function fitTitle(text, maxWidth) {
500
+ let size = 76;
501
+ while (size > 30) {
502
+ const lines = wrapText(text, maxWidth, `400 ${size}px ${font}`);
503
+ if (lines.length <= 4) return { lines, size };
504
+ size -= 4;
505
+ }
506
+ const lines = wrapText(text, maxWidth, `400 30px ${font}`);
507
+ return { lines, size: 30 };
508
+ }
509
+
510
+ function render() {
511
+ const topText = document.getElementById("topText").value;
512
+ const title = document.getElementById("title").value;
513
+ const subtitle = document.getElementById("subtitle").value;
514
+ const author = document.getElementById("author").value;
515
+ const placement = document.getElementById("placement").value;
516
+ font = document.getElementById("font").value;
517
+
518
+ // 保存按钮颜色随选中配色同步
519
+ document.documentElement.style.setProperty("--accent", color);
520
+
521
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
522
+ ctx.fillStyle = "#fff";
523
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
524
+
525
+ // 顶部色条
526
+ ctx.fillStyle = color;
527
+ ctx.fillRect(20 * S, 0, 460 * S, 10 * S);
528
+
529
+ // 顶部文字(一句话介绍)
530
+ ctx.fillStyle = "#000";
531
+ ctx.textAlign = "center";
532
+ ctx.textBaseline = "top";
533
+ ctx.font = `400 ${20 * S}px ${font}`;
534
+ ctx.fillText(topText, 250 * S, 14 * S, 460 * S);
535
+
536
+ drawTextLayer();
537
+ }
538
+
539
+ function drawTextLayer() {
540
+ const logo = document.getElementById("logo").value;
541
+ const title = document.getElementById("title").value;
542
+ const subtitle = document.getElementById("subtitle").value;
543
+ const author = document.getElementById("author").value;
544
+ const placement = document.getElementById("placement").value;
545
+
546
+ // 书名块
547
+ const maxW = 420; // 460 - 2*20 边距
548
+ const { lines, size } = fitTitle(title, maxW);
549
+ const lineHeight = size * 1.1;
550
+ const pad = 25;
551
+ const blockH = lines.length * lineHeight + pad * 2;
552
+ const titleTop = Math.round((800 - blockH) / 2);
553
+
554
+ ctx.fillStyle = color;
555
+ ctx.fillRect(20 * S, titleTop * S, 460 * S, blockH * S);
556
+ ctx.fillStyle = "#fff";
557
+ ctx.textBaseline = "top";
558
+ ctx.font = `400 ${size * S}px ${font}`;
559
+
560
+ const titleAlign = document.getElementById("titleAlign").value;
561
+ let titleX;
562
+ if (titleAlign === "left") {
563
+ ctx.textAlign = "left";
564
+ titleX = 40 * S;
565
+ } else if (titleAlign === "right") {
566
+ ctx.textAlign = "right";
567
+ titleX = 460 * S;
568
+ } else {
569
+ ctx.textAlign = "center";
570
+ titleX = 250 * S;
571
+ }
572
+ lines.forEach((ln, i) => {
573
+ ctx.fillText(ln, titleX, (titleTop + pad + i * lineHeight) * S);
574
+ });
575
+
576
+ // 副标题
577
+ const subBelowY = titleTop + blockH + 8;
578
+ const subAboveY = titleTop - 34 - 8;
579
+ ctx.fillStyle = "#000";
580
+ ctx.font = `400 ${34 * S}px ${font}`;
581
+ if (placement === "top_left") {
582
+ ctx.textAlign = "left";
583
+ ctx.fillText(subtitle, 20 * S, subAboveY * S);
584
+ } else if (placement === "top_right") {
585
+ ctx.textAlign = "right";
586
+ ctx.fillText(subtitle, 480 * S, subAboveY * S);
587
+ } else if (placement === "bottom_left") {
588
+ ctx.textAlign = "left";
589
+ ctx.fillText(subtitle, 20 * S, subBelowY * S);
590
+ } else {
591
+ ctx.textAlign = "right";
592
+ ctx.fillText(subtitle, 480 * S, subBelowY * S);
593
+ }
594
+
595
+ // LOGO(左下角)
596
+ ctx.fillStyle = "#000";
597
+ ctx.textAlign = "left";
598
+ ctx.textBaseline = "top";
599
+ ctx.font = `400 ${28 * S}px ${font}`;
600
+ ctx.fillText(logo, 20 * S, (800 - 20 - 28) * S);
601
+
602
+ // 作者(右下)
603
+ ctx.fillStyle = "#000";
604
+ ctx.textAlign = "right";
605
+ ctx.font = `400 ${24 * S}px ${font}`;
606
+ ctx.fillText(author, 480 * S, (800 - 20 - 24) * S);
607
+ }
608
+
609
+ // 加载书籍列表并自动填充
610
+ async function loadBooks() {
611
+ try {
612
+ const res = await fetch("/books.json");
613
+ const data = await res.json();
614
+ const sel = document.getElementById("book");
615
+ (data.books || []).forEach((b) => {
616
+ const opt = document.createElement("option");
617
+ opt.value = b.id;
618
+ opt.textContent = b.title + "(" + b.author + ")";
619
+ sel.appendChild(opt);
620
+ });
621
+ sel.onchange = () => {
622
+ const b = (data.books || []).find((x) => x.id === sel.value);
623
+ if (b) {
624
+ document.getElementById("title").value = b.title || "";
625
+ document.getElementById("author").value = b.author || "";
626
+ document.getElementById("subtitle").value =
627
+ b.category || b.dynasty || "";
628
+ render();
629
+ }
630
+ };
631
+ } catch (e) {
632
+ console.error("加载书籍失败", e);
633
+ }
634
+ }
635
+
636
+ function status(msg) {
637
+ document.getElementById("status").textContent = msg;
638
+ }
639
+
640
+ async function saveToBook() {
641
+ const bookId = document.getElementById("book").value;
642
+ if (!bookId) {
643
+ status("请先选择要保存到的书籍");
644
+ return;
645
+ }
646
+ const btn = document.getElementById("saveBtn");
647
+ btn.disabled = true;
648
+ try {
649
+ const dataUrl = canvas.toDataURL("image/png");
650
+ const res = await fetch("/cover/save", {
651
+ method: "POST",
652
+ headers: { "Content-Type": "application/json" },
653
+ body: JSON.stringify({ book: bookId, data: dataUrl }),
654
+ });
655
+ const result = await res.json();
656
+ status(
657
+ result.ok
658
+ ? "✅ 封面已保存到书籍 " + bookId
659
+ : "❌ " + (result.error || "保存失败"),
660
+ );
661
+ } catch (e) {
662
+ status("❌ 保存失败:" + e.message);
663
+ } finally {
664
+ btn.disabled = false;
665
+ }
666
+ }
667
+
668
+ function downloadPng() {
669
+ const a = document.createElement("a");
670
+ a.download = "cover.png";
671
+ a.href = canvas.toDataURL("image/png");
672
+ a.click();
673
+ }
674
+
675
+ document.querySelectorAll("input[type=text], select").forEach((el) => {
676
+ if (el.id !== "book") {
677
+ el.addEventListener("input", render);
678
+ }
679
+ });
680
+
681
+ initSwatches();
682
+ initFonts();
683
+ loadBooks();
684
+ render();
685
+ </script>
686
+ </body>
687
+ </html>