/** * Table utilities and row/column manipulation operations for Feishu documents. * * Combines: * - Adaptive column width calculation (content-proportional, CJK-aware) * - Block cleaning for Descendant API (removes read-only fields) * - Table row/column insert, delete, and merge operations */ import type * as Lark from "@larksuiteoapi/node-sdk"; // ============ Table Utilities ============ // Feishu table constraints const MIN_COLUMN_WIDTH = 50; // Feishu API minimum const MAX_COLUMN_WIDTH = 400; // Reasonable maximum for readability const DEFAULT_TABLE_WIDTH = 730; // Approximate Feishu page content width /** * Calculate adaptive column widths based on cell content length. * * Algorithm: * 1. For each column, find the max content length across all rows * 2. Weight CJK characters as 2x width (they render wider) * 3. Calculate proportional widths based on content length * 4. Apply min/max constraints * 5. Redistribute remaining space to fill total table width * * Total width is derived from the original column_width values returned * by the Convert API, ensuring tables match Feishu's expected dimensions. * * @param blocks - Array of blocks from Convert API * @param tableBlockId - The block_id of the table block * @returns Array of column widths in pixels */ export function calculateAdaptiveColumnWidths( // eslint-disable-next-line @typescript-eslint/no-explicit-any blocks: any[], tableBlockId: string, ): number[] { // Find the table block const tableBlock = blocks.find((b) => b.block_id === tableBlockId && b.block_type === 31); if (!tableBlock?.table?.property) { return []; } const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property; // Use original total width from Convert API, or fall back to default const totalWidth = originalWidths && originalWidths.length > 0 ? originalWidths.reduce((a: number, b: number) => a + b, 0) : DEFAULT_TABLE_WIDTH; const cellIds: string[] = tableBlock.children || []; // Build block lookup map // eslint-disable-next-line @typescript-eslint/no-explicit-any const blockMap = new Map(); for (const block of blocks) { blockMap.set(block.block_id, block); } // Extract text content from a table cell function getCellText(cellId: string): string { const cell = blockMap.get(cellId); if (!cell?.children) return ""; let text = ""; const childIds = Array.isArray(cell.children) ? cell.children : [cell.children]; for (const childId of childIds) { const child = blockMap.get(childId); if (child?.text?.elements) { for (const elem of child.text.elements) { if (elem.text_run?.content) { text += elem.text_run.content; } } } } return text; } // Calculate weighted length (CJK chars count as 2) // CJK (Chinese/Japanese/Korean) characters render ~2x wider than ASCII function getWeightedLength(text: string): number { return [...text].reduce((sum, char) => { return sum + (char.charCodeAt(0) > 255 ? 2 : 1); }, 0); } // Find max content length per column const maxLengths: number[] = new Array(column_size).fill(0); for (let row = 0; row < row_size; row++) { for (let col = 0; col < column_size; col++) { const cellIndex = row * column_size + col; const cellId = cellIds[cellIndex]; if (cellId) { const content = getCellText(cellId); const length = getWeightedLength(content); maxLengths[col] = Math.max(maxLengths[col], length); } } } // Handle empty table: distribute width equally, clamped to [MIN, MAX] so // wide tables (e.g. 15+ columns) don't produce sub-50 widths that Feishu // rejects as invalid column_width values. const totalLength = maxLengths.reduce((a, b) => a + b, 0); if (totalLength === 0) { const equalWidth = Math.max( MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.floor(totalWidth / column_size)), ); return new Array(column_size).fill(equalWidth); } // Calculate proportional widths let widths = maxLengths.map((len) => { const proportion = len / totalLength; return Math.round(proportion * totalWidth); }); // Apply min/max constraints widths = widths.map((w) => Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, w))); // Redistribute remaining space to fill total width let remaining = totalWidth - widths.reduce((a, b) => a + b, 0); while (remaining > 0) { // Find columns that can still grow (not at max) const growable = widths.map((w, i) => (w < MAX_COLUMN_WIDTH ? i : -1)).filter((i) => i >= 0); if (growable.length === 0) break; // Distribute evenly among growable columns const perColumn = Math.floor(remaining / growable.length); if (perColumn === 0) break; for (const i of growable) { const add = Math.min(perColumn, MAX_COLUMN_WIDTH - widths[i]); widths[i] += add; remaining -= add; } } return widths; } /** * Clean blocks for Descendant API with adaptive column widths. * * - Removes parent_id from all blocks * - Fixes children type (string → array) for TableCell blocks * - Removes merge_info (read-only, causes API error) * - Calculates and applies adaptive column_width for tables * * @param blocks - Array of blocks from Convert API * @returns Cleaned blocks ready for Descendant API */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function cleanBlocksForDescendant(blocks: any[]): any[] { // Pre-calculate adaptive widths for all tables const tableWidths = new Map(); for (const block of blocks) { if (block.block_type === 31) { const widths = calculateAdaptiveColumnWidths(blocks, block.block_id); tableWidths.set(block.block_id, widths); } } return blocks.map((block) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { parent_id: _parentId, ...cleanBlock } = block; // Fix: Convert API sometimes returns children as string for TableCell if (cleanBlock.block_type === 32 && typeof cleanBlock.children === "string") { cleanBlock.children = [cleanBlock.children]; } // Clean table blocks if (cleanBlock.block_type === 31 && cleanBlock.table) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { cells: _cells, ...tableWithoutCells } = cleanBlock.table; const { row_size, column_size } = tableWithoutCells.property || {}; const adaptiveWidths = tableWidths.get(block.block_id); cleanBlock.table = { property: { row_size, column_size, ...(adaptiveWidths?.length && { column_width: adaptiveWidths }), }, }; } return cleanBlock; }); } // ============ Table Row/Column Operations ============ export async function insertTableRow( client: Lark.Client, docToken: string, blockId: string, rowIndex: number = -1, ) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { insert_table_row: { row_index: rowIndex } }, }); if (res.code !== 0) { throw new Error(res.msg); } return { success: true, block: res.data?.block }; } export async function insertTableColumn( client: Lark.Client, docToken: string, blockId: string, columnIndex: number = -1, ) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { insert_table_column: { column_index: columnIndex } }, }); if (res.code !== 0) { throw new Error(res.msg); } return { success: true, block: res.data?.block }; } export async function deleteTableRows( client: Lark.Client, docToken: string, blockId: string, rowStart: number, rowCount: number = 1, ) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { delete_table_rows: { row_start_index: rowStart, row_end_index: rowStart + rowCount } }, }); if (res.code !== 0) { throw new Error(res.msg); } return { success: true, rows_deleted: rowCount, block: res.data?.block }; } export async function deleteTableColumns( client: Lark.Client, docToken: string, blockId: string, columnStart: number, columnCount: number = 1, ) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { delete_table_columns: { column_start_index: columnStart, column_end_index: columnStart + columnCount, }, }, }); if (res.code !== 0) { throw new Error(res.msg); } return { success: true, columns_deleted: columnCount, block: res.data?.block }; } export async function mergeTableCells( client: Lark.Client, docToken: string, blockId: string, rowStart: number, rowEnd: number, columnStart: number, columnEnd: number, ) { const res = await client.docx.documentBlock.patch({ path: { document_id: docToken, block_id: blockId }, data: { merge_table_cells: { row_start_index: rowStart, row_end_index: rowEnd, column_start_index: columnStart, column_end_index: columnEnd, }, }, }); if (res.code !== 0) { throw new Error(res.msg); } return { success: true, block: res.data?.block }; }