diff --git a/CHANGELOG.md b/CHANGELOG.md index 18dcc65..af61ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **Character-range highlight boxes:** `RenderHyperBox.getBoxesForCharRange(charStart, charEnd)` returns the rects of a character range, for drawing your own highlights (search hits, annotations, read-aloud). Offsets are local to each `RenderHyperBox`, so in virtualized / `auto` mode (>10k chars) they restart at 0 per chunk. Also new: `RenderHyperBox.debugLineFragments()` for the laid-out fragment geometry. By [@DrkXo](https://github.com/DrkXo) ([#26](https://github.com/brewkits/hyper_render/pull/26)). - **`text-overflow: ellipsis` fixes** (in `hyper_render_core`): the `…` glyph is no longer counted as a hidden source character, a selection highlight no longer covers it, and an empty line where nothing fits before the ellipsis now shows `…`. - **Line-breaking fixes** (in `hyper_render_core`, rendering changes): wrapped lines no longer overflow the box (every full CJK line overflowed by a fraction of a glyph), RTL paragraphs wrap properly, `word-break` / `overflow-wrap` set on a block reach its text (`break-all` had no effect), an over-long word breaks after as many characters as fit instead of after its first letter, a word that doesn't fit beside a float moves below it, lines no longer start one space in (after `
` or an indented tag), and centered / right-aligned lines ignore trailing spaces. +- **Line-breaking performance on massive unspaced text blocks** ([#28](https://github.com/brewkits/hyper_render/issues/28), [#29](https://github.com/brewkits/hyper_render/pull/29)): single-pass native multi-line layout via ICU `computeLineMetrics()` when lines are uniform and free of floats, eliminating layout freezes on long unspaced text blocks. Bounded candidate prefix search in the fallback line breaker prevents quadratic `O(N^2)` text shaping overhead. New debug hooks `HyperRenderDebugHooks.onTextPainterLayout` / `onLineLayoutTextPainter` for layout call profiling. By [@DrkXo](https://github.com/DrkXo). - **Requires `hyper_render_core` `^1.13.0`.** ## 1.12.0 diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 339f8e6..5c36d0e 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -14,6 +14,11 @@ - **A word wider than the line breaks after as many characters as fit** (like Flutter's `Text`), instead of after its first letter, which left a column of one-letter lines. - **A word that doesn't fit beside a float moves below it** instead of being split after its first letter. - **Spaces at the start and end of a line collapse**, as in CSS. A line after `

` + newline, after `
`, or after an indented `

` started one space in, and trailing spaces counted toward the width that `text-align: center/right` positions. +- **Line-breaking performance on massive unspaced text blocks** ([#28](https://github.com/brewkits/hyper_render/issues/28), [#29](https://github.com/brewkits/hyper_render/pull/29)): + - Single-pass native multi-line layout fast path via ICU `computeLineMetrics()` when lines are uniform and free of floats, eliminating UI freezes on long unspaced text. + - Bounded candidate prefix search in the fallback line breaker loop to prevent quadratic `O(N^2)` HarfBuzz text shaping overhead. + - Robust loop termination on zero or negative line widths and trailing whitespace margin parity. +- **`HyperRenderDebugHooks.onTextPainterLayout` / `onLineLayoutTextPainter`** — hook callbacks for counting text shaping and line layout operations in tests and DevTools. By [@DrkXo](https://github.com/DrkXo) ([#29](https://github.com/brewkits/hyper_render/pull/29)). ## 1.12.0 diff --git a/packages/hyper_render_core/lib/src/core/hyper_render_debug_hooks.dart b/packages/hyper_render_core/lib/src/core/hyper_render_debug_hooks.dart index f8bd59b..f67a4a3 100644 --- a/packages/hyper_render_core/lib/src/core/hyper_render_debug_hooks.dart +++ b/packages/hyper_render_core/lib/src/core/hyper_render_debug_hooks.dart @@ -18,6 +18,8 @@ import '../model/node.dart'; /// HyperRenderDebugHooks.onLayoutComplete = (id, getFrags, getLines) { ... }; /// HyperRenderDebugHooks.onFrameTiming = (id, phase, micros) { ... }; /// HyperRenderDebugHooks.onSelectionChanged = (id, start, end) { ... }; +/// HyperRenderDebugHooks.onTextPainterLayout = () { ... }; +/// HyperRenderDebugHooks.onLineLayoutTextPainter = () { ... }; /// ``` abstract final class HyperRenderDebugHooks { /// Called when a [RenderHyperBox] is attached to the pipeline. @@ -63,6 +65,14 @@ abstract final class HyperRenderDebugHooks { /// selection was cleared. static void Function(String id, int? start, int? end)? onSelectionChanged; + /// Called in debug mode whenever a TextPainter performs layout in RenderHyperBox. + /// Used by tests and DevTools to count text shaping operations. + static void Function()? onTextPainterLayout; + + /// Called in debug mode whenever a TextPainter performs layout during the line-breaking + /// pass in RenderHyperBox. + static void Function()? onLineLayoutTextPainter; + /// CSS custom property overrides set from DevTools (`{'--name': value}`). /// /// In debug builds `HyperViewer` passes these to its @@ -81,5 +91,7 @@ abstract final class HyperRenderDebugHooks { onRendererDetached != null || onLayoutComplete != null || onFrameTiming != null || - onSelectionChanged != null); + onSelectionChanged != null || + onTextPainterLayout != null || + onLineLayoutTextPainter != null); } diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index c21e5d1..ce6a194 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -187,6 +187,10 @@ class RenderHyperBox extends RenderBox _LruCache<_TextPainterKey, TextPainter> get _textPainters => _globalTextPainters; + /// Reusable TextPainter for measuring line-breaking candidate prefixes during + /// layout without polluting the global LRU cache. + TextPainter? _scratchCandidatePainter; + /// Last collapsed margin (for margin collapsing between blocks) double _lastBlockMarginBottom = 0; @@ -731,6 +735,8 @@ class RenderHyperBox extends RenderBox } _cancelBlockAnimationLoop(); _disposeImages(); + _scratchCandidatePainter?.dispose(); + _scratchCandidatePainter = null; // Do NOT call detach() on cached semantic anchor nodes here. // Flutter's semantics teardown already detaches them during widget // unmounting — calling detach() again asserts inside SemanticsOwner @@ -1088,6 +1094,7 @@ class RenderHyperBox extends RenderBox measurePainter.text = TextSpan(text: candidate, style: mergedStyle); measurePainter.layout(); + if (kDebugMode) HyperRenderDebugHooks.onTextPainterLayout?.call(); if (measurePainter.width > maxWidth) { maxWidth = measurePainter.width; } 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 3ef21e8..ce8b83e 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 @@ -224,7 +224,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { if (effectiveMarginTop > 0 || _fragments.isNotEmpty || hasWidthConstraint || - hasPadding) { + hasPadding || + style.textOverflow == TextOverflow.ellipsis) { _fragments.add(_BlockStartFragment( sourceNode: node, style: style, @@ -607,10 +608,13 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final text = fragment.text; if (text == null || text.isEmpty) { fragment.measuredSize = Size.zero; + fragment.baseline = 0; break; } final painter = _getTextPainter(text, fragment.style); fragment.measuredSize = Size(painter.width, painter.height); + fragment.baseline = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); break; case FragmentType.ruby: @@ -620,10 +624,13 @@ extension _RenderHyperBoxLayout on RenderHyperBox { case FragmentType.lineBreak: final painter = _getTextPainter(' ', fragment.style); fragment.measuredSize = Size(0, painter.height); + fragment.baseline = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); break; case FragmentType.atomic: // Already measured during tokenization + fragment.baseline = fragment.measuredSize?.height; break; } @@ -659,6 +666,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { fragment.measuredSize = Size(width, height); // Store ruby height for painting fragment.rubyHeight = rubyPainter.height; + fragment.baseline = height * 0.85; } /// The style a text/ruby fragment should actually be measured, painted and @@ -673,6 +681,13 @@ extension _RenderHyperBoxLayout on RenderHyperBox { ); } + /// Whether [whiteSpace] preserves preformatted whitespace and line breaks. + bool _isPreformattedWhiteSpace(String? whiteSpace) { + return whiteSpace == 'pre' || + whiteSpace == 'pre-wrap' || + whiteSpace == 'break-spaces'; + } + TextPainter _getTextPainter(String text, ComputedStyle style) { // Per-fragment text direction (supports RTL via CSS direction: rtl) final fragmentDirection = @@ -700,9 +715,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // FIXED: baseStyle is the foundation, computed style overrides it final mergedStyle = _baseStyle.merge(style.toTextStyle()); - // Pre/pre-wrap fragments may contain multi-line text; allow unlimited lines - final isPreformatted = - style.whiteSpace == 'pre' || style.whiteSpace == 'pre-wrap'; + // Pre/pre-wrap/break-spaces fragments may contain multi-line text; allow unlimited lines + final isPreformatted = _isPreformattedWhiteSpace(style.whiteSpace); final maxLines = isPreformatted ? null : 1; final painter = TextPainter( @@ -728,6 +742,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { applyHeightToLastDescent: true, ), )..layout(); + if (kDebugMode) HyperRenderDebugHooks.onTextPainterLayout?.call(); _textPainters.put(key, painter); return painter; @@ -1450,6 +1465,111 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Now check if fragment is wider than full line width final fullLineWidth = getAvailableWidth(); if (fragment.width > fullLineWidth && fragment.text!.length > 1) { + // Fast path: When there are no active or pending floats and the fragment + // starts at the beginning of a line, available width is uniform across all + // lines. Layout the entire text in a single TextPainter pass via native + // ICU line-breaking, avoiding thousands of individual TextPainter.layout() calls. + final bool canUseNativeMultiLine = _leftFloats.isEmpty && + _rightFloats.isEmpty && + _pendingLineLeftFloats.isEmpty && + _pendingLineRightFloats.isEmpty && + currentLineFragments.isEmpty && + fullLineWidth > 0 && + ellipsisDepth == 0 && + !fragment.style.isRtl && + textDirection != ui.TextDirection.rtl && + fragment.style.wordBreak != 'break-all' && + fragment.style.textOverflow != TextOverflow.ellipsis && + !_isPreformattedWhiteSpace(fragment.style.whiteSpace) && + fragment.style.whiteSpace != 'nowrap'; + + if (canUseNativeMultiLine) { + final text = fragment.text!; + final totalLen = text.length; + final fragmentDirection = + fragment.style.isRtl ? ui.TextDirection.rtl : textDirection; + final mergedStyle = + _baseStyle.merge(fragment.style.toTextStyle()); + final strutStyle = StrutStyle.fromTextStyle(mergedStyle, + forceStrutHeight: false); + + final multiPainter = _scratchCandidatePainter ??= TextPainter(); + multiPainter + ..text = TextSpan(text: text, style: mergedStyle) + ..strutStyle = strutStyle + ..textDirection = fragmentDirection + ..textScaler = _textScaler + ..maxLines = null + ..textHeightBehavior = const TextHeightBehavior( + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + ) + ..layout(maxWidth: fullLineWidth); + if (kDebugMode) { + HyperRenderDebugHooks.onTextPainterLayout?.call(); + HyperRenderDebugHooks.onLineLayoutTextPainter?.call(); + } + + final metrics = multiPainter.computeLineMetrics(); + if (metrics.length > 1) { + int startOffset = 0; + for (int mIdx = 0; mIdx < metrics.length; mIdx++) { + final lm = metrics[mIdx]; + int endOffset; + if (mIdx == metrics.length - 1) { + endOffset = totalLen; + } else { + final nextLm = metrics[mIdx + 1]; + final nextLineY = nextLm.baseline - nextLm.ascent / 2; + final nextStartPos = multiPainter.getPositionForOffset( + Offset(0, nextLineY), + ); + endOffset = nextStartPos.offset; + if (endOffset <= startOffset || endOffset > totalLen) { + final lineY = lm.baseline - lm.ascent / 2; + final pos = multiPainter.getPositionForOffset( + Offset(fullLineWidth, lineY), + ); + endOffset = pos.offset; + } + } + + if (endOffset <= startOffset && startOffset < totalLen) { + endOffset = startOffset + 1; + } else if (endOffset > totalLen) { + endOffset = totalLen; + } + + final lineText = text.substring(startOffset, endOffset); + final lineFrag = Fragment.text( + text: lineText, + sourceNode: fragment.sourceNode, + style: fragment.style, + characterOffset: fragment.characterOffset + startOffset, + )..globalOffset = fragment.globalOffset + startOffset; + + lineFrag.measuredSize = Size(lm.width, lm.height); + lineFrag.baseline = lm.ascent; + + lineFrag.offset = Offset(currentX, currentY); + currentLineFragments.add(lineFrag); + _updateLineMetrics(lineFrag, lineHeight, maxBaseline, (h, b) { + lineHeight = h; + maxBaseline = b; + }); + + if (mIdx < metrics.length - 1) { + finishLine(); + currentX = leftInset; + } else { + currentX += lineFrag.width; + } + startOffset = endOffset; + } + return; + } + } + // A float narrows this line and not even the first word fits // beside it: move down past the float (as CSS line boxes do) // instead of splitting the word after its first letter. @@ -1501,6 +1621,10 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } } + if (fragment.measuredSize == null || fragment.width.isInfinite) { + _measureFragment(fragment); + } + fragment.offset = Offset(currentX, currentY); currentX += fragment.width; currentLineFragments.add(fragment); @@ -1536,11 +1660,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox { /// Returns the distance from the top of [fragment] to its baseline. /// Single source of truth used by both [_updateLineMetrics] and [_positionFragments]. double _fragmentBaseline(Fragment fragment) { + if (fragment.baseline != null) return fragment.baseline!; if (fragment.type == FragmentType.text && fragment.text != null) { final painter = _getTextPainter(fragment.text!, fragment.style); - return painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + final b = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + fragment.baseline = b; + return b; } else if (fragment.type == FragmentType.ruby) { - return fragment.height * 0.85; + final b = fragment.height * 0.85; + fragment.baseline = b; + return b; } return fragment.height; // atomic: bottom-align } @@ -1641,15 +1771,83 @@ 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}) { + 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; + } + + /// Reusable TextPainter for layout candidate prefixes, avoiding allocating + /// transient objects or thrashing the global [_textPainters] cache. + TextPainter _getCandidatePainter( + String text, + ComputedStyle style, { + TextStyle? preMergedStyle, + StrutStyle? preStrutStyle, + }) { + final fragmentDirection = + style.isRtl ? ui.TextDirection.rtl : textDirection; + final mergedStyle = preMergedStyle ?? _baseStyle.merge(style.toTextStyle()); + final strutStyle = preStrutStyle ?? + StrutStyle.fromTextStyle(mergedStyle, forceStrutHeight: false); + final isPreformatted = _isPreformattedWhiteSpace(style.whiteSpace); + final maxLines = isPreformatted ? null : 1; + + final painter = _scratchCandidatePainter ??= TextPainter(); + painter + ..text = TextSpan(text: text, style: mergedStyle) + ..strutStyle = strutStyle + ..textDirection = fragmentDirection + ..textScaler = _textScaler + ..maxLines = maxLines + ..textHeightBehavior = const TextHeightBehavior( + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + ) + ..layout(); + if (kDebugMode) { + HyperRenderDebugHooks.onTextPainterLayout?.call(); + HyperRenderDebugHooks.onLineLayoutTextPainter?.call(); + } + return painter; + } + (Fragment, Fragment)? _splitTextFragment(Fragment fragment, double maxWidth) { final text = fragment.text!; if (text.isEmpty) return null; - final painter = _getTextPainter(text, fragment.style); - final fitIndex = _fitPrefixLength(painter, text, maxWidth); + final isCjk = _isLeadingCjk(text); + final safeLimit = + _safeCandidateCharLimit(maxWidth, fragment.style, isCjk: isCjk); + final isCandidate = text.length > safeLimit; + final candidateText = isCandidate ? text.substring(0, safeLimit) : text; + + final painter = isCandidate + ? _getCandidatePainter(candidateText, fragment.style) + : _getTextPainter(candidateText, fragment.style); + final fitIndex = _fitPrefixLength(painter, candidateText, maxWidth); int breakIndex = fitIndex; - if (breakIndex > 0 && breakIndex < text.length) { + if (breakIndex > 0 && breakIndex < candidateText.length) { final style = fragment.style; final bool breakAll = style.wordBreak == 'break-all'; final bool overflowWrap = style.overflowWrap == 'break-word' || @@ -1660,28 +1858,28 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } else { // Search from breakIndex inclusive: a space right after the fitting // prefix means the whole word before it fits. - final lastSpace = text.lastIndexOf(' ', breakIndex); + final lastSpace = candidateText.lastIndexOf(' ', breakIndex); if (lastSpace > 0) { // Found a space before break point - use it breakIndex = lastSpace + 1; - } else if (KinsokuProcessor.containsCjk(text)) { + } else if (KinsokuProcessor.containsCjk(candidateText)) { // Text contains CJK - check if break position is within CJK context - final isCjkBreak = _isBreakInCjkContext(text, breakIndex); + final isCjkBreak = _isBreakInCjkContext(candidateText, breakIndex); if (isCjkBreak) { // Break is in CJK region - apply Kinsoku rules - breakIndex = KinsokuProcessor.findBreakPoint(text, breakIndex); + breakIndex = + KinsokuProcessor.findBreakPoint(candidateText, breakIndex); if (breakIndex < 0) breakIndex = fitIndex; } else if (overflowWrap) { // Latin-region break with overflow-wrap -> Break at character } else { // Break is in Latin region of mixed text - treat as Latin // Look for next space AFTER break point to avoid breaking words - final afterBreak = text.substring(breakIndex); - final nextSpace = afterBreak.indexOf(' '); + final nextSpace = text.indexOf(' ', breakIndex); - if (nextSpace >= 0) { + if (nextSpace >= 0 || candidateText.length < text.length) { // Found space after - but this means moving more to next line return null; } @@ -1693,10 +1891,9 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } else { // Pure Latin text without space before break point // Look for next space AFTER break point to avoid breaking words - final afterBreak = text.substring(breakIndex); - final nextSpace = afterBreak.indexOf(' '); + final nextSpace = text.indexOf(' ', breakIndex); - if (nextSpace >= 0) { + if (nextSpace >= 0 || candidateText.length < text.length) { // Found space after - but this means moving more to next line // Return null to signal "can't fit any complete word on this line" // The caller should start a new line and try again @@ -1715,8 +1912,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Ensure breakIndex aligns with grapheme cluster boundaries. // This prevents splitting emojis (even with ZWJ) or complex scripts. - if (breakIndex > 0 && breakIndex < text.length) { - final range = text.characters.iterator; + if (breakIndex > 0 && breakIndex < candidateText.length) { + final range = candidateText.characters.iterator; int currentOffset = 0; while (range.moveNext()) { int nextOffset = currentOffset + range.current.length; @@ -1734,9 +1931,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Only trim spaces for normal/nowrap/pre-line modes // For pre/pre-wrap/break-spaces, preserve all whitespace final whiteSpace = fragment.style.whiteSpace; - final shouldTrim = (whiteSpace != 'pre' && - whiteSpace != 'pre-wrap' && - whiteSpace != 'break-spaces'); + final shouldTrim = !_isPreformattedWhiteSpace(whiteSpace); final firstPart = shouldTrim ? text.substring(0, breakIndex).trimRight() @@ -1754,7 +1949,15 @@ extension _RenderHyperBoxLayout on RenderHyperBox { style: fragment.style, characterOffset: fragment.characterOffset, )..globalOffset = fragment.globalOffset; - _measureFragment(firstFragment); + + if (firstPart.length == breakIndex) { + final w = _prefixWidth(painter, breakIndex); + firstFragment.measuredSize = Size(w, painter.height); + firstFragment.baseline = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + } else { + _measureFragment(firstFragment); + } // characterOffset points to the START of secondPart in the document. // We use `breakIndex` (not `breakIndex + trimmedLeading`) so that trimmed @@ -1766,7 +1969,19 @@ extension _RenderHyperBoxLayout on RenderHyperBox { style: fragment.style, characterOffset: fragment.characterOffset + breakIndex, )..globalOffset = fragment.globalOffset + breakIndex; - _measureFragment(secondFragment); + + // Optimization: If secondPart is far longer than could fit on ANY line in + // this container, skip expensive HarfBuzz layout and assign an estimated size. + // 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); + if (secondPart.length > safeContainerLimit) { + secondFragment.measuredSize = Size(double.infinity, firstFragment.height); + secondFragment.baseline = firstFragment.baseline; + } else { + _measureFragment(secondFragment); + } return (firstFragment, secondFragment); } @@ -1775,6 +1990,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { if (fragment.type == FragmentType.text && fragment.text != null) { final painter = _getTextPainter(fragment.text!, fragment.style); fragment.measuredSize = Size(painter.width, painter.height); + fragment.baseline = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); } } @@ -1791,31 +2008,41 @@ 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 isCandidate = text.length > safeLimit; + final candidateText = isCandidate ? text.substring(0, safeLimit) : text; + // Longest prefix that really fits (not the nearest caret, which can run // half a glyph past maxWidth, and mirrored for RTL painters). - final painter = _getTextPainter(text, style); - final fitIndex = _fitPrefixLength(painter, text, maxWidth); + final painter = isCandidate + ? _getCandidatePainter(candidateText, style) + : _getTextPainter(candidateText, style); + final fitIndex = _fitPrefixLength(painter, candidateText, maxWidth); // First, try to find a word boundary that fits. word-break: break-all // fills the line by character instead. int breakIndex = -1; if (!breakAll) { // A space at fitIndex itself still counts: the word before it fits. - final space = fitIndex < text.length - ? text.lastIndexOf(' ', fitIndex) - : text.lastIndexOf(' '); + final space = fitIndex < candidateText.length + ? candidateText.lastIndexOf(' ', fitIndex) + : candidateText.lastIndexOf(' '); if (space >= 0) breakIndex = space + 1; } // If no word boundary fits, but overflow-wrap is enabled, or word-break: break-all // then force split at character level if (breakIndex == -1 && - (breakAll || overflowWrap || KinsokuProcessor.containsCjk(text))) { + (breakAll || + overflowWrap || + KinsokuProcessor.containsCjk(candidateText))) { breakIndex = fitIndex; // Adjust for CJK rules if applicable - if (KinsokuProcessor.containsCjk(text)) { - final kinsokuBreak = KinsokuProcessor.findBreakPoint(text, breakIndex); + if (KinsokuProcessor.containsCjk(candidateText)) { + final kinsokuBreak = + KinsokuProcessor.findBreakPoint(candidateText, breakIndex); if (kinsokuBreak > 0) breakIndex = kinsokuBreak; } } @@ -1829,7 +2056,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Collapsible spaces at the wrap hang past the line end: keep them out of // the next line's start (so its glyphs keep their character offsets). final ws = style.whiteSpace; - final shouldTrim = ws != 'pre' && ws != 'pre-wrap' && ws != 'break-spaces'; + final shouldTrim = !_isPreformattedWhiteSpace(ws); if (shouldTrim) { while (breakIndex < text.length && text[breakIndex] == ' ') { breakIndex++; @@ -1838,8 +2065,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } // Ensure breakIndex aligns with grapheme cluster boundaries. - if (breakIndex > 0 && breakIndex < text.length) { - final range = text.characters.iterator; + if (breakIndex > 0 && breakIndex < candidateText.length) { + final range = candidateText.characters.iterator; int currentOffset = 0; while (range.moveNext()) { int nextOffset = currentOffset + range.current.length; @@ -1871,7 +2098,15 @@ extension _RenderHyperBoxLayout on RenderHyperBox { style: fragment.style, characterOffset: fragment.characterOffset, )..globalOffset = fragment.globalOffset; - _measureFragment(firstFragment); + + if (firstPart.length == breakIndex) { + final w = _prefixWidth(painter, breakIndex); + firstFragment.measuredSize = Size(w, painter.height); + firstFragment.baseline = + painter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + } else { + _measureFragment(firstFragment); + } final secondFragment = Fragment.text( text: secondPart, @@ -1879,7 +2114,15 @@ extension _RenderHyperBoxLayout on RenderHyperBox { style: fragment.style, characterOffset: fragment.characterOffset + breakIndex, )..globalOffset = fragment.globalOffset + breakIndex; - _measureFragment(secondFragment); + + final safeContainerLimit = + _safeCandidateCharLimit(_maxWidth, fragment.style, isCjk: isCjk); + if (secondPart.length > safeContainerLimit) { + secondFragment.measuredSize = Size(double.infinity, firstFragment.height); + secondFragment.baseline = firstFragment.baseline; + } else { + _measureFragment(secondFragment); + } return (firstFragment, secondFragment); } diff --git a/packages/hyper_render_core/lib/src/model/fragment.dart b/packages/hyper_render_core/lib/src/model/fragment.dart index e347e1e..6d0dd10 100644 --- a/packages/hyper_render_core/lib/src/model/fragment.dart +++ b/packages/hyper_render_core/lib/src/model/fragment.dart @@ -66,6 +66,9 @@ class Fragment { /// Ruby annotation height (for proper layout) double? rubyHeight; + /// Distance from top of fragment to its alphabetic baseline (cached during measurement) + double? baseline; + /// Number of leading characters from [text] that are actually visible after /// a CSS `text-overflow: ellipsis` truncation pass. /// diff --git a/test/line_breaking_perf_and_parity_test.dart b/test/line_breaking_perf_and_parity_test.dart new file mode 100644 index 0000000..f836c9b --- /dev/null +++ b/test/line_breaking_perf_and_parity_test.dart @@ -0,0 +1,681 @@ +// ignore_for_file: avoid_print + +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; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Synthetic unspaced CJK sample (100 characters) + const cjkSample = + '此開卷第一回也作者自云因曾歷過一番夢幻之后故將真事隱去而借通靈之說撰此石頭記一書也故曰甄士隱云云但書中所記何事何人自又云今風塵碌碌一事無成忽念及當日所有之女子一一細考較去覺其行止見識皆出于我之上'; + + group('Group 1: Intrinsic Widths in Shrink-to-Fit Contexts', () { + testWidgets( + 'getMinIntrinsicWidth and getMaxIntrinsicWidth for long unspaced CJK fragment', + (WidgetTester tester) async { + // 1,000 characters of unspaced CJK + final longText = cjkSample * 10; + + RenderHyperBox? hyperBox; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(longText)], + ), + ], + ), + ), + ), + ); + + final renderObject = tester.renderObject(find.byType(HyperRenderWidget)); + if (renderObject is RenderHyperBox) { + hyperBox = renderObject; + } + expect(hyperBox, isNotNull); + + // CJK characters can break after each character. Min intrinsic width is + // the width of a single character (~16px), not the full 1,000 chars. + final minWidth = hyperBox!.getMinIntrinsicWidth(double.infinity); + expect(minWidth, greaterThan(10.0)); + expect(minWidth, lessThan(30.0)); + + // Max intrinsic width represents unwrapped single-line text width. + // 1,000 CJK characters at default font size (16px) should be ~16,000px. + // Must NOT be truncated or clamped to layout maxWidth. + final maxWidth = hyperBox.getMaxIntrinsicWidth(double.infinity); + expect(maxWidth, greaterThan(10000.0)); + }); + + testWidgets('Shrink-to-fit context: table cell with unspaced CJK text', + (WidgetTester tester) async { + final text = cjkSample * 2; // 200 chars + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 500, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'table', + style: ComputedStyle(width: 500), + children: [ + BlockNode( + tagName: 'tr', + children: [ + BlockNode( + tagName: 'td', + children: [TextNode(text)], + ), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + final renderObject = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + expect(renderObject.hasSize, isTrue); + expect(renderObject.size.width, equals(500.0)); + expect(renderObject.size.height, greaterThan(0.0)); + }); + + testWidgets( + 'Shrink-to-fit context: inline-block container with unspaced CJK text', + (WidgetTester tester) async { + final text = cjkSample * 2; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 600, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'div', + style: + ComputedStyle(display: DisplayType.inlineBlock), + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + final renderObject = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + expect(renderObject.hasSize, isTrue); + expect(renderObject.size.height, greaterThan(0.0)); + final lines = renderObject.debugLines(); + expect(lines.length, greaterThan(1)); + }); + }); + + group('Group 2: Fast-Path Parity & Edge Case Coverage', () { + testWidgets('Plain text wrapping parity', (WidgetTester tester) async { + final text = cjkSample * 3; // 300 chars + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 400, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(5)); + for (final line in lines) { + expect(line['width'] as double, lessThanOrEqualTo(400.0)); + expect(line['height'] as double, greaterThan(0.0)); + } + }); + + testWidgets('text-align: justify layout', (WidgetTester tester) async { + const latinText = + 'The quick brown fox jumps over the lazy dog and runs through the forest with great speed and agility. ' + 'Every single animal in the meadow stopped to watch the astonishing performance of the graceful creatures.'; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 300, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + style: ComputedStyle(textAlign: HyperTextAlign.justify), + children: [TextNode(latinText)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(2)); + // First line width within container + expect(lines.first['width'] as double, lessThanOrEqualTo(300.0)); + }); + + testWidgets( + 'text-overflow: ellipsis truncates on single line and does not multi-line wrap', + (WidgetTester tester) async { + final text = cjkSample * 5; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 300, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + style: ComputedStyle( + textOverflow: TextOverflow.ellipsis, + whiteSpace: 'nowrap', + ), + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final lines = box.debugLines(); + // Ellipsis nowrap must remain on 1 single line + expect(lines.length, equals(1)); + final lineFrags = box.debugLineFragments(); + expect(lineFrags, isNotEmpty); + expect( + lineFrags.any((f) => (f['text'] as String?)?.contains('…') ?? false), + isTrue); + }); + + testWidgets('non-default textScaler scaling', (WidgetTester tester) async { + final text = cjkSample * 2; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.5)), + child: SizedBox( + width: 400, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(3)); + // At 1.5x scale, line height is noticeably larger than baseline 16px font + expect(lines.first['height'] as double, greaterThan(20.0)); + }); + + testWidgets('mixed CJK and Latin text wrapping', + (WidgetTester tester) async { + const mixedText = + '第一章 Introduction to Ancient Stones. 在古代中國,這是一段測試文本,including various English terms, numbers 12345, ' + 'and mixed punctuation!更多詳細內容請參見後續章節。'; + final text = mixedText * 3; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 350, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(3)); + for (final line in lines) { + expect(line['width'] as double, lessThanOrEqualTo(350.0)); + } + }); + + testWidgets( + 'float starting partway through paragraph triggers fallback path properly', + (WidgetTester tester) async { + // Paragraph with lead-in text, a floating box, then trailing text + const textBefore = cjkSample; + final textAfter = cjkSample * 2; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 500, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [ + TextNode(textBefore), + AtomicNode( + tagName: 'img', + style: ComputedStyle( + float: HyperFloat.left, + width: 100, + height: 80, + ), + ), + TextNode(textAfter), + ], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + expect(box.hasSize, isTrue); + final lines = box.debugLines(); + expect(lines.length, greaterThan(5)); + // Text alongside 100px float wraps narrower (width <= 400.0) than the full 500px container + expect(lines.any((l) => (l['width'] as double) <= 400.0), isTrue); + }); + + testWidgets( + 'text after an inline lead-in wraps into multiple lines without single-line tail overflow (Latin & CJK)', + (WidgetTester tester) async { + final samples = { + 'Latin': + '

Note: ${'lorem ipsum dolor sit amet consectetur ' * 10}

', + 'CJK': '

注意:${'日本語のテキストは行末で適切に折り返される必要があります。' * 6}

', + }; + + for (final entry in samples.entries) { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 300, + child: + HyperViewer(html: entry.value, mode: HyperRenderMode.sync), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + final box = tester.renderObject(find.byType(HyperRenderWidget).first) + as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(4), + reason: + '${entry.key} text after lead-in must wrap into multiple lines'); + + final frags = + box.debugLineFragments().where((f) => f['type'] == 'text').toList(); + for (final f in frags) { + final text = (f['text'] as String).trimRight(); + final painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle(fontSize: 16), + ), + textDirection: TextDirection.ltr, + )..layout(); + final x = f['offsetX'] as double; + expect( + x + painter.width, + lessThanOrEqualTo(300.5), + reason: + '${entry.key} line fragment "${f['text']}" on line ${f['lineIndex']} runs past the box (x: $x, width: ${painter.width})', + ); + } + } + }); + }); + + group('Group 3: Selection and Hit-Testing Continuity', () { + testWidgets('globalOffset continuity across sliced line fragments', + (WidgetTester tester) async { + final text = cjkSample * 3; // 300 chars + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 350, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + final fragments = box.debugLineFragments(); + expect(fragments.length, greaterThan(3)); + + // Verify strict continuity: fragment[i].charEnd == fragment[i+1].charStart + int expectedOffset = 0; + for (var i = 0; i < fragments.length; i++) { + final frag = fragments[i]; + final start = frag['charStart'] as int; + final end = frag['charEnd'] as int; + expect(start, equals(expectedOffset), + reason: + 'Fragment $i charStart should match prior offset with zero gaps'); + expect(end, greaterThan(start)); + expectedOffset = end; + } + expect(expectedOffset, equals(text.length)); + }); + + testWidgets('getBoxesForCharRange across line boundaries', + (WidgetTester tester) async { + final text = cjkSample * 2; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 300, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + // Select characters 10 through 60 (spanning across lines) + final boxes = box.getBoxesForCharRange(10, 60); + expect(boxes, isNotEmpty); + expect(boxes.length, greaterThanOrEqualTo(2), + reason: + 'Range crossing line boundary must produce boxes for each line'); + for (final rect in boxes) { + expect(rect.width, greaterThan(0.0)); + expect(rect.height, greaterThan(0.0)); + } + }); + + testWidgets('getSelectedText returns exact unbroken substring', + (WidgetTester tester) async { + final text = cjkSample * 2; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 300, + child: HyperRenderWidget( + selectable: true, + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = + tester.renderObject(find.byType(HyperRenderWidget)) as RenderHyperBox; + box.startSelectionAt(const Offset(10, 10)); + box.extendSelectionTo(const Offset(250, 80)); + + final selected = box.getSelectedText(); + expect(selected, isNotNull); + expect(selected!.isNotEmpty, isTrue); + // The extracted text must be a genuine substring of the source text + expect(text.contains(selected), isTrue); + }); + }); + + group('Group 4: Deterministic Performance Test (CI-Safe)', () { + testWidgets( + 'TextPainter.layout call-counting on 50-line unspaced paragraph', + (WidgetTester tester) async { + final text = cjkSample * 15; // ~1,500 characters, ~50 lines + + int lineLayoutCalls = 0; + HyperRenderDebugHooks.onLineLayoutTextPainter = () { + lineLayoutCalls++; + }; + + try { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 500, + child: HyperRenderWidget( + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text)], + ), + ], + ), + ), + ), + ), + ), + ); + + final box = tester.renderObject(find.byType(HyperRenderWidget)) + as RenderHyperBox; + final lines = box.debugLines(); + expect(lines.length, greaterThan(35)); + + // In the old O(N^2) line-by-line breaker, lineLayoutCalls was >= lines.length (40-50+ calls). + // Under the native multi-line fast path, lineLayoutCalls is exactly 1 (single multiPainter.layout call)! + expect(lineLayoutCalls, equals(1), + reason: + 'Native multi-line fast path must perform exactly 1 TextPainter pass for line breaking'); + } finally { + HyperRenderDebugHooks.onLineLayoutTextPainter = null; + } + }); + + testWidgets( + 'Scaling ratio between N and 2N demonstrates linear O(N) scaling', + (WidgetTester tester) async { + // Compare 1,500 characters (N) vs 3,000 characters (2N) + final textN = cjkSample * 15; // 1,500 chars + final text2N = cjkSample * 30; // 3,000 chars + + int callsN = 0; + int calls2N = 0; + + // Reset cache before measuring N so tests are independent of earlier runs + RenderHyperBox.setGlobalTextCacheSize(1); + RenderHyperBox.setGlobalTextCacheSize(5000); + + HyperRenderDebugHooks.onTextPainterLayout = () => callsN++; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 500, + child: HyperRenderWidget( + key: const ValueKey('N'), + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(textN)], + ), + ], + ), + ), + ), + ), + ), + ); + HyperRenderDebugHooks.onTextPainterLayout = null; + + // Reset cache before measuring 2N + RenderHyperBox.setGlobalTextCacheSize(1); + RenderHyperBox.setGlobalTextCacheSize(5000); + + HyperRenderDebugHooks.onTextPainterLayout = () => calls2N++; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 500, + child: HyperRenderWidget( + key: const ValueKey('2N'), + document: DocumentNode( + children: [ + BlockNode( + tagName: 'p', + children: [TextNode(text2N)], + ), + ], + ), + ), + ), + ), + ), + ); + HyperRenderDebugHooks.onTextPainterLayout = null; + + // In an O(N) linear line-breaker, doubling the input (N -> 2N) roughly doubles operations (ratio ~2.0). + // In an O(N^2) quadratic line-breaker, doubling the input quadruples operations (ratio >= 3.5 - 4.0). + expect(callsN, greaterThan(0)); + expect(calls2N, greaterThan(0)); + final ratio = calls2N / callsN; + expect(ratio, inInclusiveRange(1.5, 2.5), + reason: + 'Scaling ratio ($ratio) between N ($callsN calls) and 2N ($calls2N calls) must be linear O(N) (~2.0), not quadratic O(N^2)'); + }); + }); +}