trmnl_preview 0.29.0 → 0.30.1

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: 323b81319ed9997a0d49fb77fbe563b30464e7b92ba533388f8d619b68d2426e
4
- data.tar.gz: 4ebe10b57d8815de4efb146c3ad4fa2376f8723160f4ae83748162620d38b716
3
+ metadata.gz: 69dba3cf0ef5187c4e4b70ea1e465161ebd2367f8d578ef6c94bc3527e5f437c
4
+ data.tar.gz: 8a1a53e6a0d6cc711eda1b26dde6a6b574956fecced07c260990fdfbec665159
5
5
  SHA512:
6
- metadata.gz: 99afef29e4890046747621ad575e45b7d51fb93c676921f32efb35c585f84fbfe160839b3d1f77b3cce319699fe187c302f0e498bda27de56a1982a9bfdc81e1
7
- data.tar.gz: 3190bf99270ccf553479edbb4f3bf97027216f0d147156a8c2ec57301ce533ed100bf0d9e4dd99cb876af740bee4ba0007ef433d6ad8670fb349b11f52c841fb
6
+ metadata.gz: 6ce0c7e09a2f6e3164c15eb9e8a6fa779302e5bb45573e498ca5ac64f98de88be9105751c530a9f3044cdae51cfa1edeb4d20a3d2936373bb4886f6404e5d4cc
7
+ data.tar.gz: d08dd7466a052fb6dfedcde3e90761e6a236d4c784689213fcf5b17cc575c68f11cb96ecab0a7fd0a08e00b4fbf76b67d6ec4b3ad39202045dd4b645363ceec0
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.30.1
5
+
6
+ - A screen on a device TRMNL scales, as the TRMNL X or a Kobo, runs its scripts at the CSS size and is scaled only for the picture, as TRMNL does. Before, the scripts measured the scaled page, so a table cut rows the device shows (7 of 14 on a TRMNL X) and clamped text kept lines the device cuts.
7
+
8
+ ## 0.30.0
9
+
10
+ - `trmnlp test` quits a Firefox that stopped answering before it starts a new one. The old geckodriver kept running until something killed it.
11
+ - `trmnlp test`'s dark mode check also fails a view where an image, SVG or canvas that shows something in light mode shows nothing in dark mode, such as a checkbox icon drawn in a fixed black, and names it. Before, a view lost to dark mode only when most of its content went, so one lost icon passed.
12
+ - `trmnlp test`'s check for text drawn over other text reads the pixels Firefox draws for each text, in place of glyph outlines estimated from font measurements. It now also fails a glyph that only touches the line next to it, such as the bottom of a quote's last line touching the name under it, which the estimates placed a few pixels off. Text that sits close without touching still passes.
13
+
4
14
  ## 0.29.0
5
15
 
6
16
  - `trmnlp test` fails a screen that draws text over other text, or content text under the title bar, and names both texts, such as a name printed across the last line of a quote. Only glyphs that share inked pixels count, so a unit tucked against a number passes.
@@ -1,6 +1,6 @@
1
1
  # Mirrored from the TRMNL design-system source.
2
2
  # Refresh with `rake framework:sync` — do not edit manually.
3
- latest: 3.4.0
3
+ latest: 3.4.2
4
4
  versions:
5
5
  - number: 0.0.1
6
6
  released_at: '2024-06-22'
@@ -94,3 +94,7 @@ versions:
94
94
  released_at: '2026-09-18'
95
95
  - number: 3.4.0
96
96
  released_at: '2026-09-28'
97
+ - number: 3.4.1
98
+ released_at: '2026-10-14'
99
+ - number: 3.4.2
100
+ released_at: '2026-10-14'
@@ -23,11 +23,7 @@ module TRMNLP
23
23
  return if @shutdown
24
24
 
25
25
  @shutdown = true
