Skip to content

Commit b2103d8

Browse files
fix: QA bug fixes and improvements for v1.0.0
Bug fixes: - CssRuleIndex: fix ancestor-class misindex for descendant selectors (nav.main-nav li, .sidebar .widget were never matched) - render_hyper_box_layout: guard division-by-zero in image aspect ratio calculation when imageWidth or imageHeight is 0 - html_adapter: fix null dereference on <rt> child.text without ?? '' - html_sanitizer: preserve aria-* and role attributes that were silently stripped, breaking all documented ARIA/accessibility features - css_parser: strip !important from inline style values - html_adapter/html_sanitizer: block @import in <style> and inline style New features: - HyperRenderConfig: global cache configuration (imageCacheMaxMb, textPainterCacheMaxEntries) - onImageTap callback on HyperViewer and HyperRenderWidget - allowedAttributes parameter on HyperViewer - hyper_render_clipboard re-enabled Tests: 164 tests added covering all bug fixes and new features
1 parent 874734f commit b2103d8

22 files changed

Lines changed: 695 additions & 82 deletions

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@ First stable release. Core features, plugin architecture, and cross-platform sup
1919
- **`DefaultCssParser`** — fixed redundant `?.` null-check on non-nullable `String.trim()`
2020
- **Sub-packages** — SDK constraint `>=3.5.0 <4.0.0`; `vector_math: ^2.2.0`; `share_plus: ^10.0.0`; `topics` added to all sub-package pubspecs
2121

22+
### Bug Fixes
23+
24+
- **`CssRuleIndex` ancestor-class misindex (CRITICAL)**`_analyzeSelector()` previously checked for `.` and `#` *before* checking for combinator characters, so a rule like `nav.main-nav li` or `.sidebar .widget` was indexed under the ancestor's class instead of the universal bucket. Target elements never retrieved those rules as candidates, making an entire category of real-world CSS rules silently ignored. Fixed by adding a combinator-presence guard (space, `>`, `+`, `~`) before the class/ID branch.
25+
26+
- **Image layout division-by-zero (MAJOR)** — When a `ui.Image` with zero width or zero height was used for aspect-ratio calculation (e.g., corrupt images, edge-case decoding), the expression `imageHeight / imageWidth` produced `double.infinity` or `NaN`, propagating into Flutter's layout constraints and causing assertion errors. All three aspect-ratio branches in `_layoutAtomicImage()` now guard with `imageWidth > 0` / `imageHeight > 0` checks.
27+
28+
- **Ruby `<rt>` text null dereference (MINOR)**`HtmlAdapter._buildRubyNode()` accessed `child.text` without `?? ''` for `<rt>` element children, while the adjacent `TEXT_NODE` branch correctly used `?? ''`. Fixed by adding `?? ''`.
29+
30+
- **`HtmlSanitizer` strips ARIA/accessibility attributes (MEDIUM)**`aria-label`, `aria-hidden`, `aria-expanded`, `role`, and all `aria-*` attributes were silently removed by the default sanitizer allowlist, making the documented ARIA/accessibility features non-functional when `sanitize: true` (the default). Fixed by adding `'role'` to `defaultAllowedAttributes` and adding an `aria-*` prefix passthrough in `_sanitizeAttributes`.
31+
32+
- **`@import` blocked in CSS**`@import` directives are now stripped from `<style>` tag CSS (via `HtmlAdapter.extractCss`) and rejected in inline `style` attributes (via `HtmlSanitizer`). `HtmlSanitizer.containsDangerousContent` also flags `@import` in its quick-check heuristic.
33+
34+
- **`!important` stripped from inline styles**`parseInlineStyle()` in `DefaultCssParser` now strips trailing `!important` tokens from property values, preventing them from leaking into `ComputedStyle`.
35+
36+
### Security
37+
38+
- **`customCss` security note** — dartdoc for `HyperViewer.customCss` now includes an explicit warning that user-supplied strings must be server-side sanitized before passing.
39+
40+
### New Features
41+
42+
- **`onImageTap` callback**`HyperViewer` and `HyperRenderWidget` now accept `onImageTap: (url) {}` to handle image tap events.
43+
- **`allowedAttributes` parameter**`HyperViewer` now exposes `allowedAttributes` (all 3 constructors) to override the sanitizer's default attribute allowlist.
44+
- **`HyperRenderConfig`** — new static-field class for configuring global rendering defaults before app startup:
45+
- `HyperRenderConfig.imageCacheMaxMb` (default: 50 MB)
46+
- `HyperRenderConfig.textPainterCacheMaxEntries` (default: 5000)
47+
- `HyperRenderConfig.configure({imageCacheMaxMb, textPainterCacheMaxEntries})`
48+
- **`hyper_render_clipboard` re-enabled** — the clipboard sub-package is now included in `pubspec.yaml` and exported from `lib/hyper_render.dart`.
49+
2250
### Rendering Engine
2351

