ice-render 2.1.0 → 2.2.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.
@@ -270,10 +270,20 @@ declare class ICE {
270
270
  /**
271
271
  * 加载自定义字体(平台适配):浏览器走 FontFace API,小程序走 wx.loadFont。
272
272
  * 加载后,在 ICEText 的 style.fontFamily 里引用该字体名即可。
273
+ *
274
+ * **字体就绪后会重新量测已挂载的文本**(`remeasureTexts()`):首帧通常还没拿到自定义字体,
275
+ * 用回退字体量出的宽高与换行会残留 —— 这是 i18n 场景(中文字体按需加载)最容易踩的坑。
273
276
  * @param family 字体族名(如 'MyFont')
274
277
  * @param source 字体源(浏览器为 url/二进制,小程序为本地文件路径)
275
278
  */
276
279
  loadFont(family: string, source: string): Promise<any>;
280
+ /**
281
+ * 把所有已挂载的文本组件标记为「需要重新量测」(字体加载完成、主题换字号等度量前提变化时用)。
282
+ *
283
+ * 只标脏、不立即量测:真正的重算发生在各自的 render 里(`paramsDirty → calcComponentParams → measureText`),
284
+ * 因此零副作用、任何时候都能调(组件还没挂到画布上也安全)。
285
+ */
286
+ remeasureTexts(): this;
277
287
  /**
278
288
  * 切换主题(string 按名切换 / object 浅合并 semantic),预设样式(preset)会自动跟随主题变量。
279
289
  * 热切换:已渲染的组件里用了 preset 的会重新 resolve(用户显式传的样式优先)。
@@ -270,10 +270,20 @@ declare class ICE {
270
270
  /**
271
271
  * 加载自定义字体(平台适配):浏览器走 FontFace API,小程序走 wx.loadFont。
272
272
  * 加载后,在 ICEText 的 style.fontFamily 里引用该字体名即可。
273
+ *
274
+ * **字体就绪后会重新量测已挂载的文本**(`remeasureTexts()`):首帧通常还没拿到自定义字体,
275
+ * 用回退字体量出的宽高与换行会残留 —— 这是 i18n 场景(中文字体按需加载)最容易踩的坑。
273
276
  * @param family 字体族名(如 'MyFont')
274
277
  * @param source 字体源(浏览器为 url/二进制,小程序为本地文件路径)
275
278
  */
276
279
  loadFont(family: string, source: string): Promise<any>;
280
+ /**
281
+ * 把所有已挂载的文本组件标记为「需要重新量测」(字体加载完成、主题换字号等度量前提变化时用)。
282
+ *
283
+ * 只标脏、不立即量测:真正的重算发生在各自的 render 里(`paramsDirty → calcComponentParams → measureText`),
284
+ * 因此零副作用、任何时候都能调(组件还没挂到画布上也安全)。
285
+ */
286
+ remeasureTexts(): this;
277
287
  /**
278
288
  * 切换主题(string 按名切换 / object 浅合并 semantic),预设样式(preset)会自动跟随主题变量。
279
289
  * 热切换:已渲染的组件里用了 preset 的会重新 resolve(用户显式传的样式优先)。
@@ -26,6 +26,28 @@ declare class ICEText extends ICEComponent {
26
26
  * @param props
27
27
  */
28
28
  constructor(props?: any);
29
+ /** 用户没显式给 width → 宽度按量测自适应 */
30
+ private __autoWidth;
31
+ /** 用户没显式给 height → 高度按量测自适应 */
32
+ private __autoHeight;
33
+ /**
34
+ * 逐行宽度缓存(第 8 项):量测时顺手记下(`__measureByCanvas` 本来就要逐行 measureText),
35
+ * 供居中 / 右对齐、文本装饰线、SVG 导出、光标与选区复用。
36
+ *
37
+ * 失效策略:`setState`(任何 state 变化都会置 `paramsDirty`)与 `remeasureText()` 清空;
38
+ * 另外缓存带 key(内容 + 字体 + 字间距),即使漏清也能自我纠正。
39
+ */
40
+ private __lineWidthCache;
41
+ /**
42
+ * 最近一次量测得到的**字形墨迹**上下沿(相对基线;来自 `actualBoundingBoxAscent/Descent`)
43
+ * 与**字体 em 盒**上下沿(`fontBoundingBox*`,部分运行时没有则按字号粗估)。
44
+ *
45
+ * 光标 / 选区 / 命中都要把「行带」换算成屏幕上的矩形,而 canvas 的 `textBaseline` 有
46
+ * top / middle / bottom / alphabetic 几种口径(`y` 分别指 em 顶 / em 中 / em 底 / 字母基线)——
47
+ * 只按 `y + 行号 × 行高` 推会在非 bottom 基线(如 `textBaseline: 'top'`)下整体错位半行到一行。
48
+ */
49
+ private __inkMetrics;
50
+ private __fontMetrics;
29
51
  protected static arrangeParam(props: any): any;
30
52
  /**
31
53
  * @overwrite
@@ -52,6 +74,9 @@ declare class ICEText extends ICEComponent {
52
74
  /**
53
75
  * 创建透明的 HTML input 覆盖在文本上,捕获输入(含中文 IME)。
54
76
  * input 文字设为透明(canvas 负责显示),只保留可见光标。
77
+ *
78
+ * `multiline`(或文本里已有 `\n`)时改用 `<textarea>`:回车插入换行而不是提交,
79
+ * 选区 / 换行都由浏览器接管(与单行输入同一套「文字透明、只保留光标」的做法)。
55
80
  */
56
81
  private __mountEditInput;
57
82
  /**
@@ -67,13 +92,82 @@ declare class ICEText extends ICEComponent {
67
92
  */
68
93
  setText(text: string): this;
69
94
  getText(): string;
95
+ /**
96
+ * 度量前提变了(自定义字体加载完成、主题换字号…)时调用:只标脏,
97
+ * 真正的重算交给下一次 render(`paramsDirty → calcComponentParams → measureText`)。
98
+ * 见 `ICE.remeasureTexts()` 与 `ICE.loadFont()`。
99
+ */
100
+ remeasureText(): this;
101
+ /**
102
+ * 外部**显式**设置 width/height(应用代码,或布局管理器按容器分配尺寸)时,关掉对应方向的
103
+ * 自动量测 —— 否则下一帧 `measureText → __applyMeasuredSize` 会把刚设的尺寸又改回去,
104
+ * 表现为「setState({width}) 不生效」。
105
+ *
106
+ * 与构造函数里「用户是否显式传 width/height」是同一套语义(见 __autoWidth/__autoHeight)。
107
+ */
108
+ protected __beforeStateMerge(newState: any): boolean;
70
109
  /**
71
110
  * @overwrite
72
111
  * 编辑态下接管键盘输入:字符插入 / Backspace / Delete / 方向键移动光标 / Enter 提交。
73
112
  */
74
113
  protected keyboardEvtHandler(evt: any): void;
114
+ /** caret 之前最近的一个 grapheme 边界(无 DOM 时按 grapheme 移动/退格;DOM 由浏览器负责)。 */
115
+ private __prevGraphemeBoundary;
116
+ /** caret 之后最近的一个 grapheme 边界。 */
117
+ private __nextGraphemeBoundary;
118
+ /** 编辑态是否按多行处理:显式 `multiline`,或文本里已经存在 `\n`。 */
119
+ private __isMultilineEditing;
75
120
  /**
76
- * 在编辑态下渲染光标(垂直竖线),位置由 caretIndex + ctx.measureText 计算。
121
+ * 每一行的**行盒**(组件本地坐标,原点在盒子中心):文字左边缘 `x`、行宽 `width`、行带 `top/height`。
122
+ *
123
+ * 三处共用它,避免各算一遍又漂移:光标(renderCaret)、选区(renderSelection)、
124
+ * 坐标 → 下标(getCaretIndexAt)/ 编辑态命中(containsLocalPoint)。
125
+ * 行宽走缓存(第 8 项),不再逐帧 measureText。
126
+ */
127
+ private __lineBoxes;
128
+ /**
129
+ * 选中区间(`selectionStart` → `selectionEnd`,按原始文本下标;-1 表示没有选区)。
130
+ *
131
+ * 选区是**编辑**语义:按 `\n` 拆行定位与 `caretIndex` 一致;开启 `wrap` 的非编辑态下
132
+ * 显示行与原始下标不再一一对应(此时不绘制选区,避免画到错误的位置)。
133
+ */
134
+ getSelection(): {
135
+ start: number;
136
+ end: number;
137
+ };
138
+ /** 设置选区(终点省略时 = 光标位置,即「没有选中内容」);DOM 编辑态会同步给 HTML 输入元素。 */
139
+ setSelection(start: number, end?: number): this;
140
+ /** 全选。 */
141
+ selectAll(): this;
142
+ /** 清空选区(保留光标)。 */
143
+ clearSelection(): this;
144
+ /**
145
+ * 本地坐标 → 光标下标(**按字形**)。
146
+ *
147
+ * 先按 y 选中行带(行外取最近的一行),再在该行的 grapheme 边界里取**离点击点最近的**一个
148
+ * —— 判定用相邻边界的**中点**(点过中点才开始算下一个字符),这是各主流文本编辑器的手感。
149
+ * 返回值是**原始文本**里的下标(含 `\n` 偏移),可直接喂给 `caretIndex`。
150
+ */
151
+ getCaretIndexAt(localX: number, localY: number): number;
152
+ /**
153
+ * 编辑态下按**文本行**命中(而不是整个盒子):点在行带之外(如 padding / 盒子右下空白)不算命中。
154
+ * 非编辑态仍是盒子语义 —— 拖动、框选、双击进入编辑这些既有交互都依赖它。
155
+ */
156
+ protected containsLocalPoint(localX: number, localY: number): boolean;
157
+ /**
158
+ * 绘制选区底色(无 DOM 运行时没有浏览器选区;DOM 编辑态下浏览器 input/textarea 自己会画)。
159
+ */
160
+ private renderSelection;
161
+ /**
162
+ * 在编辑态下渲染光标(垂直竖线)。
163
+ *
164
+ * 无 DOM 的运行时(小程序 / Node)没有浏览器 caret 可用,这里自己算位置,三条规则:
165
+ * - **多行**:`caretIndex` 先按 `\n` 折成「第几行 + 行内偏移」,光标画在对应行(旧实现把整段前缀
166
+ * 都量在一个位置上,多行文本里光标会跑到第一行);
167
+ * - **方向**:RTL 行的阅读起点在右,光标 x 要从右边缘往左量(`rightEdge - measure(前缀)`);
168
+ * - **对齐**:与 `getRenderLines()` 同一套(left / center / right,start/end 已按方向解析)。
169
+ *
170
+ * DOM 编辑态直接返回 —— 那时光标由 HTML input 的 `caretColor` 接管(浏览器处理 grapheme / IME 更准)。
77
171
  */
78
172
  private renderCaret;
79
173
  /**
@@ -118,13 +212,30 @@ declare class ICEText extends ICEComponent {
118
212
  private __computeWrappedLines;
119
213
  /** 统一的测宽函数:优先 ctx.measureText;无 ctx 时按 fontSize 粗估。 */
120
214
  private __measureFn;
215
+ /** 字号(px):所有相对单位(em / %)与默认行高都按它折算。 */
216
+ private __fontSizePx;
217
+ /** 把 `style.letterSpacing`(数字 / '2px' / '0.2em' / '20%')归一成 CSS 值写进 ctx。 */
218
+ private __applyLetterSpacingToCtx;
219
+ /**
220
+ * 每一行的行高(px):
221
+ * - 显式配置(数字 px / 字符串)→ 用它,单行也照用(盒子高度可预测);
222
+ * - 未配置 → `max(墨迹高, 字号 × 1.35)`(见 LINE_HEIGHT_RATIO 的注释)。
223
+ */
224
+ private __lineAdvance;
121
225
  /**
122
226
  * 按 grapheme cluster 切分。
123
227
  * 优先 Intl.Segmenter(Baseline 2024),能把 emoji / ZWJ 序列 / 组合字符合成一个单元;
124
228
  * 不可用时退化为码点切分(至少不会把代理对拆开)。
229
+ *
230
+ * 实现放在 `text-wrap.ts`(断行策略共用同一份切分 + 缓存)。
125
231
  */
126
232
  private __graphemes;
127
- /** 贪心换行:逐 grapheme 累加,超过可用宽度即断行。保留段落自身的 \n。 */
233
+ /**
234
+ * 换行:保留段落自身的 `\n`,段内按 `state.wordBreak` 策略断行。
235
+ *
236
+ * 断行规则是**排版**职责(见 `text-wrap.ts`):`'normal'` 下拉丁词不被硬拆、CJK 逐字断并做禁则;
237
+ * `'break-all'` 保留旧的逐 grapheme 贪心。i18n 词条本身由应用层提供,这里不做任何文本加工。
238
+ */
128
239
  private __wrapText;
129
240
  /**
130
241
  * 超过 maxLines 时截断末行并追加省略号;逐 grapheme 回退直到「内容+省略号」放得下。
@@ -135,6 +246,14 @@ declare class ICEText extends ICEComponent {
135
246
  private __applyMeasuredSize;
136
247
  /** DOM 降级测量:line-height 归一为 1,减少 leading 干扰(旧环境/小程序)。 */
137
248
  private __measureByDOM;
249
+ /** 行宽缓存的 key:行内容 + 字体 + 字间距(三者任一变了,行宽就不可信)。 */
250
+ private __lineWidthsKey;
251
+ /** 记下量测阶段算好的行宽(`__measureByCanvas` 专用)。 */
252
+ private __cacheLineWidths;
253
+ /**
254
+ * 取逐行宽度:命中缓存直接返回;未命中也**只量这一次**(结果写回缓存)。
255
+ */
256
+ private __lineWidths;
138
257
  /**
139
258
  * 文本的**渲染行布局**:每一行的内容与基线坐标(组件本地坐标)。
140
259
  *
@@ -154,5 +273,32 @@ declare class ICEText extends ICEComponent {
154
273
  * 同时把移动坐标轴原点的偏移量计算进去。
155
274
  */
156
275
  protected doRender(): void;
276
+ /**
277
+ * 自绘文本装饰线。
278
+ *
279
+ * - 横向范围取**每一行自己的宽度**(缓存里的行宽,必要时补量一次),居右/居中/RTL 下才对得上文字;
280
+ * - 基线偏移按字号比例:下划线 `+0.12em`、删除线 `-0.30em`、上划线 `-0.80em`(与主流排版接近);
281
+ * - 颜色:`style.textDecorationColor` 优先,留空跟随 `fillStyle`;粗细 `style.textDecorationWidth`
282
+ * 留 0 时按 `字号 / 14`(至少 1px)。
283
+ */
284
+ private __drawTextDecoration;
285
+ /**
286
+ * `direction: 'auto'` 需要按文本解析成具体的 ltr/rtl —— canvas 只认 `ltr | rtl | inherit`,
287
+ * 所以这里在通用 style 应用之后覆盖一次(`style.direction` 的原始值 `'auto'` 不会被 canvas 采纳)。
288
+ *
289
+ * 特性检测用 `'direction' in ctx`(不读值):**不支持 `direction` 的运行时**(部分小程序基础库、
290
+ * 极简测试桩)就跳过,退化为默认 LTR —— 这也是小程序「Canvas 2D 子集」回归能通过的原因。
291
+ */
292
+ protected applyStyleToCtx(): void;
293
+ /** 本帧是否把 `ctx.direction` 写过(用于渲染结束后的归位)。 */
294
+ private __directionApplied;
295
+ /**
296
+ * @overwrite
297
+ * 除基类的泄漏属性外,`direction` 也要归位 —— 否则 RTL 文本会把方向"漏"给后面绘制的组件,
298
+ * 破坏「组件渲染自包含」这条铁律(脏矩形局部重绘与离屏缓存都依赖它)。
299
+ */
300
+ __resetLeakyCtxState(): void;
301
+ /** 解析后的文字方向(`'auto'` → 按首个强方向字符判定)。 */
302
+ private __resolvedDirection;
157
303
  }