26
- @drivers.each do |driver|
27
- driver.quit
28
- rescue StandardError
29
- nil
30
- end
26
+ @drivers.each { quit(it) }
31
27
  @drivers.clear
32
28
  end
33
29
  end
@@ -72,9 +68,15 @@ module TRMNLP
72
68
 
73
69
  def recycle(driver)
74
70
  @mutex.synchronize do
75
- @drivers.delete(driver)
71
+ quit(@drivers.delete(driver))
76
72
  @driver_factory.call.tap { |d| @drivers << d }
77
73
  end
78
74
  end
75
+
76
+ def quit(driver)
77
+ driver&.quit
78
+ rescue StandardError
79
+ nil
80
+ end
79
81
  end
80
82
  end
@@ -24,8 +24,11 @@ module TRMNLP
24
24
  @requested_color_depth = opts[:color_depth]
25
25
  end
26
26
 
27
- # The page as it is shown: chart libraries swapped for TRMNL's copies.
28
- def html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
27
+ # The page as it is shown: chart libraries swapped for TRMNL's copies, laid out unscaled until the capture.
28
+ def html
29
+ CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
30
+ .sub('</head>', "#{Screenshot::LAYOUT_PASS_STYLE}</head>")
31
+ end
29
32
 
30
33
  def process
31
34
  output = @screenshot.call(html:, width:, height:)
@@ -34,6 +34,21 @@ module TRMNLP
34
34
  window.onresize = null;
35
35
  JS
36
36
 
37
+ # TRMNL lays out a device it scales (TRMNL X, most BYOD screens) at its CSS size while the page's scripts
38
+ # run, then scales the frozen page for the capture, so a script that measures sees what the device shows.
39
+ LAYOUT_PASS_STYLE = '<style id="trmnlp-layout-pass">.trmnl .screen { --pixel-ratio: 1 !important; }</style>'
40
+ APPLY_CAPTURE_SCALE = <<~JS
41
+ document.getElementById('trmnlp-layout-pass')?.remove();
42
+ document.documentElement.offsetHeight;
43
+ JS
44
+ # A map redraws its canvas at the capture scale before the timers it needs are frozen, for 8s at most.
45
+ REFRESH_MAPS = <<~JS
46
+ const done = arguments[arguments.length - 1];
47
+ if (typeof window.TRMNLMaps?.refresh !== 'function') return done(false);
48
+ const timeout = new Promise((resolve) => setTimeout(resolve, 8000));
49
+ Promise.race([window.TRMNLMaps.refresh(), timeout]).then(() => done(true), () => done(false));
50
+ JS
51
+
37
52
  def initialize(pool:, viewport_timeout: 5, fonts_timeout: FONTS_TIMEOUT_SECONDS)
38
53
  @pool = pool
39
54
  @viewport_timeout = viewport_timeout
@@ -123,6 +138,14 @@ module TRMNLP
123
138
  document.documentElement.style.overflow = 'hidden';
124
139
  document.body.style.overflow = 'hidden';
125
140
  JS
141
+ freeze_at_capture_scale(driver, html)
142
+ end
143
+
144
+ def freeze_at_capture_scale(driver, html)
145
+ return driver.execute_script(FREEZE_TIMERS + APPLY_CAPTURE_SCALE) unless html.match?(MAPS_DOCUMENT_PATTERN)
146
+
147
+ driver.execute_script(APPLY_CAPTURE_SCALE)
148
+ driver.execute_async_script(REFRESH_MAPS)
126
149
  driver.execute_script(FREEZE_TIMERS)
127
150
  end
128
151
 
@@ -10,19 +10,8 @@ const visiblePart = (rect, el) => {
10
10
  }
11
11
  return part;
12
12
  };
