bindery-cli 0.2.1 → 0.3.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.
@@ -0,0 +1,892 @@
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
+ header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ padding: 16px 28px;
52
+ border-bottom: 1px solid var(--rule);
53
+ flex: none;
54
+ }
55
+
56
+ header h1 {
57
+ margin: 0;
58
+ font-size: 18px;
59
+ font-weight: 600;
60
+ font-family: "Songti SC", "STSong", serif;
61
+ letter-spacing: 0.02em;
62
+ }
63
+
64
+ .nav a {
65
+ display: inline-block;
66
+ padding: 8px 16px;
67
+ border-radius: 3px;
68
+ background: var(--seal);
69
+ color: #fff;
70
+ text-decoration: none;
71
+ font-size: 13.5px;
72
+ font-weight: 600;
73
+ }
74
+
75
+ .nav a:hover {
76
+ background: var(--seal-dark);
77
+ }
78
+
79
+ .layout {
80
+ flex: 1;
81
+ min-height: 0;
82
+ display: flex;
83
+ gap: 32px;
84
+ padding: 24px 28px;
85
+ max-width: 1100px;
86
+ width: 100%;
87
+ margin: 0 auto;
88
+ align-items: stretch;
89
+ }
90
+
91
+ .panel {
92
+ background: transparent;
93
+ padding: 0;
94
+ }
95
+
96
+ .controls {
97
+ flex: 1 1 0;
98
+ min-width: 0;
99
+ overflow-y: auto;
100
+ padding-right: 28px;
101
+ }
102
+
103
+ .preview {
104
+ flex: 1 1 0;
105
+ min-width: 0;
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: center;
109
+ justify-content: center;
110
+ gap: 16px;
111
+ }
112
+
113
+ .section {
114
+ margin-bottom: 16px;
115
+ }
116
+
117
+ .section:last-of-type {
118
+ margin-bottom: 0;
119
+ }
120
+
121
+ .row {
122
+ display: flex;
123
+ gap: 16px;
124
+ }
125
+
126
+ .row + .row {
127
+ margin-top: 16px;
128
+ }
129
+
130
+ .row > .field {
131
+ flex: 1;
132
+ min-width: 0;
133
+ }
134
+
135
+ .row > .field-options {
136
+ flex: 0 0 110px;
137
+ min-width: 0;
138
+ }
139
+
140
+ label {
141
+ display: block;
142
+ font-size: 12.5px;
143
+ color: var(--ink-soft);
144
+ margin: 16px 0 6px;
145
+ }
146
+
147
+ label:first-child {
148
+ margin-top: 0;
149
+ }
150
+
151
+ input[type="text"],
152
+ select {
153
+ width: 100%;
154
+ padding: 7px 2px 8px;
155
+ border: none;
156
+ border-bottom: 1px solid var(--rule);
157
+ border-radius: 0;
158
+ font-size: 14px;
159
+ background: transparent;
160
+ color: var(--ink);
161
+ font-family: inherit;
162
+ transition:
163
+ border-color 0.15s,
164
+ box-shadow 0.15s;
165
+ }
166
+
167
+ input[type="text"]::placeholder {
168
+ color: #b7ae9c;
169
+ }
170
+
171
+ select {
172
+ appearance: none;
173
+ -webkit-appearance: none;
174
+ background-image:
175
+ linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%),
176
+ linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%);
177
+ background-position:
178
+ calc(100% - 6px) center,
179
+ calc(100% - 1px) center;
180
+ background-size:
181
+ 6px 6px,
182
+ 6px 6px;
183
+ background-repeat: no-repeat;
184
+ padding-right: 18px;
185
+ cursor: pointer;
186
+ }
187
+
188
+ input[type="text"]:focus,
189
+ select:focus {
190
+ outline: none;
191
+ border-bottom: 1px solid var(--accent, var(--seal));
192
+ box-shadow: 0 1px 0 0 var(--accent, var(--seal));
193
+ }
194
+
195
+ .swatches {
196
+ display: flex;
197
+ flex-wrap: wrap;
198
+ gap: 14px;
199
+ padding: 6px;
200
+ }
201
+
202
+ .swatch {
203
+ width: 30px;
204
+ height: 30px;
205
+ border-radius: 50%;
206
+ cursor: pointer;
207
+ border: 2px solid transparent;
208
+ position: relative;
209
+ transition:
210
+ box-shadow 0.15s,
211
+ transform 0.1s;
212
+ }
213
+
214
+ .swatch:hover {
215
+ transform: translateY(-1px);
216
+ }
217
+
218
+ .swatch.active {
219
+ box-shadow:
220
+ 0 0 0 2px var(--panel),
221
+ 0 0 0 4px var(--ink);
222
+ }
223
+
224
+ .swatch.active::after {
225
+ content: none;
226
+ }
227
+
228
+ .swatch.custom {
229
+ border: none;
230
+ margin: 0;
231
+ }
232
+
233
+ .swatch.custom::before {
234
+ content: "";
235
+ position: absolute;
236
+ inset: 0;
237
+ border-radius: 50%;
238
+ background: conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
239
+ }
240
+
241
+ .swatch.custom.active {
242
+ box-shadow:
243
+ 0 0 0 2px var(--panel),
244
+ 0 0 0 4px var(--ink);
245
+ }
246
+
247
+ .swatch.custom input[type="color"] {
248
+ position: absolute;
249
+ inset: 0;
250
+ width: 100%;
251
+ height: 100%;
252
+ opacity: 0;
253
+ cursor: pointer;
254
+ border: none;
255
+ padding: 0;
256
+ margin: 0;
257
+ }
258
+
259
+ canvas {
260
+ max-width: 100%;
261
+ max-height: calc(100% - 90px);
262
+ width: auto;
263
+ height: auto;
264
+ display: block;
265
+ border-radius: 3px;
266
+ border: 1px solid var(--rule);
267
+ }
268
+
269
+ .actions {
270
+ display: flex;
271
+ gap: 10px;
272
+ }
273
+
274
+ .actions button {
275
+ flex: 1;
276
+ }
277
+
278
+ button {
279
+ padding: 10px 18px;
280
+ border: none;
281
+ border-radius: 3px;
282
+ font-size: 13.5px;
283
+ cursor: pointer;
284
+ font-weight: 600;
285
+ font-family: inherit;
286
+ transition:
287
+ transform 0.08s,
288
+ background 0.15s,
289
+ opacity 0.15s;
290
+ }
291
+
292
+ .primary {
293
+ background: var(--accent, var(--seal));
294
+ color: #fff;
295
+ }
296
+
297
+ .primary:hover {
298
+ filter: brightness(0.85);
299
+ }
300
+
301
+ .primary:active {
302
+ transform: translateY(1px);
303
+ }
304
+
305
+ .secondary {
306
+ background: transparent;
307
+ color: var(--ink);
308
+ border: 1px solid var(--rule);
309
+ }
310
+
311
+ .secondary:hover {
312
+ border-color: var(--ink-soft);
313
+ }
314
+
315
+ button:disabled {
316
+ opacity: 0.45;
317
+ cursor: not-allowed;
318
+ transform: none;
319
+ }
320
+
321
+ #status {
322
+ font-size: 12.5px;
323
+ color: var(--ink-soft);
324
+ min-height: 18px;
325
+ }
326
+
327
+ @media (max-width: 860px) {
328
+ .layout {
329
+ flex-direction: column;
330
+ overflow-y: auto;
331
+ padding: 20px;
332
+ }
333
+
334
+ .controls {
335
+ flex: none;
336
+ width: 100%;
337
+ border-right: none;
338
+ border-bottom: 1px solid var(--rule);
339
+ padding-right: 0;
340
+ padding-bottom: 24px;
341
+ overflow-y: visible;
342
+ }
343
+
344
+ .preview {
345
+ padding: 24px 12px;
346
+ }
347
+ }
348
+ </style>
349
+ </head>
350
+ <body>
351
+ <header>
352
+ <h1 data-i18n="pageTitle">封面生成器</h1>
353
+ <div class="nav">
354
+ <a href="/" data-i18n="library">书库</a>
355
+ </div>
356
+ </header>
357
+ <div class="layout">
358
+ <div class="panel controls">
359
+ <div class="section">
360
+ <label for="book" data-i18n="selectBook">选择书籍</label>
361
+ <select id="book">
362
+ <option value="" data-i18n="noBook">(无书籍)</option>
363
+ </select>
364
+ </div>
365
+
366
+ <div class="section">
367
+ <div class="row">
368
+ <div class="field">
369
+ <label for="title" data-i18n="title">书名</label>
370
+ <input
371
+ type="text"
372
+ id="title"
373
+ placeholder="书名"
374
+ data-i18n-placeholder="title"
375
+ />
376
+ </div>
377
+ <div class="field-options">
378
+ <label for="titleAlign" data-i18n="titleAlign">书名对齐</label>
379
+ <select id="titleAlign">
380
+ <option value="center" data-i18n="alignCenter">居中</option>
381
+ <option value="left" data-i18n="alignLeft">左对齐</option>
382
+ <option value="right" data-i18n="alignRight">右对齐</option>
383
+ </select>
384
+ </div>
385
+ </div>
386
+ <div class="row">
387
+ <div class="field">
388
+ <label for="subtitle" data-i18n="subtitle">副标题</label>
389
+ <input
390
+ type="text"
391
+ id="subtitle"
392
+ placeholder="副标题"
393
+ data-i18n-placeholder="subtitle"
394
+ />
395
+ </div>
396
+ <div class="field-options">
397
+ <label for="placement" data-i18n="subtitlePlacement"
398
+ >副标题位置</label
399
+ >
400
+ <select id="placement">
401
+ <option value="bottom_right" data-i18n="bottomRight">
402
+ 下方 · 右
403
+ </option>
404
+ <option value="bottom_left" data-i18n="bottomLeft">
405
+ 下方 · 左
406
+ </option>
407
+ <option value="top_right" data-i18n="topRight">
408
+ 上方 · 右
409
+ </option>
410
+ <option value="top_left" data-i18n="topLeft">上方 · 左</option>
411
+ </select>
412
+ </div>
413
+ </div>
414
+ <label for="topText" data-i18n="topText">顶部文字</label>
415
+ <input
416
+ type="text"
417
+ id="topText"
418
+ placeholder="一句话介绍这本书"
419
+ data-i18n-placeholder="topTextPlaceholder"
420
+ />
421
+ <label for="author" data-i18n="author">作者</label>
422
+ <input
423
+ type="text"
424
+ id="author"
425
+ placeholder="作者"
426
+ data-i18n-placeholder="author"
427
+ />
428
+ </div>
429
+
430
+ <div class="section">
431
+ <label for="font" data-i18n="font">字体</label>
432
+ <select id="font"></select>
433
+ <label for="logo" data-i18n="logo">LOGO</label>
434
+ <input type="text" id="logo" value="再读经典" />
435
+ <label data-i18n="colors">配色</label>
436
+ <div class="swatches" id="colors"></div>
437
+ </div>
438
+ </div>
439
+
440
+ <div class="panel preview">
441
+ <canvas id="cover" width="1000" height="1600"></canvas>
442
+ <div class="actions">
443
+ <button
444
+ class="primary"
445
+ id="saveBtn"
446
+ onclick="saveToBook()"
447
+ data-i18n="save"
448
+ >
449
+ 保存到书籍
450
+ </button>
451
+ <button
452
+ class="secondary"
453
+ onclick="downloadPng()"
454
+ data-i18n="download"
455
+ >
456
+ 下载 PNG
457
+ </button>
458
+ </div>
459
+ <div id="status"></div>
460
+ </div>
461
+ </div>
462
+
463
+ <script>
464
+ // ---------- 多语言 ----------
465
+ const LANG = (navigator.language || "en").toLowerCase().startsWith("zh")
466
+ ? "zh"
467
+ : "en";
468
+ const I18N = {
469
+ zh: {
470
+ pageTitle: "封面生成器",
471
+ library: "书库",
472
+ selectBook: "选择书籍",
473
+ noBook: "(无书籍)",
474
+ title: "书名",
475
+ subtitle: "副标题",
476
+ topText: "顶部文字",
477
+ topTextPlaceholder: "一句话介绍这本书",
478
+ author: "作者",
479
+ titleAlign: "书名对齐",
480
+ alignCenter: "居中",
481
+ alignLeft: "左对齐",
482
+ alignRight: "右对齐",
483
+ subtitlePlacement: "副标题位置",
484
+ bottomRight: "下方 · 右",
485
+ bottomLeft: "下方 · 左",
486
+ topRight: "上方 · 右",
487
+ topLeft: "上方 · 左",
488
+ font: "字体",
489
+ logo: "LOGO",
490
+ colors: "配色",
491
+ customColor: "自定义颜色",
492
+ save: "保存到书籍",
493
+ download: "下载 PNG",
494
+ selectBookFirst: "请先选择要保存到的书籍",
495
+ saveOk: "✅ 封面已保存到书籍 ",
496
+ saveFail: "保存失败",
497
+ bookOption: "{title}({author})",
498
+ color_gray: "灰色",
499
+ color_red: "红色",
500
+ color_orange: "橙色",
501
+ color_yellow: "黄色",
502
+ color_lime: "黄绿",
503
+ color_green: "绿色",
504
+ color_teal: "青蓝",
505
+ color_cyan: "青色",
506
+ color_sky: "天蓝",
507
+ color_blue: "蓝色",
508
+ color_indigo: "靛蓝",
509
+ color_violet: "紫罗兰",
510
+ color_fuchsia: "紫红",
511
+ font_songti: "宋体(衬线)",
512
+ font_heiti: "黑体(无衬线)",
513
+ font_kaiti: "楷体",
514
+ font_fangsong: "仿宋",
515
+ font_georgia: "Georgia(西文)",
516
+ font_helvetica: "Helvetica(西文)",
517
+ font_system: "系统默认",
518
+ },
519
+ en: {
520
+ pageTitle: "Cover Generator",
521
+ library: "Library",
522
+ selectBook: "Select book",
523
+ noBook: "(No book)",
524
+ title: "Title",
525
+ subtitle: "Subtitle",
526
+ topText: "Top text",
527
+ topTextPlaceholder: "One-sentence introduction",
528
+ author: "Author",
529
+ titleAlign: "Title align",
530
+ alignCenter: "Center",
531
+ alignLeft: "Left",
532
+ alignRight: "Right",
533
+ subtitlePlacement: "Subtitle position",
534
+ bottomRight: "Bottom · Right",
535
+ bottomLeft: "Bottom · Left",
536
+ topRight: "Top · Right",
537
+ topLeft: "Top · Left",
538
+ font: "Font",
539
+ logo: "LOGO",
540
+ colors: "Colors",
541
+ customColor: "Custom color",
542
+ save: "Save to book",
543
+ download: "Download PNG",
544
+ selectBookFirst: "Please select a book first",
545
+ saveOk: "✅ Cover saved to book ",
546
+ saveFail: "Save failed",
547
+ bookOption: "{title} ({author})",
548
+ color_gray: "Gray",
549
+ color_red: "Red",
550
+ color_orange: "Orange",
551
+ color_yellow: "Yellow",
552
+ color_lime: "Lime",
553
+ color_green: "Green",
554
+ color_teal: "Teal",
555
+ color_cyan: "Cyan",
556
+ color_sky: "Sky",
557
+ color_blue: "Blue",
558
+ color_indigo: "Indigo",
559
+ color_violet: "Violet",
560
+ color_fuchsia: "Fuchsia",
561
+ font_songti: "Songti (serif)",
562
+ font_heiti: "Heiti (sans)",
563
+ font_kaiti: "Kaiti",
564
+ font_fangsong: "Fangsong",
565
+ font_georgia: "Georgia (Latin)",
566
+ font_helvetica: "Helvetica (Latin)",
567
+ font_system: "System default",
568
+ },
569
+ };
570
+ const t = (key) => I18N[LANG][key] || I18N.en[key] || key;
571
+ function applyI18n() {
572
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
573
+ el.textContent = t(el.dataset.i18n);
574
+ });
575
+ document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
576
+ el.placeholder = t(el.dataset.i18nPlaceholder);
577
+ });
578
+ }
579
+
580
+ const COLORS = [
581
+ ["gray", "#6b7280"],
582
+ ["red", "#ff0000"],
583
+ ["orange", "#e66a21"],
584
+ ["yellow", "#f8b824"],
585
+ ["lime", "#8c9d1a"],
586
+ ["green", "#449429"],
587
+ ["teal", "#3a9e9f"],
588
+ ["cyan", "#06b6d4"],
589
+ ["sky", "#0ea5e9"],
590
+ ["blue", "#103fa4"],
591
+ ["indigo", "#6167b3"],
592
+ ["violet", "#480d71"],
593
+ ["fuchsia", "#b8459c"],
594
+ ];
595
+ const FONTS = [
596
+ ["songti", '"Songti SC", "STSong", "SimSun", serif'],
597
+ ["heiti", '"PingFang SC", "Heiti SC", "Microsoft YaHei", sans-serif'],
598
+ ["kaiti", '"Kaiti SC", "KaiTi", "STKaiti", serif'],
599
+ ["fangsong", '"FangSong", "STFangsong", serif'],
600
+ ["georgia", "Georgia, serif"],
601
+ ["helvetica", "Helvetica, Arial, sans-serif"],
602
+ ["system", "system-ui, -apple-system, sans-serif"],
603
+ ];
604
+ const canvas = document.getElementById("cover");
605
+ const ctx = canvas.getContext("2d");
606
+ const S = 2; // 500x800 逻辑尺寸,2 倍缩放输出
607
+
608
+ let color = COLORS[5][1]; // 默认 Green
609
+ let font = FONTS[0][1]; // 默认宋体
610
+
611
+ function initSwatches() {
612
+ const box = document.getElementById("colors");
613
+ COLORS.forEach(([key, hex], i) => {
614
+ const s = document.createElement("div");
615
+ s.className = "swatch" + (hex === color ? " active" : "");
616
+ s.title = t("color_" + key);
617
+ s.style.background = hex;
618
+ s.onclick = () => {
619
+ color = hex;
620
+ box
621
+ .querySelectorAll(".swatch")
622
+ .forEach((el) => el.classList.remove("active"));
623
+ s.classList.add("active");
624
+ render();
625
+ };
626
+ box.appendChild(s);
627
+ });
628
+
629
+ // 自定义颜色入口(放最后)
630
+ const custom = document.createElement("label");
631
+ custom.className = "swatch custom";
632
+ custom.title = t("customColor");
633
+ const input = document.createElement("input");
634
+ input.type = "color";
635
+ input.value = color;
636
+ custom.appendChild(input);
637
+ custom.addEventListener("click", (e) => {
638
+ if (e.target === custom) input.click();
639
+ });
640
+ input.addEventListener("input", () => {
641
+ color = input.value;
642
+ box
643
+ .querySelectorAll(".swatch")
644
+ .forEach((el) => el.classList.remove("active"));
645
+ custom.classList.add("active");
646
+ render();
647
+ });
648
+ box.appendChild(custom);
649
+ }
650
+
651
+ function initFonts() {
652
+ const sel = document.getElementById("font");
653
+ FONTS.forEach(([key, family]) => {
654
+ const opt = document.createElement("option");
655
+ opt.value = family;
656
+ opt.textContent = t("font_" + key);
657
+ sel.appendChild(opt);
658
+ });
659
+ sel.value = font;
660
+ }
661
+
662
+ function wrapText(text, maxWidth, font) {
663
+ ctx.font = font;
664
+ if (!text) return [""];
665
+ const words = text.split(/\s+/).filter(Boolean);
666
+ const lines = [];
667
+ if (words.length <= 1) {
668
+ let line = "";
669
+ for (const ch of text) {
670
+ const test = line + ch;
671
+ if (line && ctx.measureText(test).width > maxWidth) {
672
+ lines.push(line);
673
+ line = ch;
674
+ } else line = test;
675
+ }
676
+ if (line) lines.push(line);
677
+ } else {
678
+ let line = "";
679
+ for (const w of words) {
680
+ const test = line ? line + " " + w : w;
681
+ if (line && ctx.measureText(test).width > maxWidth) {
682
+ lines.push(line);
683
+ line = w;
684
+ } else line = test;
685
+ }
686
+ if (line) lines.push(line);
687
+ }
688
+ return lines;
689
+ }
690
+
691
+ // 书名自动适应字号
692
+ function fitTitle(text, maxWidth) {
693
+ let size = 76;
694
+ while (size > 30) {
695
+ const lines = wrapText(text, maxWidth, `400 ${size}px ${font}`);
696
+ if (lines.length <= 4) return { lines, size };
697
+ size -= 4;
698
+ }
699
+ const lines = wrapText(text, maxWidth, `400 30px ${font}`);
700
+ return { lines, size: 30 };
701
+ }
702
+
703
+ function render() {
704
+ const topText = document.getElementById("topText").value;
705
+ const title = document.getElementById("title").value;
706
+ const subtitle = document.getElementById("subtitle").value;
707
+ const author = document.getElementById("author").value;
708
+ const placement = document.getElementById("placement").value;
709
+ font = document.getElementById("font").value;
710
+
711
+ // 保存按钮颜色随选中配色同步
712
+ document.documentElement.style.setProperty("--accent", color);
713
+
714
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
715
+ ctx.fillStyle = "#fff";
716
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
717
+
718
+ // 顶部色条
719
+ ctx.fillStyle = color;
720
+ ctx.fillRect(20 * S, 0, 460 * S, 10 * S);
721
+
722
+ // 顶部文字(一句话介绍)
723
+ ctx.fillStyle = "#000";
724
+ ctx.textAlign = "center";
725
+ ctx.textBaseline = "top";
726
+ ctx.font = `400 ${20 * S}px ${font}`;
727
+ ctx.fillText(topText, 250 * S, 14 * S, 460 * S);
728
+
729
+ drawTextLayer();
730
+ }
731
+
732
+ function drawTextLayer() {
733
+ const logo = document.getElementById("logo").value;
734
+ const title = document.getElementById("title").value;
735
+ const subtitle = document.getElementById("subtitle").value;
736
+ const author = document.getElementById("author").value;
737
+ const placement = document.getElementById("placement").value;
738
+
739
+ // 书名块
740
+ const maxW = 420; // 460 - 2*20 边距
741
+ const { lines, size } = fitTitle(title, maxW);
742
+ const lineHeight = size * 1.1;
743
+ const pad = 25;
744
+ const blockH = lines.length * lineHeight + pad * 2;
745
+ const titleTop = Math.round((800 - blockH) / 2);
746
+
747
+ ctx.fillStyle = color;
748
+ ctx.fillRect(20 * S, titleTop * S, 460 * S, blockH * S);
749
+ ctx.fillStyle = "#fff";
750
+ ctx.textBaseline = "top";
751
+ ctx.font = `400 ${size * S}px ${font}`;
752
+
753
+ const titleAlign = document.getElementById("titleAlign").value;
754
+ let titleX;
755
+ if (titleAlign === "left") {
756
+ ctx.textAlign = "left";
757
+ titleX = 40 * S;
758
+ } else if (titleAlign === "right") {
759
+ ctx.textAlign = "right";
760
+ titleX = 460 * S;
761
+ } else {
762
+ ctx.textAlign = "center";
763
+ titleX = 250 * S;
764
+ }
765
+ lines.forEach((ln, i) => {
766
+ ctx.fillText(ln, titleX, (titleTop + pad + i * lineHeight) * S);
767
+ });
768
+
769
+ // 副标题
770
+ const subBelowY = titleTop + blockH + 8;
771
+ const subAboveY = titleTop - 34 - 8;
772
+ ctx.fillStyle = "#000";
773
+ ctx.font = `400 ${34 * S}px ${font}`;
774
+ if (placement === "top_left") {
775
+ ctx.textAlign = "left";
776
+ ctx.fillText(subtitle, 20 * S, subAboveY * S);
777
+ } else if (placement === "top_right") {
778
+ ctx.textAlign = "right";
779
+ ctx.fillText(subtitle, 480 * S, subAboveY * S);
780
+ } else if (placement === "bottom_left") {
781
+ ctx.textAlign = "left";
782
+ ctx.fillText(subtitle, 20 * S, subBelowY * S);
783
+ } else {
784
+ ctx.textAlign = "right";
785
+ ctx.fillText(subtitle, 480 * S, subBelowY * S);
786
+ }
787
+
788
+ // LOGO(左下角)
789
+ ctx.fillStyle = "#000";
790
+ ctx.textAlign = "left";
791
+ ctx.textBaseline = "top";
792
+ ctx.font = `400 ${28 * S}px ${font}`;
793
+ ctx.fillText(logo, 20 * S, (800 - 20 - 28) * S);
794
+
795
+ // 作者(右下)
796
+ ctx.fillStyle = "#000";
797
+ ctx.textAlign = "right";
798
+ ctx.font = `400 ${24 * S}px ${font}`;
799
+ ctx.fillText(author, 480 * S, (800 - 20 - 24) * S);
800
+ }
801
+
802
+ // 加载书籍列表并自动填充
803
+ async function loadBooks() {
804
+ try {
805
+ const res = await fetch("/books.json");
806
+ const data = await res.json();
807
+ const sel = document.getElementById("book");
808
+ (data.books || []).forEach((b) => {
809
+ const opt = document.createElement("option");
810
+ opt.value = b.id;
811
+ opt.textContent = t("bookOption")
812
+ .replace("{title}", b.title)
813
+ .replace("{author}", b.author);
814
+ sel.appendChild(opt);
815
+ });
816
+ sel.onchange = () => {
817
+ const b = (data.books || []).find((x) => x.id === sel.value);
818
+ if (b) {
819
+ document.getElementById("title").value = b.title || "";
820
+ document.getElementById("author").value = b.author || "";
821
+ document.getElementById("subtitle").value =
822
+ b.category || b.dynasty || "";
823
+ render();
824
+ }
825
+ };
826
+ } catch (e) {
827
+ console.error("加载书籍失败", e);
828
+ }
829
+ }
830
+
831
+ function status(msg) {
832
+ document.getElementById("status").textContent = msg;
833
+ }
834
+
835
+ async function saveToBook() {
836
+ const bookId = document.getElementById("book").value;
837
+ if (!bookId) {
838
+ status(t("selectBookFirst"));
839
+ return;
840
+ }
841
+ const btn = document.getElementById("saveBtn");
842
+ btn.disabled = true;
843
+ try {
844
+ const dataUrl = canvas.toDataURL("image/png");
845
+ const res = await fetch("/cover/save", {
846
+ method: "POST",
847
+ headers: { "Content-Type": "application/json" },
848
+ body: JSON.stringify({ book: bookId, data: dataUrl }),
849
+ });
850
+ const result = await res.json();
851
+ status(
852
+ result.ok
853
+ ? t("saveOk") + bookId
854
+ : "❌ " + (result.error || t("saveFail")),
855
+ );
856
+ } catch (e) {
857
+ status("❌ " + t("saveFail") + ": " + e.message);
858
+ } finally {
859
+ btn.disabled = false;
860
+ }
861
+ }
862
+
863
+ function downloadPng() {
864
+ const a = document.createElement("a");
865
+ a.download = "cover.png";
866
+ a.href = canvas.toDataURL("image/png");
867
+ a.click();
868
+ }
869
+
870
+ document.querySelectorAll("input[type=text], select").forEach((el) => {
871
+ if (el.id !== "book") {
872
+ el.addEventListener("input", render);
873
+ }
874
+ });
875
+
876
+ applyI18n();
877
+ initSwatches();
878
+ initFonts();
879
+ loadBooks();
880
+ render();
881
+
882
+ // 让按钮组宽度跟随画布宽度
883
+ function fitActions() {
884
+ const actions = document.querySelector(".actions");
885
+ const w = canvas.getBoundingClientRect().width;
886
+ if (w > 0) actions.style.width = w + "px";
887
+ }
888
+ window.addEventListener("resize", fitActions);
889
+ fitActions();
890
+ </script>
891
+ </body>
892
+ </html>