bindery-cli 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +44 -13
- data/lib/bindery/book.rb +34 -1
- data/lib/bindery/builder.rb +71 -7
- data/lib/bindery/cli.rb +267 -33
- data/lib/bindery/cover.rb +135 -0
- data/lib/bindery/generators/book_generator.rb +20 -4
- data/lib/bindery/generators/chapter_generator.rb +49 -0
- data/lib/bindery/generators/project_generator.rb +16 -10
- data/lib/bindery/generators/templates/book/metadata.yaml.erb +5 -3
- data/lib/bindery/generators/templates/project/README.md.erb +29 -4
- data/lib/bindery/generators/templates/project/gitignore +0 -1
- data/lib/bindery/generators/volume_generator.rb +66 -0
- data/lib/bindery/image.rb +64 -0
- data/lib/bindery/project.rb +11 -1
- data/lib/bindery/server.rb +185 -0
- data/lib/bindery/validator.rb +52 -0
- data/lib/bindery/version.rb +1 -1
- data/lib/bindery.rb +6 -0
- metadata +12 -6
|
@@ -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 = 800
|
|
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
|
|
@@ -1,26 +1,42 @@
|
|
|
1
|
+
require "securerandom"
|
|
1
2
|
require_relative "base"
|
|
2
3
|
|
|
3
4
|
module Bindery
|
|
4
5
|
module Generators
|
|
5
|
-
# bindery
|
|
6
|
+
# bindery new book <title> [--author] [--dynasty] [--category] [--translator] [--isbn] [--id]
|
|
6
7
|
class BookGenerator < Base
|
|
7
|
-
|
|
8
|
+
# 根据书名自动生成书籍 ID(目录名):英文/数字转 slug,纯中文等回退为随机 ID
|
|
9
|
+
def self.auto_id(title)
|
|
10
|
+
slug = title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
11
|
+
slug.empty? ? "book-#{SecureRandom.hex(4)}" : slug
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.call(id, title, author:, dynasty:, category:, translator: "", isbn: "", project_root: Project.root!)
|
|
8
15
|
dest = Project.books_dir(project_root) + id
|
|
9
16
|
raise Bindery::Error, "书籍目录已存在: #{dest}" if dest.exist?
|
|
10
17
|
|
|
18
|
+
config = Project.config(project_root)
|
|
11
19
|
puts "创建书籍: #{id}"
|
|
12
|
-
new(dest).generate(
|
|
20
|
+
new(dest).generate(
|
|
21
|
+
id: id, title: title, author: author, dynasty: dynasty, category: category,
|
|
22
|
+
translator: translator, isbn: isbn,
|
|
23
|
+
lang: config["lang"] || "zh-CN",
|
|
24
|
+
publisher: config["publisher"] || "再读经典",
|
|
25
|
+
rights: config["rights"] || "公共领域"
|
|
26
|
+
)
|
|
13
27
|
Bindery::Index.rebuild(project_root)
|
|
14
28
|
puts ""
|
|
15
29
|
puts "完成!接下来编辑 #{dest}/chapters/ 下的章节文件,然后:"
|
|
16
30
|
puts " bindery build #{id}"
|
|
17
31
|
end
|
|
18
32
|
|
|
19
|
-
def generate(id:, title:, author:, dynasty:, category:)
|
|
33
|
+
def generate(id:, title:, author:, dynasty:, category:, translator:, isbn:, lang:, publisher:, rights:)
|
|
20
34
|
empty_directory("chapters")
|
|
21
35
|
|
|
22
36
|
template("book", "metadata.yaml", locals: {
|
|
23
37
|
id: id, title: title, author: author, dynasty: dynasty, category: category,
|
|
38
|
+
translator: translator, isbn: isbn,
|
|
39
|
+
lang: lang, publisher: publisher, rights: rights,
|
|
24
40
|
})
|
|
25
41
|
template("book", "chapters/01-chapter.md", locals: { title: title })
|
|
26
42
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "base"
|
|
3
|
+
|
|
4
|
+
module Bindery
|
|
5
|
+
module Generators
|
|
6
|
+
# bindery new chapter <book-id> <数量>
|
|
7
|
+
# 在指定书籍里批量创建章节文件,编号自动接续已有章节(01-chapter.md 之后是 02-chapter.md…)
|
|
8
|
+
class ChapterGenerator < Base
|
|
9
|
+
def self.call(book_id, count, project_root: Project.root!)
|
|
10
|
+
book = Bindery::Book.find(book_id, project_root)
|
|
11
|
+
count = count.to_i
|
|
12
|
+
raise Bindery::Error, "章节数量必须是正整数" unless count.positive?
|
|
13
|
+
|
|
14
|
+
chapters_dir = book.chapters_dir
|
|
15
|
+
FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
|
|
16
|
+
|
|
17
|
+
start = next_number(chapters_dir)
|
|
18
|
+
puts "创建章节: #{book.id}"
|
|
19
|
+
count.times do |i|
|
|
20
|
+
n = start + i
|
|
21
|
+
file = chapters_dir + format("%02d-chapter.md", n)
|
|
22
|
+
file.write("# 第#{n}章\n\n")
|
|
23
|
+
rel = begin
|
|
24
|
+
file.relative_path_from(Pathname.pwd)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
file
|
|
27
|
+
end
|
|
28
|
+
puts " create #{rel}"
|
|
29
|
+
end
|
|
30
|
+
puts ""
|
|
31
|
+
puts "完成!已创建 #{count} 个章节(从第 #{start} 章开始)"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 扫描已有章节文件名开头的数字,返回下一个可用编号
|
|
35
|
+
def self.next_number(chapters_dir)
|
|
36
|
+
max = 0
|
|
37
|
+
return max + 1 unless chapters_dir.directory?
|
|
38
|
+
|
|
39
|
+
chapters_dir.children.each do |f|
|
|
40
|
+
next unless f.file? && f.extname == ".md"
|
|
41
|
+
|
|
42
|
+
n = f.basename.to_s[/\A(\d+)/, 1].to_i
|
|
43
|
+
max = n if n > max
|
|
44
|
+
end
|
|
45
|
+
max + 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -2,23 +2,29 @@ require_relative "base"
|
|
|
2
2
|
|
|
3
3
|
module Bindery
|
|
4
4
|
module Generators
|
|
5
|
-
# bindery
|
|
6
|
-
#
|
|
5
|
+
# bindery init
|
|
6
|
+
# 在当前目录就地生成一整套项目骨架:books/、templates/(样式)、output/、
|
|
7
7
|
# config/bindery.yml(项目标记文件)、README、.gitignore、GitHub Actions
|
|
8
8
|
class ProjectGenerator < Base
|
|
9
|
-
def self.call(project_name)
|
|
10
|
-
dest = Pathname.pwd
|
|
11
|
-
if dest.
|
|
12
|
-
raise Bindery::Error, "
|
|
9
|
+
def self.call(project_name = Pathname.pwd.basename.to_s)
|
|
10
|
+
dest = Pathname.pwd
|
|
11
|
+
if (dest + "config/bindery.yml").file?
|
|
12
|
+
raise Bindery::Error, "当前目录已经是 bindery 项目(已存在 config/bindery.yml)"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
conflicts = ["books", "templates", "output", "README.md", ".gitignore",
|
|
16
|
+
".github/workflows/build.yml"]
|
|
17
|
+
existing = conflicts.select { |f| (dest + f).exist? }
|
|
18
|
+
unless existing.empty?
|
|
19
|
+
raise Bindery::Error, "以下文件/目录已存在,为避免覆盖,无法初始化:#{existing.join('、')}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
puts "初始化项目: #{project_name}"
|
|
16
23
|
new(dest).generate(project_name)
|
|
17
24
|
puts ""
|
|
18
25
|
puts "完成!接下来:"
|
|
19
|
-
puts "
|
|
20
|
-
puts " bindery
|
|
21
|
-
puts " bindery build lunyu"
|
|
26
|
+
puts " bindery new book \"论语\" --author \"孔子及弟子\" --dynasty \"先秦\""
|
|
27
|
+
puts " bindery build --all"
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
def generate(project_name)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
id: <%= id.inspect %>
|
|
2
2
|
title: <%= title.inspect %>
|
|
3
3
|
author: <%= author.inspect %>
|
|
4
|
-
|
|
4
|
+
translator: <%= translator.inspect %>
|
|
5
|
+
lang: <%= lang.inspect %>
|
|
5
6
|
dynasty: <%= dynasty.inspect %>
|
|
6
7
|
category: <%= category.inspect %>
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
isbn: <%= isbn.inspect %>
|
|
9
|
+
rights: <%= rights.inspect %>
|
|
10
|
+
publisher: <%= publisher.inspect %>
|
|
9
11
|
cover: cover.jpg
|
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
## 常用命令
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
#
|
|
9
|
-
bindery
|
|
10
|
-
#
|
|
11
|
-
bindery
|
|
8
|
+
# 新建一本书(ID 自动生成,可用 --id 指定;可带译者 / ISBN;--cover 自动生成封面)
|
|
9
|
+
bindery new book "<书名>" --author "<作者>" --translator "<译者>" --isbn "<ISBN>" --cover
|
|
10
|
+
# 一步到位建好章节(N = 正文章节数,封面/目录由构建自动生成)
|
|
11
|
+
bindery new book "<书名>" chapter 20 --author "<作者>"
|
|
12
|
+
|
|
13
|
+
# 卷式结构:每个卷在 EPUB 中独立成页(h2=卷名,h3=章名)
|
|
14
|
+
bindery new volume <书籍ID> "<卷名>" --chapters <数量>
|
|
15
|
+
|
|
16
|
+
# 单独批量创建章节(编号自动接续已有章节)
|
|
17
|
+
bindery new chapter <书籍ID> <数量>
|
|
18
|
+
|
|
19
|
+
# 单独生成封面
|
|
20
|
+
bindery cover <书籍ID>
|
|
12
21
|
|
|
13
22
|
# 构建一本书
|
|
14
23
|
bindery build <书籍ID>
|
|
@@ -16,8 +25,24 @@ bindery build <书籍ID>
|
|
|
16
25
|
# 批量构建全部
|
|
17
26
|
bindery build --all
|
|
18
27
|
|
|
28
|
+
# 构建前校验(不实际构建)
|
|
29
|
+
bindery validate <书籍ID>
|
|
30
|
+
bindery build <书籍ID> --check
|
|
31
|
+
|
|
32
|
+
# 构建后用 epubcheck 校验
|
|
33
|
+
bindery build <书籍ID> --epubcheck
|
|
34
|
+
|
|
19
35
|
# 查看所有书籍状态
|
|
20
36
|
bindery status
|
|
37
|
+
|
|
38
|
+
# 监听书籍目录,改动自动重建
|
|
39
|
+
bindery watch
|
|
40
|
+
|
|
41
|
+
# 本地启动书库站点
|
|
42
|
+
bindery serve
|
|
43
|
+
|
|
44
|
+
# 清理构建产物
|
|
45
|
+
bindery clean --all
|
|
21
46
|
```
|
|
22
47
|
|
|
23
48
|
## 目录结构
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "base"
|
|
3
|
+
|
|
4
|
+
module Bindery
|
|
5
|
+
module Generators
|
|
6
|
+
# bindery new volume <book-id> <卷名> [--chapters N]
|
|
7
|
+
# 在书籍下创建一个卷目录(编号自动接续),可选批量创建卷内章节
|
|
8
|
+
class VolumeGenerator < Base
|
|
9
|
+
def self.call(book_id, volume_name, chapters: 0, project_root: Project.root!)
|
|
10
|
+
book = Bindery::Book.find(book_id, project_root)
|
|
11
|
+
volume_name = volume_name.to_s.strip
|
|
12
|
+
raise Bindery::Error, "卷名不能为空" if volume_name.empty?
|
|
13
|
+
|
|
14
|
+
if book.volumes.empty? && book.chapters.any?
|
|
15
|
+
warn "注意:chapters/ 下已有平铺章节文件(#{book.chapters.size} 个),卷式书构建时会忽略它们"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
chapters_dir = book.chapters_dir
|
|
19
|
+
FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
|
|
20
|
+
|
|
21
|
+
n = next_volume_number(chapters_dir)
|
|
22
|
+
vol_dir = chapters_dir + format("%02d-%s", n, volume_name)
|
|
23
|
+
raise Bindery::Error, "卷目录已存在: #{vol_dir}" if vol_dir.exist?
|
|
24
|
+
|
|
25
|
+
chapters = chapters.to_i
|
|
26
|
+
raise Bindery::Error, "章节数必须是正整数" if chapters.negative?
|
|
27
|
+
|
|
28
|
+
FileUtils.mkdir_p(vol_dir)
|
|
29
|
+
puts "创建卷: #{volume_name}"
|
|
30
|
+
say_rel(vol_dir, trailing: "/")
|
|
31
|
+
|
|
32
|
+
chapters.times do |i|
|
|
33
|
+
file = vol_dir + format("%02d.md", i + 1)
|
|
34
|
+
file.write("# 第#{i + 1}章\n\n")
|
|
35
|
+
say_rel(file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts ""
|
|
39
|
+
summary = chapters.positive? ? ",含 #{chapters} 章" : ""
|
|
40
|
+
puts "完成!已创建卷 #{format('%02d', n)}(#{volume_name})#{summary}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.next_volume_number(chapters_dir)
|
|
44
|
+
max = 0
|
|
45
|
+
if chapters_dir.directory?
|
|
46
|
+
chapters_dir.children.each do |d|
|
|
47
|
+
next unless d.directory?
|
|
48
|
+
|
|
49
|
+
m = d.basename.to_s[/\A(\d+)/, 1]
|
|
50
|
+
max = m.to_i if m && m.to_i > max
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
max + 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.say_rel(path, trailing: "")
|
|
57
|
+
rel = begin
|
|
58
|
+
path.relative_path_from(Pathname.pwd)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
path
|
|
61
|
+
end
|
|
62
|
+
puts " create #{rel}#{trailing}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Bindery
|
|
2
|
+
# 读取 JPEG/PNG 图片尺寸(纯标准库实现,不依赖第三方 gem)
|
|
3
|
+
module Image
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# 返回 [width, height];无法识别格式时返回 nil
|
|
7
|
+
def dimensions(path)
|
|
8
|
+
return nil unless path.file?
|
|
9
|
+
|
|
10
|
+
head = path.binread(24)
|
|
11
|
+
if png?(head)
|
|
12
|
+
width = head.byteslice(16, 4).unpack1("N")
|
|
13
|
+
height = head.byteslice(20, 4).unpack1("N")
|
|
14
|
+
[width, height]
|
|
15
|
+
elsif jpeg?(head)
|
|
16
|
+
jpeg_dimensions(path)
|
|
17
|
+
end
|
|
18
|
+
rescue StandardError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def png?(head)
|
|
23
|
+
head.byteslice(0, 8) == "\x89PNG\r\n\x1a\n".b
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def jpeg?(head)
|
|
27
|
+
head.byteslice(0, 2) == "\xFF\xD8".b
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def jpeg_dimensions(path)
|
|
31
|
+
File.open(path, "rb") do |f|
|
|
32
|
+
f.read(2) # SOI
|
|
33
|
+
loop do
|
|
34
|
+
marker = f.read(2)
|
|
35
|
+
return nil if marker.nil? || marker.bytesize < 2
|
|
36
|
+
return nil unless marker.getbyte(0) == 0xFF
|
|
37
|
+
|
|
38
|
+
code = marker.getbyte(1)
|
|
39
|
+
# 跳过 0xFF 填充字节
|
|
40
|
+
while code == 0xFF
|
|
41
|
+
next_byte = f.read(1)
|
|
42
|
+
return nil if next_byte.nil?
|
|
43
|
+
code = next_byte.getbyte(0)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# SOF 段携带图片尺寸
|
|
47
|
+
if (0xC0..0xCF).cover?(code) && ![0xC4, 0xC8, 0xCC].include?(code)
|
|
48
|
+
f.read(2) # length
|
|
49
|
+
f.read(1) # precision
|
|
50
|
+
height = f.read(2).unpack1("n")
|
|
51
|
+
width = f.read(2).unpack1("n")
|
|
52
|
+
return [width, height]
|
|
53
|
+
else
|
|
54
|
+
length = f.read(2).unpack1("n")
|
|
55
|
+
return nil if length.nil? || length < 2
|
|
56
|
+
f.read(length - 2)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/bindery/project.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
+
require "yaml"
|
|
2
3
|
|
|
3
4
|
module Bindery
|
|
4
5
|
# 负责在目录树中定位 bindery 项目根目录(含 config/bindery.yml 标记文件的目录)
|
|
@@ -21,7 +22,7 @@ module Bindery
|
|
|
21
22
|
root(start_dir) || raise(
|
|
22
23
|
Bindery::ProjectNotFoundError,
|
|
23
24
|
"找不到 bindery 项目(未发现 #{MARKER})。请在项目目录内执行," \
|
|
24
|
-
"或先用 `bindery
|
|
25
|
+
"或先用 `bindery init` 初始化一个项目。"
|
|
25
26
|
)
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -40,5 +41,14 @@ module Bindery
|
|
|
40
41
|
def index_file(project_root = root!)
|
|
41
42
|
project_root + "books.json"
|
|
42
43
|
end
|
|
44
|
+
|
|
45
|
+
# 读取项目配置 config/bindery.yml(即项目标记文件本身),返回 Hash
|
|
46
|
+
# 文件缺失或解析失败时返回空 Hash,由调用方提供默认值
|
|
47
|
+
def config(project_root = root!)
|
|
48
|
+
path = project_root + MARKER
|
|
49
|
+
return {} unless path.file?
|
|
50
|
+
|
|
51
|
+
YAML.safe_load(path.read, permitted_classes: [Symbol]) || {}
|
|
52
|
+
end
|
|
43
53
|
end
|
|
44
54
|
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Bindery
|
|
7
|
+
# 极简静态服务器:展示书库并提供 EPUB 下载。
|
|
8
|
+
# 仅用标准库 socket 实现(Ruby 3.0 起 WEBrick 已移出标准库)。
|
|
9
|
+
class Server
|
|
10
|
+
MIME = {
|
|
11
|
+
".html" => "text/html; charset=utf-8",
|
|
12
|
+
".json" => "application/json; charset=utf-8",
|
|
13
|
+
".epub" => "application/epub+zip",
|
|
14
|
+
".css" => "text/css; charset=utf-8",
|
|
15
|
+
".jpg" => "image/jpeg",
|
|
16
|
+
".jpeg" => "image/jpeg",
|
|
17
|
+
".png" => "image/png",
|
|
18
|
+
".svg" => "image/svg+xml",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(project_root, host: "127.0.0.1", port: 8000)
|
|
22
|
+
@project_root = project_root
|
|
23
|
+
@host = host
|
|
24
|
+
@port = port
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
server = TCPServer.new(@host, @port)
|
|
29
|
+
puts "服务已启动: http://#{@host}:#{@port} (Ctrl-C 退出)"
|
|
30
|
+
loop do
|
|
31
|
+
client = server.accept
|
|
32
|
+
begin
|
|
33
|
+
respond(client)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
warn "请求处理失败: #{e.message}"
|
|
36
|
+
ensure
|
|
37
|
+
begin
|
|
38
|
+
client.close
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
rescue Interrupt
|
|
45
|
+
puts "\n服务已停止"
|
|
46
|
+
ensure
|
|
47
|
+
begin
|
|
48
|
+
server.close
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def respond(client)
|
|
57
|
+
request_line = client.gets
|
|
58
|
+
return unless request_line
|
|
59
|
+
|
|
60
|
+
method, raw_path, = request_line.split(" ")
|
|
61
|
+
# 丢弃其余请求头
|
|
62
|
+
while (line = client.gets)
|
|
63
|
+
break if line.nil? || line == "\r\n" || line == "\n"
|
|
64
|
+
end
|
|
65
|
+
return unless method == "GET" || method == "HEAD"
|
|
66
|
+
|
|
67
|
+
path = (URI::DEFAULT_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
|
|
68
|
+
path = "/" if path.nil? || path.empty?
|
|
69
|
+
path = path.gsub("..", "") # 防目录穿越
|
|
70
|
+
|
|
71
|
+
case path
|
|
72
|
+
when "/", "/index.html"
|
|
73
|
+
body = index_html
|
|
74
|
+
send_response(client, 200, MIME[".html"], body, method)
|
|
75
|
+
when "/books.json"
|
|
76
|
+
serve_file(client, Project.index_file(@project_root), MIME[".json"], method)
|
|
77
|
+
when %r{\A/epub/([\w\-.]+\.epub)\z}
|
|
78
|
+
serve_file(client, Project.output_dir(@project_root) + "epub" + Regexp.last_match(1), MIME[".epub"], method)
|
|
79
|
+
when %r{\A/cover/([\w\-]+)\z}
|
|
80
|
+
serve_cover(client, Regexp.last_match(1), method)
|
|
81
|
+
else
|
|
82
|
+
not_found(client, method)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def index_html
|
|
87
|
+
index_file = Project.index_file(@project_root)
|
|
88
|
+
data = if index_file.file?
|
|
89
|
+
JSON.parse(index_file.read)
|
|
90
|
+
else
|
|
91
|
+
Bindery::Index.rebuild(@project_root)
|
|
92
|
+
end
|
|
93
|
+
books = data["books"] || []
|
|
94
|
+
|
|
95
|
+
rows = books.map do |b|
|
|
96
|
+
id = escape_html(b["id"].to_s)
|
|
97
|
+
title = escape_html(b["title"].to_s)
|
|
98
|
+
author = escape_html(b["author"].to_s)
|
|
99
|
+
epub_link = if b["epub"]
|
|
100
|
+
%(<a href="/epub/#{id}.epub" download>下载 EPUB</a>)
|
|
101
|
+
else
|
|
102
|
+
"<em>未构建</em>"
|
|
103
|
+
end
|
|
104
|
+
cover = %(<img src="/cover/#{id}" class="cover" alt="">)
|
|
105
|
+
"<li class=\"book\">#{cover}<div class=\"info\"><h2>#{title}</h2><p class=\"author\">#{author}</p>#{epub_link}</div></li>"
|
|
106
|
+
end.join("\n")
|
|
107
|
+
|
|
108
|
+
<<~HTML
|
|
109
|
+
<!doctype html>
|
|
110
|
+
<html lang="zh-CN">
|
|
111
|
+
<head>
|
|
112
|
+
<meta charset="utf-8">
|
|
113
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
114
|
+
<title>#{escape_html(@project_root.basename.to_s)} · 书库</title>
|
|
115
|
+
<style>
|
|
116
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
117
|
+
h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: .4em; }
|
|
118
|
+
ul { list-style: none; padding: 0; }
|
|
119
|
+
.book { display: flex; gap: 1em; padding: 1em 0; border-bottom: 1px solid #f0f0f0; }
|
|
120
|
+
.cover { width: 64px; height: 96px; object-fit: cover; background: #f5f5f5; border-radius: 4px; }
|
|
121
|
+
.info h2 { margin: 0; font-size: 1.1em; }
|
|
122
|
+
.author { color: #666; margin: .3em 0 .5em; }
|
|
123
|
+
a { color: #b45309; }
|
|
124
|
+
em { color: #999; }
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<h1>#{escape_html(@project_root.basename.to_s)} · 书库</h1>
|
|
129
|
+
<p>共 #{books.size} 本书</p>
|
|
130
|
+
<ul>#{rows}</ul>
|
|
131
|
+
</body>
|
|
132
|
+
</html>
|
|
133
|
+
HTML
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def serve_cover(client, id, method)
|
|
137
|
+
book_dir = Project.books_dir(@project_root) + id
|
|
138
|
+
return not_found(client, method) unless book_dir.directory?
|
|
139
|
+
|
|
140
|
+
name = "cover.jpg"
|
|
141
|
+
meta_file = book_dir + "metadata.yaml"
|
|
142
|
+
if meta_file.file?
|
|
143
|
+
begin
|
|
144
|
+
meta = YAML.safe_load(meta_file.read, permitted_classes: [Symbol]) || {}
|
|
145
|
+
name = meta["cover"] || "cover.jpg"
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
path = book_dir + name
|
|
152
|
+
path = (book_dir + "cover.jpg") unless path.file?
|
|
153
|
+
path = (book_dir + "cover.png") unless path.file?
|
|
154
|
+
|
|
155
|
+
serve_file(client, path, nil, method)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def serve_file(client, path, content_type, method)
|
|
159
|
+
return not_found(client, method) unless path.file?
|
|
160
|
+
|
|
161
|
+
body = path.binread
|
|
162
|
+
content_type ||= MIME[path.extname.downcase] || "application/octet-stream"
|
|
163
|
+
send_response(client, 200, content_type, body, method)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def not_found(client, method)
|
|
167
|
+
body = "<h1>404 Not Found</h1>"
|
|
168
|
+
send_response(client, 404, MIME[".html"], body, method)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def send_response(client, status, content_type, body, method)
|
|
172
|
+
reason = { 200 => "OK", 404 => "Not Found" }[status] || "OK"
|
|
173
|
+
headers = "HTTP/1.1 #{status} #{reason}\r\n" \
|
|
174
|
+
"Content-Type: #{content_type}\r\n" \
|
|
175
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
176
|
+
"Connection: close\r\n\r\n"
|
|
177
|
+
client.write(headers)
|
|
178
|
+
client.write(body) unless method == "HEAD"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def escape_html(str)
|
|
182
|
+
str.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|