13
- const textMeasuringContext = document.createElement('canvas').getContext('2d');
14
- // A text range's box is as tall as its line, so the font's own box sits in the middle of it.
15
- const baselineOf = (rect, metrics) => rect.top + metrics.fontBoundingBoxAscent +
16
- (rect.height - metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
17
- const inkOf = (rect, text, style, oneLine) => {
18
- textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
19
- const metrics = textMeasuringContext.measureText(text);
20
- const baseline = baselineOf(rect, metrics);
21
- const left = oneLine ? rect.left - metrics.actualBoundingBoxLeft : rect.left;
22
- const right = oneLine ? rect.left + metrics.actualBoundingBoxRight : rect.right;
23
- return { left, right, top: baseline - metrics.actualBoundingBoxAscent,
24
- bottom: baseline + metrics.actualBoundingBoxDescent };
25
- };
13
+ // A glyph can reach past its line box, above it or below it, by up to about half its font size.
14
+ const widen = (rect, by) => ({ left: rect.left - by, top: rect.top - by, right: rect.right + by, bottom: rect.bottom + by });
26
15
  const texts = [];
27
16
  const range = document.createRange();
28
17
  const walker = document.createTreeWalker(document.querySelector('.view') || document.body, NodeFilter.SHOW_TEXT);
@@ -30,67 +19,41 @@ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
30
19
  const owner = node.parentElement;
31
20
  if (!node.data.trim() || owner instanceof SVGElement || owner.closest('script, style, svg') || !shown(owner)) continue;
32
21
  range.selectNodeContents(node);
33
- const style = getComputedStyle(owner);
34
- const lines = [...range.getClientRects()];
35
- lines.map((rect) => visiblePart(inkOf(rect, node.data.trim(), style, lines.length === 1), owner))
36
- .filter((rect) => area(rect) > 4)
37
- .forEach((rect) => texts.push({ node, owner, rect, text: node.data.trim().slice(0, 40) }));
22
+ const fontSize = parseFloat(getComputedStyle(owner).fontSize) || 16;
23
+ [...range.getClientRects()].map((line) => ({ line: visiblePart(line, owner), rect: visiblePart(widen(line, fontSize / 2), owner) }))
24
+ .filter(({ line }) => area(line) > 4)
25
+ .forEach(({ line, rect }) => texts.push({ owner, line, rect, text: node.data.trim().slice(0, 40) }));
38
26
  }
39
- const SHARED_INK_PIXELS = 4;
40
- const glyphCache = new Map();
41
- const glyphsOf = (node) => {
42
- if (glyphCache.has(node)) return glyphCache.get(node);
43
- const style = getComputedStyle(node.parentElement);
44
- const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
45
- const glyphs = [];
46
- for (let start = 0; start < node.data.length;) {
47
- const end = start + String.fromCodePoint(node.data.codePointAt(start)).length;
48
- const glyph = node.data.slice(start, end);
49
- if (glyph.trim()) {
50
- range.setStart(node, start);
51
- range.setEnd(node, end);
52
- [...range.getClientRects()].forEach((rect) => {
53
- textMeasuringContext.font = font;
54
- const metrics = textMeasuringContext.measureText(glyph);
55
- const box = visiblePart(inkOf(rect, glyph, style, false), node.parentElement);
56
- if (area(box) > 1) glyphs.push({ glyph, font, box, x: rect.left, baseline: baselineOf(rect, metrics) });
57
- });
58
- }
59
- start = end;
60
- }
61
- glyphCache.set(node, glyphs);
62
- return glyphs;
63
- };
64
- // Draws each glyph where the page put it and counts the pixels both inked, so a glyph tucked into the
65
- // empty corner of another glyph's box does not count.
66
- const inkPixels = (glyph, region) => {
67
- const canvas = document.createElement('canvas');
68
- canvas.width = Math.ceil(region.right - region.left);
69
- canvas.height = Math.ceil(region.bottom - region.top);
70
- const context = canvas.getContext('2d');
71
- context.font = glyph.font;
72
- context.fillText(glyph.glyph, glyph.x - region.left, glyph.baseline - region.top);
73
- const alpha = context.getImageData(0, 0, canvas.width, canvas.height).data;
74
- const inked = new Set();
75
- for (let i = 3; i < alpha.length; i += 4) if (alpha[i] > 127) inked.add(i);
76
- return inked;
77
- };
78
- const inkOverlaps = (one, other) => {
79
- const shared = intersect(one.box, other.box);
80
- if (area(shared) < 1) return false;
81
- const otherInk = inkPixels(other, shared);
82
- return [...inkPixels(one, shared)].filter((pixel) => otherInk.has(pixel)).length >= SHARED_INK_PIXELS;
83
- };
84
- const inkTouches = (a, b) => glyphsOf(a.node).some((glyph) => glyphsOf(b.node).some((other) => inkOverlaps(glyph, other)));
85
27
  const related = (a, b) => a === b || a.contains(b) || b.contains(a);
