wendang 0.1.0 → 0.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.
Files changed (98) hide show
  1. package/LICENSE +43 -2
  2. package/NOTICE.md +1 -1
  3. package/README.md +120 -52
  4. package/dist/cjs/document.js +1 -19
  5. package/dist/cjs/index.js +1 -25
  6. package/dist/cjs/mindmap.js +1 -22
  7. package/dist/cjs/schema.js +1 -11
  8. package/dist/cjs/shared/anchor.js +1 -201
  9. package/dist/cjs/shared/callout.js +1 -64
  10. package/dist/cjs/shared/code-block.js +1 -33
  11. package/dist/cjs/shared/code-languages.js +1 -51
  12. package/dist/cjs/shared/doc-compatibility.js +1 -23
  13. package/dist/cjs/shared/doc-text.js +1 -52
  14. package/dist/cjs/shared/document-colors.js +1 -61
  15. package/dist/cjs/shared/headings.js +1 -0
  16. package/dist/cjs/shared/inline-comments.js +1 -179
  17. package/dist/cjs/shared/mind-map-folding.js +1 -14
  18. package/dist/cjs/shared/mind-map-phase2.js +1 -133
  19. package/dist/cjs/shared/mind-map.js +1 -445
  20. package/dist/cjs/shared/mind-topic-text.js +1 -57
  21. package/dist/cjs/shared/node-catalog.js +1 -62
  22. package/dist/cjs/shared/presence.js +1 -141
  23. package/dist/cjs/shared/safe-href.js +1 -36
  24. package/dist/cjs/shared/table-cell-format.js +1 -18
  25. package/dist/cjs/shared/table-layout.js +1 -28
  26. package/dist/cjs/shared/types.js +1 -2
  27. package/dist/cjs/shared/video-embed.js +1 -102
  28. package/dist/cjs/shared/workbook.js +1 -152
  29. package/dist/cjs/spreadsheet.js +1 -17
  30. package/dist/cjs/types.js +1 -17
  31. package/dist/cjs/validation/callout.js +1 -17
  32. package/dist/cjs/validation/doc-node-whitelist.js +1 -71
  33. package/dist/cjs/validation/error.js +1 -17
  34. package/dist/cjs/validation/mind-map.js +1 -129
  35. package/dist/cjs/validation/schema-version.js +1 -5
  36. package/dist/cjs/validation/schema.js +1 -376
  37. package/dist/cjs/validation/video-embed.js +1 -17
  38. package/dist/cjs/validation/workbook.js +1 -17
  39. package/dist/esm/document.js +1 -3
  40. package/dist/esm/index.js +1 -3
  41. package/dist/esm/mindmap.js +1 -4
  42. package/dist/esm/schema.js +1 -3
  43. package/dist/esm/shared/anchor.js +1 -192
  44. package/dist/esm/shared/callout.js +1 -56
  45. package/dist/esm/shared/code-block.js +1 -25
  46. package/dist/esm/shared/code-languages.js +1 -46
  47. package/dist/esm/shared/doc-compatibility.js +1 -20
  48. package/dist/esm/shared/doc-text.js +1 -48
  49. package/dist/esm/shared/document-colors.js +1 -56
  50. package/dist/esm/shared/headings.js +1 -0
  51. package/dist/esm/shared/inline-comments.js +1 -173
  52. package/dist/esm/shared/mind-map-folding.js +1 -10
  53. package/dist/esm/shared/mind-map-phase2.js +1 -121
  54. package/dist/esm/shared/mind-map.js +1 -427
  55. package/dist/esm/shared/mind-topic-text.js +1 -49
  56. package/dist/esm/shared/node-catalog.js +1 -57
  57. package/dist/esm/shared/presence.js +1 -130
  58. package/dist/esm/shared/safe-href.js +1 -32
  59. package/dist/esm/shared/table-cell-format.js +1 -14
  60. package/dist/esm/shared/table-layout.js +1 -23
  61. package/dist/esm/shared/types.js +1 -1
  62. package/dist/esm/shared/video-embed.js +1 -96
  63. package/dist/esm/shared/workbook.js +1 -144
  64. package/dist/esm/spreadsheet.js +1 -1
  65. package/dist/esm/types.js +1 -1
  66. package/dist/esm/validation/callout.js +1 -1
  67. package/dist/esm/validation/doc-node-whitelist.js +1 -68
  68. package/dist/esm/validation/error.js +1 -13
  69. package/dist/esm/validation/mind-map.js +1 -125
  70. package/dist/esm/validation/schema-version.js +1 -2
  71. package/dist/esm/validation/schema.js +1 -372
  72. package/dist/esm/validation/video-embed.js +1 -1
  73. package/dist/esm/validation/workbook.js +1 -1
  74. package/dist/types/document.d.ts +1 -0
  75. package/dist/types/shared/anchor.d.ts +0 -55
  76. package/dist/types/shared/callout.d.ts +39 -2
  77. package/dist/types/shared/code-block.d.ts +0 -1
  78. package/dist/types/shared/code-languages.d.ts +0 -19
  79. package/dist/types/shared/doc-text.d.ts +0 -14
  80. package/dist/types/shared/document-colors.d.ts +0 -4
  81. package/dist/types/shared/headings.d.ts +7 -0
  82. package/dist/types/shared/inline-comments.d.ts +0 -42
  83. package/dist/types/shared/mind-map-folding.d.ts +0 -2
  84. package/dist/types/shared/mind-map.d.ts +0 -4
  85. package/dist/types/shared/mind-topic-text.d.ts +0 -2
  86. package/dist/types/shared/node-catalog.d.ts +0 -17
  87. package/dist/types/shared/presence.d.ts +0 -51
  88. package/dist/types/shared/safe-href.d.ts +0 -1
  89. package/dist/types/shared/table-cell-format.d.ts +0 -1
  90. package/dist/types/shared/table-layout.d.ts +0 -3
  91. package/dist/types/shared/types.d.ts +0 -4
  92. package/dist/types/shared/video-embed.d.ts +9 -2
  93. package/dist/types/shared/workbook.d.ts +0 -2
  94. package/dist/types/validation/doc-node-whitelist.d.ts +0 -19
  95. package/dist/types/validation/mind-map.d.ts +0 -2
  96. package/dist/types/validation/schema-version.d.ts +0 -1
  97. package/dist/types/validation/schema.d.ts +0 -34
  98. package/package.json +9 -9
