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.
- checksums.yaml +4 -4
- data/README.md +46 -13
- data/lib/bindery/assets/index.html +687 -0
- data/lib/bindery/book.rb +10 -0
- data/lib/bindery/builder.rb +36 -5
- data/lib/bindery/cli.rb +326 -32
- data/lib/bindery/cover.rb +135 -0
- data/lib/bindery/generators/book_generator.rb +21 -5
- data/lib/bindery/generators/chapter_generator.rb +49 -0
- data/lib/bindery/generators/project_generator.rb +16 -10
- data/lib/bindery/generators/templates/book/chapters/{01-chapter.md.erb → chapter-0001.md.erb} +2 -1
- data/lib/bindery/generators/templates/book/metadata.yaml.erb +5 -3
- data/lib/bindery/generators/templates/project/README.md.erb +35 -4
- data/lib/bindery/generators/templates/project/gitignore +0 -1
- data/lib/bindery/generators/volume_generator.rb +62 -0
- data/lib/bindery/image.rb +64 -0
- data/lib/bindery/project.rb +11 -1
- data/lib/bindery/renamer.rb +57 -0
- data/lib/bindery/server.rb +238 -0
- data/lib/bindery/validator.rb +52 -0
- data/lib/bindery/version.rb +1 -1
- data/lib/bindery.rb +7 -0
- metadata +15 -7
data/lib/bindery/builder.rb
CHANGED
|
@@ -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
|
-
# 支持:
|
|
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
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
63
|
+
Generators::ProjectGenerator.call
|
|
49
64
|
end
|
|
50
65
|
|
|
51
|
-
# ---------- bindery
|
|
52
|
-
def
|
|
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
|
-
|
|
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
|
|
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}
|
|
82
|
+
warn "未知的生成器类型: #{type}(支持 book / chapter / volume)"
|
|
62
83
|
exit 1
|
|
63
84
|
end
|
|
64
85
|
end
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
if
|
|
76
|
-
warn "用法: bindery
|
|
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 %-
|
|
139
|
-
puts "-" *
|
|
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 %-
|
|
419
|
+
"%-16s %-16s %-6d %-4s %-6s %-16s\n",
|
|
143
420
|
book.id, book.title, book.chapters.size,
|
|
144
|
-
book.
|
|
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 "-" *
|
|
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
|
|
157
|
-
bindery
|
|
158
|
-
|
|
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
|