bindery-cli 0.1.1 → 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.
@@ -9,9 +9,10 @@ module Bindery
9
9
  class Builder
10
10
  class BuildError < Bindery::Error; end
11
11
 
12
- def initialize(book, project_root: Project.root!)
12
+ def initialize(book, project_root: Project.root!, verbose: false)
13
13
  @book = book
14
14
  @project_root = project_root
15
+ @verbose = verbose
15
16
  end
16
17
 
17
18
  # 返回 true/false 表示是否成功;详细错误信息可通过 #last_error 获取
@@ -33,8 +34,10 @@ module Bindery
33
34
  FileUtils.mkdir_p(out_dir)
34
35
  out_path = out_dir + "#{@book.id}.epub"
35
36
 
37
+ # 按内容中最顶层的标题级别拆分:h1=书名 / h2=卷 / h3=章
38
+ split_level = top_heading_level(merged)
36
39
  cmd = ["pandoc", merged.to_s, "-o", out_path.to_s,
37
- "--metadata-file", meta_file.to_s, "--toc"]
40
+ "--metadata-file", meta_file.to_s, "--toc", "--split-level", split_level.to_s]
38
41
 
39
42
  css = Project.templates_dir(@project_root) + "style.css"
40
43
  cmd += ["--css", css.to_s] if css.file?
@@ -42,6 +45,7 @@ module Bindery
42
45
  cover = @book.cover_path
43
46
  cmd += ["--epub-cover-image", cover.to_s] if cover
44
47
 
48
+ puts " $ #{cmd.join(' ')}" if @verbose
45
49
  run(cmd)
46
50
  end
47
51
  rescue Bindery::Error => e
@@ -53,6 +57,16 @@ module Bindery
53
57
  @last_error
54
58
  end
55
59
 
60
+ # 运行 epubcheck 校验单个 epub,返回 [成功?, 输出]
61
+ def epubcheck(path)
62
+ unless system("epubcheck", "--version", out: File::NULL, err: File::NULL)
63
+ return [false, "未安装 epubcheck(参考 https://github.com/w3c/epubcheck/releases)"]
64
+ end
65
+
66
+ stdout, stderr, status = Open3.capture3("epubcheck", path.to_s)
67
+ [status.success?, stderr.empty? ? stdout : stderr]
68
+ end
69
+
56
70
  private
57
71
 
58
72
  def ensure_pandoc!
@@ -72,15 +86,32 @@ module Bindery
72
86
  path
73
87
  end
74
88
 
