modern-text 2.0.9 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +27 -18
  2. package/dist/deformations/index.cjs +1 -1
  3. package/dist/deformations/index.d.cts +2 -2
  4. package/dist/deformations/index.d.mts +2 -2
  5. package/dist/deformations/index.d.ts +2 -2
  6. package/dist/deformations/index.mjs +1 -1
  7. package/dist/index.cjs +3 -4
  8. package/dist/index.d.cts +20 -21
  9. package/dist/index.d.mts +20 -21
  10. package/dist/index.d.ts +20 -21
  11. package/dist/index.js +4 -4
  12. package/dist/index.mjs +3 -3
  13. package/dist/shared/{modern-text.CKFvdNej.cjs → modern-text.BAnUZM0U.cjs} +506 -495
  14. package/dist/shared/{modern-text.rc-b3qK8.mjs → modern-text.BdF52nXP.mjs} +506 -494
  15. package/dist/shared/{modern-text.CwEJ2EZq.d.ts → modern-text.CGZmjNV0.d.ts} +1 -1
  16. package/dist/shared/{modern-text.K8BlyK2j.mjs → modern-text.CdKp2H-o.mjs} +13 -4
  17. package/dist/shared/{modern-text.DPlppHEM.d.mts → modern-text.DG27jpKj.d.cts} +111 -125
  18. package/dist/shared/{modern-text.DPlppHEM.d.ts → modern-text.DG27jpKj.d.mts} +111 -125
  19. package/dist/shared/{modern-text.DPlppHEM.d.cts → modern-text.DG27jpKj.d.ts} +111 -125
  20. package/dist/shared/{modern-text.BzOupiZm.d.mts → modern-text.DNXfZmiT.d.mts} +1 -1
  21. package/dist/shared/{modern-text.Dmjryk8u.d.cts → modern-text.DzBaDwbG.d.cts} +1 -1
  22. package/dist/shared/{modern-text.DQWdaYoZ.cjs → modern-text._10DVHum.cjs} +13 -4
  23. package/dist/web-components/index.cjs +2 -2
  24. package/dist/web-components/index.d.cts +1 -1
  25. package/dist/web-components/index.d.mts +1 -1
  26. package/dist/web-components/index.d.ts +1 -1
  27. package/dist/web-components/index.mjs +2 -2
  28. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { fonts } from 'modern-font';
2
2
  import { isNone, isGradient, normalizeGradient, clearUndef, Reactivable, normalizeText, getDefaultStyle, property } from 'modern-idoc';
3
3
  import { svgToDom, svgToPath2DSet, Path2DSet, Transform2D, setCanvasContext, Path2D, BoundingBox, Vector2 } from 'modern-path2d';
4
- import { a as definePlugin, b as deformationPlugin } from './modern-text.K8BlyK2j.mjs';
4
+ import { a as definePlugin, b as deformationPlugin } from './modern-text.CdKp2H-o.mjs';
5
5
 
