scriptorium 0.0.3 → 0.7.2

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 (353) hide show
  1. checksums.yaml +4 -4
  2. data/README.lt3 +324 -0
  3. data/README.md +3155 -1
  4. data/assets/.DS_Store +0 -0
  5. data/assets/README.md +44 -0
  6. data/assets/icons/social/reddit.png +0 -0
  7. data/assets/icons/social/x-logo.png +0 -0
  8. data/assets/icons/ui/.DS_Store +0 -0
  9. data/assets/icons/ui/back.png +0 -0
  10. data/assets/icons/ui/copy.png +0 -0
  11. data/assets/icons/ui/down.png +0 -0
  12. data/assets/icons/ui/end.png +0 -0
  13. data/assets/icons/ui/exit.png +0 -0
  14. data/assets/icons/ui/foo +10 -0
  15. data/assets/icons/ui/home.png +0 -0
  16. data/assets/icons/ui/left.png +0 -0
  17. data/assets/icons/ui/next.png +0 -0
  18. data/assets/icons/ui/right.png +0 -0
  19. data/assets/icons/ui/start.png +0 -0
  20. data/assets/icons/ui/up.png +0 -0
  21. data/assets/imagenotfound.jpg +0 -0
  22. data/assets/samples/placeholder.svg +9 -0
  23. data/assets/themes/standard/favicon.svg +6 -0
  24. data/bin/sblog +84 -5
  25. data/bin/scriptorium +1 -0
  26. data/doc/README.txt +6 -0
  27. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
  28. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
  29. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
  30. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  31. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  32. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  33. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
  34. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
  35. data/doc/anti-amnesia/20250807-213025.md +116 -0
  36. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  37. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  38. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  39. data/doc/banner_svg_config.md +114 -0
  40. data/doc/contrib.lt3 +8 -0
  41. data/doc/dependencies.md +281 -0
  42. data/doc/hacker.lt3 +5 -0
  43. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  44. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  45. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  46. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  47. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  48. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  49. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  50. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  51. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  52. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  53. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  54. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  55. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  56. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  57. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  58. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  59. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  60. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  61. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  62. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  63. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  64. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  65. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  66. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  67. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  68. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  69. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  70. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  71. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  72. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  73. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  74. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  75. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  76. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  77. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  78. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  79. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  80. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  81. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  82. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  83. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  84. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  85. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  86. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  87. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  88. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  89. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  90. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  91. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  92. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  93. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  94. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  95. data/doc/imported/0018-ruby-puns/post.html +46 -0
  96. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  97. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  98. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  99. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  100. data/doc/imported/0020-fffff/metadata.txt +7 -0
  101. data/doc/imported/0020-fffff/post.html +24 -0
  102. data/doc/imported/0020-fffff/source.lt3 +12 -0
  103. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  104. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  105. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  106. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  107. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  108. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  109. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  110. data/doc/imported/0024-just-a-test/post.html +24 -0
  111. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  112. data/doc/imported/import_summary.txt +98 -0
  113. data/doc/livetext-informal-spec.txt +65 -0
  114. data/doc/myuserdoc/ch-0.lt3 +31 -0
  115. data/doc/myuserdoc/ch-1.lt3 +37 -0
  116. data/doc/myuserdoc/ch-10.lt3 +22 -0
  117. data/doc/myuserdoc/ch-2.lt3 +37 -0
  118. data/doc/myuserdoc/ch-3.lt3 +19 -0
  119. data/doc/myuserdoc/ch-4.lt3 +43 -0
  120. data/doc/myuserdoc/ch-5.lt3 +22 -0
  121. data/doc/myuserdoc/ch-6.lt3 +19 -0
  122. data/doc/myuserdoc/ch-7.lt3 +16 -0
  123. data/doc/myuserdoc/ch-8.lt3 +13 -0
  124. data/doc/myuserdoc/ch-9.lt3 +19 -0
  125. data/doc/myuserdoc/tweak.rb +18 -0
  126. data/doc/myuserdoc/userdoc-toc.txt +88 -0
  127. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  128. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  129. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  130. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  131. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  132. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  133. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  134. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  135. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  136. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  137. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  138. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  139. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  140. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  141. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  142. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  143. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  144. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  145. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  146. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  147. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  148. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  149. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  150. data/doc/old-repo +1 -0
  151. data/doc/reddit_credentials_template.json +8 -0
  152. data/doc/reddit_integration.md +207 -0
  153. data/doc/user.lt3 +35 -0
  154. data/doc/user_guide_section_1.md +137 -0
  155. data/doc/user_guide_section_10.md +515 -0
  156. data/doc/user_guide_section_11.md +708 -0
  157. data/doc/user_guide_section_2.md +233 -0
  158. data/doc/user_guide_section_3.md +5 -0
  159. data/doc/user_guide_section_4.md +221 -0
  160. data/doc/user_guide_section_5.md +243 -0
  161. data/doc/user_guide_section_6.md +147 -0
  162. data/doc/user_guide_section_7.md +311 -0
  163. data/doc/user_guide_section_8.md +224 -0
  164. data/doc/user_guide_section_9.md +375 -0
  165. data/lib/rouge/lexers/livetext.rb +74 -0
  166. data/lib/scriptorium/api.rb +2373 -0
  167. data/lib/scriptorium/banner_svg.rb +729 -0
  168. data/lib/scriptorium/contract.rb +34 -0
  169. data/lib/scriptorium/exceptions.rb +201 -1
  170. data/lib/scriptorium/helpers.rb +675 -0
  171. data/lib/scriptorium/post.rb +259 -0
  172. data/lib/scriptorium/reddit.rb +83 -0
  173. data/lib/scriptorium/repo.rb +938 -0
  174. data/lib/scriptorium/standard_files.rb +149 -0
  175. data/lib/scriptorium/support/bootstrap/css.txt +5 -0
  176. data/lib/scriptorium/support/bootstrap/js.txt +4 -0
  177. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  178. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  179. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  180. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  181. data/lib/scriptorium/support/config/reddit.txt +10 -0
  182. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  183. data/lib/scriptorium/support/config/social.txt +8 -0
  184. data/lib/scriptorium/support/highlight/css.txt +2 -0
  185. data/lib/scriptorium/support/highlight/custom.css +119 -0
  186. data/lib/scriptorium/support/highlight/js.txt +1 -0
  187. data/lib/scriptorium/support/post_index/config.txt +15 -0
  188. data/lib/scriptorium/support/post_index/style.css +55 -0
  189. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  190. data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
  191. data/lib/scriptorium/support/templates/layout.txt +5 -0
  192. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  193. data/lib/scriptorium/support/theme/footer.lt3 +2 -0
  194. data/lib/scriptorium/support/theme/header.lt3 +4 -0
  195. data/lib/scriptorium/support/theme/left.lt3 +3 -0
  196. data/lib/scriptorium/support/theme/main.lt3 +5 -0
  197. data/lib/scriptorium/support/theme/right.lt3 +3 -0
  198. data/lib/scriptorium/theme.rb +192 -0
  199. data/lib/scriptorium/version.rb +1 -1
  200. data/lib/scriptorium/view.rb +1021 -0
  201. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  202. data/lib/scriptorium/widgets/links.rb +112 -0
  203. data/lib/scriptorium/widgets/pages.rb +133 -0
  204. data/lib/scriptorium/widgets/widget.rb +133 -0
  205. data/lib/scriptorium.rb +38 -34
  206. data/lib/skeleton.rb +10 -1
  207. data/scriptorium.gemspec +17 -5
  208. data/test/README.md +69 -0
  209. data/test/WEB_INTEGRATION_README.md +196 -0
  210. data/test/all +83 -0
  211. data/test/api_demo.rb +99 -0
  212. data/test/assets/imagenotfound.jpg +0 -0
  213. data/test/assets/images/.DS_Store +0 -0
  214. data/test/assets/images/README.md +27 -0
  215. data/test/assets/images/odd_aspect.png +0 -0
  216. data/test/assets/images/perfect.png +0 -0
  217. data/test/assets/images/small.png +0 -0
  218. data/test/assets/images/tall.png +0 -0
  219. data/test/assets/images/very_tall.png +0 -0
  220. data/test/assets/images/very_wide.png +0 -0
  221. data/test/assets/images/wide.png +0 -0
  222. data/test/assets/testbanner.jpg +0 -0
  223. data/test/banner_svg/simple_helpers.rb +13 -0
  224. data/test/banner_svg/unit.rb +1000 -0
  225. data/test/config/deployment.txt +5 -0
  226. data/test/ed_test.rb +204 -0
  227. data/test/integration/cursor_banner_combinations.rb +193 -0
  228. data/test/integration/cursor_banner_features.rb +374 -0
  229. data/test/integration/integration_test.rb +326 -0
  230. data/test/integration/preview_flow_test.rb +94 -0
  231. data/test/livetext_plugin_test.rb +500 -0
  232. data/test/manual/asset_mgmt.rb +67 -0
  233. data/test/manual/banner-tests/index.html +45 -0
  234. data/test/manual/banner-tests/svg.txt +3 -0
  235. data/test/manual/banner-tests/test01.html +122 -0
  236. data/test/manual/banner-tests/test02.html +122 -0
  237. data/test/manual/banner-tests/test03.html +122 -0
  238. data/test/manual/banner-tests/test04.html +129 -0
  239. data/test/manual/banner-tests/test05.html +129 -0
  240. data/test/manual/banner-tests/test06.html +129 -0
  241. data/test/manual/banner-tests/test07.html +129 -0
  242. data/test/manual/banner-tests/test08.html +123 -0
  243. data/test/manual/banner-tests/test09.html +123 -0
  244. data/test/manual/banner-tests/test10.html +123 -0
  245. data/test/manual/banner-tests/test11.html +123 -0
  246. data/test/manual/banner-tests/test12.html +123 -0
  247. data/test/manual/banner-tests/test13.html +123 -0
  248. data/test/manual/banner-tests/test14.html +123 -0
  249. data/test/manual/banner-tests/test15.html +122 -0
  250. data/test/manual/banner-tests/test16.html +122 -0
  251. data/test/manual/banner-tests/test17.html +122 -0
  252. data/test/manual/banner-tests/test18.html +132 -0
  253. data/test/manual/banner-tests/test19.html +132 -0
  254. data/test/manual/banner-tests/test20.html +132 -0
  255. data/test/manual/banner-tests/test21.html +132 -0
  256. data/test/manual/banner-tests/test22.html +132 -0
  257. data/test/manual/banner-tests/test23.html +132 -0
  258. data/test/manual/banner-tests/test24.html +132 -0
  259. data/test/manual/banner-tests/test25.html +131 -0
  260. data/test/manual/banner_environment.rb +205 -0
  261. data/test/manual/codemirror_demo.html +773 -0
  262. data/test/manual/create_posts_for_web.rb +114 -0
  263. data/test/manual/environment.rb +67 -0
  264. data/test/manual/make_banner.rb +153 -0
  265. data/test/manual/preview_manual_test.rb +129 -0
  266. data/test/manual/sample_banner_config.txt +12 -0
  267. data/test/manual/test_advanced_widgets.rb +73 -0
  268. data/test/manual/test_banner_combinations.rb +120 -0
  269. data/test/manual/test_banner_features.rb +306 -0
  270. data/test/manual/test_banner_integration.rb +115 -0
  271. data/test/manual/test_banner_radial.rb +87 -0
  272. data/test/manual/test_basic_posts.rb +47 -0
  273. data/test/manual/test_layout_widgets.rb +40 -0
  274. data/test/manual/test_pagination.rb +24 -0
  275. data/test/manual/test_random_posts.rb +38 -0
  276. data/test/manual/test_syntax_highlighting.rb +167 -0
  277. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  278. data/test/rubytext/rubytext_demo_test.rb +42 -0
  279. data/test/rubytext/rubytext_testing_guide.md +277 -0
  280. data/test/run_automated_tests.rb +45 -0
  281. data/test/staging/.DS_Store +0 -0
  282. data/test/support/preview_utils.rb +88 -0
  283. data/test/syntax_highlighting_test.lt3 +124 -0
  284. data/test/test_gem_assets.rb +48 -0
  285. data/test/test_helpers.rb +240 -0
  286. data/test/tui_editor_integration_test.rb +296 -0
  287. data/test/tui_integration_test.rb +883 -0
  288. data/test/unit/api.rb +1776 -0
  289. data/test/unit/asset_management.rb +219 -0
  290. data/test/unit/backup_test.rb +451 -0
  291. data/test/unit/clipboard_test.rb +60 -0
  292. data/test/unit/contract_test.rb +69 -0
  293. data/test/unit/core.rb +1211 -0
  294. data/test/unit/deploy_config_test.rb +248 -0
  295. data/test/unit/deploy_test.rb +478 -0
  296. data/test/unit/edit_post_test.rb +168 -0
  297. data/test/unit/gem_asset_management.rb +183 -0
  298. data/test/unit/livetext_basic.rb +57 -0
  299. data/test/unit/livetext_compatibility.rb +82 -0
  300. data/test/unit/parse_cmd_test.rb +260 -0
  301. data/test/unit/permalink_copy_test.rb +211 -0
  302. data/test/unit/post.rb +309 -0
  303. data/test/unit/post_index_config_test.rb +258 -0
  304. data/test/unit/post_state_helpers_test.rb +137 -0
  305. data/test/unit/read_commented_file_test.rb +278 -0
  306. data/test/unit/reddit_test.rb +235 -0
  307. data/test/unit/repo.rb +569 -0
  308. data/test/unit/social_test.rb +366 -0
  309. data/test/unit/syntax_highlighting.rb +70 -0
  310. data/test/unit/theme_management_test.rb +91 -0
  311. data/test/unit/view.rb +498 -0
  312. data/test/unit/widgets.rb +669 -0
  313. data/test/web_integration_test.rb +231 -0
  314. data/test/web_test_helper.rb +218 -0
  315. data/test/web_workflow_test.rb +527 -0
  316. data/test/wizard_test.rb +123 -0
  317. data/ui/README.md +67 -0
  318. data/ui/common/lib/ui_common.rb +8 -0
  319. data/ui/rubytext/README.md +191 -0
  320. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  321. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  322. data/ui/tui/bin/scriptorium +1890 -0
  323. data/ui/tui/test/tui_test.rb +23 -0
  324. data/ui/web/app/app.rb +2600 -0
  325. data/ui/web/app/assets/livetext_mode.js +244 -0
  326. data/ui/web/app/error_helpers.rb +150 -0
  327. data/ui/web/app/views/advanced_config.erb +196 -0
  328. data/ui/web/app/views/asset_management.erb +645 -0
  329. data/ui/web/app/views/backup_management.erb +238 -0
  330. data/ui/web/app/views/banner_config.erb +200 -0
  331. data/ui/web/app/views/config_widget.erb +232 -0
  332. data/ui/web/app/views/configure_view.erb +401 -0
  333. data/ui/web/app/views/dashboard.erb +154 -0
  334. data/ui/web/app/views/deploy_config.erb +149 -0
  335. data/ui/web/app/views/edit_pages.erb +363 -0
  336. data/ui/web/app/views/edit_post.erb +175 -0
  337. data/ui/web/app/views/edit_theme.erb +73 -0
  338. data/ui/web/app/views/edit_theme_file.erb +74 -0
  339. data/ui/web/app/views/error_page.erb +29 -0
  340. data/ui/web/app/views/header_config.erb +155 -0
  341. data/ui/web/app/views/layout_config.erb +147 -0
  342. data/ui/web/app/views/navbar_config.erb +411 -0
  343. data/ui/web/app/views/theme_management.erb +130 -0
  344. data/ui/web/app/views/view_dashboard.erb +779 -0
  345. data/ui/web/app/views/widgets.erb +249 -0
  346. data/ui/web/bin/scriptorium-web +164 -0
  347. data/ui/web/test/web_basic_test.rb +38 -0
  348. data/ui/web/test_navbar.txt +7 -0
  349. data/ui/web/tmp/timing.log +17 -0
  350. data/ui/web/tmp/web_server.log +0 -0
  351. metadata +434 -8
  352. data/lib/scriptorium/engine.rb +0 -22
  353. data/test/engine/unit.rb +0 -44
