wendang 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +3 -0
  2. package/NOTICE.md +7 -0
  3. package/README.md +101 -2
  4. package/dist/cjs/document.js +19 -0
  5. package/dist/cjs/index.js +25 -0
  6. package/dist/cjs/mindmap.js +22 -0
  7. package/dist/cjs/package.json +1 -0
  8. package/dist/cjs/schema.js +11 -0
  9. package/dist/cjs/shared/anchor.js +201 -0
  10. package/dist/cjs/shared/callout.js +64 -0
  11. package/dist/cjs/shared/code-block.js +33 -0
  12. package/dist/cjs/shared/code-languages.js +51 -0
  13. package/dist/cjs/shared/doc-compatibility.js +23 -0
  14. package/dist/cjs/shared/doc-text.js +52 -0
  15. package/dist/cjs/shared/document-colors.js +61 -0
  16. package/dist/cjs/shared/inline-comments.js +179 -0
  17. package/dist/cjs/shared/mind-map-folding.js +14 -0
  18. package/dist/cjs/shared/mind-map-phase2.js +133 -0
  19. package/dist/cjs/shared/mind-map.js +445 -0
  20. package/dist/cjs/shared/mind-topic-text.js +57 -0
  21. package/dist/cjs/shared/node-catalog.js +62 -0
  22. package/dist/cjs/shared/presence.js +141 -0
  23. package/dist/cjs/shared/safe-href.js +36 -0
  24. package/dist/cjs/shared/table-cell-format.js +18 -0
  25. package/dist/cjs/shared/table-layout.js +28 -0
  26. package/dist/cjs/shared/types.js +2 -0
  27. package/dist/cjs/shared/video-embed.js +102 -0
  28. package/dist/cjs/shared/workbook.js +152 -0
  29. package/dist/cjs/spreadsheet.js +17 -0
  30. package/dist/cjs/types.js +17 -0
  31. package/dist/cjs/validation/callout.js +17 -0
  32. package/dist/cjs/validation/doc-node-whitelist.js +71 -0
  33. package/dist/cjs/validation/error.js +17 -0
  34. package/dist/cjs/validation/mind-map.js +129 -0
  35. package/dist/cjs/validation/schema-version.js +5 -0
  36. package/dist/cjs/validation/schema.js +376 -0
  37. package/dist/cjs/validation/video-embed.js +17 -0
  38. package/dist/cjs/validation/workbook.js +17 -0
  39. package/dist/esm/document.js +3 -0
  40. package/dist/esm/index.js +3 -0
  41. package/dist/esm/mindmap.js +4 -0
  42. package/dist/esm/schema.js +3 -0
  43. package/dist/esm/shared/anchor.js +192 -0
  44. package/dist/esm/shared/callout.js +56 -0
  45. package/dist/esm/shared/code-block.js +25 -0
  46. package/dist/esm/shared/code-languages.js +46 -0
  47. package/dist/esm/shared/doc-compatibility.js +20 -0
  48. package/dist/esm/shared/doc-text.js +48 -0
  49. package/dist/esm/shared/document-colors.js +56 -0
  50. package/dist/esm/shared/inline-comments.js +173 -0
  51. package/dist/esm/shared/mind-map-folding.js +10 -0
  52. package/dist/esm/shared/mind-map-phase2.js +121 -0
  53. package/dist/esm/shared/mind-map.js +427 -0
  54. package/dist/esm/shared/mind-topic-text.js +49 -0
  55. package/dist/esm/shared/node-catalog.js +57 -0
  56. package/dist/esm/shared/presence.js +130 -0
  57. package/dist/esm/shared/safe-href.js +32 -0
  58. package/dist/esm/shared/table-cell-format.js +14 -0
  59. package/dist/esm/shared/table-layout.js +23 -0
  60. package/dist/esm/shared/types.js +1 -0
  61. package/dist/esm/shared/video-embed.js +96 -0
  62. package/dist/esm/shared/workbook.js +144 -0
  63. package/dist/esm/spreadsheet.js +1 -0
  64. package/dist/esm/types.js +1 -0
  65. package/dist/esm/validation/callout.js +1 -0
  66. package/dist/esm/validation/doc-node-whitelist.js +68 -0
  67. package/dist/esm/validation/error.js +13 -0
  68. package/dist/esm/validation/mind-map.js +125 -0
  69. package/dist/esm/validation/schema-version.js +2 -0
  70. package/dist/esm/validation/schema.js +372 -0
  71. package/dist/esm/validation/video-embed.js +1 -0
  72. package/dist/esm/validation/workbook.js +1 -0
  73. package/dist/types/document.d.ts +4 -0
  74. package/dist/types/index.d.ts +4 -0
  75. package/dist/types/mindmap.d.ts +4 -0
  76. package/dist/types/schema.d.ts +4 -0
  77. package/dist/types/shared/anchor.d.ts +87 -0
  78. package/dist/types/shared/callout.d.ts +27 -0
  79. package/dist/types/shared/code-block.d.ts +26 -0
  80. package/dist/types/shared/code-languages.d.ts +26 -0
  81. package/dist/types/shared/doc-compatibility.d.ts +1 -0
  82. package/dist/types/shared/doc-text.d.ts +21 -0
  83. package/dist/types/shared/document-colors.d.ts +106 -0
  84. package/dist/types/shared/inline-comments.d.ts +71 -0
  85. package/dist/types/shared/mind-map-folding.d.ts +5 -0
  86. package/dist/types/shared/mind-map-phase2.d.ts +19 -0
  87. package/dist/types/shared/mind-map.d.ts +134 -0
  88. package/dist/types/shared/mind-topic-text.d.ts +18 -0
  89. package/dist/types/shared/node-catalog.d.ts +30 -0
  90. package/dist/types/shared/presence.d.ts +69 -0
  91. package/dist/types/shared/safe-href.d.ts +3 -0
  92. package/dist/types/shared/table-cell-format.d.ts +24 -0
  93. package/dist/types/shared/table-layout.d.ts +7 -0
  94. package/dist/types/shared/types.d.ts +44 -0
  95. package/dist/types/shared/video-embed.d.ts +11 -0
  96. package/dist/types/shared/workbook.d.ts +56 -0
  97. package/dist/types/spreadsheet.d.ts +1 -0
  98. package/dist/types/types.d.ts +3 -0
  99. package/dist/types/validation/callout.d.ts +1 -0
  100. package/dist/types/validation/doc-node-whitelist.d.ts +21 -0
  101. package/dist/types/validation/error.d.ts +7 -0
  102. package/dist/types/validation/mind-map.d.ts +22 -0
  103. package/dist/types/validation/schema-version.d.ts +2 -0
  104. package/dist/types/validation/schema.d.ts +49 -0
  105. package/dist/types/validation/video-embed.d.ts +1 -0
  106. package/dist/types/validation/workbook.d.ts +1 -0
  107. package/package.json +87 -5
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ /**
3
+ * 协同在线状态(presence)的纯逻辑(可单测,不依赖 DOM / Yjs / 网络)。
4
+ *
5
+ * ## 为什么单独抽出来
6
+ *
7
+ * 在线状态有两个容易做错、且出错后**不报错、只是显示不对**的地方:
8
+ * 1) 颜色分配:同一用户在不同客户端必须得到**相同颜色**(否则"那个蓝色光标是谁"
9
+ * 无法对应),而颜色若只按"连接顺序"分,B 客户端上同一个人的颜色就会变。
10
+ * 因此颜色必须由**用户身份**稳定推导,不能依赖顺序或随机。
11
+ * 2) 对比度:名字气泡上的文字要与背景色对比可读;简单地对 6 个色相取固定亮度,
12
+ * 在浅色模式下会有几个色相偏亮、白字看不清。
13
+ * 这些都能用纯函数锁定,因此放在这里。
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.DARK_TEXT = exports.PRESENCE_PALETTE = void 0;
17
+ exports.colorForUser = colorForUser;
18
+ exports.hashIndex = hashIndex;
19
+ exports.initialOf = initialOf;
20
+ exports.collectPresence = collectPresence;
21
+ exports.relativeLuminance = relativeLuminance;
22
+ exports.contrastRatio = contrastRatio;
23
+ exports.readableTextColor = readableTextColor;
24
+ exports.parseHex = parseHex;
25
+ /**
26
+ * 稳定的用户色板。
27
+ *
28
+ * 选取标准不是"好看",而是**所有颜色配白字都达到 WCAG AA(≥4.5:1)**——
29
+ * 这样光标/名字气泡永远是"彩色底 + 白字",视觉一致,也不会出现浅底白字看不清。
30
+ * (初版用了偏亮的绿 `#1a9d5a`(3.49:1)与橙 `#d97706`(3.19:1),配白字或深字
31
+ * 都不到 AA;这里换成更深的一档,由单测锁定对比度。)
32
+ */
33
+ exports.PRESENCE_PALETTE = [
34
+ '#2f6feb', // 蓝 4.57:1
35
+ '#127a45', // 绿 5.39:1
36
+ '#b45309', // 棕橙 5.02:1
37
+ '#c026d3', // 紫 4.71:1
38
+ '#dc2626', // 红 4.83:1
39
+ '#0e7490', // 青 5.36:1
40
+ '#7c3aed', // 靛 5.70:1
41
+ '#9a4a08', // 深橙 6.26:1
42
+ ];
43
+ /**
44
+ * 由用户 id 稳定映射到调色板颜色。
45
+ *
46
+ * 用 FNV-1a 哈希而非 simple sum:简单的字符求和会让 `user1`/`user2`
47
+ * 这类相邻 id 落到同一颜色,而实际用户 id 常是顺序生成的。
48
+ */
49
+ function colorForUser(userId) {
50
+ return exports.PRESENCE_PALETTE[hashIndex(userId, exports.PRESENCE_PALETTE.length)];
51
+ }
52
+ /** FNV-1a 32 位哈希 → 取模。 */
53
+ function hashIndex(input, buckets) {
54
+ let h = 0x811c9dc5;
55
+ for (let i = 0; i < input.length; i++) {
56
+ h ^= input.charCodeAt(i);
57
+ // h *= 16777619(用移位避免大整数精度问题)
58
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
59
+ }
60
+ return buckets > 0 ? h % buckets : 0;
61
+ }
62
+ /** 名字首字(用于头像占位)。中文取首字,英文取首字母大写。 */
63
+ function initialOf(displayName) {
64
+ const t = displayName.trim();
65
+ if (t === '')
66
+ return '?';
67
+ return t.slice(0, 1).toUpperCase();
68
+ }
69
+ /**
70
+ * 汇总在线用户:去重(同一用户多标签页只显示一次)、排除自己、稳定排序。
71
+ *
72
+ * 稳定性很重要:若按 Map 遍历顺序输出,光标/头像列表会随消息到达顺序抖动。
73
+ * 这里按 displayName 排序,保证界面上顺序稳定。
74
+ */
75
+ function collectPresence(states, selfUserId) {
76
+ const byUser = new Map();
77
+ for (const s of states) {
78
+ if (!s.userId)
79
+ continue;
80
+ if (selfUserId && s.userId === selfUserId)
81
+ continue;
82
+ if (!byUser.has(s.userId)) {
83
+ byUser.set(s.userId, {
84
+ userId: s.userId,
85
+ displayName: s.displayName || '匿名',
86
+ color: colorForUser(s.userId),
87
+ });
88
+ }
89
+ }
90
+ return [...byUser.values()].sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'));
91
+ }
92
+ /**
93
+ * 相对亮度(WCAG),用于决定光标/气泡上该用白字还是深字。
94
+ * 返回 0(黑)~1(白)。
95
+ */
96
+ function relativeLuminance(hex) {
97
+ const rgb = parseHex(hex);
98
+ if (!rgb)
99
+ return 0;
100
+ const [r, g, b] = rgb.map((v) => {
101
+ const c = v / 255;
102
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
103
+ });
104
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
105
+ }
106
+ /** 前景深色的基准(与 :root 的 --color-text 接近)。 */
107
+ exports.DARK_TEXT = '#1f2937';
108
+ /** WCAG 对比度。 */
109
+ function contrastRatio(a, b) {
110
+ const la = relativeLuminance(a);
111
+ const lb = relativeLuminance(b);
112
+ const hi = Math.max(la, lb);
113
+ const lo = Math.min(la, lb);
114
+ return (hi + 0.05) / (lo + 0.05);
115
+ }
116
+ /**
117
+ * 该背景色上应使用的前景色。
118
+ *
119
+ * 实现为「比较白字与深字各自的对比度,取更高的那个」,而不是用一个亮度阈值判断——
120
+ * 阈值是脆弱的:初版用 0.179 作分界,`#2f6feb`(亮度 0.180)被判为用深字,
121
+ * 但实际白字对比度 4.57 优于深字 3.21,选错了。(由单测发现。)
122
+ */
123
+ function readableTextColor(hex) {
124
+ const withWhite = contrastRatio(hex, '#ffffff');
125
+ const withDark = contrastRatio(hex, exports.DARK_TEXT);
126
+ return withWhite >= withDark ? '#ffffff' : exports.DARK_TEXT;
127
+ }
128
+ /** 解析 #rgb / #rrggbb;非法返回 null。 */
129
+ function parseHex(hex) {
130
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
131
+ if (!m)
132
+ return null;
133
+ let s = m[1];
134
+ if (s.length === 3)
135
+ s = s.split('').map((c) => c + c).join('');
136
+ return [
137
+ parseInt(s.slice(0, 2), 16),
138
+ parseInt(s.slice(2, 4), 16),
139
+ parseInt(s.slice(4, 6), 16),
140
+ ];
141
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSafeHref = isSafeHref;
4
+ exports.safeHref = safeHref;
5
+ /**
6
+ * href 协议白名单(客户端纵深防御,L4)。
7
+ *
8
+ * 服务端在写入与读取投影时都做了协议白名单(挡 `javascript:`),但历史数据、
9
+ * 导入内容或服务端回归仍可能让危险 href 出现在正文 JSON 里。渲染器直接把它
10
+ * 写进 `<a href>`,等于给浏览器一个可执行入口——因此在**渲染侧**再兜一层:
11
+ * 只放行确定安全的协议;其余(含 `javascript:` / `data:` / `vbscript:` 等)
12
+ * 一律降级为不可点击的锚点(href 置为 `#`)。
13
+ *
14
+ * 允许:http(s)、mailto、以 `/` 或 `#` 开头的站内相对链接,以及相对路径。
15
+ */
16
+ const SAFE_ABSOLUTE = /^(https?:|mailto:)/i;
17
+ function isSafeHref(raw) {
18
+ if (typeof raw !== 'string')
19
+ return false;
20
+ // 去掉控制字符与空白后再判断,避免 `java\nscript:` 之类绕过
21
+ const href = raw.replace(/[\u0000-\u001f\u007f\s]/g, '').toLowerCase();
22
+ if (href === '')
23
+ return false;
24
+ if (href.startsWith('/') || href.startsWith('#'))
25
+ return true;
26
+ if (SAFE_ABSOLUTE.test(href))
27
+ return true;
28
+ // 相对路径(无协议、无 `:` 前缀):允许。含 `:` 的一律按协议处理并要求在白名单内。
29
+ if (!href.includes(':'))
30
+ return true;
31
+ return false;
32
+ }
33
+ /** 渲染用:安全则原样返回,否则返回 '#'。 */
34
+ function safeHref(raw) {
35
+ return isSafeHref(raw) ? String(raw) : '#';
36
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CELL_BACKGROUNDS = void 0;
4
+ exports.cellBackground = cellBackground;
5
+ /** 固定单元格底色,不接受任意 CSS;读写端共用,服务端同步白名单。 */
6
+ exports.CELL_BACKGROUNDS = [
7
+ { name: '默认', value: null },
8
+ { name: '灰', value: '#f1f3f5' },
9
+ { name: '黄', value: '#fff3bf' },
10
+ { name: '绿', value: '#d3f9d8' },
11
+ { name: '蓝', value: '#d0ebff' },
12
+ { name: '紫', value: '#e5dbff' },
13
+ { name: '红', value: '#ffe3e3' },
14
+ ];
15
+ function cellBackground(value) {
16
+ return typeof value === 'string' && exports.CELL_BACKGROUNDS.some((c) => c.value === value)
17
+ ? value : null;
18
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TABLE_CELL_MIN_WIDTH = void 0;
4
+ exports.tableColumnWidths = tableColumnWidths;
5
+ exports.tableMinWidth = tableMinWidth;
6
+ /** 自动列宽保留可读空间;窄屏在表格内部滚动,不把长文本压成单字列。 */
7
+ exports.TABLE_CELL_MIN_WIDTH = 120;
8
+ /** 首行合并单元格的 colwidth 数组包含多个视觉列,必须逐列展开。 */
9
+ function tableColumnWidths(table) {
10
+ const row = table.content?.find((node) => node.type === 'tableRow');
11
+ const widths = [];
12
+ for (const cell of row?.content ?? []) {
13
+ const span = Number(cell.attrs?.colspan ?? 1);
14
+ const count = Number.isSafeInteger(span) && span > 0 ? Math.min(span, 1000) : 1;
15
+ const colwidth = cell.attrs?.colwidth;
16
+ for (let index = 0; index < count; index += 1) {
17
+ const value = Array.isArray(colwidth) ? colwidth[index] : undefined;
18
+ widths.push(typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
19
+ }
20
+ }
21
+ return widths;
22
+ }
23
+ /** 未设置宽度的列与编辑器 cellMinWidth 共用同一默认值;保留历史显式列宽。 */
24
+ function tableMinWidth(widths) {
25
+ return widths.length > 0
26
+ ? widths.reduce((sum, width) => sum + (width > 0 ? width : exports.TABLE_CELL_MIN_WIDTH), 0)
27
+ : undefined;
28
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeVideoEmbed = normalizeVideoEmbed;
4
+ exports.parseVideoUrl = parseVideoUrl;
5
+ exports.videoSourceUrl = videoSourceUrl;
6
+ exports.videoPlayerUrl = videoPlayerUrl;
7
+ function normalizeVideoEmbed(raw) {
8
+ if (!raw || typeof raw !== 'object')
9
+ return null;
10
+ const data = raw;
11
+ const validId = data.provider === 'youtube'
12
+ ? typeof data.videoId === 'string' && /^[\w-]{11}$/.test(data.videoId)
13
+ : data.provider === 'bilibili' && typeof data.videoId === 'string' &&
14
+ /^(BV[A-Za-z0-9]{10}|av[1-9]\d{0,15})$/.test(data.videoId);
15
+ const page = data.page ?? 1;
16
+ const startSeconds = data.startSeconds ?? 0;
17
+ if (!validId || !Number.isInteger(page) || Number(page) < 1 || Number(page) > 1000 ||
18
+ !Number.isInteger(startSeconds) || Number(startSeconds) < 0 || Number(startSeconds) > 604800)
19
+ return null;
20
+ return { provider: data.provider, videoId: data.videoId,
21
+ page: data.provider === 'youtube' ? 1 : Number(page), startSeconds: Number(startSeconds) };
22
+ }
23
+ function seconds(value) {
24
+ if (!value)
25
+ return 0;
26
+ if (/^\d+$/.test(value))
27
+ return Number(value);
28
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
29
+ if (!match || !match[0])
30
+ return NaN;
31
+ return Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0);
32
+ }
33
+ function parseVideoUrl(value) {
34
+ let url;
35
+ try {
36
+ url = new URL(value.trim());
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.port)
42
+ return null;
43
+ const host = url.hostname.toLowerCase();
44
+ const path = url.pathname.split('/').filter(Boolean);
45
+ let provider;
46
+ let videoId;
47
+ if (host === 'youtu.be') {
48
+ provider = 'youtube';
49
+ videoId = path.length === 1 ? path[0] : null;
50
+ }
51
+ else if (['youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'].includes(host)) {
52
+ provider = 'youtube';
53
+ videoId = url.pathname === '/watch' ? url.searchParams.get('v')
54
+ : ['embed', 'shorts', 'live'].includes(path[0]) && path.length === 2 ? path[1] : null;
55
+ }
56
+ else if (['bilibili.com', 'www.bilibili.com', 'm.bilibili.com'].includes(host)) {
57
+ provider = 'bilibili';
58
+ videoId = path[0] === 'video' && path.length === 2 ? path[1] : null;
59
+ }
60
+ else if (host === 'player.bilibili.com' && url.pathname === '/player.html') {
61
+ provider = 'bilibili';
62
+ videoId = url.searchParams.get('bvid') ?? (url.searchParams.get('aid') ? `av${url.searchParams.get('aid')}` : null);
63
+ }
64
+ else
65
+ return null;
66
+ const hashTime = /^#t=(.*)$/.exec(url.hash)?.[1] ?? null;
67
+ return normalizeVideoEmbed({ provider, videoId, page: Number(url.searchParams.get('p') ?? 1),
68
+ startSeconds: seconds(url.searchParams.get('start') ?? url.searchParams.get('t') ?? hashTime) });
69
+ }
70
+ function videoSourceUrl(data) {
71
+ const safe = normalizeVideoEmbed(data);
72
+ if (!safe)
73
+ return '';
74
+ const url = new URL(safe.provider === 'youtube'
75
+ ? `https://www.youtube.com/watch?v=${safe.videoId}`
76
+ : `https://www.bilibili.com/video/${safe.videoId}`);
77
+ if (safe.provider === 'bilibili' && safe.page > 1)
78
+ url.searchParams.set('p', String(safe.page));
79
+ if (safe.startSeconds)
80
+ url.searchParams.set('t', String(safe.startSeconds));
81
+ return url.href;
82
+ }
83
+ function videoPlayerUrl(data) {
84
+ const safe = normalizeVideoEmbed(data);
85
+ if (!safe)
86
+ return '';
87
+ const url = new URL(safe.provider === 'youtube'
88
+ ? `https://www.youtube.com/embed/${safe.videoId}` : 'https://player.bilibili.com/player.html');
89
+ url.searchParams.set('autoplay', '0');
90
+ if (safe.provider === 'youtube') {
91
+ url.searchParams.set('playsinline', '1');
92
+ if (safe.startSeconds)
93
+ url.searchParams.set('start', String(safe.startSeconds));
94
+ }
95
+ else {
96
+ url.searchParams.set(safe.videoId.startsWith('BV') ? 'bvid' : 'aid', safe.videoId.startsWith('BV') ? safe.videoId : safe.videoId.slice(2));
97
+ url.searchParams.set('p', String(safe.page));
98
+ url.searchParams.set('t', String(safe.startSeconds));
99
+ url.searchParams.set('danmaku', '0');
100
+ }
101
+ return url.href;
102
+ }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WORKBOOK_LIMITS = void 0;
4
+ exports.isWorkbookDocument = isWorkbookDocument;
5
+ exports.emptyWorkbook = emptyWorkbook;
6
+ exports.validateWorkbook = validateWorkbook;
7
+ exports.workbookCellText = workbookCellText;
8
+ exports.workbookText = workbookText;
9
+ exports.WORKBOOK_LIMITS = { sheets: 20, rows: 10000, columns: 200, cells: 50000, bytes: 4 * 1024 * 1024 };
10
+ function isWorkbookDocument(value) {
11
+ return !!value && typeof value === 'object' && value.type === 'workbook';
12
+ }
13
+ function emptyWorkbook(id) {
14
+ return {
15
+ type: 'workbook', formatVersion: 1,
16
+ workbook: {
17
+ id, name: '工作簿', appVersion: '1.1.0', locale: 'zhCN',
18
+ sheetOrder: ['sheet1'],
19
+ sheets: { sheet1: { id: 'sheet1', name: 'Sheet1', rowCount: 1000, columnCount: 26, cellData: {} } },
20
+ },
21
+ };
22
+ }
23
+ function record(value) {
24
+ return !!value && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+ function index(key, upper) {
27
+ return /^(0|[1-9]\d*)$/.test(key) && Number(key) < upper;
28
+ }
29
+ /** Validate the complete snapshot without silently dropping supported engine attributes. */
30
+ function validateWorkbook(raw) {
31
+ const fail = (message) => { throw new Error(`工作表格式无效:${message}`); };
32
+ if (!isWorkbookDocument(raw) || raw.formatVersion !== 1 || !record(raw.workbook))
33
+ fail('不支持的版本');
34
+ const doc = raw;
35
+ const json = JSON.stringify(doc);
36
+ if (new TextEncoder().encode(json).byteLength > exports.WORKBOOK_LIMITS.bytes)
37
+ fail('超过 4MB');
38
+ let nodes = 0;
39
+ const walk = (value, depth) => {
40
+ if (++nodes > 500000 || depth > 40)
41
+ fail('结构过大或嵌套过深');
42
+ if (typeof value === 'number' && !Number.isFinite(value))
43
+ fail('包含非法数值');
44
+ if (Array.isArray(value))
45
+ value.forEach(item => walk(item, depth + 1));
46
+ else if (record(value)) {
47
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
48
+ fail('包含非法对象');
49
+ for (const [key, item] of Object.entries(value)) {
50
+ if (['__proto__', 'constructor', 'prototype'].includes(key))
51
+ fail('包含非法属性');
52
+ walk(item, depth + 1);
53
+ }
54
+ }
55
+ };
56
+ walk(doc, 0);
57
+ const book = doc.workbook;
58
+ if (typeof book.id !== 'string' || !book.id || book.id.length > 128 ||
59
+ typeof book.name !== 'string' || book.name.length > 256)
60
+ fail('工作簿名称或标识无效');
61
+ if (!Array.isArray(book.sheetOrder) || !book.sheetOrder.length || book.sheetOrder.length > exports.WORKBOOK_LIMITS.sheets ||
62
+ new Set(book.sheetOrder).size !== book.sheetOrder.length || !record(book.sheets) ||
63
+ Object.keys(book.sheets).length !== book.sheetOrder.length)
64
+ fail('表页数量或顺序无效');
65
+ let cells = 0;
66
+ const names = new Set();
67
+ for (const id of book.sheetOrder) {
68
+ if (typeof id !== 'string' || !id || id.length > 128)
69
+ fail('表页标识无效');
70
+ const sheet = book.sheets[id];
71
+ if (!record(sheet) || sheet.id !== id || typeof sheet.name !== 'string' || !sheet.name.trim() ||
72
+ sheet.name.length > 31 || /[\\/*?:[\]]/.test(sheet.name) || names.has(sheet.name.toLowerCase()))
73
+ fail('表页名称无效或重复(最多 31 字符)');
74
+ names.add(sheet.name.toLowerCase());
75
+ if (!Number.isInteger(sheet.rowCount) || sheet.rowCount < 1 || sheet.rowCount > exports.WORKBOOK_LIMITS.rows ||
76
+ !Number.isInteger(sheet.columnCount) || sheet.columnCount < 1 || sheet.columnCount > exports.WORKBOOK_LIMITS.columns)
77
+ fail('行列超出上限');
78
+ if (sheet.cellData !== undefined) {
79
+ if (!record(sheet.cellData))
80
+ fail('单元格结构无效');
81
+ for (const [r, row] of Object.entries(sheet.cellData)) {
82
+ if (!index(r, sheet.rowCount) || !record(row))
83
+ fail('单元格行号无效');
84
+ for (const [c, cell] of Object.entries(row)) {
85
+ if (++cells > exports.WORKBOOK_LIMITS.cells)
86
+ fail('单元格超过 50000 个');
87
+ if (!index(c, sheet.columnCount))
88
+ fail('单元格列号无效');
89
+ if (cell === null)
90
+ continue;
91
+ if (!record(cell))
92
+ fail('单元格结构无效');
93
+ if (cell.v !== undefined && cell.v !== null && !['string', 'number', 'boolean'].includes(typeof cell.v))
94
+ fail('单元格值无效');
95
+ if (typeof cell.v === 'string' && cell.v.length > 16000)
96
+ fail('单元格文字过长');
97
+ if (cell.f !== undefined && (typeof cell.f !== 'string' || cell.f.length > 4000))
98
+ fail('公式无效');
99
+ if (cell.t !== undefined && (!Number.isInteger(cell.t) || Number(cell.t) < 0 || Number(cell.t) > 4))
100
+ fail('单元格类型无效');
101
+ if (cell.s !== undefined && cell.s !== null && typeof cell.s !== 'string' && !record(cell.s))
102
+ fail('单元格样式无效');
103
+ if (cell.p !== undefined && (!record(cell.p) || (cell.p.body !== undefined &&
104
+ (!record(cell.p.body) || typeof cell.p.body.dataStream !== 'string' || cell.p.body.dataStream.length > 16000))))
105
+ fail('单元格富文本无效');
106
+ }
107
+ }
108
+ }
109
+ for (const [data, upper] of [[sheet.rowData, sheet.rowCount], [sheet.columnData, sheet.columnCount]]) {
110
+ if (data !== undefined && (!record(data) || Object.keys(data).some(key => !index(key, upper))))
111
+ fail('行列属性越界');
112
+ }
113
+ if (sheet.mergeData !== undefined) {
114
+ if (!Array.isArray(sheet.mergeData) || sheet.mergeData.length > exports.WORKBOOK_LIMITS.cells)
115
+ fail('合并区域无效');
116
+ const occupied = new Set();
117
+ for (const range of sheet.mergeData) {
118
+ if (!record(range) || ![range.startRow, range.endRow, range.startColumn, range.endColumn].every(Number.isInteger) ||
119
+ range.startRow < 0 || range.endRow < range.startRow || range.endRow >= sheet.rowCount ||
120
+ range.startColumn < 0 || range.endColumn < range.startColumn || range.endColumn >= sheet.columnCount)
121
+ fail('合并区域越界');
122
+ const area = (range.endRow - range.startRow + 1) * (range.endColumn - range.startColumn + 1);
123
+ if (occupied.size + area > 200000)
124
+ fail('合并区域总范围过大');
125
+ for (let r = range.startRow; r <= range.endRow; r++) {
126
+ for (let c = range.startColumn; c <= range.endColumn; c++) {
127
+ const position = r * exports.WORKBOOK_LIMITS.columns + c;
128
+ if (occupied.has(position))
129
+ fail('合并区域重叠');
130
+ occupied.add(position);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return JSON.parse(json);
137
+ }
138
+ function workbookCellText(cell) {
139
+ if (!cell)
140
+ return '';
141
+ if (cell.p?.body?.dataStream)
142
+ return cell.p.body.dataStream.replace(/[\r\n]+$/, '');
143
+ if (cell.v !== undefined && cell.v !== null)
144
+ return String(cell.v);
145
+ return cell.f ?? '';
146
+ }
147
+ function workbookText(doc) {
148
+ return doc.workbook.sheetOrder.map(id => {
149
+ const sheet = doc.workbook.sheets[id];
150
+ return `${sheet.name} ${Object.values(sheet.cellData ?? {}).flatMap(row => Object.values(row).map(workbookCellText)).join(' ')}`;
151
+ }).join(' ').slice(0, 200000);
152
+ }
@@ -0,0 +1,17 @@
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);
@@ -0,0 +1,17 @@
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/types.js"), exports);
@@ -0,0 +1,17 @@
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/callout.js"), exports);
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ALLOWED_MARKS = exports.ALLOWED_NODES = void 0;
4
+ /**
5
+ * 文档节点白名单(**纯数据,无依赖**)。
6
+ *
7
+ * 为什么单独成文件:白名单需要被三处引用——
8
+ * 1) 服务端校验(doc-schema.service.ts)
9
+ * 2) 一致性测试(与前端 node-catalog 交叉核对)
10
+ * 3) 导出序列化器的防漂移测试
11
+ * 若把它放在 doc-schema.service.ts 里,测试导入会连带加载 Nest 依赖,
12
+ * 导致 spec 在测试环境下加载失败(实测:整个测试文件被静默跳过)。
13
+ *
14
+ * 因此这里只放数据,不放逻辑,任何一方都能安全引用。
15
+ *
16
+ * 新增节点时必须同时更新:
17
+ * 1) 本文件
18
+ * 2) 前端 web/src/shared/node-catalog.ts
19
+ * 3) 导出序列化器 markdown-serializer.ts 的处理分支
20
+ * 三处一致性由测试自动核对(读取真实文件,而非维护测试内副本)。
21
+ */
22
+ exports.ALLOWED_NODES = new Set([
23
+ 'mindMapDescription', 'mindMapNote', 'mindMapRelation',
24
+ 'mindMap',
25
+ 'mindMapTopic',
26
+ 'doc',
27
+ 'paragraph',
28
+ 'text',
29
+ 'heading',
30
+ 'bulletList',
31
+ 'orderedList',
32
+ 'listItem',
33
+ // 待办列表:前端编辑器(TaskList/TaskItem)支持,导出序列化器也支持,
34
+ // 但白名单此前漏了这两个节点 —— 导致「用户插入待办列表后保存必被拒」。
35
+ // 由导入测试暴露(详见 doc-schema.service.spec.ts 的三方一致性断言)。
36
+ 'taskList',
37
+ 'taskItem',
38
+ 'blockquote',
39
+ 'codeBlock',
40
+ 'horizontalRule',
41
+ 'hardBreak',
42
+ 'image',
43
+ 'fileAttachment', // 视频或文件卡片(只存 attachmentId,不存存储地址)
44
+ 'videoEmbed',
45
+ 'callout', // 高亮块(容器,content: block+)
46
+ // 分栏:columns(行,content: column+)包 column(栏,content: block+)。
47
+ // 两个都要登记——只登记外层时,栏节点会在保存校验处被判为「不支持」而拒绝写入。
48
+ 'columns',
49
+ 'column',
50
+ 'table',
51
+ 'tableRow',
52
+ 'tableHeader',
53
+ 'tableCell',
54
+ 'pageLink', // 内部页面引用 [[page]]
55
+ 'mention', // @提及用户
56
+ 'mathInline', // 行内公式(KaTeX)
57
+ 'mathBlock', // 块级公式(KaTeX)
58
+ ]);
59
+ /** 允许的标记类型。 */
60
+ exports.ALLOWED_MARKS = new Set([
61
+ 'bold',
62
+ 'italic',
63
+ 'strike',
64
+ 'code',
65
+ 'link',
66
+ // 行内样式:文字颜色与背景高亮。两者的颜色属性必须在
67
+ // doc-schema.service.ts 的 ALLOWED_ATTRS 里同时登记,否则颜色值会被剥离、
68
+ // 只剩一个空标记(实测的"颜色丢失但标记还在"就是这么来的)。
69
+ 'textStyle',
70
+ 'highlight',
71
+ ]);
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContentValidationError = void 0;
4
+ class ContentValidationError extends Error {
5
+ code;
6
+ status;
7
+ details;
8
+ constructor(code, message, status = 422, details) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ this.details = details;
13
+ this.name = 'ContentValidationError';
14
+ }
15
+ static validation(message) { return new ContentValidationError('validation.failed', message); }
16
+ }
17
+ exports.ContentValidationError = ContentValidationError;