trmnl_preview 0.29.0 → 0.30.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/db/data/framework_versions.yml +5 -1
- data/lib/trmnlp/browser_pool.rb +8 -6
- data/lib/trmnlp/testing/overlapping_text.js +32 -69
- data/lib/trmnlp/testing/publishable_recipe.rb +5 -1
- data/lib/trmnlp/testing/rspec.rb +22 -0
- data/lib/trmnlp/testing/screen.rb +33 -3
- data/lib/trmnlp/testing/text_ink.rb +105 -0
- data/lib/trmnlp/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20431d997e72a0c534f9945c0c8e23cf97dcfff3be5119cbbf2d7973b2c3af73
|
|
4
|
+
data.tar.gz: 4a96842d311255ad1cd618e36dab4ef4eaa0504c58efe394a609ee5ddc043dba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6dcab087ef567886345b3ef14e90931af73c236680afb0555667357f277fd650eaed3c473fa7bb514196ca701cccbd3a68ee6f19e302e0be3d37a23bb5abcdb1
|
|
7
|
+
data.tar.gz: '070453995dac9d20fb77914c1a2adcfde9d01f2ccb34899f00b94cf238278b15a3afae3abfe33da9aae17dbacad5f842e34bd080c169ef256c58d144c449b9b5'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
|
|
2
2
|
# Changelog
|
|
3
3
|
|
|
4
|
+
## 0.30.0
|
|
5
|
+
|
|
6
|
+
- `trmnlp test` quits a Firefox that stopped answering before it starts a new one. The old geckodriver kept running until something killed it.
|
|
7
|
+
- `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.
|
|
8
|
+
- `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.
|
|
9
|
+
|
|
4
10
|
## 0.29.0
|
|
5
11
|
|
|
6
12
|
- `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.
|
|
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'
|
data/lib/trmnlp/browser_pool.rb
CHANGED
|
@@ -23,11 +23,7 @@ module TRMNLP
|
|
|
23
23
|
return if @shutdown
|
|
24
24
|
|
|
25
25
|
@shutdown = true
|
|
26
|
-
@drivers.each
|
|
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
|
|
@@ -10,19 +10,8 @@ const visiblePart = (rect, el) => {
|
|
|
10
10
|
}
|
|
11
11
|
return part;
|
|
12
12
|
};
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
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
|
|
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 (
|
|
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.
|
|
94
|
-
.forEach((t) =>
|
|
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(
|
|
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))
|
|
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
|
|
data/lib/trmnlp/testing/rspec.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/trmnlp/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.30.0
|
|
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
|