Stories

Detail Return Return

(在線CAD插件)網頁CAD二開表格提取功能 - Stories Detail

前言

CAD圖紙上的表格信息承載着大量關鍵數據,生產過程中會導出表格數據到excel,本文將介紹如何通過自定義 MxCAD 插件,在web端實現對CAD圖紙中表格的智能識別、自動合併與高效導出,大幅提升數據提取效率與準確性,效果如下:

一、功能概述

本次圖紙表格提取主要實現以下核心功能:

  1. 交互式區域選擇:用户通過鼠標框選目標表格區域。
  2. 圖形元素識別:自動識別範圍內的直線、文字、多段線等實體。
  3. 表格結構重建:基於交點分析重建表格網格。
  4. 智能單元格合併:支持橫向與縱向跨單元格合併識別。
  5. 內容提取與導出:提取單元格文本內容並導出為 Excel 文件。

二、技術實現原理

2.1 實體獲取與預處理

首先讓用户指定一個提取範圍(矩形框),然後利用 mxcad 中的[MxCADSelectionSet]選擇集跨區域選擇所有相關實體:

const ss = new MxCADSelectionSet();
await ss.crossingSelect(corner1.x, corner1.y, corner2.x, corner2.y);

為確保嵌套塊(BlockReference)中的實體也能被識別,程序遞歸遍歷塊定義,並應用變換矩陣(blockTransform)還原其真實座標位置。

const needTransformEntity: { handle: string, mart: McGeMatrix3d }[] = [];
const Mx_getBlokEntity = (blkRec: McDbBlockTableRecord, mart: McGeMatrix3d) => {
    blkRec.getAllEntityId().forEach(id => {
        let ent = id.getMcDbEntity();
        if (ent instanceof McDbBlockReference) {
            let blkref = ent as McDbBlockReference;
            let mat = blkref.blockTransform.clone();
            mat.preMultBy(mart);
            Mx_getBlokEntity(blkref.blockTableRecordId.getMcDbBlockTableRecord(), mat);
        } else {
            needTransformEntity.push({ handle: ent.getHandle(), mart });
            ...
        }
    })
}

此外,多段線(Polyline)會被打散為獨立的直線或圓弧段,便於後續交點計算。

const explodePl = (ent: McDbPolyline, mart?: McGeMatrix3d): McDbEntity[] => {
    // 如果是多段線,需要打散成線段
    const numVert = ent.numVerts();
    const entsArr: McDbEntity[] = [];
    for (let i = 0; i < numVert; i++) {
        if (i < numVert - 1) {
            const convexity = ent.getBulgeAt(i);
            const pt1 = ent.getPointAt(i).val;
            const pt2 = ent.getPointAt(i + 1).val;
            if (mart) {
                pt1.transformBy(mart);
                pt2.transformBy(mart);
            }
            if (!convexity) {
                const line = new McDbLine(pt1, pt2);
                entsArr.push(line)
            } else {
                const d = (ent.getDistAtPoint(pt1).val + ent.getDistAtPoint(pt2).val) / 2;
                const midPt = ent.getPointAtDist(d).val;
                const arc = new McDbArc();
                arc.computeArc(pt1.x, pt1.y, midPt.x, midPt.y, pt2.x, pt2.y);
                entsArr.push(arc)
            }
        } else {
            if (ent.isClosed) entsArr.push(new McDbLine(ent.getPointAt(0).val, ent.getPointAt(numVert - 1).val))
        }
    }
    return entsArr;
}

2.2 表格線段分類

在上述步驟中,我們提取到了圖紙框選範圍內的所有實體並對部分實體做了初步處理,接下來我們需要通過提取出框選範圍內的所有直線,並將這些直線分為兩類:

  • 水平線:方向接近 X 軸
  • 垂直線:方向接近 Y 軸

直線的分類通過線向量與X軸、Y軸的單位向量之間的夾角來判斷:

