modern-text 2.0.8 → 2.1.0

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