| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402 |
- /**
- * blockOperations.ts - Block操作工具函数
- *
- * 提供block的各种操作辅助函数
- *
- * @module utils/blockOperations
- */
- import type {
- DocumentBlock,
- BlockType,
- TableBlock,
- TableRow,
- TableCell,
- RichText,
- } from '../types/editor';
- // ══════════════════════════════════════════════════════════════════════════════
- // Block ID Generation
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * 生成块ID
- *
- * @param type 块类型
- * @param index 索引(可选)
- * @returns 块ID
- *
- * @example
- * ```ts
- * generateBlockId('heading') // "block-h-1704096000000-0"
- * ```
- */
- export function generateBlockId(type: BlockType, index: number = 0): string {
- const typePrefix: Record<BlockType, string> = {
- heading: 'h',
- paragraph: 'p',
- table: 'tbl',
- image: 'img',
- toc: 'toc',
- };
- return `block-${typePrefix[type]}-${Date.now()}-${index}`;
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Block Order Operations
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * 计算插入位置的block_order
- * 稀疏排序策略:在两个块之间找到中间值
- *
- * @param prevOrder 前一个块的order
- * @param nextOrder 后一个块的order
- * @returns 新的order,如果返回-1表示需要重排
- *
- * @example
- * ```ts
- * computeInsertOrder(100, 200) // 150
- * computeInsertOrder(100, 101) // -1 (需要重排)
- * ```
- */
- export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
- const gap = nextOrder - prevOrder;
-
- if (gap > 1) {
- // 有间隙,直接取中间值
- return Math.floor((prevOrder + nextOrder) / 2);
- }
-
- // 间隙不足,需要重排
- return -1;
- }
- /**
- * 重新平衡block_order(稀疏排序,间隔100)
- *
- * @param blocks 块数组
- * @returns 重新排序后的块数组
- *
- * @example
- * ```ts
- * const rebalanced = rebalanceBlockOrders(blocks);
- * // blocks[0].block_order = 0
- * // blocks[1].block_order = 100
- * // blocks[2].block_order = 200
- * ```
- */
- export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
- const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
- return sorted.map((block, index) => ({
- ...block,
- block_order: index * 100,
- }));
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Table Operations
- // ══════════════════════════════════════════════════════════════════════════════
- function assertTableIndex(index: number, length: number, label: string): void {
- if (!Number.isInteger(index) || index < 0 || index >= length) {
- throw new Error(`${label}索引无效`);
- }
- }
- function assertTableRange(
- table: TableBlock,
- startRow: number,
- startCol: number,
- endRow: number,
- endCol: number,
- ): void {
- assertTableIndex(startRow, table.content.rows.length, '起始行');
- assertTableIndex(endRow, table.content.rows.length, '结束行');
- if (startRow > endRow) throw new Error('行范围无效');
- assertTableIndex(startCol, table.metadata.cols, '起始列');
- assertTableIndex(endCol, table.metadata.cols, '结束列');
- if (startCol > endCol) throw new Error('列范围无效');
- const selectedRows = table.content.rows.slice(startRow, endRow + 1);
- if (selectedRows.some((row) => row.cells.length <= endCol)) {
- throw new Error('表格单元格结构无效');
- }
- }
- export interface TableVisualCellPosition {
- rowStart: number;
- rowEnd: number;
- colStart: number;
- colEnd: number;
- }
- export interface TableCellReference {
- rowIndex: number;
- cellIndex: number;
- visual: TableVisualCellPosition;
- }
- export function getTableVisualCellPositions(table: TableBlock): Map<string, TableVisualCellPosition> {
- const occupied: boolean[][] = [];
- const positions = new Map<string, TableVisualCellPosition>();
- table.content.rows.forEach((row, rowIndex) => {
- if (!occupied[rowIndex]) occupied[rowIndex] = [];
- let visualCol = 0;
- row.cells.forEach((cell, cellIndex) => {
- const rowSpan = cell.rowspan ?? 1;
- const colSpan = cell.colspan ?? 1;
- if (rowSpan <= 0 || colSpan <= 0) {
- const hiddenCol = Number.isInteger(cell.col_index) && (cell.col_index ?? 0) > 0
- ? (cell.col_index ?? 1) - 1
- : visualCol;
- visualCol = Math.max(visualCol, hiddenCol + 1);
- return;
- }
- while (occupied[rowIndex][visualCol]) visualCol += 1;
- const position = {
- rowStart: rowIndex,
- rowEnd: rowIndex + rowSpan - 1,
- colStart: visualCol,
- colEnd: visualCol + colSpan - 1,
- };
- positions.set(`${rowIndex}-${cellIndex}`, position);
- for (let occupiedRow = position.rowStart; occupiedRow <= position.rowEnd; occupiedRow += 1) {
- if (!occupied[occupiedRow]) occupied[occupiedRow] = [];
- for (let occupiedCol = position.colStart; occupiedCol <= position.colEnd; occupiedCol += 1) {
- occupied[occupiedRow][occupiedCol] = true;
- }
- }
- visualCol = position.colEnd + 1;
- });
- });
- return positions;
- }
- export function getTableCellReference(
- table: TableBlock,
- rowIndex: number,
- cellIndex: number,
- positions: Map<string, TableVisualCellPosition> = getTableVisualCellPositions(table),
- ): TableCellReference | null {
- const visual = positions.get(`${rowIndex}-${cellIndex}`);
- if (!visual) return null;
- return { rowIndex, cellIndex, visual };
- }
- export function getTableCellsForVisualBounds(
- table: TableBlock,
- bounds: TableVisualCellPosition,
- ): TableCell[] {
- const positions = getTableVisualCellPositions(table);
- const cells: TableCell[] = [];
- for (const [key, position] of positions.entries()) {
- const intersects = position.rowStart <= bounds.rowEnd
- && position.rowEnd >= bounds.rowStart
- && position.colStart <= bounds.colEnd
- && position.colEnd >= bounds.colStart;
- if (!intersects) continue;
- const [rowIndex, cellIndex] = key.split('-').map(Number);
- const cell = table.content.rows[rowIndex]?.cells[cellIndex];
- if (cell) cells.push(cell);
- }
- return cells;
- }
- export function validateTableStructure(table: TableBlock): boolean {
- const columnCount = table.metadata?.cols;
- const rowCount = table.metadata?.rows;
- const rows = table.content?.rows;
- if (!Number.isInteger(columnCount) || columnCount <= 0 || columnCount > 1000) return false;
- if (!Number.isInteger(rowCount) || rowCount <= 0 || rowCount > 1000) return false;
- if (!Array.isArray(rows) || rows.length !== rowCount) return false;
- if (!Array.isArray(table.metadata.col_widths) || table.metadata.col_widths.length !== columnCount) {
- return false;
- }
- if (table.content.col_widths && table.content.col_widths.length !== columnCount) return false;
- if (table.metadata.col_widths.some((width) => !Number.isFinite(width) || width <= 0)) return false;
- if (table.content.col_widths?.some((width) => !Number.isFinite(width) || width <= 0)) return false;
- for (const row of rows) {
- if (!Array.isArray(row.cells) || row.cells.length > columnCount) return false;
- if (row.height !== undefined && (!Number.isFinite(row.height) || row.height <= 0)) return false;
- for (const cell of row.cells) {
- const rowspan = cell.rowspan;
- const colspan = cell.colspan;
- if (!Number.isInteger(rowspan) || !Number.isInteger(colspan)) return false;
- const isHidden = rowspan === 0 && colspan === 0;
- if (isHidden) {
- const colIndex = cell.col_index;
- if (typeof colIndex !== 'number' || !Number.isInteger(colIndex) || colIndex < 1 || colIndex > columnCount) {
- return false;
- }
- continue;
- }
- if (rowspan < 1 || rowspan > rowCount || colspan < 1 || colspan > columnCount) return false;
- if (cell.width !== undefined && (!Number.isFinite(cell.width) || cell.width <= 0)) return false;
- }
- }
- return [...getTableVisualCellPositions(table).values()].every((position) =>
- position.rowStart >= 0
- && position.rowEnd < rowCount
- && position.colStart >= 0
- && position.colEnd < columnCount
- );
- }
- function createHiddenCell(colIndex: number): TableCell {
- return {
- text: '',
- rowspan: 0,
- colspan: 0,
- col_index: colIndex + 1,
- style: {},
- word_style: 'Normal',
- width: 100,
- };
- }
- interface PositionedTableCell {
- cell: TableCell;
- position: TableVisualCellPosition;
- }
- function rebuildTableRows(
- cells: PositionedTableCell[],
- rowHeights: Array<number | undefined>,
- columnCount: number,
- ): TableRow[] {
- const starts = new Map<string, PositionedTableCell>();
- for (const entry of cells) {
- starts.set(`${entry.position.rowStart}-${entry.position.colStart}`, entry);
- }
- return rowHeights.map((height, rowIndex) => {
- const rowCells: TableCell[] = [];
- for (let colIndex = 0; colIndex < columnCount; colIndex += 1) {
- const start = starts.get(`${rowIndex}-${colIndex}`);
- if (start) {
- rowCells.push({
- ...start.cell,
- rowspan: start.position.rowEnd - start.position.rowStart + 1,
- colspan: start.position.colEnd - start.position.colStart + 1,
- col_index: colIndex + 1,
- });
- continue;
- }
- const covered = cells.some(({ position }) =>
- position.rowStart <= rowIndex
- && position.rowEnd >= rowIndex
- && position.colStart <= colIndex
- && position.colEnd >= colIndex
- );
- rowCells.push(covered ? createHiddenCell(colIndex) : createEmptyCell(colIndex + 1));
- }
- return {
- cells: rowCells,
- ...(height !== undefined ? { height } : {}),
- };
- });
- }
- export function getTableSelectionBounds(
- table: TableBlock,
- startRow: number,
- startCol: number,
- endRow: number,
- endCol: number,
- ): TableVisualCellPosition | null {
- if (![startRow, startCol, endRow, endCol].every(Number.isInteger)) return null;
- if (startRow < 0 || endRow < startRow || endRow >= table.content.rows.length) return null;
- const positions = getTableVisualCellPositions(table);
- const startPosition = positions.get(`${startRow}-${startCol}`);
- const endPosition = positions.get(`${endRow}-${endCol}`);
- if (!startPosition || !endPosition) return null;
- const bounds = {
- rowStart: Math.min(startPosition.rowStart, endPosition.rowStart),
- rowEnd: Math.max(startPosition.rowEnd, endPosition.rowEnd),
- colStart: Math.min(startPosition.colStart, endPosition.colStart),
- colEnd: Math.max(startPosition.colEnd, endPosition.colEnd),
- };
- for (const position of positions.values()) {
- const intersects = position.rowStart <= bounds.rowEnd
- && position.rowEnd >= bounds.rowStart
- && position.colStart <= bounds.colEnd
- && position.colEnd >= bounds.colStart;
- const isContained = position.rowStart >= bounds.rowStart
- && position.rowEnd <= bounds.rowEnd
- && position.colStart >= bounds.colStart
- && position.colEnd <= bounds.colEnd;
- if (intersects && !isContained) return null;
- }
- return bounds;
- }
- export function getTableCellRangeForVisualBounds(
- table: TableBlock,
- bounds: TableVisualCellPosition,
- ): { startRow: number; startCol: number; endRow: number; endCol: number } | null {
- const positions = getTableVisualCellPositions(table);
- let start: { row: number; col: number } | null = null;
- let end: { row: number; col: number } | null = null;
- for (const [key, position] of positions.entries()) {
- if (position.rowStart !== bounds.rowStart || position.colStart !== bounds.colStart) continue;
- const [row, col] = key.split('-').map(Number);
- start = { row, col };
- }
- for (const [key, position] of positions.entries()) {
- if (position.rowEnd !== bounds.rowEnd || position.colEnd !== bounds.colEnd) continue;
- const [row, col] = key.split('-').map(Number);
- end = { row, col };
- }
- if (!start || !end) return null;
- return { startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col };
- }
- /**
- * 将 text 字段从富文本数组转换为纯字符串
- *
- * @param text 文本内容(字符串或富文本数组)
- * @returns 纯字符串
- */
- export function flattenTextToString(text: string | RichText[]): string {
- if (typeof text === 'string') {
- return text;
- }
- // 如果是 RichText 数组,提取所有的纯文本并连接
- return text.map(seg => seg.text).join('');
- }
- /**
- * 序列化表格单元格为后端格式
- * 保留富文本数组,确保单元格内部格式可以跨保存恢复
- *
- * @param cell 前端单元格数据
- * @param colIndex 列索引(从1开始)
- * @param defaultWidth 默认宽度(磅)
- * @returns 序列化后的单元格(text保留字符串或富文本数组)
- */
- export function serializeTableCell(
- cell: TableCell,
- colIndex: number,
- defaultWidth: number = 100
- ): TableCell {
- // 构建样式对象,只包含有值的属性
- const style: TableCell['style'] = {};
-
- if (cell.style?.align) {
- style.align = cell.style.align;
- }
-
- if (cell.style?.font_size !== undefined && cell.style.font_size > 0) {
- style.font_size = cell.style.font_size;
- }
-
- if (cell.style?.font_name) {
- style.font_name = cell.style.font_name;
- }
-
- if (cell.style?.bold !== undefined) {
- style.bold = cell.style.bold;
- }
-
- if (cell.style?.italic !== undefined) {
- style.italic = cell.style.italic;
- }
-
- if (cell.style?.underline !== undefined) {
- style.underline = cell.style.underline;
- }
-
- if (cell.style?.color) {
- style.color = cell.style.color;
- }
-
- // 垂直对齐(表格专用)
- if (cell.style?.valign) {
- style.valign = cell.style.valign;
- }
-
- return {
- text: cell.text,
- rowspan: cell.rowspan ?? 1,
- colspan: cell.colspan ?? 1,
- col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
- style: style,
- word_style: cell.word_style || 'Normal',
- width: cell.width !== undefined ? cell.width : defaultWidth,
- };
- }
- /**
- * 序列化表格块为后端格式
- * 保留单元格富文本内容,并补齐后端所需的单元格字段
- *
- * @param table 表格块
- * @returns 序列化后的表格块
- */
- export function serializeTableBlock(table: TableBlock): TableBlock {
- // 从content.col_widths计算平均宽度作为默认值
- // 如果没有content.col_widths,从metadata.col_widths和table_width推算
- let avgWidth = 100;
-
- if (table.content.col_widths && table.content.col_widths.length > 0) {
- avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length;
- } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
- // 从百分比反推pt值
- const tableWidthPercent = table.metadata.table_width || 100;
- const pageWidthPt = 478; // A4宽度减边距
- const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
- avgWidth = tableActualWidthPt / table.metadata.col_widths.length;
- }
- const rows = table.content.rows.map((row) => {
- const serializedRow: TableRow = {
- cells: row.cells.map((cell, colIdx) =>
- serializeTableCell(cell, colIdx + 1, avgWidth)
- ),
- };
-
- // 只在有值时才添加height字段
- if (row.height !== undefined) {
- serializedRow.height = row.height;
- }
-
- return serializedRow;
- });
- return {
- ...table,
- content: {
- rows,
- // 如果content有col_widths,保留它
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- };
- }
- /**
- * 规范化表格单元格数据结构
- * 确保单元格包含所有必需字段,与后端期望的格式一致
- *
- * @param cell 原始单元格数据
- * @param colIndex 列索引(从1开始)
- * @param defaultWidth 默认宽度(磅)
- * @returns 规范化后的单元格
- */
- export function normalizeTableCell(
- cell: Partial<TableCell>,
- colIndex: number,
- defaultWidth: number = 100
- ): TableCell {
- // 构建样式对象,只包含有值的属性
- const style: TableCell['style'] = {};
-
- if (cell.style?.align) {
- style.align = cell.style.align;
- }
-
- if (cell.style?.font_size !== undefined && cell.style.font_size > 0) {
- style.font_size = cell.style.font_size;
- }
-
- if (cell.style?.font_name) {
- style.font_name = cell.style.font_name;
- }
-
- if (cell.style?.bold !== undefined) {
- style.bold = cell.style.bold;
- }
-
- if (cell.style?.italic !== undefined) {
- style.italic = cell.style.italic;
- }
-
- if (cell.style?.underline !== undefined) {
- style.underline = cell.style.underline;
- }
-
- if (cell.style?.color) {
- style.color = cell.style.color;
- }
-
- // 垂直对齐(表格专用)
- if (cell.style?.valign) {
- style.valign = cell.style.valign;
- }
-
- return {
- text: cell.text || '',
- rowspan: cell.rowspan ?? 1,
- colspan: cell.colspan ?? 1,
- col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
- style: style,
- word_style: cell.word_style || 'Normal',
- width: cell.width !== undefined ? cell.width : defaultWidth,
- };
- }
- /**
- * 规范化整个表格的数据结构
- * 确保所有单元格都包含完整的字段信息
- *
- * @param table 表格块
- * @returns 规范化后的表格块
- */
- export function normalizeTableBlock(table: TableBlock): TableBlock {
- // 从content.col_widths计算平均宽度作为默认值
- let avgWidth = 100;
-
- if (table.content.col_widths && table.content.col_widths.length > 0) {
- avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length;
- } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
- // 从百分比反推pt值
- const tableWidthPercent = table.metadata.table_width || 100;
- const pageWidthPt = 478; // A4宽度减边距
- const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
- avgWidth = tableActualWidthPt / table.metadata.col_widths.length;
- }
- const rows = table.content.rows.map((row) => {
- const normalizedRow: TableRow = {
- cells: row.cells.map((cell, colIdx) =>
- normalizeTableCell(cell, colIdx + 1, avgWidth)
- ),
- };
-
- // 只在有值时才添加height字段
- if (row.height !== undefined) {
- normalizedRow.height = row.height;
- }
-
- return normalizedRow;
- });
- return {
- ...table,
- content: {
- rows,
- // 如果content有col_widths,保留它
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- };
- }
- /**
- * 创建空的表格单元格
- *
- * @param colIndex 列索引(从1开始,可选)
- * @param width 单元格宽度(磅,可选)
- * @returns 空单元格,使用最小化的样式
- */
- export function createEmptyCell(colIndex?: number, width?: number): TableCell {
- return {
- text: '',
- rowspan: 1,
- colspan: 1,
- col_index: colIndex,
- style: {}, // 空样式对象,让后端或显示层使用默认值
- word_style: 'Normal',
- width: width !== undefined ? width : 100,
- };
- }
- /**
- * 创建空的表格行
- *
- * @param cols 列数
- * @param colWidths 列宽数组(磅,可选)
- * @param height 行高(磅,可选,默认58)
- * @returns 表格行
- */
- export function createEmptyRow(cols: number, colWidths?: number[], height?: number): TableRow {
- return {
- cells: Array(cols).fill(null).map((_, index) =>
- createEmptyCell(
- index + 1, // col_index从1开始
- colWidths?.[index]
- )
- ),
- height: height !== undefined ? height : 58, // 默认行高58磅
- };
- }
- /**
- * 在表格中插入行
- *
- * @param table 表格块
- * @param afterRow 在此行之后插入
- * @returns 新的表格块
- *
- * @example
- * ```ts
- * const newTable = insertTableRow(table, 1); // 在第2行后插入
- * ```
- */
- function insertTableRowAt(table: TableBlock, insertIndex: number): TableBlock {
- if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.content.rows.length) {
- throw new Error('行索引无效');
- }
- // 使用合理的默认行高
- const defaultHeight = 58; // 默认行高58磅
-
- // 计算列宽:从content.col_widths或从metadata推算
- let colWidths: number[];
-
- if (table.content.col_widths && table.content.col_widths.length > 0) {
- colWidths = table.content.col_widths;
- } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) {
- // 从百分比反推pt值
- const tableWidthPercent = table.metadata.table_width || 100;
- const pageWidthPt = 478; // A4宽度减边距
- const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
- colWidths = table.metadata.col_widths.map(percent =>
- (tableActualWidthPt * percent / 100)
- );
- } else {
- // 没有列宽信息,使用默认值
- colWidths = Array(table.metadata.cols).fill(100);
- }
-
- const newRow = createEmptyRow(table.metadata.cols, colWidths, defaultHeight);
- const positions = getTableVisualCellPositions(table);
- const rows = table.content.rows.map((row, rowIndex) => ({
- ...row,
- cells: row.cells.map((cell, cellIndex) => {
- const position = positions.get(`${rowIndex}-${cellIndex}`);
- if (!position || position.rowStart >= insertIndex || position.rowEnd < insertIndex) return cell;
- return { ...cell, rowspan: (cell.rowspan ?? 1) + 1 };
- }),
- }));
- newRow.cells = newRow.cells.map((cell, colIndex) => {
- const spanningCell = [...positions.values()].find((position) =>
- position.rowStart < insertIndex
- && position.rowEnd >= insertIndex
- && position.colStart <= colIndex
- && position.colEnd >= colIndex
- );
- return spanningCell ? createHiddenCell(colIndex) : cell;
- });
- rows.splice(insertIndex, 0, newRow);
-
- return {
- ...table,
- content: {
- rows,
- // 保留col_widths如果存在
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- metadata: {
- ...table.metadata,
- rows: rows.length,
- },
- };
- }
- export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
- assertTableIndex(afterRow, table.content.rows.length, '行');
- return insertTableRowAt(table, afterRow + 1);
- }
- /**
- * 在表格中插入行
- *
- * @param table 表格块
- * @param beforeRow 在此行之前插入
- * @returns 新的表格块
- */
- export function insertTableRowBefore(table: TableBlock, beforeRow: number): TableBlock {
- assertTableIndex(beforeRow, table.content.rows.length, '行');
- return insertTableRowAt(table, beforeRow);
- }
- /**
- * 在表格中插入列
- *
- * @param table 表格块
- * @param afterCol 在此列之后插入
- * @returns 新的表格块
- *
- * @example
- * ```ts
- * const newTable = insertTableColumn(table, 1); // 在第2列后插入
- * ```
- */
- function insertTableColumnAt(table: TableBlock, insertIndex: number): TableBlock {
- if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.metadata.cols) {
- throw new Error('列索引无效');
- }
- // 计算新列的宽度
- // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
- let newColWidth = 100; // 默认列宽100磅
-
- const adjacentCol = Math.min(insertIndex, Math.max(table.metadata.cols - 1, 0));
- if (table.content.col_widths && table.content.col_widths.length > adjacentCol) {
- newColWidth = table.content.col_widths[adjacentCol];
- } else if (table.metadata.col_widths && table.metadata.col_widths.length > adjacentCol) {
- // 从百分比反推pt值
- const tableWidthPercent = table.metadata.table_width || 100;
- const pageWidthPt = 478;
- const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
- newColWidth = tableActualWidthPt * table.metadata.col_widths[adjacentCol] / 100;
- }
-
- const positions = getTableVisualCellPositions(table);
- const positionedCells: PositionedTableCell[] = [];
- table.content.rows.forEach((row, rowIndex) => {
- row.cells.forEach((cell, cellIndex) => {
- const position = positions.get(`${rowIndex}-${cellIndex}`);
- if (!position) return;
- positionedCells.push({
- cell,
- position: position.colStart < insertIndex && position.colEnd >= insertIndex
- ? { ...position, colEnd: position.colEnd + 1 }
- : position.colStart >= insertIndex
- ? { ...position, colStart: position.colStart + 1, colEnd: position.colEnd + 1 }
- : position,
- });
- });
- });
- const rows = rebuildTableRows(
- positionedCells,
- table.content.rows.map((row) => row.height),
- table.metadata.cols + 1,
- );
-
- // 更新metadata.col_widths(百分比)
- const metadataColWidths = [...table.metadata.col_widths];
- // 新列使用相邻列的百分比,如果没有则平均分配
- const newColPercent = metadataColWidths[adjacentCol] || (100 / (metadataColWidths.length + 1));
- metadataColWidths.splice(insertIndex, 0, newColPercent);
-
- // 更新content.col_widths(pt单位)
- const contentColWidths = table.content.col_widths ? [...table.content.col_widths] : [];
- if (contentColWidths.length > 0) {
- contentColWidths.splice(insertIndex, 0, newColWidth);
- }
-
- return {
- ...table,
- content: {
- rows,
- ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {})
- },
- metadata: {
- ...table.metadata,
- cols: table.metadata.cols + 1,
- col_widths: metadataColWidths,
- },
- };
- }
- export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
- assertTableIndex(afterCol, table.metadata.cols, '列');
- return insertTableColumnAt(table, afterCol + 1);
- }
- /**
- * 在表格中插入列
- *
- * @param table 表格块
- * @param beforeCol 在此列之前插入
- * @returns 新的表格块
- */
- export function insertTableColumnBefore(table: TableBlock, beforeCol: number): TableBlock {
- assertTableIndex(beforeCol, table.metadata.cols, '列');
- return insertTableColumnAt(table, beforeCol);
- }
- /**
- * 删除表格行
- *
- * @param table 表格块
- * @param rowIndex 行索引
- * @returns 新的表格块
- */
- export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
- assertTableIndex(rowIndex, table.content.rows.length, '行');
- if (table.content.rows.length <= 1) {
- throw new Error('表格至少需要一行');
- }
-
- const positions = getTableVisualCellPositions(table);
- const positionedCells: PositionedTableCell[] = [];
- table.content.rows.forEach((row, sourceRow) => {
- row.cells.forEach((cell, sourceCol) => {
- const position = positions.get(`${sourceRow}-${sourceCol}`);
- if (!position) return;
- if (position.rowStart <= rowIndex && position.rowEnd >= rowIndex) {
- if (position.rowStart === position.rowEnd) return;
- positionedCells.push({
- cell,
- position: {
- ...position,
- rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
- rowEnd: position.rowEnd - 1,
- },
- });
- return;
- }
- positionedCells.push({
- cell,
- position: {
- ...position,
- rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
- rowEnd: position.rowEnd > rowIndex ? position.rowEnd - 1 : position.rowEnd,
- },
- });
- });
- });
- const rowHeights = table.content.rows
- .filter((_, index) => index !== rowIndex)
- .map((row) => row.height);
- const rows = rebuildTableRows(positionedCells, rowHeights, table.metadata.cols);
-
- return {
- ...table,
- content: {
- rows,
- // 保留col_widths如果存在
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- metadata: {
- ...table.metadata,
- rows: rows.length,
- },
- };
- }
- /**
- * 删除表格列
- *
- * @param table 表格块
- * @param colIndex 列索引
- * @returns 新的表格块
- */
- export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
- assertTableIndex(colIndex, table.metadata.cols, '列');
- if (table.metadata.cols <= 1) {
- throw new Error('表格至少需要一列');
- }
-
- const positions = getTableVisualCellPositions(table);
- const positionedCells: PositionedTableCell[] = [];
- table.content.rows.forEach((row, sourceRow) => {
- row.cells.forEach((cell, sourceCol) => {
- const position = positions.get(`${sourceRow}-${sourceCol}`);
- if (!position) return;
- if (position.colStart <= colIndex && position.colEnd >= colIndex) {
- if (position.colStart === position.colEnd) return;
- positionedCells.push({
- cell,
- position: {
- ...position,
- colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
- colEnd: position.colEnd - 1,
- },
- });
- return;
- }
- positionedCells.push({
- cell,
- position: {
- ...position,
- colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
- colEnd: position.colEnd > colIndex ? position.colEnd - 1 : position.colEnd,
- },
- });
- });
- });
- const rows = rebuildTableRows(
- positionedCells,
- table.content.rows.map((row) => row.height),
- table.metadata.cols - 1,
- );
-
- // 更新metadata.col_widths
- const metadataColWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex);
-
- // 更新content.col_widths
- const contentColWidths = table.content.col_widths
- ? table.content.col_widths.filter((_, i) => i !== colIndex)
- : [];
-
- return {
- ...table,
- content: {
- rows,
- ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {})
- },
- metadata: {
- ...table.metadata,
- cols: table.metadata.cols - 1,
- col_widths: metadataColWidths,
- },
- };
- }
- /**
- * 合并单元格
- *
- * 支持横向合并(colspan)和纵向合并(rowspan)
- * 合并后的主单元格会保存所有被合并单元格的文本内容(用空格连接)
- * 被合并的单元格会被标记为隐藏(rowspan=0, colspan=0)
- *
- * 重要:与后端逻辑保持一致
- * - 主单元格(左上角)保留完整的字段信息
- * - 被合并的单元格标记为rowspan=0, colspan=0, text=''
- * - 保留所有单元格的col_index
- * - 合并后的文本在包含富文本时保留片段样式
- *
- * @param table 表格块
- * @param startRow 起始行
- * @param startCol 起始列
- * @param endRow 结束行
- * @param endCol 结束列
- * @returns 新的表格块
- *
- * @example
- * ```ts
- * // 横向合并: a + b (第0行,第0-1列)
- * mergeCells(table, 0, 0, 0, 1);
- * // 结果: a单元格 colspan=2, text包含a和b的内容
- *
- * // 纵向合并: a + d (第0-1行,第0列)
- * mergeCells(table, 0, 0, 1, 0);
- * // 结果: a单元格 rowspan=2, text包含a和d的内容
- * ```
- */
- export function mergeCells(
- table: TableBlock,
- startRow: number,
- startCol: number,
- endRow: number,
- endCol: number
- ): TableBlock {
- assertTableRange(table, startRow, startCol, endRow, endCol);
- const selectionBounds = getTableSelectionBounds(table, startRow, startCol, endRow, endCol);
- if (!selectionBounds) {
- throw new Error('合并范围不能截断已有合并单元格');
- }
- return mergeCellsByVisualBounds(table, selectionBounds);
- }
- /**
- * 按视觉网格边界合并单元格。
- *
- * 视觉坐标与行内 cells 数组下标不是同一个坐标系,尤其在跨行单元格
- * 产生隐藏占位时,不能先把视觉范围当作数组范围再次校验。
- */
- export function mergeCellsByVisualBounds(
- table: TableBlock,
- selectionBounds: TableVisualCellPosition,
- ): TableBlock {
- if (selectionBounds.rowStart > selectionBounds.rowEnd) {
- throw new Error('行范围无效');
- }
- if (selectionBounds.colStart > selectionBounds.colEnd) {
- throw new Error('列范围无效');
- }
- const visualPositions = getTableVisualCellPositions(table);
- const visualColumnEnd = Math.max(
- table.metadata.cols - 1,
- ...[...visualPositions.values()].map((position) => position.colEnd),
- );
- const visualRowEnd = Math.max(
- table.content.rows.length - 1,
- ...[...visualPositions.values()].map((position) => position.rowEnd),
- );
- if (
- selectionBounds.rowStart < 0
- || selectionBounds.rowEnd > visualRowEnd
- || selectionBounds.colStart < 0
- || selectionBounds.colEnd > visualColumnEnd
- ) {
- throw new Error('结束索引无效');
- }
- for (const position of visualPositions.values()) {
- const intersects = position.rowStart <= selectionBounds.rowEnd
- && position.rowEnd >= selectionBounds.rowStart
- && position.colStart <= selectionBounds.colEnd
- && position.colEnd >= selectionBounds.colStart;
- const isContained = position.rowStart >= selectionBounds.rowStart
- && position.rowEnd <= selectionBounds.rowEnd
- && position.colStart >= selectionBounds.colStart
- && position.colEnd <= selectionBounds.colEnd;
- if (intersects && !isContained) {
- throw new Error('合并范围不能截断已有合并单元格');
- }
- }
- const primaryEntry = [...visualPositions.entries()].find(([, position]) =>
- position.rowStart === selectionBounds.rowStart && position.colStart === selectionBounds.colStart
- );
- if (!primaryEntry) {
- throw new Error('合并起始单元格不存在');
- }
- const primaryKey = primaryEntry[0];
- // 计算合并范围
- const rowSpan = selectionBounds.rowEnd - selectionBounds.rowStart + 1;
- const colSpan = selectionBounds.colEnd - selectionBounds.colStart + 1;
-
- // 收集所有被合并单元格的文本内容
- const displayTexts: string[] = [];
- const mergedRichText: RichText[] = [];
- let hasRichText = false;
-
- // 辅助函数:将 text 字段规范化为纯字符串
- const normalizeText = (text: string | RichText[]): string => {
- if (typeof text === 'string') {
- return text;
- }
- // 如果是 RichText 数组,提取所有的纯文本
- return text.map(seg => seg.text).join('');
- };
-
- // 收集文本
- table.content.rows.forEach((row, rowIdx) => {
- row.cells.forEach((cell, colIdx) => {
- const position = visualPositions.get(`${rowIdx}-${colIdx}`);
- if (!position) return;
- const isContained = position.rowStart >= selectionBounds.rowStart
- && position.rowEnd <= selectionBounds.rowEnd
- && position.colStart >= selectionBounds.colStart
- && position.colEnd <= selectionBounds.colEnd;
- if (isContained) {
- const text = normalizeText(cell.text);
- if (text.trim()) {
- displayTexts.push(text.trim());
- if (Array.isArray(cell.text)) {
- if (!hasRichText) {
- displayTexts.slice(0, -1).forEach((previousText) => {
- if (mergedRichText.length > 0) {
- mergedRichText.push({ text: ' ', style: {} });
- }
- mergedRichText.push({ text: previousText, style: {} });
- });
- }
- hasRichText = true;
- if (mergedRichText.length > 0) {
- mergedRichText.push({ text: ' ', style: {} });
- }
- mergedRichText.push(...cell.text);
- } else if (hasRichText) {
- if (mergedRichText.length > 0) {
- mergedRichText.push({ text: ' ', style: {} });
- }
- mergedRichText.push({ text: text.trim(), style: {} });
- }
- }
- }
- });
- });
-
- const rows = table.content.rows.map((row, rowIdx) => {
- const cells = row.cells.map((cell, colIdx) => {
- const key = `${rowIdx}-${colIdx}`;
- const position = visualPositions.get(key);
- if (!position) return cell;
- const isContained = position.rowStart >= selectionBounds.rowStart
- && position.rowEnd <= selectionBounds.rowEnd
- && position.colStart >= selectionBounds.colStart
- && position.colEnd <= selectionBounds.colEnd;
- if (!isContained) return cell;
- if (key === primaryKey) {
- // 主单元格:设置完整的标准格式
- // 合并后的文本在包含富文本时保留片段样式
- const mergedText = displayTexts.join(' ');
- const mergedContent = hasRichText ? mergedRichText : mergedText;
-
- // 构建标准格式的单元格对象
- return {
- text: mergedContent.length > 0 ? mergedContent : normalizeText(cell.text),
- rowspan: rowSpan,
- colspan: colSpan,
- col_index: colIdx + 1, // 列索引从1开始
- style: {
- ...cell.style, // 保留原有样式
- },
- word_style: cell.word_style || 'Normal',
- width: cell.width !== undefined ? cell.width : 100,
- };
- }
-
- // 被合并的单元格,标记为隐藏
- return {
- text: '', // 清空显示内容
- rowspan: 0,
- colspan: 0,
- col_index: colIdx + 1, // 保持列索引
- style: cell.style || {},
- word_style: cell.word_style || 'Normal',
- width: cell.width,
- };
- });
-
- return {
- cells,
- height: row.height, // 保留行高
- };
- });
-
- return {
- ...table,
- content: {
- rows,
- // 保留col_widths如果存在
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- };
- }
- /**
- * 拆分单元格
- *
- * 将已合并的单元格拆分回独立单元格
- * 主单元格保留原有内容,其他单元格恢复为空单元格
- *
- * 关键逻辑:
- * 1. 对于横向合并(colspan>1),需要在当前行**插入**新的单元格
- * 2. 对于纵向合并(rowspan>1),需要恢复被隐藏的单元格(rowspan=0, colspan=0)
- * 3. 对于同时横向和纵向合并,需要同时处理插入和恢复
- *
- * 与后端逻辑保持一致:
- * - 主单元格保留原有text和样式
- * - 新单元格使用空text和默认样式
- * - 保留所有单元格的width
- * - 正确更新col_index
- *
- * @param table 表格块
- * @param rowIndex 单元格所在行
- * @param colIndex 单元格所在列
- * @returns 新的表格块
- *
- * @example
- * ```ts
- * // 拆分横向合并的单元格 (a+b)
- * splitCell(table, 0, 0);
- * // 结果: a单元格保留内容,b单元格变为空单元格
- *
- * // 拆分纵向合并的单元格 (a+d)
- * splitCell(table, 0, 0);
- * // 结果: a单元格保留内容,d单元格变为空单元格
- * ```
- */
- export function splitCell(
- table: TableBlock,
- rowIndex: number,
- colIndex: number
- ): TableBlock {
- assertTableIndex(rowIndex, table.content.rows.length, '行');
- assertTableIndex(colIndex, table.metadata.cols, '列');
- const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
- if (!targetCell) {
- throw new Error('单元格不存在');
- }
- // 如果单元格没有合并,无需拆分
- if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
- throw new Error('此单元格未合并,无需拆分');
- }
- const positions = getTableVisualCellPositions(table);
- const targetPosition = positions.get(`${rowIndex}-${colIndex}`);
- if (!targetPosition) {
- throw new Error('合并单元格位置无效');
- }
- const positionedCells: PositionedTableCell[] = [];
- for (const [key, position] of positions.entries()) {
- const [currentRow, currentCol] = key.split('-').map(Number);
- const cell = table.content.rows[currentRow]?.cells[currentCol];
- if (!cell) continue;
- if (currentRow !== rowIndex || currentCol !== colIndex) {
- positionedCells.push({ cell, position });
- continue;
- }
- for (let splitRow = targetPosition.rowStart; splitRow <= targetPosition.rowEnd; splitRow += 1) {
- for (let splitCol = targetPosition.colStart; splitCol <= targetPosition.colEnd; splitCol += 1) {
- const isPrimary = splitRow === targetPosition.rowStart && splitCol === targetPosition.colStart;
- positionedCells.push({
- cell: isPrimary
- ? {
- ...cell,
- rowspan: 1,
- colspan: 1,
- col_index: splitCol + 1,
- word_style: cell.word_style || 'Normal',
- }
- : createEmptyCell(splitCol + 1, cell.width || 100),
- position: {
- rowStart: splitRow,
- rowEnd: splitRow,
- colStart: splitCol,
- colEnd: splitCol,
- },
- });
- }
- }
- }
- const rows = rebuildTableRows(
- positionedCells,
- table.content.rows.map((row) => row.height),
- table.metadata.cols,
- );
-
- return {
- ...table,
- content: {
- rows,
- // 保留col_widths如果存在
- ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {})
- },
- };
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Block Validation
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * 验证块数据是否有效
- *
- * @param block 块数据
- * @returns 是否有效
- */
- export function validateBlock(block: Partial<DocumentBlock>): boolean {
- if (!block.type) return false;
- if (block.block_order === undefined) return false;
-
- switch (block.type) {
- case 'heading':
- return (
- typeof block.level === 'number' &&
- block.level >= 1 &&
- block.level <= 6
- );
-
- case 'paragraph':
- return true;
-
- case 'table': {
- const table = block as Partial<TableBlock>;
- return !!(
- table.metadata &&
- table.content &&
- validateTableStructure(table as TableBlock)
- );
- }
-
- case 'image':
- return !!(block.content && typeof block.content === 'string');
-
- case 'toc':
- return true; // TOC块总是有效的
-
- default:
- return false;
- }
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Block Search and Filter
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * 在blocks中搜索文本
- *
- * @param blocks 块数组
- * @param query 搜索关键词
- * @returns 匹配的块ID数组
- */
- export function searchBlocks(blocks: DocumentBlock[], query: string): string[] {
- const lowerQuery = query.toLowerCase();
-
- return blocks
- .filter((block) => {
- switch (block.type) {
- case 'heading':
- case 'paragraph': {
- const content =
- typeof block.content === 'string'
- ? block.content
- : block.content.map((seg) => seg.text).join('');
- return content.toLowerCase().includes(lowerQuery);
- }
-
- case 'table': {
- return block.content.rows.some((row) =>
- row.cells.some((cell) => {
- const text =
- typeof cell.text === 'string'
- ? cell.text
- : cell.text.map((seg) => seg.text).join('');
- return text.toLowerCase().includes(lowerQuery);
- })
- );
- }
-
- case 'image': {
- return block.metadata.alt?.toLowerCase().includes(lowerQuery);
- }
-
- case 'toc': {
- // 搜索TOC标题
- return block.content.title?.toLowerCase().includes(lowerQuery);
- }
-
- default:
- return false;
- }
- })
- .map((block) => block.id);
- }
- /**
- * 获取指定类型的所有块
- *
- * @param blocks 块数组
- * @param type 块类型
- * @returns 匹配类型的块数组
- */
- export function filterBlocksByType<T extends DocumentBlock>(
- blocks: DocumentBlock[],
- type: BlockType
- ): T[] {
- return blocks.filter((block) => block.type === type) as T[];
- }
|