fatcms-clipboard-parser 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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +18 -0
- package/src/html.js +98 -0
- package/src/index.d.ts +26 -0
- package/src/index.js +3 -0
- package/src/styles.js +87 -0
- package/src/table.js +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fatcms-clipboard-parser contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# fatcms-clipboard-parser
|
|
2
|
+
|
|
3
|
+
编辑器无关的 HTML 剪贴板解析工具。MIT 许可,公开可读 ES Module 源码,附 TypeScript 声明。无需 React、编辑器实例或全局变量。
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install fatcms-clipboard-parser
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { parseHtml, parseText, parseTableGrid } from 'fatcms-clipboard-parser';
|
|
11
|
+
|
|
12
|
+
const { nodes } = parseHtml('<p><strong>你好</strong></p>');
|
|
13
|
+
const values = parseText('姓名\t数量\n张三\t3');
|
|
14
|
+
|
|
15
|
+
// 在浏览器中解析表格结构;不需要把 table 插入页面。
|
|
16
|
+
const document = new DOMParser().parseFromString(html, 'text/html');
|
|
17
|
+
const table = document.querySelector('table');
|
|
18
|
+
const grid = table ? parseTableGrid(table) : null;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
- `parseHtml(html, options?)`:返回 `{ nodes, warnings }`。节点包含 `name`、`type`、`attrs`、`style`、`children` 或 `data`。`parent` 为父节点引用,因此节点树不可直接 JSON.stringify;转换为业务数据时请忽略 parent。支持实体、br、pre/code 换行、简单标签/类/id/后代选择器及内联样式。默认最多 2000 万字符,可通过 `maxLength` 调整;`normalizeWhitespace: false` 保留源文本换行。
|
|
24
|
+
- `parseStyle(text)`:解析 CSS 声明,保留引号、函数内部的分号及值的大小写。
|
|
25
|
+
- `parseText(text)`:按制表符和换行拆成二维数组,保留空格、空单元格和末尾空行。
|
|
26
|
+
- `parseTableGrid(table, options?)`:返回 `{ rows, rowCount, columnCount, merges }`。普通单元格包含原始 DOM `element`、`rowSpan`、`colSpan`;合并覆盖位置包含 `coveredBy: { row, col }`;缺失位置为 null。merge 为 `{ row, col, rowCount, colCount }`,索引从零开始。默认最多 100 万个网格位置,`maxCells` 可调整。`alignFirstRow: 'right'` 兼容省略前导空标题的导出格式,通常使用默认 left。不修改传入 DOM,不做布局测量。
|
|
27
|
+
- `isSafeUrl(value, image?)`:基础 URL 协议检查,供剪贴板适配使用。
|
|
28
|
+
|
|
29
|
+
## 范围与限制
|
|
30
|
+
|
|
31
|
+
本包负责公共解析结构,不负责编辑器内容模型、公式计算、图片上传、粘贴命令、撤销历史或工作簿文件导入。电子表格的 Office 特殊格式预处理、行列尺寸测量、数据类型和样式转换由宿主适配器负责。
|
|
32
|
+
|
|
33
|
+
HTML 节点解析可运行在 Node.js 中;表格解析需要 DOM 对象(浏览器,或调用方提供的 jsdom)。无文档级缓存、监听器或全局语言设置,每次调用返回独立结构。返回的 table element 属于调用者,使用完结果即可释放。
|
|
34
|
+
|
|
35
|
+
样式解析仅支持扁平的简单规则,不实现完整 CSS cascade、继承、伪类、媒体查询或 `!important`。HTML 解析会忽略部分活动元素和事件属性,但**不是完整 HTML/CSS 安全净化器**;不要把未经专门净化的剪贴板 HTML 插入可信页面或直接执行。
|
|
36
|
+
|
|
37
|
+
## FatCMS 接入
|
|
38
|
+
|
|
39
|
+
`fatcms-doc-sdk` 正常 import 本包并挂载到 `window.fatcmsDocDependencies['fatcms-clipboard-parser']`;富文本读取公共节点树。`fatcms-ysheet-sdk` 使用同名 Webpack external,读取公共表格网格,再转换成工作表数据。先加载 doc-sdk,随后加载 ysheet-sdk。仅共享无状态函数,不共享文档数据。
|
|
40
|
+
|
|
41
|
+
## 开发
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npm ci
|
|
45
|
+
npm test
|
|
46
|
+
npm pack --dry-run
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
发布包只包含 src、README 和 LICENSE;测试代码与开发依赖不进入发布文件。源码重新实现公共解析接口,编辑器恢复代码和业务配置不包含在包内。
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fatcms-clipboard-parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Editor-independent HTML clipboard and table parsing with optional browser layout measurement",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {".": {"types": "./src/index.d.ts", "import": "./src/index.js", "default": "./src/index.js"}},
|
|
9
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"scripts": {"test": "node --test test/*.test.js", "prepublishOnly": "npm test"},
|
|
12
|
+
"engines": {"node": ">=18"},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"dependencies": {"htmlparser2": "^6.1.0"},
|
|
15
|
+
"devDependencies": {"jsdom": "^26.1.0"},
|
|
16
|
+
"publishConfig": {"registry": "https://registry.npmjs.org/", "access": "public"},
|
|
17
|
+
"keywords": ["clipboard", "html", "table", "paste", "spreadsheet"]
|
|
18
|
+
}
|
package/src/html.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Parser } from 'htmlparser2';
|
|
2
|
+
import { parseStyle, parseStyleRules, matchesSelector, selectorSpecificity } from './styles.js';
|
|
3
|
+
|
|
4
|
+
const excludedTags = new Set(['script', 'noscript', 'svg', 'iframe', 'object', 'embed', 'link', 'meta', 'base']);
|
|
5
|
+
|
|
6
|
+
export function isSafeUrl(value, image = false)
|
|
7
|
+
{
|
|
8
|
+
const compact = String(value).replace(/[\u0000-\u0020\u007f]/g, '');
|
|
9
|
+
if (/^(https?:|blob:)/i.test(compact)) return true;
|
|
10
|
+
if (image && /^data:image\/(png|jpeg|gif|webp|avif|bmp);base64,/i.test(compact)) return true;
|
|
11
|
+
if (!image && /^(mailto:|tel:)/i.test(compact)) return true;
|
|
12
|
+
return !/^[a-z][a-z\d+.-]*:/i.test(compact);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse HTML without executing it or requiring window/document. */
|
|
16
|
+
export function parseHtml(html, { maxLength = 20_000_000, normalizeWhitespace = true } = {})
|
|
17
|
+
{
|
|
18
|
+
if (typeof html !== 'string') throw new TypeError('HTML must be a string');
|
|
19
|
+
if (html.length > maxLength) throw new RangeError('Clipboard HTML exceeds maxLength');
|
|
20
|
+
const root = { type: 'root', name: '#root', children: [] };
|
|
21
|
+
const stack = [root];
|
|
22
|
+
const stylesheets = [];
|
|
23
|
+
const warnings = [];
|
|
24
|
+
let ignoredDepth = 0;
|
|
25
|
+
let styleDepth = 0;
|
|
26
|
+
let styleText = '';
|
|
27
|
+
let preformattedDepth = 0;
|
|
28
|
+
function appendText(text)
|
|
29
|
+
{
|
|
30
|
+
if (!text) return;
|
|
31
|
+
const parent = stack[stack.length - 1];
|
|
32
|
+
const last = parent.children[parent.children.length - 1];
|
|
33
|
+
if (last?.type === 'text') last.data += text;
|
|
34
|
+
else parent.children.push({ type: 'text', name: '#text', data: text, parent });
|
|
35
|
+
}
|
|
36
|
+
const parser = new Parser({
|
|
37
|
+
onopentag(name, attributes)
|
|
38
|
+
{
|
|
39
|
+
if (ignoredDepth || excludedTags.has(name)) { ignoredDepth++; return; }
|
|
40
|
+
if (name === 'style') { styleDepth++; styleText = ''; return; }
|
|
41
|
+
if (name === 'br') { appendText('\n'); return; }
|
|
42
|
+
const attrs = Object.create(null);
|
|
43
|
+
for (const [key, value] of Object.entries(attributes))
|
|
44
|
+
{
|
|
45
|
+
if (/^on/i.test(key) || ['srcdoc', 'srcset'].includes(key)) continue;
|
|
46
|
+
if (['href', 'src'].includes(key) && !isSafeUrl(value, key === 'src')) continue;
|
|
47
|
+
attrs[key] = value;
|
|
48
|
+
}
|
|
49
|
+
const style = parseStyle(attrs.style);
|
|
50
|
+
delete attrs.style;
|
|
51
|
+
const parent = stack[stack.length - 1];
|
|
52
|
+
const node = { type: 'element', name, attrs, style, children: [], parent };
|
|
53
|
+
parent.children.push(node);
|
|
54
|
+
stack.push(node);
|
|
55
|
+
if (name === 'pre' || name === 'code') preformattedDepth++;
|
|
56
|
+
},
|
|
57
|
+
ontext(text)
|
|
58
|
+
{
|
|
59
|
+
if (ignoredDepth) return;
|
|
60
|
+
if (styleDepth) { styleText += text; return; }
|
|
61
|
+
let value = text.replace(/\u200b/g, '');
|
|
62
|
+
if (normalizeWhitespace && !preformattedDepth) value = value.replace(/\n+/g, ' ');
|
|
63
|
+
appendText(value);
|
|
64
|
+
},
|
|
65
|
+
onclosetag(name)
|
|
66
|
+
{
|
|
67
|
+
if (ignoredDepth) { ignoredDepth--; return; }
|
|
68
|
+
if (name === 'style') { stylesheets.push(styleText); styleDepth--; return; }
|
|
69
|
+
if (name === 'br') return;
|
|
70
|
+
if (stack.length > 1 && stack[stack.length - 1].name === name)
|
|
71
|
+
{
|
|
72
|
+
stack.pop();
|
|
73
|
+
if (name === 'pre' || name === 'code') preformattedDepth--;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
|
|
77
|
+
parser.end(html);
|
|
78
|
+
const rules = stylesheets.flatMap(parseStyleRules);
|
|
79
|
+
function applyStyles(parent)
|
|
80
|
+
{
|
|
81
|
+
parent.children = parent.children.filter(node =>
|
|
82
|
+
{
|
|
83
|
+
if (node.type !== 'element') return true;
|
|
84
|
+
const inheritedRules = rules.filter(rule => matchesSelector(node, rule.selector))
|
|
85
|
+
.sort((left, right) => selectorSpecificity(left.selector) - selectorSpecificity(right.selector));
|
|
86
|
+
node.style = Object.assign(Object.create(null), ...inheritedRules.map(rule => rule.style), node.style);
|
|
87
|
+
applyStyles(node);
|
|
88
|
+
return node.style.display !== 'none' || ['tr', 'td', 'th', 'col'].includes(node.name);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
applyStyles(root);
|
|
92
|
+
return { nodes: root.children, warnings };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseText(text)
|
|
96
|
+
{
|
|
97
|
+
return String(text).replace(/\r\n?/g, '\n').split('\n').map(row => row.split('\t'));
|
|
98
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface HtmlNode {
|
|
2
|
+
type: 'root' | 'element' | 'text';
|
|
3
|
+
name: string;
|
|
4
|
+
attrs?: Record<string, string>;
|
|
5
|
+
style?: Record<string, string>;
|
|
6
|
+
data?: string;
|
|
7
|
+
children?: HtmlNode[];
|
|
8
|
+
parent?: HtmlNode;
|
|
9
|
+
}
|
|
10
|
+
export interface TableCell {
|
|
11
|
+
element?: HTMLTableCellElement;
|
|
12
|
+
rowSpan?: number;
|
|
13
|
+
colSpan?: number;
|
|
14
|
+
coveredBy?: { row: number; col: number };
|
|
15
|
+
}
|
|
16
|
+
export interface TableGrid {
|
|
17
|
+
rows: (TableCell | null)[][];
|
|
18
|
+
rowCount: number;
|
|
19
|
+
columnCount: number;
|
|
20
|
+
merges: { row: number; col: number; rowCount: number; colCount: number }[];
|
|
21
|
+
}
|
|
22
|
+
export function parseHtml(html: string, options?: { maxLength?: number; normalizeWhitespace?: boolean }): { nodes: HtmlNode[]; warnings: string[] };
|
|
23
|
+
export function parseText(text: string): string[][];
|
|
24
|
+
export function parseStyle(text?: string): Record<string, string>;
|
|
25
|
+
export function isSafeUrl(value: string, image?: boolean): boolean;
|
|
26
|
+
export function parseTableGrid(table: HTMLTableElement, options?: { maxCells?: number; alignFirstRow?: 'left' | 'right' }): TableGrid;
|
package/src/index.js
ADDED
package/src/styles.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Split CSS declarations without splitting quoted strings or function arguments. */
|
|
2
|
+
function splitDeclarations(text)
|
|
3
|
+
{
|
|
4
|
+
const declarations = [];
|
|
5
|
+
let quote = '';
|
|
6
|
+
let depth = 0;
|
|
7
|
+
let start = 0;
|
|
8
|
+
for (let index = 0; index < text.length; index++)
|
|
9
|
+
{
|
|
10
|
+
const character = text[index];
|
|
11
|
+
if (character === '\\') { index++; continue; }
|
|
12
|
+
if (quote) { if (character === quote) quote = ''; continue; }
|
|
13
|
+
if (character === '"' || character === "'") quote = character;
|
|
14
|
+
else if (character === '(') depth++;
|
|
15
|
+
else if (character === ')') depth = Math.max(0, depth - 1);
|
|
16
|
+
else if (character === ';' && depth === 0)
|
|
17
|
+
{
|
|
18
|
+
declarations.push(text.slice(start, index));
|
|
19
|
+
start = index + 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
declarations.push(text.slice(start));
|
|
23
|
+
return declarations;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseStyle(text = '')
|
|
27
|
+
{
|
|
28
|
+
const style = Object.create(null);
|
|
29
|
+
for (const declaration of splitDeclarations(text))
|
|
30
|
+
{
|
|
31
|
+
const separator = declaration.indexOf(':');
|
|
32
|
+
if (separator < 0) continue;
|
|
33
|
+
const name = declaration.slice(0, separator).trim().toLowerCase();
|
|
34
|
+
const value = declaration.slice(separator + 1).trim();
|
|
35
|
+
if (name && value) style[name] = value;
|
|
36
|
+
}
|
|
37
|
+
return style;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseStyleRules(text)
|
|
41
|
+
{
|
|
42
|
+
const rules = [];
|
|
43
|
+
// Clipboard stylesheets use flat rules; discard nested at-rules.
|
|
44
|
+
let clean = text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
45
|
+
clean = clean.replace(/@[^{};]+\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
|
|
46
|
+
for (const match of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g))
|
|
47
|
+
{
|
|
48
|
+
for (const selector of match[1].split(','))
|
|
49
|
+
{
|
|
50
|
+
if (/^[\w.# -]+$/.test(selector.trim()))
|
|
51
|
+
rules.push({ selector: selector.trim(), style: parseStyle(match[2]) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function matchesSelector(node, selector)
|
|
58
|
+
{
|
|
59
|
+
const parts = selector.trim().split(/\s+/);
|
|
60
|
+
let current = node;
|
|
61
|
+
for (let index = parts.length - 1; index >= 0; index--)
|
|
62
|
+
{
|
|
63
|
+
const part = parts[index];
|
|
64
|
+
const matches = candidate =>
|
|
65
|
+
{
|
|
66
|
+
if (candidate.type !== 'element') return false;
|
|
67
|
+
const tag = part.match(/^[\w-]+/)?.[0];
|
|
68
|
+
const id = part.match(/#([\w-]+)/)?.[1];
|
|
69
|
+
const classes = [...part.matchAll(/\.([\w-]+)/g)].map(match => match[1]);
|
|
70
|
+
return (!tag || tag === candidate.name) && (!id || id === candidate.attrs.id) &&
|
|
71
|
+
classes.every(name => (candidate.attrs.class || '').split(/\s+/).includes(name));
|
|
72
|
+
};
|
|
73
|
+
if (index === parts.length - 1 && !matches(current)) return false;
|
|
74
|
+
while (current && !matches(current)) current = current.parent;
|
|
75
|
+
if (!current) return false;
|
|
76
|
+
current = current.parent;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function selectorSpecificity(selector)
|
|
82
|
+
{
|
|
83
|
+
const ids = (selector.match(/#/g) || []).length;
|
|
84
|
+
const classes = (selector.match(/\./g) || []).length;
|
|
85
|
+
const tags = selector.split(/\s+/).filter(part => /^[a-z]/i.test(part)).length;
|
|
86
|
+
return ids * 10000 + classes * 100 + tags;
|
|
87
|
+
}
|
package/src/table.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Expand merged cells into a rectangular grid without mutating the source table. */
|
|
2
|
+
export function parseTableGrid(table, { maxCells = 1_000_000, alignFirstRow = 'left' } = {})
|
|
3
|
+
{
|
|
4
|
+
const rows = [];
|
|
5
|
+
const merges = [];
|
|
6
|
+
let allocatedCells = 0;
|
|
7
|
+
let columnCount = 0;
|
|
8
|
+
const sourceRows = Array.from(table.rows);
|
|
9
|
+
for (const [rowIndex, sourceRow] of sourceRows.entries())
|
|
10
|
+
{
|
|
11
|
+
const row = rows[rowIndex] ||= [];
|
|
12
|
+
let columnIndex = 0;
|
|
13
|
+
for (const element of Array.from(sourceRow.cells))
|
|
14
|
+
{
|
|
15
|
+
while (row[columnIndex]) columnIndex++;
|
|
16
|
+
const rowSpan = element.rowSpan === 0 ? sourceRows.length - rowIndex : Math.max(1, element.rowSpan || 1);
|
|
17
|
+
const colSpan = Math.max(1, element.colSpan || 1);
|
|
18
|
+
allocatedCells += rowSpan * colSpan;
|
|
19
|
+
if (allocatedCells > maxCells) throw new RangeError('Clipboard table exceeds maxCells');
|
|
20
|
+
row[columnIndex] = { element, rowSpan, colSpan };
|
|
21
|
+
if (rowSpan > 1 || colSpan > 1)
|
|
22
|
+
{
|
|
23
|
+
merges.push({ row: rowIndex, col: columnIndex, rowCount: rowSpan, colCount: colSpan });
|
|
24
|
+
for (let rowOffset = 0; rowOffset < rowSpan; rowOffset++)
|
|
25
|
+
{
|
|
26
|
+
const targetRow = rows[rowIndex + rowOffset] ||= [];
|
|
27
|
+
for (let colOffset = 0; colOffset < colSpan; colOffset++)
|
|
28
|
+
{
|
|
29
|
+
if (rowOffset === 0 && colOffset === 0) continue;
|
|
30
|
+
targetRow[columnIndex + colOffset] = { coveredBy: { row: rowIndex, col: columnIndex } };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
columnIndex += colSpan;
|
|
35
|
+
columnCount = Math.max(columnCount, columnIndex);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (rows.length * columnCount > maxCells) throw new RangeError('Clipboard table exceeds maxCells');
|
|
39
|
+
// Some spreadsheet exports omit leading blank heading cells. Opt in explicitly.
|
|
40
|
+
const firstRow = rows[0];
|
|
41
|
+
if (alignFirstRow === 'right' && firstRow && firstRow.length < columnCount)
|
|
42
|
+
{
|
|
43
|
+
const offset = columnCount - firstRow.length;
|
|
44
|
+
firstRow.unshift(...Array(offset).fill(null));
|
|
45
|
+
for (const merge of merges)
|
|
46
|
+
{
|
|
47
|
+
if (merge.row === 0) merge.col += offset;
|
|
48
|
+
}
|
|
49
|
+
for (const row of rows)
|
|
50
|
+
{
|
|
51
|
+
for (const cell of row)
|
|
52
|
+
{
|
|
53
|
+
if (cell?.coveredBy?.row === 0) cell.coveredBy.col += offset;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const row of rows)
|
|
58
|
+
{
|
|
59
|
+
for (let column = 0; column < columnCount; column++) row[column] ||= null;
|
|
60
|
+
}
|
|
61
|
+
return { rows, rowCount: rows.length, columnCount, merges };
|
|
62
|
+
}
|