const horizontalLineArr: McDbLine[] = [];//橫向
const verticalLineArr: McDbLine[] = [];//縱向
lineArr.forEach(item => {
    const vec_x = McGeVector3d.kXAxis;
    const vec_y = McGeVector3d.kYAxis;
    const line = item.clone() as McDbLine;
    //判斷直線是塊內實體,如果是則需要使用變換矩陣還原真是座標位置
    const res = needTransformEntity.find(i => i.handle === item.getHandle());
    if (res) {
        line.startPoint = line.startPoint.clone().transformBy(res.mart);
        line.endPoint = line.endPoint.transformBy(res.mart);
    }
    const _vec = line.startPoint.sub(line.endPoint).normalize().mult(precision);
    if (vec_x.angleTo1(_vec) < precision || Math.abs((vec_x.angleTo1(_vec) - Math.PI)) < precision) {
        horizontalLineArr.push(new McDbLine(line.startPoint.addvec(_vec), line.endPoint.subvec(_vec)))
    }
    if (vec_y.angleTo1(_vec) < precision || Math.abs((vec_y.angleTo1(_vec) - Math.PI)) < precision) {
        verticalLineArr.push(new McDbLine(line.startPoint.addvec(_vec), line.endPoint.subvec(_vec)))
    };
});

2.3 表格交點提取與去重

在上一步中,我們以及獲取到了所有的橫縱直線。接下來,我們將利用水平線與垂直線之間的交點構建表格節點矩陣。所有交點經過座標四捨五入(精度控制)和去重處理,形成唯一的網格點集合。

// 點數組去重
const deduplicatePoints = (points: McGePoint3d[]): McGePoint3d[]=> {
    const allPoints: McGePoint3d[] = [];
    points.forEach((item, index) => {
        const res = points.filter((j, ind) => {
            return ind > index && item.distanceTo(j) < 0.00001
        });
        if (!res.length) allPoints.push(item)
    });
    return allPoints;
}
// 根據線拿到所有的點
const roundToPrecision = (num, precision = 0.0001): number => {
    const decimals = Math.abs(Math.floor(Math.log10(precision))); // 計算精度對應的小數位數
    const factor = Math.pow(10, decimals);
    return Math.round(num * factor) / factor;
}
let allPoints: McGePoint3d[] = [];
horizontalLineArr.forEach(line1 => {
    verticalLineArr.forEach(line2 => {
        const res = line1.IntersectWith(line2, McDb.Intersect.kOnBothOperands);
        if (res.length()) res.forEach(pt => {
            pt.x = roundToPrecision(pt.x, precision);
            pt.y = roundToPrecision(pt.y, precision);
            if (arePointsInRectangle([new_corner1, new McGePoint3d(new_corner1.x, new_corner2.y), new_corner2, new McGePoint3d(new_corner2.x, new_corner1.y)], [pt])) {
                allPoints.push(pt)
            }
        })
    })
});
allPoints = deduplicatePoints(allPoints);//點數組去重;

2.4 構建初始單元格矩陣

根據交點的 X 和 Y 座標排序,生成二維網格結構 cellPointsArr,每個元素為交點或 null(表示缺失的角點),例如:

[
  [A1, B1, null, D1],
  [A2, B2, C2, D2],
  [null, B3, C3, D3]
]
const _x = Array.from(new Set(allPoints.map(item => item.x))).sort((a, b) => a - b);
const _y = Array.from(new Set(allPoints.map(item => item.y))).sort((a, b) => b - a);
const cellPointsArr: (McGePoint3d | null)[][] = [];
_y.forEach((y, row) => {
    const arr: (McGePoint3d | null)[] = [];
    const pts = allPoints.filter(item => item.y === y);
    if (pts.length) {
        _x.forEach((x, col) => {
            const index = pts.findIndex(item => item.x === x);
            // 若表格四個角點缺失,則手動補充數據使表格完整
            if (index === -1) {
                if ((row === 0 || row === _y.length - 1) && (col === 0 || row === _x.length - 1)) {
                    arr.push(new McGePoint3d(x, y));
                } else {
                    arr.push(null)
                }
            } else {
                arr.push(pts[index])
            }
        });
        cellPointsArr.push(arr)
    } else {
        cellPointsArr.push(null);
    }
});