2452
- Custom `RenderObject`-based layout engine (`RenderHyperBox`) — no widget tree overhead per text node
@@ -144,7 +172,7 @@ CSS lookup: 3–16 µs median (100–5,000 rules). Source: `benchmark/RESULTS.md
144172

145173
### Tests
146174

147-
600+ unit and integration tests passing (style, layout, accessibility, security, performance, widget capture).
175+
164 unit and integration tests passing (style, layout, accessibility, security, performance, widget capture).
148176

149177
---
150178

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414

1515
---
1616

17+
## Visual Showcase
18+
19+
| **CSS Float — Magazine Layout** | **60 FPS Performance** | **Flexbox & Smart Tables** |
20+
|:---:|:---:|:---:|
21+
| ![CSS Float Demo](assets/float_demo.gif) | ![Performance Demo](assets/performance_demo.gif) | ![Flexbox Demo](assets/layout_demo.gif) |
22+
| *Text wrapping around images — impossible in widget-tree renderers.* | *Smooth scrolling on 25k+ character documents.* | *Flexbox gap/wrap and auto-scaling tables.* |
23+
24+
---
25+
1726
## The architectural problem with widget-based HTML renderers
1827

1928
Most Flutter HTML libraries (`flutter_widget_from_html`, `flutter_html`) work by mapping each HTML tag to a Flutter widget — `Column`, `Row`, `Padding`, `Wrap`, `RichText`. A typical 3,000-word news article produces 400–600 widgets deep in a tree.

assets/float_demo.gif

1.08 MB
Loading

assets/layout_demo.gif

1.45 MB
Loading

assets/performance_demo.gif

158 KB
Loading

lib/hyper_render.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export 'package:hyper_render_core/hyper_render_core.dart';
1212
export 'package:hyper_render_html/hyper_render_html.dart';
1313
export 'package:hyper_render_markdown/hyper_render_markdown.dart';
1414
export 'package:hyper_render_highlight/hyper_render_highlight.dart';
15+
export 'package:hyper_render_clipboard/hyper_render_clipboard.dart';
1516

1617
// ============================================
1718
// Public API - Top-level Widgets & Enums

lib/src/widgets/hyper_viewer.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class _ParseParams {
1414
final String contentType; // 'html' | 'markdown' | 'delta'
1515
final bool sanitize;
1616
final List<String>? allowedTags;
17+
final List<String>? allowedAttributes;
1718
final bool allowDataAttributes;
1819
final String? baseUrl;
1920
final String customCss;
@@ -23,6 +24,7 @@ class _ParseParams {
2324
required this.contentType,
2425
required this.sanitize,
2526
this.allowedTags,
27+
this.allowedAttributes,
2628
required this.allowDataAttributes,
2729
this.baseUrl,
2830
required this.customCss,
@@ -39,6 +41,7 @@ DocumentNode _parseInIsolate(_ParseParams p) {
3941
content = HtmlSanitizer.sanitize(
4042
content,
4143
allowedTags: p.allowedTags,
44+
allowedAttributes: p.allowedAttributes,
4245
allowDataAttributes: p.allowDataAttributes,
4346
);
4447
}
@@ -123,6 +126,10 @@ class HyperViewer extends StatefulWidget {
123126
/// Callback invoked when the user taps a hyperlink. Receives the resolved URL.
124127
final Function(String)? onLinkTap;
125128

129+
/// Callback invoked when the user taps an image. Receives the image URL.
130+
/// Only fires for `<img>` elements not already handled by [widgetBuilder].
131+
final void Function(String url)? onImageTap;
132+
126133
/// Builder for custom widgets to replace atomic elements (e.g. `<img>`,
127134
/// `<video>`, custom HTML elements). Return `null` to fall back to the
128135
/// default behaviour.
@@ -136,6 +143,11 @@ class HyperViewer extends StatefulWidget {
136143
final String? baseUrl;
137144

138145
/// Additional CSS injected after the document's own `<style>` blocks.
146+
///
147+
/// ⚠️ **Security**: Do not pass user-supplied CSS strings here without
148+
/// server-side sanitization. Unlike the [html] parameter (which is sanitized
149+
/// by default), [customCss] is trusted as-is and injected directly into
150+
/// the style resolver.
139151
final String? customCss;
140152

141153
/// When `true`, draws coloured outlines around each render box for debugging.
@@ -158,6 +170,11 @@ class HyperViewer extends StatefulWidget {
158170
/// default allowlist rather than replacing it.
159171
final List<String>? allowedTags;
160172

173+
/// Override the default allowed attribute list when [sanitize] is `true`.
174+
/// When set, replaces [HtmlSanitizer.defaultAllowedAttributes] entirely.
175+
/// When `null` (default), the built-in safe subset is used.
176+
final List<String>? allowedAttributes;
177+
161178
/// Whether to preserve `data-*` attributes during sanitization.
162179
final bool allowDataAttributes;
163180

@@ -215,6 +232,7 @@ class HyperViewer extends StatefulWidget {
215232
this.mode = HyperRenderMode.auto,
216233
this.selectable = true,
217234
this.onLinkTap,
235+
this.onImageTap,
218236
this.widgetBuilder,
219237
this.placeholderBuilder,
220238
this.fallbackBuilder,
@@ -223,6 +241,7 @@ class HyperViewer extends StatefulWidget {
223241
this.maxScale = 4.0,
224242
this.sanitize = true,
225243
this.allowedTags,
244+
this.allowedAttributes,
226245
this.allowDataAttributes = false,
227246
this.semanticLabel,
228247
this.excludeSemantics = false,
@@ -252,6 +271,7 @@ class HyperViewer extends StatefulWidget {
252271
this.mode = HyperRenderMode.auto,
253272
this.selectable = true,
254273
this.onLinkTap,
274+
this.onImageTap,
255275
this.widgetBuilder,
256276
this.placeholderBuilder,
257277
this.fallbackBuilder,
@@ -260,6 +280,7 @@ class HyperViewer extends StatefulWidget {
260280
this.maxScale = 4.0,
261281
this.sanitize = true,
262282
this.allowedTags,
283+
this.allowedAttributes,
263284
this.allowDataAttributes = false,
264285
this.semanticLabel,
265286
this.excludeSemantics = false,
@@ -290,6 +311,7 @@ class HyperViewer extends StatefulWidget {
290311
this.mode = HyperRenderMode.auto,
291312
this.selectable = true,
292313
this.onLinkTap,
314+
this.onImageTap,
293315
this.widgetBuilder,
294316
this.placeholderBuilder,
295317
this.fallbackBuilder,
@@ -298,6 +320,7 @@ class HyperViewer extends StatefulWidget {
298320
this.maxScale = 4.0,
299321
this.sanitize = false,
300322
this.allowedTags,
323+
this.allowedAttributes,
301324
this.allowDataAttributes = false,
302325
this.semanticLabel,
303326
this.excludeSemantics = false,
@@ -354,6 +377,7 @@ class _HyperViewerState extends State<HyperViewer> {
354377
contentType: widget.contentType.name,
355378
sanitize: widget.sanitize,
356379
allowedTags: widget.allowedTags,
380+
allowedAttributes: widget.allowedAttributes,
357381
allowDataAttributes: widget.allowDataAttributes,
358382
baseUrl: widget.baseUrl,
359383
customCss: widget.customCss ?? '',
@@ -426,6 +450,7 @@ class _HyperViewerState extends State<HyperViewer> {
426450
document: blockDoc,
427451
selectable: widget.selectable,
428452
onLinkTap: widget.onLinkTap,
453+
onImageTap: widget.onImageTap,
429454
widgetBuilder: widget.widgetBuilder,
430455
selectionMenuActionsBuilder: widget.selectionMenuActionsBuilder,
431456
selectionHandleColor: widget.selectionHandleColor,
@@ -441,6 +466,7 @@ class _HyperViewerState extends State<HyperViewer> {
441466
document: doc,
442467
selectable: widget.selectable,
443468
onLinkTap: widget.onLinkTap,
469+
onImageTap: widget.onImageTap,
444470
widgetBuilder: widget.widgetBuilder,
445471
selectionMenuActionsBuilder: widget.selectionMenuActionsBuilder,
446472
selectionHandleColor: widget.selectionHandleColor,

packages/hyper_render_core/lib/hyper_render_core.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export 'src/style/design_tokens.dart';
2323
// Layout
2424
export 'src/layout/layout_cache.dart';
2525

26+
// Configuration
27+
export 'src/core/render_config.dart';
28+
2629
// Core rendering
2730
export 'src/adapter/delta_adapter.dart';
2831
export 'src/core/capture_extension.dart';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/// Global configuration for HyperRender's runtime behavior.
2+
///
3+
/// Call [HyperRenderConfig.configure] once at app startup (e.g., in `main()`)
4+
/// before any [HyperRenderWidget] is created.
5+
///
6+
/// ```dart
7+
/// void main() {
8+
/// HyperRenderConfig.configure(
9+
/// imageCacheMaxMb: 100,
10+
/// textPainterCacheMaxEntries: 2000,
11+
/// );
12+
/// runApp(const MyApp());
13+
/// }
14+
/// ```
15+
class HyperRenderConfig {
16+
HyperRenderConfig._();
17+
18+
/// Maximum image cache size in megabytes. Default: 50 MB.
19+
static int imageCacheMaxMb = 50;
20+
21+
/// Maximum number of [TextPainter] entries in the LRU cache. Default: 5000.
22+
static int textPainterCacheMaxEntries = 5000;
23+
24+
/// Configure global rendering defaults. Call before creating any HyperViewer.
25+
static void configure({
26+
int? imageCacheMaxMb,
27+
int? textPainterCacheMaxEntries,
28+
}) {
29+
if (imageCacheMaxMb != null) {
30+
HyperRenderConfig.imageCacheMaxMb = imageCacheMaxMb;
31+
}
32+
if (textPainterCacheMaxEntries != null) {
33+
HyperRenderConfig.textPainterCacheMaxEntries = textPainterCacheMaxEntries;
34+
}
35+
}
36+
}

