Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<h6>`, `<code>` / `<pre>`, `<mark>` 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`). |

Expand Down
5 changes: 3 additions & 2 deletions doc/CSS_PROPERTIES_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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*
2 changes: 1 addition & 1 deletion doc/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
3 changes: 2 additions & 1 deletion doc/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions doc/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions doc/TESTING.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>` or after `<br>`.
// Without this every such line started one space in.
if (currentLineFragments.isEmpty &&
fragment.type == FragmentType.text &&
fragment.text != null) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/hyper_render_core/lib/src/style/resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p> 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;

Expand Down
Loading
Loading