三、智能單元格合併機制

3.1 合併策略總覽

接下來我們將採用兩階段合併策略:

  1. 橫向合併優先
  2. 縱向合併補充
    縱向合併僅在橫向合併後形成的 2×2 子矩陣仍包含 null 元素 時觸發。

3.2 橫向合併邏輯

系統將整個表格劃分為多個 2×2 子矩陣塊,每個塊以左上角單元格命名(如 B2 表示第2行第2列開始的塊)。
對於每一個 2×2 塊,若其四個角點中有 null,則判定為“不完整”,需要參與合併。

合併規則(橫向擴展)

條件                 查找方向 判斷依據                                      
第一個元素為 null 左側塊   當前塊的左鄰塊(如 A2)第二個元素是否為 null
第二個元素為 null 右側塊   當前塊的右鄰塊(如 C2)第一個元素是否為 null
第三個元素為 null 左側塊   當前塊的左鄰塊第四個元素是否為 null          
第四個元素為 null 右側塊   當前塊的右鄰塊第三個元素是否為 null          
示例:B2:[[null,a],[c,b]] → 檢查 A2 的第二個元素是否為 null

通過廣度優先搜索(BFS),收集所有可橫向連接的“不完整”塊,形成一個合併組。

3.3 縱向合併觸發條件

當橫向合併完成後,若新生成的 2×2 外圍矩陣仍含有 null,則啓動縱向合併流程。

縱向合併規則

條件                 查找方向 判斷依據                              
第一個元素為 null 上方塊   上方塊(如 B1)第三個元素是否為 null
第二個元素為 null 上方塊   上方塊第四個元素是否為 null          
第三個元素為 null 下方塊   下方塊(如 B3)第一個元素是否為 null
第四個元素為 null 下方塊   下方塊第二個元素是否為 null          
示例:B2:[[a,null],[c,b]] → 檢查 B1 的第四個元素是否為 null

程序繼續擴展合併組,直到包圍盒內所有 2×2 塊都被納入,最終形成一個完整的矩形區域。

3.4 合併結果生成

合併完成後,系統計算最小行/列與最大行/列,生成新的 2×2 矩陣代表合併區域的四個角點,並記錄其原始單元格範圍(如 "A1+B1+A2+B2")。

