trmnl_preview 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffe3bc9bab021853f3ca1002841681f0e3de4aa51972c98ddd6939cf6ffa4e8a
4
- data.tar.gz: e5100dba5bf58cf6998889f7cbb15abc62186325373c2b82c88fd3eb9aa602fb
3
+ metadata.gz: 5728e75c9a9b9508254942f91d4e64c09440014d2b40ede681b60e5fe054a847
4
+ data.tar.gz: 1330a50632f9757807e35aee4525f573d7923d639bf345c4e6efacb5ed994c78
5
5
  SHA512:
6
- metadata.gz: 9781cbf7eb27c811c0985c9c0765a1bff4f5255cf9b29e2cb8e0e7815672db18e4aa988262c700fa5b509268f11f67ca1b1b6d4357a58c21ae49985eb742b46e
7
- data.tar.gz: 2f8f56ef49b794eebda404afa2feb1f69b0d89a1565b2a7c4332a1252b4551337e84c9517088361bea1e2b97545352af955868ba7c5a2678bf11da6ac8cd9fcb
6
+ metadata.gz: ca4d3138556081762e15ffc3ed2644bed18c4bdd6edd3bfc2f6ed6e002653f47eae7d025ddc1c477b9fbc2092a5c4c5f09192489fd3ccfd9c44c13e1511b2f4b
7
+ data.tar.gz: 890fcbe509e699e77970de5590b26595e52c20ad0772f234fa25f837c73d8f489b5681eabceecad017ab6830ef2b8a233002b140559cbd7b90fe3e7748e39937
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.7.0
5
+
6
+ - Switch from Puppeteer + CDP to Selenium + WebDriver BiDi (@SorceressLyra)
7
+
8
+ ## 0.6.1
9
+
10
+ - Update trmnl-liquid to 0.4.0
11
+
4
12
  ## 0.6.0
5
13
 
6
14
  - Drop trmnl-component in lieu of plain iframe
data/lib/trmnlp/app.rb CHANGED
@@ -78,27 +78,19 @@ module TRMNLP
78
78
  end
79
79
 
80
80
  get "/render/#{view}.html" do
81
- @view = view
82
- @screen_classes = @context.screen_classes(params[:screen_classes])
83
-
84
- case view
85
- when 'half_horizontal'
86
- @mashup_classes = 'mashup mashup--1Tx1B'
87
- when 'half_vertical'
88
- @mashup_classes = 'mashup mashup--1Lx1R'
89
- when 'quadrant'
90
- @mashup_classes = 'mashup mashup--2x2'
91
- end
92
-
93
- erb :render_html do
94
- @context.render_template(view)
95
- end
81
+ @context.render_full_page(view, params)
96
82
  end
97
-
83
+
98
84
  get "/render/#{view}.png" do
99
85
  @view = view
100
- html = @context.render_full_page(view)
101
- generator = ScreenGenerator.new(html, image: true)
86
+ html = @context.render_full_page(view, params)
87
+
88
+ # Parse optional rendering params (sent by the web UI for PNG output)
89
+ width = params[:width] && params[:width].to_i
90
+ height = params[:height] && params[:height].to_i
91
+ color_depth = params[:color_depth] && params[:color_depth].to_i
92
+
93
+ generator = ScreenGenerator.new(html, image: true, width: width, height: height, color_depth: color_depth)
102
94
  temp_image = generator.process
103
95
 
104
96
  send_file temp_image.path, type: 'image/png', disposition: 'inline'
@@ -89,9 +89,9 @@ module TRMNLP
89
89
  if response.status == 200
90
90
  content_type = response.headers['content-type'].split(';').first.strip if response.headers.include?('content-type')
91
91
  case content_type
92
- when 'application/json'
92
+ when 'application/json', /^application\/.+\+json/
93
93
  json = wrap_array(JSON.parse(response.body))
94
- when 'text/xml', 'application/xml', 'application/rss+xml', 'application/atom+xml', 'application/soap+xml'
94
+ when 'text/xml', 'application/xml', /^application\/.+\+xml/
95
95
  json = wrap_array(Hash.from_xml(response.body))