86
- const found = [];
28
+ const pairs = [];
29
+ const neighbors = new Map(texts.map((t) => [t.owner, new Set()]));
87
30
  texts.forEach((a, i) => texts.slice(i + 1).forEach((b) => {
88
- if (!related(a.owner, b.owner) && covers(a.rect, b.rect, 0) && inkTouches(a, b)) found.push(`"${a.text}" and "${b.text}"`);
31
+ if (a.owner === b.owner || !covers(a.rect, b.rect, 0)) return;
32
+ neighbors.get(a.owner).add(b.owner);
33
+ neighbors.get(b.owner).add(a.owner);
34
+ if (!related(a.owner, b.owner)) pairs.push([a, b]);
89
35
  }));
36
+ // Texts whose widened boxes do not meet share a group, and each group one screenshot.
37
+ const groups = new Map();
38
+ pairs.flat().map((t) => t.owner).forEach((owner) => {
39
+ if (groups.has(owner)) return;
40
+ const taken = new Set([...neighbors.get(owner)].map((other) => groups.get(other)));
41
+ let group = 0;
42
+ while (taken.has(group)) group += 1;
43
+ groups.set(owner, group);
44
+ });
45
+ groups.forEach((group, owner) => owner.setAttribute('data-trmnlp-text', group));
46
+ const candidates = pairs.map(([a, b]) => {
47
+ const region = intersect(a.rect, b.rect);
48
+ return { texts: [groups.get(a.owner), groups.get(b.owner)], names: `"${a.text}" and "${b.text}"`,
49
+ region: { left: region.left, top: region.top, width: region.right - region.left,
50
+ height: region.bottom - region.top } };
51
+ });
52
+ const underTitleBar = [];
90
53
  const titleBar = document.querySelector('.view .title_bar');
91
54
  if (titleBar) {
92
55
  const bar = titleBar.getBoundingClientRect();
93
- texts.filter((t) => !titleBar.contains(t.owner) && covers(t.rect, bar, 1 / 3))
94
- .forEach((t) => found.push(`"${t.text}" and the title bar`));
56
+ texts.filter((t) => !titleBar.contains(t.owner) && covers(t.line, bar, 1 / 3))
57
+ .forEach((t) => underTitleBar.push(`"${t.text}" and the title bar`));
95
58
  }
96
- return [...new Set(found)];
59
+ return { candidates, under_title_bar: [...new Set(underTitleBar)] };
@@ -21,6 +21,9 @@ module TRMNLP
21
21
  # Share of a layout's ink its dark mode render must keep. Of 17 recipes measured on 10-13, those that draw
22
22
  # well in dark mode kept 85% or more; those whose text turned black on black kept 0% to 15%.
23
23
  DARK_MODE_MIN_INK_KEPT = 0.5
24
+ DARK_MODE_MIN_LIGHT_IMAGE_INK_PIXELS = 16
25
+ # Share of an image's ink its dark mode render must keep: lost icons kept 0%, the lowest one shown 17% (10-14).
26
+ DARK_MODE_MIN_IMAGE_INK_KEPT = 0.05
24
27
 
