rufio 1.1.0 → 1.3.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/CHANGELOG.md +34 -0
- data/README.md +74 -3
- data/README_ja.md +71 -2
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/info/help.md +2 -0
- data/info/keybindings.md +3 -1
- data/info/welcome.md +1 -1
- data/lib/rufio/application.rb +12 -1
- data/lib/rufio/archive_controller.rb +211 -0
- data/lib/rufio/background_command_executor.rb +9 -0
- data/lib/rufio/background_status.rb +49 -0
- data/lib/rufio/bookmark_controller.rb +38 -121
- data/lib/rufio/bookmark_manager.rb +77 -331
- data/lib/rufio/command_completion.rb +6 -1
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +40 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +88 -136
- data/lib/rufio/config_loader.rb +3 -11
- data/lib/rufio/dialog_renderer.rb +64 -152
- data/lib/rufio/dialog_support.rb +69 -0
- data/lib/rufio/document_text_extractor.rb +167 -0
- data/lib/rufio/file_operation_controller.rb +49 -222
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +121 -9
- data/lib/rufio/help_menu.rb +8 -2
- data/lib/rufio/interpreter_resolver.rb +6 -11
- data/lib/rufio/job_manager.rb +2 -5
- data/lib/rufio/key_input.rb +77 -0
- data/lib/rufio/keybind_handler.rb +81 -115
- data/lib/rufio/local_script_scanner.rb +6 -18
- data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native_content_search.rb +153 -0
- data/lib/rufio/native_scanner.rb +13 -68
- data/lib/rufio/native_scanner_zig.rb +106 -56
- data/lib/rufio/native_zip.rb +121 -0
- data/lib/rufio/navigation_controller.rb +18 -20
- data/lib/rufio/screen.rb +2 -12
- data/lib/rufio/script_executor.rb +6 -73
- data/lib/rufio/script_file.rb +44 -0
- data/lib/rufio/script_path_manager.rb +9 -43
- data/lib/rufio/script_runner.rb +13 -50
- data/lib/rufio/search_controller.rb +1 -1
- data/lib/rufio/shell_command_completion.rb +1 -5
- data/lib/rufio/shell_init.rb +90 -0
- data/lib/rufio/task_status.rb +5 -1
- data/lib/rufio/terminal_ui.rb +55 -294
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +63 -40
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zip_archive.rb +314 -0
- data/lib/rufio/zip_extractor.rb +346 -0
- data/lib/rufio/zip_writer.rb +268 -0
- data/lib/rufio/zoxide_integration.rb +5 -43
- data/lib/rufio.rb +5 -0
- data/lib_zig/rufio_native/Makefile +54 -17
- data/lib_zig/rufio_native/bench.sh +36 -0
- data/lib_zig/rufio_native/build.zig +32 -35
- data/lib_zig/rufio_native/src/cli.zig +99 -0
- data/lib_zig/rufio_native/src/main.zig +187 -83
- data/lib_zig/rufio_native/src/os.zig +889 -0
- data/lib_zig/rufio_native/src/search.zig +919 -0
- data/lib_zig/rufio_native/src/search_test.zig +88 -0
- data/lib_zig/rufio_native/src/zip.zig +482 -0
- metadata +125 -3
- data/lib/rufio/native/rufio_zig.bundle +0 -0
|
@@ -20,185 +20,52 @@ module Rufio
|
|
|
20
20
|
# @option options [String] :title_color Title color ANSI code
|
|
21
21
|
# @option options [String] :content_color Content color ANSI code
|
|
22
22
|
def draw_floating_window_to_buffer(screen, x, y, width, height, title, content_lines, options = {})
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
content_color = options[:content_color] || "\e[37m" # White
|
|
27
|
-
|
|
28
|
-
# Draw top border
|
|
29
|
-
screen.put_string(x, y, "┌#{'─' * (width - 2)}┐", fg: border_color)
|
|
30
|
-
|
|
31
|
-
# Draw title line if title exists
|
|
32
|
-
if title
|
|
33
|
-
title_width = TextUtils.display_width(title)
|
|
34
|
-
title_padding = (width - 2 - title_width) / 2
|
|
35
|
-
padded_title = ' ' * title_padding + title
|
|
36
|
-
title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
|
|
37
|
-
|
|
38
|
-
screen.put(x, y + 1, '│', fg: border_color)
|
|
39
|
-
screen.put_string(x + 1, y + 1, title_line, fg: title_color)
|
|
40
|
-
screen.put(x + width - 1, y + 1, '│', fg: border_color)
|
|
41
|
-
|
|
42
|
-
# Draw title separator
|
|
43
|
-
screen.put_string(x, y + 2, "├#{'─' * (width - 2)}┤", fg: border_color)
|
|
44
|
-
content_start_y = y + 3
|
|
45
|
-
else
|
|
46
|
-
content_start_y = y + 1
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Draw content lines
|
|
50
|
-
content_height = title ? height - 4 : height - 2
|
|
51
|
-
content_lines.each_with_index do |line, index|
|
|
52
|
-
break if index >= content_height
|
|
53
|
-
|
|
54
|
-
line_y = content_start_y + index
|
|
55
|
-
line_content = TextUtils.pad_string_to_width(line, width - 2)
|
|
56
|
-
|
|
57
|
-
screen.put(x, line_y, '│', fg: border_color)
|
|
58
|
-
screen.put_string(x + 1, line_y, line_content, fg: content_color)
|
|
59
|
-
screen.put(x + width - 1, line_y, '│', fg: border_color)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Fill remaining lines with empty space
|
|
63
|
-
remaining_lines = content_height - content_lines.length
|
|
64
|
-
remaining_lines.times do |i|
|
|
65
|
-
line_y = content_start_y + content_lines.length + i
|
|
66
|
-
empty_line = ' ' * (width - 2)
|
|
67
|
-
|
|
68
|
-
screen.put(x, line_y, '│', fg: border_color)
|
|
69
|
-
screen.put_string(x + 1, line_y, empty_line)
|
|
70
|
-
screen.put(x + width - 1, line_y, '│', fg: border_color)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Draw bottom border
|
|
74
|
-
bottom_y = y + height - 1
|
|
75
|
-
screen.put_string(x, bottom_y, "└#{'─' * (width - 2)}┘", fg: border_color)
|
|
23
|
+
draw_window_cells(x, y, width, height, title, content_lines, options,
|
|
24
|
+
put_cell: ->(cx, cy, ch, fg: nil) { screen.put(cx, cy, ch, fg: fg) },
|
|
25
|
+
put_str: ->(cx, cy, str, fg: nil) { screen.put_string(cx, cy, str, fg: fg) })
|
|
76
26
|
end
|
|
77
27
|
|
|
78
28
|
# オーバーレイレイヤーにフローティングウィンドウを描画
|
|
79
|
-
# @param screen [Screen] Screen buffer with overlay enabled
|
|
80
|
-
# @param x [Integer] X position (column)
|
|
81
|
-
# @param y [Integer] Y position (row)
|
|
82
|
-
# @param width [Integer] Window width
|
|
83
|
-
# @param height [Integer] Window height
|
|
84
|
-
# @param title [String, nil] Window title (optional)
|
|
85
|
-
# @param content_lines [Array<String>] Content lines to display
|
|
86
|
-
# @param options [Hash] Customization options
|
|
87
29
|
def draw_floating_window_to_overlay(screen, x, y, width, height, title, content_lines, options = {})
|
|
88
|
-
# オーバーレイが有効でない場合は有効化
|
|
89
30
|
screen.enable_overlay unless screen.overlay_enabled?
|
|
90
31
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# Draw top border
|
|
96
|
-
screen.put_overlay_string(x, y, "┌#{'─' * (width - 2)}┐", fg: border_color)
|
|
97
|
-
|
|
98
|
-
# Draw title line if title exists
|
|
99
|
-
if title
|
|
100
|
-
title_width = TextUtils.display_width(title)
|
|
101
|
-
title_padding = (width - 2 - title_width) / 2
|
|
102
|
-
padded_title = ' ' * title_padding + title
|
|
103
|
-
title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
|
|
104
|
-
|
|
105
|
-
screen.put_overlay(x, y + 1, '│', fg: border_color)
|
|
106
|
-
screen.put_overlay_string(x + 1, y + 1, title_line, fg: title_color)
|
|
107
|
-
screen.put_overlay(x + width - 1, y + 1, '│', fg: border_color)
|
|
108
|
-
|
|
109
|
-
# Draw title separator
|
|
110
|
-
screen.put_overlay_string(x, y + 2, "├#{'─' * (width - 2)}┤", fg: border_color)
|
|
111
|
-
content_start_y = y + 3
|
|
112
|
-
else
|
|
113
|
-
content_start_y = y + 1
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Draw content lines
|
|
117
|
-
content_height = title ? height - 4 : height - 2
|
|
118
|
-
content_lines.each_with_index do |line, index|
|
|
119
|
-
break if index >= content_height
|
|
120
|
-
|
|
121
|
-
line_y = content_start_y + index
|
|
122
|
-
line_content = TextUtils.pad_string_to_width(line, width - 2)
|
|
123
|
-
|
|
124
|
-
screen.put_overlay(x, line_y, '│', fg: border_color)
|
|
125
|
-
screen.put_overlay_string(x + 1, line_y, line_content, fg: content_color)
|
|
126
|
-
screen.put_overlay(x + width - 1, line_y, '│', fg: border_color)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Fill remaining lines with empty space
|
|
130
|
-
remaining_lines = content_height - content_lines.length
|
|
131
|
-
remaining_lines.times do |i|
|
|
132
|
-
line_y = content_start_y + content_lines.length + i
|
|
133
|
-
empty_line = ' ' * (width - 2)
|
|
134
|
-
|
|
135
|
-
screen.put_overlay(x, line_y, '│', fg: border_color)
|
|
136
|
-
screen.put_overlay_string(x + 1, line_y, empty_line)
|
|
137
|
-
screen.put_overlay(x + width - 1, line_y, '│', fg: border_color)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Draw bottom border
|
|
141
|
-
bottom_y = y + height - 1
|
|
142
|
-
screen.put_overlay_string(x, bottom_y, "└#{'─' * (width - 2)}┘", fg: border_color)
|
|
32
|
+
draw_window_cells(x, y, width, height, title, content_lines, options,
|
|
33
|
+
put_cell: ->(cx, cy, ch, fg: nil) { screen.put_overlay(cx, cy, ch, fg: fg) },
|
|
34
|
+
put_str: ->(cx, cy, str, fg: nil) { screen.put_overlay_string(cx, cy, str, fg: fg) })
|
|
143
35
|
end
|
|
144
36
|
|
|
145
37
|
# Draw a floating window with title, content, and customizable colors
|
|
146
|
-
# @param x [Integer] X position (column)
|
|
147
|
-
# @param y [Integer] Y position (row)
|
|
148
|
-
# @param width [Integer] Window width
|
|
149
|
-
# @param height [Integer] Window height
|
|
150
|
-
# @param title [String, nil] Window title (optional)
|
|
151
|
-
# @param content_lines [Array<String>] Content lines to display
|
|
152
|
-
# @param options [Hash] Customization options
|
|
153
|
-
# @option options [String] :border_color Border color ANSI code
|
|
154
|
-
# @option options [String] :title_color Title color ANSI code
|
|
155
|
-
# @option options [String] :content_color Content color ANSI code
|
|
156
38
|
def draw_floating_window(x, y, width, height, title, content_lines, options = {})
|
|
157
|
-
# Default options
|
|
158
39
|
border_color = options[:border_color] || "\e[37m" # White
|
|
159
40
|
title_color = options[:title_color] || "\e[1;33m" # Bold yellow
|
|
160
41
|
content_color = options[:content_color] || "\e[37m" # White
|
|
161
42
|
reset_color = "\e[0m"
|
|
43
|
+
inner_width = width - 2
|
|
162
44
|
|
|
163
|
-
#
|
|
164
|
-
print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
|
|
45
|
+
print "\e[#{y};#{x}H#{border_color}┌#{'─' * inner_width}┐#{reset_color}"
|
|
165
46
|
|
|
166
|
-
# Draw title line if title exists
|
|
167
47
|
if title
|
|
168
|
-
|
|
169
|
-
title_padding = (width - 2 - title_width) / 2
|
|
170
|
-
padded_title = ' ' * title_padding + title
|
|
171
|
-
title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
|
|
48
|
+
title_line = format_centered_title(title, inner_width)
|
|
172
49
|
print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
|
|
173
|
-
|
|
174
|
-
# Draw title separator
|
|
175
|
-
print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
|
|
50
|
+
print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * inner_width}┤#{reset_color}"
|
|
176
51
|
content_start_y = y + 3
|
|
177
52
|
else
|
|
178
53
|
content_start_y = y + 1
|
|
179
54
|
end
|
|
180
55
|
|
|
181
|
-
# Draw content lines
|
|
182
56
|
content_height = title ? height - 4 : height - 2
|
|
183
|
-
|
|
184
|
-
break if index >= content_height
|
|
185
|
-
|
|
57
|
+
content_height.times do |index|
|
|
186
58
|
line_y = content_start_y + index
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
remaining_lines.times do |i|
|
|
194
|
-
line_y = content_start_y + content_lines.length + i
|
|
195
|
-
empty_line = ' ' * (width - 2)
|
|
196
|
-
print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
|
|
59
|
+
if index < content_lines.length
|
|
60
|
+
line_content = TextUtils.pad_string_to_width(content_lines[index], inner_width)
|
|
61
|
+
print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
|
|
62
|
+
else
|
|
63
|
+
print "\e[#{line_y};#{x}H#{border_color}│#{' ' * inner_width}│#{reset_color}"
|
|
64
|
+
end
|
|
197
65
|
end
|
|
198
66
|
|
|
199
|
-
# Draw bottom border
|
|
200
67
|
bottom_y = y + height - 1
|
|
201
|
-
print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' *
|
|
68
|
+
print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * inner_width}┘#{reset_color}"
|
|
202
69
|
end
|
|
203
70
|
|
|
204
71
|
# Calculate center position for a window
|
|
@@ -307,6 +174,51 @@ module Rufio
|
|
|
307
174
|
|
|
308
175
|
private
|
|
309
176
|
|
|
177
|
+
def format_centered_title(title, inner_width)
|
|
178
|
+
title_width = TextUtils.display_width(title)
|
|
179
|
+
title_padding = [(inner_width - title_width) / 2, 0].max
|
|
180
|
+
padded_title = ' ' * title_padding + title
|
|
181
|
+
TextUtils.pad_string_to_width(padded_title, inner_width)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def draw_window_cells(x, y, width, height, title, content_lines, options, put_cell:, put_str:)
|
|
185
|
+
border_color = options[:border_color] || "\e[37m"
|
|
186
|
+
title_color = options[:title_color] || "\e[1;33m"
|
|
187
|
+
content_color = options[:content_color] || "\e[37m"
|
|
188
|
+
inner_width = width - 2
|
|
189
|
+
|
|
190
|
+
put_str.call(x, y, "┌#{'─' * inner_width}┐", fg: border_color)
|
|
191
|
+
|
|
192
|
+
if title
|
|
193
|
+
title_line = format_centered_title(title, inner_width)
|
|
194
|
+
put_cell.call(x, y + 1, '│', fg: border_color)
|
|
195
|
+
put_str.call(x + 1, y + 1, title_line, fg: title_color)
|
|
196
|
+
put_cell.call(x + width - 1, y + 1, '│', fg: border_color)
|
|
197
|
+
|
|
198
|
+
put_str.call(x, y + 2, "├#{'─' * inner_width}┤", fg: border_color)
|
|
199
|
+
content_start_y = y + 3
|
|
200
|
+
else
|
|
201
|
+
content_start_y = y + 1
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
content_height = title ? height - 4 : height - 2
|
|
205
|
+
empty_line = ' ' * inner_width
|
|
206
|
+
content_height.times do |index|
|
|
207
|
+
line_y = content_start_y + index
|
|
208
|
+
put_cell.call(x, line_y, '│', fg: border_color)
|
|
209
|
+
if index < content_lines.length
|
|
210
|
+
line_content = TextUtils.pad_string_to_width(content_lines[index], inner_width)
|
|
211
|
+
put_str.call(x + 1, line_y, line_content, fg: content_color)
|
|
212
|
+
else
|
|
213
|
+
put_str.call(x + 1, line_y, empty_line)
|
|
214
|
+
end
|
|
215
|
+
put_cell.call(x + width - 1, line_y, '│', fg: border_color)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
bottom_y = y + height - 1
|
|
219
|
+
put_str.call(x, bottom_y, "└#{'─' * inner_width}┘", fg: border_color)
|
|
220
|
+
end
|
|
221
|
+
|
|
310
222
|
# ASCII character range constants
|
|
311
223
|
ASCII_PRINTABLE_START = 32
|
|
312
224
|
ASCII_PRINTABLE_END = 127
|
|
@@ -329,7 +241,7 @@ module Rufio
|
|
|
329
241
|
max_input_width = dialog_width - 8 # Leave space for borders and padding (border 2 + padding 4 + border 2)
|
|
330
242
|
|
|
331
243
|
loop do
|
|
332
|
-
char =
|
|
244
|
+
char = KeyInput.getch
|
|
333
245
|
|
|
334
246
|
case char
|
|
335
247
|
when "\e" # Escape - cancel
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
module Rufio
|
|
6
|
+
# ダイアログの表示方式選択と入力待ち・後片付けを共有する。
|
|
7
|
+
# 利用側は @dialog_renderer と @terminal_ui を設定する。
|
|
8
|
+
module DialogSupport
|
|
9
|
+
CONFIRMATION_CANCEL_KEYS = ["n", "\e", "\x03"].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# 表示とキー判定を共有し、終了確認だけは従来どおり q を無視する。
|
|
14
|
+
def confirm_dialog(title, content_lines, options = {}, cancel_on_quit: true)
|
|
15
|
+
confirmed = false
|
|
16
|
+
show_overlay_dialog(title, content_lines, options) do
|
|
17
|
+
confirmed = read_confirmation(cancel_on_quit: cancel_on_quit)
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
confirmed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read_confirmation(cancel_on_quit: true)
|
|
24
|
+
loop do
|
|
25
|
+
input = KeyInput.getch.downcase
|
|
26
|
+
return true if input == 'y'
|
|
27
|
+
return false if CONFIRMATION_CANCEL_KEYS.include?(input) || (cancel_on_quit && input == 'q')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def show_overlay_dialog(title, content_lines, options = {}, &block)
|
|
32
|
+
use_overlay = @terminal_ui &&
|
|
33
|
+
@terminal_ui.respond_to?(:screen) &&
|
|
34
|
+
@terminal_ui.respond_to?(:renderer) &&
|
|
35
|
+
@terminal_ui.screen &&
|
|
36
|
+
@terminal_ui.renderer
|
|
37
|
+
|
|
38
|
+
if use_overlay
|
|
39
|
+
@terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
|
|
40
|
+
else
|
|
41
|
+
width = options[:width]
|
|
42
|
+
height = options[:height]
|
|
43
|
+
|
|
44
|
+
unless width && height
|
|
45
|
+
width, height = @dialog_renderer.calculate_dimensions(content_lines, {
|
|
46
|
+
title: title,
|
|
47
|
+
min_width: options[:min_width] || 40,
|
|
48
|
+
max_width: options[:max_width] || 80
|
|
49
|
+
})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
x, y = @dialog_renderer.calculate_center(width, height)
|
|
53
|
+
|
|
54
|
+
@dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
|
|
55
|
+
border_color: options[:border_color] || "\e[37m",
|
|
56
|
+
title_color: options[:title_color] || "\e[1;33m",
|
|
57
|
+
content_color: options[:content_color] || "\e[37m"
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
key = block_given? ? yield : KeyInput.getch
|
|
61
|
+
|
|
62
|
+
@dialog_renderer.clear_area(x, y, width, height)
|
|
63
|
+
@terminal_ui&.refresh_display
|
|
64
|
+
|
|
65
|
+
key
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pdf/reader'
|
|
4
|
+
require 'csv'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'roo'
|
|
7
|
+
require 'zip'
|
|
8
|
+
require 'nokogiri'
|
|
9
|
+
require 'pathname'
|
|
10
|
+
require 'uri'
|
|
11
|
+
|
|
12
|
+
module Rufio
|
|
13
|
+
# 文書内の文字列を、元文書の位置情報付きで列挙する。外部コマンドは実行しない。
|
|
14
|
+
class DocumentTextExtractor
|
|
15
|
+
class ExtractionError < StandardError; end
|
|
16
|
+
|
|
17
|
+
EXTENSIONS = %w[.pdf .xlsx .pptx].freeze
|
|
18
|
+
DRAWING_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
|
19
|
+
PART_URI_PARSER = URI::RFC2396_Parser.new
|
|
20
|
+
|
|
21
|
+
def supported?(path)
|
|
22
|
+
EXTENSIONS.include?(File.extname(path).downcase)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each_record(path)
|
|
26
|
+
return enum_for(:each_record, path) unless block_given?
|
|
27
|
+
|
|
28
|
+
case File.extname(path).downcase
|
|
29
|
+
when '.pdf' then each_pdf_record(path) { |record| yield record }
|
|
30
|
+
when '.xlsx' then each_excel_record(path) { |record| yield record }
|
|
31
|
+
when '.pptx' then each_powerpoint_record(path) { |record| yield record }
|
|
32
|
+
else raise ExtractionError, "Unsupported document: #{File.extname(path)}"
|
|
33
|
+
end
|
|
34
|
+
rescue ExtractionError
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise ExtractionError, "#{File.basename(path)}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def each_pdf_record(path)
|
|
43
|
+
File.open(path, 'rb') do |io|
|
|
44
|
+
reader = PDF::Reader.new(io)
|
|
45
|
+
reader.pages.each_with_index do |page, index|
|
|
46
|
+
page.text.each_line do |text|
|
|
47
|
+
next if text.strip.empty?
|
|
48
|
+
|
|
49
|
+
yield(text: text.chomp, document_type: :pdf, page: index + 1, location: "Page #{index + 1}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each_excel_record(path)
|
|
56
|
+
workbook = Roo::Excelx.new(path)
|
|
57
|
+
workbook.sheets.each do |name|
|
|
58
|
+
workbook.sheet(name).each_row_streaming do |row|
|
|
59
|
+
row.compact.each do |cell|
|
|
60
|
+
value = cell.value
|
|
61
|
+
next if value.nil? || value.to_s.empty?
|
|
62
|
+
|
|
63
|
+
row_number, column = cell.coordinate
|
|
64
|
+
address = "#{Roo::Utils.number_to_letter(column)}#{row_number}"
|
|
65
|
+
yield(text: value.to_s, document_type: :xlsx, sheet: name, cell: address, location: "#{name}!#{address}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
ensure
|
|
70
|
+
workbook.close if workbook
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def read_xml(zip, part)
|
|
74
|
+
entry = zip.find_entry(part)
|
|
75
|
+
raise ExtractionError, "Missing document part: #{part}" unless entry
|
|
76
|
+
|
|
77
|
+
xml = entry.get_input_stream { |stream| stream.read }
|
|
78
|
+
Nokogiri::XML(xml) { |config| config.strict.nonet }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def each_powerpoint_record(path)
|
|
82
|
+
Zip::File.open(path) do |zip|
|
|
83
|
+
presentation = read_xml(zip, 'ppt/presentation.xml')
|
|
84
|
+
relationships = read_xml(zip, 'ppt/_rels/presentation.xml.rels')
|
|
85
|
+
slides = relationships.xpath('//*[local-name()="Relationship"]').each_with_object({}) do |node, result|
|
|
86
|
+
next unless node['Type'].to_s.end_with?('/slide') && node['TargetMode'] != 'External'
|
|
87
|
+
|
|
88
|
+
target = PART_URI_PARSER.unescape(node['Target'].to_s)
|
|
89
|
+
part = target.start_with?('/') ? target.delete_prefix('/') : "ppt/#{target}"
|
|
90
|
+
part = Pathname.new(part).cleanpath.to_s
|
|
91
|
+
raise ExtractionError, 'Invalid slide path' unless part.start_with?('ppt/')
|
|
92
|
+
|
|
93
|
+
result[node['Id']] = part
|
|
94
|
+
end
|
|
95
|
+
presentation.xpath('//*[local-name()="sldIdLst"]/*[local-name()="sldId"]').each_with_index do |node, index|
|
|
96
|
+
relationship = node.attribute_nodes.find { |attribute| attribute.name == 'id' && attribute.namespace }
|
|
97
|
+
part = slides[relationship&.value]
|
|
98
|
+
raise ExtractionError, 'Missing slide relationship' unless part
|
|
99
|
+
|
|
100
|
+
slide = read_xml(zip, part)
|
|
101
|
+
slide.xpath('//a:p', 'a' => DRAWING_NS).each do |paragraph|
|
|
102
|
+
text = paragraph.xpath('.//a:t | .//a:br', 'a' => DRAWING_NS).map do |fragment|
|
|
103
|
+
fragment.name == 'br' ? "\n" : fragment.text
|
|
104
|
+
end.join
|
|
105
|
+
next if text.strip.empty?
|
|
106
|
+
|
|
107
|
+
yield(text: text, document_type: :pptx, slide: index + 1, location: "Slide #{index + 1}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 検索語を変えるたびに文書を再解析しないための、容量制限付きメモリキャッシュ。
|
|
115
|
+
# 完了した抽出のみ保持し、更新・置換されたファイルは再抽出する。
|
|
116
|
+
class DocumentTextCache
|
|
117
|
+
def initialize(extractor: DocumentTextExtractor.new, max_entries: 64, max_bytes: 16 * 1024 * 1024)
|
|
118
|
+
@extractor = extractor
|
|
119
|
+
@max_entries = max_entries
|
|
120
|
+
@max_bytes = max_bytes
|
|
121
|
+
@cache = {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def each_record(path)
|
|
125
|
+
return enum_for(:each_record, path) unless block_given?
|
|
126
|
+
|
|
127
|
+
key = File.expand_path(path)
|
|
128
|
+
before = signature(path)
|
|
129
|
+
cached = @cache.delete(key)
|
|
130
|
+
if cached && cached[:signature] == before
|
|
131
|
+
@cache[key] = cached
|
|
132
|
+
cached[:records].each { |record| yield record }
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
records = []
|
|
137
|
+
bytes = 0
|
|
138
|
+
@extractor.each_record(path) do |record|
|
|
139
|
+
record = record.merge(search_text: record[:text].downcase).transform_values do |value|
|
|
140
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
141
|
+
end.freeze
|
|
142
|
+
bytes += record.values.sum { |value| value.is_a?(String) ? value.bytesize : 16 } + 64
|
|
143
|
+
if bytes <= @max_bytes
|
|
144
|
+
records << record if records
|
|
145
|
+
else
|
|
146
|
+
records = nil
|
|
147
|
+
end
|
|
148
|
+
yield record
|
|
149
|
+
end
|
|
150
|
+
return unless records && @max_entries.positive? && signature(path) == before
|
|
151
|
+
|
|
152
|
+
@cache.shift while !@cache.empty? && (@cache.length >= @max_entries || cache_bytes + bytes > @max_bytes)
|
|
153
|
+
@cache[key] = { signature: before, records: records.freeze, bytes: bytes }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def cache_bytes
|
|
159
|
+
@cache.values.sum { |entry| entry[:bytes] }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def signature(path)
|
|
163
|
+
stat = File.stat(path)
|
|
164
|
+
[stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|