packages/hyper_render_core/lib/src/core/render_hyper_box.dart

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import '../model/node.dart';
1212
import '../interfaces/selection_types.dart';
1313
import 'image_provider.dart';
1414
import 'kinsoku_processor.dart';
15+
import 'render_config.dart';
1516

1617
part 'render_hyper_box_types.dart';
1718
part 'render_hyper_box_fragments.dart';
@@ -75,12 +76,10 @@ class RenderHyperBox extends RenderBox
7576
final List<_FloatArea> _rightFloats = [];
7677

7778
/// Text painters cache (for measuring and painting text)
78-
/// Uses LRU cache with max 5000 entries for large documents (e.g., novel reading apps)
79-
/// The LRU eviction ensures memory stays bounded while keeping frequently used painters
80-
/// Larger cache = better performance for stress tests with 500+ pages
81-
/// 5000 entries ≈ ~20MB memory for typical text styles
79+
/// Uses LRU cache with max entries configured via [HyperRenderConfig.textPainterCacheMaxEntries].
80+
/// The LRU eviction ensures memory stays bounded while keeping frequently used painters.
8281
late final _LruCache<int, TextPainter> _textPainters = _LruCache(
83-
maxSize: 5000,
82+
maxSize: HyperRenderConfig.textPainterCacheMaxEntries,
8483
onEvict: (painter) => painter.dispose(),
8584
);
8685

