shoko 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.
Files changed (294) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +4 -0
  3. data/.bundle/config.bak +3 -0
  4. data/.rspec_status +42 -0
  5. data/.rubocop.yml +124 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE +21 -0
  8. data/README.md +82 -0
  9. data/Rakefile +29 -0
  10. data/bin/start +15 -0
  11. data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
  12. data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
  13. data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
  14. data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
  15. data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
  16. data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
  17. data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
  18. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
  19. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
  20. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
  21. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
  22. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
  23. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
  24. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
  25. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
  26. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
  27. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
  28. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
  29. data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
  30. data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
  31. data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
  32. data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
  33. data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
  34. data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
  35. data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
  36. data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
  37. data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
  38. data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
  39. data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
  40. data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
  41. data/lib/shoko/adapters/input/command_bridge.rb +148 -0
  42. data/lib/shoko/adapters/input/command_factory.rb +255 -0
  43. data/lib/shoko/adapters/input/commands.rb +60 -0
  44. data/lib/shoko/adapters/input/dispatcher.rb +69 -0
  45. data/lib/shoko/adapters/input/input_controller.rb +250 -0
  46. data/lib/shoko/adapters/input/key_definitions.rb +108 -0
  47. data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
  48. data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
  49. data/lib/shoko/adapters/monitoring/logger.rb +150 -0
  50. data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
  51. data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
  52. data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
  53. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
  54. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
  55. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
  56. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
  57. data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
  58. data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
  59. data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
  60. data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
  61. data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
  62. data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
  63. data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
  64. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
  65. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
  66. data/lib/shoko/adapters/output/notification_service.rb +58 -0
  67. data/lib/shoko/adapters/output/render_registry.rb +45 -0
  68. data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
  69. data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
  70. data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
  71. data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
  72. data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
  73. data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
  74. data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
  75. data/lib/shoko/adapters/output/terminal/input.rb +161 -0
  76. data/lib/shoko/adapters/output/terminal/output.rb +105 -0
  77. data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
  78. data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
  79. data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
  80. data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
  81. data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
  82. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
  83. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
  84. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
  85. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
  86. data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
  87. data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
  88. data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
  89. data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
  90. data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
  91. data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
  92. data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
  93. data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
  94. data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
  95. data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
  96. data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
  97. data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
  98. data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
  99. data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
  100. data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
  101. data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
  102. data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
  103. data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
  104. data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
  105. data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
  106. data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
  107. data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
  108. data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
  109. data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
  110. data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
  111. data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
  112. data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
  113. data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
  114. data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
  115. data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
  116. data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
  117. data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
  118. data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
  119. data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
  120. data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
  121. data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
  122. data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
  123. data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
  124. data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
  125. data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
  126. data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
  127. data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
  128. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
  129. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
  130. data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
  131. data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
  132. data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
  133. data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
  134. data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
  135. data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
  136. data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
  137. data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
  138. data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
  139. data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
  140. data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
  141. data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
  142. data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
  143. data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
  144. data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
  145. data/lib/shoko/adapters/storage/background_worker.rb +66 -0
  146. data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
  147. data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
  148. data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
  149. data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
  150. data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
  151. data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
  152. data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
  153. data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
  154. data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
  155. data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
  156. data/lib/shoko/adapters/storage/config_paths.rb +30 -0
  157. data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
  158. data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
  159. data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
  160. data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
  161. data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
  162. data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
  163. data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
  164. data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
  165. data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
  166. data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
  167. data/lib/shoko/adapters/storage/recent_files.rb +78 -0
  168. data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
  169. data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
  170. data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
  171. data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
  172. data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
  173. data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
  174. data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
  175. data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
  176. data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
  177. data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
  178. data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
  179. data/lib/shoko/application/cli.rb +134 -0
  180. data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
  181. data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
  182. data/lib/shoko/application/controllers/menu_controller.rb +469 -0
  183. data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
  184. data/lib/shoko/application/controllers/reader_controller.rb +449 -0
  185. data/lib/shoko/application/controllers/state_controller.rb +410 -0
  186. data/lib/shoko/application/controllers/ui_controller.rb +782 -0
  187. data/lib/shoko/application/dependency_container.rb +301 -0
  188. data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
  189. data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
  190. data/lib/shoko/application/infrastructure/state_store.rb +413 -0
  191. data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
  192. data/lib/shoko/application/pending_jump_handler.rb +122 -0
  193. data/lib/shoko/application/reader_lifecycle.rb +65 -0
  194. data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
  195. data/lib/shoko/application/selectors/config_selectors.rb +62 -0
  196. data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
  197. data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
  198. data/lib/shoko/application/state/actions/base_action.rb +24 -0
  199. data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
  200. data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
  201. data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
  202. data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
  203. data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
  204. data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
  205. data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
  206. data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
  207. data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
  208. data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
  209. data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
  210. data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
  211. data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
  212. data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
  213. data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
  214. data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
  215. data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
  216. data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
  217. data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
  218. data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
  219. data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
  220. data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
  221. data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
  222. data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
  223. data/lib/shoko/application/unified_application.rb +48 -0
  224. data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
  225. data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
  226. data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
  227. data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
  228. data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
  229. data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
  230. data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
  231. data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
  232. data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
  233. data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
  234. data/lib/shoko/application/use_cases/settings_service.rb +123 -0
  235. data/lib/shoko/core/events/annotation_events.rb +94 -0
  236. data/lib/shoko/core/events/base_domain_event.rb +169 -0
  237. data/lib/shoko/core/events/bookmark_events.rb +41 -0
  238. data/lib/shoko/core/events/domain_event_bus.rb +163 -0
  239. data/lib/shoko/core/events/progress_events.rb +108 -0
  240. data/lib/shoko/core/models/bookmark.rb +36 -0
  241. data/lib/shoko/core/models/bookmark_data.rb +10 -0
  242. data/lib/shoko/core/models/chapter.rb +25 -0
  243. data/lib/shoko/core/models/content_block.rb +44 -0
  244. data/lib/shoko/core/models/reader_settings.rb +20 -0
  245. data/lib/shoko/core/models/selection_anchor.rb +73 -0
  246. data/lib/shoko/core/models/toc_entry.rb +14 -0
  247. data/lib/shoko/core/ports/annotation_repository.rb +0 -0
  248. data/lib/shoko/core/ports/book_repository.rb +0 -0
  249. data/lib/shoko/core/ports/book_source.rb +0 -0
  250. data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
  251. data/lib/shoko/core/ports/cache.rb +0 -0
  252. data/lib/shoko/core/ports/input_handler.rb +0 -0
  253. data/lib/shoko/core/ports/renderer.rb +0 -0
  254. data/lib/shoko/core/ports/storage.rb +0 -0
  255. data/lib/shoko/core/services/annotation_service.rb +102 -0
  256. data/lib/shoko/core/services/base_service.rb +60 -0
  257. data/lib/shoko/core/services/bookmark_service.rb +267 -0
  258. data/lib/shoko/core/services/coordinate_service.rb +265 -0
  259. data/lib/shoko/core/services/layout_service.rb +95 -0
  260. data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
  261. data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
  262. data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
  263. data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
  264. data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
  265. data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
  266. data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
  267. data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
  268. data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
  269. data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
  270. data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
  271. data/lib/shoko/core/services/navigation_service.rb +150 -0
  272. data/lib/shoko/core/services/page_calculator_service.rb +242 -0
  273. data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
  274. data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
  275. data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
  276. data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
  277. data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
  278. data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
  279. data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
  280. data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
  281. data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
  282. data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
  283. data/lib/shoko/core/services/pagination.rb +10 -0
  284. data/lib/shoko/core/services/progress_helper.rb +22 -0
  285. data/lib/shoko/core/services/selection_service.rb +126 -0
  286. data/lib/shoko/core/validator.rb +76 -0
  287. data/lib/shoko/shared/errors.rb +97 -0
  288. data/lib/shoko/shared/version.rb +5 -0
  289. data/lib/shoko/test_support/terminal_double.rb +175 -0
  290. data/lib/shoko/test_support/test_mode.rb +78 -0
  291. data/lib/shoko.rb +279 -0
  292. data/lib/zip.rb +732 -0
  293. data/zip.rb +5 -0
  294. metadata +370 -0
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../core/services/base_service.rb'
4
+ require_relative '../../../core/services/pagination/internal/chapter_cache.rb'
5
+ require_relative '../terminal/text_metrics.rb'
6
+
7
+ module Shoko
8
+ module Adapters
9
+ module Output
10
+ module Formatting
11
+ # Service responsible for wrapping chapter lines to a column width.
12
+ # Uses the shared ChapterCache to avoid recomputation across frames.
13
+ class WrappingService < BaseService
14
+ WINDOW_CACHE_LIMIT = 200
15
+
16
+ def initialize(dependencies)
17
+ super
18
+ @chapter_cache = Shoko::Core::Services::Pagination::Internal::ChapterCache.new
19
+ @window_cache = Hash.new { |h, k| h[k] = { store: {}, order: [] } }
20
+ end
21
+
22
+ # Wrap raw lines for a chapter to the given width.
23
+ # Falls back to a local wrapper if cache is unavailable.
24
+ #
25
+ # @param lines [Array<String>] raw chapter lines
26
+ # @param chapter_index [Integer] chapter index for caching keying
27
+ # @param width [Integer] column width
28
+ # @return [Array<String>] wrapped lines
29
+ def wrap_lines(lines, chapter_index, width)
30
+ return [] if lines.nil? || width.to_i < 10
31
+
32
+ formatted = fetch_formatted_lines(chapter_index, width, 0, lines.length)
33
+ return formatted if formatted
34
+
35
+ cache = begin
36
+ registered?(:chapter_cache) ? resolve(:chapter_cache) : @chapter_cache
37
+ rescue StandardError
38
+ @chapter_cache
39
+ end
40
+ cache.get_wrapped_lines(chapter_index, lines, width)
41
+ end
42
+
43
+ # Wrap only a window of text sufficient for immediate display.
44
+ # This avoids wrapping the entire chapter on first render.
45
+ #
46
+ # @param lines [Array<String>] raw chapter lines
47
+ # @param chapter_index [Integer] chapter index (for caching semantics if needed)
48
+ # @param width [Integer] column width
49
+ # @param start [Integer] wrapped-lines start offset
50
+ # @param length [Integer] number of wrapped lines to return
51
+ # @return [Array<String>] slice of wrapped lines covering the requested window
52
+ def wrap_window(lines, chapter_index, width, start, length)
53
+ width_i = width.to_i
54
+ length_i = length.to_i
55
+ start_i = start.to_i
56
+ return [] if lines.nil? || width_i <= 0 || length_i <= 0
57
+
58
+ formatted = fetch_formatted_lines(chapter_index, width_i, start_i, length_i)
59
+ return formatted if formatted
60
+
61
+ target_end = [start_i, 0].max + length_i - 1
62
+ key = [lines.object_id, chapter_index, width_i]
63
+ cached = @window_cache[key][:store][[start_i, length_i]]
64
+ return cached if cached
65
+
66
+ wrapped = []
67
+
68
+ lines.each do |line|
69
+ break if wrapped.length >= (target_end + 1)
70
+
71
+ next if line.nil?
72
+
73
+ if line.strip.empty?
74
+ wrapped << ''
75
+ next
76
+ end
77
+
78
+ segments = Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(line, width_i)
79
+ wrapped.concat(segments)
80
+ end
81
+
82
+ start_index = [start_i, 0].max
83
+ return [] if start_index >= wrapped.length
84
+
85
+ slice = wrapped[start_index, length_i] || []
86
+ cache_put(key, [start_i, length_i], slice)
87
+ slice
88
+ end
89
+
90
+ def prefetch_windows(lines, chapter_index, width, start, length)
91
+ wrap_window(lines, chapter_index, width, start, length)
92
+ end
93
+
94
+ # Wrap the visible window and prefetch ±N pages around it in the background.
95
+ # This centralizes the behavior that was previously embedded in ReaderController.
96
+ #
97
+ # @param doc [Object] document responding to #get_chapter(index)
98
+ # @param chapter_index [Integer]
99
+ # @param col_width [Integer]
100
+ # @param offset [Integer] wrapped-line offset
101
+ # @param display_height [Integer] lines per page
102
+ # @param pre_pages [Integer,nil] optional number of pages to prefetch; defaults from config
103
+ # @return [Array<String>] visible wrapped lines for the requested window
104
+ def fetch_window_and_prefetch(doc, chapter_index, col_width, offset, display_height,
105
+ pre_pages = nil)
106
+ return [] unless doc && display_height.to_i.positive?
107
+
108
+ chapter = doc.get_chapter(chapter_index)
109
+ return [] unless chapter
110
+
111
+ lines = chapter.lines || []
112
+ start_i = [offset.to_i, 0].max
113
+ length_i = display_height.to_i
114
+
115
+ visible = wrap_window(lines, chapter_index, col_width, start_i, length_i)
116
+
117
+ begin
118
+ pages = pre_pages
119
+ if pages.nil?
120
+ st = resolve(:state_store) if registered?(:state_store)
121
+ pages = begin
122
+ st.get(%i[config prefetch_pages]) if st.respond_to?(:get)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ pages = pages.nil? ? 20 : pages.to_i
128
+ pages = pages.clamp(0, 200)
129
+ window = pages * length_i
130
+ prefetch_start = [start_i - window, 0].max
131
+ prefetch_end = start_i + window + (length_i - 1)
132
+ prefetch_len = prefetch_end - prefetch_start + 1
133
+ enqueue_prefetch(chapter_index, col_width, prefetch_start, prefetch_len, lines)
134
+ rescue StandardError
135
+ # best-effort prefetch
136
+ end
137
+
138
+ visible
139
+ end
140
+
141
+ # Clear all cached wrapped lines
142
+ def clear_cache
143
+ @chapter_cache = Shoko::Core::Services::Pagination::Internal::ChapterCache.new
144
+ @window_cache.clear
145
+ end
146
+
147
+ # Clear cache entries for a given width
148
+ def clear_cache_for_width(width)
149
+ width_i = width.to_i
150
+ @chapter_cache.clear_cache_for_width(width_i)
151
+ @window_cache.delete_if do |key, _|
152
+ key.is_a?(Array) ? key[2] == width_i : key.to_s.end_with?("_#{width_i}")
153
+ end
154
+ end
155
+
156
+ protected
157
+
158
+ def required_dependencies
159
+ []
160
+ end
161
+
162
+ private
163
+
164
+ # No-op private helpers retained for compatibility-free interface.
165
+
166
+ def cache_put(key, subkey, value)
167
+ entry = @window_cache[key]
168
+ store = entry[:store]
169
+ order = entry[:order]
170
+ unless store.key?(subkey)
171
+ order << subkey
172
+ if order.length > WINDOW_CACHE_LIMIT
173
+ oldest = order.shift
174
+ store.delete(oldest)
175
+ end
176
+ end
177
+ store[subkey] = value
178
+ end
179
+
180
+ def fetch_formatted_lines(chapter_index, width, offset, length)
181
+ return unless registered?(:formatting_service)
182
+
183
+ document = begin
184
+ resolve(:document)
185
+ rescue StandardError
186
+ nil
187
+ end
188
+ return unless document
189
+
190
+ formatting = resolve(:formatting_service)
191
+ lines = formatting.wrap_window(document, chapter_index, width, offset: offset, length: length)
192
+ return unless lines && !lines.empty?
193
+
194
+ lines.map { |line| line.respond_to?(:text) ? line.text : line }
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ def enqueue_prefetch(chapter_index, col_width, prefetch_start, prefetch_len, lines)
200
+ worker = background_worker
201
+ job = lambda do
202
+ prefetch_windows(lines, chapter_index, col_width, prefetch_start, prefetch_len)
203
+ end
204
+ if worker
205
+ worker.submit(&job)
206
+ else
207
+ Thread.new do
208
+ job.call
209
+ rescue StandardError
210
+ # ignore background failures
211
+ end
212
+ end
213
+ rescue StandardError
214
+ # ignore background failures
215
+ end
216
+
217
+ def background_worker
218
+ return nil unless registered?(:background_worker)
219
+
220
+ resolve(:background_worker)
221
+ rescue StandardError
222
+ nil
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../core/services/base_service.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Output
7
+ # Provides a single facade for performance monitoring and tracing so that
8
+ # higher layers do not talk to infrastructure modules directly.
9
+ class InstrumentationService < BaseService
10
+ def initialize(dependencies)
11
+ super
12
+ @monitor = resolve_optional(:performance_monitor)
13
+ @tracer = resolve_optional(:perf_tracer)
14
+ end
15
+
16
+ def time(metric, &)
17
+ raise ArgumentError, 'block required for #time' unless block_given?
18
+ return yield unless @monitor.respond_to?(:time)
19
+
20
+ @monitor.time(metric, &)
21
+ end
22
+
23
+ def record_metric(name, value, count = 1)
24
+ @monitor&.record_metric(name, value, count)
25
+ end
26
+
27
+ def record_trace(metric, value)
28
+ @tracer&.record(metric, value)
29
+ end
30
+
31
+ def complete_trace(**payload)
32
+ @tracer&.complete(**payload)
33
+ end
34
+
35
+ def cancel_trace
36
+ @tracer&.cancel
37
+ end
38
+
39
+ def start_trace(path)
40
+ @tracer.respond_to?(:start_open) ? @tracer.start_open(path) : nil
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_optional(name)
46
+ resolve(name)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Kitty
7
+ # Converts common raster image formats to PNG bytes so they can be rendered
8
+ # by the Kitty graphics protocol.
9
+ #
10
+ # This is invoked only when Kitty images are enabled and an image is about
11
+ # to be rendered; it is not part of the default import pipeline.
12
+ class ImageTranscoder
13
+ PNG_HEADER = "\x89PNG\r\n\x1a\n".b
14
+
15
+ def initialize(command: nil)
16
+ @command = command || default_command
17
+ end
18
+
19
+ def available?
20
+ !@command.nil?
21
+ end
22
+
23
+ # @param bytes [String] original image bytes
24
+ # @return [String,nil] PNG bytes
25
+ def to_png(bytes)
26
+ data = String(bytes).dup
27
+ data.force_encoding(Encoding::BINARY)
28
+ return nil if data.empty?
29
+ return data if png?(data)
30
+ return nil unless available?
31
+
32
+ stdout, _stderr, status = Open3.capture3(*@command, stdin_data: data)
33
+ return nil unless status.success?
34
+
35
+ out = String(stdout).dup
36
+ out.force_encoding(Encoding::BINARY)
37
+ return nil unless png?(out)
38
+
39
+ out
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def png?(bytes)
47
+ bytes.to_s.b.start_with?(PNG_HEADER)
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ def default_command
53
+ return ['magick', '-', 'png:-'] if executable_in_path?('magick')
54
+ return ['convert', '-', 'png:-'] if executable_in_path?('convert')
55
+
56
+ nil
57
+ end
58
+
59
+ def executable_in_path?(name)
60
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
61
+ next false if dir.to_s.empty?
62
+
63
+ path = File.join(dir, name)
64
+ File.file?(path) && File.executable?(path)
65
+ end
66
+ rescue StandardError
67
+ false
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Kitty
7
+ # Minimal support for the Kitty graphics protocol.
8
+ #
9
+ # This module intentionally keeps all logic in-process (no external commands)
10
+ # and suppresses terminal responses (q=2) to avoid corrupting raw input reads.
11
+ module KittyGraphics
12
+ module_function
13
+
14
+ ESC = "\e"
15
+ APC_START = "#{ESC}_G".freeze
16
+ APC_END = "#{ESC}\\".freeze
17
+ MAX_CHUNK_BYTES = 4096
18
+
19
+ def supported?
20
+ return true if env_present?('KITTY_WINDOW_ID')
21
+ return true if ENV.fetch('TERM', '').include?('kitty')
22
+ return true if ENV.fetch('TERM_PROGRAM', '') == 'kitty'
23
+
24
+ false
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ def enabled_for?(config_store)
30
+ return false unless supported?
31
+ return false unless config_store.respond_to?(:get)
32
+
33
+ !!config_store.get(%i[config kitty_images])
34
+ rescue StandardError
35
+ false
36
+ end
37
+
38
+ def transmit_png(image_id, png_bytes, quiet: true)
39
+ bytes = String(png_bytes).dup
40
+ bytes.force_encoding(Encoding::BINARY)
41
+ payload = Base64.strict_encode64(bytes)
42
+ chunks = chunk_payload(payload)
43
+ chunks.map.with_index do |chunk, index|
44
+ more = index < chunks.length - 1 ? 1 : 0
45
+ keys = if index.zero?
46
+ { a: 't', f: 100, t: 'd', i: image_id.to_i, m: more }
47
+ else
48
+ { m: more }
49
+ end
50
+ keys[:q] = 2 if quiet
51
+ control = serialize_keys(keys)
52
+ "#{APC_START}#{control};#{chunk}#{APC_END}"
53
+ end
54
+ end
55
+
56
+ def place(image_id, placement_id:, cols:, rows:, quiet: true, **options)
57
+ z = options.fetch(:z, nil)
58
+ keys = {
59
+ a: 'p',
60
+ i: image_id.to_i,
61
+ p: placement_id.to_i,
62
+ c: cols.to_i,
63
+ r: rows.to_i,
64
+ C: 1,
65
+ }
66
+ keys[:q] = 2 if quiet
67
+ keys[:z] = z.to_i if z
68
+ "#{APC_START}#{serialize_keys(keys)}#{APC_END}"
69
+ end
70
+
71
+ def virtual_place(image_id, cols:, rows:, placement_id: nil, quiet: true, **options)
72
+ z = options.fetch(:z, nil)
73
+ keys = {
74
+ a: 'p',
75
+ U: 1,
76
+ i: image_id.to_i,
77
+ p: placement_id.to_i,
78
+ c: cols.to_i,
79
+ r: rows.to_i,
80
+ C: 1,
81
+ }
82
+ keys.delete(:p) if placement_id.to_i <= 0
83
+ keys[:q] = 2 if quiet
84
+ keys[:z] = z.to_i if z
85
+ "#{APC_START}#{serialize_keys(keys)}#{APC_END}"
86
+ end
87
+
88
+ def delete_visible(quiet: true)
89
+ keys = { a: 'd' }
90
+ keys[:q] = 2 if quiet
91
+ "#{APC_START}#{serialize_keys(keys)}#{APC_END}"
92
+ end
93
+
94
+ def chunk_payload(payload)
95
+ return [] if payload.nil? || payload.empty?
96
+
97
+ max = MAX_CHUNK_BYTES
98
+ payload.scan(/.{1,#{max}}/m)
99
+ end
100
+ private_class_method :chunk_payload
101
+
102
+ def serialize_keys(hash)
103
+ hash.map { |k, v| "#{k}=#{v}" }.join(',')
104
+ end
105
+ private_class_method :serialize_keys
106
+
107
+ def env_present?(key)
108
+ value = ENV[key].to_s
109
+ !value.empty?
110
+ end
111
+ private_class_method :env_present?
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ require_relative '../../book_sources/epub/epub_resource_loader.rb'
6
+ require_relative 'image_transcoder'
7
+ require_relative 'kitty_graphics'
8
+
9
+ module Shoko
10
+ module Adapters::Output::Kitty
11
+ # Stateful renderer that transmits images once per session and then places
12
+ # them on screen using the Kitty graphics protocol.
13
+ class KittyImageRenderer
14
+ MAX_ID = 4_294_967_295
15
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b
16
+ DEFAULT_CELL_ASPECT = 0.5 # width/height ratio for typical terminal cells
17
+
18
+ def initialize(resource_loader: EpubResourceLoader.new, transcoder: ImageTranscoder.new)
19
+ @resource_loader = resource_loader
20
+ @transcoder = transcoder
21
+ @transmitted = {}
22
+ @dimensions = {}
23
+ end
24
+
25
+ def enabled?(config_store)
26
+ KittyGraphics.enabled_for?(config_store)
27
+ rescue StandardError
28
+ false
29
+ end
30
+
31
+ def render(output:, book_sha:, epub_path:, chapter_entry_path:, src:, row:, col:, cols:, rows:,
32
+ placement_id:, **options)
33
+ z = options.fetch(:z, nil)
34
+ return false unless output
35
+ return false unless epub_path && File.file?(epub_path)
36
+
37
+ entry_path = EpubResourceLoader.resolve_chapter_relative(chapter_entry_path, src)
38
+ return false unless entry_path
39
+
40
+ image_id = image_id_for(book_sha, epub_path, entry_path)
41
+ return false unless ensure_transmitted(output, image_id, book_sha, epub_path, entry_path)
42
+
43
+ fit = fit_geometry(image_id, cols.to_i, rows.to_i)
44
+
45
+ place_seq = KittyGraphics.place(image_id,
46
+ placement_id: clamp_id(placement_id),
47
+ cols: fit[:cols],
48
+ rows: fit[:rows],
49
+ quiet: true,
50
+ z: z)
51
+ abs_row = row.to_i + fit[:row_offset]
52
+ abs_col = col.to_i + fit[:col_offset]
53
+ if output.respond_to?(:raw)
54
+ output.raw(TerminalOutput::ANSI.move(abs_row, abs_col) + place_seq)
55
+ elsif output.respond_to?(:print)
56
+ output.print(TerminalOutput::ANSI.move(abs_row, abs_col) + place_seq)
57
+ elsif output.respond_to?(:write)
58
+ output.write(abs_row, abs_col, place_seq)
59
+ else
60
+ return false
61
+ end
62
+ true
63
+ rescue StandardError
64
+ false
65
+ end
66
+
67
+ # Ensure the image is transmitted and has a virtual placement for Unicode placeholders.
68
+ # Returns the image_id on success, otherwise nil.
69
+ def prepare_virtual(output:, book_sha:, epub_path:, chapter_entry_path:, src:, cols:, rows:, placement_id: nil,
70
+ **options)
71
+ z = options.fetch(:z, nil)
72
+ return nil unless output
73
+ return nil unless epub_path && File.file?(epub_path)
74
+
75
+ entry_path = EpubResourceLoader.resolve_chapter_relative(chapter_entry_path, src)
76
+ return nil unless entry_path
77
+
78
+ image_id = image_id_for(book_sha, epub_path, entry_path)
79
+ return nil unless ensure_transmitted(output, image_id, book_sha, epub_path, entry_path)
80
+ return nil unless ensure_virtual_placement(output,
81
+ image_id,
82
+ cols.to_i,
83
+ rows.to_i,
84
+ placement_id: placement_id,
85
+ z: z)
86
+
87
+ image_id
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ def ensure_transmitted(output, image_id, book_sha, epub_path, entry_path)
95
+ return :cached if @transmitted[image_id]
96
+
97
+ cache_key = png_cache_key(entry_path)
98
+ bytes = @resource_loader.fetch(book_sha: book_sha,
99
+ epub_path: epub_path,
100
+ entry_path: entry_path,
101
+ cache_key: cache_key,
102
+ persist: false)
103
+ png_bytes = @transcoder.to_png(bytes)
104
+ return nil unless png_bytes
105
+
106
+ dims = png_dimensions(png_bytes)
107
+ @dimensions[image_id] = dims if dims
108
+
109
+ @resource_loader.store(book_sha: book_sha, entry_path: cache_key, bytes: png_bytes)
110
+
111
+ KittyGraphics.transmit_png(image_id, png_bytes, quiet: true).each do |seq|
112
+ emit_raw(output, seq)
113
+ end
114
+
115
+ @transmitted[image_id] = true
116
+ :transmitted
117
+ end
118
+
119
+ def ensure_virtual_placement(output, image_id, cols, rows, placement_id: nil, **options)
120
+ z = options.fetch(:z, nil)
121
+ cols_i = cols.to_i
122
+ rows_i = rows.to_i
123
+ return false if cols_i <= 0 || rows_i <= 0
124
+
125
+ place_id = placement_id.to_i
126
+ place_id = clamp_id(place_id) if place_id.positive?
127
+ seq = KittyGraphics.virtual_place(image_id,
128
+ cols: cols_i,
129
+ rows: rows_i,
130
+ placement_id: place_id,
131
+ quiet: true,
132
+ z: z)
133
+ emit_raw(output, seq)
134
+ true
135
+ rescue StandardError
136
+ false
137
+ end
138
+
139
+ def emit_raw(output, seq)
140
+ if output.respond_to?(:raw)
141
+ output.raw(seq)
142
+ elsif output.respond_to?(:print)
143
+ output.print(seq)
144
+ elsif output.respond_to?(:write)
145
+ output.write(1, 1, seq)
146
+ end
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def fit_geometry(image_id, max_cols, max_rows)
152
+ cols_i = max_cols.to_i
153
+ rows_i = max_rows.to_i
154
+ cols_i = 1 if cols_i <= 0
155
+ rows_i = 1 if rows_i <= 0
156
+
157
+ dims = @dimensions[image_id]
158
+ return { cols: cols_i, rows: rows_i, col_offset: 0, row_offset: 0 } unless dims
159
+
160
+ img_w = dims[:width].to_i
161
+ img_h = dims[:height].to_i
162
+ return { cols: cols_i, rows: rows_i, col_offset: 0, row_offset: 0 } if img_w <= 0 || img_h <= 0
163
+
164
+ aspect = img_w.to_f / img_h
165
+ cell_aspect = DEFAULT_CELL_ASPECT
166
+
167
+ cols_for_rows = (rows_i.to_f * aspect / cell_aspect)
168
+ target_cols = cols_for_rows.floor
169
+ target_cols = 1 if target_cols <= 0
170
+
171
+ if target_cols <= cols_i
172
+ fit_cols = target_cols
173
+ fit_rows = rows_i
174
+ else
175
+ fit_cols = cols_i
176
+ rows_for_cols = (cols_i.to_f * cell_aspect / aspect)
177
+ fit_rows = rows_for_cols.floor
178
+ fit_rows = 1 if fit_rows <= 0
179
+ fit_rows = rows_i if fit_rows > rows_i
180
+ end
181
+
182
+ col_offset = ((cols_i - fit_cols) / 2.0).floor
183
+ col_offset = 0 if col_offset.negative?
184
+
185
+ row_offset = 0
186
+
187
+ { cols: fit_cols, rows: fit_rows, col_offset: col_offset, row_offset: row_offset }
188
+ rescue StandardError
189
+ { cols: cols_i, rows: rows_i, col_offset: 0, row_offset: 0 }
190
+ end
191
+
192
+ def png_dimensions(bytes)
193
+ data = bytes.to_s.b
194
+ return nil unless data.start_with?(PNG_SIGNATURE)
195
+
196
+ # PNG signature (8) + length (4) + type (4) + width (4) + height (4)
197
+ return nil unless data.bytesize >= 24
198
+ return nil unless data.byteslice(12, 4) == 'IHDR'
199
+
200
+ width = data.byteslice(16, 4).unpack1('N')
201
+ height = data.byteslice(20, 4).unpack1('N')
202
+ return nil if width.to_i <= 0 || height.to_i <= 0
203
+
204
+ { width: width.to_i, height: height.to_i }
205
+ rescue StandardError
206
+ nil
207
+ end
208
+
209
+ def png_cache_key(entry_path)
210
+ "#{entry_path}|kitty_png_v1"
211
+ rescue StandardError
212
+ "#{entry_path}|kitty_png_v1"
213
+ end
214
+
215
+ def image_id_for(book_sha, epub_path, entry_path)
216
+ seed = "#{book_sha}|#{epub_path}|#{entry_path}"
217
+ hashed_id(seed)
218
+ end
219
+
220
+ def hashed_id(seed)
221
+ raw = Digest::SHA1.digest(seed.to_s)
222
+ int = raw.unpack1('N')
223
+ int.zero? ? 1 : int
224
+ rescue StandardError
225
+ 1
226
+ end
227
+
228
+ def clamp_id(value)
229
+ int = value.to_i
230
+ int = 1 if int <= 0
231
+ if int > MAX_ID
232
+ int %= MAX_ID
233
+ int = 1 if int.zero?
234
+ end
235
+ int
236
+ end
237
+ end
238
+ end
239
+ end