89
+ # 返回合并后内容中最顶层的标题级别(1..6),无标题时返回 1
90
+ def top_heading_level(merged)
91
+ level = nil
92
+ merged.each_line do |line|
93
+ next unless line =~ /^(\#{1,6})\s/
94
+
95
+ l = Regexp.last_match(1).length
96
+ level = l if level.nil? || l < level
97
+ end
98
+ level || 1
99
+ end
100
+
75
101
  def write_pandoc_metadata(tmp_dir, meta)
102
+ config = Project.config(@project_root)
76
103
  pandoc_meta = {
77
104
  "title" => meta["title"] || @book.id,
78
105
  "author" => meta["author"] || "佚名",
79
- "lang" => meta["lang"] || "zh-CN",
80
- "rights" => meta["rights"] || "公共领域",
81
- "publisher" => meta["publisher"] || "再读经典",
106
+ "lang" => meta["lang"] || config["lang"] || "zh-CN",
107
+ "rights" => meta["rights"] || config["rights"] || "公共领域",
108
+ "publisher" => meta["publisher"] || config["publisher"] || "再读经典",
82
109
  }
83
110
  pandoc_meta["date"] = meta["dynasty"] if meta["dynasty"] && !meta["dynasty"].to_s.empty?
111
+ # 可选元数据:ISBN / 译者 / 分类,映射到 Pandoc 识别的字段
112
+ pandoc_meta["identifier"] = meta["isbn"] if meta["isbn"] && !meta["isbn"].to_s.empty?
113
+ pandoc_meta["contributor"] = meta["translator"] if meta["translator"] && !meta["translator"].to_s.empty?
114
+ pandoc_meta["subject"] = meta["category"] if meta["category"] && !meta["category"].to_s.empty?
84
115
 
85
116
  path = Pathname.new(tmp_dir) + "metadata.yaml"
86
117
  path.write(pandoc_meta.to_yaml)
data/lib/bindery/cli.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  require "optparse"
2
+ require "fileutils"
3
+ require "rbconfig"
2
4
 
3
5
  module Bindery
4
6
  # 命令行入口:解析子命令并分发
5
- # 支持: new / generate(g) / build / status / version / help
7
+ # 支持: init / new book / build / clean / validate / watch / serve / status / version / help
6
8
  class CLI
7
9
  def self.start(argv)
8
10
  new.run(argv)
@@ -11,12 +13,26 @@ module Bindery
11
13
  def run(argv)
12
14
  command, *rest = argv
13
15
  case command
16
+ when "init"
17
+ cmd_init(rest)
14
18
  when "new"
15
19
  cmd_new(rest)
16
- when "generate", "g"
17
- cmd_generate(rest)
18
20
  when "build"
19
21
  cmd_build(rest)
22
+ when "clean"
23
+ cmd_clean(rest)
24
+ when "validate"
25
+ cmd_validate(rest)
26
+ when "cover"
27
+ cmd_cover(rest)
28
+ when "rename"
29
+ cmd_rename(rest)
30
+ when "watch"
31
+ cmd_watch(rest)
32
+ when "serve"
33
+ cmd_serve(rest)
34
+ when "web"
35
+ cmd_web(rest)
20
36
  when "status"
21
37
  cmd_status(rest)
22
38
  when "version", "-v", "--version"
@@ -38,74 +54,183 @@ module Bindery
38
54
 
39
55
  private
40
56
 
41
- # ---------- bindery new <项目名> ----------
42
- def cmd_new(args)
43
- name = args.first
44
- if name.nil? || name.empty?
45
- warn "用法: bindery new <项目名>"
57
+ # ---------- bindery init ----------
58
+ def cmd_init(args)
59
+ unless args.empty?
60
+ warn "用法: bindery init(不接受参数)"
46
61
  exit 1
47
62
  end
48
- Generators::ProjectGenerator.call(name)
63
+ Generators::ProjectGenerator.call
49
64
  end
50
65
 
51
- # ---------- bindery generate book <id> <title> [选项] ----------
52
- def cmd_generate(args)
66
+ # ---------- bindery new book|chapter|volume ... ----------
67
+ def cmd_new(args)
53
68
  type, *rest = args
54
69
  case type
55
70
  when "book"
56
- cmd_generate_book(rest)
71
+ cmd_new_book(rest)
72
+ when "chapter"
73
+ cmd_new_chapter(rest)
74
+ when "volume"
75
+ cmd_new_volume(rest)
57
76
  when nil
58
- warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
77
+ warn "用法: bindery new book <书名> [chapter <章数>] [选项]"
78
+ warn " bindery new chapter <book-id> <数量>"
79
+ warn " bindery new volume <book-id> <卷名> [--chapters N]"
59
80
  exit 1
60
81
  else
61
- warn "未知的生成器类型: #{type}(目前只支持 book)"
82
+ warn "未知的生成器类型: #{type}(支持 book / chapter / volume)"
62
83
  exit 1
63
84
  end
64
85
  end
65
86
 
66
- def cmd_generate_book(args)
67
- options = { author: "佚名", dynasty: "", category: "" }
87
+ # ---------- bindery new chapter <book-id> <数量> ----------
88
+ def cmd_new_chapter(args)
89
+ book_id, count = args
90
+ if book_id.nil? || count.nil?
91
+ warn "用法: bindery new chapter <book-id> <数量>"
92
+ exit 1
93
+ end
94
+ Generators::ChapterGenerator.call(book_id, count)
95
+ end
96
+
97
+ # ---------- bindery new volume <book-id> <卷名> [--chapters N] ----------
98
+ def cmd_new_volume(args)
99
+ options = { chapters: 0 }
100
+ parser = OptionParser.new do |o|
101
+ o.on("--chapters N", Integer) { |v| options[:chapters] = v }
102
+ end
103
+ positional = parser.parse(args)
104
+ book_id, volume_name = positional
105
+ if book_id.nil? || volume_name.nil?
106
+ warn "用法: bindery new volume <book-id> <卷名> [--chapters N]"
107
+ exit 1
108
+ end
109
+ Generators::VolumeGenerator.call(book_id, volume_name, chapters: options[:chapters])
110
+ end
111
+
112
+ # ---------- bindery cover <book-id> ----------
113
+ def cmd_cover(args)
114
+ id = args.first
115
+ if id.nil?
116
+ warn "用法: bindery cover <book-id>"
117
+ exit 1
118
+ end
119
+ project_root = Project.root!
120
+ book = Bindery::Book.find(id, project_root)
121
+ path = Bindery::Cover.generate(book)
122
+ puts "已生成封面: #{path}"
123
+ end
124
+
125
+ # ---------- bindery rename chapters <book-id> ----------
126
+ def cmd_rename(args)
127
+ type, book_id = args
128
+ if type != "chapters" || book_id.nil?
129
+ warn "用法: bindery rename chapters <book-id>"
130
+ exit 1
131
+ end
132
+ project_root = Project.root!
133
+ book = Bindery::Book.find(book_id, project_root)
134
+ count = Bindery::Renamer.rename_chapters(book)
135
+ puts "重命名完成:更新 #{count} 个文件"
136
+ end
137
+
138
+ def cmd_new_book(args)
139
+ options = { author: "佚名", dynasty: "", category: "", translator: "", isbn: "", id: nil, cover: false }
68
140
  parser = OptionParser.new do |o|
69
141
  o.on("--author NAME") { |v| options[:author] = v }
70
142
  o.on("--dynasty NAME") { |v| options[:dynasty] = v }
71
143
  o.on("--category NAME") { |v| options[:category] = v }
144
+ o.on("--translator NAME") { |v| options[:translator] = v }
145
+ o.on("--isbn ISBN") { |v| options[:isbn] = v }
146
+ o.on("--id ID") { |v| options[:id] = v }
147
+ o.on("--cover") { options[:cover] = true }
72
148
  end
73
149
  positional = parser.parse(args)
74
- id, title = positional
75
- if id.nil? || title.nil?
76
- warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
150
+ title = positional.first
151
+ if title.nil? || title.empty?
152
+ warn "用法: bindery new book <书名> [chapter <章数>] [--cover] [--author NAME] [--dynasty 朝代] [--category 分类] [--translator 译者] [--isbn ISBN] [--id ID]"
77
153
  exit 1
78
154
  end
79
155
 
156
+ # 可选组合参数:bindery new book <书名> chapter <章数>
157
+ # 语义:总共 N 章(默认已含第一章,所以再补 N-1 章)
158
+ chapters = nil
159
+ unless positional[1].nil?
160
+ if positional[1] == "chapter"
161
+ chapters = positional[2].to_i
162
+ unless chapters.positive?
163
+ warn "章节数必须是正整数"
164
+ exit 1
165
+ end
166
+ else
167
+ warn "未知参数: #{positional[1]}"
168
+ exit 1
169
+ end
170
+ end
171
+
172
+ id = options[:id] || Generators::BookGenerator.auto_id(title)
80
173
  Generators::BookGenerator.call(
81
174
  id, title,
82
- author: options[:author], dynasty: options[:dynasty], category: options[:category]
175
+ author: options[:author], dynasty: options[:dynasty], category: options[:category],
176
+ translator: options[:translator], isbn: options[:isbn]
83
177
  )
178
+ Generators::ChapterGenerator.call(id, chapters - 1) if chapters && chapters > 1
179
+ Bindery::Cover.generate(Bindery::Book.find(id)) if options[:cover]
84
180
  end
85
181
 
86
- # ---------- bindery build [<id>] [--all] ----------
182
+ # ---------- bindery build [<id>] [--all] [--check] [--dry-run] [--verbose] ----------
87
183
  def cmd_build(args)
88
- options = { all: false }
184
+ options = { all: false, check: false, dry_run: false, verbose: false, epubcheck: false }
89
185
  parser = OptionParser.new do |o|
90
186
  o.on("--all") { options[:all] = true }
187
+ o.on("--check") { options[:check] = true }
188
+ o.on("--dry-run") { options[:dry_run] = true }
189
+ o.on("--verbose") { options[:verbose] = true }
190
+ o.on("--epubcheck") { options[:epubcheck] = true }
91
191
  end
92
192
  positional = parser.parse(args)
93
193
 
94
194
  project_root = Project.root!
95
195
  books = options[:all] ? Bindery::Book.all(project_root) : [book_from_arg(positional.first, project_root)]
96
196
 
197
+ if options[:check]
198
+ # 仅做构建前校验,不实际调用 Pandoc
199
+ exit 1 unless check_books(books, project_root)
200
+ return
201
+ end
202
+
97
203
  if books.empty?
98
204
  warn "books/ 目录下没有任何书籍"
205
+ # 即使没有书也重建索引,保证 books.json 存在(供 CI / 静态站读取)
206
+ Index.rebuild(project_root)
207
+ puts "" if options[:all]
208
+ puts "完成:成功 0 本,失败 0 本" if options[:all]
209
+ return
210
+ end
211
+
212
+ if options[:dry_run]
213
+ books.each do |book|
214
+ puts "[#{book.id}] 将构建 → #{book.epub_output(project_root)}(#{book.chapters.size} 个章节)"
215
+ end
99
216
  return
100
217
  end
101
218
 
102
219
  success = 0
103
220
  failure = 0
104
221
  books.each do |book|
105
- builder = Builder.new(book, project_root: project_root)
222
+ builder = Builder.new(book, project_root: project_root, verbose: options[:verbose])
106
223
  if builder.build_epub
107
224
  puts "✅ [#{book.id}] epub 构建成功"
108
225
  success += 1
226
+ if options[:epubcheck]
227
+ ok, msg = builder.epubcheck(book.epub_output(project_root))
228
+ if ok
229
+ puts " ✅ epubcheck 通过"
230
+ else
231
+ puts " ⚠️ epubcheck 未通过:#{msg.lines.first&.strip}"
232
+ end
233
+ end
109
234
  else
110
235
  puts "❌ [#{book.id}] epub 构建失败"
111
236
  puts " #{builder.last_error}"
@@ -126,6 +251,158 @@ module Bindery
126
251
  Bindery::Book.find(id, project_root)
127
252
  end
128
253
 
254
+ # ---------- bindery clean [--all] ----------
255
+ def cmd_clean(args)
256
+ options = { all: false }
257
+ parser = OptionParser.new do |o|
258
+ o.on("--all") { options[:all] = true }
259
+ end
260
+ parser.parse(args)
261
+
262
+ project_root = Project.root!
263
+ epub_dir = Project.output_dir(project_root) + "epub"
264
+ count = 0
265
+ if epub_dir.directory?
266
+ epub_dir.children.each do |f|
267
+ FileUtils.rm_rf(f)
268
+ count += 1
269
+ end
270
+ end
271
+
272
+ if options[:all]
273
+ index = Project.index_file(project_root)
274
+ if index.file?
275
+ index.delete
276
+ count += 1
277
+ end
278
+ end
279
+
280
+ puts "清理完成:移除 #{count} 项"
281
+ end
282
+
283
+ # ---------- bindery validate [<id>] [--all] ----------
284
+ def cmd_validate(args)
285
+ options = { all: false }
286
+ parser = OptionParser.new do |o|
287
+ o.on("--all") { options[:all] = true }
288
+ end
289
+ positional = parser.parse(args)
290
+
291
+ project_root = Project.root!
292
+ books = options[:all] ? Bindery::Book.all(project_root) : [book_from_arg(positional.first, project_root)]
293
+ exit 1 unless check_books(books, project_root)
294
+ end
295
+
296
+ # 校验并打印结果,返回是否全部通过(供 validate 与 build --check 共用)
297
+ def check_books(books, project_root)
298
+ return true if books.empty?
299
+
300
+ ok = true
301
+ books.each do |book|
302
+ issues = Bindery::Validator.check(book, project_root: project_root)
303
+ warns = Bindery::Validator.warnings(book, project_root: project_root)
304
+ if issues.empty?
305
+ puts "✅ [#{book.id}] 校验通过"
306
+ warns.each { |w| puts " ⚠️ #{w}" }
307
+ else
308
+ ok = false
309
+ puts "❌ [#{book.id}] 发现问题:"
310
+ issues.each { |i| puts " - #{i}" }
311
+ warns.each { |w| puts " ⚠️ #{w}" }
312
+ end
313
+ end
314
+ ok
315
+ end
316
+
317
+ # ---------- bindery watch [--interval SECONDS] ----------
318
+ def cmd_watch(args)
319
+ options = { interval: 2.0 }
320
+ parser = OptionParser.new do |o|
321
+ o.on("--interval SECONDS", Float) { |v| options[:interval] = v }
322
+ end
323
+ parser.parse(args)
324
+
325
+ project_root = Project.root!
326
+ puts "监听 books/ 变化,检测到改动会自动重建全部书籍(Ctrl-C 退出)..."
327
+ previous = books_fingerprint(project_root)
328
+ loop do
329
+ sleep options[:interval]
330
+ current = books_fingerprint(project_root)
331
+ next if current == previous
332
+
333
+ previous = current
334
+ puts "\n[#{Time.now.strftime('%H:%M:%S')}] 检测到变化,重新构建..."
335
+ cmd_build(["--all"])
336
+ end
337
+ rescue Interrupt
338
+ puts "\n已停止监听"
339
+ end
340
+
341
+ def books_fingerprint(project_root)
342
+ books_dir = Project.books_dir(project_root)
343
+ return nil unless books_dir.directory?
344
+
345
+ sig = []
346
+ Pathname.glob(books_dir + "**" + "*").each do |f|
347
+ sig << [f.to_s, f.mtime.to_f, f.size] if f.file?
348
+ end
349
+ sig.hash
350
+ end
351
+
352
+ # ---------- bindery serve [--port N] [--host H] ----------
353
+ def cmd_serve(args)
354
+ options = { port: 8000, host: "127.0.0.1" }
355
+ parser = OptionParser.new do |o|
356
+ o.on("--port N", Integer) { |v| options[:port] = v }
357
+ o.on("--host H") { |v| options[:host] = v }
358
+ end
359
+ parser.parse(args)
360
+
361
+ project_root = Project.root!
362
+ Index.rebuild(project_root) # 确保书库索引是最新的
363
+ Bindery::Server.new(project_root, host: options[:host], port: options[:port]).start
364
+ end
365
+
366
+ # ---------- bindery web [--port N] [--host H] ----------
367
+ def cmd_web(args)
368
+ options = { port: 8000, host: "127.0.0.1" }
369
+ parser = OptionParser.new do |o|
370
+ o.on("--port N", Integer) { |v| options[:port] = v }
371
+ o.on("--host H") { |v| options[:host] = v }
372
+ end
373
+ parser.parse(args)
374
+
375
+ project_root = Project.root!
376
+ Index.rebuild(project_root)
377
+
378
+ url = "http://#{options[:host]}:#{options[:port]}/cover"
379
+ server = Bindery::Server.new(project_root, host: options[:host], port: options[:port])
380
+ thread = Thread.new do
381
+ begin
382
+ server.start
383
+ rescue StandardError => e
384
+ warn "服务启动失败: #{e.message}"
385
+ end
386
+ end
387
+ sleep 0.5 # 等服务就绪
388
+ open_browser(url)
389
+ puts "封面生成器已打开: #{url}(Ctrl-C 退出)"
390
+ thread.join
391
+ rescue Interrupt
392
+ puts "\n已退出"
393
+ end
394
+
395
+ def open_browser(url)
396
+ case RbConfig::CONFIG["host_os"]
397
+ when /darwin/
398
+ system("open", url)
399
+ when /mswin|mingw|cygwin/
400
+ system("cmd", "/c", "start", "", url)
401
+ else
402
+ system("xdg-open", url)
403
+ end
404
+ end
405
+
129
406
  # ---------- bindery status ----------
130
407
  def cmd_status(_args)
131
408
  project_root = Project.root!
@@ -135,16 +412,18 @@ module Bindery
135
412
  return
136
413
  end
137
414
 
138
- printf("%-16s %-16s %-8s %-6s\n", "书籍ID", "标题", "章节数", "EPUB")
139
- puts "-" * 50
415
+ printf("%-16s %-16s %-6s %-4s %-6s %-16s\n", "书籍ID", "标题", "章节数", "封面", "EPUB", "构建时间")
416
+ puts "-" * 66
140
417
  books.each do |book|
141
418
  printf(
142
- "%-16s %-16s %-8d %-6s\n",
419
+ "%-16s %-16s %-6d %-4s %-6s %-16s\n",
143
420
  book.id, book.title, book.chapters.size,
144
- book.built?(project_root) ? "✅" : "—"
421
+ book.cover? ? "✅" : "—",
422
+ book.built?(project_root) ? "✅" : "—",
423
+ book.built_at(project_root)&.strftime("%m-%d %H:%M") || "—"
145
424
  )
146
425
  end
147
- puts "-" * 50
426
+ puts "-" * 66
148
427
  puts "共 #{books.size} 本书"
149
428
  end
150
429
 
@@ -153,12 +432,27 @@ module Bindery
153
432
  bindery #{Bindery::VERSION} — 再读经典项目脚手架工具
154
433
 
155
434
  用法:
156
- bindery new <项目名> 创建新项目
157
- bindery generate book <id> <书名> [选项] 为项目生成一本新书
158
- 简写: bindery g book <id> <书名>
159
- 选项: --author NAME --dynasty 朝代 --category 分类
435
+ bindery init 在当前目录初始化项目
436
+ bindery new book <书名> [chapter <章数>] [选项] 新建一本书(ID 自动生成)
437
+ 可组合: chapter <章数> 一并批量创建章节(N = 正文章节数,封面/目录不计入)
438
+ 选项: --id ID(可选) --author NAME --dynasty 朝代 --category 分类
439
+ --translator 译者 --isbn ISBN --cover 自动生成封面
440
+ bindery new chapter <book-id> <数量> 批量创建章节(编号自动接续)
441
+ bindery new volume <book-id> <卷名> [--chapters N] 新建卷(平铺 md,h2=卷名/h3=章名)
442
+ bindery cover <book-id> 自动生成装饰性封面
443
+ bindery rename chapters <book-id> 按标题自动更新章节文件名
160
444
  bindery build <id> 构建单本书的 epub
161
445
  bindery build --all 批量构建所有书
446
+ bindery build <id> --check 构建前校验(不实际构建)
447
+ bindery build <id> --dry-run 仅打印将构建的目标
448
+ bindery build <id> --verbose 打印 pandoc 命令
449
+ bindery build <id> --epubcheck 构建后用 epubcheck 校验
450
+ bindery validate <id> 校验单本书
451
+ bindery validate --all 校验所有书
452
+ bindery clean [--all] 清理构建产物(--all 连 books.json 一起删)
453
+ bindery watch [--interval 秒] 监听 books/ 自动重建
454
+ bindery serve [--port N] [--host H] 启动本地书库站点
455
+ bindery web [--port N] 打开 Web 封面生成器
162
456
  bindery status 查看所有书籍状态
163
457
  bindery version 查看版本号
164
458
  HELP
@@ -0,0 +1,135 @@
1
+ require "zlib"
2
+ require "digest"
3
+
4
+ module Bindery
5
+ # 纯 Ruby 生成装饰性封面 PNG:配色由书名稳定派生,不含文字(文字渲染需要字体光栅化,超出标准库能力)
6
+ module Cover
7
+ WIDTH = 600
8
+ HEIGHT = 960
9
+
10
+ module_function
11
+
12
+ # 为书籍生成 cover.png,并把 metadata.yaml 的 cover 字段指向它;返回文件路径
13
+ def generate(book)
14
+ png = encode_png(WIDTH, HEIGHT, build_pixels(WIDTH, HEIGHT, book.title))
15
+ path = book.dir + "cover.png"
16
+ path.binwrite(png)
17
+ update_metadata_cover(book)
18
+ path
19
+ end
20
+
21
+ def update_metadata_cover(book)
22
+ meta_file = book.metadata_file
23
+ return unless meta_file.file?
24
+
25
+ text = meta_file.read
26
+ text = if text =~ /^cover:.*$/
27
+ text.sub(/^cover:.*$/, "cover: cover.png")
28
+ else
29
+ text.chomp + "\ncover: cover.png\n"
30
+ end
31
+ meta_file.write(text)
32
+ end
33
+
34
+ # 逐像素生成 RGB 位图,返回行数组(每行是一串 RGB 字节)
35
+ def build_pixels(width, height, title)
36
+ hue = Digest::MD5.hexdigest(title).to_i(16) % 360
37
+ top = hsl_to_rgb(hue, 0.45, 0.92)
38
+ bottom = hsl_to_rgb(hue, 0.60, 0.28)
39
+ accent = hsl_to_rgb((hue + 40) % 360, 0.55, 0.50)
40
+ frame = hsl_to_rgb(hue, 0.50, 0.18)
41
+
42
+ margin = 44
43
+ thickness = 4
44
+
45
+ (0...height).map do |y|
46
+ t = y.to_f / (height - 1)
47
+ base = lerp(top, bottom, t)
48
+ row = (+"").b
49
+ (0...width).each do |x|
50
+ c =
51
+ if border?(x, y, width, height, margin, thickness)
52
+ frame
53
+ elsif ring?(x, y, width, height)
54
+ accent
55
+ else
56
+ base
57
+ end
58
+ row << c.pack("C3")
59
+ end
60
+ row
61
+ end
62
+ end
63
+
64
+ def border?(x, y, w, h, margin, thickness)
65
+ in_x = x >= margin && x < w - margin
66
+ in_y = y >= margin && y < h - margin
67
+ return false unless in_x && in_y
68
+
69
+ near_x = (x - margin).abs < thickness || (x - (w - margin - 1)).abs < thickness
70
+ near_y = (y - margin).abs < thickness || (y - (h - margin - 1)).abs < thickness
71
+ near_x || near_y
72
+ end
73
+
74
+ # 居中的方框纹样(外方框 + 内镂空)
75
+ def ring?(x, y, w, h)
76
+ cx = w / 2
77
+ cy = h / 2
78
+ outer = 220
79
+ inner = 150
80
+ dx = (x - cx).abs
81
+ dy = (y - cy).abs
82
+ in_outer = dx <= outer / 2 && dy <= outer / 2
83
+ in_inner = dx <= inner / 2 && dy <= inner / 2
84
+ in_outer && !in_inner
85
+ end
86
+
87
+ def lerp(c1, c2, t)
88
+ [
89
+ c1[0] + (c2[0] - c1[0]) * t,
90
+ c1[1] + (c2[1] - c1[1]) * t,
91
+ c1[2] + (c2[2] - c1[2]) * t,
92
+ ].map(&:round)
93
+ end
94
+
95
+ def hsl_to_rgb(h, s, l)
96
+ h = (h % 360) / 360.0
97
+ if s.zero?
98
+ r = g = b = l
99
+ else
100
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
101
+ p = 2 * l - q
102
+ r = hue_to_rgb(p, q, h + 1 / 3.0)
103
+ g = hue_to_rgb(p, q, h)
104
+ b = hue_to_rgb(p, q, h - 1 / 3.0)
105
+ end
106
+ [(r * 255).round, (g * 255).round, (b * 255).round]
107
+ end
108
+
109
+ def hue_to_rgb(p, q, t)
110
+ t += 1 if t.negative?
111
+ t -= 1 if t > 1
112
+ if t < 1 / 6.0
113
+ p + (q - p) * 6 * t
114
+ elsif t < 1 / 2.0
115
+ q
116
+ elsif t < 2 / 3.0
117
+ p + (q - p) * (2 / 3.0 - t) * 6
118
+ else
119
+ p
120
+ end
121
+ end
122
+
123
+ def encode_png(width, height, rows)
124
+ sig = "\x89PNG\r\n\x1a\n".b
125
+ ihdr = [width, height, 8, 2, 0, 0, 0].pack("N2C5")
126
+ raw = rows.map { |row| "\x00".b + row }.join
127
+ idat = Zlib::Deflate.deflate(raw, 9)
128
+ sig + chunk("IHDR", ihdr) + chunk("IDAT", idat) + chunk("IEND", "")
129
+ end
130
+
131
+ def chunk(type, data)
132
+ [data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N")
133
+ end
134
+ end
135
+ end