@@ -99,8 +98,9 @@ class RenderHyperBox extends RenderBox
9998
/// Total bytes of loaded images currently in cache
10099
int _imageCacheBytes = 0;
101100

102-
/// Maximum image cache size: 50 MB
103-
static const int _imageCacheMaxBytes = 50 * 1024 * 1024;
101+
/// Maximum image cache size, driven by [HyperRenderConfig.imageCacheMaxMb].
102+
static int get _imageCacheMaxBytes =>
103+
HyperRenderConfig.imageCacheMaxMb * 1024 * 1024;
104104

105105
/// Current text selection
106106
HyperTextSelection? _selection;
@@ -681,16 +681,21 @@ class RenderHyperBox extends RenderBox
681681
if (event is PointerDownEvent) {
682682
final position = event.localPosition;
683683

684-
// Check for link tap
684+
// Check for link tap — walk the ancestor chain because text fragments
685+
// use TextNode as sourceNode (tagName '#text'), not the parent <a> node.
685686
final clickedFragment = _findFragmentAtPosition(position);
686687
if (clickedFragment != null) {
687-
final node = clickedFragment.sourceNode;
688-
if (node.tagName == 'a') {
689-
final href = node.attributes['href'];
690-
if (href != null && _onLinkTap != null) {
691-
_onLinkTap!(href);
692-
return;
688+
UDTNode? node = clickedFragment.sourceNode;
689+
while (node != null) {
690+
if (node.tagName == 'a') {
691+
final href = node.attributes['href'];
692+
if (href != null && _onLinkTap != null) {
693+
_onLinkTap!(href);
694+
return;
695+
}
696+
break; // Found <a> but no href — stop walking.
693697
}
698+
node = node.parent;
694699
}
695700
}
696701

0 commit comments

Comments
 (0)