@@ -1,376 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ContentSchemaService = exports.SCHEMA_VERSION = exports.ALLOWED_NODES = void 0;
4
- const error_js_1 = require("./error.js");
5
- const ErrorCode = { VALIDATION_FAILED: 'validation.failed' };
6
- const doc_node_whitelist_js_1 = require("./doc-node-whitelist.js");
7
- Object.defineProperty(exports, "ALLOWED_NODES", { enumerable: true, get: function () { return doc_node_whitelist_js_1.ALLOWED_NODES; } });
8
- const video_embed_js_1 = require("./video-embed.js");
9
- const schema_version_js_1 = require("./schema-version.js");
10
- const mind_map_js_1 = require("./mind-map.js");
11
- const callout_js_1 = require("./callout.js");
12
- const workbook_js_1 = require("./workbook.js");
13
- /**
14
- * 文档 schema 校验与派生提取(M0 定稿,见实施计划 4.1)。
15
- *
16
- * 职责:
17
- * 1) 校验客户端提交的 ProseMirror JSON 是否只含白名单节点/标记 → 否则**拒绝写入**,
18
- * 避免旧客户端遇到未知节点的文档时覆盖成空(对齐主方案第 3 节)。
19
- * 2) 提取派生数据:正文引用的页面 ID(用于反向链接)、附件 ID。
20
- *
21
- * schemaVersion 升级策略:新增节点或旧客户端无法保留的持久属性时提升 SCHEMA_VERSION,并在此登记。
22
- * 旧版本客户端读到更高版本文档时由前端拒编辑(本服务不影响读取)。
23
- */
24
- /**
25
- * 正文格式版本。
26
- *
27
- * 变更记录:
28
- * - 1:M0 初始版本(段落/标题/列表/引用/代码/表格/图片/页面引用/提及/公式)
29
- * - 2:新增 `fileAttachment`(视频或文件卡片)与 `callout`(高亮块)
30
- * - 3:新增块级格式属性 `textAlign`/`indent`(段落与标题)与行内样式标记
31
- * `textStyle`(文字颜色)/`highlight`(背景高亮);同时补上此前遗漏的
32
- * `paragraph` 属性条目与表格 `colwidth`(列宽)
33
- * - 4:新增分栏节点 `columns`/`column`(等宽,无属性)
34
- * - 5:新增平台视频节点 `videoEmbed`
35
- * - 6:新增结构化思维导图 `mindMap` / `mindMapTopic`
36
- * - 7:导图描述、备注、关系节点、受限主题样式与图片引用;标题支持行内标记
37
- * - 8:思维导图分支方向 side,防止旧编辑器重写时丢失左右布局
38
- * - 9:高亮块图标与独立文字、边框、填充颜色
39
- * - 10:独立工作簿根格式 workbook(不进入 ProseMirror)
40
- *
41
- * 提升它的意义:客户端读到**更高版本**的文档时可以选择拒编辑(而非静默丢内容),
42
- * 因此只要引入了新的节点类型就必须登记在此,不能只改白名单。
43
- */
44
- exports.SCHEMA_VERSION = schema_version_js_1.CONTENT_SCHEMA_VERSION;
45
- /** 属性白名单:只保留我们认识且安全的属性,其余剥离(防 XSS 与样式污染)。 */
46
- const ALLOWED_ATTRS = {
47
- // 标题:级别 + 块级格式(对齐/缩进)
48
- heading: new Set(['level', 'textAlign', 'indent']),
49
- // 段落此前**没有条目** → 任何段落属性都会被丢弃(对齐/缩进加上来时必须补)
50
- paragraph: new Set(['textAlign', 'indent']),
51
- // Tiptap / Markdown 已有的标准属性;遗漏会让从 3 开始的列表保存后变回 1。
52
- orderedList: new Set(['start']),
53
- // 行内样式标记的颜色值(漏登记 = 颜色丢失,只剩空标记)
54
- textStyle: new Set(['color']),
55
- highlight: new Set(['color']),
56
- // width:等比缩放的显式宽度(四角手柄);align:图片工具条的左/中/右对齐。
57
- // 两者都必须登记,否则作者调整尺寸/对齐后一经保存就被静默剥离。
58
- image: new Set(['attachmentId', 'alt', 'width', 'align']),
59
- fileAttachment: new Set(['attachmentId', 'filename', 'mimeType', 'size']),
60
- videoEmbed: new Set(['provider', 'videoId', 'page', 'startSeconds']),
61
- mindMap: new Set(['id', 'rootId', 'formatVersion', 'layout', 'theme', 'height', 'branchStyle']),
62
- mindMapTopic: new Set(['id', 'parentId', 'order', 'shape', 'color', 'background', 'fontSize', 'align', 'icon', 'done', 'attachmentId', 'side']),
63
- mindMapRelation: new Set(['id', 'source', 'target', 'kind', 'label']),
64
- callout: new Set(['tone', 'icon', 'textColor', 'borderColor', 'backgroundColor']),
65
- codeBlock: new Set(['language']),
66
- link: new Set(['href', 'target', 'rel']),
67
- pageLink: new Set(['pageId', 'label']),
68
- mention: new Set(['userId', 'label']),
69
- // colwidth:Tiptap 的列宽就是这个属性。此前未登记 → 拖动列宽保存后被
70
- // 静默剥离(且当时没有任何测试覆盖属性白名单,所以长期未被发现)。
71
- tableCell: new Set(['colspan', 'rowspan', 'align', 'colwidth', 'backgroundColor']),
72
- tableHeader: new Set(['colspan', 'rowspan', 'align', 'colwidth', 'backgroundColor']),
73
- taskItem: new Set(['checked']),
74
- mathInline: new Set(['latex']),
75
- mathBlock: new Set(['latex']),
76
- };
77
- /**
78
- * 需要强制为**正整数**的表格属性。
79
- *
80
- * ## 为什么只登记名字不够(实测的正文整篇消失)
81
- *
82
- * 白名单只保证"属性名合法",不保证**类型**合法。历史数据里出现过
83
- * `colspan: "2"` / `colspan: 0` / `colwidth: "[180,0]"`:
84
- *
85
- * - 字符串 colspan 会让 `prosemirror-tables` 的 `findWidth()` 执行
86
- * `rowWidth += colspan` 时发生**字符串拼接**(`0 + "1"` → `"01"`),
87
- * `width` 膨胀到 1e39,`computeMap()` 的 `map[i] = 0` 抛
88
- * `RangeError: Invalid array length`;
89
- * - `colspan: 0` 会让列宽数组长度与 colspan 不匹配;
90
- * - 字符串 colwidth 会在 `removeColSpan` 的 `slice().splice()` 处抛错。
91
- *
92
- * 三者都挂在 Tiptap 挂载期,表现为**正文区整体渲染失败**(用户看到"标题与目录
93
- * 还在、正文空白")。因此这里在入库边界上做强类型归一:把能安全转换的转成正确
94
- * 类型,转换不了的**丢弃该属性**(回退 schema 默认值),而不是原样存进去。
95
- *
96
- * 前端另有读取侧兜底(`web/src/shared/table-attrs.ts`),因为历史数据已经存在,
97
- * 且协同路径不经过本服务的校验。
98
- */
99
- const POSITIVE_INT_ATTRS = new Set(['colspan', 'rowspan']);
100
- const CELL_BACKGROUND_COLORS = new Set([
101
- '#f1f3f5', '#fff3bf', '#d3f9d8', '#d0ebff', '#e5dbff', '#ffe3e3',
102
- ]);
103
- /** 该节点是否是表格单元格(需要强类型化数值属性)。 */
104
- function isTableCellType(type) {
105
- return type === 'tableCell' || type === 'tableHeader';
106
- }
107
- /**
108
- * 把值归一为 `[1, +∞)` 的整数;不合法返回 undefined(表示丢弃该属性)。
109
- *
110
- * 不抛错而丢弃的理由:这是**净化**而非校验——粘贴来的内容常带脏属性,
111
- * 为此拒绝整个保存会让用户无法保存正文,代价远大于丢掉一个单元格属性。
112
- */
113
- function coercePositiveInt(raw) {
114
- const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw ?? ''), 10);
115
- if (!Number.isFinite(n))
116
- return undefined;
117
- const i = Math.floor(n);
118
- return i >= 1 ? i : undefined;
119
- }
120
- /**
121
- * 把 colwidth 归一为「正整数数组」或 null;不合法返回 undefined(丢弃)。
122
- *
123
- * 注意 0 是**合法分量**(表示"该列自适应"),长度必须与 colspan 对齐,
124
- * 因此这里保留 0、只把非数字分量归 0。
125
- */
126
- function coerceColwidth(raw) {
127
- if (raw === null)
128
- return null;
129
- let list;
130
- if (Array.isArray(raw)) {
131
- list = raw;
132
- }
133
- else if (typeof raw === 'string') {
134
- const text = raw.trim();
135
- if (!text)
136
- return null;
137
- if (text.startsWith('[')) {
138
- try {
139
- const parsed = JSON.parse(text);
140
- list = Array.isArray(parsed) ? parsed : [];
141
- }
142
- catch {
143
- return undefined;
144
- }
145
- }
146
- else {
147
- list = text.split(',');
148
- }
149
- }
150
- else {
151
- return undefined;
152
- }
153
- if (list.length === 0)
154
- return null;
155
- const nums = list.map((v) => {
156
- const n = typeof v === 'number' ? v : Number.parseInt(String(v ?? ''), 10);
157
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
158
- });
159
- return nums.some((n) => n > 0) ? nums : null;
160
- }
161
- const MAX_NODES = 20000; // 防御性上限,避免超大文档拖垮服务
162
- const MAX_DEPTH = 100;
163
- class ContentSchemaService {
164
- validate(raw, schemaVersion) {
165
- if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 1)
166
- throw error_js_1.ContentValidationError.validation('内容版本必须是正整数');
167
- if ((0, workbook_js_1.isWorkbookDocument)(raw)) {
168
- if (schemaVersion !== 10)
169
- throw error_js_1.ContentValidationError.validation('工作表需要当前编辑器版本');
170
- try {
171
- return { doc: (0, workbook_js_1.validateWorkbook)(raw), schemaVersion,
172
- linkTargetIds: [], attachmentIds: [], mentionUserIds: [] };
173
- }
174
- catch (error) {
175
- throw error_js_1.ContentValidationError.validation(error instanceof Error ? error.message : '工作表格式无效');
176
- }
177
- }
178
- if (typeof raw !== 'object' ||
179
- raw === null ||
180
- raw.type !== 'doc') {
181
- throw error_js_1.ContentValidationError.validation('正文格式非法:根节点必须是 doc');
182
- }
183
- if (schemaVersion > exports.SCHEMA_VERSION) {
184
- throw new error_js_1.ContentValidationError(ErrorCode.VALIDATION_FAILED, `正文版本 ${schemaVersion} 高于服务端支持的 ${exports.SCHEMA_VERSION},请升级后再编辑`, 422);
185
- }
186
- const linkTargetIds = new Set();
187
- const attachmentIds = new Set();
188
- const mentionUserIds = new Set();
189
- let nodeCount = 0;
190
- const walk = (node, depth, parentType = '') => {
191
- if (typeof node !== 'object' || node === null) {
192
- throw error_js_1.ContentValidationError.validation('正文包含非法节点');
193
- }
194
- if (depth > MAX_DEPTH) {
195
- throw error_js_1.ContentValidationError.validation('正文嵌套层级过深');
196
- }
197
- const n = node;
198
- const type = n.type;
199
- if ((type === 'mindMapTopic' || type === 'mindMapRelation') && parentType !== 'mindMap') {
200
- throw error_js_1.ContentValidationError.validation('导图主题只能放在思维导图内');
201
- }
202
- if (['mindMapDescription', 'mindMapNote'].includes(String(type)) && parentType !== 'mindMapTopic')
203
- throw error_js_1.ContentValidationError.validation('导图描述只能放在主题内');
204
- if (type === 'mindMap') {
205
- try {
206
- (0, mind_map_js_1.validateMindMap)(n);
207
- }
208
- catch (err) {
209
- throw error_js_1.ContentValidationError.validation(err instanceof Error ? err.message : '思维导图结构无效');
210
- }
211
- }
212
- if (type === 'callout' && !(0, callout_js_1.validCalloutAttrs)(n.attrs ?? {}))
213
- throw error_js_1.ContentValidationError.validation('高亮块属性无效');
214
- if (typeof type === 'string') {
215
- if (!doc_node_whitelist_js_1.ALLOWED_NODES.has(type)) {
216
- throw new error_js_1.ContentValidationError(ErrorCode.VALIDATION_FAILED, `正文包含服务端不支持的节点「${type}」,已拒绝保存以避免内容丢失`, 422, { nodeType: type, schemaVersion: exports.SCHEMA_VERSION });
217
- }
218
- nodeCount += 1;
219
- if (nodeCount > MAX_NODES) {
220
- throw error_js_1.ContentValidationError.validation('正文节点数超出上限');
221
- }
222
- }
223
- const out = {};
224
- if (typeof type === 'string')
225
- out.type = type;
226
- // 属性净化:非白名单属性直接丢弃,而非报错(粘贴内容常携带脏属性)
227
- if (n.attrs && typeof n.attrs === 'object') {
228
- const allowed = typeof type === 'string' ? ALLOWED_ATTRS[type] : undefined;
229
- const cleaned = {};
230
- for (const [k, v] of Object.entries(n.attrs)) {
231
- if (!allowed || !allowed.has(k))
232
- continue;
233
- if (isTableCellType(type) && k === 'backgroundColor') {
234
- if (typeof v === 'string' && CELL_BACKGROUND_COLORS.has(v))
235
- cleaned[k] = v;
236
- continue;
237
- }
238
- if (k === 'href' && typeof v === 'string') {
239
- if (!this.isSafeHref(v))
240
- continue;
241
- }
242
- // 表格单元格的数值属性必须强类型化:字符串形态会让
243
- // prosemirror-tables 崩溃、整篇正文渲染失败(见 POSITIVE_INT_ATTRS 注释)
244
- if (isTableCellType(type) && POSITIVE_INT_ATTRS.has(k)) {
245
- const coerced = coercePositiveInt(v);
246
- if (coerced === undefined)
247
- continue;
248
- cleaned[k] = coerced;
249
- continue;
250
- }
251
- if (isTableCellType(type) && k === 'colwidth') {
252
- const coerced = coerceColwidth(v);
253
- if (coerced === undefined)
254
- continue;
255
- cleaned[k] = coerced;
256
- continue;
257
- }
258
- cleaned[k] = v;
259
- }
260
- if (Object.keys(cleaned).length > 0)
261
- out.attrs = cleaned;
262
- }
263
- if (type === 'videoEmbed') {
264
- const video = (0, video_embed_js_1.normalizeVideoEmbed)(n.attrs);
265
- if (!video)
266
- throw error_js_1.ContentValidationError.validation('视频属性无效,请重新插入视频');
267
- out.attrs = { ...video };
268
- }
269
- if (typeof n.text === 'string')
270
- out.text = n.text;
271
- // 标记净化
272
- if (Array.isArray(n.marks)) {
273
- const marks = n.marks
274
- .filter((m) => typeof m.type === 'string' && doc_node_whitelist_js_1.ALLOWED_MARKS.has(m.type))
275
- .map((m) => {
276
- const mm = { type: m.type };
277
- if (m.attrs && typeof m.attrs === 'object') {
278
- const allowed = ALLOWED_ATTRS[m.type];
279
- const cleaned = {};
280
- for (const [k, v] of Object.entries(m.attrs)) {
281
- if (!allowed?.has(k))
282
- continue;
283
- // 链接标记的 href 同样必须过协议白名单。
284
- // 注意:这里曾漏检,导致正文内超链接可携带 javascript: 协议
285
- // 绕过服务端校验(由 doc-schema 单测发现)。
286
- if (k === 'href') {
287
- if (typeof v !== 'string' || !this.isSafeHref(v))
288
- continue;
289
- }
290
- cleaned[k] = v;
291
- }
292
- if (Object.keys(cleaned).length > 0)
293
- mm.attrs = cleaned;
294
- }
295
- return mm;
296
- });
297
- if (marks.length > 0)
298
- out.marks = marks;
299
- }
300
- // 派生提取
301
- if (type === 'pageLink' && n.attrs) {
302
- const pid = n.attrs.pageId;
303
- if (typeof pid === 'string' && pid.length > 0)
304
- linkTargetIds.add(pid);
305
- }
306
- if ((type === 'image' || type === 'mindMapTopic') && n.attrs) {
307
- const aid = n.attrs.attachmentId;
308
- if (typeof aid === 'string' && aid.length > 0) {
309
- attachmentIds.add(aid);
310
- }
311
- }
312
- // 视频/文件卡片同样引用附件:必须一并收集,否则这些附件会被
313
- // 附件引用计数与孤儿清理误判为「无人引用」而删掉。
314
- if (type === 'fileAttachment' && n.attrs) {
315
- const aid = n.attrs.attachmentId;
316
- if (typeof aid === 'string' && aid.length > 0) {
317
- attachmentIds.add(aid);
318
- }
319
- }
320
- // 公式源码长度上限:LaTeX 渲染复杂度可被超长输入放大
321
- if ((type === 'mathInline' || type === 'mathBlock') && n.attrs) {
322
- const latex = n.attrs.latex;
323
- if (typeof latex === 'string' && latex.length > 5000) {
324
- throw error_js_1.ContentValidationError.validation('公式内容过长(上限 5000 字符)');
325
- }
326
- }
327
- if (type === 'mention' && n.attrs) {
328
- const uid = n.attrs.userId;
329
- if (typeof uid === 'string' && uid.length > 0) {
330
- mentionUserIds.add(uid);
331
- }
332
- }
333
- if (Array.isArray(n.content)) {
334
- out.content = n.content
335
- .filter((c) => c !== null && c !== undefined)
336
- .map((c) => walk(c, depth + 1, typeof type === 'string' ? type : ''));
337
- }
338
- return out;
339
- };
340
- const doc = walk(raw, 0);
341
- return {
342
- doc,
343
- schemaVersion: exports.SCHEMA_VERSION,
344
- linkTargetIds: [...linkTargetIds],
345
- attachmentIds: [...attachmentIds],
346
- mentionUserIds: [...mentionUserIds],
347
- };
348
- }
349
- /** 仅允许 http/https/mailto 与站内相对链接,阻断 javascript: 等危险协议。 */
350
- isSafeHref(href) {
351
- const trimmed = href.trim().toLowerCase();
352
- if (trimmed.startsWith('/') || trimmed.startsWith('#'))
353
- return true;
354
- return (trimmed.startsWith('http://') ||
355
- trimmed.startsWith('https://') ||
356
- trimmed.startsWith('mailto:'));
357
- }
358
- /** 纯文本投影:供 M0 搜索与摘要使用(不含 HTML,避免 XSS 面)。 */
359
- extractPlainText(doc) {
360
- if ((0, workbook_js_1.isWorkbookDocument)(doc))
361
- return (0, workbook_js_1.workbookText)(doc);
362
- const buf = [];
363
- const walk = (node) => {
364
- if (typeof node !== 'object' || node === null)
365
- return;
366
- const n = node;
367
- if (typeof n.text === 'string')
368
- buf.push(n.text);
369
- if (Array.isArray(n.content))
370
- n.content.forEach(walk);
371
- };
372
- walk(doc);
373
- return buf.join(' ').replace(/\s+/g, ' ').trim();
374
- }
375
- }
376
- exports.ContentSchemaService = ContentSchemaService;
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.ContentSchemaService=exports.SCHEMA_VERSION=exports.ALLOWED_NODES=void 0;const t=require("./error.js"),e="validation.failed",n=require("./doc-node-whitelist.js");Object.defineProperty(exports,"ALLOWED_NODES",{enumerable:!0,get:function(){return n.ALLOWED_NODES}});const r=require("./video-embed.js"),o=require("./schema-version.js"),i=require("./mind-map.js"),a=require("./callout.js"),s=require("./workbook.js");exports.SCHEMA_VERSION=o.CONTENT_SCHEMA_VERSION;const l={heading:new Set(["level","textAlign","indent"]),paragraph:new Set(["textAlign","indent"]),orderedList:new Set(["start"]),textStyle:new Set(["color"]),highlight:new Set(["color"]),image:new Set(["attachmentId","alt","width","align"]),fileAttachment:new Set(["attachmentId","filename","mimeType","size"]),videoEmbed:new Set(["provider","videoId","page","startSeconds"]),mindMap:new Set(["id","rootId","formatVersion","layout","theme","height","branchStyle"]),mindMapTopic:new Set(["id","parentId","order","shape","color","background","fontSize","align","icon","done","attachmentId","side"]),mindMapRelation:new Set(["id","source","target","kind","label"]),callout:new Set(["tone","icon","textColor","borderColor","backgroundColor"]),codeBlock:new Set(["language"]),link:new Set(["href","target","rel"]),pageLink:new Set(["pageId","label"]),mention:new Set(["userId","label"]),tableCell:new Set(["colspan","rowspan","align","colwidth","backgroundColor"]),tableHeader:new Set(["colspan","rowspan","align","colwidth","backgroundColor"]),taskItem:new Set(["checked"]),mathInline:new Set(["latex"]),mathBlock:new Set(["latex"])},c=new Set(["colspan","rowspan"]),d=new Set(["#f1f3f5","#fff3bf","#d3f9d8","#d0ebff","#e5dbff","#ffe3e3"]);function isTableCellType(t){return"tableCell"===t||"tableHeader"===t}function coercePositiveInt(t){const e="number"==typeof t?t:Number.parseInt(String(t??""),10);if(!Number.isFinite(e))return;const n=Math.floor(e);return n>=1?n:void 0}function coerceColwidth(t){if(null===t)return null;let e;if(Array.isArray(t))e=t;else{if("string"!=typeof t)return;{const n=t.trim();if(!n)return null;if(n.startsWith("["))try{const t=JSON.parse(n);e=Array.isArray(t)?t:[]}catch{return}else e=n.split(",")}}if(0===e.length)return null;const n=e.map(t=>{const e="number"==typeof t?t:Number.parseInt(String(t??""),10);return Number.isFinite(e)&&e>0?Math.floor(e):0});return n.some(t=>t>0)?n:null}exports.ContentSchemaService=class ContentSchemaService{validate(o,f){if(!Number.isSafeInteger(f)||f<1)throw t.ContentValidationError.validation("内容版本必须是正整数");if((0,s.isWorkbookDocument)(o)){if(10!==f)throw t.ContentValidationError.validation("工作表需要当前编辑器版本");try{return{doc:(0,s.validateWorkbook)(o),schemaVersion:f,linkTargetIds:[],attachmentIds:[],mentionUserIds:[]}}catch(e){throw t.ContentValidationError.validation(e instanceof Error?e.message:"工作表格式无效")}}if("object"!=typeof o||null===o||"doc"!==o.type)throw t.ContentValidationError.validation("正文格式非法:根节点必须是 doc");if(f>exports.SCHEMA_VERSION)throw new t.ContentValidationError(e,`正文版本 ${f} 高于服务端支持的 ${exports.SCHEMA_VERSION},请升级后再编辑`,422);const p=new Set,h=new Set,m=new Set;let u=0;const walk=(o,s,f="")=>{if("object"!=typeof o||null===o)throw t.ContentValidationError.validation("正文包含非法节点");if(s>100)throw t.ContentValidationError.validation("正文嵌套层级过深");const S=o,g=S.type;if(("mindMapTopic"===g||"mindMapRelation"===g)&&"mindMap"!==f)throw t.ContentValidationError.validation("导图主题只能放在思维导图内");if(["mindMapDescription","mindMapNote"].includes(String(g))&&"mindMapTopic"!==f)throw t.ContentValidationError.validation("导图描述只能放在主题内");if("mindMap"===g)try{(0,i.validateMindMap)(S)}catch(e){throw t.ContentValidationError.validation(e instanceof Error?e.message:"思维导图结构无效")}if("callout"===g&&!(0,a.validCalloutAttrs)(S.attrs??{}))throw t.ContentValidationError.validation("高亮块属性无效");if("string"==typeof g){if(!n.ALLOWED_NODES.has(g))throw new t.ContentValidationError(e,`正文包含服务端不支持的节点「${g}」,已拒绝保存以避免内容丢失`,422,{nodeType:g,schemaVersion:exports.SCHEMA_VERSION});if(u+=1,u>2e4)throw t.ContentValidationError.validation("正文节点数超出上限")}const y={};if("string"==typeof g&&(y.type=g),S.attrs&&"object"==typeof S.attrs){const t="string"==typeof g?l[g]:void 0,e={};for(const[n,r]of Object.entries(S.attrs))if(t&&t.has(n))if(isTableCellType(g)&&"backgroundColor"===n)"string"==typeof r&&d.has(r)&&(e[n]=r);else if("href"!==n||"string"!=typeof r||this.isSafeHref(r)){if(isTableCellType(g)&&c.has(n)){const t=coercePositiveInt(r);if(void 0===t)continue;e[n]=t;continue}if(isTableCellType(g)&&"colwidth"===n){const t=coerceColwidth(r);if(void 0===t)continue;e[n]=t;continue}e[n]=r}Object.keys(e).length>0&&(y.attrs=e)}if("videoEmbed"===g){const e=(0,r.normalizeVideoEmbed)(S.attrs);if(!e)throw t.ContentValidationError.validation("视频属性无效,请重新插入视频");y.attrs={...e}}if("string"==typeof S.text&&(y.text=S.text),Array.isArray(S.marks)){const t=S.marks.filter(t=>"string"==typeof t.type&&n.ALLOWED_MARKS.has(t.type)).map(t=>{const e={type:t.type};if(t.attrs&&"object"==typeof t.attrs){const n=l[t.type],r={};for(const[e,o]of Object.entries(t.attrs))n?.has(e)&&("href"!==e||"string"==typeof o&&this.isSafeHref(o))&&(r[e]=o);Object.keys(r).length>0&&(e.attrs=r)}return e});t.length>0&&(y.marks=t)}if("pageLink"===g&&S.attrs){const t=S.attrs.pageId;"string"==typeof t&&t.length>0&&p.add(t)}if(("image"===g||"mindMapTopic"===g)&&S.attrs){const t=S.attrs.attachmentId;"string"==typeof t&&t.length>0&&h.add(t)}if("fileAttachment"===g&&S.attrs){const t=S.attrs.attachmentId;"string"==typeof t&&t.length>0&&h.add(t)}if(("mathInline"===g||"mathBlock"===g)&&S.attrs){const e=S.attrs.latex;if("string"==typeof e&&e.length>5e3)throw t.ContentValidationError.validation("公式内容过长(上限 5000 字符)")}if("mention"===g&&S.attrs){const t=S.attrs.userId;"string"==typeof t&&t.length>0&&m.add(t)}return Array.isArray(S.content)&&(y.content=S.content.filter(t=>null!=t).map(t=>walk(t,s+1,"string"==typeof g?g:""))),y};return{doc:walk(o,0),schemaVersion:exports.SCHEMA_VERSION,linkTargetIds:[...p],attachmentIds:[...h],mentionUserIds:[...m]}}isSafeHref(t){const e=t.trim().toLowerCase();return!(!e.startsWith("/")&&!e.startsWith("#"))||e.startsWith("http://")||e.startsWith("https://")||e.startsWith("mailto:")}extractPlainText(t){if((0,s.isWorkbookDocument)(t))return(0,s.workbookText)(t);const e=[],walk=t=>{if("object"!=typeof t||null===t)return;const n=t;"string"==typeof n.text&&e.push(n.text),Array.isArray(n.content)&&n.content.forEach(walk)};return walk(t),e.join(" ").replace(/\s+/g," ").trim()}};
@@ -1,17 +1 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
- };
16
- Object.defineProperty(exports, "__esModule", { value: true });
17
- __exportStar(require("../shared/video-embed.js"), exports);
1
+ "use strict";var e=this&&this.__createBinding||(Object.create?function(e,t,r,i){void 0===i&&(i=r);var o=Object.getOwnPropertyDescriptor(t,r);o&&!("get"in o?!t.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,i,o)}:function(e,t,r,i){void 0===i&&(i=r),e[i]=t[r]}),t=this&&this.__exportStar||function(t,r){for(var i in t)"default"===i||Object.prototype.hasOwnProperty.call(r,i)||e(r,t,i)};Object.defineProperty(exports,"__esModule",{value:!0}),t(require("../shared/video-embed.js"),exports);
@@ -1,17 +1 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
- };
16
- Object.defineProperty(exports, "__esModule", { value: true });
17
- __exportStar(require("../shared/workbook.js"), exports);
1
+ "use strict";var e=this&&this.__createBinding||(Object.create?function(e,t,r,o){void 0===o&&(o=r);var i=Object.getOwnPropertyDescriptor(t,r);i&&!("get"in i?!t.__esModule:i.writable||i.configurable)||(i={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,o,i)}:function(e,t,r,o){void 0===o&&(o=r),e[o]=t[r]}),t=this&&this.__exportStar||function(t,r){for(var o in t)"default"===o||Object.prototype.hasOwnProperty.call(r,o)||e(r,t,o)};Object.defineProperty(exports,"__esModule",{value:!0}),t(require("../shared/workbook.js"),exports);
@@ -1,3 +1 @@
1
- export * from './schema.js';
2
- export * from './shared/node-catalog.js';
3
- export * from './shared/doc-text.js';
1
+ export*from"./schema.js";export*from"./shared/node-catalog.js";export*from"./shared/doc-text.js";export*from"./shared/headings.js";
package/dist/esm/index.js CHANGED
@@ -1,3 +1 @@
1
- export * from './schema.js';
2
- export { emptyWorkbook, validateWorkbook } from './spreadsheet.js';
3
- export { createMindMap, readMindMap, mindMapToJson } from './mindmap.js';
1
+ export*from"./schema.js";export{emptyWorkbook,validateWorkbook}from"./spreadsheet.js";export{createMindMap,readMindMap,mindMapToJson}from"./mindmap.js";
@@ -1,4 +1 @@
1
- export * from './shared/mind-map.js';
2
- export * from './shared/mind-map-phase2.js';
3
- export * from './shared/mind-map-folding.js';
4
- export { validateMindMap } from './validation/mind-map.js';
1
+ export*from"./shared/mind-map.js";export*from"./shared/mind-map-phase2.js";export*from"./shared/mind-map-folding.js";export{validateMindMap}from"./validation/mind-map.js";
@@ -1,3 +1 @@
1
- export { ContentSchemaService, SCHEMA_VERSION } from './validation/schema.js';
2
- export { ContentValidationError } from './validation/error.js';
3
- export { ALLOWED_NODES, ALLOWED_MARKS } from './validation/doc-node-whitelist.js';
1
+ export{ContentSchemaService,SCHEMA_VERSION}from"./validation/schema.js";export{ContentValidationError}from"./validation/error.js";export{ALLOWED_NODES,ALLOWED_MARKS}from"./validation/doc-node-whitelist.js";
@@ -1,192 +1 @@
1
- /**
2
- * 行内评论的锚点定位(纯函数,可单测)。
3
- *
4
- * ## 为什么不能存位置
5
- *
6
- * 直觉做法是「记录选中文字的 start/end 偏移」。但文档会被编辑:
7
- * 在选区前面插入一个字,所有后续位置全部偏移——锚点立刻指向错误文字,
8
- * 而且**错得很安静**(评论跑到别的地方,没人会立刻发现)。
9
- *
10
- * ## 采用方案:文本引用(TextQuoteSelector 思路,W3C Web Annotation)
11
- *
12
- * 存三样东西:
13
- * - `quote`:选中的原文
14
- * - `prefix` / `suffix`:前后各一小段上下文(用于区分重复文本)
15
- *
16
- * 定位时在文档纯文本里搜索 quote;有多处命中时,用 prefix/suffix 的匹配长度打分,
17
- * 取最匹配的一处。这样:
18
- * - 在文档**别处**编辑(不碰该段文字)→ 锚点依然准确定位;
19
- * - 该段文字本身被改动 → 匹配不到,此时用 prefix+suffix 的接缝尽力定位,
20
- * 返回一个「降级」结果(exact=false),前端据此提示「原文已改动」;
21
- * - 完全找不到(整段被删)→ 返回 null,前端把评论归入「已失联」,
22
- * **而不是指向一处无关文字**。
23
- *
24
- * 宁可显示「已失联」,也不要指向错误位置——后者会让用户读到与上下文无关的评论,
25
- * 比看得见的缺失更有害。
26
- */
27
- /** 上下文最大长度(与提交时截断长度一致)。 */
28
- export const ANCHOR_CONTEXT_LENGTH = 64;
29
- export const ANCHOR_QUOTE_MAX = 500;
30
- /**
31
- * 把若干块文本拼成扁平文本,记录片段边界。
32
- * 块之间插入换行(可用块间匹配不应跨越块边界,换行同时避免把块首块尾误连成词)。
33
- */
34
- export function flattenDocText(blocks) {
35
- const segments = [];
36
- let text = '';
37
- blocks.forEach((blockText, blockIndex) => {
38
- if (blockIndex > 0)
39
- text += '\n';
40
- segments.push({ start: text.length, blockIndex, length: blockText.length });
41
- text += blockText;
42
- });
43
- return { text, segments };
44
- }
45
- /** 由扁平字符偏移定位到 (块索引, 块内偏移)。 */
46
- export function locateBlock(flat, charOffset) {
47
- for (const seg of flat.segments) {
48
- if (charOffset >= seg.start && charOffset <= seg.start + seg.length) {
49
- return { blockIndex: seg.blockIndex, offsetInBlock: charOffset - seg.start };
50
- }
51
- }
52
- // 落在块间换行或末尾时,归到最后一个片段
53
- const last = flat.segments[flat.segments.length - 1];
54
- if (!last)
55
- return null;
56
- if (charOffset > last.start + last.length) {
57
- return { blockIndex: last.blockIndex, offsetInBlock: last.length };
58
- }
59
- return null;
60
- }
61
- /**
62
- * 定位锚点。返回 null 表示无法可信定位(整段被删),调用方应标记为「已失联」。
63
- */
64
- export function resolveAnchor(text, anchor) {
65
- const quote = (anchor.quote ?? '').trim();
66
- if (quote.length === 0)
67
- return null;
68
- const prefix = anchor.prefix ?? '';
69
- const suffix = anchor.suffix ?? '';
70
- const expectedStart = anchor.start ?? -1;
71
- // 1) 收集 quote 的全部出现位置
72
- const candidates = [];
73
- let idx = text.indexOf(quote);
74
- while (idx !== -1) {
75
- candidates.push(idx);
76
- // 从下一个字符继续,避免重叠匹配导致死循环
77
- idx = text.indexOf(quote, idx + 1);
78
- }
79
- if (candidates.length > 0) {
80
- let best = null;
81
- for (const start of candidates) {
82
- const score = scoreCandidate(text, start, quote.length, prefix, suffix, expectedStart);
83
- if (!best || score > best.score)
84
- best = { start, score };
85
- }
86
- if (best) {
87
- return {
88
- from: best.start,
89
- to: best.start + quote.length,
90
- score: best.score,
91
- exact: true,
92
- };
93
- }
94
- }
95
- // 2) quote 已不在文档中:用上下文降级定位,返回零长度锚点。
96
- // 两种情况:
97
- // a) quote 整段被删除 → prefix 与 suffix 在文档中相邻,用接缝精确落点;
98
- // b) quote 被改写但上下文还在 → 用较长的一侧贴住,落在其边缘。
99
- // 两者都标记 exact=false,前端据此提示「原文已改动」。
100
- // 2a) prefix 与 suffix 相邻(说明中间的原引用被删掉了)
101
- if (prefix.length > 0 && suffix.length > 0) {
102
- const needle = prefix + suffix;
103
- const at = text.indexOf(needle);
104
- if (at !== -1) {
105
- const pos = at + prefix.length;
106
- return { from: pos, to: pos, score: 50, exact: false };
107
- }
108
- }
109
- // 2b) 用较长的一侧贴住边缘。
110
- // 注意两点(都是实测教训):
111
- // - 阈值取 4 而非 8:中文一个词常只有 2–4 字,阈值过高会让中文文档
112
- // 几乎无法降级定位;
113
- // - 必须遍历**全部**出现位置并取最接近原偏移的那个:
114
- // 短上下文在文档中可能重复,取首个出现会落到错误位置。
115
- const side = prefix.length >= suffix.length ? prefix : suffix;
116
- if (side.length >= 4) {
117
- const at = nearestOccurrence(text, side, expectedStart);
118
- if (at !== -1) {
119
- const pos = prefix.length >= suffix.length ? at + side.length : at;
120
- return { from: pos, to: pos, score: 10, exact: false };
121
- }
122
- }
123
- return null;
124
- }
125
- /**
126
- * 在文本中找出 needle 的全部出现位置,返回最接近 expected 的那个。
127
- * expected < 0(无提示)时退化为返回第一个出现位置。
128
- * 这样短上下文重复时不会盲目落到首次出现处。
129
- */
130
- function nearestOccurrence(text, needle, expected) {
131
- if (needle.length === 0)
132
- return -1;
133
- const positions = [];
134
- let idx = text.indexOf(needle);
135
- while (idx !== -1) {
136
- positions.push(idx);
137
- if (positions.length > 500)
138
- break; // 防御:极端重复文本
139
- idx = text.indexOf(needle, idx + 1);
140
- }
141
- if (positions.length === 0)
142
- return -1;
143
- if (expected < 0)
144
- return positions[0];
145
- let best = positions[0];
146
- for (const p of positions) {
147
- if (Math.abs(p - expected) < Math.abs(best - expected))
148
- best = p;
149
- }
150
- return best;
151
- }
152
- /**
153
- * 给候选位置打分:
154
- * - 引用本身匹配是基础分;
155
- * - 前后文匹配越长越可信(能区分「同一句话在文档里出现多次」);
156
- * - 与提交时偏移越近,略微加分(仅在文本重复且上下文缺失时起作用)。
157
- */
158
- function scoreCandidate(text, start, quoteLength, prefix, suffix, expectedStart) {
159
- let score = 1000;
160
- if (prefix.length > 0) {
161
- const actualPrefix = text.slice(Math.max(0, start - prefix.length), start);
162
- score += commonSuffixLength(actualPrefix, prefix) * 4;
163
- }
164
- if (suffix.length > 0) {
165
- const actualSuffix = text.slice(start + quoteLength, start + quoteLength + suffix.length);
166
- score += commonPrefixLength(actualSuffix, suffix) * 4;
167
- }
168
- if (expectedStart >= 0) {
169
- score -= Math.min(Math.abs(start - expectedStart), 200);
170
- }
171
- return score;
172
- }
173
- /** 两个字符串从末尾开始相同的字符数。 */
174
- export function commonSuffixLength(a, b) {
175
- let n = 0;
176
- while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) {
177
- n += 1;
178
- }
179
- return n;
180
- }
181
- /** 两个字符串从头开始相同的字符数。 */
182
- export function commonPrefixLength(a, b) {
183
- let n = 0;
184
- while (n < a.length && n < b.length && a[n] === b[n]) {
185
- n += 1;
186
- }
187
- return n;
188
- }
189
- /** 截断上下文到限定长度(提交时使用)。 */
190
- export function clipContext(text) {
191
- return text.slice(0, ANCHOR_CONTEXT_LENGTH);
192
- }
1
+ export const ANCHOR_CONTEXT_LENGTH=64;export const ANCHOR_QUOTE_MAX=500;export function flattenDocText(t){const e=[];let n="";return t.forEach((t,o)=>{o>0&&(n+="\n"),e.push({start:n.length,blockIndex:o,length:t.length}),n+=t}),{text:n,segments:e}}export function locateBlock(t,e){for(const n of t.segments)if(e>=n.start&&e<=n.start+n.length)return{blockIndex:n.blockIndex,offsetInBlock:e-n.start};const n=t.segments[t.segments.length-1];return n&&e>n.start+n.length?{blockIndex:n.blockIndex,offsetInBlock:n.length}:null}export function resolveAnchor(t,e){const n=(e.quote??"").trim();if(0===n.length)return null;const o=e.prefix??"",r=e.suffix??"",l=e.start??-1,c=[];let s=t.indexOf(n);for(;-1!==s;)c.push(s),s=t.indexOf(n,s+1);if(c.length>0){let e=null;for(const s of c){const c=scoreCandidate(t,s,n.length,o,r,l);(!e||c>e.score)&&(e={start:s,score:c})}if(e)return{from:e.start,to:e.start+n.length,score:e.score,exact:!0}}if(o.length>0&&r.length>0){const e=o+r,n=t.indexOf(e);if(-1!==n){const t=n+o.length;return{from:t,to:t,score:50,exact:!1}}}const f=o.length>=r.length?o:r;if(f.length>=4){const e=function nearestOccurrence(t,e,n){if(0===e.length)return-1;const o=[];let r=t.indexOf(e);for(;-1!==r&&(o.push(r),!(o.length>500));)r=t.indexOf(e,r+1);if(0===o.length)return-1;if(n<0)return o[0];let l=o[0];for(const t of o)Math.abs(t-n)<Math.abs(l-n)&&(l=t);return l}(t,f,l);if(-1!==e){const t=o.length>=r.length?e+f.length:e;return{from:t,to:t,score:10,exact:!1}}}return null}function scoreCandidate(t,e,n,o,r,l){let c=1e3;return o.length>0&&(c+=4*commonSuffixLength(t.slice(Math.max(0,e-o.length),e),o)),r.length>0&&(c+=4*commonPrefixLength(t.slice(e+n,e+n+r.length),r)),l>=0&&(c-=Math.min(Math.abs(e-l),200)),c}export function commonSuffixLength(t,e){let n=0;for(;n<t.length&&n<e.length&&t[t.length-1-n]===e[e.length-1-n];)n+=1;return n}export function commonPrefixLength(t,e){let n=0;for(;n<t.length&&n<e.length&&t[n]===e[n];)n+=1;return n}export function clipContext(t){return t.slice(0,64)}