6
6
  function createSvgLoader() {
7
7
  const loaded = /* @__PURE__ */ new Map();
@@ -145,15 +145,21 @@ class Canvas2DRenderer {
145
145
  this.context = context;
146
146
  }
147
147
  pixelRatio = window?.devicePixelRatio || 1;
148
+ // 子区域(平铺)渲染:只把 boundingBox 内相对偏移 (x,y)、尺寸 (width,height) 的一块
149
+ // 画到画布上(其余字形被画布边界裁掉)。用于超大文字按 GPU 上限分块栅格。不设则画整段。
150
+ region;
148
151
  _setupView = () => {
149
152
  const pixelRatio = this.pixelRatio;
150
153
  const ctx = this.context;
151
- const { left, top, width, height } = this.text.boundingBox;
154
+ const bb = this.text.boundingBox;
155
+ const region = this.region;
156
+ const left = bb.left + (region?.x ?? 0);
157
+ const top = bb.top + (region?.y ?? 0);
158
+ const canvasWidth = region?.width ?? bb.width;
159
+ const canvasHeight = region?.height ?? bb.height;
152
160
  const view = ctx.canvas;
153
- view.dataset.viewBox = String(`${left} ${top} ${width} ${height}`);
161
+ view.dataset.viewBox = String(`${left} ${top} ${canvasWidth} ${canvasHeight}`);
154
162
  view.dataset.pixelRatio = String(pixelRatio);
155
- const canvasWidth = width;
156
- const canvasHeight = height;
157
163
  view.width = Math.max(1, Math.ceil(canvasWidth * pixelRatio));
158
164
  view.height = Math.max(1, Math.ceil(canvasHeight * pixelRatio));
159
165
  view.style.width = `${canvasWidth}px`;
@@ -431,47 +437,196 @@ const fontWeightMap = {
431
437
  800: 0.4,
432
438
  900: 0.5
433
439
  };
440
+ const GLYPH_CACHE_CAP = 4096;
441
+ const glyphCache = /* @__PURE__ */ new Map();
442
+ function glyphCacheSet(key, tmpl) {
443
+ glyphCache.set(key, tmpl);
444
+ if (glyphCache.size > GLYPH_CACHE_CAP) {
445
+ glyphCache.delete(glyphCache.keys().next().value);
446
+ }
447
+ }
448
+ let _sfntIdCounter = 0;
449
+ const _sfntIds = /* @__PURE__ */ new WeakMap();
450
+ function sfntId(sfnt) {
451
+ let id = _sfntIds.get(sfnt);
452
+ if (id === void 0) {
453
+ id = ++_sfntIdCounter;
454
+ _sfntIds.set(sfnt, id);
455
+ }
456
+ return id;
457
+ }
458
+ const FONT_METRICS_CAP = 256;
459
+ const fontMetricsCache = /* @__PURE__ */ new Map();
460
+ function getFontMetrics(sfnt, fontSize) {
461
+ const key = `${sfntId(sfnt)}|${fontSize}`;
462
+ const cached = fontMetricsCache.get(key);
463
+ if (cached) {
464
+ return cached;
465
+ }
466
+ const { hhea, os2, post, head } = sfnt;
467
+ const unitsPerEm = head.unitsPerEm;
468
+ const ascender = hhea.ascent;
469
+ const descender = hhea.descent;
470
+ const rate = unitsPerEm / fontSize;
471
+ const advanceHeight = (ascender + Math.abs(descender)) / rate;
472
+ const baseline = ascender / rate;
473
+ const m = {
474
+ sfnt,
475
+ unitsPerEm,
476
+ advanceHeight,
477
+ baseline,
478
+ ascender: ascender / rate,
479
+ descender: descender / rate,
480
+ underlinePosition: (ascender - post.underlinePosition) / rate,
481
+ underlineThickness: post.underlineThickness / rate,
482
+ strikeoutPosition: (ascender - os2.yStrikeoutPosition) / rate,
483
+ strikeoutSize: os2.yStrikeoutSize / rate,
484
+ typoAscender: os2.sTypoAscender / rate,
485
+ typoDescender: os2.sTypoDescender / rate,
486
+ typoLineGap: os2.sTypoLineGap / rate,
487
+ winAscent: os2.usWinAscent / rate,
488
+ winDescent: os2.usWinDescent / rate,
489
+ xHeight: os2.version > 1 ? os2.sxHeight / rate : 0,
490
+ capHeight: os2.version > 1 ? os2.sCapHeight / rate : 0,
491
+ centerDiviation: advanceHeight / 2 - baseline,
492
+ fontStyle: fsSelectionMap[os2.fsSelection] ?? macStyleMap[head.macStyle]
493
+ };
494
+ if (fontMetricsCache.size >= FONT_METRICS_CAP) {
495
+ fontMetricsCache.delete(fontMetricsCache.keys().next().value);
496
+ }
497
+ fontMetricsCache.set(key, m);
498
+ return m;
499
+ }
434
500
  class Character {
435
501
  constructor(content, index, parent) {
436
502
  this.content = content;
437
503
  this.index = index;
438
504
  this.parent = parent;
439
505
  }
440
- path = new Path2D().setMeta(this);
441
- lineBox = new BoundingBox();
506
+ // 惰性 path:布局阶段(measure)只算度量 + glyphBox,不构造逐字定位 path
507
+ // (3681 字逐字 clone+平移占 Text.update 主成本)。path 仅在真正渲染/命中时才按需构建。
508
+ _path;
509
+ _lazyPath;
510
+ get path() {
511
+ if (this._path) {
512
+ return this._path;
513
+ }
514
+ const lazy = this._lazyPath;
515
+ if (lazy) {
516
+ const { x, y } = lazy;
517
+ const p = lazy.tmpl.path.clone().setMeta(this);
518
+ p.applyTransform((pt) => {
519
+ pt.x += x;
520
+ pt.y += y;
521
+ });
522
+ p.style = lazy.style;
523
+ this._path = p;
524
+ this._lazyPath = void 0;
525
+ return p;
526
+ }
527
+ const empty = new Path2D().setMeta(this);
528
+ this._path = empty;
529
+ return empty;
530
+ }
531
+ set path(value) {
532
+ this._path = value;
533
+ this._lazyPath = void 0;
534
+ }
442
535
  inlineBox = new BoundingBox();
536
+ // lineBox(行高/列厚那条 strip)完全由 inlineBox + fontHeight 推出,不再逐字存一个 BoundingBox。
537
+ // 横排:与 inlineBox 同列、在内容盒上下居中、高=fontHeight;竖排:inline/block 轴交换。
538
+ get lineBox() {
539
+ const ib = this.inlineBox;
540
+ const fh = this.fontHeight;
541
+ return this.isVertical ? new BoundingBox(ib.left + (ib.width - fh) / 2, ib.top, fh, ib.height) : new BoundingBox(ib.left, ib.top + (ib.height - fh) / 2, ib.width, fh);
542
+ }
443
543
  glyphBox;
444
544
  advanceWidth = 0;
445
- advanceHeight = 0;
446
- underlinePosition = 0;
447
- underlineThickness = 0;
448
- strikeoutPosition = 0;
449
- strikeoutSize = 0;
450
- ascender = 0;
451
- descender = 0;
452
- typoAscender = 0;
453
- typoDescender = 0;
454
- typoLineGap = 0;
455
- winAscent = 0;
456
- winDescent = 0;
457
- xHeight = 0;
458
- capHeight = 0;
459
- baseline = 0;
460
- centerDiviation = 0;
461
- fontStyle;
545
+ // 字偶距(kerning):与「行内逻辑前一字符」之间的水平调整(px,通常为负=拉近)。
546
+ // Measurer._applyKerning 在横排测量时按字形对填充;行首/换字体/竖排时为 0
547
+ // 断行与定位都计入它,使纯/混排拉丁文本的行宽、换行点与浏览器渲染一致。
548
+ kerningBefore = 0;
549
+ // 本字符首字形在其 sfnt 内的索引(逐字),供相邻字符查字偶距。
550
+ _glyphIndex;
551
+ // 字体度量 flyweight(按 (sfnt,fontSize) 共享);下面所有度量 getter 都从这里推导,不逐字存。
552
+ _metrics;
553
+ // 度量 getter —— 零实例存储,从共享 _metrics 推导(advanceWidth 是唯一逐字存的度量)。
554
+ get advanceHeight() {
555
+ return this._metrics?.advanceHeight ?? 0;
556
+ }
557
+ get baseline() {
558
+ return this._metrics?.baseline ?? 0;
559
+ }
560
+ get ascender() {
561
+ return this._metrics?.ascender ?? 0;
562
+ }
563
+ get descender() {
564
+ return this._metrics?.descender ?? 0;
565
+ }
566
+ get underlinePosition() {
567
+ return this._metrics?.underlinePosition ?? 0;
568
+ }
569
+ get underlineThickness() {
570
+ return this._metrics?.underlineThickness ?? 0;
571
+ }
572
+ get strikeoutPosition() {
573
+ return this._metrics?.strikeoutPosition ?? 0;
574
+ }
575
+ get strikeoutSize() {
576
+ return this._metrics?.strikeoutSize ?? 0;
577
+ }
578
+ get typoAscender() {
579
+ return this._metrics?.typoAscender ?? 0;
580
+ }
581
+ get typoDescender() {
582
+ return this._metrics?.typoDescender ?? 0;
583
+ }
584
+ get typoLineGap() {
585
+ return this._metrics?.typoLineGap ?? 0;
586
+ }
587
+ get winAscent() {
588
+ return this._metrics?.winAscent ?? 0;
589
+ }
590
+ get winDescent() {
591
+ return this._metrics?.winDescent ?? 0;
592
+ }
593
+ get xHeight() {
594
+ return this._metrics?.xHeight ?? 0;
595
+ }
596
+ get capHeight() {
597
+ return this._metrics?.capHeight ?? 0;
598
+ }
599
+ get centerDiviation() {
600
+ return this._metrics?.centerDiviation ?? 0;
601
+ }
602
+ get fontStyle() {
603
+ return this._metrics?.fontStyle;
604
+ }
605
+ // 增量布局:把已测量的字形整体沿 y 平移 dy(用于未变段落因前序段落高度变化而下移)。
606
+ // 同步移动所有盒(inline/line/glyph)与 path(惰性偏移或已构建几何),保持与全量重排一致。
607
+ translateY(dy) {
608
+ if (!dy) {
609
+ return;
610
+ }
611
+ this.inlineBox.top += dy;
612
+ if (this.glyphBox) {
613
+ this.glyphBox.top += dy;
614
+ }
615
+ if (this._lazyPath) {
616
+ this._lazyPath.y += dy;
617
+ } else if (this._path) {
618
+ this._path.applyTransform((p) => {
619
+ p.y += dy;
620
+ });
621
+ }
622
+ }
462
623
  get compatibleGlyphBox() {
624
+ if (this.glyphBox) {
625
+ return this.glyphBox;
626
+ }
463
627
  const size = this.computedStyle.fontSize * 0.8;
464
- return this.glyphBox ?? (this.isVertical ? new BoundingBox(
465
- this.lineBox.left + this.lineBox.width / 2 - size / 2,
466
- this.lineBox.top,
467
- size,
468
- this.lineBox.height
469
- ) : new BoundingBox(
470
- this.lineBox.left,
471
- this.lineBox.top + this.lineBox.height / 2 - size / 2,
472
- this.lineBox.width,
473
- size
474
- ));
628
+ const lb = this.lineBox;
629
+ return this.isVertical ? new BoundingBox(lb.left + lb.width / 2 - size / 2, lb.top, size, lb.height) : new BoundingBox(lb.left, lb.top + lb.height / 2 - size / 2, lb.width, size);
475
630
  }
476
631
  get center() {
477
632
  return this.compatibleGlyphBox.center;
@@ -508,44 +663,30 @@ class Character {
508
663
  if (!sfnt) {
509
664
  return this;
510
665
  }
511
- const { hhea, os2, post, head } = sfnt;
512
- const unitsPerEm = head.unitsPerEm;
513
- const ascender = hhea.ascent;
514
- const descender = hhea.descent;
515
666
  const { content, computedStyle } = this;
516
- const { fontSize } = computedStyle;
517
- const rate = unitsPerEm / fontSize;
518
- const advanceWidth = sfnt.getAdvanceWidth(content, fontSize);
519
- const advanceHeight = (ascender + Math.abs(descender)) / rate;
520
- const baseline = ascender / rate;
521
- this.advanceWidth = advanceWidth;
522
- this.advanceHeight = advanceHeight;
523
- this.underlinePosition = (ascender - post.underlinePosition) / rate;
524
- this.underlineThickness = post.underlineThickness / rate;
525
- this.strikeoutPosition = (ascender - os2.yStrikeoutPosition) / rate;
526
- this.strikeoutSize = os2.yStrikeoutSize / rate;
527
- this.ascender = ascender / rate;
528
- this.descender = descender / rate;
529
- this.typoAscender = os2.sTypoAscender / rate;
530
- this.typoDescender = os2.sTypoDescender / rate;
531
- this.typoLineGap = os2.sTypoLineGap / rate;
532
- this.winAscent = os2.usWinAscent / rate;
533
- this.winDescent = os2.usWinDescent / rate;
534
- this.xHeight = os2.version > 1 ? os2.sxHeight / rate : 0;
535
- this.capHeight = os2.version > 1 ? os2.sCapHeight / rate : 0;
536
- this.baseline = baseline;
537
- this.centerDiviation = advanceHeight / 2 - baseline;
538
- this.fontStyle = fsSelectionMap[os2.fsSelection] ?? macStyleMap[head.macStyle];
667
+ const fontSize = computedStyle.fontSize;
668
+ this._metrics = getFontMetrics(sfnt, fontSize);
669
+ this.advanceWidth = sfnt.getAdvanceWidth(content, fontSize);
670
+ this._glyphIndex = sfnt.textToGlyphIndexes(content)[0];
539
671
  return this;
540
672
  }
673
+ // 与逻辑前一字符 prev 之间的字偶距(px)。仅当同一 sfnt(同字体、非缺字回退分叉)
674
+ // 且两侧字形索引均有效时返回非零;否则 0。kerning value 为 font units,按本字符字号转 px。
675
+ computeKerningBefore(prev) {
676
+ const m = this._metrics;
677
+ if (!prev || !m || prev._metrics?.sfnt !== m.sfnt || prev._glyphIndex === void 0 || this._glyphIndex === void 0) {
678
+ return 0;
679
+ }
680
+ const kv = m.sfnt.getKerningValue(prev._glyphIndex, this._glyphIndex);
681
+ return kv ? kv * this.computedStyle.fontSize / m.unitsPerEm : 0;
682
+ }
541
683
  /**
542
684
  * Populate glyph metrics only (advance width/height, ascender/descender,
543
685
  * baseline, …) without building the glyph `path` or touching boxes.
544
686
  *
545
- * The DOM {@link DomMeasurer} never needs this it reads positions back from the
546
- * browser. A pure-JS measurer (e.g. `FontMeasurer`) must know advances *before*
547
- * it can place characters, so it calls this ahead of layout. `update()` later
548
- * recomputes the same metrics while building the path, so this is idempotent.
687
+ * The pure-JS `Measurer` must know advances *before* it can place characters,
688
+ * so it calls this ahead of layout. `update()` later recomputes the same metrics
689
+ * while building the path, so this is idempotent.
549
690
  */
550
691
  measureGlyph(fonts) {
551
692
  return this.updateGlyph(this._getFontSFNT(fonts));
@@ -556,10 +697,15 @@ class Character {
556
697
  return this;
557
698
  }
558
699
  this.updateGlyph(sfnt);
700
+ const style = this.computedStyle;
701
+ const needsItalic = style.fontStyle === "italic" && this.fontStyle !== "italic";
702
+ if (!this.isVertical && !needsItalic && !style.textStrokeWidth) {
703
+ this._updateFromCache(sfnt, style);
704
+ return this;
705
+ }
559
706
  const {
560
707
  isVertical,
561
708
  content,
562
- computedStyle: style,
563
709
  baseline,
564
710
  inlineBox,
565
711
  ascender,
@@ -570,7 +716,6 @@ class Character {
570
716
  advanceHeight
571
717
  } = this;
572
718
  const { left, top } = inlineBox;
573
- const needsItalic = style.fontStyle === "italic" && fontStyle !== "italic";
574
719
  let x = left;
575
720
  let y = top + baseline;
576
721
  let glyphIndex;
@@ -640,6 +785,39 @@ class Character {
640
785
  this.glyphBox = this.getGlyphBoundingBox();
641
786
  return this;
642
787
  }
788
+ // 横排非合成斜体的快路径:用字形模板缓存构建 path 与 glyphBox。
789
+ _updateFromCache(sfnt, style) {
790
+ const content = this.content;
791
+ const fontSize = style.fontSize;
792
+ const x = this.inlineBox.left;
793
+ const y = this.inlineBox.top + this.baseline;
794
+ const fontWeight = style.fontWeight ?? 400;
795
+ const boldAmount = fontWeight in fontWeightMap && (fontWeight === 700 || fontWeight === "bold") && this.fontStyle !== "bold" ? fontWeightMap[fontWeight] * fontSize * 0.05 : 0;
796
+ const key = `${content}|${sfntId(sfnt)}|${fontSize}|${boldAmount}`;
797
+ let tmpl = glyphCache.get(key);
798
+ if (!tmpl) {
799
+ const tpath = new Path2D();
800
+ tpath.addCommands(sfnt.getPathCommands(content, 0, 0, fontSize));
801
+ if (boldAmount) {
802
+ tpath.bold(boldAmount);
803
+ }
804
+ let glyphBox;
805
+ if (tpath.curves[0]?.curves.length) {
806
+ const { min, max } = tpath.getMinMax(void 0, void 0, true);
807
+ glyphBox = new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
808
+ }
809
+ tmpl = { path: tpath, glyphBox };
810
+ glyphCacheSet(key, tmpl);
811
+ }
812
+ this._path = void 0;
813
+ this._lazyPath = {
814
+ tmpl,
815
+ x,
816
+ y,
817
+ style: clearUndef({ fill: style.color, fillRule: "nonzero" })
818
+ };
819
+ this.glyphBox = tmpl.glyphBox ? new BoundingBox(tmpl.glyphBox.left + x, tmpl.glyphBox.top + y, tmpl.glyphBox.width, tmpl.glyphBox.height) : void 0;
820
+ }
643
821
  _italic(path, startPoint) {
644
822
  path.skew(-0.24, 0, startPoint || {
645
823
  y: this.inlineBox.top + this.baseline,
@@ -647,6 +825,19 @@ class Character {
647
825
  });
648
826
  }
649
827
  getGlyphMinMax(min, max, withStyle) {
828
+ if (this._path === void 0 && this._lazyPath !== void 0) {
829
+ const gb = this.glyphBox;
830
+ if (!gb) {
831
+ return void 0;
832
+ }
833
+ const tl = new Vector2(gb.left, gb.top);
834
+ const br = new Vector2(gb.left + gb.width, gb.top + gb.height);
835
+ const rMin = min ?? new Vector2(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
836
+ const rMax = max ?? new Vector2(Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER);
837
+ rMin.clampMin(tl, br);
838
+ rMax.clampMax(tl, br);
839
+ return { min: rMin, max: rMax };
840
+ }
650
841
  if (this.path.curves[0]?.curves.length) {
651
842
  return this.path.getMinMax(min, max, withStyle);
652
843
  } else {
@@ -711,6 +902,18 @@ class Paragraph {
711
902
  fragments = [];
712
903
  fill;
713
904
  outline;
905
+ // 增量布局缓存(见 Text._update / Measurer):
906
+ // _layoutDirty=true 时须全量重排该段;为 false(复用未变段)时,measurer 仅按 dy 平移。
907
+ // _layoutTop/_layoutHeight/_layoutRight 记录上次测量的绝对 y 顶/高/右,供平移与并集复用。
908
+ // _glyphBox 缓存该段字形包围盒(绝对坐标),平移时随段一起移,免去逐字 union。
909
+ _layoutDirty = true;
910
+ _layoutTop = 0;
911
+ _layoutHeight = 0;
912
+ _layoutRight = 0;
913
+ _glyphBox;
914
+ // 本段是否「有效测量」:含字符却所有字形 advance 为 0(字体未就绪时的退化测量)则为 false,
915
+ // 不可作为增量复用基准,须下次重测,避免把字体就绪前的零宽布局固化。
916
+ _layoutValid = false;
714
917
  update() {
715
918
  this.computedStyle = {
716
919
  ...clearUndef(this.parent.computedStyle),
@@ -724,381 +927,25 @@ class Paragraph {
724
927
  }
725
928
  }
726
929
 
727
- let sharedContainer;
728
- function getSharedContainer() {
729
- if (sharedContainer?.isConnected) {
730
- return sharedContainer;
731
- }
732
- const container = document.createElement("div");
733
- container.dataset.modernText = "measurer";
734
- Object.assign(container.style, {
735
- position: "fixed",
736
- left: "0",
737
- top: "0",
738
- visibility: "hidden",
739
- pointerEvents: "none"
740
- });
741
- document.body.appendChild(container);
742
- sharedContainer = container;
743
- return container;
744
- }
745
- class DomMeasurer {
746
- static notZeroStyles = /* @__PURE__ */ new Set([
747
- "width",
748
- "height"
749
- ]);
750
- static pxStyles = /* @__PURE__ */ new Set([
751
- "width",
752
- "height",
753
- "fontSize",
754
- "letterSpacing",
755
- "textStrokeWidth",
756
- "textIndent",
757
- "shadowOffsetX",
758
- "shadowOffsetY",
759
- "shadowBlur",
760
- "margin",
761
- "marginLeft",
762
- "marginTop",
763
- "marginRight",
764
- "marginBottom",
765
- "padding",
766
- "paddingLeft",
767
- "paddingTop",
768
- "paddingRight",
769
- "paddingBottom"
770
- ]);
771
- _styleCache = /* @__PURE__ */ new WeakMap();
772
- _mountedDom;
773
- _mountedSignature;
774
- _toDomStyle(style) {
775
- const cached = this._styleCache.get(style);
776
- if (cached) {
777
- return cached;
778
- }
779
- const domStyle = {};
780
- const { notZeroStyles, pxStyles } = DomMeasurer;
781
- for (const key in style) {
782
- const value = style[key];
783
- if (notZeroStyles.has(key) && value === 0) {
784
- continue;
785
- }
786
- domStyle[key] = typeof value === "number" && pxStyles.has(key) ? `${value}px` : value;
787
- }
788
- this._styleCache.set(style, domStyle);
789
- return domStyle;
790
- }
791
- _resolveRootStyles(rootStyle) {
792
- const style = { ...rootStyle };
793
- const isHorizontal = rootStyle.writingMode.includes("horizontal");
794
- switch (rootStyle.textAlign) {
795
- case "start":
796
- case "left":
797
- style.justifyContent = "flex-start";
798
- break;
799
- case "center":
800
- style.justifyContent = "center";
801
- break;
802
- case "end":
803
- case "right":
804
- style.justifyContent = "flex-end";
805
- break;
806
- }
807
- switch (rootStyle.verticalAlign) {
808
- case "top":
809
- style.alignItems = "flex-start";
810
- break;
811
- case "middle":
812
- style.alignItems = "center";
813
- break;
814
- case "bottom":
815
- style.alignItems = "flex-end";
816
- break;
817
- }
818
- const isFlex = Boolean(style.justifyContent || style.alignItems);
819
- return {
820
- section: {
821
- ...this._toDomStyle({
822
- ...style,
823
- boxSizing: style.boxSizing ?? "border-box",
824
- display: style.display ?? (isFlex ? "inline-flex" : void 0),
825
- width: style.width ?? "max-content",
826
- height: style.height ?? "max-content"
827
- }),
828
- whiteSpace: "pre-wrap",
829
- wordBreak: "break-all"
830
- },
831
- ul: {
832
- verticalAlign: "inherit",
833
- listStyleType: "inherit",
834
- padding: "0",
835
- margin: "0",
836
- width: isFlex && isHorizontal ? "100%" : void 0,
837
- height: isFlex && !isHorizontal ? "100%" : void 0
838
- }
839
- };
840
- }
841
- _applyRootStyle(section, ul, rootStyle) {
842
- const styles = this._resolveRootStyles(rootStyle);
843
- section.removeAttribute("style");
844
- Object.assign(section.style, styles.section);
845
- ul.removeAttribute("style");
846
- Object.assign(ul.style, styles.ul);
847
- }
848
- _applyLiStyle(li, paragraph) {
849
- li.removeAttribute("style");
850
- li.style.verticalAlign = "inherit";
851
- Object.assign(li.style, this._toDomStyle(paragraph.style));
852
- }
853
- _applySpanStyle(span, fragment) {
854
- span.removeAttribute("style");
855
- span.style.verticalAlign = "inherit";
856
- Object.assign(span.style, this._toDomStyle(fragment.style));
857
- }
858
- _applyFragmentContent(span, fragment) {
859
- const textNode = span.firstChild;
860
- if (textNode) {
861
- if (textNode.data !== fragment.content) {
862
- textNode.data = fragment.content;
863
- }
864
- } else {
865
- span.appendChild(document.createTextNode(fragment.content));
866
- }
867
- }
868
- _hide(element) {
869
- element.style.visibility = "hidden";
870
- }
871
- _signature(paragraphs) {
872
- let sig = `${paragraphs.length}`;
873
- for (let i = 0; i < paragraphs.length; i++) {
874
- sig += `:${paragraphs[i].fragments.length}`;
875
- }
876
- return sig;
877
- }
878
- /**
879
- * <section style="...">
880
- * <ul>
881
- * <li style="...">
882
- * <span style="...">...</span>
883
- * <span>...</span>
884
- * </li>
885
- * </ul>
886
- * </section>
887
- */
888
- createDom(paragraphs, rootStyle) {
889
- const section = document.createElement("section");
890
- const ul = document.createElement("ul");
891
- this._applyRootStyle(section, ul, rootStyle);
892
- paragraphs.forEach((paragraph) => {
893
- const li = document.createElement("li");
894
- this._applyLiStyle(li, paragraph);
895
- paragraph.fragments.forEach((fragment) => {
896
- const span = document.createElement("span");
897
- this._applySpanStyle(span, fragment);
898
- this._applyFragmentContent(span, fragment);
899
- li.appendChild(span);
900
- });
901
- ul.appendChild(li);
902
- });
903
- section.appendChild(ul);
904
- return section;
905
- }
906
- _patchDom(dom, paragraphs, rootStyle, hidden) {
907
- const ul = dom.firstChild;
908
- this._applyRootStyle(dom, ul, rootStyle);
909
- if (hidden) {
910
- this._hide(dom);
911
- }
912
- const lis = ul.children;
913
- for (let i = 0; i < paragraphs.length; i++) {
914
- const li = lis[i];
915
- this._applyLiStyle(li, paragraphs[i]);
916
- const fragments = paragraphs[i].fragments;
917
- const spans = li.children;
918
- for (let j = 0; j < fragments.length; j++) {
919
- const span = spans[j];
920
- this._applySpanStyle(span, fragments[j]);
921
- this._applyFragmentContent(span, fragments[j]);
922
- }
923
- }
924
- }
925
- measureDomText(text) {
926
- const data = text.data ?? "";
927
- const results = [];
928
- if (!data.length) {
929
- return results;
930
- }
931
- const range = document.createRange();
932
- let i = 0;
933
- while (i < data.length) {
934
- const cp = data.codePointAt(i);
935
- const end = i + (cp > 65535 ? 2 : 1);
936
- range.setStart(text, i);
937
- range.setEnd(text, end);
938
- const rects = range.getClientRects?.();
939
- let rect;
940
- if (rects && rects.length) {
941
- rect = rects[rects.length - 1];
942
- if (rects.length > 1 && rect.width < 2) {
943
- rect = rects[rects.length - 2];
944
- }
945
- } else {
946
- rect = range.getBoundingClientRect();
947
- }
948
- if (rect && rect.width + rect.height !== 0) {
949
- results.push({
950
- content: data.slice(i, end),
951
- top: rect.top,
952
- left: rect.left,
953
- width: rect.width,
954
- height: rect.height
955
- });
956
- }
957
- i = end;
958
- }
959
- return results;
960
- }
961
- measureDom(dom) {
962
- const paragraphs = [];
963
- const fragments = [];
964
- const characters = [];
965
- dom.querySelectorAll("li").forEach((pDom, paragraphIndex) => {
966
- const pBox = pDom.getBoundingClientRect();
967
- paragraphs.push({
968
- paragraphIndex,
969
- left: pBox.x,
970
- top: pBox.y,
971
- width: pBox.width,
972
- height: pBox.height
973
- });
974
- pDom.querySelectorAll(":scope > *").forEach((fDom, fragmentIndex) => {
975
- const fBox = fDom.getBoundingClientRect();
976
- fragments.push({
977
- paragraphIndex,
978
- fragmentIndex,
979
- left: fBox.x,
980
- top: fBox.y,
981
- width: fBox.width,
982
- height: fBox.height
983
- });
984
- let characterIndex = 0;
985
- const pushChars = (textNode) => {
986
- this.measureDomText(textNode).forEach((char) => {
987
- characters.push({
988
- ...char,
989
- newParagraphIndex: -1,
990
- paragraphIndex,
991
- fragmentIndex,
992
- characterIndex: characterIndex++,
993
- textWidth: -1,
994
- textHeight: -1
995
- });
996
- });
997
- };
998
- if (!fDom.children.length && fDom.firstChild instanceof window.Text) {
999
- pushChars(fDom.firstChild);
1000
- } else {
1001
- fDom.querySelectorAll(":scope > *").forEach((cDom) => {
1002
- if (cDom.firstChild instanceof window.Text) {
1003
- pushChars(cDom.firstChild);
1004
- }
1005
- });
1006
- }
1007
- });
1008
- });
1009
- return { paragraphs, fragments, characters };
1010
- }
1011
- measureParagraphDom(paragraphs, dom) {
1012
- const rect = dom.getBoundingClientRect();
1013
- const measured = this.measureDom(dom);
1014
- measured.paragraphs.forEach((p) => {
1015
- const box = paragraphs[p.paragraphIndex].lineBox;
1016
- box.left = p.left - rect.left;
1017
- box.top = p.top - rect.top;
1018
- box.width = p.width;
1019
- box.height = p.height;
1020
- });
1021
- measured.fragments.forEach((f) => {
1022
- const box = paragraphs[f.paragraphIndex].fragments[f.fragmentIndex].inlineBox;
1023
- box.left = f.left - rect.left;
1024
- box.top = f.top - rect.top;
1025
- box.width = f.width;
1026
- box.height = f.height;
1027
- });
1028
- const results = [];
1029
- measured.characters.forEach((character) => {
1030
- const { paragraphIndex, fragmentIndex, characterIndex } = character;
1031
- const left = character.left - rect.left;
1032
- const top = character.top - rect.top;
1033
- results.push({
1034
- ...character,
1035
- newParagraphIndex: paragraphIndex,
1036
- left,
1037
- top
1038
- });
1039
- const item = paragraphs[paragraphIndex].fragments[fragmentIndex].characters[characterIndex];
1040
- const { fontHeight, isVertical, inlineBox, lineBox } = item;
1041
- inlineBox.left = left;
1042
- inlineBox.top = top;
1043
- inlineBox.width = character.width;
1044
- inlineBox.height = character.height;
1045
- if (isVertical) {
1046
- lineBox.left = left + (character.width - fontHeight) / 2;
1047
- lineBox.top = top;
1048
- lineBox.width = fontHeight;
1049
- lineBox.height = character.height;
1050
- } else {
1051
- lineBox.left = left;
1052
- lineBox.top = top + (character.height - fontHeight) / 2;
1053
- lineBox.width = character.width;
1054
- lineBox.height = fontHeight;
1055
- }
1056
- });
1057
- return {
1058
- paragraphs,
1059
- boundingBox: new BoundingBox(0, 0, rect.width, rect.height)
1060
- };
1061
- }
1062
- measure(paragraphs, rootStyle, dom) {
1063
- return this.measureParagraphDom(paragraphs, dom ?? this._mount(paragraphs, rootStyle));
1064
- }
1065
- _mount(paragraphs, rootStyle) {
1066
- const signature = this._signature(paragraphs);
1067
- if (this._mountedDom?.isConnected && this._mountedSignature === signature) {
1068
- this._patchDom(this._mountedDom, paragraphs, rootStyle, true);
1069
- return this._mountedDom;
1070
- }
1071
- this._unmount();
1072
- const dom = this.createDom(paragraphs, rootStyle);
1073
- this._hide(dom);
1074
- getSharedContainer().appendChild(dom);
1075
- this._mountedDom = dom;
1076
- this._mountedSignature = signature;
1077
- return dom;
1078
- }
1079
- _unmount() {
1080
- this._mountedDom?.parentNode?.removeChild(this._mountedDom);
1081
- this._mountedDom = void 0;
1082
- this._mountedSignature = void 0;
1083
- }
1084
- dispose() {
1085
- this._unmount();
1086
- }
1087
- }
1088
-
1089
930
  function side(style, name, edge) {
1090
931
  return style[`${name}${edge}`] ?? style[name] ?? 0;
1091
932
  }
1092
- class FontMeasurer {
933
+ class Measurer {
1093
934
  measure(paragraphs, rootStyle, _dom, fonts$1) {
1094
935
  const _fonts = fonts$1 ?? fonts;
1095
936
  for (const paragraph of paragraphs) {
937
+ if (!paragraph._layoutDirty) {
938
+ continue;
939
+ }
1096
940
  for (const fragment of paragraph.fragments) {
1097
941
  for (const character of fragment.characters) {
1098
942
  character.measureGlyph(_fonts);
1099
943
  }
1100
944
  }
1101
945
  }
946
+ if (!rootStyle.writingMode.includes("vertical")) {
947
+ this._applyKerning(paragraphs);
948
+ }
1102
949
  return rootStyle.writingMode.includes("vertical") ? this._measureVertical(paragraphs, rootStyle) : this._measureHorizontal(paragraphs, rootStyle);
1103
950
  }
1104
951
  _rootPadding(rootStyle) {
@@ -1132,18 +979,48 @@ class FontMeasurer {
1132
979
  y += mTop + pTop;
1133
980
  const paraTop = y;
1134
981
  let paraRight = liLeft;
982
+ if (!paragraph._layoutDirty) {
983
+ const dy = paraTop - paragraph._layoutTop;
984
+ if (dy) {
985
+ for (const fragment of paragraph.fragments) {
986
+ for (const c of fragment.characters) {
987
+ c.translateY(dy);
988
+ }
989
+ fragment.inlineBox.top += dy;
990
+ }
991
+ paragraph.lineBox.top += dy;
992
+ if (paragraph._glyphBox) {
993
+ paragraph._glyphBox.top += dy;
994
+ }
995
+ }
996
+ paragraph._layoutTop = paraTop;
997
+ paraRight = paragraph._layoutRight;
998
+ y = paraTop + paragraph.lineBox.height;
999
+ if (paraRight > maxRight) {
1000
+ maxRight = paraRight;
1001
+ }
1002
+ y += pBottom + mBottom;
1003
+ continue;
1004
+ }
1135
1005
  const lines = this._breakLines(paragraph, liAvail);
1136
1006
  const align = pStyle.textAlign;
1137
1007
  const indent = pStyle.textIndent ?? 0;
1008
+ let hasChars = false;
1009
+ let anyAdvance = false;
1138
1010
  for (let i = 0; i < lines.length; i++) {
1139
1011
  const line = lines[i];
1140
1012
  const lineIndent = i === 0 ? indent : 0;
1141
1013
  let lineHeight = pStyle.fontSize * pStyle.lineHeight;
1142
1014
  let contentWidth = 0;
1015
+ let firstW = true;
1143
1016
  for (const c of line) {
1144
1017
  if (c.fontHeight > lineHeight) {
1145
1018
  lineHeight = c.fontHeight;
1146
1019
  }
1020
+ if (!firstW) {
1021
+ contentWidth += c.kerningBefore;
1022
+ }
1023
+ firstW = false;
1147
1024
  contentWidth += this._advance(c);
1148
1025
  }
1149
1026
  let x = liLeft + lineIndent;
@@ -1155,18 +1032,22 @@ class FontMeasurer {
1155
1032
  x += slack;
1156
1033
  }
1157
1034
  }
1035
+ let firstInLine = true;
1158
1036
  for (const c of line) {
1037
+ hasChars = true;
1038
+ if (c.advanceWidth > 0) {
1039
+ anyAdvance = true;
1040
+ }
1041
+ if (!firstInLine) {
1042
+ x += c.kerningBefore;
1043
+ }
1044
+ firstInLine = false;
1159
1045
  const adv = c.advanceWidth;
1160
1046
  const contentHeight = c.advanceHeight;
1161
- const fontHeight = c.fontHeight;
1162
1047
  c.inlineBox.left = x;
1163
1048
  c.inlineBox.top = y + (lineHeight - contentHeight) / 2;
1164
1049
  c.inlineBox.width = adv;
1165
1050
  c.inlineBox.height = contentHeight;
1166
- c.lineBox.left = x;
1167
- c.lineBox.top = c.inlineBox.top + (contentHeight - fontHeight) / 2;
1168
- c.lineBox.width = adv;
1169
- c.lineBox.height = fontHeight;
1170
1051
  x += this._advance(c);
1171
1052
  }
1172
1053
  if (x > paraRight) {
@@ -1184,6 +1065,11 @@ class FontMeasurer {
1184
1065
  paragraph.lineBox.top = paraTop;
1185
1066
  paragraph.lineBox.width = liAvail === Infinity ? paraRight - liLeft : liAvail;
1186
1067
  paragraph.lineBox.height = y - paraTop;
1068
+ paragraph._layoutTop = paraTop;
1069
+ paragraph._layoutHeight = paragraph.lineBox.height;
1070
+ paragraph._layoutRight = paraRight;
1071
+ paragraph._glyphBox = void 0;
1072
+ paragraph._layoutValid = !hasChars || anyAdvance;
1187
1073
  y += pBottom + mBottom;
1188
1074
  }
1189
1075
  const contentBottom = y + rootPad.bottom;
@@ -1207,9 +1093,8 @@ class FontMeasurer {
1207
1093
  * flow top→bottom. It is the horizontal layout with the inline and block axes
1208
1094
  * swapped — the inline (down-column) advance is `advanceWidth` (CJK upright = em;
1209
1095
  * Latin is rotated, so its advance ≈ its width), the cross-axis content box is
1210
- * `advanceHeight`, and the column thickness is `fontHeight`. The lineBox is
1211
- * derived exactly as DomMeasurer.measureParagraphDom's vertical branch, so it
1212
- * stays accurate even though inlineBox carries the content-box rounding residual.
1096
+ * `advanceHeight`, and the column thickness is `fontHeight`. The lineBox stays
1097
+ * accurate even though inlineBox carries the content-box rounding residual.
1213
1098
  *
1214
1099
  * v1: `vertical-rl` only; no per-paragraph margin/padding or block alignment.
1215
1100
  */
@@ -1243,15 +1128,10 @@ class FontMeasurer {
1243
1128
  for (const c of column.chars) {
1244
1129
  const advance = c.advanceWidth;
1245
1130
  const contentWidth = c.advanceHeight;
1246
- const fontHeight = c.fontHeight;
1247
1131
  c.inlineBox.top = y;
1248
1132
  c.inlineBox.height = advance;
1249
1133
  c.inlineBox.width = contentWidth;
1250
1134
  c.inlineBox.left = colLeft + (column.thickness - contentWidth) / 2;
1251
- c.lineBox.left = c.inlineBox.left + (contentWidth - fontHeight) / 2;
1252
- c.lineBox.top = y;
1253
- c.lineBox.width = fontHeight;
1254
- c.lineBox.height = advance;
1255
1135
  y += this._advance(c);
1256
1136
  }
1257
1137
  if (y > maxBottom) {
@@ -1298,9 +1178,13 @@ class FontMeasurer {
1298
1178
  flush();
1299
1179
  continue;
1300
1180
  }
1301
- if (avail !== Infinity && current.length > 0 && width + c.advanceWidth > avail) {
1181
+ const kern = current.length > 0 ? c.kerningBefore : 0;
1182
+ if (avail !== Infinity && current.length > 0 && width + kern + c.advanceWidth > avail) {
1302
1183
  flush();
1303
1184
  }
1185
+ if (current.length > 0) {
1186
+ width += c.kerningBefore;
1187
+ }
1304
1188
  current.push(c);
1305
1189
  width += this._advance(c);
1306
1190
  }
@@ -1308,6 +1192,22 @@ class FontMeasurer {
1308
1192
  flush();
1309
1193
  return lines;
1310
1194
  }
1195
+ // 横排:为每段(仅 dirty 段)填充相邻字形的字偶距。clean 段沿用上次结果(kern 是行内水平量,
1196
+ // 不随段落 y 平移变化),契合增量布局。
1197
+ _applyKerning(paragraphs) {
1198
+ for (const paragraph of paragraphs) {
1199
+ if (!paragraph._layoutDirty) {
1200
+ continue;
1201
+ }
1202
+ let prev;
1203
+ for (const fragment of paragraph.fragments) {
1204
+ for (const c of fragment.characters) {
1205
+ c.kerningBefore = c.computeKerningBefore(prev);
1206
+ prev = c;
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1311
1211
  _unionInto(target, boxes) {
1312
1212
  const used = boxes.filter((b) => b.width !== 0 || b.height !== 0);
1313
1213
  if (!used.length) {
@@ -1326,7 +1226,6 @@ class FontMeasurer {
1326
1226
  fragment.inlineBox.top += dy;
1327
1227
  for (const character of fragment.characters) {
1328
1228
  character.inlineBox.top += dy;
1329
- character.lineBox.top += dy;
1330
1229
  }
1331
1230
  }
1332
1231
  }
@@ -1829,19 +1728,37 @@ function outlinePlugin() {
1829
1728
 
1830
1729
  function renderPlugin() {
1831
1730
  const pathSet = new Path2DSet();
1731
+ let chars = [];
1732
+ let built = [];
1733
+ Object.defineProperty(pathSet, "paths", {
1734
+ configurable: true,
1735
+ enumerable: true,
1736
+ get() {
1737
+ if (built === null) {
1738
+ built = chars.map((c) => c.path);
1739
+ }
1740
+ return built;
1741
+ },
1742
+ set(value) {
1743
+ built = value;
1744
+ }
1745
+ });
1832
1746
  return definePlugin({
1833
1747
  name: "render",
1834
1748
  pathSet,
1835
1749
  update: (text) => {
1836
- pathSet.paths.length = 0;
1750
+ const next = [];
1837
1751
  const { paragraphs } = text;
1838
1752
  paragraphs.forEach((paragraph) => {
1839
1753
  paragraph.fragments.forEach((fragment) => {
1840
1754
  fragment.characters.forEach((character) => {
1841
- pathSet.paths.push(character.path);
1755
+ next.push(character);
1842
1756
  });
1843
1757
  });
1844
1758
  });
1759
+ chars = next;
1760
+ built = null;
1761
+ pathSet._lazyCount = next.length;
1845
1762
  },
1846
1763
  getBoundingBox: (text) => {
1847
1764
  const { characters, computedEffects, fontSize } = text;
@@ -2093,11 +2010,24 @@ class Text extends Reactivable {
2093
2010
  glyphBox = new BoundingBox();
2094
2011
  pathBox = new BoundingBox();
2095
2012
  boundingBox = new BoundingBox();
2096
- measurer = new DomMeasurer();
2013
+ measurer = new Measurer();
2097
2014
  plugins = /* @__PURE__ */ new Map();
2098
2015
  pathSets = [];
2099
2016
  _paragraphs = [];
2100
2017
  _cachedCharacters;
2018
+ // 增量布局开关与上一帧快照:未变段落按内容键复用,仅重排受影响段、平移后续段。
2019
+ incrementalLayout = true;
2020
+ _prevParagraphs = [];
2021
+ _prevContentKeys = [];
2022
+ _prevStyleKey = "";
2023
+ // 上次测量所用的 fonts 引用:从 undefined→tree.fonts 的赋值会改变字形度量,须作废增量基准
2024
+ // (否则会复用「fonts 未就绪」时测得的零宽字形)。
2025
+ _prevFonts;
2026
+ _prevFontsSet = false;
2027
+ // _update 计算出的待提交快照;仅在 measure() 真正完成测量后才提交到 _prev*(见 measure),
2028
+ // 否则构造函数里的 _update(只建树不测量)会让首测误把未测量段当作可复用。
2029
+ _pendingContentKeys = [];
2030
+ _pendingStyleKey = "";
2101
2031
  _pluginsByUpdateOrder = [];
2102
2032
  _pluginsByRenderOrder = [];
2103
2033
  _renderer;
@@ -2151,12 +2081,6 @@ class Text extends Reactivable {
2151
2081
  outline,
2152
2082
  deformation
2153
2083
  } = normalizeText(options);
2154
- if (options.measurer && typeof options.measurer !== "string") {
2155
- this.measurer = options.measurer;
2156
- } else {
2157
- const kind = options.measurer ?? "font";
2158
- this.measurer = kind === "font" ? new FontMeasurer() : new DomMeasurer();
2159
- }
2160
2084
  this.debug = options.debug ?? false;
2161
2085
  this.content = content;
2162
2086
  this.effects = effects;
@@ -2196,10 +2120,13 @@ class Text extends Reactivable {
2196
2120
  }
2197
2121
  async load() {
2198
2122
  this._update();
2199
- await Promise.all([
2123
+ const [decoded] = await Promise.all([
2200
2124
  this._decodeFonts(),
2201
2125
  ...Array.from(this.plugins.values()).map((p) => p.load?.(this))
2202
2126
  ]);
2127
+ if (decoded > 0) {
2128
+ this._prevParagraphs = [];
2129
+ }
2203
2130
  }
2204
2131
  /**
2205
2132
  * Eagerly decode the fonts this text uses, off the main thread — WOFF tables
@@ -2219,12 +2146,15 @@ class Text extends Reactivable {
2219
2146
  }
2220
2147
  }
2221
2148
  entries.add(fonts$1.fallbackFont);
2222
- await Promise.all(Array.from(entries, async (entry) => {
2149
+ const decoded = await Promise.all(Array.from(entries, async (entry) => {
2223
2150
  const font = entry?.getFont();
2224
2151
  if (font && typeof font.createSFNTAsync === "function" && !font._sfnt) {
2225
2152
  font._sfnt = await font.createSFNTAsync();
2153
+ return 1;
2226
2154
  }
2155
+ return 0;
2227
2156
  }));
2157
+ return decoded.reduce((a, b) => a + b, 0);
2228
2158
  }
2229
2159
  /**
2230
2160
  * Coerce numeric style fields to finite numbers.
@@ -2257,44 +2187,70 @@ class Text extends Reactivable {
2257
2187
  }
2258
2188
  return style;
2259
2189
  }
2190
+ _buildParagraph(p, pIndex) {
2191
+ const { fragments, fill: pFill, outline: pOutline, ...pStyle } = p;
2192
+ const paragraph = new Paragraph(pStyle, pIndex, this);
2193
+ paragraph.fill = pFill;
2194
+ paragraph.outline = pOutline;
2195
+ fragments.forEach((f, fIndex) => {
2196
+ const { content, fill: fFill, outline: fOutline, ...fStyle } = f;
2197
+ if (content !== void 0) {
2198
+ paragraph.fragments.push(
2199
+ new Fragment(content, fStyle, fFill, fOutline, fIndex, paragraph)
2200
+ );
2201
+ }
2202
+ });
2203
+ return paragraph;
2204
+ }
2205
+ // 仅当根级样式/填充/描边/特效/形变未变、且非竖排、非块级垂直对齐位移时,才允许复用未变段落
2206
+ // (这些会改变各段 computedStyle 或全局位移,复用会得到错误结果)。
2207
+ _canReuseLayout(styleKey) {
2208
+ if (!this.incrementalLayout || !this._prevParagraphs.length) {
2209
+ return false;
2210
+ }
2211
+ if (styleKey !== this._prevStyleKey) {
2212
+ return false;
2213
+ }
2214
+ if (!this._prevFontsSet || this.fonts !== this._prevFonts) {
2215
+ return false;
2216
+ }
2217
+ const cs = this.computedStyle;
2218
+ if (cs.writingMode.includes("vertical")) {
2219
+ return false;
2220
+ }
2221
+ if (typeof cs.height === "number" && (cs.verticalAlign === "middle" || cs.verticalAlign === "bottom")) {
2222
+ return false;
2223
+ }
2224
+ return true;
2225
+ }
2260
2226
  _update() {
2261
2227
  this.computedStyle = this._normalizeComputedStyle({ ...textDefaultStyle, ...this.style });
2262
2228
  this.computedFill = this.fill ? { ...this.fill } : void 0;
2263
2229
  this.computedOutline = this.outline ? { ...this.outline } : void 0;
2264
2230
  this.computedEffects = this.effects?.map((v) => v) ?? [];
2265
- const paragraphs = [];
2266
- this.content.forEach((p, pIndex) => {
2267
- const { fragments, fill: pFill, outline: pOutline, ...pStyle } = p;
2268
- const paragraph = new Paragraph(pStyle, pIndex, this);
2269
- paragraph.fill = pFill;
2270
- paragraph.outline = pOutline;
2271
- fragments.forEach((f, fIndex) => {
2272
- const { content, fill: fFill, outline: fOutline, ...fStyle } = f;
2273
- if (content !== void 0) {
2274
- paragraph.fragments.push(
2275
- new Fragment(
2276
- content,
2277
- fStyle,
2278
- fFill,
2279
- fOutline,
2280
- fIndex,
2281
- paragraph
2282
- )
2283
- );
2284
- }
2285
- });
2286
- paragraphs.push(paragraph);
2287
- });
2231
+ const styleKey = JSON.stringify([this.style, this.fill, this.outline, this.effects, this.deformation]);
2232
+ const reuse = this._canReuseLayout(styleKey);
2233
+ const content = this.content;
2234
+ const contentKeys = Array.from({ length: content.length });
2235
+ const paragraphs = Array.from({ length: content.length });
2236
+ for (let i = 0; i < content.length; i++) {
2237
+ const key = JSON.stringify(content[i]);
2238
+ contentKeys[i] = key;
2239
+ const prev = this._prevParagraphs[i];
2240
+ if (reuse && prev && prev._layoutValid && this._prevContentKeys[i] === key) {
2241
+ prev._layoutDirty = false;
2242
+ paragraphs[i] = prev;
2243
+ } else {
2244
+ const para = this._buildParagraph(content[i], i);
2245
+ para._layoutDirty = true;
2246
+ paragraphs[i] = para;
2247
+ }
2248
+ }
2288
2249
  this.paragraphs = paragraphs;
2250
+ this._pendingContentKeys = contentKeys;
2251
+ this._pendingStyleKey = styleKey;
2289
2252
  return this;
2290
2253
  }
2291
- createDom() {
2292
- this._update();
2293
- if (!this.measurer.createDom) {
2294
- throw new Error("current measurer does not support createDom()");
2295
- }
2296
- return this.measurer.createDom(this.paragraphs, this.computedStyle);
2297
- }
2298
2254
  measure(dom = this.measureDom) {
2299
2255
  const old = {
2300
2256
  paragraphs: this.paragraphs,
@@ -2309,13 +2265,27 @@ class Text extends Reactivable {
2309
2265
  const result = this.measurer.measure(this.paragraphs, this.computedStyle, dom, this.fonts);
2310
2266
  this.paragraphs = result.paragraphs;
2311
2267
  this.lineBox = result.boundingBox;
2312
- const characters = this.characters;
2313
- for (let i = 0; i < characters.length; i++) {
2314
- characters[i].update(this.fonts);
2268
+ const paragraphs = this.paragraphs;
2269
+ for (let pi = 0; pi < paragraphs.length; pi++) {
2270
+ const paragraph = paragraphs[pi];
2271
+ if (!paragraph._layoutDirty) {
2272
+ continue;
2273
+ }
2274
+ for (const fragment of paragraph.fragments) {
2275
+ const chars = fragment.characters;
2276
+ for (let i = 0; i < chars.length; i++) {
2277
+ chars[i].update(this.fonts);
2278
+ }
2279
+ }
2315
2280
  }
2316
- const glyphBox = this.getGlyphBox();
2281
+ const glyphBox = this._computeGlyphBox();
2317
2282
  this.rawGlyphBox = glyphBox;
2318
2283
  this.glyphBox = glyphBox;
2284
+ this._prevParagraphs = this.paragraphs;
2285
+ this._prevContentKeys = this._pendingContentKeys;
2286
+ this._prevStyleKey = this._pendingStyleKey;
2287
+ this._prevFonts = this.fonts;
2288
+ this._prevFontsSet = true;
2319
2289
  const updatePlugins = this._pluginsByUpdateOrder;
2320
2290
  for (let i = 0; i < updatePlugins.length; i++) {
2321
2291
  updatePlugins[i].update?.(this);
@@ -2324,8 +2294,10 @@ class Text extends Reactivable {
2324
2294
  this.pathSets.length = 0;
2325
2295
  for (let i = 0; i < renderPlugins.length; i++) {
2326
2296
  const plugin = renderPlugins[i];
2327
- if (plugin.pathSet?.paths.length) {
2328
- this.pathSets.push(plugin.pathSet);
2297
+ const ps = plugin.pathSet;
2298
+ const count = ps?._lazyCount ?? ps?.paths.length;
2299
+ if (ps && count) {
2300
+ this.pathSets.push(ps);
2329
2301
  }
2330
2302
  }
2331
2303
  this._updateInlineBox()._updatePathBox()._updateBoundingBox();
@@ -2362,6 +2334,45 @@ class Text extends Reactivable {
2362
2334
  max.y - min.y
2363
2335
  );
2364
2336
  }
2337
+ // 单段字形包围盒(绝对坐标),逻辑与 getGlyphBox 等价但只覆盖一段;空段返回 undefined。
2338
+ _paragraphGlyphBox(paragraph) {
2339
+ const min = Vector2.MAX;
2340
+ const max = Vector2.MIN;
2341
+ const fragments = paragraph.fragments;
2342
+ for (let fi = 0; fi < fragments.length; fi++) {
2343
+ const chars = fragments[fi].characters;
2344
+ for (let i = 0; i < chars.length; i++) {
2345
+ const character = chars[i];
2346
+ if (character.getGlyphMinMax(min, max)) {
2347
+ continue;
2348
+ }
2349
+ const { left, top, width, height } = character.inlineBox;
2350
+ min.clampMin(new Vector2(left, top), new Vector2(left + width, top + height));
2351
+ max.clampMax(new Vector2(left, top), new Vector2(left + width, top + height));
2352
+ }
2353
+ }
2354
+ if (!Number.isFinite(min.x) || !Number.isFinite(min.y) || !Number.isFinite(max.x) || !Number.isFinite(max.y)) {
2355
+ return void 0;
2356
+ }
2357
+ return new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
2358
+ }
2359
+ // 增量版整体字形盒:仅重排段重算并缓存 _glyphBox,未变段复用已平移的缓存,再并集。
2360
+ _computeGlyphBox() {
2361
+ const paragraphs = this.paragraphs;
2362
+ const boxes = [];
2363
+ for (let pi = 0; pi < paragraphs.length; pi++) {
2364
+ const paragraph = paragraphs[pi];
2365
+ let pbox = paragraph._glyphBox;
2366
+ if (paragraph._layoutDirty || !pbox) {
2367
+ pbox = this._paragraphGlyphBox(paragraph);
2368
+ paragraph._glyphBox = pbox;
2369
+ }
2370
+ if (pbox) {
2371
+ boxes.push(pbox);
2372
+ }
2373
+ }
2374
+ return boxes.length ? BoundingBox.from(...boxes) : new BoundingBox(0, 0, 0, 0);
2375
+ }
2365
2376
  _updateInlineBox() {
2366
2377
  const boxes = [];
2367
2378
  const paragraphs = this._paragraphs;
@@ -2421,6 +2432,7 @@ class Text extends Reactivable {
2421
2432
  }
2422
2433
  const renderer = this._getRenderer(ctx);
2423
2434
  renderer.pixelRatio = pixelRatio;
2435
+ renderer.region = options.region;
2424
2436
  renderer.setup();
2425
2437
  const renderPlugins = this._pluginsByRenderOrder;
2426
2438
  for (let i = 0; i < renderPlugins.length; i++) {
@@ -2474,4 +2486,4 @@ __decorateClass([
2474
2486
  property({ internal: true })
2475
2487
  ], Text.prototype, "fonts");
2476
2488
 
2477
- export { Canvas2DRenderer as C, DomMeasurer as D, FontMeasurer as F, Paragraph as P, Text as T, Character as a, Fragment as b, backgroundPlugin as c, createSvgLoader as d, createSvgParser as e, getHighlightStyle as f, getEffectTransform2D as g, highlightPlugin as h, isEqualObject as i, isEqualValue as j, parseTransformOrigin as k, listStylePlugin as l, parseValueNumber as m, textDefaultStyle as n, outlinePlugin as o, parseColormap as p, renderPlugin as r, textDecorationPlugin as t };
2489
+ export { Canvas2DRenderer as C, Fragment as F, Measurer as M, Paragraph as P, Text as T, Character as a, backgroundPlugin as b, createSvgLoader as c, createSvgParser as d, getHighlightStyle as e, isEqualValue as f, getEffectTransform2D as g, highlightPlugin as h, isEqualObject as i, parseTransformOrigin as j, parseValueNumber as k, listStylePlugin as l, textDefaultStyle as m, outlinePlugin as o, parseColormap as p, renderPlugin as r, textDecorationPlugin as t };