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