158
304
  export default ICEText;
@@ -26,6 +26,28 @@ declare class ICEText extends ICEComponent {
26
26
  * @param props
27
27
  */
28
28
  constructor(props?: any);
29
+ /** 用户没显式给 width → 宽度按量测自适应 */
30
+ private __autoWidth;
31
+ /** 用户没显式给 height → 高度按量测自适应 */
32
+ private __autoHeight;
33
+ /**
34
+ * 逐行宽度缓存(第 8 项):量测时顺手记下(`__measureByCanvas` 本来就要逐行 measureText),
35
+ * 供居中 / 右对齐、文本装饰线、SVG 导出、光标与选区复用。
36
+ *
37
+ * 失效策略:`setState`(任何 state 变化都会置 `paramsDirty`)与 `remeasureText()` 清空;
38
+ * 另外缓存带 key(内容 + 字体 + 字间距),即使漏清也能自我纠正。
39
+ */
40
+ private __lineWidthCache;
41
+ /**
42
+ * 最近一次量测得到的**字形墨迹**上下沿(相对基线;来自 `actualBoundingBoxAscent/Descent`)
43
+ * 与**字体 em 盒**上下沿(`fontBoundingBox*`,部分运行时没有则按字号粗估)。
44
+ *
45
+ * 光标 / 选区 / 命中都要把「行带」换算成屏幕上的矩形,而 canvas 的 `textBaseline` 有
46
+ * top / middle / bottom / alphabetic 几种口径(`y` 分别指 em 顶 / em 中 / em 底 / 字母基线)——
47
+ * 只按 `y + 行号 × 行高` 推会在非 bottom 基线(如 `textBaseline: 'top'`)下整体错位半行到一行。
48
+ */
49
+ private __inkMetrics;
50
+ private __fontMetrics;
29
51
  protected static arrangeParam(props: any): any;
30
52
  /**
31
53
  * @overwrite
@@ -52,6 +74,9 @@ declare class ICEText extends ICEComponent {
52
74
  /**
53
75
  * 创建透明的 HTML input 覆盖在文本上,捕获输入(含中文 IME)。
54
76
  * input 文字设为透明(canvas 负责显示),只保留可见光标。
77
+ *
78
+ * `multiline`(或文本里已有 `\n`)时改用 `<textarea>`:回车插入换行而不是提交,
79
+ * 选区 / 换行都由浏览器接管(与单行输入同一套「文字透明、只保留光标」的做法)。
55
80
  */
56
81
  private __mountEditInput;
57
82
  /**
@@ -67,13 +92,82 @@ declare class ICEText extends ICEComponent {
67
92
  */
68
93
  setText(text: string): this;
69
94
  getText(): string;
95
+ /**
96
+ * 度量前提变了(自定义字体加载完成、主题换字号…)时调用:只标脏,
97
+ * 真正的重算交给下一次 render(`paramsDirty → calcComponentParams → measureText`)。
98
+ * 见 `ICE.remeasureTexts()` 与 `ICE.loadFont()`。
99
+ */
100
+ remeasureText(): this;
101
+ /**
102
+ * 外部**显式**设置 width/height(应用代码,或布局管理器按容器分配尺寸)时,关掉对应方向的
103
+ * 自动量测 —— 否则下一帧 `measureText → __applyMeasuredSize` 会把刚设的尺寸又改回去,
104
+ * 表现为「setState({width}) 不生效」。
105
+ *
106
+ * 与构造函数里「用户是否显式传 width/height」是同一套语义(见 __autoWidth/__autoHeight)。
107
+ */
108
+ protected __beforeStateMerge(newState: any): boolean;
70
109
  /**
71
110
  * @overwrite
72
111
  * 编辑态下接管键盘输入:字符插入 / Backspace / Delete / 方向键移动光标 / Enter 提交。
73
112
  */
74
113
  protected keyboardEvtHandler(evt: any): void;
114
+ /** caret 之前最近的一个 grapheme 边界(无 DOM 时按 grapheme 移动/退格;DOM 由浏览器负责)。 */
115
+ private __prevGraphemeBoundary;
116
+ /** caret 之后最近的一个 grapheme 边界。 */
117
+ private __nextGraphemeBoundary;
118
+ /** 编辑态是否按多行处理:显式 `multiline`,或文本里已经存在 `\n`。 */
119
+ private __isMultilineEditing;
75
120
  /**
76
- * 在编辑态下渲染光标(垂直竖线),位置由 caretIndex + ctx.measureText 计算。
121
+ * 每一行的**行盒**(组件本地坐标,原点在盒子中心):文字左边缘 `x`、行宽 `width`、行带 `top/height`。
122
+ *
123
+ * 三处共用它,避免各算一遍又漂移:光标(renderCaret)、选区(renderSelection)、
124
+ * 坐标 → 下标(getCaretIndexAt)/ 编辑态命中(containsLocalPoint)。
125
+ * 行宽走缓存(第 8 项),不再逐帧 measureText。
126
+ */
127
+ private __lineBoxes;
128
+ /**
129
+ * 选中区间(`selectionStart` → `selectionEnd`,按原始文本下标;-1 表示没有选区)。
130
+ *
131
+ * 选区是**编辑**语义:按 `\n` 拆行定位与 `caretIndex` 一致;开启 `wrap` 的非编辑态下
132
+ * 显示行与原始下标不再一一对应(此时不绘制选区,避免画到错误的位置)。
133
+ */
134
+ getSelection(): {
135
+ start: number;
136
+ end: number;
137
+ };
138
+ /** 设置选区(终点省略时 = 光标位置,即「没有选中内容」);DOM 编辑态会同步给 HTML 输入元素。 */
139
+ setSelection(start: number, end?: number): this;
140
+ /** 全选。 */
141
+ selectAll(): this;
142
+ /** 清空选区(保留光标)。 */
143
+ clearSelection(): this;
144
+ /**
145
+ * 本地坐标 → 光标下标(**按字形**)。
146
+ *
147
+ * 先按 y 选中行带(行外取最近的一行),再在该行的 grapheme 边界里取**离点击点最近的**一个
148
+ * —— 判定用相邻边界的**中点**(点过中点才开始算下一个字符),这是各主流文本编辑器的手感。
149
+ * 返回值是**原始文本**里的下标(含 `\n` 偏移),可直接喂给 `caretIndex`。
150
+ */
151
+ getCaretIndexAt(localX: number, localY: number): number;
152
+ /**
153
+ * 编辑态下按**文本行**命中(而不是整个盒子):点在行带之外(如 padding / 盒子右下空白)不算命中。
154
+ * 非编辑态仍是盒子语义 —— 拖动、框选、双击进入编辑这些既有交互都依赖它。
155
+ */
156
+ protected containsLocalPoint(localX: number, localY: number): boolean;
157
+ /**
158
+ * 绘制选区底色(无 DOM 运行时没有浏览器选区;DOM 编辑态下浏览器 input/textarea 自己会画)。
159
+ */
160
+ private renderSelection;
161
+ /**
162
+ * 在编辑态下渲染光标(垂直竖线)。
163
+ *
164
+ * 无 DOM 的运行时(小程序 / Node)没有浏览器 caret 可用,这里自己算位置,三条规则:
165
+ * - **多行**:`caretIndex` 先按 `\n` 折成「第几行 + 行内偏移」,光标画在对应行(旧实现把整段前缀
166
+ * 都量在一个位置上,多行文本里光标会跑到第一行);
167
+ * - **方向**:RTL 行的阅读起点在右,光标 x 要从右边缘往左量(`rightEdge - measure(前缀)`);
168
+ * - **对齐**:与 `getRenderLines()` 同一套(left / center / right,start/end 已按方向解析)。
169
+ *
170
+ * DOM 编辑态直接返回 —— 那时光标由 HTML input 的 `caretColor` 接管(浏览器处理 grapheme / IME 更准)。
77
171
  */
78
172
  private renderCaret;
79
173
  /**
@@ -118,13 +212,30 @@ declare class ICEText extends ICEComponent {
118
212
  private __computeWrappedLines;
119
213
  /** 统一的测宽函数:优先 ctx.measureText;无 ctx 时按 fontSize 粗估。 */
120
214
  private __measureFn;
215
+ /** 字号(px):所有相对单位(em / %)与默认行高都按它折算。 */
216
+ private __fontSizePx;
217
+ /** 把 `style.letterSpacing`(数字 / '2px' / '0.2em' / '20%')归一成 CSS 值写进 ctx。 */
218
+ private __applyLetterSpacingToCtx;
219
+ /**
220
+ * 每一行的行高(px):
221
+ * - 显式配置(数字 px / 字符串)→ 用它,单行也照用(盒子高度可预测);
222
+ * - 未配置 → `max(墨迹高, 字号 × 1.35)`(见 LINE_HEIGHT_RATIO 的注释)。
223
+ */
224
+ private __lineAdvance;
121
225
  /**
122
226
  * 按 grapheme cluster 切分。
123
227
  * 优先 Intl.Segmenter(Baseline 2024),能把 emoji / ZWJ 序列 / 组合字符合成一个单元;
124
228
  * 不可用时退化为码点切分(至少不会把代理对拆开)。
229
+ *
230
+ * 实现放在 `text-wrap.ts`(断行策略共用同一份切分 + 缓存)。
125
231
  */
126
232
  private __graphemes;
127
- /** 贪心换行:逐 grapheme 累加,超过可用宽度即断行。保留段落自身的 \n。 */
233
+ /**
234
+ * 换行:保留段落自身的 `\n`,段内按 `state.wordBreak` 策略断行。
235
+ *
236
+ * 断行规则是**排版**职责(见 `text-wrap.ts`):`'normal'` 下拉丁词不被硬拆、CJK 逐字断并做禁则;
237
+ * `'break-all'` 保留旧的逐 grapheme 贪心。i18n 词条本身由应用层提供,这里不做任何文本加工。
238
+ */
128
239
  private __wrapText;
129
240
  /**
130
241
  * 超过 maxLines 时截断末行并追加省略号;逐 grapheme 回退直到「内容+省略号」放得下。
@@ -135,6 +246,14 @@ declare class ICEText extends ICEComponent {
135
246
  private __applyMeasuredSize;
136
247
  /** DOM 降级测量:line-height 归一为 1,减少 leading 干扰(旧环境/小程序)。 */
137
248
  private __measureByDOM;
249
+ /** 行宽缓存的 key:行内容 + 字体 + 字间距(三者任一变了,行宽就不可信)。 */
250
+ private __lineWidthsKey;
251
+ /** 记下量测阶段算好的行宽(`__measureByCanvas` 专用)。 */
252
+ private __cacheLineWidths;
253
+ /**
254
+ * 取逐行宽度:命中缓存直接返回;未命中也**只量这一次**(结果写回缓存)。
255
+ */
256
+ private __lineWidths;
138
257
  /**
139
258
  * 文本的**渲染行布局**:每一行的内容与基线坐标(组件本地坐标)。
140
259
  *
@@ -154,5 +273,32 @@ declare class ICEText extends ICEComponent {
154
273
  * 同时把移动坐标轴原点的偏移量计算进去。
155
274
  */
156
275
  protected doRender(): void;
276
+ /**
277
+ * 自绘文本装饰线。
278
+ *
279
+ * - 横向范围取**每一行自己的宽度**(缓存里的行宽,必要时补量一次),居右/居中/RTL 下才对得上文字;
280
+ * - 基线偏移按字号比例:下划线 `+0.12em`、删除线 `-0.30em`、上划线 `-0.80em`(与主流排版接近);
281
+ * - 颜色:`style.textDecorationColor` 优先,留空跟随 `fillStyle`;粗细 `style.textDecorationWidth`
282
+ * 留 0 时按 `字号 / 14`(至少 1px)。
283
+ */
284
+ private __drawTextDecoration;
285
+ /**
286
+ * `direction: 'auto'` 需要按文本解析成具体的 ltr/rtl —— canvas 只认 `ltr | rtl | inherit`,
287
+ * 所以这里在通用 style 应用之后覆盖一次(`style.direction` 的原始值 `'auto'` 不会被 canvas 采纳)。
288
+ *
289
+ * 特性检测用 `'direction' in ctx`(不读值):**不支持 `direction` 的运行时**(部分小程序基础库、
290
+ * 极简测试桩)就跳过,退化为默认 LTR —— 这也是小程序「Canvas 2D 子集」回归能通过的原因。
291
+ */
292
+ protected applyStyleToCtx(): void;
293
+ /** 本帧是否把 `ctx.direction` 写过(用于渲染结束后的归位)。 */
294
+ private __directionApplied;
295
+ /**
296
+ * @overwrite
297
+ * 除基类的泄漏属性外,`direction` 也要归位 —— 否则 RTL 文本会把方向"漏"给后面绘制的组件,
298
+ * 破坏「组件渲染自包含」这条铁律(脏矩形局部重绘与离屏缓存都依赖它)。
299
+ */
300
+ __resetLeakyCtxState(): void;
301
+ /** 解析后的文字方向(`'auto'` → 按首个强方向字符判定)。 */
302
+ private __resolvedDirection;
157
303
  }