25
28
  # Each select field's keyname and the values its options save, from the plugin's settings.yml.
26
29
  def self.select_field_values
@@ -61,7 +64,8 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
61
64
  TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.each do |view|
62
65
  it "keeps the #{view} view's content in dark mode" do
63
66
  light = trmnl.render(view:, **dark_mode_screen, **recipe_inputs)
64
- expect(trmnl.render(view:, **dark_mode_screen, **recipe_inputs, dark_mode: true)).to keep_the_content_of(light)
67
+ expect(trmnl.render(view:, **dark_mode_screen, **recipe_inputs, dark_mode: true))
68
+ .to keep_the_content_of(light).and keep_each_image_of(light)
65
69
  end
66
70
  end
67
71
 
@@ -103,6 +103,28 @@ RSpec::Matchers.define :keep_the_content_of do |light|
103
103
  end
104
104
  end
105
105
 
106
+ # Each image, SVG and canvas that stands out in light mode must still show in dark mode.
107
+ RSpec::Matchers.define :keep_each_image_of do |light|
108
+ def ink(screen, box) = TRMNLP::Testing::InkCount.new(screen.png_path, area: box).pixels
109
+
110
+ def lost_images(light, dark)
111
+ light.drawn_images.zip(dark.drawn_images).filter_map do |(name, light_box), (dark_name, dark_box)|
112
+ next unless name == dark_name
113
+
114
+ light_ink = ink(light, light_box)
115
+ lost = light_ink >= TRMNLP::Testing::DARK_MODE_MIN_LIGHT_IMAGE_INK_PIXELS &&
116
+ ink(dark, dark_box) < light_ink * TRMNLP::Testing::DARK_MODE_MIN_IMAGE_INK_KEPT
117
+ "#{name} at #{light_box.left.round},#{light_box.top.round}" if lost
118
+ end
119
+ end
120
+
121
+ match { |dark| (@lost = lost_images(light, dark)).empty? }
122
+ failure_message do |dark|
123
+ "expected #{dark.inspect} in dark mode to keep each image, but #{@lost.join(', ')} shows nothing in dark " \
124
+ 'mode: it is drawn in a fixed black (#000, black). Use currentColor, or image--adaptive on an <img>.'
125
+ end
126
+ end
127
+
106
128
  RSpec::Matchers.define :have_no_text_clamped_with_room_to_spare do
107
129
  match { |screen| screen.clamped_with_room.empty? }
108
130
  failure_message do |screen|
@@ -5,6 +5,7 @@ require 'delegate'
5
5
  require 'nokogiri'
6
6
 
7
7
  require_relative 'qr_scanner'
8
+ require_relative 'text_ink'
8
9
 
9
10
  module TRMNLP
10
11
  module Testing
@@ -111,10 +112,27 @@ module TRMNLP
111
112
  }).map((el) => `"${el.innerText.trim()}" (all of "${el.getAttribute('data-clamp-original').trim()}" fits)`);
112
113
  JS
113
114
 
114
- # Pairs of text whose ink is drawn over each other, and text drawn under the title bar. Each line's ink is cut
115
- # to what its clipping parents show; two count when their glyphs share inked pixels.
115
+ # Pairs of text near enough to touch, and text drawn under the title bar. TextInk decides which pairs touch.
116
116
  OVERLAPPING_TEXT_SCRIPT = File.read(File.join(__dir__, 'overlapping_text.js')).freeze
117
117
 