// 合併表格
function solveWithMerging(input: MatrixValue[][]): MergeResult[] {
    const rows = input.length;
    const cols = input[0].length;
    if (rows < 2 || cols < 2) {
        return;
    }
 
    // 1. 提取所有 2x2 子矩陣
    const blocks: Record<string, MatrixValue[][]> = {};
    const positions: Record<string, Position> = {};
 
    for (let r = 0; r <= rows - 2; r++) {
        for (let c = 0; c <= cols - 2; c++) {
            const key = `${String.fromCharCode(65 + c)}${r + 1}`;
            blocks[key] = [
                [input[r][c], input[r][c + 1]],
                [input[r + 1][c], input[r + 1][c + 1]]
            ];
            positions[key] = { row: r, col: c };
        }
    }
 
    // 工具:判斷是否含 null
    const hasNull = (mat: MatrixValue[][]): boolean =>
        mat.some(row => row.some(cell => cell === null));
 
    const processed = new Set<string>(); // 已參與合併的塊
    const results: MergeResult[] = [];
 
    // 篩選出所有塊
    const getAllBlockNames = (visited: Set<string>): { fullRangeKeys: string[], newMatrix: MatrixValue[][] } => {
        // 獲取包圍盒(原始合併區域)
        let minRow = Infinity, maxRow = -Infinity;
        let minCol = Infinity, maxCol = -Infinity;
 
        Array.from(visited).forEach(key => {
            const { row, col } = positions[key];
            minRow = Math.min(minRow, row);
            maxRow = Math.max(maxRow, row);
            minCol = Math.min(minCol, col);
            maxCol = Math.max(maxCol, col);
        });
 
        // ===== 拓展:生成包圍盒內所有 2×2 塊名(完整矩形區域)=====
        const fullRangeKeys: string[] = [];
        for (let r = minRow; r <= maxRow; r++) {
            for (let c = minCol; c <= maxCol; c++) {
                const key = `${String.fromCharCode(65 + c)}${r + 1}`;
                fullRangeKeys.push(key);
                // 標記這些塊為已處理(防止在獨立塊中重複)
                processed.add(key);
            }
        };
 
        // 提取新 2x2 矩陣(四個角)
        const safeGet = (r: number, c: number): MatrixValue =>
            r < rows && c < cols ? input[r][c] : null;
 
        const newMatrix: MatrixValue[][] = [
            [safeGet(minRow, minCol), safeGet(minRow, maxCol + 1)],
            [safeGet(maxRow + 1, minCol), safeGet(maxRow + 1, maxCol + 1)]
        ];
        return { fullRangeKeys, newMatrix }
    }
 
    // ===== 第一階段:處理含 null 的合併組 =====
    for (const startKey in blocks) {
        if (processed.has(startKey) || !hasNull(blocks[startKey])) continue;
 
        const visited = new Set<string>();
        const queue: string[] = [startKey];
        visited.add(startKey);
        processed.add(startKey);
 
        while (queue.length > 0) {
            const key = queue.shift()!;
            const { row, col } = positions[key];
            const block = blocks[key];
            const [a, b] = block[0];
            const [c, d] = block[1];
 
            const leftKey = col > 0 ? `${String.fromCharCode(64 + col)}${row + 1}` : null;
            const rightKey = col < cols - 2 ? `${String.fromCharCode(66 + col)}${row + 1}` : null;
 
            // 先橫向合併,如果符合要求就跳出循環
 
            // 規則1: 第一個元素 null → 上方第三個 或 左邊第二個
            if (a === null) {
                if (leftKey && blocks[leftKey] && !visited.has(leftKey) && blocks[leftKey][0][1] === null) {
                    visited.add(leftKey);
                    queue.push(leftKey);
                    processed.add(leftKey);
                }
            }
 
            // 規則2: 第二個元素 null → 上方第四個 或 右邊第一個
            if (b === null) {
                if (rightKey && blocks[rightKey] && !visited.has(rightKey) && blocks[rightKey][0][0] === null) {
                    visited.add(rightKey);
                    queue.push(rightKey);
                    processed.add(rightKey);
                }
            }
 
            // 規則3: 第三個元素 null → 下方第一個 或 左邊第四個
            if (c === null) {
                if (leftKey && blocks[leftKey] && !visited.has(leftKey) && blocks[leftKey][1][1] === null) {
                    visited.add(leftKey);
                    queue.push(leftKey);
                    processed.add(leftKey);
                }
            }
 
            // 規則4: 第四個元素 null → 下方第二個 或 右邊第三個
            if (d === null) {
                if (rightKey && blocks[rightKey] && !visited.has(rightKey) && blocks[rightKey][1][0] === null) {
                    visited.add(rightKey);
                    queue.push(rightKey);
                    processed.add(rightKey);
                }
            };
        }
        if (visited.size === 1) queue.push(startKey);
        if (!getAllBlockNames(visited).newMatrix.flat().every(item => item !== null)) {
            while (queue.length > 0) {
                const key = queue.shift()!;
                const { row, col } = positions[key];
                const block = blocks[key];
                const [a, b] = block[0];
                const [c, d] = block[1];
 
                const upKey = row > 0 ? `${String.fromCharCode(65 + col)}${row}` : null;
                const downKey = row < rows - 2 ? `${String.fromCharCode(65 + col)}${row + 2}` : null;
                // 規則1: 第一個元素 null → 上方第三個 或 左邊第二個
                if (a === null) {
                    if (upKey && blocks[upKey] && !visited.has(upKey) && blocks[upKey][1][0] === null) {
                        visited.add(upKey);
                        queue.push(upKey);
                        processed.add(upKey);
                    }
                }
 
                // 規則2: 第二個元素 null → 上方第四個 或 右邊第一個
                if (b === null) {
                    if (upKey && blocks[upKey] && !visited.has(upKey) && blocks[upKey][1][1] === null) {
                        visited.add(upKey);
                        queue.push(upKey);
                        processed.add(upKey);
                    }
                }
 
                // 規則3: 第三個元素 null → 下方第一個 或 左邊第四個
                if (c === null) {
                    if (downKey && blocks[downKey] && !visited.has(downKey) && blocks[downKey][0][0] === null) {
                        visited.add(downKey);
                        queue.push(downKey);
                        processed.add(downKey);
                    }
                }
 
                // 規則4: 第四個元素 null → 下方第二個 或 右邊第三個
                if (d === null) {
                    if (downKey && blocks[downKey] && !visited.has(downKey) && blocks[downKey][0][1] === null) {
                        visited.add(downKey);
                        queue.push(downKey);
                        processed.add(downKey);
                    }
                };
            }
        }
        const { fullRangeKeys, newMatrix } = getAllBlockNames(visited);
        const isOnlyCol = (cells: string[]): Boolean => {
            const prefixes = new Set<string>();
            for (const cell of cells) {
                // 提取開頭的字母部分(連續的大寫A-Z)
                const match = cell.match(/^[A-Z]+/);
                if (match) {
                    prefixes.add(match[0]);
                }
            }
            return prefixes.size === 1;
        }
        if (isOnlyCol(fullRangeKeys)) {
            results.push({
                merged: {
                    fullRangeKeys: fullRangeKeys, // 重命名後的完整範圍
                    matrix: newMatrix
                }
            });
        } else {
            // 拿到所有合併元素後再重新組合
            const res = combineSubMatrices(input, fullRangeKeys);
            res.forEach(item => {
                results.push({
                    merged: {
                        fullRangeKeys: getAllBlockNames(new Set(item.name.split('+'))).fullRangeKeys, // 重命名後的完整範圍
                        matrix: item.data
                    }
                });
            })
        }
    }
    // ===== 第二階段:處理獨立塊(未被合併且未被覆蓋)=====
    for (const key in blocks) {
        if (!processed.has(key)) {
            results.push({
                standalone: {
                    key,
                    matrix: blocks[key]
                }
            });
        }
    }
    return results
}
type Matrix = any[][];
type SubMatrix2x2 = MatrixValue[][];
 
