"""Focused browser checks for reader CSS, independent of format-engine suites."""
import io
import os
import unittest
import urllib.parse
import zipfile
from PIL import Image
from tests.test_reader_performance import (
STORE_SCRIPT,
PlaywrightError,
epub_with_navigation,
minimal_docx,
static_server,
sync_playwright,
zip_bytes,
)
from tests import test_reader_performance as support
@unittest.skipIf(sync_playwright is None, "install requirements-test.txt for reader style tests")
class ReaderStyleTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = static_server()
cls.origin = cls.server.__enter__()
cls.addClassCleanup(cls.server.__exit__, None, None, None)
cls.playwright = sync_playwright().start()
cls.addClassCleanup(cls.playwright.stop)
try:
cls.browser = getattr(cls.playwright, os.environ.get("READER_STYLE_BROWSER", "chromium")).launch(headless=True)
except PlaywrightError as error:
raise unittest.SkipTest(str(error))
cls.addClassCleanup(cls.browser.close)
def setUp(self):
context = self.browser.new_context(viewport={"width": 1440, "height": 900})
self.addCleanup(context.close)
self.page = context.new_page()
self.page.add_init_script("localStorage.setItem('theme', 'dark')")
self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(
content_type="text/javascript", body=STORE_SCRIPT
))
self.page.route("**/api/reader-content**", lambda route: route.fulfill(
content_type="text/plain", body="Reader style fixture\n" * 80
))
source = urllib.parse.quote(
"https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/style.txt", safe=""
)
self.page.goto(
f"{self.origin}/static/reader.html?url={source}&ext=txt&title=Style&path=Test",
wait_until="domcontentloaded",
)
self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'")
def settle(self):
self.page.wait_for_function("""() =>
!document.documentElement.classList.contains('theme-transition') &&
!document.getAnimations().some(animation => animation.playState === 'running')
""")
def test_back_and_download_icons_have_stable_browser_independent_geometry(self):
self.page.evaluate("""() => {
document.querySelector('.page-controls').hidden = false;
document.querySelector('.zoom-controls').hidden = false;
}""")
for theme in ("dark", "light"):
self.page.evaluate("theme => document.documentElement.dataset.theme = theme", theme)
for width in (1100, 390, 320):
self.page.set_viewport_size({"width": width, "height": 900})
for selector in ("#back", "#download"):
with self.subTest(theme=theme, width=width, selector=selector):
button = self.page.locator(selector)
geometry = button.evaluate("""button => {
const icon = button.querySelector('svg');
if (!icon) return null;
const b = button.getBoundingClientRect(), i = icon.getBoundingClientRect();
const toolbar = button.closest('header').getBoundingClientRect();
const style = getComputedStyle(button);
return {width: b.width, height: b.height, iconWidth: i.width, iconHeight: i.height,
dx: i.x + i.width / 2 - b.x - b.width / 2,
dy: i.y + i.height / 2 - b.y - b.height / 2,
fits: b.left >= 0 && b.right <= innerWidth && b.top >= toolbar.top && b.bottom <= toolbar.bottom,
padding: style.padding, appearance: style.appearance,
stroke: getComputedStyle(icon).stroke, color: style.color,
hiddenIcon: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable')};
}""")
self.assertIsNotNone(geometry)
self.assertEqual((geometry["width"], geometry["height"]), (32, 32) if width > 600 else (26, 30))
self.assertEqual((geometry["iconWidth"], geometry["iconHeight"]), (18, 18))
self.assertAlmostEqual(geometry["dx"], 0, delta=0.5)
self.assertAlmostEqual(geometry["dy"], 0, delta=0.5)
self.assertTrue(geometry["fits"], geometry)
self.assertEqual((geometry["padding"], geometry["appearance"]), ("0px", "none"))
self.assertEqual(geometry["stroke"], geometry["color"])
self.assertEqual((geometry["hiddenIcon"], geometry["focusable"]), ("true", "false"))
self.assertTrue(button.get_attribute("title"))
self.assertTrue(button.get_attribute("aria-label"))
before = button.bounding_box()
button.hover()
button.focus()
self.assertEqual(button.bounding_box(), before)
self.assertEqual(button.evaluate("e => getComputedStyle(e).outlineStyle"), "solid")
def assert_contrast(self, selector):
result = self.page.locator(selector).first.evaluate("""element => {
const rgb = value => value.match(/[\\d.]+/g).map(Number);
const luminance = color => rgb(color).slice(0, 3).reduce((sum, value, index) => {
const channel = value / 255;
return sum + [0.2126, 0.7152, 0.0722][index] *
(channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
}, 0);
const foreground = getComputedStyle(element).color;
let background = getComputedStyle(element).backgroundColor;
for (let parent = element.parentElement; rgb(background)[3] === 0 && parent;
parent = parent.parentElement) background = getComputedStyle(parent).backgroundColor;
const a = luminance(foreground), b = luminance(background);
return {foreground, background, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)};
}""")
self.assertGreaterEqual(result["ratio"], 4.5, (selector, result))
def test_search_result_uses_full_width_five_line_excerpt(self):
self.page.locator("#history").click()
self.page.locator("#full-search-toggle").click()
self.page.locator("#full-search-input").fill("Reader")
row = self.page.locator(".full-search-result").first
row.wait_for()
page_input = self.page.locator("#full-search-page")
page_input.wait_for()
before = page_input.bounding_box()
self.assertEqual(page_input.evaluate("e => getComputedStyle(e).borderTopColor"), "rgba(0, 0, 0, 0)")
page_input.click()
self.assertEqual(page_input.evaluate("e => getComputedStyle(e).outlineStyle"), "solid")
self.assertEqual(page_input.bounding_box()["width"], before["width"])
page_input.press("Tab")
self.assertEqual(page_input.evaluate("e => getComputedStyle(e).outlineStyle"), "none")
for width in (1440, 390, 320):
self.page.set_viewport_size({"width": width, "height": 900})
for rank in ("1.", "123456."):
with self.subTest(width=width, rank=rank):
metrics = row.evaluate("""(row, rank) => {
const number = row.querySelector('.full-search-rank');
const location = row.querySelector('.full-search-location');
const snippet = row.querySelector('.full-search-snippet');
number.textContent = rank;
location.textContent = 'Chapter location '.repeat(12);
snippet.textContent = 'Reader excerpt '.repeat(100);
const n = number.getBoundingClientRect(), l = location.getBoundingClientRect();
const s = snippet.getBoundingClientRect(), r = row.getBoundingClientRect();
return {gap: l.left - n.right, indent: s.left - n.left,
right: s.right - l.right, below: s.top >= l.bottom,
lines: s.height / parseFloat(getComputedStyle(snippet).lineHeight),
overflow: row.scrollWidth > row.clientWidth,
fits: r.right <= innerWidth};
}""", rank)
self.assertAlmostEqual(metrics["gap"], 4, delta=1)
self.assertAlmostEqual(metrics["indent"], 0, delta=1)
self.assertAlmostEqual(metrics["right"], 0, delta=1)
self.assertAlmostEqual(metrics["lines"], 5, delta=0.1)
self.assertTrue(metrics["below"] and metrics["fits"], metrics)
self.assertFalse(metrics["overflow"], metrics)
def test_light_contrast_after_theme_transition(self):
self.page.locator("#history").click()
self.page.locator("#theme-toggle").click()
self.settle()
self.page.locator("#full-search-toggle").click()
self.page.locator("#full-search-input").fill("Reader")
self.page.locator(".full-search-result").first.wait_for()
for selector in ("#full-search-input", ".full-search-status", ".full-search-location",
".full-search-snippet", "#full-search-toggle", "#reader-path"):
self.assert_contrast(selector)
self.page.locator("#reader-path").hover()
self.assert_contrast("#reader-path")
# Exercise document surfaces without loading external conversion engines.
self.page.locator("#content").evaluate("""content => content.insertAdjacentHTML('beforeend', `
Failed to load
Document link`)
""")
for selector in (".reader-error", ".epub-frame .reader-markdown", ".reader-markdown a"):
self.assert_contrast(selector)
def test_pdf_page_surface_follows_dark_theme_without_touching_text_layer(self):
self.page.route("**/api/reader-content**", lambda route: route.fulfill(
content_type="application/pdf", body=support.minimal_pdf()
))
source = urllib.parse.quote(
"https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/theme.pdf", safe=""
)
self.page.goto(
f"{self.origin}/static/reader.html?url={source}&ext=pdf&title=Theme PDF",
wait_until="domcontentloaded",
)
self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'")
canvas = self.page.locator('.reader-content[data-mode="pdf"] .reader-page canvas').first
self.page.locator('.reader-page canvas.ready').first.wait_for()
self.page.wait_for_function("() => document.querySelector('.reader-page')?.dataset.textReady === '1'")
self.page.locator('.reader-page').first.evaluate("node => window.__themePage = node")
text_layer = self.page.locator('.reader-pdf-text').first
self.assertEqual(text_layer.evaluate("e => getComputedStyle(e).filter"), "none")
self.assertEqual(canvas.evaluate("e => getComputedStyle(e).filter"), "none")
self.page.locator("#history").click()
self.page.locator("#page-invert-toggle").click()
for theme in ('light', 'dark'):
self.page.locator("#theme-toggle").click()
self.settle()
self.assertEqual(canvas.evaluate("e => getComputedStyle(e).filter"), 'invert(0.9) hue-rotate(180deg)')
self.page.locator("#history").click()
for width in (1100, 390):
self.page.set_viewport_size({"width": width, "height": 844})
self.page.locator('.reader-pdf-text-run').first.evaluate("node => {const range=document.createRange(); range.selectNodeContents(node); getSelection().removeAllRanges(); getSelection().addRange(range);}")
selected = self.page.evaluate("getSelection().toString()")
self.assertEqual(selected, 'Reader PDF')
original_pixels = canvas.evaluate("c => c.toDataURL()")
for theme in ("dark", "light", "dark"):
self.page.evaluate("theme => {document.documentElement.dataset.theme = theme; document.documentElement.dataset.pageInverted = String(theme === 'dark');}", theme)
self.settle()
bounds = self.page.locator('.reader-page').first.bounding_box()
png = Image.open(io.BytesIO(self.page.screenshot())).convert("RGB")
pixel = png.getpixel((round(bounds['x'] + bounds['width'] * .85), round(bounds['y'] + 30)))
self.assertTrue(max(pixel) < 50 if theme == "dark" else min(pixel) > 240, (theme, pixel))
self.assertEqual(canvas.evaluate("c => c.toDataURL()"), original_pixels)
self.assertTrue(self.page.evaluate("document.querySelector('.reader-page') === __themePage"))
self.assertEqual(self.page.evaluate("getSelection().toString()"), selected)
self.page.evaluate("getSelection().removeAllRanges()")
self.page.locator('#history').click()
self.page.locator('#full-search-toggle').click()
self.page.locator('#full-search-input').fill('Reader')
self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
mark = self.page.locator('.reader-page mark.full-search-highlight').first
self.assertEqual(mark.text_content(), 'Reader')
self.assertEqual(mark.evaluate("e => getComputedStyle(e).filter"), 'none')
self.assertNotEqual(mark.evaluate("e => getComputedStyle(e).backgroundColor"), 'rgba(0, 0, 0, 0)')
self.page.locator('#content').evaluate("node => node.dataset.mode = 'image-pages'")
self.assertEqual(self.page.locator('.reader-page canvas').first.evaluate("e => getComputedStyle(e).filter"), 'none')
def test_pdf_page_images_follow_theme_but_original_images_do_not(self):
output = io.BytesIO()
Image.new('RGB', (600, 800), 'white').save(output, 'WEBP')
self.page.route('**/api/reader-content**', lambda route: route.fulfill(
json={"version": 2, "kind": "pdf-pages", "page_count": 1}))
self.page.route('**/pages/page-*.webp', lambda route: route.fulfill(
content_type='image/webp', body=output.getvalue()))
source = urllib.parse.quote('https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/' + 'a' * 64 + '/page-manifest.json', safe='')
self.page.goto(f'{self.origin}/static/reader.html?url={source}&ext=pdf-pages')
image = self.page.locator('.reader-page img.ready').first
image.wait_for()
original = image.get_attribute('src')
self.assertEqual(image.evaluate('e => getComputedStyle(e).filter'), 'none')
self.page.locator('#history').click()
self.page.locator('#page-invert-toggle').click()
self.page.locator('#history').click()
for width in (1100, 390):
self.page.set_viewport_size({'width': width, 'height': 844})
for theme in ('dark', 'light', 'dark'):
self.page.evaluate("theme => {document.documentElement.dataset.theme = theme; document.documentElement.dataset.pageInverted = String(theme === 'dark');}", theme)
self.settle()
bounds = image.bounding_box()
png = Image.open(io.BytesIO(self.page.screenshot())).convert('RGB')
pixel = png.getpixel((round(bounds['x'] + bounds['width'] * .8), round(bounds['y'] + 30)))
self.assertTrue(max(pixel) < 50 if theme == 'dark' else min(pixel) > 240, (theme, pixel))
self.assertEqual(image.get_attribute('src'), original)
self.page.evaluate("document.querySelector('#content').dataset.mode = 'image-pages'")
self.assertEqual(image.evaluate('e => getComputedStyle(e).filter'), 'none')
def test_page_inversion_remembers_explicit_choice_and_fits_sidebar(self):
self.page.route('**/api/reader-content**', lambda route: route.fulfill(
content_type='application/pdf', body=support.minimal_pdf()))
source = urllib.parse.quote('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/inversion.pdf', safe='')
self.page.goto(f'{self.origin}/static/reader.html?url={source}&ext=pdf')
self.page.locator('.reader-page canvas.ready').first.wait_for()
button = self.page.locator('#page-invert-toggle')
self.assertEqual(button.get_attribute('aria-pressed'), 'false')
self.assertEqual(button.text_content(), '页面反色')
self.page.locator('#history').click()
button.click()
self.assertEqual(button.text_content(), '关闭页面反色')
self.assertEqual(self.page.evaluate("localStorage.getItem('reader-page-inversion')"), 'true')
for width in (1100, 390, 320):
self.page.set_viewport_size(dict(width=width, height=844))
self.page.locator('#full-search-toggle').click()
self.settle()
metrics = self.page.locator('#history-panel > header').evaluate('''header => {
const buttons = [...header.querySelectorAll('button')].filter(node => !node.hidden);
const bounds = header.getBoundingClientRect();
const boxes = buttons.map(node => node.getBoundingClientRect());
return {ids: buttons.map(node => node.id), fits: boxes.every(rect => rect.left >= bounds.left && rect.right <= bounds.right),
gaps: boxes.slice(1).map((rect, index) => rect.left - boxes[index].right), overflow: header.scrollWidth > header.clientWidth};
}''')
self.assertEqual(metrics['ids'], ['full-search-toggle', 'page-invert-toggle', 'theme-toggle', 'history-close'])
self.assertTrue(metrics['fits'], metrics)
self.assertFalse(metrics['overflow'], metrics)
self.assertTrue(all(gap >= 0 for gap in metrics['gaps']), metrics)
self.page.reload()
self.page.locator('.reader-page canvas.ready').first.wait_for()
self.assertEqual(button.get_attribute('aria-pressed'), 'true')
self.assertEqual(button.text_content(), '关闭页面反色')
self.page.locator('#history').click()
self.page.locator('#theme-toggle').click()
self.settle()
self.assert_contrast('#page-invert-toggle')
self.assertEqual(self.page.locator('canvas.ready').first.evaluate('e => getComputedStyle(e).filter'), 'invert(0.9) hue-rotate(180deg)')
button.click()
self.page.reload()
self.page.locator('.reader-page canvas.ready').first.wait_for()
self.assertEqual(button.get_attribute('aria-pressed'), 'false')
self.assertEqual(self.page.locator('canvas.ready').first.evaluate('e => getComputedStyle(e).filter'), 'none')
def open_theme_fixture(self, extension, data):
self.page.route("**/api/reader-content**", lambda route: route.fulfill(body=data))
source = urllib.parse.quote(
f"https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/{'a' * 64}/docx-native-v2/document.docx"
if extension == "docx" else "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/theme.epub", safe=""
)
self.page.goto(f"{self.origin}/static/reader.html?url={source}&ext={extension}&title=Theme")
self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'")
def check_document_theme_round_trip(self, selector):
target = self.page.locator(selector).first
initial = target.bounding_box()
for theme in ("dark", "light", "dark"):
if self.page.locator("html").get_attribute("data-theme") != theme:
self.page.locator("#history").click()
self.page.locator("#theme-toggle").click()
self.page.locator("#history").click()
self.settle()
colors = target.evaluate("e=>[getComputedStyle(e).color,getComputedStyle(e).backgroundColor]")
self.assertEqual(colors, ["rgb(231, 233, 235)", "rgba(0, 0, 0, 0)"] if theme == "dark"
else ["rgb(0, 0, 0)", "rgb(255, 255, 255)"])
self.assertEqual(target.bounding_box(), initial)
def test_docx_shaded_runs_and_paragraphs_follow_theme(self):
with zipfile.ZipFile(io.BytesIO(minimal_docx())) as archive:
files = {name: archive.read(name) for name in archive.namelist()}
files["word/document.xml"] = files["word/document.xml"].decode().replace(
"", ''
''
)
self.open_theme_fixture("docx", zip_bytes(files))
self.check_document_theme_round_trip(".reader-docx p span")
self.assertEqual(self.page.locator(".reader-docx p").first.evaluate(
"e=>getComputedStyle(e).backgroundColor"), "rgba(0, 0, 0, 0)")
def test_epub_legacy_font_colors_and_shading_follow_shadow_theme(self):
with zipfile.ZipFile(io.BytesIO(epub_with_navigation())) as archive:
files = {name: archive.read(name) for name in archive.namelist()}
files["OEBPS/nav.xhtml"] = files["OEBPS/nav.xhtml"].decode().replace(
"