@@ -0,0 +1,729 @@
1
+ class Scriptorium::BannerSVG
2
+ include Scriptorium::Helpers
3
+ include Scriptorium::Exceptions
4
+ include Scriptorium::Contract
5
+
6
+ # Invariants
7
+ def define_invariants
8
+ invariant { @title.is_a?(String) }
9
+ invariant { @subtitle.is_a?(String) }
10
+ invariant { @title_scale.is_a?(Numeric) && @title_scale > 0 }
11
+ invariant { @subtitle_scale.is_a?(Numeric) && @subtitle_scale > 0 }
12
+ invariant { @aspect.is_a?(Numeric) && @aspect > 0 }
13
+ invariant { @font.is_a?(String) && !@font.empty? }
14
+ invariant { @text_color.is_a?(String) && !@text_color.empty? }
15
+ invariant { @background.is_a?(String) && !@background.empty? }
16
+ end
17
+
18
+ def initialize(title, subtitle)
19
+ assume { title.is_a?(String) }
20
+ assume { subtitle.is_a?(String) }
21
+
22
+ @title, @subtitle = title, subtitle
23
+ @title_scale = 0.8
24
+ @subtitle_scale = 0.4
25
+ @title_style = "normal"
26
+ @subtitle_style = "normal"
27
+ @title_weight = "normal"
28
+ @subtitle_weight = "normal"
29
+ @text_color = "#374151"
30
+ @text_anchor = "start"
31
+ @aspect = 8.0
32
+ @font = "Verdana"
33
+ @base_font_size = 60
34
+ @title_font_size = @base_font_size * @title_scale
35
+ @subtitle_font_size = @base_font_size * @subtitle_scale
36
+ # Remove default @title_xy and @subtitle_xy
37
+ @background = "#fff"
38
+ @gradient_start_color = nil
39
+ @gradient_end_color = nil
40
+ @gradient_direction = nil
41
+ @radial_start_color = nil
42
+ @radial_end_color = nil
43
+ @image_background = nil
44
+ @title_xy_set = false
45
+ @subtitle_xy_set = false
46
+
47
+ define_invariants
48
+ verify { @title == title }
49
+ verify { @subtitle == subtitle }
50
+ check_invariants
51
+ end
52
+
53
+
54
+
55
+ def handle_background(*args)
56
+ check_invariants
57
+ assume { args.is_a?(Array) }
58
+
59
+ validate_background_args(args)
60
+ @background = args.first
61
+
62
+ verify { @background.is_a?(String) && !@background.empty? }
63
+ check_invariants
64
+ end
65
+
66
+ private def validate_background_args(args)
67
+ raise BackgroundNoArgs if args.nil? || args.empty?
68
+
69
+ raise BackgroundFirstArgNil if args.first.nil?
70
+
71
+ raise BackgroundFirstArgEmpty if args.first.to_s.strip.empty?
72
+ end
73
+
74
+ def handle_linear_gradient(*args)
75
+ validate_linear_gradient_args(args)
76
+ @gradient_start_color = args[0]
77
+ @gradient_end_color = args[1]
78
+ @gradient_direction = args[2] || "lr"
79
+ end
80
+
81
+ private def validate_linear_gradient_args(args)
82
+ raise LinearGradientNoArgs if args.nil? || args.empty?
83
+
84
+ raise LinearGradientStartColorNil if args[0].nil? || args[0].to_s.strip.empty?
85
+
86
+ # Validate all provided arguments (up to 3: start_color, end_color, direction)
87
+ args.each_with_index do |arg, index|
88
+ next if arg.nil? # Allow nil for optional arguments
89
+ raise LinearGradientArgEmpty(index + 1) if arg.to_s.strip.empty?
90
+ end
91
+ end
92
+
93
+ def handle_radial_gradient(*args)
94
+ validate_radial_gradient_args(args)
95
+ @radial_start_color = args[0]
96
+ @radial_end_color = args[1]
97
+ # Optional: cx, cy, r
98
+ @radial_cx = args[2] || '50%'
99
+ @radial_cy = args[3] || '50%'
100
+ @radial_r = args[4] || '50%'
101
+ # 6th param: aspect ratio compensation for gradientTransform
102
+ @radial_ar = args[5] ? args[5].to_f : nil
103
+ end
104
+
105
+ private def validate_radial_gradient_args(args)
106
+ raise RadialGradientNoArgs if args.nil? || args.empty?
107
+
108
+ raise RadialGradientStartColorNil if args[0].nil? || args[0].to_s.strip.empty?
109
+
110
+ # Validate all provided arguments (up to 6: start_color, end_color, cx, cy, r, aspect_ratio)
111
+ args.each_with_index do |arg, index|
112
+ next if arg.nil? # Allow nil for optional arguments
113
+ raise RadialGradientArgEmpty(index + 1) if arg.to_s.strip.empty?
114
+ end
115
+ end
116
+
117
+ # Image backgrounds: Users should provide images matching the banner's aspect ratio.
118
+ # SVG will crop/stretch if aspect ratios don't match (use preserveAspectRatio="xMidYMid slice" for cropping).
119
+ def handle_image_background(*args)
120
+ validate_image_background_args(args)
121
+ @image_background = args[0]
122
+ end
123
+
124
+ private def validate_image_background_args(args)
125
+ raise ImageBackgroundNoArgs if args.nil? || args.empty?
126
+
127
+ raise ImageBackgroundFirstArgNil if args[0].nil?
128
+
129
+ raise ImageBackgroundFirstArgEmpty if args[0].to_s.strip.empty?
130
+ end
131
+
132
+ def handle_aspect(*args)
133
+ check_invariants
134
+ assume { args.is_a?(Array) }
135
+
136
+ validate_aspect_args(args)
137
+ @aspect = args.first.to_f
138
+
139
+ verify { @aspect.is_a?(Numeric) && @aspect > 0 }
140
+ check_invariants
141
+ end
142
+
143
+ private def validate_aspect_args(args)
144
+ raise AspectNoArgs if args.nil? || args.empty?
145
+
146
+ raise AspectFirstArgNil if args.first.nil?
147
+
148
+ raise AspectFirstArgEmpty if args.first.to_s.strip.empty?
149
+
150
+ unless args.first.to_s.match?(/^\d+(\.\d+)?$/)
151
+ raise AspectInvalidValue(args.first)
152
+ end
153
+ end
154
+
155
+ def handle_preserve_aspect(*args)
156
+ @preserve_aspect = args.first
157
+ end
158
+
159
+ def handle_font(*args)
160
+ validate_font_args(args)
161
+ @font = args.join(" ")
162
+ end
163
+
164
+ private def validate_font_args(args)
165
+ raise FontArgsNil if args.nil?
166
+
167
+ # Font arguments are optional - empty args array is allowed
168
+ # But if any arguments are provided, they must be valid
169
+ args.each_with_index do |arg, index|
170
+ raise FontArgNil(index + 1) if arg.nil?
171
+
172
+ raise FontArgEmpty(index + 1) if arg.to_s.strip.empty?
173
+ end
174
+ end
175
+
176
+ def handle_text_color(*args)
177
+ validate_text_color_args(args)
178
+ @text_color = args.first
179
+ end
180
+
181
+ private def validate_text_color_args(args)
182
+ raise TextColorNoArgs if args.nil? || args.empty?
183
+
184
+ raise TextColorFirstArgNil if args.first.nil?
185
+
186
+ raise TextColorFirstArgEmpty if args.first.to_s.strip.empty?
187
+ end
188
+
189
+ def handle_text_align(*args)
190
+ direction = args[0]
191
+ # Apply to both title and subtitle
192
+ handle_title_align(*args)
193
+ handle_subtitle_align(*args)
194
+ end
195
+
196
+ def handle_scale(which, *args)
197
+ check_invariants
198
+ assume { which.is_a?(String) && !which.empty? }
199
+ assume { args.is_a?(Array) }
200
+
201
+ if which == "title"
202
+ @title_scale = args.first.to_f
203
+ verify { @title_scale.is_a?(Numeric) && @title_scale > 0 }
204
+ elsif which == "subtitle"
205
+ @subtitle_scale = args.first.to_f
206
+ verify { @subtitle_scale.is_a?(Numeric) && @subtitle_scale > 0 }
207
+ end
208
+
209
+ check_invariants
210
+ end
211
+
212
+ def handle_style(which, *args)
213
+ args.each do |arg|
214
+ case
215
+ when which == "title" && arg =~ /bold/i
216
+ @title_weight = "bold"
217
+ when which == "title" && arg =~ /italic/i
218
+ @title_style = "italic"
219
+ when which == "subtitle" && arg =~ /bold/i
220
+ @subtitle_weight = "bold"
221
+ when which == "subtitle" && arg =~ /italic/i
222
+ @subtitle_style = "italic"
223
+ else
224
+ @title_style = arg
225
+ @subtitle_style = arg
226
+ end
227
+ end
228
+ end
229
+
230
+ def handle_xy(which, *args)
231
+ validate_xy_which(which)
232
+
233
+ if which == "title"
234
+ @title_xy = args
235
+ @title_xy_set = true
236
+ elsif which == "subtitle"
237
+ @subtitle_xy = args
238
+ @subtitle_xy_set = true
239
+ end
240
+ end
241
+
242
+ private def validate_xy_which(which)
243
+ raise XYWhichNil if which.nil?
244
+
245
+ raise XYWhichEmpty if which.to_s.strip.empty?
246
+
247
+ unless ["title", "subtitle"].include?(which)
248
+ raise XYInvalidWhich(which)
249
+ end
250
+ end
251
+
252
+ private def validate_align_args(args)
253
+ raise AlignNoArgs if args.nil? || args.empty?
254
+
255
+ raise AlignDirectionNil if args[0].nil? || args[0].to_s.strip.empty?
256
+
257
+ unless ["left", "center", "right"].include?(args[0])
258
+ raise AlignInvalidDirection(args[0])
259
+ end
260
+
261
+ # Validate optional x and y arguments if provided
262
+ args.each_with_index do |arg, index|
263
+ next if index == 0 # Skip direction (already validated)
264
+ next if arg.nil? # Allow nil for optional arguments
265
+ raise AlignArgEmpty(index + 1) if arg.to_s.strip.empty?
266
+ end
267
+ end
268
+
269
+ def handle_title_align(*args)
270
+ validate_align_args(args)
271
+ direction = args[0]
272
+ x = args[1]
273
+ y = args[2]
274
+ @title_align = direction
275
+ @title_align_x = x
276
+ @title_align_y = y
277
+ # Smart default for x if 'auto'
278
+ if x == 'auto' || x.nil?
279
+ @title_align_x = case direction
280
+ when 'left' then '5%'
281
+ when 'center' then '50%'
282
+ when 'right' then '95%'
283
+ else '5%'
284
+ end
285
+ end
286
+ # Warn if direction and x seem incompatible
287
+ if direction == 'center' && @title_align_x !~ /^50%$/
288
+ warn "[BannerSVG] Warning: title.align center with x=#{@title_align_x} may not be visually centered."
289
+ elsif direction == 'left' && @title_align_x !~ /^5%$/
290
+ warn "[BannerSVG] Warning: title.align left with x=#{@title_align_x} may not be visually left-aligned."
291
+ elsif direction == 'right' && @title_align_x !~ /^95%$/
292
+ warn "[BannerSVG] Warning: title.align right with x=#{@title_align_x} may not be visually right-aligned."
293
+ end
294
+ # Set anchor
295
+ @title_text_anchor = case direction
296
+ when 'left' then 'start'
297
+ when 'center' then 'middle'
298
+ when 'right' then 'end'
299
+ else 'start'
300
+ end
301
+ # Set y if provided
302
+ @title_align_y = y if y
303
+ end
304
+
305
+ def handle_subtitle_align(*args)
306
+ validate_align_args(args)
307
+ direction = args[0]
308
+ x = args[1]
309
+ y = args[2]
310
+ @subtitle_align = direction
311
+ @subtitle_align_x = x
312
+ @subtitle_align_y = y
313
+ if x == 'auto' || x.nil?
314
+ @subtitle_align_x = case direction
315
+ when 'left' then '5%'
316
+ when 'center' then '50%'
317
+ when 'right' then '95%'
318
+ else '5%'
319
+ end
320
+ end
321
+ if direction == 'center' && @subtitle_align_x !~ /^50%$/
322
+ warn "[BannerSVG] Warning: subtitle.align center with x=#{@subtitle_align_x} may not be visually centered."
323
+ elsif direction == 'left' && @subtitle_align_x !~ /^5%$/
324
+ warn "[BannerSVG] Warning: subtitle.align left with x=#{@subtitle_align_x} may not be visually left-aligned."
325
+ elsif direction == 'right' && @subtitle_align_x !~ /^95%$/
326
+ warn "[BannerSVG] Warning: subtitle.align right with x=#{@subtitle_align_x} may not be visually right-aligned."
327
+ end
328
+ @subtitle_text_anchor = case direction
329
+ when 'left' then 'start'
330
+ when 'center' then 'middle'
331
+ when 'right' then 'end'
332
+ else 'start'
333
+ end
334
+ @subtitle_align_y = y if y
335
+ end
336
+
337
+ def handle_title_color(*args)
338
+ validate_color_args(args)
339
+ @title_color = args.first
340
+ end
341
+
342
+ def handle_subtitle_color(*args)
343
+ validate_color_args(args)
344
+ @subtitle_color = args.first
345
+ end
346
+
347
+ private def validate_color_args(args)
348
+ raise ColorNoArgs if args.nil? || args.empty?
349
+ raise ColorFirstArgNil if args.first.nil?
350
+ raise ColorFirstArgEmpty if args.first.to_s.strip.empty?
351
+ end
352
+
353
+ def parse_header_svg(config_file = "svg.txt")
354
+ check_invariants
355
+ assume { config_file.is_a?(String) && !config_file.empty? }
356
+
357
+ lines = read_commented_file(config_file)
358
+
359
+ # Parse config into a hash
360
+ cfg = {}
361
+ lines.each do |line|
362
+ key, *values = line.split(/\s+/)
363
+ cfg[key.strip] = Array(values) if key && values
364
+ end
365
+
366
+ # Use instance variables instead of local variables
367
+ handlers = {
368
+ "back.color" => ->(args) { handle_background(*args) },
369
+ "back.linear" => ->(args) { handle_linear_gradient(*args) },
370
+ "back.radial" => ->(args) { handle_radial_gradient(*args) },
371
+ "back.image" => ->(args) { handle_image_background(*args) },
372
+ "aspect" => ->(args) { handle_aspect(*args) },
373
+ "preserve_aspect" => ->(args) { handle_preserve_aspect(*args) },
374
+ "text.font" => ->(args) { handle_font(*args) },
375
+ "text.color" => ->(args) { handle_text_color(*args) },
376
+ "title.color" => ->(args) { handle_title_color(*args) },
377
+ "subtitle.color" => ->(args) { handle_subtitle_color(*args) },
378
+
379
+ "title.align" => ->(args) { handle_title_align(*args) },
380
+ "subtitle.align" => ->(args) { handle_subtitle_align(*args) },
381
+ "title.scale" => ->(args) { handle_scale("title", *args) },
382
+ "subtitle.scale" => ->(args) { handle_scale("subtitle", *args) },
383
+ "title.style" => ->(args) { handle_style("title", *args) },
384
+ "subtitle.style" => ->(args) { handle_style("subtitle", *args) },
385
+ "title.xy" => ->(args) { handle_xy("title", *args) },
386
+ "subtitle.xy" => ->(args) { handle_xy("subtitle", *args) },
387
+ "text.align" => ->(args) { handle_text_align(*args) }
388
+ }
389
+
390
+ cfg.each_pair do |key, args|
391
+ handler = handlers[key]
392
+ if handler
393
+ # Skip malformed lines (empty args) to avoid validation errors
394
+ next if args.nil? || args.empty?
395
+ handler.call(args)
396
+ end
397
+ end
398
+
399
+ # Check for align/xy conflicts and warn
400
+ # Note: xy coordinates take precedence over align coordinates when both are set
401
+ if @title_align && @title_xy && @title_align_x && @title_xy[0] && @title_align_x != @title_xy[0]
402
+ warn "[BannerSVG] Warning: title.align x=#{@title_align_x} conflicts with title.xy x=#{@title_xy[0]} (xy will override)"
403
+ end
404
+ if @subtitle_align && @subtitle_xy && @subtitle_align_x && @subtitle_xy[0] && @subtitle_align_x != @subtitle_xy[0]
405
+ warn "[BannerSVG] Warning: subtitle.align x=#{@subtitle_align_x} conflicts with subtitle.xy x=#{@subtitle_xy[0]} (xy will override)"
406
+ end
407
+
408
+ # Recalculate font sizes after config parsing
409
+ @title_font_size = @base_font_size * @title_scale
410
+ @subtitle_font_size = @base_font_size * @subtitle_scale
411
+
412
+ width = 800 # Arbitrary starting point for calculations
413
+ height = (width / @aspect).to_i # height calculated based on aspect ratio
414
+
415
+ # Handle background (image, radial gradient, linear gradient, or solid color)
416
+ background_svg = ""
417
+ if @image_background
418
+ # Generate image background
419
+ background_svg = <<~IMAGE
420
+ <defs>
421
+ <pattern id="bg-pattern" x="0" y="0" width="100%" height="100%" patternUnits="objectBoundingBox">
422
+ <image href="#{@image_background}" x="0" y="0" width="100%" height="100%"
423
+ preserveAspectRatio="xMidYMid slice" />
424
+ </pattern>
425
+ </defs>
426
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#bg-pattern)' />
427
+ IMAGE
428
+ elsif @radial_start_color && @radial_end_color
429
+ # Generate radial gradient
430
+ background_svg = <<~RADIAL
431
+ <defs>
432
+ <radialGradient id="radial1" cx="50%" cy="50%" r="50%">
433
+ <stop offset="0%" style="stop-color:#{@radial_start_color};stop-opacity:1" />
434
+ <stop offset="100%" style="stop-color:#{@radial_end_color};stop-opacity:1" />
435
+ </radialGradient>
436
+ </defs>
437
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#radial1)' />
438
+ RADIAL
439
+ elsif @gradient_start_color && @gradient_end_color
440
+ # Generate linear gradient
441
+ directions = {
442
+ "lr" => ["0%", "0%", "100%", "0%"],
443
+ "tb" => ["0%", "0%", "0%", "100%"],
444
+ "ul-lr" => ["0%", "0%", "100%", "100%"],
445
+ "ll-ur" => ["0%", "100%", "100%", "0%"]
446
+ }
447
+
448
+ direction_coords = directions[@gradient_direction] || directions["lr"]
449
+ x1, y1, x2, y2 = direction_coords
450
+
451
+ background_svg = <<~GRADIENT
452
+ <defs>
453
+ <linearGradient id="grad1" x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}">
454
+ <stop offset="0%" style="stop-color:#{@gradient_start_color};stop-opacity:1" />
455
+ <stop offset="100%" style="stop-color:#{@gradient_end_color};stop-opacity:1" />
456
+ </linearGradient>
457
+ </defs>
458
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#grad1)' />
459
+ GRADIENT
460
+ else
461
+ # Solid color background
462
+ background_svg = "<rect x='0' y='0' width='100%' height='100%' fill='#{@background}' />"
463
+ end
464
+
465
+ # Build style strings
466
+ title_style = "font-family: #{@font}; "
467
+ title_style << "font-size: #{@title_font_size}px; "
468
+ title_style << "font-weight: #{@title_weight}; "
469
+ title_style << "font-style: #{@title_style}"
470
+
471
+ subtitle_style = "font-family: #{@font}; "
472
+ subtitle_style << "font-size: #{@subtitle_font_size}px; "
473
+ subtitle_style << "font-weight: #{@subtitle_weight}; "
474
+ subtitle_style << "font-style: #{@subtitle_style}"
475
+
476
+ # Get xy coordinates if set, otherwise use alignment fallbacks
477
+ if @title_xy_set && @title_xy
478
+ title_x = @title_xy[0]
479
+ title_y = @title_xy[1]
480
+ else
481
+ title_x = @title_align_x || '5%'
482
+ title_y = @title_align_y || '52%'
483
+ end
484
+ if @subtitle_xy_set && @subtitle_xy
485
+ subtitle_x = @subtitle_xy[0]
486
+ subtitle_y = @subtitle_xy[1]
487
+ else
488
+ subtitle_x = @subtitle_align_x || '5%'
489
+ subtitle_y = @subtitle_align_y || '82%'
490
+ end
491
+
492
+ title_svg = <<~EOS
493
+ <text x='#{title_x}'
494
+ y='#{title_y}'
495
+ text-anchor='#{@text_anchor}'
496
+ style='#{title_style}'
497
+ fill='#{@text_color}'>#@title</text>
498
+ EOS
499
+
500
+ # Call generate_svg to return the complete SVG
501
+ generate_svg
502
+ end
503
+
504
+ def generate_svg
505
+ check_invariants
506
+
507
+ width = 800 # Arbitrary starting point for calculations
508
+ height = (width / @aspect).to_i # height calculated based on aspect ratio
509
+
510
+ # Handle background (image, radial gradient, linear gradient, or solid color)
511
+ background_svg = ""
512
+ if @image_background
513
+ # Generate image background
514
+ background_svg = <<~IMAGE
515
+ <defs>
516
+ <pattern id="bg-pattern" x="0" y="0" width="100%" height="100%" patternUnits="objectBoundingBox">
517
+ <image href="#{@image_background}" x="0" y="0" width="100%" height="100%"
518
+ preserveAspectRatio="xMidYMid slice" />
519
+ </pattern>
520
+ </defs>
521
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#bg-pattern)' />
522
+ IMAGE
523
+ elsif @radial_start_color && @radial_end_color
524
+ # Calculate aspect ratio compensation for gradientTransform
525
+ ar = @radial_ar || (1.0 / @aspect)
526
+ # Compensate cx for X scaling so that cx visually matches the intended center
527
+ cx_val = @radial_cx
528
+ if cx_val.is_a?(String) && cx_val.strip.end_with?('%')
529
+ cx_num = cx_val.strip.chomp('%').to_f
530
+ cx_val = (cx_num / ar).to_s + '%'
531
+ end
532
+ gradient_transform = "gradientTransform=\"scale(#{ar},1)\"" if ar
533
+ background_svg = <<~RADIAL
534
+ <defs>
535
+ <radialGradient id="radial1" cx="#{cx_val}" cy="#{@radial_cy}" r="#{@radial_r}" #{gradient_transform}>
536
+ <stop offset="0%" style="stop-color:#{@radial_start_color};stop-opacity:1" />
537
+ <stop offset="100%" style="stop-color:#{@radial_end_color};stop-opacity:1" />
538
+ </radialGradient>
539
+ </defs>
540
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#radial1)' />
541
+ RADIAL
542
+ elsif @gradient_start_color && @gradient_end_color
543
+ # Generate linear gradient
544
+ directions = {
545
+ "lr" => ["0%", "0%", "100%", "0%"],
546
+ "tb" => ["0%", "0%", "0%", "100%"],
547
+ "ul-lr" => ["0%", "0%", "100%", "100%"],
548
+ "ll-ur" => ["0%", "100%", "100%", "0%"]
549
+ }
550
+
551
+ direction_coords = directions[@gradient_direction] || directions["lr"]
552
+ x1, y1, x2, y2 = direction_coords
553
+
554
+ background_svg = <<~GRADIENT
555
+ <defs>
556
+ <linearGradient id="grad1" x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}">
557
+ <stop offset="0%" style="stop-color:#{@gradient_start_color};stop-opacity:1" />
558
+ <stop offset="100%" style="stop-color:#{@gradient_end_color};stop-opacity:1" />
559
+ </linearGradient>
560
+ </defs>
561
+ <rect x='0' y='0' width='100%' height='100%' fill='url(#grad1)' />
562
+ GRADIENT
563
+ else
564
+ # Solid color background
565
+ background_svg = "<rect x='0' y='0' width='100%' height='100%' fill='#{@background}' />"
566
+ end
567
+
568
+ # Build style strings
569
+ title_style = "font-family: #{@font}; "
570
+ title_style << "font-size: #{@title_font_size}px; "
571
+ title_style << "font-weight: #{@title_weight}; "
572
+ title_style << "font-style: #{@title_style}"
573
+
574
+ subtitle_style = "font-family: #{@font}; "
575
+ subtitle_style << "font-size: #{@subtitle_font_size}px; "
576
+ subtitle_style << "font-weight: #{@subtitle_weight}; "
577
+ subtitle_style << "font-style: #{@subtitle_style}"
578
+
579
+ title_color = @title_color || @text_color
580
+ subtitle_color = @subtitle_color || @text_color
581
+
582
+ # Get xy coordinates if set, otherwise use alignment fallbacks
583
+ if @title_xy_set && @title_xy
584
+ title_x = @title_xy[0]
585
+ title_y = @title_xy[1]
586
+ else
587
+ title_x = @title_align_x || '5%'
588
+ title_y = @title_align_y || '52%'
589
+ end
590
+
591
+ if @subtitle_xy_set && @subtitle_xy
592
+ subtitle_x = @subtitle_xy[0]
593
+ subtitle_y = @subtitle_xy[1]
594
+ else
595
+ subtitle_x = @subtitle_align_x || '5%'
596
+ subtitle_y = @subtitle_align_y || '82%'
597
+ end
598
+
599
+ title_anchor = @title_text_anchor || @text_anchor
600
+ subtitle_anchor = @subtitle_text_anchor || @text_anchor
601
+
602
+ title_svg = <<~EOS
603
+ <text x='#{title_x}'
604
+ y='#{title_y}'
605
+ text-anchor='#{title_anchor}'
606
+ style='#{title_style}'
607
+ fill='#{title_color}'>#@title</text>
608
+ EOS
609
+ subtitle_svg = <<~EOS
610
+ <text x='#{subtitle_x}'
611
+ y='#{subtitle_y}'
612
+ text-anchor='#{subtitle_anchor}'
613
+ style='#{subtitle_style}'
614
+ fill='#{subtitle_color}'>#@subtitle</text>
615
+ EOS
616
+
617
+ # Define the SVG output
618
+ # Use different preserveAspectRatio for radial gradients to maintain circular shape
619
+ preserve_aspect = if @radial_start_color && @radial_end_color && @preserve_aspect
620
+ @preserve_aspect
621
+ elsif @radial_start_color && @radial_end_color
622
+ 'xMidYMid slice' # Default for radial gradients: crop to maintain aspect ratio
623
+ else
624
+ 'xMidYMid meet' # Default for other backgrounds: fit within bounds
625
+ end
626
+
627
+ svg = <<~SVG
628
+ <svg xmlns='http://www.w3.org/2000/svg'
629
+ width='100%' height='#{height}'
630
+ viewBox='0 0 #{width} #{height}'
631
+ preserveAspectRatio='#{preserve_aspect}'>
632
+ #{background_svg}
633
+ #{title_svg}
634
+ #{subtitle_svg}
635
+ </svg>
636
+ SVG
637
+
638
+ svg
639
+ end
640
+
641
+ def get_svg
642
+ check_invariants
643
+
644
+ # Generate SVG without re-parsing config (use current instance variables)
645
+ svg_code = generate_svg
646
+ svg_lines = svg_code.split("\n").map {|line| " "*6 + line }
647
+ svg_code = svg_lines.join("\n")
648
+
649
+ # Calculate coordinates safely
650
+ title_x = @title_xy_set && @title_xy ? @title_xy[0] : (@title_align_x || '5%')
651
+ title_y = @title_xy_set && @title_xy ? @title_xy[1] : (@title_align_y || '52%')
652
+ subtitle_x = @subtitle_xy_set && @subtitle_xy ? @subtitle_xy[0] : (@subtitle_align_x || '5%')
653
+ subtitle_y = @subtitle_xy_set && @subtitle_xy ? @subtitle_xy[1] : (@subtitle_align_y || '82%')
654
+
655
+ code = <<~EOS
656
+ <script>
657
+ function insert_svg_header(container) {
658
+ const svg_text = `#{svg_code}`;
659
+ const svgElement = document.createElement('div');
660
+ svgElement.innerHTML = svg_text;
661
+ const svg = svgElement.firstElementChild;
662
+
663
+ const svgWidth = window.innerWidth;
664
+ const aspectRatio = #{@aspect};
665
+ const svgHeight = svgWidth / aspectRatio;
666
+
667
+ svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
668
+ svg.setAttribute('width', svgWidth);
669
+ svg.setAttribute('height', svgHeight);
670
+
671
+ const titleFontSize = #{@title_scale} * #{@base_font_size};
672
+ const subtitleFontSize = #{@subtitle_scale} * #{@base_font_size};
673
+
674
+ const te1 = svg.querySelector('text:nth-of-type(1)')
675
+ const te2 = svg.querySelector('text:nth-of-type(2)')
676
+
677
+ // Don't override the styles - they're already set correctly in the SVG
678
+ // Just update the positioning and text-anchor
679
+
680
+ const titleXpct = "#{title_x}";
681
+ const titleYpct = "#{title_y}";
682
+ const subtitleXpct = "#{subtitle_x}";
683
+ const subtitleYpct = "#{subtitle_y}";
684
+
685
+ const tX = svgWidth * (parseFloat(titleXpct) / 100);
686
+ const tY = svgHeight * (parseFloat(titleYpct) / 100);
687
+ const sX = svgWidth * (parseFloat(subtitleXpct) / 100);
688
+ const sY = svgHeight * (parseFloat(subtitleYpct) / 100);
689
+
690
+ te1.setAttribute('x', tX);
691
+ te1.setAttribute('y', tY);
692
+ te2.setAttribute('x', sX);
693
+ te2.setAttribute('y', sY);
694
+
695
+ // Set text-anchor for proper positioning (use individual anchors if set)
696
+ te1.setAttribute('text-anchor', '#{@title_text_anchor || @text_anchor}');
697
+ te2.setAttribute('text-anchor', '#{@subtitle_text_anchor || @text_anchor}');
698
+
699
+ // Apply calculated font sizes
700
+ te1.setAttribute('font-size', titleFontSize + 'px');
701
+ te2.setAttribute('font-size', subtitleFontSize + 'px');
702
+
703
+ const containerElement = document.getElementById(container);
704
+ if (containerElement) {
705
+ console.log('Container found, inserting SVG...');
706
+ containerElement.innerHTML = svg.outerHTML;
707
+ console.log('SVG inserted successfully');
708
+ } else {
709
+ console.error('Container not found:', container);
710
+ }
711
+ }
712
+
713
+ console.log('SVG script loaded');
714
+
715
+ // Use DOMContentLoaded to avoid conflicts with main window.onload
716
+ document.addEventListener('DOMContentLoaded', function() {
717
+ console.log('DOM ready, trying SVG insertion...');
718
+ insert_svg_header('header');
719
+ });
720
+ </script>
721
+ EOS
722
+
723
+ code
724
+ end
725
+
726
+ # How to call?? bsvg = BannerSVG.new(...); bsvg.parse_svg_header; code = bsvg.get_svg # Simplify?
727
+ # Doesn't output, just returns string...
728
+
729
+ end