158
304
  export default ICEText;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 文本方向(BiDi)解析。
3
+ *
4
+ * 背景:i18n 的「词条」属于应用层,但**文字方向**属于排版 —— canvas 不替我们做 BiDi 重排,
5
+ * 引擎必须把 `direction` 落到实处(`ctx.direction`),并让 `textAlign: 'start' | 'end'`
6
+ * 有语言相关的语义(start = 阅读起点,而不是物理左边)。
7
+ *
8
+ * 规则(刻意保守,只做「首个强方向字符」判定,不实现完整 UAX#9 ——
9
+ * 完整的 BiDi 段落重排应由平台/字体栈负责,这里只需要把基线方向传对):
10
+ * - `'ltr'` / `'rtl'`:照用;
11
+ * - `'auto'`:跳过空白、标点、数字、符号,取**第一个字母**;落在 RTL 区段则 `'rtl'`,否则 `'ltr'`;
12
+ * 整段没有字母(纯数字/标点)按 `'ltr'` 处理,与浏览器 `dir="auto"` 的默认行为一致。
13
+ */
14
+ export type ICETextDirection = 'ltr' | 'rtl' | 'auto';
15
+ export type ICETextAlign = 'left' | 'center' | 'right' | 'start' | 'end';
16
+ /**
17
+ * 把 `'auto'` 解析成具体的 `'ltr' | 'rtl'`。
18
+ *
19
+ * @param text 该行 / 该段文本(用首个强方向字符判定)
20
+ * @param direction 声明方向;缺省 `'auto'`
21
+ */
22
+ export declare function resolveTextDirection(text: string, direction?: ICETextDirection): 'ltr' | 'rtl';
23
+ /**
24
+ * 把 `textAlign` 解析成物理对齐(`left` / `center` / `right`)。
25
+ *
26
+ * `'start'` / `'end'` 依 `direction` 映射:rtl 下 start = 右、end = 左。
27
+ */
28
+ export declare function resolveTextAlign(textAlign: ICETextAlign | string | undefined, direction: 'ltr' | 'rtl'): 'left' | 'center' | 'right';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 文本方向(BiDi)解析。
3
+ *
4
+ * 背景:i18n 的「词条」属于应用层,但**文字方向**属于排版 —— canvas 不替我们做 BiDi 重排,
5
+ * 引擎必须把 `direction` 落到实处(`ctx.direction`),并让 `textAlign: 'start' | 'end'`
6
+ * 有语言相关的语义(start = 阅读起点,而不是物理左边)。
7
+ *
8
+ * 规则(刻意保守,只做「首个强方向字符」判定,不实现完整 UAX#9 ——
9
+ * 完整的 BiDi 段落重排应由平台/字体栈负责,这里只需要把基线方向传对):
10
+ * - `'ltr'` / `'rtl'`:照用;
11
+ * - `'auto'`:跳过空白、标点、数字、符号,取**第一个字母**;落在 RTL 区段则 `'rtl'`,否则 `'ltr'`;
12
+ * 整段没有字母(纯数字/标点)按 `'ltr'` 处理,与浏览器 `dir="auto"` 的默认行为一致。
13
+ */
14
+ export type ICETextDirection = 'ltr' | 'rtl' | 'auto';
15
+ export type ICETextAlign = 'left' | 'center' | 'right' | 'start' | 'end';
16
+ /**
17
+ * 把 `'auto'` 解析成具体的 `'ltr' | 'rtl'`。
18
+ *
19
+ * @param text 该行 / 该段文本(用首个强方向字符判定)
20
+ * @param direction 声明方向;缺省 `'auto'`
21
+ */
22
+ export declare function resolveTextDirection(text: string, direction?: ICETextDirection): 'ltr' | 'rtl';
23
+ /**
24
+ * 把 `textAlign` 解析成物理对齐(`left` / `center` / `right`)。
25
+ *
26
+ * `'start'` / `'end'` 依 `direction` 映射:rtl 下 start = 右、end = 左。
27
+ */
28
+ export declare function resolveTextAlign(textAlign: ICETextAlign | string | undefined, direction: 'ltr' | 'rtl'): 'left' | 'center' | 'right';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 文本排版属性的解析(`lineHeight` / `letterSpacing`)。
3
+ *
4
+ * 为什么单独抽一个模块:这两项**必须在四处保持同一口径** —— 量测(盒子宽高)、换行(断行宽度)、
5
+ * 渲染(ctx + 自绘装饰线)、SVG 导出。任何一处各自 `parseFloat` 都会漂移,表现为
6
+ * 「屏幕上有字间距、盒子宽度却不含间距」(`letterSpacing` 原先只是透传给 ctx,就是这个毛病)。
7
+ *
8
+ * 语义约定(与 CSS 对齐,避免「数字到底是 px 还是倍数」的歧义):
9
+ * - `letterSpacing`:数字 = **px**;字符串支持 `'2px'` / `'0.2em'`(相对字号)/ `'20%'`(相对字号)。
10
+ * - `lineHeight`:数字 = **px**;字符串支持 `'2'`(无单位 = **倍数**)/ `'40px'` / `'1.5em'` / `'150%'`;
11
+ * `0` / 空 / `'normal'` = 未配置,走引擎默认(`max(墨迹高, 字号 × 1.35)`)。
12
+ */
13
+ /** `letterSpacing` → px(相对字号单位按 fontSize 折算)。无法解析时按 0(与 CSS 的容错一致)。 */
14
+ export declare function resolveLetterSpacingPx(value: any, fontSize: number): number;
15
+ /** `letterSpacing` → 可以直接写进 `ctx.letterSpacing` / SVG `letter-spacing` 的 CSS 值。 */
16
+ export declare function resolveLetterSpacingCss(value: any, fontSize: number): string;
17
+ /**
18
+ * `lineHeight` → **每一行的行高**(px)。
19
+ *
20
+ * @returns `null` 表示「调用方没有配置」——由调用方决定默认行高(见 `ICEText.LINE_HEIGHT_RATIO`)。
21
+ */
22
+ export declare function resolveLineHeightPx(value: any, fontSize: number): number | null;
23
+ /** 文本装饰线的取值(空格分隔可组合,如 `'underline line-through'`)。 */
24
+ export declare const TEXT_DECORATIONS: readonly ["underline", "line-through", "overline"];
25
+ export declare function resolveTextDecorations(value: any): string[];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 文本排版属性的解析(`lineHeight` / `letterSpacing`)。
3
+ *
4
+ * 为什么单独抽一个模块:这两项**必须在四处保持同一口径** —— 量测(盒子宽高)、换行(断行宽度)、
5
+ * 渲染(ctx + 自绘装饰线)、SVG 导出。任何一处各自 `parseFloat` 都会漂移,表现为
6
+ * 「屏幕上有字间距、盒子宽度却不含间距」(`letterSpacing` 原先只是透传给 ctx,就是这个毛病)。
7
+ *
8
+ * 语义约定(与 CSS 对齐,避免「数字到底是 px 还是倍数」的歧义):
9
+ * - `letterSpacing`:数字 = **px**;字符串支持 `'2px'` / `'0.2em'`(相对字号)/ `'20%'`(相对字号)。
10
+ * - `lineHeight`:数字 = **px**;字符串支持 `'2'`(无单位 = **倍数**)/ `'40px'` / `'1.5em'` / `'150%'`;
11
+ * `0` / 空 / `'normal'` = 未配置,走引擎默认(`max(墨迹高, 字号 × 1.35)`)。
12
+ */
13
+ /** `letterSpacing` → px(相对字号单位按 fontSize 折算)。无法解析时按 0(与 CSS 的容错一致)。 */
14
+ export declare function resolveLetterSpacingPx(value: any, fontSize: number): number;
15
+ /** `letterSpacing` → 可以直接写进 `ctx.letterSpacing` / SVG `letter-spacing` 的 CSS 值。 */
16
+ export declare function resolveLetterSpacingCss(value: any, fontSize: number): string;
17
+ /**
18
+ * `lineHeight` → **每一行的行高**(px)。
19
+ *
20
+ * @returns `null` 表示「调用方没有配置」——由调用方决定默认行高(见 `ICEText.LINE_HEIGHT_RATIO`)。
21
+ */
22
+ export declare function resolveLineHeightPx(value: any, fontSize: number): number | null;
23
+ /** 文本装饰线的取值(空格分隔可组合,如 `'underline line-through'`)。 */
24
+ export declare const TEXT_DECORATIONS: readonly ["underline", "line-through", "overline"];
25
+ export declare function resolveTextDecorations(value: any): string[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 自动换行的断行策略(纯函数,便于单测)。
3
+ *
4
+ * 背景:i18n 的「词条」在应用层,但**断行规则**属于排版:
5
+ * - 旧实现是「逐 grapheme 贪心」——拉丁词会被硬拆("hello" 会被拆成 "hell" / "o");
6
+ * - CJK 可以在任意字之间断,但有**禁则**:行首不能是闭标点(`、。,)」`…),行尾不能是开标点(`(「`…)。
7
+ *
8
+ * 两种策略(`ICEText` 的 `wordBreak`):
9
+ * - `'normal'`(默认):优先在**词边界**断行(空白 / 连字符 / CJK 字间);单个词整行放不下时才硬拆
10
+ * (等价于 CSS `overflow-wrap: break-word`);并做 CJK 禁则调整。
11
+ * - `'break-all'`:保留旧的「逐 grapheme 贪心」,留给需要等宽硬断的场景(代码、艺术字)。
12
+ *
13
+ * 这里只处理**段内**换行;`\n` 由调用方先切段。不做任何文本规范化(i18n 词条必须原样保留)。
14
+ */
15
+ export type ICEWordBreak = 'normal' | 'break-all';
16
+ /** 按 grapheme cluster 切分(与 ICEText 同一口径)。带小缓存,避免重复切分同一段文本。 */
17
+ export declare function splitGraphemes(s: string, intl?: any): string[];
18
+ /** 测试用:清空 grapheme 缓存。 */
19
+ export declare function clearGraphemeCache(): void;
20
+ /**
21
+ * 对**单个段落**(不含 `\n`)做换行,返回行数组(至少一行)。
22
+ *
23
+ * @param maxWidth 可用宽度(必须 > 0,否则原样返回一段)
24
+ * @param measure 测宽函数(调用方保证已设置字体)
25
+ */
26
+ export declare function wrapParagraph(paragraph: string, maxWidth: number, measure: (s: string) => number, wordBreak?: ICEWordBreak, intl?: any): string[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 自动换行的断行策略(纯函数,便于单测)。
3
+ *
4
+ * 背景:i18n 的「词条」在应用层,但**断行规则**属于排版:
5
+ * - 旧实现是「逐 grapheme 贪心」——拉丁词会被硬拆("hello" 会被拆成 "hell" / "o");
6
+ * - CJK 可以在任意字之间断,但有**禁则**:行首不能是闭标点(`、。,)」`…),行尾不能是开标点(`(「`…)。
7
+ *
8
+ * 两种策略(`ICEText` 的 `wordBreak`):
9
+ * - `'normal'`(默认):优先在**词边界**断行(空白 / 连字符 / CJK 字间);单个词整行放不下时才硬拆
10
+ * (等价于 CSS `overflow-wrap: break-word`);并做 CJK 禁则调整。
11
+ * - `'break-all'`:保留旧的「逐 grapheme 贪心」,留给需要等宽硬断的场景(代码、艺术字)。
12
+ *
13
+ * 这里只处理**段内**换行;`\n` 由调用方先切段。不做任何文本规范化(i18n 词条必须原样保留)。
14
+ */
15
+ export type ICEWordBreak = 'normal' | 'break-all';
16
+ /** 按 grapheme cluster 切分(与 ICEText 同一口径)。带小缓存,避免重复切分同一段文本。 */
17
+ export declare function splitGraphemes(s: string, intl?: any): string[];
18
+ /** 测试用:清空 grapheme 缓存。 */
19
+ export declare function clearGraphemeCache(): void;
20
+ /**
21
+ * 对**单个段落**(不含 `\n`)做换行,返回行数组(至少一行)。
22
+ *
23
+ * @param maxWidth 可用宽度(必须 > 0,否则原样返回一段)
24
+ * @param measure 测宽函数(调用方保证已设置字体)
25
+ */
26
+ export declare function wrapParagraph(paragraph: string, maxWidth: number, measure: (s: string) => number, wordBreak?: ICEWordBreak, intl?: any): string[];
@@ -57,5 +57,7 @@ export { default as Serializer, SERIALIZATION_VERSION } from './persistence/Seri
57
57
  export { default as Deserializer, SERIALIZATION_MIGRATIONS } from './persistence/Deserializer.mjs';
58
58
  export { toIsoTime } from './persistence/document-time.mjs';
59
59
  export { TYPE_ID_PATTERN, isTypeId, assertTypeId, parseTypeId, makeTypeId } from './util/type-id.mjs';
60
+ export { ICE_ERROR_CODES, iceError, getICEErrorCode, isICEError } from './util/errors.mjs';
61
+ export type { ICEError, ICEErrorCode, ICEErrorDetails } from './util/errors.mjs';
60
62
  export { buildAccessibilityTree } from './a11y/accessibility.mjs';
61
63
  export type { ICEAccessibleNode, ICEAccessibleRole, ICEAccessibilityOptions } from './a11y/accessibility.mjs';
@@ -57,5 +57,7 @@ export { default as Serializer, SERIALIZATION_VERSION } from './persistence/Seri
57
57
  export { default as Deserializer, SERIALIZATION_MIGRATIONS } from './persistence/Deserializer';
58
58
  export { toIsoTime } from './persistence/document-time';
59
59
  export { TYPE_ID_PATTERN, isTypeId, assertTypeId, parseTypeId, makeTypeId } from './util/type-id';
60
+ export { ICE_ERROR_CODES, iceError, getICEErrorCode, isICEError } from './util/errors';
61
+ export type { ICEError, ICEErrorCode, ICEErrorDetails } from './util/errors';
60
62
  export { buildAccessibilityTree } from './a11y/accessibility';
61
63
  export type { ICEAccessibleNode, ICEAccessibleRole, ICEAccessibilityOptions } from './a11y/accessibility';