blockOperations.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402
  1. /**
  2. * blockOperations.ts - Block操作工具函数
  3. *
  4. * 提供block的各种操作辅助函数
  5. *
  6. * @module utils/blockOperations
  7. */
  8. import type {
  9. DocumentBlock,
  10. BlockType,
  11. TableBlock,
  12. TableRow,
  13. TableCell,
  14. RichText,
  15. } from '../types/editor';
  16. // ══════════════════════════════════════════════════════════════════════════════
  17. // Block ID Generation
  18. // ══════════════════════════════════════════════════════════════════════════════
  19. /**
  20. * 生成块ID
  21. *
  22. * @param type 块类型
  23. * @param index 索引(可选)
  24. * @returns 块ID
  25. *
  26. * @example
  27. * ```ts
  28. * generateBlockId('heading') // "block-h-1704096000000-0"
  29. * ```
  30. */
  31. export function generateBlockId(type: BlockType, index: number = 0): string {
  32. const typePrefix: Record<BlockType, string> = {
  33. heading: 'h',
  34. paragraph: 'p',
  35. table: 'tbl',
  36. image: 'img',
  37. toc: 'toc',
  38. };
  39. return `block-${typePrefix[type]}-${Date.now()}-${index}`;
  40. }
  41. // ══════════════════════════════════════════════════════════════════════════════
  42. // Block Order Operations
  43. // ══════════════════════════════════════════════════════════════════════════════
  44. /**
  45. * 计算插入位置的block_order
  46. * 稀疏排序策略:在两个块之间找到中间值
  47. *
  48. * @param prevOrder 前一个块的order
  49. * @param nextOrder 后一个块的order
  50. * @returns 新的order,如果返回-1表示需要重排
  51. *
  52. * @example
  53. * ```ts
  54. * computeInsertOrder(100, 200) // 150
  55. * computeInsertOrder(100, 101) // -1 (需要重排)
  56. * ```
  57. */
  58. export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
  59. const gap = nextOrder - prevOrder;
  60. if (gap > 1) {
  61. // 有间隙,直接取中间值
  62. return Math.floor((prevOrder + nextOrder) / 2);
  63. }
  64. // 间隙不足,需要重排
  65. return -1;
  66. }
  67. /**
  68. * 重新平衡block_order(稀疏排序,间隔100)
  69. *
  70. * @param blocks 块数组
  71. * @returns 重新排序后的块数组
  72. *
  73. * @example
  74. * ```ts
  75. * const rebalanced = rebalanceBlockOrders(blocks);
  76. * // blocks[0].block_order = 0
  77. * // blocks[1].block_order = 100
  78. * // blocks[2].block_order = 200
  79. * ```
  80. */
  81. export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
  82. const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
  83. return sorted.map((block, index) => ({
  84. ...block,
  85. block_order: index * 100,
  86. }));
  87. }
  88. // ══════════════════════════════════════════════════════════════════════════════
  89. // Table Operations
  90. // ══════════════════════════════════════════════════════════════════════════════
  91. function assertTableIndex(index: number, length: number, label: string): void {
  92. if (!Number.isInteger(index) || index < 0 || index >= length) {
  93. throw new Error(`${label}索引无效`);
  94. }
  95. }
  96. function assertTableRange(
  97. table: TableBlock,
  98. startRow: number,
  99. startCol: number,
  100. endRow: number,
  101. endCol: number,
  102. ): void {
  103. assertTableIndex(startRow, table.content.rows.length, '起始行');
  104. assertTableIndex(endRow, table.content.rows.length, '结束行');
  105. if (startRow > endRow) throw new Error('行范围无效');
  106. assertTableIndex(startCol, table.metadata.cols, '起始列');
  107. assertTableIndex(endCol, table.metadata.cols, '结束列');
  108. if (startCol > endCol) throw new Error('列范围无效');
  109. const selectedRows = table.content.rows.slice(startRow, endRow + 1);
  110. if (selectedRows.some((row) => row.cells.length <= endCol)) {
  111. throw new Error('表格单元格结构无效');
  112. }
  113. }
  114. export interface TableVisualCellPosition {
  115. rowStart: number;
  116. rowEnd: number;
  117. colStart: number;
  118. colEnd: number;
  119. }
  120. export interface TableCellReference {
  121. rowIndex: number;
  122. cellIndex: number;
  123. visual: TableVisualCellPosition;
  124. }
  125. export function getTableVisualCellPositions(table: TableBlock): Map<string, TableVisualCellPosition> {
  126. const occupied: boolean[][] = [];
  127. const positions = new Map<string, TableVisualCellPosition>();
  128. table.content.rows.forEach((row, rowIndex) => {
  129. if (!occupied[rowIndex]) occupied[rowIndex] = [];
  130. let visualCol = 0;
  131. row.cells.forEach((cell, cellIndex) => {
  132. const rowSpan = cell.rowspan ?? 1;
  133. const colSpan = cell.colspan ?? 1;
  134. if (rowSpan <= 0 || colSpan <= 0) {
  135. const hiddenCol = Number.isInteger(cell.col_index) && (cell.col_index ?? 0) > 0
  136. ? (cell.col_index ?? 1) - 1
  137. : visualCol;
  138. visualCol = Math.max(visualCol, hiddenCol + 1);
  139. return;
  140. }
  141. while (occupied[rowIndex][visualCol]) visualCol += 1;
  142. const position = {
  143. rowStart: rowIndex,
  144. rowEnd: rowIndex + rowSpan - 1,
  145. colStart: visualCol,
  146. colEnd: visualCol + colSpan - 1,
  147. };
  148. positions.set(`${rowIndex}-${cellIndex}`, position);
  149. for (let occupiedRow = position.rowStart; occupiedRow <= position.rowEnd; occupiedRow += 1) {
  150. if (!occupied[occupiedRow]) occupied[occupiedRow] = [];
  151. for (let occupiedCol = position.colStart; occupiedCol <= position.colEnd; occupiedCol += 1) {
  152. occupied[occupiedRow][occupiedCol] = true;
  153. }
  154. }
  155. visualCol = position.colEnd + 1;
  156. });
  157. });
  158. return positions;
  159. }
  160. export function getTableCellReference(
  161. table: TableBlock,
  162. rowIndex: number,
  163. cellIndex: number,
  164. positions: Map<string, TableVisualCellPosition> = getTableVisualCellPositions(table),
  165. ): TableCellReference | null {
  166. const visual = positions.get(`${rowIndex}-${cellIndex}`);
  167. if (!visual) return null;
  168. return { rowIndex, cellIndex, visual };
  169. }
  170. export function getTableCellsForVisualBounds(
  171. table: TableBlock,
  172. bounds: TableVisualCellPosition,
  173. ): TableCell[] {
  174. const positions = getTableVisualCellPositions(table);
  175. const cells: TableCell[] = [];
  176. for (const [key, position] of positions.entries()) {
  177. const intersects = position.rowStart <= bounds.rowEnd
  178. && position.rowEnd >= bounds.rowStart
  179. && position.colStart <= bounds.colEnd
  180. && position.colEnd >= bounds.colStart;
  181. if (!intersects) continue;
  182. const [rowIndex, cellIndex] = key.split('-').map(Number);
  183. const cell = table.content.rows[rowIndex]?.cells[cellIndex];
  184. if (cell) cells.push(cell);
  185. }
  186. return cells;
  187. }
  188. export function validateTableStructure(table: TableBlock): boolean {
  189. const columnCount = table.metadata?.cols;
  190. const rowCount = table.metadata?.rows;
  191. const rows = table.content?.rows;
  192. if (!Number.isInteger(columnCount) || columnCount <= 0 || columnCount > 1000) return false;
  193. if (!Number.isInteger(rowCount) || rowCount <= 0 || rowCount > 1000) return false;
  194. if (!Array.isArray(rows) || rows.length !== rowCount) return false;
  195. if (!Array.isArray(table.metadata.col_widths) || table.metadata.col_widths.length !== columnCount) {
  196. return false;
  197. }
  198. if (table.content.col_widths && table.content.col_widths.length !== columnCount) return false;
  199. if (table.metadata.col_widths.some((width) => !Number.isFinite(width) || width <= 0)) return false;
  200. if (table.content.col_widths?.some((width) => !Number.isFinite(width) || width <= 0)) return false;
  201. for (const row of rows) {
  202. if (!Array.isArray(row.cells) || row.cells.length > columnCount) return false;
  203. if (row.height !== undefined && (!Number.isFinite(row.height) || row.height <= 0)) return false;
  204. for (const cell of row.cells) {
  205. const rowspan = cell.rowspan;
  206. const colspan = cell.colspan;
  207. if (!Number.isInteger(rowspan) || !Number.isInteger(colspan)) return false;
  208. const isHidden = rowspan === 0 && colspan === 0;
  209. if (isHidden) {
  210. const colIndex = cell.col_index;
  211. if (typeof colIndex !== 'number' || !Number.isInteger(colIndex) || colIndex < 1 || colIndex > columnCount) {
  212. return false;
  213. }
  214. continue;
  215. }
  216. if (rowspan < 1 || rowspan > rowCount || colspan < 1 || colspan > columnCount) return false;
  217. if (cell.width !== undefined && (!Number.isFinite(cell.width) || cell.width <= 0)) return false;
  218. }
  219. }
  220. return [...getTableVisualCellPositions(table).values()].every((position) =>
  221. position.rowStart >= 0
  222. && position.rowEnd < rowCount
  223. && position.colStart >= 0
  224. && position.colEnd < columnCount
  225. );
  226. }
  227. function createHiddenCell(colIndex: number): TableCell {
  228. return {
  229. text: '',
  230. rowspan: 0,
  231. colspan: 0,
  232. col_index: colIndex + 1,
  233. style: {},
  234. word_style: 'Normal',
  235. width: 100,
  236. };
  237. }
  238. interface PositionedTableCell {
  239. cell: TableCell;
  240. position: TableVisualCellPosition;
  241. }
  242. function rebuildTableRows(
  243. cells: PositionedTableCell[],
  244. rowHeights: Array<number | undefined>,
  245. columnCount: number,
  246. ): TableRow[] {
  247. const starts = new Map<string, PositionedTableCell>();
  248. for (const entry of cells) {
  249. starts.set(`${entry.position.rowStart}-${entry.position.colStart}`, entry);
  250. }
  251. return rowHeights.map((height, rowIndex) => {
  252. const rowCells: TableCell[] = [];
  253. for (let colIndex = 0; colIndex < columnCount; colIndex += 1) {
  254. const start = starts.get(`${rowIndex}-${colIndex}`);
  255. if (start) {
  256. rowCells.push({
  257. ...start.cell,
  258. rowspan: start.position.rowEnd - start.position.rowStart + 1,
  259. colspan: start.position.colEnd - start.position.colStart + 1,
  260. col_index: colIndex + 1,
  261. });
  262. continue;
  263. }
  264. const covered = cells.some(({ position }) =>
  265. position.rowStart <= rowIndex
  266. && position.rowEnd >= rowIndex
  267. && position.colStart <= colIndex
  268. && position.colEnd >= colIndex
  269. );
  270. rowCells.push(covered ? createHiddenCell(colIndex) : createEmptyCell(colIndex + 1));
  271. }
  272. return {
  273. cells: rowCells,
  274. ...(height !== undefined ? { height } : {}),
  275. };
  276. });
  277. }
  278. export function getTableSelectionBounds(
  279. table: TableBlock,
  280. startRow: number,
  281. startCol: number,
  282. endRow: number,
  283. endCol: number,
  284. ): TableVisualCellPosition | null {
  285. if (![startRow, startCol, endRow, endCol].every(Number.isInteger)) return null;
  286. if (startRow < 0 || endRow < startRow || endRow >= table.content.rows.length) return null;
  287. const positions = getTableVisualCellPositions(table);
  288. const startPosition = positions.get(`${startRow}-${startCol}`);
  289. const endPosition = positions.get(`${endRow}-${endCol}`);
  290. if (!startPosition || !endPosition) return null;
  291. const bounds = {
  292. rowStart: Math.min(startPosition.rowStart, endPosition.rowStart),
  293. rowEnd: Math.max(startPosition.rowEnd, endPosition.rowEnd),
  294. colStart: Math.min(startPosition.colStart, endPosition.colStart),
  295. colEnd: Math.max(startPosition.colEnd, endPosition.colEnd),
  296. };
  297. for (const position of positions.values()) {
  298. const intersects = position.rowStart <= bounds.rowEnd
  299. && position.rowEnd >= bounds.rowStart
  300. && position.colStart <= bounds.colEnd
  301. && position.colEnd >= bounds.colStart;
  302. const isContained = position.rowStart >= bounds.rowStart
  303. && position.rowEnd <= bounds.rowEnd
  304. && position.colStart >= bounds.colStart
  305. && position.colEnd <= bounds.colEnd;
  306. if (intersects && !isContained) return null;
  307. }
  308. return bounds;
  309. }
  310. export function getTableCellRangeForVisualBounds(
  311. table: TableBlock,
  312. bounds: TableVisualCellPosition,
  313. ): { startRow: number; startCol: number; endRow: number; endCol: number } | null {
  314. const positions = getTableVisualCellPositions(table);
  315. let start: { row: number; col: number } | null = null;
  316. let end: { row: number; col: number } | null = null;
  317. for (const [key, position] of positions.entries()) {
  318. if (position.rowStart !== bounds.rowStart || position.colStart !== bounds.colStart) continue;
  319. const [row, col] = key.split('-').map(Number);
  320. start = { row, col };
  321. }
  322. for (const [key, position] of positions.entries()) {
  323. if (position.rowEnd !== bounds.rowEnd || position.colEnd !== bounds.colEnd) continue;
  324. const [row, col] = key.split('-').map(Number);
  325. end = { row, col };
  326. }
  327. if (!start || !end) return null;
  328. return { startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col };
  329. }
  330. /**
  331. * 将 text 字段从富文本数组转换为纯字符串
  332. *
  333. * @param text 文本内容(字符串或富文本数组)
  334. * @returns 纯字符串
  335. */
  336. export function flattenTextToString(text: string | RichText[]): string {
  337. if (typeof text === 'string') {
  338. return text;
  339. }
  340. // 如果是 RichText 数组,提取所有的纯文本并连接
  341. return text.map(seg => seg.text).join('');
  342. }
  343. /**
  344. * 序列化表格单元格为后端格式
  345. * 保留富文本数组,确保单元格内部格式可以跨保存恢复
  346. *
  347. * @param cell 前端单元格数据
  348. * @param colIndex 列索引(从1开始)
  349. * @param defaultWidth 默认宽度(磅)
  350. * @returns 序列化后的单元格(text保留字符串或富文本数组)
  351. */
  352. export function serializeTableCell(
  353. cell: TableCell,
  354. colIndex: number,
  355. defaultWidth: number = 100
  356. ): TableCell {
  357. // 构建样式对象,只包含有值的属性
  358. const style: TableCell['style'] = {};
  359. if (cell.style?.align) {
  360. style.align = cell.style.align;
  361. }
  362. if (cell.style?.font_size !== undefined && cell.style.font_size > 0) {
  363. style.font_size = cell.style.font_size;
  364. }
  365. if (cell.style?.font_name) {
  366. style.font_name = cell.style.font_name;
  367. }
  368. if (cell.style?.bold !== undefined) {
  369. style.bold = cell.style.bold;
  370. }
  371. if (cell.style?.italic !== undefined) {
  372. style.italic = cell.style.italic;
  373. }
  374. if (cell.style?.underline !== undefined) {
  375. style.underline = cell.style.underline;
  376. }
  377. if (cell.style?.color) {
  378. style.color = cell.style.color;
  379. }
  380. // 垂直对齐(表格专用)
  381. if (cell.style?.valign) {
  382. style.valign = cell.style.valign;
  383. }
  384. return {
  385. text: cell.text,
  386. rowspan: cell.rowspan ?? 1,
  387. colspan: cell.colspan ?? 1,
  388. col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
  389. style: style,
  390. word_style: cell.word_style || 'Normal',
  391. width: cell.width !== undefined ? cell.width : defaultWidth,
  392. };
  393. }
  394. /**
  395. * 序列化表格块为后端格式
  396. * 保留单元格富文本内容,并补齐后端所需的单元格字段
  397. *
  398. * @param table 表格块
  399. * @returns 序列化后的表格块
  400. */
  401. export function serializeTableBlock(table: TableBlock): TableBlock {
  402. // 从content.col_widths计算平均宽度作为默认值
  403. // 如果没有content.col_widths,从metadata.col_widths和table_width推算
  404. let avgWidth = 100;
  405. if (table.content.col_widths && table.content.col_widths.length > 0) {
  406. avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length;
  407. } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
  408. // 从百分比反推pt值
  409. const tableWidthPercent = table.metadata.table_width || 100;
  410. const pageWidthPt = 478; // A4宽度减边距
  411. const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
  412. avgWidth = tableActualWidthPt / table.metadata.col_widths.length;
  413. }
  414. const rows = table.content.rows.map((row) => {
  415. const serializedRow: TableRow = {
  416. cells: row.cells.map((cell, colIdx) =>
  417. serializeTableCell(cell, colIdx + 1, avgWidth)
  418. ),
  419. };
  420. // 只在有值时才添加height字段
  421. if (row.height !== undefined) {
  422. serializedRow.height = row.height;
  423. }
  424. return serializedRow;
  425. });
  426. return {
  427. ...table,
  428. content: {
  429. rows,
  430. // 如果content有col_widths,保留它
  431. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  432. },
  433. };
  434. }
  435. /**
  436. * 规范化表格单元格数据结构
  437. * 确保单元格包含所有必需字段,与后端期望的格式一致
  438. *
  439. * @param cell 原始单元格数据
  440. * @param colIndex 列索引(从1开始)
  441. * @param defaultWidth 默认宽度(磅)
  442. * @returns 规范化后的单元格
  443. */
  444. export function normalizeTableCell(
  445. cell: Partial<TableCell>,
  446. colIndex: number,
  447. defaultWidth: number = 100
  448. ): TableCell {
  449. // 构建样式对象,只包含有值的属性
  450. const style: TableCell['style'] = {};
  451. if (cell.style?.align) {
  452. style.align = cell.style.align;
  453. }
  454. if (cell.style?.font_size !== undefined && cell.style.font_size > 0) {
  455. style.font_size = cell.style.font_size;
  456. }
  457. if (cell.style?.font_name) {
  458. style.font_name = cell.style.font_name;
  459. }
  460. if (cell.style?.bold !== undefined) {
  461. style.bold = cell.style.bold;
  462. }
  463. if (cell.style?.italic !== undefined) {
  464. style.italic = cell.style.italic;
  465. }
  466. if (cell.style?.underline !== undefined) {
  467. style.underline = cell.style.underline;
  468. }
  469. if (cell.style?.color) {
  470. style.color = cell.style.color;
  471. }
  472. // 垂直对齐(表格专用)
  473. if (cell.style?.valign) {
  474. style.valign = cell.style.valign;
  475. }
  476. return {
  477. text: cell.text || '',
  478. rowspan: cell.rowspan ?? 1,
  479. colspan: cell.colspan ?? 1,
  480. col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
  481. style: style,
  482. word_style: cell.word_style || 'Normal',
  483. width: cell.width !== undefined ? cell.width : defaultWidth,
  484. };
  485. }
  486. /**
  487. * 规范化整个表格的数据结构
  488. * 确保所有单元格都包含完整的字段信息
  489. *
  490. * @param table 表格块
  491. * @returns 规范化后的表格块
  492. */
  493. export function normalizeTableBlock(table: TableBlock): TableBlock {
  494. // 从content.col_widths计算平均宽度作为默认值
  495. let avgWidth = 100;
  496. if (table.content.col_widths && table.content.col_widths.length > 0) {
  497. avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length;
  498. } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
  499. // 从百分比反推pt值
  500. const tableWidthPercent = table.metadata.table_width || 100;
  501. const pageWidthPt = 478; // A4宽度减边距
  502. const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
  503. avgWidth = tableActualWidthPt / table.metadata.col_widths.length;
  504. }
  505. const rows = table.content.rows.map((row) => {
  506. const normalizedRow: TableRow = {
  507. cells: row.cells.map((cell, colIdx) =>
  508. normalizeTableCell(cell, colIdx + 1, avgWidth)
  509. ),
  510. };
  511. // 只在有值时才添加height字段
  512. if (row.height !== undefined) {
  513. normalizedRow.height = row.height;
  514. }
  515. return normalizedRow;
  516. });
  517. return {
  518. ...table,
  519. content: {
  520. rows,
  521. // 如果content有col_widths,保留它
  522. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  523. },
  524. };
  525. }
  526. /**
  527. * 创建空的表格单元格
  528. *
  529. * @param colIndex 列索引(从1开始,可选)
  530. * @param width 单元格宽度(磅,可选)
  531. * @returns 空单元格,使用最小化的样式
  532. */
  533. export function createEmptyCell(colIndex?: number, width?: number): TableCell {
  534. return {
  535. text: '',
  536. rowspan: 1,
  537. colspan: 1,
  538. col_index: colIndex,
  539. style: {}, // 空样式对象,让后端或显示层使用默认值
  540. word_style: 'Normal',
  541. width: width !== undefined ? width : 100,
  542. };
  543. }
  544. /**
  545. * 创建空的表格行
  546. *
  547. * @param cols 列数
  548. * @param colWidths 列宽数组(磅,可选)
  549. * @param height 行高(磅,可选,默认58)
  550. * @returns 表格行
  551. */
  552. export function createEmptyRow(cols: number, colWidths?: number[], height?: number): TableRow {
  553. return {
  554. cells: Array(cols).fill(null).map((_, index) =>
  555. createEmptyCell(
  556. index + 1, // col_index从1开始
  557. colWidths?.[index]
  558. )
  559. ),
  560. height: height !== undefined ? height : 58, // 默认行高58磅
  561. };
  562. }
  563. /**
  564. * 在表格中插入行
  565. *
  566. * @param table 表格块
  567. * @param afterRow 在此行之后插入
  568. * @returns 新的表格块
  569. *
  570. * @example
  571. * ```ts
  572. * const newTable = insertTableRow(table, 1); // 在第2行后插入
  573. * ```
  574. */
  575. function insertTableRowAt(table: TableBlock, insertIndex: number): TableBlock {
  576. if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.content.rows.length) {
  577. throw new Error('行索引无效');
  578. }
  579. // 使用合理的默认行高
  580. const defaultHeight = 58; // 默认行高58磅
  581. // 计算列宽:从content.col_widths或从metadata推算
  582. let colWidths: number[];
  583. if (table.content.col_widths && table.content.col_widths.length > 0) {
  584. colWidths = table.content.col_widths;
  585. } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
  586. // 从百分比反推pt值
  587. const tableWidthPercent = table.metadata.table_width || 100;
  588. const pageWidthPt = 478; // A4宽度减边距
  589. const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
  590. colWidths = table.metadata.col_widths.map(percent =>
  591. (tableActualWidthPt * percent / 100)
  592. );
  593. } else {
  594. // 没有列宽信息,使用默认值
  595. colWidths = Array(table.metadata.cols).fill(100);
  596. }
  597. const newRow = createEmptyRow(table.metadata.cols, colWidths, defaultHeight);
  598. const positions = getTableVisualCellPositions(table);
  599. const rows = table.content.rows.map((row, rowIndex) => ({
  600. ...row,
  601. cells: row.cells.map((cell, cellIndex) => {
  602. const position = positions.get(`${rowIndex}-${cellIndex}`);
  603. if (!position || position.rowStart >= insertIndex || position.rowEnd < insertIndex) return cell;
  604. return { ...cell, rowspan: (cell.rowspan ?? 1) + 1 };
  605. }),
  606. }));
  607. newRow.cells = newRow.cells.map((cell, colIndex) => {
  608. const spanningCell = [...positions.values()].find((position) =>
  609. position.rowStart < insertIndex
  610. && position.rowEnd >= insertIndex
  611. && position.colStart <= colIndex
  612. && position.colEnd >= colIndex
  613. );
  614. return spanningCell ? createHiddenCell(colIndex) : cell;
  615. });
  616. rows.splice(insertIndex, 0, newRow);
  617. return {
  618. ...table,
  619. content: {
  620. rows,
  621. // 保留col_widths如果存在
  622. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  623. },
  624. metadata: {
  625. ...table.metadata,
  626. rows: rows.length,
  627. },
  628. };
  629. }
  630. export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
  631. assertTableIndex(afterRow, table.content.rows.length, '行');
  632. return insertTableRowAt(table, afterRow + 1);
  633. }
  634. /**
  635. * 在表格中插入行
  636. *
  637. * @param table 表格块
  638. * @param beforeRow 在此行之前插入
  639. * @returns 新的表格块
  640. */
  641. export function insertTableRowBefore(table: TableBlock, beforeRow: number): TableBlock {
  642. assertTableIndex(beforeRow, table.content.rows.length, '行');
  643. return insertTableRowAt(table, beforeRow);
  644. }
  645. /**
  646. * 在表格中插入列
  647. *
  648. * @param table 表格块
  649. * @param afterCol 在此列之后插入
  650. * @returns 新的表格块
  651. *
  652. * @example
  653. * ```ts
  654. * const newTable = insertTableColumn(table, 1); // 在第2列后插入
  655. * ```
  656. */
  657. function insertTableColumnAt(table: TableBlock, insertIndex: number): TableBlock {
  658. if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.metadata.cols) {
  659. throw new Error('列索引无效');
  660. }
  661. // 计算新列的宽度
  662. // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
  663. let newColWidth = 100; // 默认列宽100磅
  664. const adjacentCol = Math.min(insertIndex, Math.max(table.metadata.cols - 1, 0));
  665. if (table.content.col_widths && table.content.col_widths.length > adjacentCol) {
  666. newColWidth = table.content.col_widths[adjacentCol];
  667. } else if (table.metadata.col_widths && table.metadata.col_widths.length > adjacentCol) {
  668. // 从百分比反推pt值
  669. const tableWidthPercent = table.metadata.table_width || 100;
  670. const pageWidthPt = 478;
  671. const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
  672. newColWidth = tableActualWidthPt * table.metadata.col_widths[adjacentCol] / 100;
  673. }
  674. const positions = getTableVisualCellPositions(table);
  675. const positionedCells: PositionedTableCell[] = [];
  676. table.content.rows.forEach((row, rowIndex) => {
  677. row.cells.forEach((cell, cellIndex) => {
  678. const position = positions.get(`${rowIndex}-${cellIndex}`);
  679. if (!position) return;
  680. positionedCells.push({
  681. cell,
  682. position: position.colStart < insertIndex && position.colEnd >= insertIndex
  683. ? { ...position, colEnd: position.colEnd + 1 }
  684. : position.colStart >= insertIndex
  685. ? { ...position, colStart: position.colStart + 1, colEnd: position.colEnd + 1 }
  686. : position,
  687. });
  688. });
  689. });
  690. const rows = rebuildTableRows(
  691. positionedCells,
  692. table.content.rows.map((row) => row.height),
  693. table.metadata.cols + 1,
  694. );
  695. // 更新metadata.col_widths(百分比)
  696. const metadataColWidths = [...table.metadata.col_widths];
  697. // 新列使用相邻列的百分比,如果没有则平均分配
  698. const newColPercent = metadataColWidths[adjacentCol] || (100 / (metadataColWidths.length + 1));
  699. metadataColWidths.splice(insertIndex, 0, newColPercent);
  700. // 更新content.col_widths(pt单位)
  701. const contentColWidths = table.content.col_widths ? [...table.content.col_widths] : [];
  702. if (contentColWidths.length > 0) {
  703. contentColWidths.splice(insertIndex, 0, newColWidth);
  704. }
  705. return {
  706. ...table,
  707. content: {
  708. rows,
  709. ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {})
  710. },
  711. metadata: {
  712. ...table.metadata,
  713. cols: table.metadata.cols + 1,
  714. col_widths: metadataColWidths,
  715. },
  716. };
  717. }
  718. export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
  719. assertTableIndex(afterCol, table.metadata.cols, '列');
  720. return insertTableColumnAt(table, afterCol + 1);
  721. }
  722. /**
  723. * 在表格中插入列
  724. *
  725. * @param table 表格块
  726. * @param beforeCol 在此列之前插入
  727. * @returns 新的表格块
  728. */
  729. export function insertTableColumnBefore(table: TableBlock, beforeCol: number): TableBlock {
  730. assertTableIndex(beforeCol, table.metadata.cols, '列');
  731. return insertTableColumnAt(table, beforeCol);
  732. }
  733. /**
  734. * 删除表格行
  735. *
  736. * @param table 表格块
  737. * @param rowIndex 行索引
  738. * @returns 新的表格块
  739. */
  740. export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
  741. assertTableIndex(rowIndex, table.content.rows.length, '行');
  742. if (table.content.rows.length <= 1) {
  743. throw new Error('表格至少需要一行');
  744. }
  745. const positions = getTableVisualCellPositions(table);
  746. const positionedCells: PositionedTableCell[] = [];
  747. table.content.rows.forEach((row, sourceRow) => {
  748. row.cells.forEach((cell, sourceCol) => {
  749. const position = positions.get(`${sourceRow}-${sourceCol}`);
  750. if (!position) return;
  751. if (position.rowStart <= rowIndex && position.rowEnd >= rowIndex) {
  752. if (position.rowStart === position.rowEnd) return;
  753. positionedCells.push({
  754. cell,
  755. position: {
  756. ...position,
  757. rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
  758. rowEnd: position.rowEnd - 1,
  759. },
  760. });
  761. return;
  762. }
  763. positionedCells.push({
  764. cell,
  765. position: {
  766. ...position,
  767. rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
  768. rowEnd: position.rowEnd > rowIndex ? position.rowEnd - 1 : position.rowEnd,
  769. },
  770. });
  771. });
  772. });
  773. const rowHeights = table.content.rows
  774. .filter((_, index) => index !== rowIndex)
  775. .map((row) => row.height);
  776. const rows = rebuildTableRows(positionedCells, rowHeights, table.metadata.cols);
  777. return {
  778. ...table,
  779. content: {
  780. rows,
  781. // 保留col_widths如果存在
  782. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  783. },
  784. metadata: {
  785. ...table.metadata,
  786. rows: rows.length,
  787. },
  788. };
  789. }
  790. /**
  791. * 删除表格列
  792. *
  793. * @param table 表格块
  794. * @param colIndex 列索引
  795. * @returns 新的表格块
  796. */
  797. export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
  798. assertTableIndex(colIndex, table.metadata.cols, '列');
  799. if (table.metadata.cols <= 1) {
  800. throw new Error('表格至少需要一列');
  801. }
  802. const positions = getTableVisualCellPositions(table);
  803. const positionedCells: PositionedTableCell[] = [];
  804. table.content.rows.forEach((row, sourceRow) => {
  805. row.cells.forEach((cell, sourceCol) => {
  806. const position = positions.get(`${sourceRow}-${sourceCol}`);
  807. if (!position) return;
  808. if (position.colStart <= colIndex && position.colEnd >= colIndex) {
  809. if (position.colStart === position.colEnd) return;
  810. positionedCells.push({
  811. cell,
  812. position: {
  813. ...position,
  814. colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
  815. colEnd: position.colEnd - 1,
  816. },
  817. });
  818. return;
  819. }
  820. positionedCells.push({
  821. cell,
  822. position: {
  823. ...position,
  824. colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
  825. colEnd: position.colEnd > colIndex ? position.colEnd - 1 : position.colEnd,
  826. },
  827. });
  828. });
  829. });
  830. const rows = rebuildTableRows(
  831. positionedCells,
  832. table.content.rows.map((row) => row.height),
  833. table.metadata.cols - 1,
  834. );
  835. // 更新metadata.col_widths
  836. const metadataColWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex);
  837. // 更新content.col_widths
  838. const contentColWidths = table.content.col_widths
  839. ? table.content.col_widths.filter((_, i) => i !== colIndex)
  840. : [];
  841. return {
  842. ...table,
  843. content: {
  844. rows,
  845. ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {})
  846. },
  847. metadata: {
  848. ...table.metadata,
  849. cols: table.metadata.cols - 1,
  850. col_widths: metadataColWidths,
  851. },
  852. };
  853. }
  854. /**
  855. * 合并单元格
  856. *
  857. * 支持横向合并(colspan)和纵向合并(rowspan)
  858. * 合并后的主单元格会保存所有被合并单元格的文本内容(用空格连接)
  859. * 被合并的单元格会被标记为隐藏(rowspan=0, colspan=0)
  860. *
  861. * 重要:与后端逻辑保持一致
  862. * - 主单元格(左上角)保留完整的字段信息
  863. * - 被合并的单元格标记为rowspan=0, colspan=0, text=''
  864. * - 保留所有单元格的col_index
  865. * - 合并后的文本在包含富文本时保留片段样式
  866. *
  867. * @param table 表格块
  868. * @param startRow 起始行
  869. * @param startCol 起始列
  870. * @param endRow 结束行
  871. * @param endCol 结束列
  872. * @returns 新的表格块
  873. *
  874. * @example
  875. * ```ts
  876. * // 横向合并: a + b (第0行,第0-1列)
  877. * mergeCells(table, 0, 0, 0, 1);
  878. * // 结果: a单元格 colspan=2, text包含a和b的内容
  879. *
  880. * // 纵向合并: a + d (第0-1行,第0列)
  881. * mergeCells(table, 0, 0, 1, 0);
  882. * // 结果: a单元格 rowspan=2, text包含a和d的内容
  883. * ```
  884. */
  885. export function mergeCells(
  886. table: TableBlock,
  887. startRow: number,
  888. startCol: number,
  889. endRow: number,
  890. endCol: number
  891. ): TableBlock {
  892. assertTableRange(table, startRow, startCol, endRow, endCol);
  893. const selectionBounds = getTableSelectionBounds(table, startRow, startCol, endRow, endCol);
  894. if (!selectionBounds) {
  895. throw new Error('合并范围不能截断已有合并单元格');
  896. }
  897. return mergeCellsByVisualBounds(table, selectionBounds);
  898. }
  899. /**
  900. * 按视觉网格边界合并单元格。
  901. *
  902. * 视觉坐标与行内 cells 数组下标不是同一个坐标系,尤其在跨行单元格
  903. * 产生隐藏占位时,不能先把视觉范围当作数组范围再次校验。
  904. */
  905. export function mergeCellsByVisualBounds(
  906. table: TableBlock,
  907. selectionBounds: TableVisualCellPosition,
  908. ): TableBlock {
  909. if (selectionBounds.rowStart > selectionBounds.rowEnd) {
  910. throw new Error('行范围无效');
  911. }
  912. if (selectionBounds.colStart > selectionBounds.colEnd) {
  913. throw new Error('列范围无效');
  914. }
  915. const visualPositions = getTableVisualCellPositions(table);
  916. const visualColumnEnd = Math.max(
  917. table.metadata.cols - 1,
  918. ...[...visualPositions.values()].map((position) => position.colEnd),
  919. );
  920. const visualRowEnd = Math.max(
  921. table.content.rows.length - 1,
  922. ...[...visualPositions.values()].map((position) => position.rowEnd),
  923. );
  924. if (
  925. selectionBounds.rowStart < 0
  926. || selectionBounds.rowEnd > visualRowEnd
  927. || selectionBounds.colStart < 0
  928. || selectionBounds.colEnd > visualColumnEnd
  929. ) {
  930. throw new Error('结束索引无效');
  931. }
  932. for (const position of visualPositions.values()) {
  933. const intersects = position.rowStart <= selectionBounds.rowEnd
  934. && position.rowEnd >= selectionBounds.rowStart
  935. && position.colStart <= selectionBounds.colEnd
  936. && position.colEnd >= selectionBounds.colStart;
  937. const isContained = position.rowStart >= selectionBounds.rowStart
  938. && position.rowEnd <= selectionBounds.rowEnd
  939. && position.colStart >= selectionBounds.colStart
  940. && position.colEnd <= selectionBounds.colEnd;
  941. if (intersects && !isContained) {
  942. throw new Error('合并范围不能截断已有合并单元格');
  943. }
  944. }
  945. const primaryEntry = [...visualPositions.entries()].find(([, position]) =>
  946. position.rowStart === selectionBounds.rowStart && position.colStart === selectionBounds.colStart
  947. );
  948. if (!primaryEntry) {
  949. throw new Error('合并起始单元格不存在');
  950. }
  951. const primaryKey = primaryEntry[0];
  952. // 计算合并范围
  953. const rowSpan = selectionBounds.rowEnd - selectionBounds.rowStart + 1;
  954. const colSpan = selectionBounds.colEnd - selectionBounds.colStart + 1;
  955. // 收集所有被合并单元格的文本内容
  956. const displayTexts: string[] = [];
  957. const mergedRichText: RichText[] = [];
  958. let hasRichText = false;
  959. // 辅助函数:将 text 字段规范化为纯字符串
  960. const normalizeText = (text: string | RichText[]): string => {
  961. if (typeof text === 'string') {
  962. return text;
  963. }
  964. // 如果是 RichText 数组,提取所有的纯文本
  965. return text.map(seg => seg.text).join('');
  966. };
  967. // 收集文本
  968. table.content.rows.forEach((row, rowIdx) => {
  969. row.cells.forEach((cell, colIdx) => {
  970. const position = visualPositions.get(`${rowIdx}-${colIdx}`);
  971. if (!position) return;
  972. const isContained = position.rowStart >= selectionBounds.rowStart
  973. && position.rowEnd <= selectionBounds.rowEnd
  974. && position.colStart >= selectionBounds.colStart
  975. && position.colEnd <= selectionBounds.colEnd;
  976. if (isContained) {
  977. const text = normalizeText(cell.text);
  978. if (text.trim()) {
  979. displayTexts.push(text.trim());
  980. if (Array.isArray(cell.text)) {
  981. if (!hasRichText) {
  982. displayTexts.slice(0, -1).forEach((previousText) => {
  983. if (mergedRichText.length > 0) {
  984. mergedRichText.push({ text: ' ', style: {} });
  985. }
  986. mergedRichText.push({ text: previousText, style: {} });
  987. });
  988. }
  989. hasRichText = true;
  990. if (mergedRichText.length > 0) {
  991. mergedRichText.push({ text: ' ', style: {} });
  992. }
  993. mergedRichText.push(...cell.text);
  994. } else if (hasRichText) {
  995. if (mergedRichText.length > 0) {
  996. mergedRichText.push({ text: ' ', style: {} });
  997. }
  998. mergedRichText.push({ text: text.trim(), style: {} });
  999. }
  1000. }
  1001. }
  1002. });
  1003. });
  1004. const rows = table.content.rows.map((row, rowIdx) => {
  1005. const cells = row.cells.map((cell, colIdx) => {
  1006. const key = `${rowIdx}-${colIdx}`;
  1007. const position = visualPositions.get(key);
  1008. if (!position) return cell;
  1009. const isContained = position.rowStart >= selectionBounds.rowStart
  1010. && position.rowEnd <= selectionBounds.rowEnd
  1011. && position.colStart >= selectionBounds.colStart
  1012. && position.colEnd <= selectionBounds.colEnd;
  1013. if (!isContained) return cell;
  1014. if (key === primaryKey) {
  1015. // 主单元格:设置完整的标准格式
  1016. // 合并后的文本在包含富文本时保留片段样式
  1017. const mergedText = displayTexts.join(' ');
  1018. const mergedContent = hasRichText ? mergedRichText : mergedText;
  1019. // 构建标准格式的单元格对象
  1020. return {
  1021. text: mergedContent.length > 0 ? mergedContent : normalizeText(cell.text),
  1022. rowspan: rowSpan,
  1023. colspan: colSpan,
  1024. col_index: colIdx + 1, // 列索引从1开始
  1025. style: {
  1026. ...cell.style, // 保留原有样式
  1027. },
  1028. word_style: cell.word_style || 'Normal',
  1029. width: cell.width !== undefined ? cell.width : 100,
  1030. };
  1031. }
  1032. // 被合并的单元格,标记为隐藏
  1033. return {
  1034. text: '', // 清空显示内容
  1035. rowspan: 0,
  1036. colspan: 0,
  1037. col_index: colIdx + 1, // 保持列索引
  1038. style: cell.style || {},
  1039. word_style: cell.word_style || 'Normal',
  1040. width: cell.width,
  1041. };
  1042. });
  1043. return {
  1044. cells,
  1045. height: row.height, // 保留行高
  1046. };
  1047. });
  1048. return {
  1049. ...table,
  1050. content: {
  1051. rows,
  1052. // 保留col_widths如果存在
  1053. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  1054. },
  1055. };
  1056. }
  1057. /**
  1058. * 拆分单元格
  1059. *
  1060. * 将已合并的单元格拆分回独立单元格
  1061. * 主单元格保留原有内容,其他单元格恢复为空单元格
  1062. *
  1063. * 关键逻辑:
  1064. * 1. 对于横向合并(colspan>1),需要在当前行**插入**新的单元格
  1065. * 2. 对于纵向合并(rowspan>1),需要恢复被隐藏的单元格(rowspan=0, colspan=0)
  1066. * 3. 对于同时横向和纵向合并,需要同时处理插入和恢复
  1067. *
  1068. * 与后端逻辑保持一致:
  1069. * - 主单元格保留原有text和样式
  1070. * - 新单元格使用空text和默认样式
  1071. * - 保留所有单元格的width
  1072. * - 正确更新col_index
  1073. *
  1074. * @param table 表格块
  1075. * @param rowIndex 单元格所在行
  1076. * @param colIndex 单元格所在列
  1077. * @returns 新的表格块
  1078. *
  1079. * @example
  1080. * ```ts
  1081. * // 拆分横向合并的单元格 (a+b)
  1082. * splitCell(table, 0, 0);
  1083. * // 结果: a单元格保留内容,b单元格变为空单元格
  1084. *
  1085. * // 拆分纵向合并的单元格 (a+d)
  1086. * splitCell(table, 0, 0);
  1087. * // 结果: a单元格保留内容,d单元格变为空单元格
  1088. * ```
  1089. */
  1090. export function splitCell(
  1091. table: TableBlock,
  1092. rowIndex: number,
  1093. colIndex: number
  1094. ): TableBlock {
  1095. assertTableIndex(rowIndex, table.content.rows.length, '行');
  1096. assertTableIndex(colIndex, table.metadata.cols, '列');
  1097. const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
  1098. if (!targetCell) {
  1099. throw new Error('单元格不存在');
  1100. }
  1101. // 如果单元格没有合并,无需拆分
  1102. if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
  1103. throw new Error('此单元格未合并,无需拆分');
  1104. }
  1105. const positions = getTableVisualCellPositions(table);
  1106. const targetPosition = positions.get(`${rowIndex}-${colIndex}`);
  1107. if (!targetPosition) {
  1108. throw new Error('合并单元格位置无效');
  1109. }
  1110. const positionedCells: PositionedTableCell[] = [];
  1111. for (const [key, position] of positions.entries()) {
  1112. const [currentRow, currentCol] = key.split('-').map(Number);
  1113. const cell = table.content.rows[currentRow]?.cells[currentCol];
  1114. if (!cell) continue;
  1115. if (currentRow !== rowIndex || currentCol !== colIndex) {
  1116. positionedCells.push({ cell, position });
  1117. continue;
  1118. }
  1119. for (let splitRow = targetPosition.rowStart; splitRow <= targetPosition.rowEnd; splitRow += 1) {
  1120. for (let splitCol = targetPosition.colStart; splitCol <= targetPosition.colEnd; splitCol += 1) {
  1121. const isPrimary = splitRow === targetPosition.rowStart && splitCol === targetPosition.colStart;
  1122. positionedCells.push({
  1123. cell: isPrimary
  1124. ? {
  1125. ...cell,
  1126. rowspan: 1,
  1127. colspan: 1,
  1128. col_index: splitCol + 1,
  1129. word_style: cell.word_style || 'Normal',
  1130. }
  1131. : createEmptyCell(splitCol + 1, cell.width || 100),
  1132. position: {
  1133. rowStart: splitRow,
  1134. rowEnd: splitRow,
  1135. colStart: splitCol,
  1136. colEnd: splitCol,
  1137. },
  1138. });
  1139. }
  1140. }
  1141. }
  1142. const rows = rebuildTableRows(
  1143. positionedCells,
  1144. table.content.rows.map((row) => row.height),
  1145. table.metadata.cols,
  1146. );
  1147. return {
  1148. ...table,
  1149. content: {
  1150. rows,
  1151. // 保留col_widths如果存在
  1152. ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
  1153. },
  1154. };
  1155. }
  1156. // ══════════════════════════════════════════════════════════════════════════════
  1157. // Block Validation
  1158. // ══════════════════════════════════════════════════════════════════════════════
  1159. /**
  1160. * 验证块数据是否有效
  1161. *
  1162. * @param block 块数据
  1163. * @returns 是否有效
  1164. */
  1165. export function validateBlock(block: Partial<DocumentBlock>): boolean {
  1166. if (!block.type) return false;
  1167. if (block.block_order === undefined) return false;
  1168. switch (block.type) {
  1169. case 'heading':
  1170. return (
  1171. typeof block.level === 'number' &&
  1172. block.level >= 1 &&
  1173. block.level <= 6
  1174. );
  1175. case 'paragraph':
  1176. return true;
  1177. case 'table': {
  1178. const table = block as Partial<TableBlock>;
  1179. return !!(
  1180. table.metadata &&
  1181. table.content &&
  1182. validateTableStructure(table as TableBlock)
  1183. );
  1184. }
  1185. case 'image':
  1186. return !!(block.content && typeof block.content === 'string');
  1187. case 'toc':
  1188. return true; // TOC块总是有效的
  1189. default:
  1190. return false;
  1191. }
  1192. }
  1193. // ══════════════════════════════════════════════════════════════════════════════
  1194. // Block Search and Filter
  1195. // ══════════════════════════════════════════════════════════════════════════════
  1196. /**
  1197. * 在blocks中搜索文本
  1198. *
  1199. * @param blocks 块数组
  1200. * @param query 搜索关键词
  1201. * @returns 匹配的块ID数组
  1202. */
  1203. export function searchBlocks(blocks: DocumentBlock[], query: string): string[] {
  1204. const lowerQuery = query.toLowerCase();
  1205. return blocks
  1206. .filter((block) => {
  1207. switch (block.type) {
  1208. case 'heading':
  1209. case 'paragraph': {
  1210. const content =
  1211. typeof block.content === 'string'
  1212. ? block.content
  1213. : block.content.map((seg) => seg.text).join('');
  1214. return content.toLowerCase().includes(lowerQuery);
  1215. }
  1216. case 'table': {
  1217. return block.content.rows.some((row) =>
  1218. row.cells.some((cell) => {
  1219. const text =
  1220. typeof cell.text === 'string'
  1221. ? cell.text
  1222. : cell.text.map((seg) => seg.text).join('');
  1223. return text.toLowerCase().includes(lowerQuery);
  1224. })
  1225. );
  1226. }
  1227. case 'image': {
  1228. return block.metadata.alt?.toLowerCase().includes(lowerQuery);
  1229. }
  1230. case 'toc': {
  1231. // 搜索TOC标题
  1232. return block.content.title?.toLowerCase().includes(lowerQuery);
  1233. }
  1234. default:
  1235. return false;
  1236. }
  1237. })
  1238. .map((block) => block.id);
  1239. }
  1240. /**
  1241. * 获取指定类型的所有块
  1242. *
  1243. * @param blocks 块数组
  1244. * @param type 块类型
  1245. * @returns 匹配类型的块数组
  1246. */
  1247. export function filterBlocksByType<T extends DocumentBlock>(
  1248. blocks: DocumentBlock[],
  1249. type: BlockType
  1250. ): T[] {
  1251. return blocks.filter((block) => block.type === type) as T[];
  1252. }