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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. 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
- # Default options
24
- border_color = options[:border_color] || "\e[37m" # White
25
- title_color = options[:title_color] || "\e[1;33m" # Bold yellow
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
- border_color = options[:border_color] || "\e[37m"
92
- title_color = options[:title_color] || "\e[1;33m"
93
- content_color = options[:content_color] || "\e[37m"
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
- # Draw top border
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
- title_width = TextUtils.display_width(title)
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
- content_lines.each_with_index do |line, index|
184
- break if index >= content_height
185
-
57
+ content_height.times do |index|
186
58
  line_y = content_start_y + index
187
- line_content = TextUtils.pad_string_to_width(line, width - 2)
188
- print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
189
- end
190
-
191
- # Fill remaining lines with empty space
192
- remaining_lines = content_height - content_lines.length
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}└#{'─' * (width - 2)}┘#{reset_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 = STDIN.getch
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