1// Copyright 2013 The Flutter Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5part of engine; 6 7// TODO(yjbanov): this is a hack we use to compute ideographic baseline; this 8// number is the ratio ideographic/alphabetic for font Ahem, 9// which matches the Flutter number. It may be completely wrong 10// for any other font. We'll need to eventually fix this. That 11// said Flutter doesn't seem to use ideographic baseline for 12// anything as of this writing. 13const double _baselineRatioHack = 1.1662499904632568; 14 15/// Signature of a function that takes a character and returns true or false. 16typedef CharPredicate = bool Function(int char); 17 18bool _whitespacePredicate(int char) => 19 char == CharCode.space || char == CharCode.tab || _newlinePredicate(char); 20bool _newlinePredicate(int char) => 21 char == CharCode.cr || char == CharCode.lf || char == CharCode.nl; 22 23/// Manages [ParagraphRuler] instances and caches them per unique 24/// [ParagraphGeometricStyle]. 25/// 26/// All instances of [ParagraphRuler] should be created through this class. 27class RulerManager { 28 RulerManager({@required this.rulerCacheCapacity}) { 29 _rulerHost.style 30 ..position = 'fixed' 31 ..visibility = 'hidden' 32 ..overflow = 'hidden' 33 ..top = '0' 34 ..left = '0' 35 ..width = '0' 36 ..height = '0'; 37 html.document.body.append(_rulerHost); 38 registerHotRestartListener(dispose); 39 } 40 41 final int rulerCacheCapacity; 42 43 /// Hosts a cache of rulers that measure text. 44 /// 45 /// This element exists purely for organizational purposes. Otherwise the 46 /// rulers would be attached to the `<body>` element polluting the element 47 /// tree and making it hard to navigate. It does not serve any functional 48 /// purpose. 49 final html.Element _rulerHost = html.Element.tag('flt-ruler-host'); 50 51 /// The cache of rulers used to measure text. 52 /// 53 /// Each ruler is keyed by paragraph style. This allows us to setup the 54 /// ruler's DOM structure once during the very first measurement of a given 55 /// paragraph style. Subsequent measurements could reuse the same ruler and 56 /// only swap the text contents. This minimizes the amount of work a browser 57 /// needs to do when measure many pieces of text with the same style. 58 /// 59 /// What makes this cache effective is the fact that a typical application 60 /// only uses a limited number of text styles. Using too many text styles on 61 /// the same screen is considered bad for user experience. 62 Map<ParagraphGeometricStyle, ParagraphRuler> get rulers => _rulers; 63 Map<ParagraphGeometricStyle, ParagraphRuler> _rulers = 64 <ParagraphGeometricStyle, ParagraphRuler>{}; 65 66 bool _rulerCacheCleanupScheduled = false; 67 68 void _scheduleRulerCacheCleanup() { 69 if (!_rulerCacheCleanupScheduled) { 70 _rulerCacheCleanupScheduled = true; 71 scheduleMicrotask(() { 72 _rulerCacheCleanupScheduled = false; 73 cleanUpRulerCache(); 74 }); 75 } 76 } 77 78 /// Releases the resources used by this [RulerManager]. 79 /// 80 /// After this is called, this object is no longer usable. 81 void dispose() { 82 _rulerHost?.remove(); 83 } 84 85 /// If ruler cache size exceeds [rulerCacheCapacity], evicts those rulers that 86 /// were used the least. 87 /// 88 /// Resets hit counts back to zero. 89 @visibleForTesting 90 void cleanUpRulerCache() { 91 if (_rulers.length > rulerCacheCapacity) { 92 final List<ParagraphRuler> sortedByUsage = _rulers.values.toList(); 93 sortedByUsage.sort((ParagraphRuler a, ParagraphRuler b) { 94 return b.hitCount - a.hitCount; 95 }); 96 _rulers = <ParagraphGeometricStyle, ParagraphRuler>{}; 97 for (int i = 0; i < sortedByUsage.length; i++) { 98 final ParagraphRuler ruler = sortedByUsage[i]; 99 ruler.resetHitCount(); 100 if (i < rulerCacheCapacity) { 101 // Retain this ruler. 102 _rulers[ruler.style] = ruler; 103 } else { 104 // This ruler did not have enough usage this frame to be retained. 105 ruler.dispose(); 106 } 107 } 108 } 109 } 110 111 /// Adds an element used for measuring text as a child of [_rulerHost]. 112 void addHostElement(html.DivElement element) { 113 _rulerHost.append(element); 114 } 115 116 /// Performs a cache lookup to find an existing [ParagraphRuler] for the given 117 /// [style] and if it can't find one in the cache, it would create one. 118 /// 119 /// The returned ruler is marked as hit so there's no need to do that 120 /// elsewhere. 121 @visibleForTesting 122 ParagraphRuler findOrCreateRuler(ParagraphGeometricStyle style) { 123 ParagraphRuler ruler = _rulers[style]; 124 if (ruler == null) { 125 if (assertionsEnabled) { 126 domRenderer.debugRulerCacheMiss(); 127 } 128 ruler = _rulers[style] = ParagraphRuler(style, this); 129 _scheduleRulerCacheCleanup(); 130 } else { 131 if (assertionsEnabled) { 132 domRenderer.debugRulerCacheHit(); 133 } 134 } 135 ruler.hit(); 136 return ruler; 137 } 138} 139 140/// Provides various text measurement APIs using either a dom-based approach 141/// in [DomTextMeasurementService], or a canvas-based approach in 142/// [CanvasTextMeasurementService]. 143abstract class TextMeasurementService { 144 /// Initializes the text measurement service with a specific 145 /// [rulerCacheCapacity] that gets passed to the [RulerManager]. 146 static void initialize({@required int rulerCacheCapacity}) { 147 clearCache(); 148 rulerManager = RulerManager(rulerCacheCapacity: rulerCacheCapacity); 149 } 150 151 @visibleForTesting 152 static RulerManager rulerManager; 153 154 /// The DOM-based text measurement service. 155 @visibleForTesting 156 static TextMeasurementService get domInstance => 157 DomTextMeasurementService.instance; 158 159 /// The canvas-based text measurement service. 160 @visibleForTesting 161 static TextMeasurementService get canvasInstance => 162 CanvasTextMeasurementService.instance; 163 164 /// Whether the new experimental implementation of canvas-based text 165 /// measurement is enabled or not. 166 /// 167 /// This is only used for testing at the moment. Once the implementation is 168 /// complete and production-ready, we'll get rid of this flag. 169 static bool enableExperimentalCanvasImplementation = false; 170 171 /// Gets the appropriate [TextMeasurementService] instance for the given 172 /// [paragraph]. 173 static TextMeasurementService forParagraph(ui.Paragraph paragraph) { 174 // TODO(flutter_web): https://github.com/flutter/flutter/issues/33523 175 // When the canvas-based implementation is complete and passes all the 176 // tests, get rid of [_experimentalEnableCanvasImplementation]. 177 if (enableExperimentalCanvasImplementation && 178 _canUseCanvasMeasurement(paragraph)) { 179 return canvasInstance; 180 } 181 return domInstance; 182 } 183 184 /// Clears the cache of paragraph rulers. 185 @visibleForTesting 186 static void clearCache() { 187 rulerManager?.dispose(); 188 rulerManager = null; 189 } 190 191 static bool _canUseCanvasMeasurement(EngineParagraph paragraph) { 192 // Currently, the canvas-based approach only works on plain text that 193 // doesn't have any of the following styles: 194 // - decoration 195 // - word spacing 196 final ParagraphGeometricStyle style = paragraph._geometricStyle; 197 return paragraph._plainText != null && 198 style.decoration == null && 199 style.wordSpacing == null; 200 } 201 202 /// Measures the paragraph and returns a [MeasurementResult] object. 203 MeasurementResult measure( 204 EngineParagraph paragraph, 205 ui.ParagraphConstraints constraints, 206 ) { 207 assert(rulerManager != null); 208 final ParagraphGeometricStyle style = paragraph._geometricStyle; 209 final ParagraphRuler ruler = 210 TextMeasurementService.rulerManager.findOrCreateRuler(style); 211 212 if (assertionsEnabled) { 213 if (paragraph._plainText == null) { 214 domRenderer.debugRichTextLayout(); 215 } else { 216 domRenderer.debugPlainTextLayout(); 217 } 218 } 219 220 MeasurementResult result = ruler.cacheLookup(paragraph, constraints); 221 if (result != null) { 222 return result; 223 } 224 225 result = _doMeasure(paragraph, constraints, ruler); 226 ruler.cacheMeasurement(paragraph, result); 227 return result; 228 } 229 230 /// Measures the width of a substring of the given [paragraph] with no 231 /// constraints. 232 double measureSubstringWidth(EngineParagraph paragraph, int start, int end); 233 234 /// Delegates to a [ParagraphRuler] to measure a list of text boxes that 235 /// enclose the given range of text. 236 List<ui.TextBox> measureBoxesForRange( 237 EngineParagraph paragraph, 238 ui.ParagraphConstraints constraints, { 239 int start, 240 int end, 241 double alignOffset, 242 ui.TextDirection textDirection, 243 }) { 244 final ParagraphGeometricStyle style = paragraph._geometricStyle; 245 final ParagraphRuler ruler = 246 TextMeasurementService.rulerManager.findOrCreateRuler(style); 247 248 return ruler.measureBoxesForRange( 249 paragraph._plainText, 250 constraints, 251 start: start, 252 end: end, 253 alignOffset: alignOffset, 254 textDirection: textDirection, 255 ); 256 } 257 258 /// Performs the actual measurement of the following values for the given 259 /// paragraph: 260 /// 261 /// * isSingleLine: whether the paragraph can be rendered in a single line. 262 /// * height: constrained measure of the entire paragraph's height. 263 /// * lineHeight: the height of a single line of the paragraph. 264 /// * alphabeticBaseline: single line measure. 265 /// * ideographicBaseline: based on [alphabeticBaseline]. 266 /// * maxIntrinsicWidth: the width of the paragraph with no line-wrapping. 267 /// * minIntrinsicWidth: the min width the paragraph fits in without overflowing. 268 /// 269 /// [MeasurementResult.width] is set to the same value of [constraints.width]. 270 /// 271 /// It also optionally computes [MeasurementResult.lines] in the given 272 /// paragraph. When that's available, it can be used by a canvas to render 273 /// the text line. 274 MeasurementResult _doMeasure( 275 EngineParagraph paragraph, 276 ui.ParagraphConstraints constraints, 277 ParagraphRuler ruler, 278 ); 279} 280 281/// A DOM-based text measurement implementation. 282/// 283/// This implementation is slower than [CanvasTextMeasurementService] but it's 284/// needed for some cases that aren't yet supported in the canvas-based 285/// implementation such as letter-spacing, word-spacing, etc. 286class DomTextMeasurementService extends TextMeasurementService { 287 /// The text measurement service singleton. 288 static DomTextMeasurementService get instance => 289 _instance ??= DomTextMeasurementService(); 290 291 static DomTextMeasurementService _instance; 292 293 @override 294 MeasurementResult _doMeasure( 295 EngineParagraph paragraph, 296 ui.ParagraphConstraints constraints, 297 ParagraphRuler ruler, 298 ) { 299 ruler.willMeasure(paragraph); 300 final String plainText = paragraph._plainText; 301 302 ruler.measureAll(constraints); 303 304 MeasurementResult result; 305 // When the text has a new line, we should always use multi-line mode. 306 final bool hasNewline = plainText?.contains('\n') ?? false; 307 if (!hasNewline && ruler.singleLineDimensions.width <= constraints.width) { 308 result = _measureSingleLineParagraph(ruler, paragraph, constraints); 309 } else { 310 // Assert: If text doesn't have new line for infinite constraints we 311 // should have called single line measure paragraph instead. 312 assert(hasNewline || constraints.width != double.infinity); 313 result = _measureMultiLineParagraph(ruler, paragraph, constraints); 314 } 315 ruler.didMeasure(); 316 return result; 317 } 318 319 @override 320 double measureSubstringWidth(EngineParagraph paragraph, int start, int end) { 321 final ParagraphGeometricStyle style = paragraph._geometricStyle; 322 final ParagraphRuler ruler = 323 TextMeasurementService.rulerManager.findOrCreateRuler(style); 324 325 final String text = paragraph._plainText.substring(start, end); 326 final ui.Paragraph substringParagraph = paragraph._cloneWithText(text); 327 328 ruler.willMeasure(substringParagraph); 329 ruler.measureAsSingleLine(); 330 final TextDimensions dimensions = ruler.singleLineDimensions; 331 ruler.didMeasure(); 332 return dimensions.width; 333 } 334 335 /// Called when we have determined that the paragraph fits the [constraints] 336 /// without wrapping. 337 /// 338 /// This means that: 339 /// * `width == maxIntrinsicWidth` - we gave it more horizontal space than 340 /// it needs and so the paragraph won't expand beyond `maxIntrinsicWidth`. 341 /// * `height` is the height computed by `measureAsSingleLine`; giving the 342 /// paragraph the width constraint won't change its height as we already 343 /// determined that it fits within the constraint without wrapping. 344 /// * `alphabeticBaseline` is also final for the same reason as the `height` 345 /// value. 346 /// 347 /// This method still needs to measure `minIntrinsicWidth`. 348 MeasurementResult _measureSingleLineParagraph(ParagraphRuler ruler, 349 ui.Paragraph paragraph, ui.ParagraphConstraints constraints) { 350 final double width = constraints.width; 351 final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width; 352 double maxIntrinsicWidth = ruler.singleLineDimensions.width; 353 final double alphabeticBaseline = ruler.alphabeticBaseline; 354 final double height = ruler.singleLineDimensions.height; 355 356 maxIntrinsicWidth = 357 _applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth); 358 final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack; 359 return MeasurementResult( 360 constraints.width, 361 isSingleLine: true, 362 width: width, 363 height: height, 364 naturalHeight: height, 365 lineHeight: height, 366 minIntrinsicWidth: minIntrinsicWidth, 367 maxIntrinsicWidth: maxIntrinsicWidth, 368 alphabeticBaseline: alphabeticBaseline, 369 ideographicBaseline: ideographicBaseline, 370 lines: null, 371 ); 372 } 373 374 /// Called when we have determined that the paragraph needs to wrap into 375 /// multiple lines to fit the [constraints], i.e. its `maxIntrinsicWidth` is 376 /// bigger than the available horizontal space. 377 /// 378 /// While `maxIntrinsicWidth` is still good from the call to 379 /// `measureAsSingleLine`, we need to re-measure with the width constraint 380 /// and get new values for width, height and alphabetic baseline. We also need 381 /// to measure `minIntrinsicWidth`. 382 MeasurementResult _measureMultiLineParagraph(ParagraphRuler ruler, 383 EngineParagraph paragraph, ui.ParagraphConstraints constraints) { 384 // If constraint is infinite, we must use _measureSingleLineParagraph 385 final double width = constraints.width; 386 final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width; 387 double maxIntrinsicWidth = ruler.singleLineDimensions.width; 388 final double alphabeticBaseline = ruler.alphabeticBaseline; 389 // Natural height is the full height of text ignoring height constraints. 390 final double naturalHeight = ruler.constrainedDimensions.height; 391 392 double height; 393 double lineHeight; 394 final int maxLines = paragraph._geometricStyle.maxLines; 395 if (maxLines == null) { 396 height = naturalHeight; 397 } else { 398 // Lazily compute [lineHeight] when [maxLines] is not null. 399 lineHeight = ruler.lineHeightDimensions.height; 400 height = math.min(naturalHeight, maxLines * lineHeight); 401 } 402 403 maxIntrinsicWidth = 404 _applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth); 405 assert(minIntrinsicWidth <= maxIntrinsicWidth); 406 final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack; 407 return MeasurementResult( 408 constraints.width, 409 isSingleLine: false, 410 width: width, 411 height: height, 412 lineHeight: lineHeight, 413 naturalHeight: naturalHeight, 414 minIntrinsicWidth: minIntrinsicWidth, 415 maxIntrinsicWidth: maxIntrinsicWidth, 416 alphabeticBaseline: alphabeticBaseline, 417 ideographicBaseline: ideographicBaseline, 418 lines: null, 419 ); 420 } 421 422 /// This hack is needed because `offsetWidth` rounds the value to the nearest 423 /// whole number. On a very rare occasion the minimum intrinsic width reported 424 /// by the browser is slightly bigger than the reported maximum intrinsic 425 /// width. If the discrepancy overlaps 0.5 then the rounding happens in 426 /// opposite directions. 427 /// 428 /// For example, if minIntrinsicWidth == 99.5 and maxIntrinsicWidth == 99.48, 429 /// then minIntrinsicWidth is rounded up to 100, and maxIntrinsicWidth is 430 /// rounded down to 99. 431 // TODO(yjbanov): remove the need for this hack. 432 static double _applySubPixelRoundingHack( 433 double minIntrinsicWidth, double maxIntrinsicWidth) { 434 if (minIntrinsicWidth <= maxIntrinsicWidth) { 435 return maxIntrinsicWidth; 436 } 437 438 if (minIntrinsicWidth - maxIntrinsicWidth < 2.0) { 439 return minIntrinsicWidth; 440 } 441 442 throw Exception('minIntrinsicWidth ($minIntrinsicWidth) is greater than ' 443 'maxIntrinsicWidth ($maxIntrinsicWidth).'); 444 } 445} 446 447/// A canvas-based text measurement implementation. 448/// 449/// This is a faster implementation than [DomTextMeasurementService] and 450/// provides line breaks information that can be useful for multi-line text. 451class CanvasTextMeasurementService extends TextMeasurementService { 452 /// The text measurement service singleton. 453 static CanvasTextMeasurementService get instance => 454 _instance ??= CanvasTextMeasurementService(); 455 456 static CanvasTextMeasurementService _instance; 457 458 final html.CanvasRenderingContext2D _canvasContext = 459 html.CanvasElement().context2D; 460 461 @override 462 MeasurementResult _doMeasure( 463 EngineParagraph paragraph, 464 ui.ParagraphConstraints constraints, 465 ParagraphRuler ruler, 466 ) { 467 final String text = paragraph._plainText; 468 final ParagraphGeometricStyle style = paragraph._geometricStyle; 469 assert(text != null); 470 471 // TODO(mdebbar): Check if the whole text can fit in a single-line. Then avoid all this ceremony. 472 _canvasContext.font = style.cssFontString; 473 final LinesCalculator linesCalculator = 474 LinesCalculator(_canvasContext, text, style, constraints.width); 475 final MinIntrinsicCalculator minIntrinsicCalculator = 476 MinIntrinsicCalculator(_canvasContext, text, style); 477 final MaxIntrinsicCalculator maxIntrinsicCalculator = 478 MaxIntrinsicCalculator(_canvasContext, text, style); 479 480 // Indicates whether we've reached the end of text or not. Even if the index 481 // [i] reaches the end of text, we don't want to stop looping until we hit 482 // [LineBreakType.endOfText] because there could be a "\n" at the end of the 483 // string and that would mess things up. 484 bool reachedEndOfText = false; 485 486 // TODO(flutter_web): Chrome & Safari return more info from [canvasContext.measureText]. 487 int i = 0; 488 while (!reachedEndOfText) { 489 final LineBreakResult brk = nextLineBreak(text, i); 490 491 linesCalculator.update(brk); 492 minIntrinsicCalculator.update(brk); 493 maxIntrinsicCalculator.update(brk); 494 495 i = brk.index; 496 if (brk.type == LineBreakType.endOfText) { 497 reachedEndOfText = true; 498 } 499 } 500 501 final int lineCount = linesCalculator.lines.length; 502 final double lineHeight = ruler.lineHeightDimensions.height; 503 final double naturalHeight = lineCount * lineHeight; 504 505 final double height = style.maxLines == null 506 ? naturalHeight 507 : math.min(lineCount, style.maxLines) * lineHeight; 508 509 final MeasurementResult result = MeasurementResult( 510 constraints.width, 511 isSingleLine: lineCount == 1, 512 alphabeticBaseline: ruler.alphabeticBaseline, 513 ideographicBaseline: ruler.alphabeticBaseline * _baselineRatioHack, 514 height: height, 515 naturalHeight: naturalHeight, 516 lineHeight: lineHeight, 517 // `minIntrinsicWidth` is the greatest width of text that can't 518 // be broken down into multiple lines. 519 minIntrinsicWidth: minIntrinsicCalculator.value, 520 // `maxIntrinsicWidth` is the width of the widest piece of text 521 // that doesn't contain mandatory line breaks. 522 maxIntrinsicWidth: maxIntrinsicCalculator.value, 523 width: constraints.width, 524 lines: linesCalculator.lines, 525 ); 526 return result; 527 } 528 529 @override 530 double measureSubstringWidth(EngineParagraph paragraph, int start, int end) { 531 final String text = paragraph._plainText; 532 final ParagraphGeometricStyle style = paragraph._geometricStyle; 533 _canvasContext.font = style.cssFontString; 534 return _measureSubstring( 535 _canvasContext, 536 paragraph._geometricStyle, 537 text, 538 start, 539 end, 540 ); 541 } 542} 543 544// These global variables are used to memoize calls to [_measureSubstring]. They 545// are used to remember the last arguments passed to it, and the last return 546// value. 547// They are being initialized so that the compiler knows they'll never be null. 548int _lastStart = -1; 549int _lastEnd = -1; 550String _lastText = ''; 551ParagraphGeometricStyle _lastStyle; 552double _lastWidth = -1; 553 554/// Measures the width of the substring of [text] starting from the index 555/// [start] (inclusive) to [end] (exclusive). 556/// 557/// This method assumes that the correct font has already been set on 558/// [_canvasContext]. 559double _measureSubstring( 560 html.CanvasRenderingContext2D _canvasContext, 561 ParagraphGeometricStyle style, 562 String text, 563 int start, 564 int end, 565) { 566 assert(0 <= start && start <= end && end <= text.length); 567 568 if (start == end) { 569 return 0; 570 } 571 572 if (start == _lastStart && 573 end == _lastEnd && 574 text == _lastText && 575 _lastStyle == style) { 576 return _lastWidth; 577 } 578 _lastStart = start; 579 _lastEnd = end; 580 _lastText = text; 581 _lastStyle = style; 582 583 final double letterSpacing = style.letterSpacing ?? 0.0; 584 final String sub = 585 start == 0 && end == text.length ? text : text.substring(start, end); 586 final double width = 587 _canvasContext.measureText(sub).width + letterSpacing * sub.length; 588 589 // What we are doing here is we are rounding to the nearest 2nd decimal 590 // point. So 39.999423 becomes 40, and 11.243982 becomes 11.24. 591 // The reason we are doing this is because we noticed that canvas API has a 592 // ±0.001 error margin. 593 return _lastWidth = _roundWidth(width); 594} 595 596double _roundWidth(double width) { 597 return (width * 100).round() / 100; 598} 599 600/// From the substring defined by [text], [start] (inclusive) and [end] 601/// (exclusive), exclude trailing characters that satisfy the given [predicate]. 602/// 603/// The return value is the new end of the substring after excluding the 604/// trailing characters. 605int _excludeTrailing(String text, int start, int end, CharPredicate predicate) { 606 assert(0 <= start && start <= end && end <= text.length); 607 608 while (start < end && predicate(text.codeUnitAt(end - 1))) { 609 end--; 610 } 611 return end; 612} 613 614/// During the text layout phase, this class splits the lines of text so that it 615/// ends up fitting into the given width constraint. 616/// 617/// It mimicks the Flutter engine's behavior when it comes to handling ellipsis 618/// and max lines. 619class LinesCalculator { 620 LinesCalculator(this._canvasContext, this._text, this._style, this._maxWidth); 621 622 final html.CanvasRenderingContext2D _canvasContext; 623 final String _text; 624 final ParagraphGeometricStyle _style; 625 final double _maxWidth; 626 627 /// The lines that have been consumed so far. 628 List<String> lines = <String>[]; 629 630 int _lineStart = 0; 631 int _chunkStart = 0; 632 bool _reachedMaxLines = false; 633 634 double _cachedEllipsisWidth; 635 double get _ellipsisWidth => _cachedEllipsisWidth ??= 636 _roundWidth(_canvasContext.measureText(_style.ellipsis).width); 637 638 bool get hasEllipsis => _style.ellipsis != null; 639 bool get unlimitedLines => _style.maxLines == null; 640 641 /// Consumes the next line break opportunity in [_text]. 642 /// 643 /// This method should be called for every line break. As soon as it reaches 644 /// the maximum number of lines required 645 void update(LineBreakResult brk) { 646 final bool isHardBreak = brk.type == LineBreakType.mandatory || 647 brk.type == LineBreakType.endOfText; 648 final int chunkEnd = brk.index; 649 final int chunkEndWithoutSpace = 650 _excludeTrailing(_text, _chunkStart, chunkEnd, _whitespacePredicate); 651 652 // A single chunk of text could be force-broken into multiple lines if it 653 // doesn't fit in a single line. That's why we need a loop. 654 while (!_reachedMaxLines) { 655 final double lineWidth = _measureSubstring( 656 _canvasContext, 657 _style, 658 _text, 659 _lineStart, 660 chunkEndWithoutSpace, 661 ); 662 663 // The current chunk doesn't reach the maximum width, so we stop here and 664 // wait for the next line break. 665 if (lineWidth <= _maxWidth) { 666 break; 667 } 668 669 // If the current chunk starts at the beginning of the line and exceeds 670 // [maxWidth], then we will need to force-break it. 671 final bool isChunkTooLong = _chunkStart == _lineStart; 672 673 // When ellipsis is set, and maxLines is null, we stop at the first line 674 // that exceeds [maxWidth]. 675 final bool isLastLine = _reachedMaxLines = 676 (hasEllipsis && unlimitedLines) || 677 lines.length + 1 == _style.maxLines; 678 679 if (isLastLine && hasEllipsis) { 680 // When there's an ellipsis, truncate text to leave enough space for 681 // the ellipsis. 682 final double availableWidth = _maxWidth - _ellipsisWidth; 683 final int breakingPoint = _forceBreak( 684 availableWidth, 685 _text, 686 _lineStart, 687 chunkEndWithoutSpace, 688 ); 689 lines.add(_text.substring(_lineStart, breakingPoint) + _style.ellipsis); 690 } else if (isChunkTooLong) { 691 final int breakingPoint = 692 _forceBreak(_maxWidth, _text, _lineStart, chunkEndWithoutSpace); 693 if (breakingPoint == chunkEndWithoutSpace) { 694 // We could force-break the chunk any further which means we reached 695 // the last character and there isn't enough space for it to fit in 696 // its own line. Since this is the last character in the chunk, we 697 // don't do anything here and we rely on the next iteration (or the 698 // [isHardBreak] check below) to break the line. 699 break; 700 } 701 _addLineBreak(lineEnd: breakingPoint); 702 _chunkStart = breakingPoint; 703 } else { 704 // The control case of current line exceeding [_maxWidth], we break the 705 // line. 706 _addLineBreak(lineEnd: _chunkStart); 707 } 708 } 709 710 if (_reachedMaxLines) { 711 return; 712 } 713 714 if (isHardBreak) { 715 _addLineBreak(lineEnd: chunkEnd); 716 } 717 _chunkStart = chunkEnd; 718 } 719 720 void _addLineBreak({@required int lineEnd}) { 721 final int indexWithoutNewlines = _excludeTrailing( 722 _text, 723 _lineStart, 724 lineEnd, 725 _newlinePredicate, 726 ); 727 lines.add(_text.substring(_lineStart, indexWithoutNewlines)); 728 _lineStart = lineEnd; 729 if (lines.length == _style.maxLines) { 730 _reachedMaxLines = true; 731 } 732 } 733 734 /// In a continuous block of text, finds the point where text can be broken to 735 /// fit in the given constraint [maxWidth]. 736 /// 737 /// This always returns at least one character even if there isn't enough 738 /// space for it. 739 int _forceBreak(double maxWidth, String text, int start, int end) { 740 assert(0 <= start && start < end && end <= text.length); 741 742 int low = hasEllipsis ? start : start + 1; 743 int high = end; 744 do { 745 final int mid = (low + high) ~/ 2; 746 final double width = 747 _measureSubstring(_canvasContext, _style, text, start, mid); 748 if (width < maxWidth) { 749 low = mid; 750 } else if (width > maxWidth) { 751 high = mid; 752 } else { 753 low = high = mid; 754 } 755 } while (high - low > 1); 756 757 // The breaking point should be at least one character away from [start]. 758 return low; 759 } 760} 761 762/// During the text layout phase, this class takes care of calculating the 763/// minimum intrinsic width of the given text. 764class MinIntrinsicCalculator { 765 MinIntrinsicCalculator(this._canvasContext, this._text, this._style); 766 767 final html.CanvasRenderingContext2D _canvasContext; 768 final String _text; 769 final ParagraphGeometricStyle _style; 770 771 /// The value of minimum intrinsic width calculated so far. 772 double value = 0.0; 773 int _lastChunkEnd = 0; 774 775 /// Consumes the next line break opportunity in [_text]. 776 /// 777 /// As this method gets called, it updates the [value] to the minimum 778 /// intrinsic width calculated so far. When the whole text is consumed, 779 /// [value] will contain the final minimum intrinsic width. 780 void update(LineBreakResult brk) { 781 final int chunkEnd = brk.index; 782 final int chunkEndWithoutSpace = 783 _excludeTrailing(_text, _lastChunkEnd, chunkEnd, _whitespacePredicate); 784 final double width = _measureSubstring( 785 _canvasContext, _style, _text, _lastChunkEnd, chunkEndWithoutSpace); 786 if (width > value) { 787 value = width; 788 } 789 _lastChunkEnd = chunkEnd; 790 } 791} 792 793/// During text layout, this class is responsible for calculating the maximum 794/// intrinsic width of the given text. 795class MaxIntrinsicCalculator { 796 MaxIntrinsicCalculator(this._canvasContext, this._text, this._style); 797 798 final html.CanvasRenderingContext2D _canvasContext; 799 final String _text; 800 final ParagraphGeometricStyle _style; 801 802 /// The value of maximum intrinsic width calculated so far. 803 double value = 0.0; 804 int _lastHardLineEnd = 0; 805 806 /// Consumes the next line break opportunity in [_text]. 807 /// 808 /// As this method gets called, it updates the [value] to the maximum 809 /// intrinsic width calculated so far. When the whole text is consumed, 810 /// [value] will contain the final maximum intrinsic width. 811 void update(LineBreakResult brk) { 812 if (brk.type == LineBreakType.opportunity) { 813 return; 814 } 815 816 final int hardLineEnd = brk.index; 817 final int hardLineEndWithoutNewlines = _excludeTrailing( 818 _text, 819 _lastHardLineEnd, 820 hardLineEnd, 821 _newlinePredicate, 822 ); 823 final double lineWidth = _measureSubstring( 824 _canvasContext, 825 _style, 826 _text, 827 _lastHardLineEnd, 828 hardLineEndWithoutNewlines, 829 ); 830 if (lineWidth > value) { 831 value = lineWidth; 832 } 833 _lastHardLineEnd = hardLineEnd; 834 } 835} 836