diff --git a/.pubignore b/.pubignore index a894a35..ac9cb9c 100644 --- a/.pubignore +++ b/.pubignore @@ -7,6 +7,13 @@ build/ *.dill.track.dill +# Crash reports / logs (.pubignore replaces .gitignore, so ignored files would +# otherwise be published — flutter_0N.log carries local paths and commands) +*.log +**/*.log +.DS_Store +**/.DS_Store + # Tests — not needed by package consumers test/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d6dd067..8720e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.11.0 + +- **Dark mode: `HyperViewer` text is readable on dark surfaces** ([#20](https://github.com/brewkits/hyper_render/issues/20)). Unstyled text was always the fixed dark gray `#1F2937`, with no way to change it from `HyperViewer`. Under a `Brightness.dark` `Theme` the default is now `colorScheme.onSurface`; under a light theme nothing changes. A theme toggle re-resolves styles in place (no loading state, scroll position kept). `EpubReader` goes through `HyperViewer`, so it follows the theme the same way. +- **Built-in light surfaces stay readable.** `
`, ``, `` and any author `background` without a `color` assume dark text; once a light default is in play they would inherit white on near-white. An element with its own opaque background and no color of its own now falls back to dark gray / white when the inherited text would be under 3:1 contrast. Only active when a host-supplied color exists (dark theme or `textColor`), so light-theme output without `textColor` is unchanged. An element's own `color` always wins. +- **New `HyperViewer(textColor:)`** — a host override of the document's text color. It wins over the content's own `html` / `:root` / `body` color (so an app can force a reader theme over a publisher stylesheet) but not over an element's own color (`p { color }`, inline `style`). Not available on `HyperViewer.fromNode`. +- **`body { color }` and `html { color }` now work.** The adapters only keep ``'s children, so those selectors could never match and were silently ignored. They now set the document root color, layered as in a browser (`html` < `:root` < `body`; a `body` declaration beats `html` / `:root` even when theirs is `!important`). **Behavior change:** content that declares `body { color: … }` (EPUB stylesheets often do) now renders in that color. Only `color` is honoured; other `body` properties (`display`, `margin`, `background`) are still ignored. Consequence: a publisher `body { color: #000 }` stays black on a dark theme — set `HyperViewer(textColor:)` / `EpubReader(textColor:)`, which win over it. On `HyperViewer`, `customCss` comes *before* the document's own `$_html', + css: 'body { color: #ffffff; }'); + expect(plain.red, greaterThan(200), + reason: 'the document wins at equal priority: $plain'); + + final forced = await _shoot(t, + brightness: Brightness.dark, + surface: Colors.black, + html: '$_html', + css: 'body { color: #ffffff !important; }'); + expect(forced.light, greaterThan(200), reason: '$forced'); + expect(forced.red, lessThan(50), reason: '$forced'); + }); + }); + + group('theme toggle in virtualized mode', () { + testWidgets('keeps the scroll offset and shows no placeholder', (t) async { + final long = List.generate(400, (i) => '

Paragraph $i text

').join(); + var placeholders = 0; + Widget app(Brightness b) => MaterialApp( + theme: ThemeData(brightness: b), + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: HyperViewer( + html: long, + mode: HyperRenderMode.virtualized, + placeholderBuilder: (_) { + placeholders++; + return const SizedBox(); + }, + renderConfig: const HyperRenderConfig( + useMicrotaskParsing: true, + virtualizationChunkSize: 1000, + ), + ), + ), + ), + ); + ScrollPosition position() => + t.state(find.byType(Scrollable).first).position; + double offset() => position().pixels; + await t.pumpWidget(app(Brightness.light)); + await t.pumpAndSettle(); + expect(find.byType(ListView), findsOneWidget); + position().jumpTo(1500); + await t.pumpAndSettle(); + final before = offset(); + expect(before, greaterThan(1000)); + final placeholdersBefore = placeholders; + + await t.pumpWidget(app(Brightness.dark)); + await t.pumpAndSettle(); + expect(offset(), closeTo(before, 1.0), + reason: 'a theme toggle must not reset the reader to the top'); + expect(placeholders, placeholdersBefore, + reason: 'no loading placeholder frame on a theme toggle'); + }); + }); +} diff --git a/test/integration/dark_mode_hardening_test.dart b/test/integration/dark_mode_hardening_test.dart new file mode 100644 index 0000000..5528bf8 --- /dev/null +++ b/test/integration/dark_mode_hardening_test.dart @@ -0,0 +1,254 @@ +// Hardening for the dark-mode / root-color code paths (issue #20). +// +// * security — the root color now flows through `_applyDeclarations` from +// `body` / `html` rules, so every bound that protects ordinary +// declarations (var() expansion cap, scheme checks) must hold +// there too, and a hostile value must never throw. +// * stress — repeated theme toggles, in every parse mode, on a large +// document: no exception, scroll and page position kept. +// * performance — body/html rules and a root color add no measurable cost to +// style resolution. +// +// Rendering behaviour itself is covered by test/dark_mode_text_color_test.dart. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +DocumentNode _resolve(String html, String css, {Color? override}) { + final doc = HtmlAdapter().parse(html); + StyleResolver() + ..rootColorOverride = override + ..parseCss(css) + ..resolveStyles(doc); + return doc; +} + +Color _firstP(DocumentNode doc) => + doc.children.firstWhere((n) => n.tagName == 'p').style.color; + +Future _pumpApp(WidgetTester t, Brightness b, Widget viewer) => + t.pumpWidget(MaterialApp( + theme: ThemeData(brightness: b), + home: Scaffold(body: SizedBox(width: 400, height: 600, child: viewer)), + )); + +void main() { + group('security: root color declarations', () { + test('a var() expansion bomb in body { color } is capped, not expanded', + () { + final css = StringBuffer(':root { --l0: #fff;'); + for (var i = 1; i <= 12; i++) { + css.write('--l$i: ${List.filled(10, 'var(--l${i - 1})').join(' ')};'); + } + css.write('} body { color: var(--l12); }'); + final sw = Stopwatch()..start(); + final doc = _resolve('

x

', css.toString()); + expect(sw.elapsed, lessThan(const Duration(seconds: 2))); + // An unparsable/over-long value is dropped, leaving the default color. + expect(_firstP(doc), const Color(0xFF1F2937)); + }); + + test('hostile body colors never throw and fall back to the default', () { + for (final value in [ + 'url(javascript:alert(1))', + 'expression(alert(1))', + 'var(--missing)', + 'var(--a)', // self reference below + '#', + 'rgb(', + '${'(' * 5000}red', + 'x' * 100000, + '\u0000\u0001', + ]) { + expect( + () => _resolve('

x

', ':root{--a:var(--a)} body{color:$value}'), + returnsNormally, + reason: value.length > 40 ? '${value.length} chars' : value, + ); + } + }); + + test('a 5000-rule stylesheet with body/html rules resolves in bounded time', + () { + final css = StringBuffer(); + for (var i = 0; i < 5000; i++) { + css.write('.c$i { color: #00$i; } '); + if (i % 500 == 0) { + css.write('body { color: #ff0000; } html { color: #0000ff; } '); + } + } + final sw = Stopwatch()..start(); + final doc = _resolve('

x

y

', css.toString()); + expect(sw.elapsed, lessThan(const Duration(seconds: 3))); + expect(doc.children.length, 2); + }); + + test('a reused resolver keeps its body rule, a fresh one starts clean', () { + // Root-color state lives on the resolver instance: re-using it applies + // the same rules to the next document (documented behaviour), while a + // new resolver must not inherit anything. + final r = StyleResolver()..parseCss('body { color: #ff0000; }'); + final a = HtmlAdapter().parse('

a

'); + r.resolveStyles(a); + expect(_firstP(a), const Color(0xFFFF0000)); + + r.parseCss(''); // empty: must keep prior rules, not crash + final b = HtmlAdapter().parse('

b

'); + r.resolveStyles(b); + expect(_firstP(b), const Color(0xFFFF0000)); + + final fresh = StyleResolver(); + final c = HtmlAdapter().parse('

c

'); + fresh.resolveStyles(c); + expect(_firstP(c), const Color(0xFF1F2937), + reason: 'a new resolver starts clean'); + }); + + test('an out-of-range textColor (alpha 0) still renders without throwing', + () { + expect( + () => _resolve('

x

', '', override: const Color(0x00000000)), + returnsNormally, + ); + }); + + testWidgets( + 'sanitizer still strips