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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rukbat
4
+ VERSION = "0.1.0"
5
+ end