diff --git a/ROADMAP.md b/ROADMAP.md index 03e78e7..b88b10b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ This document outlines the architectural roadmap for **HyperRender** to become t | **v1.10.0** | Shipped 2026-10-02 | **DevTools v2 & Stylesheet CSS Correctness** | Timeline / Selection / live CSS-variable / snapshot DevTools tabs; stylesheet `var()`, `url()`, `calc()` and `:root` fixed. | | **v1.11.0** | Shipped 2026-10-04 | **Dark Mode & Root Colors** | Dark-`Theme` text default, `HyperViewer(textColor:)` / `EpubReader(textColor:)`, `body`/`html` `color`, `:root` fix, contrast guard (#20). | | **v1.12.0** | Shipped 2026-10-04 | **Dark-Surface Element Colors** | Links, `
` / ``, `` keep >= 4.5:1 on dark surfaces (#23). |
-| **v1.13.0** | Shipped 2026-10-08 | **Character-Range Highlight Geometry** | `getBoxesForCharRange` / `debugLineFragments` for app-drawn highlights; ellipsis character-count fix (#26). |
+| **v1.13.0** | Release candidate | **Highlight Geometry, Line-Breaking Fixes & CJK Layout Speed** | `getBoxesForCharRange` / `debugLineFragments` for app-drawn highlights; wrapped text stays inside the box, RTL wraps, `word-break` / `overflow-wrap` inherit; a 14,000-character unspaced CJK paragraph lays out in well under a second instead of ~13 s (#26, #28, #29). |
| **Next** | Unscheduled | **Native Vector Diagramming & Headless Export** | Pure Canvas/Vector Mermaid.js & GraphViz (Zero-WebView), Headless Image & PDF byte stream generator. |
| **v2.0.0** | Q1 2027 | **Interactive Editorial & Magazine Typography** | Medium-style Text Annotation/Highlighting layer, Multi-column layout (`column-count`), Z-Index Stacking Context, Vertical Text (`writing-mode: vertical-rl`). |
diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md
index a82d480..944f926 100644
--- a/doc/CSS_PROPERTIES_MATRIX.md
+++ b/doc/CSS_PROPERTIES_MATRIX.md
@@ -126,7 +126,8 @@ This document lists CSS property support in HyperRender.
| `text-indent` | ✅ | px, em, rem, pt, % | First line of a block (LTR); inherits. `%` resolves against the containing block width |
| `text-overflow` | ✅ | clip, ellipsis | |
| `white-space` | ✅ | normal, nowrap, pre, pre-wrap | |
-| `word-break` | ✅ | normal, break-all, keep-all | |
+| `word-break` | ✅ | normal, break-all | `break-all` fills each line by character. Inherited (1.13.0; before, a value set on a block never reached its text). `keep-all` is parsed but behaves as `normal` |
+| `overflow-wrap` | ✅ | normal, break-word, anywhere | A word wider than the whole line breaks at character level instead of overflowing. Inherited (1.13.0) |
| `vertical-align` | ⚠️ | baseline, sub, super | Limited support |
| `text-shadow` | ✅ | x y blur color | Multiple shadows supported in v1.2.0 |
@@ -250,4 +251,4 @@ This document lists CSS property support in HyperRender.
---
-*Last updated: September 6, 2026 — HyperRender v1.8.0*
+*Last updated: October 10, 2026 — HyperRender v1.13.0*
diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md
index 87c10ac..a0e75cf 100644
--- a/doc/LIMITATIONS.md
+++ b/doc/LIMITATIONS.md
@@ -238,4 +238,4 @@ the full suite plus goldens as the safety net.
---
-*Last updated: September 6, 2026 — HyperRender v1.8.0*
+*Last updated: October 10, 2026 — HyperRender v1.13.0*
diff --git a/doc/README.md b/doc/README.md
index d28f217..e49f842 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,6 +1,6 @@
# HyperRender Documentation
-Welcome to the documentation for **HyperRender v1.3.3** — the high-performance HTML/Markdown renderer for Flutter.
+Welcome to the documentation for **HyperRender v1.13.0** — the high-performance HTML/Markdown renderer for Flutter.
## Documentation Index
@@ -29,6 +29,7 @@ Welcome to the documentation for **HyperRender v1.3.3** — the high-performance
| **[CSS Properties Matrix](CSS_PROPERTIES_MATRIX.md)** | Full CSS property support status |
| **[Roadmap](ROADMAP.md)** | Planned features and versioning strategy |
| **[Contributing Guide](CONTRIBUTING.md)** | How to contribute code, docs, and issues |
+| **[Testing](TESTING.md)** | Test map by kind (unit, integration, system, performance, stress, security), commands and conventions |
| **[Code of Conduct](CODE_OF_CONDUCT.md)** | Community standards |
### Architecture Decision Records
diff --git a/doc/ROADMAP.md b/doc/ROADMAP.md
index 54db51c..c809f63 100644
--- a/doc/ROADMAP.md
+++ b/doc/ROADMAP.md
@@ -1,7 +1,7 @@
# HyperRender — Product Roadmap
**Last Updated**: 2026-10-08
-**Current Stable**: v1.13.0
+**Current Stable**: v1.12.0 (v1.13.0 is the release candidate)
**Repository**: [github.com/brewkits/hyper_render](https://github.com/brewkits/hyper_render)
This document tracks the long-term direction of the HyperRender ecosystem.
@@ -116,7 +116,7 @@ Properties deferred from Phase 3 in [`CSS_PROPERTIES_MATRIX.md`](CSS_PROPERTIES_
- [x] `text-shadow` — parsed + applied to `TextStyle.shadows` in `ComputedStyle`
- [x] `text-overflow: ellipsis` — parsed + executed in `render_hyper_box_fragments.dart`
- [x] `box-shadow` — parsed + applied in `render_hyper_box_paint.dart`
-- [x] `word-break`, `overflow-wrap` — parsed + executed in `render_hyper_box_layout.dart` (L1339–1375)
+- [x] `word-break: break-all`, `overflow-wrap: break-word | anywhere` — parsed, inherited and executed by the line breaker (`word-break: keep-all` is parsed only)
- [x] `list-style-type`, `list-style-position` — fully parsed, resolved, and painted (v1.3.1)
- [x] `background-repeat` — parsed + mapped to `ImageRepeat` in `paintImage()`
- [x] `background-position` — parsed + mapped to `Alignment` in `paintImage()` (keyword values: top/center/bottom/left/right and combinations)
diff --git a/doc/TESTING.md b/doc/TESTING.md
new file mode 100644
index 0000000..ac91b4e
--- /dev/null
+++ b/doc/TESTING.md
@@ -0,0 +1,70 @@
+# Testing
+
+How HyperRender is tested, where each kind of test lives, and how to run it.
+
+## Commands
+
+```bash
+# Everything that gates a release (root + core, goldens excluded)
+flutter test test/ packages/hyper_render_core/test/ --exclude-tags golden
+
+# One file / one test
+flutter test test/line_breaking_regression_test.dart
+flutter test test/html_adapter_test.dart --plain-name 'parses links'
+
+# Sub-packages the root does not depend on run from inside the package
+(cd packages/hyper_render_html && flutter test) # also _markdown, _highlight, _math, _epub, _clipboard, _devtools
+(cd packages/hyper_render_devtools/devtools_ui && flutter test --platform chrome)
+
+# The line-breaking suites also run on the web (native ICU line breaking)
+flutter test --platform chrome test/line_breaking_regression_test.dart \
+ test/line_breaking_perf_and_parity_test.dart test/line_breaking_stress_test.dart \
+ test/fuzz/layout_fuzz_test.dart
+
+# Demo app: 44 widget tests, then every demo screen on a real device
+(cd example && flutter test)
+(cd example && flutter test integration_test/all_demos_test.dart -d macos)
+
+# Coverage across root, core and the other in-repo packages
+flutter test --coverage --coverage-package='hyper_render_core|hyper_render' \
+ test/ packages/hyper_render_core/test/ --exclude-tags golden
+```
+
+## What lives where
+
+| Kind | What it proves | Where |
+|---|---|---|
+| **Unit** | One class or function in isolation: CSS parsing and resolution, adapters, fragments, selection maths | `packages/hyper_render_core/test/`, `test/*_test.dart` (parsers, `html_adapter_test`, `css_*`, `text_breaking_test`, …) |
+| **Integration** | Several layers together on realistic content | `test/integration/` (real-world HTML, selection, lifecycle, error recovery, dark mode), `test/integration_test*.dart` |
+| **System** | The whole app and pixels | `test/golden/` (pixel tests, CI-only), `example/integration_test/all_demos_test.dart` (opens every demo screen, fails on any `FlutterError`) |
+| **Performance** | Cost does not grow faster than it should | `test/line_breaking_perf_and_parity_test.dart` (counts `TextPainter` layouts, checks N→2N scaling; no clock), `test/integration/performance_*`, `test/streaming_engine_benchmark_test.dart`, `packages/hyper_render_core/test/*performance*`, `benchmark/` |
+| **Stress** | Very large or degenerate input terminates and stays correct | `test/line_breaking_stress_test.dart` (200 000-character paragraphs, zero-width and 1 px boxes, padding or floats wider than the container, narrow glyphs), `test/hyper_render_stress_test.dart`, `test/integration/large_document_test.dart`, `cjk_stress_test.dart`, `animation_v150_stress_test.dart` |
+| **Security** | Hostile input cannot execute, escape, or hang the parser | `test/html_sanitizer_test.dart` (incl. ReDoS timing), `test/hyper_render_security_test.dart`, `test/security_edge_cases_test.dart`, `test/integration/*security*`, `url_safety` tests, `stylesheet_var_security_test.dart` |
+| **Fuzz** | Mutated input never throws or hangs | `test/fuzz/parser_fuzz_test.dart` (parse only), `test/fuzz/layout_fuzz_test.dart` (lays mutants out at widths 0, 1, 37 and 300) |
+| **Docs sync** | The CSS matrix never over-claims | `test/docs_matrix_sync_test.dart` |
+
+## Conventions that have caught real bugs
+
+- **Test the behavior, not that nothing threw.** A 0×0 widget satisfies
+ `findsOneWidget`; assert sizes, line positions or pixels.
+- **Mutation-check new regression tests.** Revert the fix and confirm the test
+ fails. Several tests in this repo were written to a passing state that did not
+ actually exercise the code.
+- **Flutter's test font (Ahem) makes every glyph 1 em wide.** That hides any bug
+ that depends on narrow glyphs; `TextScaler.linear(0.5)` is the way to reach
+ them (see `line_breaking_stress_test.dart`). Real-font checks need a device.
+- **Layout assertions use `debugLineFragments()`**, which reports the position
+ and character range each fragment actually has on its line.
+- **Prefer counts and ratios to wall-clock thresholds.** Performance tests that
+ compare milliseconds flake on shared CI runners.
+- **A synchronous hang cannot be interrupted by the test timeout.** A breaker
+ regression that turns linear work quadratic blocks the isolate until the CI job
+ timeout rather than failing cleanly.
+- **Goldens only match on the CI runner** (pinned Ubuntu image, Flutter version
+ and fonts). Do not regenerate them locally: run the `Visual Regression`
+ workflow with `workflow_dispatch`, then review every changed image next to
+ its previous version before accepting it.
+- **Repo tests cannot catch a missing export** from the root barrel
+ (`lib/hyper_render.dart`), because they resolve through `dependency_overrides`.
+ After publishing, install the real packages into a throwaway project and
+ analyze code that names every new public type.
diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
index e4b6151..9877d79 100644
--- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
+++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
@@ -1329,7 +1329,6 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
// Collapsible spaces at the start of a line are removed (CSS Text 3
// §4.1.2), e.g. the indentation newline after `` or after `
`.
- // Without this every such line started one space in.
if (currentLineFragments.isEmpty &&
fragment.type == FragmentType.text &&
fragment.text != null) {
@@ -1775,29 +1774,33 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
return (space < 0 ? text.length : space) <= fit;
}
- /// Checks if the leading run of [text] starting at [start] contains CJK characters.
- bool _isLeadingCjk(String text, [int start = 0]) {
- final limit = math.min(text.length, start + 32);
- for (int i = start; i < limit; i++) {
- if (KinsokuProcessor.isCjkCodeUnit(text.codeUnitAt(i))) return true;
- }
- return false;
- }
-
- /// Calculates the maximum number of characters that could possibly fit
- /// within [maxWidth] given [style]. Bounded prefixes prevent O(N^2)
- /// text shaping overhead on massive paragraphs (e.g. Gutenberg EPUBs).
- int _safeCandidateCharLimit(double maxWidth, ComputedStyle style,
- {bool isCjk = false}) {
+ /// The number of characters of [text], from [start], that could possibly fit
+ /// on one line of [maxWidth] (plus a safety margin). Candidate prefixes are
+ /// bounded by this so a long paragraph is not re-shaped from scratch for
+ /// every line (O(N^2)).
+ ///
+ /// Walks the text adding the narrowest width each character can have: a full
+ /// width CJK character is 1em, anything else is assumed to be at least
+ /// 0.25em, both scaled by the active text scaler. Measuring the actual text
+ /// matters for mixed content: a Latin run after a single CJK character must
+ /// not inherit the CJK bound, or a line is cut short.
+ int _safeCandidateCharLimit(
+ String text, int start, double maxWidth, ComputedStyle style) {
+ const margin = 64;
final effectiveWidth = maxWidth > 0 ? maxWidth : 1.0;
- final fontSize = style.fontSize > 0 ? style.fontSize : 16.0;
- if (isCjk) {
- // In CJK, fullwidth characters are 1.0 * fontSize wide. Bounding to
- // (effectiveWidth / (fontSize * 0.75)).ceil() + 16 provides ample margin
- // (~80-85 chars at 800px) while avoiding over-shaping 116+ chars per line.
- return (effectiveWidth / (fontSize * 0.75)).ceil() + 16;
- }
- return (effectiveWidth / (fontSize * 0.25)).ceil() + 64;
+ final fontSize =
+ _textScaler.scale(style.fontSize > 0 ? style.fontSize : 16.0);
+ final cjkMin = fontSize;
+ final otherMin = fontSize * 0.25;
+ var width = 0.0;
+ var i = start;
+ while (i < text.length && width < effectiveWidth) {
+ width += KinsokuProcessor.isCjkCodeUnit(text.codeUnitAt(i))
+ ? cjkMin
+ : otherMin;
+ i++;
+ }
+ return (i - start) + margin;
}
/// Reusable TextPainter for layout candidate prefixes, avoiding allocating
@@ -1839,9 +1842,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
final text = fragment.text!;
if (text.isEmpty) return null;
- final isCjk = _isLeadingCjk(text);
final safeLimit =
- _safeCandidateCharLimit(maxWidth, fragment.style, isCjk: isCjk);
+ _safeCandidateCharLimit(text, 0, maxWidth, fragment.style);
final isCandidate = text.length > safeLimit;
final candidateText = isCandidate ? text.substring(0, safeLimit) : text;
@@ -1979,7 +1981,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
// Container width _maxWidth is used rather than line maxWidth to avoid
// erroneously assuming the tail fits on the next full-width line.
final safeContainerLimit =
- _safeCandidateCharLimit(_maxWidth, fragment.style, isCjk: isCjk);
+ _safeCandidateCharLimit(text, breakIndex, _maxWidth, fragment.style);
if (secondPart.length > safeContainerLimit) {
secondFragment.measuredSize = Size(double.infinity, firstFragment.height);
secondFragment.baseline = firstFragment.baseline;
@@ -2012,8 +2014,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
final bool overflowWrap =
style.overflowWrap == 'break-word' || style.overflowWrap == 'anywhere';
- final isCjk = _isLeadingCjk(text);
- final safeLimit = _safeCandidateCharLimit(maxWidth, style, isCjk: isCjk);
+ final safeLimit = _safeCandidateCharLimit(text, 0, maxWidth, style);
final isCandidate = text.length > safeLimit;
final candidateText = isCandidate ? text.substring(0, safeLimit) : text;
@@ -2052,8 +2053,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
}
// Fallback: a single word wider than the whole line. Break it after as
- // many characters as fit (as Flutter's Text does) rather than after the
- // first character, which used to leave a column of one-letter lines.
+ // many characters as fit, as Flutter's Text does.
if (breakIndex <= 0) breakIndex = math.max(1, fitIndex);
if (breakIndex >= text.length) return null;
@@ -2120,7 +2120,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
)..globalOffset = fragment.globalOffset + breakIndex;
final safeContainerLimit =
- _safeCandidateCharLimit(_maxWidth, fragment.style, isCjk: isCjk);
+ _safeCandidateCharLimit(text, breakIndex, _maxWidth, fragment.style);
if (secondPart.length > safeContainerLimit) {
secondFragment.measuredSize = Size(double.infinity, firstFragment.height);
secondFragment.baseline = firstFragment.baseline;
diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart
index 1078678..9437b19 100644
--- a/packages/hyper_render_core/lib/src/style/resolver.dart
+++ b/packages/hyper_render_core/lib/src/style/resolver.dart
@@ -3488,8 +3488,8 @@ class StyleResolver {
// White space - inherit if not explicitly set
style.whiteSpace ??= parentStyle.whiteSpace;
- // word-break / overflow-wrap inherit in CSS. Without this a value set on
- // a
never reached its text nodes, which is where line breaking reads it.
+ // word-break / overflow-wrap inherit in CSS. Line breaking reads them from
+ // the text node's own style, so they must be copied down from the block.
style.wordBreak ??= parentStyle.wordBreak;
style.overflowWrap ??= parentStyle.overflowWrap;
diff --git a/test/char_range_and_hooks_test.dart b/test/char_range_and_hooks_test.dart
new file mode 100644
index 0000000..72576ca
--- /dev/null
+++ b/test/char_range_and_hooks_test.dart
@@ -0,0 +1,144 @@
+// Edge cases for the 1.13.0 additions that the main suites reach only
+// indirectly: character-range boxes over ruby text, the ellipsis-only line,
+// and the TextPainter-layout debug hooks (used for a deterministic,
+// clock-free check that line breaking does a bounded amount of shaping).
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hyper_render/hyper_render.dart';
+import 'package:hyper_render_core/hyper_render_core.dart'
+ show HyperRenderDebugHooks;
+
+Future _pump(
+ WidgetTester tester,
+ String html, {
+ double width = 300,
+}) async {
+ await tester.pumpWidget(MaterialApp(
+ home: Scaffold(
+ body: Align(
+ alignment: Alignment.topLeft,
+ child: SingleChildScrollView(
+ child: SizedBox(
+ width: width,
+ child: HyperViewer(html: html, mode: HyperRenderMode.sync),
+ ),
+ ),
+ ),
+ ),
+ ));
+ await tester.pump(const Duration(milliseconds: 50));
+ return tester.renderObject(
+ find.byType(HyperRenderWidget).first,
+ );
+}
+
+void main() {
+ group('getBoxesForCharRange', () {
+ testWidgets('a ruby fragment yields one box spanning its line',
+ (tester) async {
+ final box = await _pump(
+ tester,
+ '前日本語後
',
+ );
+ final ruby = box
+ .debugLineFragments()
+ .firstWhere((f) => f['type'] == 'ruby' || f['rubyText'] != null);
+ final start = ruby['charStart'] as int;
+ final end = ruby['charEnd'] as int;
+ final boxes = box.getBoxesForCharRange(start, end);
+ expect(boxes, hasLength(1));
+ expect(boxes.single.left, closeTo(ruby['offsetX'] as double, 0.5));
+ expect(boxes.single.width, closeTo(ruby['width'] as double, 0.5));
+ // The box spans the whole line, not just the base text.
+ expect(
+ boxes.single.height, greaterThanOrEqualTo(ruby['height'] as double));
+ });
+
+ testWidgets('an out-of-range or empty range yields nothing',
+ (tester) async {
+ final box = await _pump(tester, 'Some plain text.
');
+ expect(box.getBoxesForCharRange(500, 600), isEmpty);
+ expect(box.getBoxesForCharRange(3, 3), isEmpty);
+ expect(box.getBoxesForCharRange(0, 4), isNotEmpty);
+ });
+ });
+
+ group('text-overflow: ellipsis', () {
+ testWidgets('a box too narrow for any character shows only the ellipsis',
+ (tester) async {
+ final box = await _pump(
+ tester,
+ '',
+ width: 10,
+ );
+ final lines =
+ box.debugLineFragments().where((f) => f['type'] == 'text').toList();
+ expect(lines, hasLength(1));
+ expect(lines.single['text'], '…');
+ expect(lines.single['ellipsisVisibleLength'], 0);
+ // The ellipsis glyph is not a source character: no box for char 0.
+ expect(box.getBoxesForCharRange(0, 16), isEmpty);
+ });
+ });
+
+ group('TextPainter layout hooks', () {
+ late int layouts;
+ late int lineLayouts;
+
+ setUp(() {
+ layouts = 0;
+ lineLayouts = 0;
+ HyperRenderDebugHooks.onTextPainterLayout = () => layouts++;
+ HyperRenderDebugHooks.onLineLayoutTextPainter = () => lineLayouts++;
+ });
+
+ tearDown(() {
+ HyperRenderDebugHooks.onTextPainterLayout = null;
+ HyperRenderDebugHooks.onLineLayoutTextPainter = null;
+ });
+
+ testWidgets('the bounded breaker shapes a bounded amount per line',
+ (tester) async {
+ // break-all keeps this off the single-pass native path, so it measures
+ // candidate prefixes line by line.
+ final text = 'あいうえおかきくけこ' * 500; // 5,000 characters
+ final box = await _pump(
+ tester,
+ '注$text
',
+ );
+ final lines = box
+ .debugLineFragments()
+ .map((f) => f['lineIndex'] as int)
+ .toSet()
+ .length;
+ expect(lines, greaterThan(200));
+ expect(lineLayouts, greaterThan(0), reason: 'hook should fire');
+ // A constant number of candidate layouts per line, not a number that
+ // grows with the remaining text.
+ expect(lineLayouts, lessThanOrEqualTo(lines * 4 + 20));
+ expect(layouts, greaterThanOrEqualTo(lineLayouts));
+ });
+
+ testWidgets('hooks are silent when unset', (tester) async {
+ HyperRenderDebugHooks.onTextPainterLayout = null;
+ HyperRenderDebugHooks.onLineLayoutTextPainter = null;
+ final before = layouts + lineLayouts;
+ await _pump(tester, '${'word ' * 200}
');
+ expect(layouts + lineLayouts, before);
+ });
+
+ testWidgets('intrinsic measurement of a long paragraph is bounded',
+ (tester) async {
+ final box = await _pump(
+ tester,
+ 'x ${'あいうえお' * 400}
',
+ );
+ layouts = 0;
+ final max = box.getMaxIntrinsicWidth(double.infinity);
+ expect(max.isFinite, isTrue);
+ expect(layouts, lessThan(2000));
+ });
+ });
+}
diff --git a/test/fuzz/layout_fuzz_test.dart b/test/fuzz/layout_fuzz_test.dart
new file mode 100644
index 0000000..f984b58
--- /dev/null
+++ b/test/fuzz/layout_fuzz_test.dart
@@ -0,0 +1,100 @@
+// Layout fuzz: the parser fuzz suite only parses, so a hang or crash in the
+// line breaker would never surface there. This lays mutated documents out at
+// awkward widths (zero, one pixel, a prime, and a normal one) and asserts the
+// render object finishes with a finite size and throws nothing.
+//
+// Same fixed-seed approach as parser_fuzz_test.dart: a failing mutant is
+// printed and should be pinned as its own regression test.
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hyper_render/hyper_render.dart';
+
+const _longWord = 'Supercalifragilisticexpialidocious';
+const _cjk = '日本語のテキストは行末で適切に折り返される必要があります。';
+
+const _seeds = [
+ 'Hello world, $_longWord and a link that wraps.
',
+ '$_cjk$_cjk$_cjk
',
+ '注意:$_cjk$_cjk Latin words after CJK text follow.
',
+ 'هذا نص عربي طويل يجب أن يلتف بشكل صحيح
',
+ '
'
+ '$_longWord $_longWord text beside a float $_cjk
',
+ '$_longWord $_longWord
',
+ 'centered inline text that wraps over lines
',
+ '- $_longWord item
- $_cjk
',
+ '$_cjk x $_longWord
',
+ '$_longWord $_cjk
',
+];
+
+typedef _Mutator = String Function(String input, Random rng);
+
+String _delete(String s, Random rng) {
+ if (s.length < 2) return s;
+ final i = rng.nextInt(s.length);
+ return s.replaceRange(i, i + 1, '');
+}
+
+String _insert(String s, Random rng) {
+ const chars = '<>"\'&/{} 注ا';
+ final i = rng.nextInt(s.length + 1);
+ return s.replaceRange(i, i, chars[rng.nextInt(chars.length)]);
+}
+
+String _duplicate(String s, Random rng) {
+ if (s.length < 4) return s;
+ final start = rng.nextInt(s.length - 2);
+ final end = start + 1 + rng.nextInt(min(60, s.length - start - 1));
+ return s.replaceRange(start, start, s.substring(start, end));
+}
+
+String _truncate(String s, Random rng) =>
+ s.isEmpty ? s : s.substring(0, rng.nextInt(s.length));
+
+const List<_Mutator> _mutators = [_delete, _insert, _duplicate, _truncate];
+
+String _mutate(String seed, Random rng) {
+ var out = seed;
+ for (var i = 0; i < 3; i++) {
+ out = _mutators[rng.nextInt(_mutators.length)](out, rng);
+ }
+ return out;
+}
+
+void main() {
+ final rng = Random(20261008);
+ final mutants = [
+ ..._seeds,
+ for (final seed in _seeds)
+ for (var i = 0; i < 4; i++) _mutate(seed, rng),
+ ];
+
+ for (final width in const [0.0, 1.0, 37.0, 300.0]) {
+ testWidgets('layout at ${width}px never throws and stays finite',
+ (tester) async {
+ for (final html in mutants) {
+ final doc = HtmlAdapter().parse(html);
+ StyleResolver().resolveStyles(doc);
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: width,
+ child: SingleChildScrollView(
+ child: HyperRenderWidget(document: doc),
+ ),
+ ),
+ ),
+ ));
+ expect(tester.takeException(), isNull, reason: html);
+ final box = tester.renderObject(
+ find.byType(HyperRenderWidget).first,
+ );
+ expect(box.size.width.isFinite, isTrue, reason: html);
+ expect(box.size.height.isFinite, isTrue, reason: html);
+ expect(box.size.height, greaterThanOrEqualTo(0), reason: html);
+ }
+ });
+ }
+}
diff --git a/test/integration/README.md b/test/integration/README.md
index 7c86678..8c34514 100644
--- a/test/integration/README.md
+++ b/test/integration/README.md
@@ -1,63 +1,30 @@
-# Integration Tests
+# Integration tests
-This directory contains integration tests for HyperRender that test real-world scenarios, performance, and cross-cutting concerns.
+Tests that exercise several layers together on realistic content. The full
+map of test kinds, commands and conventions is in
+[`doc/TESTING.md`](../../doc/TESTING.md).
-## Test Categories
+## Files
-### 1. Real-World Content Tests
-- `real_world_html_test.dart` - News articles, blog posts, documentation
-- `markdown_integration_test.dart` - Complete Markdown documents
-- `delta_integration_test.dart` - Quill Delta documents
+| Area | Files |
+|---|---|
+| Real-world content | `real_world_html_test.dart` (news, blog, docs samples from `../fixtures/integration/`) |
+| Large documents | `large_document_test.dart`, `cjk_stress_test.dart` |
+| Selection | `selection_integration_test.dart` |
+| Lifecycle and resources | `lifecycle_stability_test.dart`, `resource_management_test.dart`, `error_recovery_test.dart` |
+| Security and accessibility | `security_integration_test.dart`, `advanced_security_test.dart`, `accessibility_test.dart`, `security_accessibility_integration_test.dart` |
+| Rendering modes | `dark_mode_hardening_test.dart` |
+| Animation | `animation_v150_integration_test.dart`, `animation_v150_performance_test.dart`, `animation_v150_stress_test.dart` |
+| Performance | `performance_benchmarks_test.dart`, `performance_regression_test.dart` |
-### 2. Performance Tests
-- `performance_regression_test.dart` - Ensure performance doesn't degrade
-- `large_document_test.dart` - 100K+ character documents
-- `memory_test.dart` - Memory leak detection
+Fixtures: `../fixtures/integration/` (`news_article.html`, `blog_post.html`,
+`documentation.html`, `large_document.html`, `complex_table.html`,
+`float_layout.html`, `cjk_content.html`).
-### 3. Error Recovery Tests
-- `error_recovery_test.dart` - Malformed HTML, missing resources, edge cases
-- `security_test.dart` - XSS attack vectors, sanitization
-
-### 4. Feature Integration Tests
-- `selection_integration_test.dart` - Complex selection scenarios
-- `float_integration_test.dart` - Float layout with real content
-- `table_integration_test.dart` - Complex table rendering
-
-## Running Tests
+## Running
```bash
-# Run all integration tests
flutter test test/integration/
-
-# Run specific test file
flutter test test/integration/real_world_html_test.dart
-
-# Run with coverage
flutter test --coverage test/integration/
-
-# Run in profile mode (for performance tests)
-flutter test --profile test/integration/performance_regression_test.dart
```
-
-## Test Data
-
-Real-world HTML samples are stored in `test/fixtures/integration/`:
-- `news_article.html` - News article with images
-- `blog_post.html` - Blog post with code blocks
-- `documentation.html` - Technical documentation
-- `large_document.html` - 100KB+ document
-
-## CI/CD Integration
-
-These tests can be run:
-- On every PR (fast tests only)
-- On merge to main (all tests)
-- Nightly (performance + memory tests)
-
-## Performance Testing
-
-These tests help track performance over time:
-- Parse time for various document sizes
-- Memory usage during rendering
-- Scroll performance for virtualized mode
-- Selection latency
diff --git a/test/line_breaking_stress_test.dart b/test/line_breaking_stress_test.dart
new file mode 100644
index 0000000..9994674
--- /dev/null
+++ b/test/line_breaking_stress_test.dart
@@ -0,0 +1,290 @@
+// Stress, boundary and termination tests for the line breaker.
+//
+// The line breaker bounds how much text it shapes per line (for performance on
+// very long paragraphs), which is exactly the kind of optimisation that breaks
+// on inputs the happy-path tests never produce: zero-width boxes, padding wider
+// than the container, floats wider than the container, glyphs narrower than the
+// bound assumes, and shrink-to-fit parents asking for intrinsic sizes.
+//
+// Every test here must terminate (the default 30 s test timeout is the hang
+// detector), throw nothing, and keep every character exactly once.
+//
+// Flutter's test font (Ahem) makes every glyph 1em wide, which hides any bug
+// that depends on narrow glyphs. `textScaler` below 1 makes glyphs narrower
+// without changing the em the layout bounds are computed from, so it is how
+// those cases are reached.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hyper_render/hyper_render.dart';
+
+String _words(int count) => List.generate(
+ count,
+ (i) => const [
+ 'lorem',
+ 'ipsum',
+ 'dolor',
+ 'sit',
+ 'amet',
+ 'consectetur'
+ ][i % 6],
+ ).join(' ');
+
+const _cjk = '日本語のテキストは行末で適切に折り返される必要があります。';
+const _arabic = 'هذا نص عربي طويل يجب أن يلتف بشكل صحيح عبر عدة أسطر ';
+
+Future _pump(
+ WidgetTester tester,
+ String html, {
+ double width = 300,
+ double textScale = 1.0,
+}) async {
+ await tester.pumpWidget(MaterialApp(
+ home: MediaQuery(
+ data: MediaQueryData(textScaler: TextScaler.linear(textScale)),
+ child: Scaffold(
+ body: Align(
+ alignment: Alignment.topLeft,
+ child: SingleChildScrollView(
+ child: SizedBox(
+ width: width,
+ child: HyperViewer(html: html, mode: HyperRenderMode.sync),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ));
+ await tester.pump(const Duration(milliseconds: 50));
+ return tester.renderObject(
+ find.byType(HyperRenderWidget).first,
+ );
+}
+
+List