118
+ # Each drawn image, SVG and canvas in the view but the Framework's adaptive icon, its box cut to the screen.
119
+ DRAWN_IMAGES_SCRIPT = <<~JS
120
+ const screen = document.documentElement.getBoundingClientRect();
121
+ const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
122
+ (el.classList.length ? '.' + [...el.classList].join('.') : '');
123
+ return [...document.querySelectorAll('.view img, .view svg, .view canvas')]
124
+ .filter((el) => !el.parentElement.closest('svg') && !el.matches('img.image--adaptive[data-adaptive]') &&
125
+ el.checkVisibility({ opacityProperty: true }))
126
+ .map((el) => {
127
+ const box = el.getBoundingClientRect();
128
+ const left = Math.max(box.left, 0);
129
+ const top = Math.max(box.top, 0);
130
+ const right = Math.min(box.right, screen.right);
131
+ const bottom = Math.min(box.bottom, screen.bottom);
132
+ return { name: describe(el), left, top, right, bottom, width: right - left, height: bottom - top };
133
+ }).filter((image) => image.width >= 1 && image.height >= 1);
134
+ JS
135
+
118
136
  HIDDEN_MARK = 'data-trmnlp-hidden'
119
137
  # The DOM with every element Firefox does not draw marked, so Capybara's visible text is what is on screen.
120
138
  DRAWN_DOM_SCRIPT = <<~JS.freeze
@@ -154,8 +172,20 @@ module TRMNLP
154
172
 
155
173
  def overflowing(except: nil) = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT, except) }
156
174
 
175
+ def drawn_images
176
+ @browser.on(self) { it.execute_script(DRAWN_IMAGES_SCRIPT) }.map do |image|
177
+ [image['name'], Box.new(**image.slice(*Box.members.map(&:to_s)).transform_keys(&:to_sym))]
178
+ end
179
+ end
180
+
157
181
  def clamped_with_room = @browser.on(self) { it.execute_script(CLAMPED_WITH_ROOM_SCRIPT) }
158
- def overlapping_text = @browser.on(self) { it.execute_script(OVERLAPPING_TEXT_SCRIPT) }
182
+
183
+ def overlapping_text
184
+ @browser.on(self) do |driver|
185
+ found = driver.execute_script(OVERLAPPING_TEXT_SCRIPT)
186
+ TextInk.new(driver).overlapping(found['candidates']) + found['under_title_bar']
187
+ end
188
+ end
159
189
 
160
190
  # Script errors, unhandled rejections, console.error lines and files that failed to load.