interface CombineResult<T> {
    name: string;
    data: SubMatrix2x2;
}
/**
 * 生成所有左塊 + 右塊組合,只保留左塊行號 ≤ 右塊行號的組合
 * 規則:
 * - 左塊:最左列的子矩陣 (A列)
 * - 右塊:最右列的子矩陣 (C列)
 * - 組合:Xr + Ys,其中 r <= s
 * - 輸出:所有滿足條件的組合
 */
// 改為支持任意類型 T
function combineSubMatrices<T>(matrix: Matrix, inputNames: string[]): CombineResult<T>[] {
    if (!matrix || matrix.length === 0 || matrix[0].length < 2) {
        throw new Error("Matrix must be at least 1x2");
    }
    const nameToPosition = new Map<string, { row: number; col: number }>();
    // 解析輸入名稱
    for (const rawName of inputNames) {
        const name = rawName.trim().toUpperCase();
        const match = name.match(/^([A-Z])(\d+)$/);
        if (!match) continue;
        const colIndex = match[1].charCodeAt(0) - 65;
        const rowIndex = parseInt(match[2], 10) - 1;
        if (rowIndex >= 0 && colIndex >= 0 &&
            rowIndex <= matrix.length - 2 && colIndex <= matrix[0].length - 2) {
            nameToPosition.set(name, { row: rowIndex, col: colIndex });
        }
    }
 
    if (nameToPosition.size === 0) {
        console.log("No valid submatrices found in input.");
        return [];
    }
    // 按列分組
    const colGroups = new Map<number, Map<number, string>>(); // col -> row -> name
    nameToPosition.forEach((pos, name) => {
        if (!colGroups.has(pos.col)) {
            colGroups.set(pos.col, new Map());
        }
        colGroups.get(pos.col)!.set(pos.row, name);
    })
    // 找出最左列(左塊)和最右列(右塊)
    const cols = Array.from(colGroups.keys()).sort((a, b) => a - b);
    if (cols.length < 2) {
        console.log("Need at least two columns for combination.");
        return [];
    }
    const leftCol = cols[0];
    const rightCol = cols[cols.length - 1];
    const leftColMap = colGroups.get(leftCol)!;
    const rightColMap = colGroups.get(rightCol)!;
    // 獲取所有行號
    const leftRows = Array.from(leftColMap.keys()).sort((a, b) => a - b);
    const rightRows = Array.from(rightColMap.keys()).sort((a, b) => a - b);
    const results: CombineResult<T>[] = [];
    // 生成所有左塊 + 右塊組合,只保留左塊行號 ≤ 右塊行號
    for (const leftRow of leftRows) {
        const leftName = leftColMap.get(leftRow)!;
        const leftRowNum = leftRow + 1; // 0-based to 1-based
        for (const rightRow of rightRows) {
            const rightName = rightColMap.get(rightRow)!;
            const rightRowNum = rightRow + 1;
            // 只保留左塊行號 ≤ 右塊行號的組合
            if (leftRowNum > rightRowNum) continue;
            const combinedName = `${leftName}+${rightName}`;
            try {
                // 統一規則:對於 Xr + Ys
                // - [0][0]: Xr 的左上角
                // - [0][1]: Yr 的右上角 (同左塊行號)
                // - [1][0]: Xs 的左下角 (同右塊行號)
                // - [1][1]: Ys 的右下角
                const yRowName = `${String.fromCharCode(65 + rightCol)}${leftRowNum}`;
                const xSRowName = `${String.fromCharCode(65 + leftCol)}${rightRowNum}`;
                if (!nameToPosition.has(yRowName) || !nameToPosition.has(xSRowName)) {
                    console.warn(`Required blocks not found for ${combinedName}: ${yRowName}, ${xSRowName}`);
                    continue;
                }
                const yRowPos = nameToPosition.get(yRowName)!;
                const xSRowPos = nameToPosition.get(xSRowName)!;
                const topLeft = matrix[leftRow][leftCol];
                const topRight = matrix[yRowPos.row][yRowPos.col + 1];
                const bottomLeft = matrix[xSRowPos.row + 1][xSRowPos.col];
                const bottomRight = matrix[rightRow + 1][rightCol + 1];
                const data: SubMatrix2x2 = [
                    [topLeft, topRight],
                    [bottomLeft, bottomRight]
                ];
                if (!data.flat().filter(item => !item).length) {
                    results.push({ name: combinedName, data });
                    break;
                }
            } catch (error) {
                console.warn(`Error processing ${combinedName}:`, error);
                continue;
            }
        }
    }
    return results;
}