96
96
  else
97
97
  puts "unknown content type received: #{response.headers['content-type']}"
@@ -127,7 +127,7 @@ module TRMNLP
127
127
  puts "webhook error: #{e.message}"
128
128
  end
129
129
 
130
- def render_template(view)
130
+ def render_liquid_template(view)
131
131
  template_path = paths.template(view)
132
132
  return "Missing template: #{template_path}" unless template_path.exist?
133
133
 
@@ -144,11 +144,11 @@ module TRMNLP
144
144
  e.message
145
145
  end
146
146
 
147
- def render_full_page(view)
147
+ def render_full_page(view, params = {})
148
148
  template = paths.render_template.read
149
149
 
150
- ERB.new(template).result(TemplateBinding.new(self, view).get_binding do
151
- render_template(view)
150
+ ERB.new(template).result(TemplateBinding.new(self, view, params).get_binding do
151
+ render_liquid_template(view)
152
152
  end)
153
153
  end
154
154
 
@@ -161,9 +161,18 @@ module TRMNLP
161
161
 
162
162
  # bindings must match the `GET /render/{view}.html` route in app.rb
163
163
  class TemplateBinding
164
- def initialize(context, view)
165
- @screen_classes = context.screen_classes
164
+ def initialize(context, view, params)
166
165
  @view = view
166
+ @screen_classes = context.screen_classes(params[:screen_classes])
167
+
168
+ case view
169
+ when 'half_horizontal'
170
+ @mashup_classes = 'mashup mashup--1Tx1B'
171
+ when 'half_vertical'
172
+ @mashup_classes = 'mashup mashup--1Lx1R'
173
+ when 'quadrant'
174
+ @mashup_classes = 'mashup mashup--2x2'
175
+ end
167
176
  end
168
177
 
169
178
  def get_binding = binding
@@ -1,111 +1,109 @@
1
1
  require 'mini_magick'
2
- require 'puppeteer-ruby'
2
+ require 'selenium-webdriver'
3
3
  require 'base64'
4
4
  require 'thread'
5
+ require 'tempfile'
6
+ require 'fileutils'
7
+ require 'uri'
5
8
 
6
9
  module TRMNLP
7
10
  class ScreenGenerator
8
11
  # Browser pool management for efficient resource usage
9
12
  class BrowserPool
10
13
  def initialize(max_size: 2)
11
- @browsers = []
14
+ @drivers = []
12
15
  @available = Queue.new
13
16
  @mutex = Mutex.new
14
17
  @max_size = max_size
15
18
  @shutdown = false
16
-
17
- # Register cleanup on exit
19
+
18
20
  at_exit { shutdown }
19
21
  end
20
-
21
- def with_page
22
- browser = nil
23
- page = nil
24
-
22
+
23
+ def with_driver
24
+ driver = nil
25
+
25
26
  begin
26
- browser = checkout_browser
27
- page = browser.new_page
28
- yield page
27
+ driver = checkout_driver
28
+ yield driver
29
29
  ensure
30
- # Clean up page but keep browser alive
31
- page&.close rescue nil
32
- checkin_browser(browser) if browser
30
+ checkin_driver(driver) if driver
33
31
  end
34
32
  end
35
-
33
+
36
34
  def shutdown
37
35
  @mutex.synchronize do
38
36
  return if @shutdown
39
37
  @shutdown = true
40
-
41
- # Close all browsers
42
- @browsers.each do |browser|
43
- browser.close rescue nil
38
+
39
+ @drivers.each do |driver|
40
+ driver.quit rescue nil
44
41
  end
45
- @browsers.clear
42
+
43
+ @drivers.clear
46
44
  end
47
45
  end
48
-
46
+
49
47
  private
50
-
51
- def checkout_browser
52
- # Try to get an available browser
53
- browser = @available.pop(true) rescue nil
54
-
55
- # If no browser available and we haven't reached max size, create a new one
56
- if browser.nil?
48
+
49
+ def checkout_driver
50
+ driver = @available.pop(true) rescue nil
51
+
52
+ if driver.nil?
57
53
  @mutex.synchronize do
58
- if @browsers.size < @max_size
59
- browser = create_browser
60
- @browsers << browser
54
+ if @drivers.size < @max_size
55
+ driver = create_driver
56
+ @drivers << driver
61
57
  end
62
58
  end
63
59
  end
64
-
65
- # If still no browser, wait for one to become available
66
- browser ||= @available.pop
67
-
68
- # Verify browser is still alive
60
+
61
+ driver ||= @available.pop
62
+
69
63
  begin
70
- browser.targets # Simple check to see if browser responds
71
- browser
64
+ # Ping the driver
65
+ driver.title
66
+ driver
72
67
  rescue
73
- # Browser is dead, create a new one
74
68
  @mutex.synchronize do
75
- @browsers.delete(browser)
76
- browser = create_browser
77
- @browsers << browser
69
+ @drivers.delete(driver)
70
+ driver = create_driver
71
+ @drivers << driver
78
72
  end
79
- browser
73
+ driver
80
74
  end
81
75
  end
82
-
83
- def checkin_browser(browser)
76
+
77
+ def checkin_driver(driver)
84
78
  return if @shutdown
85
- @available.push(browser)
79
+ @available.push(driver)
86
80
  end
87
-
88
- def create_browser
89
- Puppeteer.launch(
90
- product: 'firefox',
91
- headless: true,
92
- args: [
93
- "--window-size=800,480",
94
- "--disable-web-security"
95
- ]
96
- )
81
+
82
+ def create_driver
83
+ options = Selenium::WebDriver::Firefox::Options.new
84
+ options.add_argument('--headless')
85
+ options.add_argument('--disable-web-security')
86
+
87
+ driver = Selenium::WebDriver.for(:firefox, options: options)
88
+ # Set a default window size that will be consistent
89
+ driver.manage.window.maximize
90
+ driver
97
91
  end
98
92
  end
99
-
100
- # Class-level browser pool shared across all instances
93
+
101
94
  @@browser_pool = BrowserPool.new
102
-
95
+
103
96
  def initialize(html, opts = {})
104
97
  self.input = html
105
98
  self.image = !!opts[:image]
99
+
100
+ # Accept optional rendering parameters (width/height/color depth/dark mode)
101
+ @requested_width = opts[:width]
102
+ @requested_height = opts[:height]
103
+ @requested_color_depth = opts[:color_depth]
106
104
  end
107
105
 
108
- attr_accessor :input, :output, :image, :processor, :img_path
106
+ attr_accessor :input, :output, :image
109
107
 
110
108
  def process
111
109
  convert_to_image
@@ -117,80 +115,126 @@ module TRMNLP
117
115
 
118
116
  def convert_to_image
119
117
  retry_count = 0
120
-
118
+
121
119
  begin
122
- @@browser_pool.with_page do |page|
123
- # Configure page
124
- page.viewport = Puppeteer::Viewport.new(width: width, height: height)
125
-
126
- # Set content with appropriate wait strategy
127
- page.set_content(input, timeout: 10000)
128
-
129
- # Hide scrollbars
130
- page.evaluate(<<~JAVASCRIPT)
131
- () => {
132
- document.getElementsByTagName('html')[0].style.overflow = "hidden";
133
- document.getElementsByTagName('body')[0].style.overflow = "hidden";
120
+ @@browser_pool.with_driver do |driver|
121
+ # determine dimensions of toolbars, etc
122
+ borders = driver.execute_script(<<~JS)
123
+ return {
124
+ width: window.outerWidth - window.innerWidth,
125
+ height: window.outerHeight - window.innerHeight
134
126
  }
135
- JAVASCRIPT
127
+ JS
128
+
129
+ window_width = width + borders['width']
130
+ window_height = height + borders['height']
131
+ driver.manage.window.size = Selenium::WebDriver::Dimension.new(window_width, window_height)
136
132
 
137
- # Take screenshot
133
+ sleep(0.1)
134
+
135
+ prepare_page(driver)
136
+
138
137
  self.output = Tempfile.new(['screenshot', '.png'])
139
- page.screenshot(path: output.path, type: 'png')
138
+ driver.save_screenshot(output.path)
139
+ output.close
140
140
  end
141
- rescue Puppeteer::TimeoutError, Puppeteer::FrameManager::NavigationError => e
141
+ rescue Selenium::WebDriver::Error::TimeoutError,
142
+ Selenium::WebDriver::Error::WebDriverError => e
142
143
  retry_count += 1
143
- if retry_count <= 1
144
- retry
145
- else
146
- puts "ERROR -> ScreenGenerator#convert_to_image -> #{e.message}"
147
- raise
148
- end
144
+ retry if retry_count <= 1
145
+ raise
149
146
  end
150
147
  end
151
148
 
152
- def mono(img)
153
- MiniMagick::Tool::Convert.new do |m|
154
- m << img.path
155
- m.monochrome # Use built-in smart monochrome dithering (but it's not working as expected)
156
- m.depth(color_depth) # Should be set to 1 for 1-bit output
157
- m.strip # Remove any additional metadata
158
- m << img.path
149
+ def prepare_page(driver)
150
+ driver.navigate.to('about:blank')
151
+
152
+ driver.execute_script(<<~JS, input)
153
+ document.open();
154
+ document.write(arguments[0]);
155
+ document.close();
156
+ JS
157
+
158
+ Selenium::WebDriver::Wait.new(timeout: 5).until do
159
+ driver.execute_script('return document.readyState') == 'complete'
159
160
  end
161
+
162
+ # Wait for fonts (prevents layout shifts)
163
+ driver.execute_script('return document.fonts && document.fonts.ready')
164
+
165
+ driver.execute_script(<<~JS)
166
+ document.documentElement.style.overflow = 'hidden';
167
+ document.body.style.overflow = 'hidden';
168
+ JS
160
169
  end
170
+
171
+ def convert_with_mini_magick(img, depth)
172
+ tmp = Tempfile.new(['mono', '.png'])
173
+ tmp.close
174
+
175
+ levels = 2**depth
161
176
 
162
- def mono_image(img)
163
- # Convert to monochrome bitmap with proper dithering
164
- # This implementation works with both ImageMagick 6.x and 7.x
165
177
  MiniMagick::Tool::Convert.new do |m|
166
178
  m << img.path
167
-
168
- # First convert to grayscale to ensure proper channel handling
169
- m.colorspace << 'Gray'
170
-
171
- # Apply Floyd-Steinberg dithering for better quality
172
- m.dither << 'FloydSteinberg'
173
-
174
- # Remap to a 50% gray pattern for better dithering
175
- m.remap << 'pattern:gray50'
176
-
177
- # Set the image type to bilevel (1-bit black and white)
178
- m.type << 'Bilevel'
179
-
180
- # Set color depth to 1 bit
181
- m.depth << color_depth
182
-
183
- # Remove any metadata to reduce file size
179
+ m.colorspace 'Gray'
180
+ m.dither 'FloydSteinberg'
181
+
182
+ yield(m, depth, levels)
183
+
184
+ m.depth depth
185
+ m.define "png:bit-depth=#{depth}"
184
186
  m.strip
185
-
186
- m << img.path
187
+ m << tmp.path
187
188
  end
189
+
190
+ FileUtils.mv(tmp.path, img.path, force: true)
188
191
  end
189
192
 
190
- def width = 800
193
+ def mono(img)
194
+ depth = [[color_depth.to_i, 1].max, 8].min
191
195
 
192
- def height = 480
196
+ convert_with_mini_magick(img, depth) do |m, d, levels|
197
+ m.posterize levels
198
+ m.colors levels
199
+ m.type 'Bilevel' if d == 1
200
+ end
201
+ end
193
202
 
194
- def color_depth = 1
203
+ def mono_image(img)
204
+ depth = [[color_depth.to_i, 1].max, 8].min
205
+
206
+ convert_with_mini_magick(img, depth) do |m, d, levels|
207
+ if d == 1
208
+ # For true 1-bit, use a halftone/remap and bilevel output
209
+ m.remap 'pattern:gray50'
210
+ m.posterize 2
211
+ m.colors 2
212
+ m.type 'Bilevel'
213
+ else
214
+ m.posterize levels
215
+ m.colors levels
216
+ end
217
+ end
218
+ end
219
+
220
+
221
+ def width
222
+ @requested_width || 800
223
+ end
224
+
225
+ def height
226
+ @requested_height || 480
227
+ end
228
+
229
+ def color_depth
230
+ return @requested_color_depth if @requested_color_depth
231
+
232
+ # Try to infer color depth from the rendered HTML's screen classes
233
+ if input && input.match(/screen--(\d+)bit/)
234
+ return $1.to_i
235
+ end
236
+
237
+ 1
238
+ end
195
239
  end
196
240
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = "0.6.1".freeze
4
+ VERSION = "0.7.0".freeze
5
5
  end
@@ -47,7 +47,8 @@ Gem::Specification.new do |spec|
47
47
  spec.add_dependency "trmnl-liquid", "~> 0.4.0"
48
48
 
49
49
  # PNG rendering
50
- spec.add_dependency 'puppeteer-ruby', '~> 0.45.6'
50
+ # spec.add_dependency 'puppeteer-ruby', '~> 0.45.6'
51
+ spec.add_dependency 'selenium-webdriver', '~> 4.39'
51
52
  spec.add_dependency 'mini_magick', '~> 4.12.0'
52
53
 
53
54
  # Utilities
data/web/public/index.js CHANGED
@@ -27,7 +27,21 @@ trmnlp.connectLiveRender = function () {
27
27
  trmnlp.fetchPreview = function (pickerState) {
28
28
  const screenClasses = (pickerState?.screenClasses || trmnlp.picker.state.screenClasses).join(" ");
29
29
  const encodedScreenClasses = encodeURIComponent(screenClasses);
30
- const src = `/render/${trmnlp.view}.${trmnlp.formatSelect.value}?screen_classes=${encodedScreenClasses}`;
30
+ let src = `/render/${trmnlp.view}.${trmnlp.formatSelect.value}?screen_classes=${encodedScreenClasses}`;
31
+
32
+ // If requesting a PNG, also include dimensions, dark mode, and color depth
33
+ if (trmnlp.formatSelect.value === 'png') {
34
+ const state = pickerState || trmnlp.picker.state;
35
+ const width = encodeURIComponent(state.width);
36
+ const height = encodeURIComponent(state.height);
37
+ const isDarkMode = state.isDarkMode ? 1 : 0;
38
+
39
+ // derive numeric color depth from classes like 'screen--1bit'
40
+ const grays = state.palette.grays || 2;
41
+ const colorDepth = Math.ceil(Math.log2(grays));
42
+
43
+ src += `&width=${width}&height=${height}&color_depth=${colorDepth}`;
44
+ }
31
45
 
32
46
  trmnlp.spinner.style.display = "inline-block";
33
47
  trmnlp.iframe.src = src;
@@ -11,7 +11,7 @@
11
11
  <!-- End Inter font -->
12
12
  </head>
13
13
 
14
- <body class="environment trmnl" style="background: white">
14
+ <body class="environment trmnl">
15
15
  <div class="<%= @screen_classes %>">
16
16
  <% if @mashup_classes %><div class="<%= @mashup_classes %>"><% end %>
17
17
  <div class="view view--<%= @view %>">
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -94,19 +94,19 @@ dependencies:
94
94
  - !ruby/object:Gem::Version
95
95
  version: 0.4.0
96
96
  - !ruby/object:Gem::Dependency
97
- name: puppeteer-ruby
97
+ name: selenium-webdriver
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: 0.45.6
102
+ version: '4.39'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: 0.45.6
109
+ version: '4.39'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: mini_magick
112
112
  requirement: !ruby/object:Gem::Requirement