rukbat 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/exe/rukbat +37 -0
- data/lib/rukbat/application.rb +37 -0
- data/lib/rukbat/atomic_file.rb +11 -0
- data/lib/rukbat/cell_source.rb +25 -0
- data/lib/rukbat/csv_file.rb +125 -0
- data/lib/rukbat/grid_view.rb +847 -0
- data/lib/rukbat/pdf_file.rb +163 -0
- data/lib/rukbat/version.rb +5 -0
- data/lib/rukbat/workbook.rb +1045 -0
- data/lib/rukbat.rb +23 -0
- data/sig/rukbat.rbs +118 -0
- metadata +201 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Rukbat
|
|
6
|
+
module PDFFile
|
|
7
|
+
PAGE_WIDTH = 842
|
|
8
|
+
PAGE_HEIGHT = 595
|
|
9
|
+
ROWS_PER_PAGE = 28
|
|
10
|
+
COLUMNS_PER_PAGE = 8
|
|
11
|
+
MAX_CELLS = 100_000
|
|
12
|
+
CELL_WIDTH = 94
|
|
13
|
+
CELL_HEIGHT = 17
|
|
14
|
+
MARGIN = 20
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def render(workbook, font:, sheet: workbook.active_sheet, font_db: nil)
|
|
19
|
+
font = normalize_font(font)
|
|
20
|
+
font_cache = {}
|
|
21
|
+
current = workbook.sheet(sheet)
|
|
22
|
+
area = workbook.print_area(sheet: sheet)
|
|
23
|
+
origin_row, origin_column = area ? [area.top - 1, area.left - 1] : [0, 0]
|
|
24
|
+
rows = area ? area.bottom - area.top + 1 : [current.row_count, 1].max
|
|
25
|
+
columns = area ? area.right - area.left + 1 : [current.column_count, 1].max
|
|
26
|
+
raise Error, "PDF export exceeds #{MAX_CELLS} cells" if rows * columns > MAX_CELLS
|
|
27
|
+
|
|
28
|
+
document = Okab::Document.new(title: "#{sheet} — Rukbat", author: "Yudai Takada", creator: "Rukbat")
|
|
29
|
+
(0...rows).step(ROWS_PER_PAGE) do |row_offset|
|
|
30
|
+
(0...columns).step(COLUMNS_PER_PAGE) do |column_offset|
|
|
31
|
+
render_page(document, workbook, font, font_db, font_cache, sheet, origin_row, origin_column, row_offset, column_offset,
|
|
32
|
+
[rows - row_offset, ROWS_PER_PAGE].min, [columns - column_offset, COLUMNS_PER_PAGE].min)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
document.render
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write(workbook, path, font:, sheet: workbook.active_sheet, font_db: nil)
|
|
39
|
+
bytes = render(workbook, font: font, sheet: sheet, font_db: font_db)
|
|
40
|
+
target = File.expand_path(path)
|
|
41
|
+
stat = File.lstat(target) if File.exist?(target) || File.symlink?(target)
|
|
42
|
+
raise Error, "PDF target must be a regular file" if stat && !stat.file?
|
|
43
|
+
|
|
44
|
+
mode = stat ? stat.mode & 0o7777 : 0o666 & ~File.umask
|
|
45
|
+
Tempfile.create([".rukbat-", ".pdf"], File.dirname(target), binmode: true) do |file|
|
|
46
|
+
file.write(bytes)
|
|
47
|
+
file.flush
|
|
48
|
+
file.fsync
|
|
49
|
+
file.chmod(mode)
|
|
50
|
+
file.close
|
|
51
|
+
AtomicFile.install(file.path, target, replace: true)
|
|
52
|
+
end
|
|
53
|
+
path
|
|
54
|
+
rescue SystemCallError => error
|
|
55
|
+
raise Error, "cannot write PDF: #{error.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_page(document, workbook, font, font_db, font_cache, sheet, origin_row, origin_column, row_offset, column_offset, row_count, column_count)
|
|
59
|
+
document.page(width: PAGE_WIDTH, height: PAGE_HEIGHT) do |page|
|
|
60
|
+
first_row = origin_row + row_offset + 1
|
|
61
|
+
last_row = first_row + row_count - 1
|
|
62
|
+
page.text("#{sheet} — rows #{first_row}-#{last_row}",
|
|
63
|
+
x: MARGIN, y: PAGE_HEIGHT - MARGIN, font: font, size: 10)
|
|
64
|
+
data_top = PAGE_HEIGHT - MARGIN - 24
|
|
65
|
+
(0..row_count).each do |row_index|
|
|
66
|
+
(0..column_count).each do |column_index|
|
|
67
|
+
x = MARGIN + (column_index.zero? ? 0 : 38 + (column_index - 1) * CELL_WIDTH)
|
|
68
|
+
width = column_index.zero? ? 38 : CELL_WIDTH
|
|
69
|
+
y = data_top - row_index * CELL_HEIGHT
|
|
70
|
+
text, style = cell_text(workbook, sheet, origin_row, origin_column,
|
|
71
|
+
row_offset, column_offset, row_index, column_index)
|
|
72
|
+
paint_cell(page, text, style, font_for_style(style, font, font_db, font_cache), x, y, width)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :render_page
|
|
78
|
+
|
|
79
|
+
def cell_text(workbook, sheet, origin_row, origin_column, row_offset, column_offset, row_index, column_index)
|
|
80
|
+
return ["", {}] if row_index.zero? && column_index.zero?
|
|
81
|
+
return [(origin_row + row_offset + row_index).to_s, {}] if column_index.zero?
|
|
82
|
+
return [Furud::Formula.column_name(origin_column + column_offset + column_index), {}] if row_index.zero?
|
|
83
|
+
|
|
84
|
+
row = origin_row + row_offset + row_index
|
|
85
|
+
column = origin_column + column_offset + column_index
|
|
86
|
+
workbook.presentation_at(row, column, sheet: sheet)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :cell_text
|
|
89
|
+
|
|
90
|
+
def paint_cell(page, text, style, font, x, top, width)
|
|
91
|
+
y = top - CELL_HEIGHT
|
|
92
|
+
if style[:background]
|
|
93
|
+
page.rect(x, y, width, CELL_HEIGHT).fill(rgb(style[:background]))
|
|
94
|
+
end
|
|
95
|
+
page.rect(x, y, width, CELL_HEIGHT).stroke(rgb(style[:border_color] || "#B8BEC8"),
|
|
96
|
+
width: style[:border_width] || 0.5)
|
|
97
|
+
text = text.to_s.lines.first.to_s.chomp
|
|
98
|
+
text = text.encode(Encoding::UTF_8)
|
|
99
|
+
# ponytail: fixed-height PDF rows cap text at 13pt; variable page geometry can lift the ceiling.
|
|
100
|
+
size = [[style[:font_size] || 8, 6].max, CELL_HEIGHT - 4].min
|
|
101
|
+
text = fit_text(text, font, size, width - 6)
|
|
102
|
+
unless text.empty?
|
|
103
|
+
text_width = font.measure(text, size: size)
|
|
104
|
+
text_x = case style[:horizontal_alignment]
|
|
105
|
+
when :center then x + (width - text_width) / 2
|
|
106
|
+
when :right then x + width - text_width - 3
|
|
107
|
+
else x + 3
|
|
108
|
+
end
|
|
109
|
+
baseline = case style[:vertical_alignment]
|
|
110
|
+
when :top then y + CELL_HEIGHT - size - 2
|
|
111
|
+
when :bottom then y + 2
|
|
112
|
+
else y + (CELL_HEIGHT - size) / 2
|
|
113
|
+
end
|
|
114
|
+
page.text(text, x: text_x, y: baseline, font: font, size: size,
|
|
115
|
+
color: rgb(style[:color] || "#20242C"), bold: style[:bold] || false, italic: style[:italic] || false)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
private_class_method :paint_cell
|
|
119
|
+
|
|
120
|
+
def font_for_style(style, fallback, font_db, cache)
|
|
121
|
+
family = style[:font_family]
|
|
122
|
+
return fallback unless family
|
|
123
|
+
|
|
124
|
+
key = family.unicode_normalize(:nfkc).downcase(:fold).gsub(/\s+/, " ").strip
|
|
125
|
+
cache[key] ||= begin
|
|
126
|
+
database = font_db || Zaniah::TextSystem::FontDB.new
|
|
127
|
+
face = database.faces.find do |candidate|
|
|
128
|
+
candidate.families.any? { |name| name.unicode_normalize(:nfkc).downcase(:fold).gsub(/\s+/, " ").strip == key }
|
|
129
|
+
end
|
|
130
|
+
raise Error, "font family is not installed: #{family}" unless face
|
|
131
|
+
|
|
132
|
+
Okab::Font.new(database.open(face.path, index: face.index))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :font_for_style
|
|
136
|
+
|
|
137
|
+
def fit_text(text, font, size, width)
|
|
138
|
+
return text if font.measure(text, size: size) <= width
|
|
139
|
+
|
|
140
|
+
result = +""
|
|
141
|
+
text.each_char do |character|
|
|
142
|
+
break if font.measure(result + character + "…", size: size) > width
|
|
143
|
+
result << character
|
|
144
|
+
end
|
|
145
|
+
result + "…"
|
|
146
|
+
end
|
|
147
|
+
private_class_method :fit_text
|
|
148
|
+
|
|
149
|
+
def rgb(color)
|
|
150
|
+
color.delete_prefix("#").scan(/../).map { |channel| channel.to_i(16) / 255.0 }
|
|
151
|
+
end
|
|
152
|
+
private_class_method :rgb
|
|
153
|
+
|
|
154
|
+
def normalize_font(font)
|
|
155
|
+
font = Okab::Font.load(font) if font.is_a?(String)
|
|
156
|
+
font = Okab::Font.new(font) if font.is_a?(Alhena::Font)
|
|
157
|
+
raise ArgumentError, "font must be an Okab::Font, Alhena::Font, or font path" unless font.is_a?(Okab::Font)
|
|
158
|
+
|
|
159
|
+
font
|
|
160
|
+
end
|
|
161
|
+
private_class_method :normalize_font
|
|
162
|
+
end
|
|
163
|
+
end
|