161
191
  def problems = evaluate('window.__trmnlpProblems || []')
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_magick'
4
+ require 'tempfile'
5
+
6
+ module TRMNLP
7
+ module Testing
8
+ # Which candidate pairs of text share inked pixels, as Firefox draws them.
9
+ class TextInk
10
+ MIN_SHARED_INK_PIXELS = 4
11
+ TEXT_GROUP_ATTRIBUTE = 'data-trmnlp-text'
12
+ INK_ATTRIBUTE = 'data-trmnlp-ink'
13
+ HIDE_TEXT_STYLE = <<~CSS.freeze
14
+ .view, .view * { color: transparent !important; -webkit-text-fill-color: transparent !important;
15
+ -webkit-text-stroke-width: 0 !important; text-shadow: none !important; }
16
+ .view [#{INK_ATTRIBUTE}="black"] { color: #000 !important; -webkit-text-fill-color: #000 !important; }
17
+ .view [#{INK_ATTRIBUTE}="white"] { color: #fff !important; -webkit-text-fill-color: #fff !important; }
18
+ CSS
19
+
20
+ def initialize(driver)
21
+ @driver = driver
22
+ @masks = {}
23
+ end
24
+
25
+ # candidates: { 'texts' => [group, group], 'names' => String, 'region' => { left, top, width, height } }.
26
+ def overlapping(candidates)
27
+ return [] if candidates.empty?
28
+
29
+ hide_text
30
+ candidates.select { shared_pixels(it) >= MIN_SHARED_INK_PIXELS }.map { it['names'] }.uniq
31
+ ensure
32
+ show_text
33
+ end
34
+
35
+ private
36
+
37
+ def shared_pixels(candidate)
38
+ one, other = candidate['texts'].map { mask(it) }
39
+ one.shared_with(other, candidate['region'])
40
+ end
41
+
42
+ # One byte per pixel of the screen, above zero where the text inks.
43
+ Mask = Data.define(:width, :height, :pixels) do
44
+ def shared_with(other, region)
45
+ left, top = region.values_at('left', 'top').map { it.floor.clamp(0..) }
46
+ right = (region['left'] + region['width']).ceil.clamp(..width)
47
+ bottom = (region['top'] + region['height']).ceil.clamp(..height)
48
+ (top...bottom).sum { |row| (left...right).count { inked?(it, row) && other.inked?(it, row) } }
49
+ end
50
+
51
+ def inked?(column, row) = pixels.getbyte((row * width) + column).positive?
52
+ end
53
+
54
+ # Where the group inks more than half a pixel: the difference of a black and a white screenshot.
55
+ def mask(group)
56
+ @masks[group] ||= begin
57
+ black, white = %w[black white].map { screenshot_with(group, it) }
58
+ Tempfile.create(['text-ink', '.pgm']) do |pgm|
59
+ MiniMagick.convert do |convert|
60
+ convert << black.path << white.path << '-alpha' << 'off' << '-compose' << 'difference' << '-composite'
61
+ convert << '-separate' << '-evaluate-sequence' << 'max' << '-threshold' << '50%' << '-depth' << '8'
62
+ convert << pgm.path
63
+ end
64
+ parse_pgm(File.binread(pgm.path))
65
+ end
66
+ ensure
67
+ [black, white].compact.each(&:close!)
68
+ end
69
+ end
70
+
71
+ # A binary PGM: "P5", width, height and the largest value, then one byte per pixel.
72
+ def parse_pgm(data)
73
+ header = data.match(/\AP5\s+(\d+)\s+(\d+)\s+\d+\s/)
74
+ Mask.new(width: header[1].to_i, height: header[2].to_i, pixels: data.byteslice(header.end(0)..))
75
+ end
76
+
77
+ def screenshot_with(group, color)
78
+ run_on_group_elements(group, "el.setAttribute('#{INK_ATTRIBUTE}', '#{color}')")
79
+ Tempfile.new(['text-ink', '.png']).tap { @driver.save_screenshot(it.path) }
80
+ ensure
81
+ run_on_group_elements(group, "el.removeAttribute('#{INK_ATTRIBUTE}')")
82
+ end
83
+
84
+ def run_on_group_elements(group, statement)
85
+ selector = "[#{TEXT_GROUP_ATTRIBUTE}=\"#{group}\"]"
86
+ @driver.execute_script("document.querySelectorAll('#{selector}').forEach((el) => #{statement})")
87
+ end
88
+
89
+ def hide_text
90
+ @driver.execute_script(<<~JS, HIDE_TEXT_STYLE)
91
+ const style = document.createElement('style');
92
+ style.id = 'trmnlp-text-ink';
93
+ style.textContent = arguments[0];
94
+ document.head.appendChild(style);
95
+ JS
96
+ end
97
+
98
+ def show_text
99
+ @driver.execute_script("document.getElementById('trmnlp-text-ink')?.remove(); " \
100
+ "document.querySelectorAll('[#{TEXT_GROUP_ATTRIBUTE}]')" \
101
+ ".forEach((el) => el.removeAttribute('#{TEXT_GROUP_ATTRIBUTE}'))")
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.29.0'
4
+ VERSION = '0.30.1'
5
5
  end
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.29.0
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -496,6 +496,7 @@ files:
496
496
  - lib/trmnlp/testing/run.rb
497
497
  - lib/trmnlp/testing/screen.rb
498
498
  - lib/trmnlp/testing/snapshot.rb
499
+ - lib/trmnlp/testing/text_ink.rb
499
500
  - lib/trmnlp/transform_backend/http.rb
500
501
  - lib/trmnlp/transform_backend/subprocess.rb
501
502
  - lib/trmnlp/transform_backend/which.rb