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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../terminal/text_metrics.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Formatting
7
+ class FormattingService
8
+ class LineAssembler
9
+ # Wraps tokens into display lines (including inline image placeholders).
10
+ class TextWrapper
11
+ include Shoko::Core::Models
12
+
13
+ def initialize(width, image_builder:)
14
+ @width = width.to_i
15
+ @image_builder = image_builder
16
+ end
17
+
18
+ def wrap(tokens, metadata:, prefix: nil, continuation_prefix: nil)
19
+ return [] if tokens.empty?
20
+
21
+ first_prefix_tokens, continuation_tokens = prefix_tokens(prefix, continuation_prefix)
22
+ state = LineState.new(first_prefix_tokens, continuation_tokens)
23
+ wrapped = []
24
+
25
+ tokens.each do |token|
26
+ process_token(token, state, metadata, wrapped)
27
+ end
28
+
29
+ append_final_line(state, metadata, wrapped)
30
+ wrapped
31
+ end
32
+
33
+ private
34
+
35
+ def prefix_tokens(prefix, continuation_prefix)
36
+ first = Tokenizer.prefix_tokens(prefix)
37
+ continuation = continuation_prefix_value(prefix, continuation_prefix)
38
+ [first, Tokenizer.prefix_tokens(continuation)]
39
+ end
40
+
41
+ def continuation_prefix_value(prefix, continuation_prefix)
42
+ return continuation_prefix unless continuation_prefix.nil?
43
+
44
+ Tokenizer.prefix_indent(prefix)
45
+ end
46
+
47
+ def process_token(token, state, metadata, wrapped)
48
+ return append_inline_image(token[:inline_image], state, metadata, wrapped) if token[:image]
49
+ return append_newline(state, metadata, wrapped) if token[:newline]
50
+
51
+ append_text(token, state, metadata, wrapped)
52
+ end
53
+
54
+ def append_inline_image(inline, state, metadata, wrapped)
55
+ wrapped << finalize_line(state.tokens, metadata) unless state.tokens.empty?
56
+ wrapped.concat(@image_builder.inline_lines(inline, state.indent_cols))
57
+ state.reset_to_continuation!
58
+ end
59
+
60
+ def append_newline(state, metadata, wrapped)
61
+ wrapped << finalize_line(state.tokens, metadata)
62
+ state.reset_to_continuation!
63
+ end
64
+
65
+ def append_text(token, state, metadata, wrapped)
66
+ token_width = text_width(token[:text])
67
+ if wrap_needed?(state.width, token_width)
68
+ wrapped << finalize_line(state.tokens, metadata)
69
+ state.reset_to_continuation!
70
+ return if token[:text].strip.empty?
71
+ end
72
+
73
+ return if state.tokens.empty? && token[:text].strip.empty?
74
+
75
+ state.tokens << token
76
+ state.width += token_width
77
+ end
78
+
79
+ def append_final_line(state, metadata, wrapped)
80
+ wrapped << finalize_line(state.tokens, metadata) unless state.tokens.empty?
81
+ end
82
+
83
+ def wrap_needed?(current_width, token_width)
84
+ current_width.positive? && current_width + token_width > @width
85
+ end
86
+
87
+ def finalize_line(tokens, metadata)
88
+ DisplayLine.new(
89
+ text: line_text(tokens),
90
+ segments: merge_tokens_into_segments(tokens).reject { |seg| seg.text.empty? },
91
+ metadata: metadata
92
+ )
93
+ end
94
+
95
+ def line_text(tokens)
96
+ tokens.select { |token| token[:text] }.map { |token| token[:text] }.join.rstrip
97
+ end
98
+
99
+ def merge_tokens_into_segments(tokens)
100
+ merged = []
101
+ tokens.each do |token|
102
+ next unless token[:text]
103
+
104
+ styles = token[:styles] || {}
105
+ if merged.empty? || merged.last.styles != styles
106
+ merged << TextSegment.new(text: token[:text], styles: styles)
107
+ else
108
+ merged[-1] = TextSegment.new(text: merged[-1].text + token[:text], styles: styles)
109
+ end
110
+ end
111
+ merged
112
+ end
113
+
114
+ def text_width(text)
115
+ Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(text.to_s)
116
+ end
117
+
118
+ # Tracks the in-progress wrapped line while streaming tokens.
119
+ class LineState
120
+ attr_accessor :tokens, :width
121
+
122
+ def initialize(first_prefix_tokens, continuation_tokens)
123
+ @continuation_tokens = continuation_tokens
124
+ @tokens = first_prefix_tokens.dup
125
+ @width = visible_length(@tokens)
126
+ end
127
+
128
+ def indent_cols
129
+ visible_length(@continuation_tokens)
130
+ end
131
+
132
+ def reset_to_continuation!
133
+ @tokens = @continuation_tokens.dup
134
+ @width = visible_length(@tokens)
135
+ end
136
+
137
+ private
138
+
139
+ def visible_length(tokens)
140
+ tokens
141
+ .select { |token| token[:text] }
142
+ .sum { |token| Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(token[:text]) }
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Formatting
5
+ class FormattingService
6
+ class LineAssembler
7
+ # Turns styled text segments into a stream of wrapping tokens.
8
+ module Tokenizer
9
+ module_function
10
+
11
+ def tokenize(segments, image_rendering:, renderable_image_src:)
12
+ tokens = []
13
+
14
+ segments.to_a.each do |segment|
15
+ styles = segment.styles || {}
16
+ inline = styles[:inline_image] || styles['inline_image']
17
+ if image_rendering && inline_image_token?(inline, renderable_image_src)
18
+ tokens << { image: true, inline_image: inline }
19
+ next
20
+ end
21
+
22
+ tokens.concat(tokenize_text(segment.text.to_s, styles))
23
+ end
24
+
25
+ tokens
26
+ end
27
+
28
+ def prefix_indent(prefix)
29
+ return nil unless prefix
30
+
31
+ ' ' * prefix.to_s.length
32
+ end
33
+
34
+ def prefix_tokens(prefix)
35
+ return [] if prefix.nil? || prefix.empty?
36
+
37
+ [token_from_string(prefix, styles: { prefix: true })]
38
+ end
39
+
40
+ def tokenize_text(text, styles)
41
+ return [] if text.empty?
42
+
43
+ return split_token(text, styles) unless text.include?("\n")
44
+
45
+ tokenize_with_newlines(text, styles)
46
+ end
47
+
48
+ def tokenize_with_newlines(text, styles)
49
+ tokens = []
50
+ text.split(/(\n)/).each do |piece|
51
+ if piece == "\n"
52
+ tokens << { newline: true }
53
+ elsif !piece.empty?
54
+ tokens.concat(split_token(piece, styles))
55
+ end
56
+ end
57
+ tokens
58
+ end
59
+
60
+ def split_token(text, styles)
61
+ return [] if text.empty?
62
+
63
+ parts = text.scan(/\S+\s*/)
64
+ return [{ text: text, styles: styles.dup }] if parts.empty?
65
+
66
+ parts.map { |part| { text: part, styles: styles.dup } }
67
+ end
68
+
69
+ def token_from_string(text, styles:)
70
+ { text: text, styles: styles.dup }
71
+ end
72
+
73
+ def inline_image_token?(inline, renderable_image_src)
74
+ src = image_src(inline)
75
+ renderable_image_src.call(src)
76
+ rescue StandardError
77
+ false
78
+ end
79
+ private_class_method :inline_image_token?
80
+
81
+ def image_src(inline)
82
+ return nil unless inline.is_a?(Hash)
83
+
84
+ inline[:src] || inline['src']
85
+ end
86
+ private_class_method :image_src
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../core/models/content_block.rb'
4
+ require_relative '../../terminal/text_metrics.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Formatting
8
+ class FormattingService
9
+ # Helper responsible for converting semantic blocks into display-ready
10
+ # lines, preserving inline styles and metadata.
11
+ class LineAssembler
12
+ include Shoko::Core::Models
13
+
14
+ def initialize(width, chapter_index: nil, chapter_source_path: nil, rendering_mode: nil,
15
+ image_rendering: false, max_image_rows: nil)
16
+ @width = [width.to_i, 10].max
17
+ @chapter_index = chapter_index
18
+ @chapter_source_path = chapter_source_path
19
+ @image_rendering = if rendering_mode
20
+ rendering_mode == :images
21
+ else
22
+ image_rendering ? true : false
23
+ end
24
+ @image_builder = ImageBuilder.new(
25
+ width: @width,
26
+ chapter_index: chapter_index,
27
+ chapter_source_path: chapter_source_path,
28
+ max_image_rows: max_image_rows
29
+ )
30
+ @text_wrapper = TextWrapper.new(@width, image_builder: @image_builder)
31
+ end
32
+
33
+ def build(blocks)
34
+ blocks.to_a.each_with_index.with_object([]) do |(block, index), lines|
35
+ lines.concat(lines_for_block(block, index: index))
36
+ lines << blank_line if blank_line_after?(block, blocks, index)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def metadata_for(block)
43
+ base = (block.metadata || {}).merge(block_type: block.type)
44
+ base[:chapter_index] = @chapter_index if @chapter_index
45
+ base[:chapter_source_path] = @chapter_source_path if @chapter_source_path
46
+ base
47
+ end
48
+
49
+ def lines_for_block(block, index:)
50
+ return preformatted_lines(block) if preformatted?(block)
51
+ return [separator_line] if block.type == :separator
52
+ return [blank_line] if block.type == :break
53
+ return image_block_lines(block, index) if block.type == :image && renderable_image_block?(block)
54
+
55
+ wrapped_block_lines(block)
56
+ end
57
+
58
+ def preformatted?(block)
59
+ %i[code table].include?(block.type)
60
+ end
61
+
62
+ def blank_line_after?(block, blocks, index)
63
+ return false if index >= blocks.length - 1
64
+ return true if force_blank_line_after?(block)
65
+
66
+ blocks[index + 1]&.type != :list_item
67
+ end
68
+
69
+ def force_blank_line_after?(block)
70
+ block.type == :image || preformatted?(block)
71
+ end
72
+
73
+ def renderable_image_block?(block)
74
+ @image_rendering && @image_builder.renderable_block_image?(block)
75
+ end
76
+
77
+ def image_block_lines(block, index)
78
+ @image_builder.block_lines(block, block_index: index, base_metadata: metadata_for(block))
79
+ end
80
+
81
+ def wrapped_block_lines(block)
82
+ metadata, prefix, continuation_prefix = wrapped_block_options(block)
83
+ tokens = Tokenizer.tokenize(
84
+ block.segments,
85
+ image_rendering: @image_rendering,
86
+ renderable_image_src: @image_builder.method(:renderable_image_src?)
87
+ )
88
+ @text_wrapper.wrap(tokens, metadata: metadata, prefix: prefix, continuation_prefix: continuation_prefix)
89
+ end
90
+
91
+ def wrapped_block_options(block)
92
+ metadata = metadata_for(block)
93
+
94
+ case block.type
95
+ when :heading
96
+ [metadata, '', '']
97
+ when :list_item
98
+ list_item_options(block, metadata)
99
+ when :quote
100
+ [metadata.merge(block_type: :quote), '│ ', '│ ']
101
+ else
102
+ [metadata, nil, nil]
103
+ end
104
+ end
105
+
106
+ def list_item_options(block, metadata)
107
+ indent = ' ' * [block.level.to_i - 1, 0].max
108
+ marker = (block.metadata && block.metadata[:marker]) || '•'
109
+ first_prefix = "#{indent}#{marker} "
110
+ continuation_prefix = indent + (' ' * (marker.to_s.length + 1))
111
+ [metadata.merge(list: true), first_prefix, continuation_prefix]
112
+ end
113
+
114
+ def preformatted_lines(block)
115
+ text = block.segments.to_a.map(&:text).join
116
+ style = (block.segments.first&.styles || {}).merge(code: true)
117
+
118
+ text.split(/\r?\n/).map do |row|
119
+ plain = row.rstrip
120
+ DisplayLine.new(
121
+ text: plain,
122
+ segments: [TextSegment.new(text: plain, styles: style)],
123
+ metadata: metadata_for(block)
124
+ )
125
+ end
126
+ end
127
+
128
+ def separator_line
129
+ bar = '─' * [@width, 40].min
130
+ segment = TextSegment.new(text: bar, styles: { separator: true })
131
+ DisplayLine.new(text: bar, segments: [segment], metadata: { block_type: :separator })
132
+ end
133
+
134
+ def blank_line
135
+ DisplayLine.new(text: '', segments: [], metadata: { spacer: true })
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require_relative 'line_assembler/image_builder'
143
+ require_relative 'line_assembler/text_wrapper'
144
+ require_relative 'line_assembler/tokenizer'
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Formatting
5
+ class FormattingService
6
+ # Builds plain text fallback lines from parsed content blocks.
7
+ module PlainLinesBuilder
8
+ module_function
9
+
10
+ def build(blocks)
11
+ lines = []
12
+ blocks.to_a.each { |block| append_lines_for_block(lines, block) }
13
+ trim_trailing_blank_lines(lines)
14
+ end
15
+
16
+ def append_lines_for_block(lines, block)
17
+ case block.type
18
+ when :heading, :paragraph, :image
19
+ append_text_with_blank_line(lines, block.text)
20
+ when :list_item
21
+ lines << list_item_plain_text(block)
22
+ when :quote
23
+ append_text_with_blank_line(lines, "> #{block.text}")
24
+ when :code, :table
25
+ append_preformatted_lines(lines, block.text)
26
+ when :separator
27
+ lines << ('╌' * 40)
28
+ end
29
+ end
30
+
31
+ def list_item_plain_text(block)
32
+ indent = ' ' * [block.level.to_i - 1, 0].max
33
+ marker = (block.metadata && block.metadata[:marker]) || '•'
34
+ "#{indent}#{marker} #{block.text}"
35
+ end
36
+
37
+ def append_text_with_blank_line(lines, text)
38
+ lines << text
39
+ lines << ''
40
+ end
41
+
42
+ def append_preformatted_lines(lines, text)
43
+ text.to_s.split(/\r?\n/).each { |row| lines << row.rstrip }
44
+ lines << ''
45
+ end
46
+
47
+ def trim_trailing_blank_lines(lines)
48
+ lines.pop while lines.last&.strip&.empty?
49
+ lines
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ require_relative '../../../core/services/base_service.rb'
6
+ require_relative '../../../core/models/content_block.rb'
7
+ require_relative '../terminal/text_metrics.rb'
8
+ require_relative '../kitty/kitty_graphics.rb'
9
+
10
+ module Shoko
11
+ module Adapters::Output::Formatting
12
+ # Responsible for transforming chapter XHTML into semantic blocks and
13
+ # producing display-ready wrapped lines (with style metadata) for renderers.
14
+ class FormattingService < BaseService
15
+ # Cached chapter formatting results.
16
+ FormattedChapter = Struct.new(:blocks, :plain_lines, :checksum, keyword_init: true)
17
+ private_constant :FormattedChapter
18
+
19
+ def initialize(dependencies = nil)
20
+ super
21
+ @chapter_cache = {}
22
+ @wrapped_cache = Hash.new { |h, k| h[k] = {} }
23
+ @parser_factory = begin
24
+ resolve(:xhtml_parser_factory)
25
+ rescue StandardError
26
+ nil
27
+ end
28
+ @logger = begin
29
+ resolve(:logger)
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ end
34
+
35
+ # Ensure the provided chapter has semantic blocks + plain lines.
36
+ #
37
+ # @param document [EPUBDocument]
38
+ # @param chapter_index [Integer]
39
+ # @param chapter [Core::Models::Chapter]
40
+ def ensure_formatted!(document, chapter_index, chapter)
41
+ ensure_formatted_core(document, chapter_index, chapter)
42
+ rescue Shoko::FormattingError
43
+ raise
44
+ rescue StandardError => e
45
+ @logger&.error('Formatting service failed', error: e.message)
46
+ nil
47
+ end
48
+
49
+ # Retrieve wrapped, display-ready lines for a chapter window.
50
+ # Returns an array of Core::Models::DisplayLine, falling back to plain
51
+ # strings when formatting is unavailable.
52
+ #
53
+ # @param document [EPUBDocument]
54
+ # @param chapter_index [Integer]
55
+ # @param width [Integer]
56
+ # @param offset [Integer]
57
+ # @param length [Integer]
58
+ # @param config [Object,nil] state store-like object responding to #get
59
+ # @param lines_per_page [Integer,nil] optional page height hint for image sizing
60
+ # @return [Array<Core::Models::DisplayLine,String>]
61
+ def wrap_window(document, chapter_index, width, offset:, length:, config: nil, lines_per_page: nil)
62
+ width_i = width.to_i
63
+ length_i = length.to_i
64
+ offset_i = offset.to_i
65
+ return [] if width_i <= 0 || length_i <= 0
66
+
67
+ chapter = document&.get_chapter(chapter_index)
68
+ return [] unless chapter
69
+
70
+ formatted = ensure_formatted!(document, chapter_index, chapter)
71
+ return plain_window(chapter, offset: offset_i, length: length_i) unless formatted
72
+
73
+ chapter_source_path = chapter_source_path_for(chapter)
74
+ wrapped = wrapped_lines_for(document, chapter_index, formatted, width_i,
75
+ chapter_source_path: chapter_source_path, config: config,
76
+ lines_per_page: lines_per_page)
77
+ window_slice(wrapped, offset: offset_i, length: length_i)
78
+ end
79
+
80
+ # Retrieve all wrapped lines for a chapter at the provided width.
81
+ #
82
+ # @return [Array<Core::Models::DisplayLine>]
83
+ def wrap_all(document, chapter_index, width, config: nil, lines_per_page: nil)
84
+ return [] if width.to_i <= 0
85
+
86
+ chapter = document&.get_chapter(chapter_index)
87
+ return [] unless chapter
88
+
89
+ formatted = ensure_formatted!(document, chapter_index, chapter)
90
+ return chapter.lines || [] unless formatted
91
+
92
+ chapter_source_path = chapter_source_path_for(chapter)
93
+ wrapped_lines_for(document, chapter_index, formatted, width.to_i,
94
+ chapter_source_path: chapter_source_path, config: config,
95
+ lines_per_page: lines_per_page)
96
+ end
97
+
98
+ private
99
+
100
+ def wrapped_lines_for(document, chapter_index, formatted, width, chapter_source_path:, config:,
101
+ lines_per_page: nil)
102
+ width_key = width.to_i
103
+ cache_key = chapter_cache_key(document, chapter_index)
104
+ variant = wrap_variant(config)
105
+ max_image_rows = max_image_rows_for(lines_per_page)
106
+ composite_key = wrapped_composite_key(width_key, variant, max_image_rows)
107
+ cache_for_chapter = @wrapped_cache[cache_key]
108
+ cache_for_chapter[composite_key] ||= build_wrapped_lines(
109
+ formatted.blocks,
110
+ width: width_key,
111
+ chapter_index: chapter_index,
112
+ chapter_source_path: chapter_source_path,
113
+ rendering_mode: rendering_mode_for(variant),
114
+ max_image_rows: max_image_rows
115
+ )
116
+ end
117
+
118
+ def checksum_for(content)
119
+ Digest::SHA1.hexdigest(content.to_s)
120
+ end
121
+
122
+ def apply_formatted_to_chapter(chapter, formatted)
123
+ chapter.blocks = formatted.blocks if chapter.respond_to?(:blocks=)
124
+ return unless chapter.respond_to?(:lines=) && (chapter.lines.nil? || chapter.lines.empty?)
125
+
126
+ chapter.lines = formatted.plain_lines
127
+ end
128
+
129
+ def chapter_cache_key(document, chapter_index)
130
+ source = document.respond_to?(:canonical_path) ? document.canonical_path : document.object_id
131
+ "#{source}:#{chapter_index}"
132
+ end
133
+
134
+ def build_parser(raw)
135
+ return nil unless @parser_factory.respond_to?(:call)
136
+
137
+ @parser_factory.call(raw)
138
+ rescue StandardError
139
+ nil
140
+ end
141
+
142
+ def build_plain_lines(blocks)
143
+ PlainLinesBuilder.build(blocks)
144
+ end
145
+
146
+ def chapter_source_path_for(chapter)
147
+ metadata = chapter.respond_to?(:metadata) ? chapter.metadata : nil
148
+ return nil unless metadata
149
+
150
+ metadata[:source_path] || metadata['source_path'] || metadata[:href] || metadata['href']
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def wrap_variant(config)
156
+ Shoko::Adapters::Output::Kitty::KittyGraphics.enabled_for?(config) ? 'img' : 'txt'
157
+ rescue StandardError
158
+ 'txt'
159
+ end
160
+
161
+ def raw_content_for(chapter)
162
+ chapter.respond_to?(:raw_content) ? chapter.raw_content : nil
163
+ end
164
+
165
+ def formatted_chapter_from_blocks(blocks, checksum)
166
+ FormattedChapter.new(
167
+ blocks: blocks,
168
+ plain_lines: build_plain_lines(blocks),
169
+ checksum: checksum
170
+ )
171
+ end
172
+
173
+ def plain_window(chapter, offset:, length:)
174
+ (chapter.lines || [])[offset, length] || []
175
+ end
176
+
177
+ def window_slice(lines, offset:, length:)
178
+ (lines || [])[offset, length] || []
179
+ end
180
+
181
+ def max_image_rows_for(lines_per_page)
182
+ rows = lines_per_page.to_i
183
+ rows.positive? ? rows : nil
184
+ end
185
+
186
+ def wrapped_composite_key(width_key, variant, max_image_rows)
187
+ return "#{width_key}|#{variant}" unless variant == 'img' && max_image_rows
188
+
189
+ "#{width_key}|#{variant}|#{max_image_rows}"
190
+ end
191
+
192
+ def rendering_mode_for(variant)
193
+ variant == 'img' ? :images : :text
194
+ end
195
+
196
+ def build_wrapped_lines(blocks, width:, chapter_index:, chapter_source_path:, rendering_mode:, max_image_rows:)
197
+ LineAssembler.new(
198
+ width,
199
+ chapter_index: chapter_index,
200
+ chapter_source_path: chapter_source_path,
201
+ rendering_mode: rendering_mode,
202
+ max_image_rows: max_image_rows
203
+ ).build(blocks)
204
+ end
205
+
206
+ def ensure_formatted_core(document, chapter_index, chapter)
207
+ return nil unless chapter
208
+
209
+ raw = raw_content_for(chapter)
210
+ cache_key = chapter_cache_key(document, chapter_index)
211
+ cached = @chapter_cache[cache_key]
212
+ checksum = checksum_for(raw)
213
+ return cached if cache_hit?(cached, checksum, chapter)
214
+ return cached if raw.nil?
215
+
216
+ formatted = build_formatted_from_raw(raw, checksum)
217
+ return cached unless formatted
218
+
219
+ store_formatted_chapter(cache_key, formatted, chapter)
220
+ end
221
+
222
+ def cache_hit?(cached, checksum, chapter)
223
+ return false unless cached && cached.checksum == checksum
224
+
225
+ apply_formatted_to_chapter(chapter, cached)
226
+ true
227
+ end
228
+
229
+ def build_formatted_from_raw(raw, checksum)
230
+ parser = build_parser(raw)
231
+ return nil unless parser
232
+
233
+ formatted_chapter_from_blocks(parser.parse, checksum)
234
+ end
235
+
236
+ def store_formatted_chapter(cache_key, formatted, chapter)
237
+ @chapter_cache[cache_key] = formatted
238
+ @wrapped_cache.delete(cache_key)
239
+ apply_formatted_to_chapter(chapter, formatted)
240
+ formatted
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ require_relative 'formatting_service/line_assembler'
247
+ require_relative 'formatting_service/plain_lines_builder'