四、文字內容提取與Excel導出

4.1 文本匹配

遍歷所有文本實體(McDbText/McDbMText),判斷其幾何中心是否落在某個單元格範圍內,若匹配成功,則將其內容附加到對應單元格。

/**
 * 判斷點是否都在矩形範圍內(含邊界)
 * @param rectPoints - 矩形的四個頂點(順序無關,要求為軸對齊矩形)
 * @param points - 點數組
 * @returns 兩個點都在矩形內返回 true,否則返回 false
 */
function arePointsInRectangle(
    rectPoints: McGePoint3d[],
    points: McGePoint3d[],
): boolean {
    // 提取所有 x 和 y 座標
    const xs = rectPoints.map(p => p.x);
    const ys = rectPoints.map(p => p.y);
    const minX = Math.min(...xs);
    const maxX = Math.max(...xs);
    const minY = Math.min(...ys);
    const maxY = Math.max(...ys);
    /**
     * 檢查單個點是否在矩形邊界內(含邊界)
     */
    const isPointInRect = (p: McGePoint3d): boolean => {
        return p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY;
    };
    // 兩個點都必須在矩形內
    return points.every(pt => isPointInRect(pt));
}
    // 篩選出所有表格數據
    const tableDataArr: CellInput[] = []
    const results = solveWithMerging(cellPointsArr);
    const getTextContent = (matrix: McGePoint3d[][]): string => {
        let str: string = '';
        const textArr = scopeAllEntity.filter(item => {
            const ent = item.clone() as McDbEntity;
            let _minPt: McGePoint3d, _maxPt: McGePoint3d
            if (ent instanceof McDbText) {
                const { minPt, maxPt } = ent.getBoundingBox();
                _minPt = minPt;
                _maxPt = maxPt;
            } else if (item instanceof McDbMText) {
                const textStyleId = MxCpp.getCurrentMxCAD().getDatabase().getCurrentlyTextStyleId();
                ent.textStyleId = textStyleId;
                (ent as McDbMText).reCompute();
                const { minPt, maxPt } = MxCADUtility.getTextEntityBox(ent, false);
                _minPt = minPt;
                _maxPt = maxPt;
            }
            if (_maxPt && _minPt) {
                // matrix扁平化
                const res = needTransformEntity.find(i => i.handle === item.getHandle())
                if (res) {
                    _minPt.transformBy(res.mart);
                    _maxPt.transformBy(res.mart);
                }
                return arePointsInRectangle(matrix.flat(), [_minPt.clone().addvec(_maxPt.sub(_minPt).mult(1 / 2))])
            } else {
                return false
            }
        })
        if (textArr.length) {
            textArr.forEach(text => {
                if (text instanceof McDbText) {
                    str += `${text.textString}\n`
                } else if (text instanceof McDbMText) {
                    str += `${text.contents}\n`
                }
            })
        };
        return str
    }
    results.forEach(async res => {
        if (res.merged) {
            const { fullRangeKeys, matrix } = res.merged;
            const str = getTextContent(matrix);
            tableDataArr.push({ type: DataType.merged, content: str, name: fullRangeKeys.join('+') })
        } else if (res.standalone) {
            const { key, matrix } = res.standalone;
            const str = getTextContent(matrix);
            tableDataArr.push({ type: DataType.standalone, content: str, name: key });
        }
    });

4.2 Excel 輸出

使用 ExcelJS 庫創建工作簿,執行以下操作:

  • 合併單元格:根據 fullRangeKeys 設置跨行跨列
  • 填充內容:寫入提取的文本
  • 樣式美化:添加邊框、居中對齊、自動換行
  • 文件導出:瀏覽器端生成 Blob 下載,Node.js 端保存為 .xlsx 文件
/**
 * 將單元格數據導出為 Excel
 */
async function exportExcelFromCells(
    data: CellInput[],
    filename: string = 'tableData.xlsx'
) {
    const workbook = new ExcelJS.Workbook();
    const worksheet = workbook.addWorksheet('Sheet1');
    const cellRegex = /^([A-Z]+)(\d+)$/;
    const parsedMerges: { start: { row: number; col: number }; end: { row: number; col: number } }[] = [];
    const cellsToSet: { row: number; col: number; value: string }[] = [];
    /**
     * 解析 A1 格式為 {row, col}
     */
    function parseCellRef(cellName: string): { row: number; col: number } {
        const match = cellName.match(cellRegex);
        if (!match) throw new Error(`無效的單元格名: ${cellName}`);
        const [, colStr, rowStr] = match;
        let col = 0;
        for (let i = 0; i < colStr.length; i++) {
            col = col * 26 + (colStr.charCodeAt(i) - 64);
        }
        return { row: parseInt(rowStr), col };
    }
    // 第一步:處理所有數據
    for (const item of data) {
        if (item.type === DataType.merged) {
            const cellNames = item.name.split('+').map(s => s.trim());
            const positions = cellNames.map(parseCellRef);
            const startRow = Math.min(...positions.map(p => p.row));
            const endRow = Math.max(...positions.map(p => p.row));
            const startCol = Math.min(...positions.map(p => p.col));
            const endCol = Math.max(...positions.map(p => p.col));
            parsedMerges.push({
                start: { row: startRow, col: startCol },
                end: { row: endRow, col: endCol }
            });
            worksheet.mergeCells(startRow, startCol, endRow, endCol);
            const masterCell = worksheet.getCell(startRow, startCol);
            masterCell.value = item.content;
            masterCell.alignment = { horizontal: 'center', vertical: 'middle' };
        } else if (item.type === DataType.standalone) {
            const pos = parseCellRef(item.name);
            cellsToSet.push({ row: pos.row, col: pos.col, value: item.content });
        }
    }
    // 第二步:設置獨立單元格(跳過合併區域)
    for (const cell of cellsToSet) {
        const isOverlapped = parsedMerges.some(merge =>
            cell.row >= merge.start.row &&
            cell.row <= merge.end.row &&
            cell.col >= merge.start.col &&
            cell.col <= merge.end.col
        );
        if (!isOverlapped) {
            const wsCell = worksheet.getCell(cell.row, cell.col);
            wsCell.value = cell.value;
        }
    }
    //  第三步:添加邊框樣式到所有已使用的單元格
    //  正確寫法:TypeScript 兼容
    const borderStyle = {
        top: { style: 'thin' as const, color: { argb: 'FF000000' } },
        left: { style: 'thin' as const, color: { argb: 'FF000000' } },
        bottom: { style: 'thin' as const, color: { argb: 'FF000000' } },
        right: { style: 'thin' as const, color: { argb: 'FF000000' } }
    };
    // 獲取最大行列範圍
    let maxRow = 1;
    let maxCol = 1;
    [...cellsToSet, ...parsedMerges.flatMap(merge => [
        merge.start, { row: merge.end.row, col: merge.end.col }
    ])].forEach(pos => {
        maxRow = Math.max(maxRow, pos.row);
        maxCol = Math.max(maxCol, pos.col);
    });
    // 為所有可能用到的單元格加邊框
    for (let row = 1; row <= maxRow; row++) {
        for (let col = 1; col <= maxCol; col++) {
            const cell = worksheet.getCell(row, col);
            if (cell.value !== null && cell.value !== undefined) {
                cell.border = borderStyle;
                // 可選:默認居中對齊
                if (!cell.alignment) {
                    cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
                }
            }
        }
    }
    // 瀏覽器環境
    const buffer = await workbook.xlsx.writeBuffer();
    const blob = new Blob([buffer], {
        type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    });
    // @ts-ignore
    saveAs(blob, filename);
}
worksheet.mergeCells(startRow, startCol, endRow, endCol);
masterCell.value = item.content;
masterCell.alignment = { horizontal: 'center', vertical: 'middle' };

五、實踐結果

根據上述步驟實踐,我們能得到一個圖紙表格提取的初步demo,如果遇到其他表格情況可以參考上述實現思路在此基礎上二開更多識別表格的功能。
我們編寫的提取表格的demo的實踐效果如下:

如果想要更多提取表格相關的功能實踐,可在demo的擴展工具中查看:

若想要查看錶格提取的源碼,可直接下載我們的雲圖在線開發包。

user avatar user_2dx56kla Avatar pengxiaohei Avatar front_yue Avatar shuirong1997 Avatar guixiangyyds Avatar hyfhao Avatar aser1989 Avatar mandy_597086799bac8 Avatar yanyue404 Avatar sy_records Avatar hsr2022 Avatar hu_qi Avatar
Favorites 66 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.