- Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐 - 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
229 lines
7.8 KiB
TypeScript
229 lines
7.8 KiB
TypeScript
/**
|
|
* 표 기하 — 셀 사각형과 그려야 할 경계선을 계산한다. 상태가 없는 순수 함수라
|
|
* 화면·SVG·내보내기가 같은 규칙을 쓴다.
|
|
*
|
|
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
|
|
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
|
|
*
|
|
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
|
|
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
|
|
*/
|
|
|
|
export interface TableCell {
|
|
text: string;
|
|
/** 오른쪽으로 합칠 칸 수 (기본 1) */
|
|
colSpan?: number;
|
|
/** 아래로 합칠 칸 수 (기본 1) */
|
|
rowSpan?: number;
|
|
align?: 'left' | 'center' | 'right';
|
|
/** 문자 색. 없으면 표 기본색 (라벨·값 색을 칸마다 달리 쓰려고 둔다) */
|
|
color?: string;
|
|
fontSize?: number;
|
|
bold?: boolean;
|
|
italic?: boolean;
|
|
/** 백엔드가 값을 되읽을 때 쓰는 이름 (수량 산출표의 항목 키). 화면 동작에는 쓰지 않는다 */
|
|
key?: string;
|
|
}
|
|
|
|
/** null은 앞 칸의 병합에 먹힌 자리다 */
|
|
export type TableCells = (TableCell | null)[][];
|
|
|
|
export interface CellRect {
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
}
|
|
|
|
/** 열 경계 x 좌표 (길이 = 열 수 + 1) */
|
|
export function columnEdges(originX: number, columnWidths: number[]): number[] {
|
|
const edges = [originX];
|
|
for (const width of columnWidths) edges.push(edges[edges.length - 1] + width);
|
|
return edges;
|
|
}
|
|
|
|
/** 행 경계 y 좌표. 표는 좌측 상단이 원점이라 아래로 내려간다 (길이 = 행 수 + 1) */
|
|
export function rowEdges(originY: number, rowHeights: number[]): number[] {
|
|
const edges = [originY];
|
|
for (const height of rowHeights) edges.push(edges[edges.length - 1] - height);
|
|
return edges;
|
|
}
|
|
|
|
const span = (value: number | undefined) => Math.max(1, Math.round(value ?? 1));
|
|
|
|
/**
|
|
* 칸마다 그 칸을 소유한 앵커 셀의 위치를 적어 둔 표.
|
|
* 병합된 칸은 앵커와 같은 값을 갖는다 — 경계선을 그릴지 판단하는 데 쓴다.
|
|
*/
|
|
export function cellOwners(cells: TableCells, rows: number, columns: number): (string | null)[][] {
|
|
const owners: (string | null)[][] = Array.from({ length: rows }, () =>
|
|
Array.from({ length: columns }, () => null as string | null)
|
|
);
|
|
for (let row = 0; row < rows; row += 1) {
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const cell = cells[row]?.[column];
|
|
if (!cell) continue;
|
|
const key = `${row}:${column}`;
|
|
const rowEnd = Math.min(rows, row + span(cell.rowSpan));
|
|
const columnEnd = Math.min(columns, column + span(cell.colSpan));
|
|
for (let r = row; r < rowEnd; r += 1) {
|
|
for (let c = column; c < columnEnd; c += 1) {
|
|
owners[r][c] = key;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return owners;
|
|
}
|
|
|
|
/** 앵커 셀이 차지하는 사각형 (병합 반영) */
|
|
export function cellRect(
|
|
origin: { x: number; y: number },
|
|
columnWidths: number[],
|
|
rowHeights: number[],
|
|
row: number,
|
|
column: number,
|
|
cell: TableCell | null
|
|
): CellRect {
|
|
const xs = columnEdges(origin.x, columnWidths);
|
|
const ys = rowEdges(origin.y, rowHeights);
|
|
const columnEnd = Math.min(columnWidths.length, column + span(cell?.colSpan));
|
|
const rowEnd = Math.min(rowHeights.length, row + span(cell?.rowSpan));
|
|
return {
|
|
left: xs[column],
|
|
right: xs[columnEnd],
|
|
top: ys[row],
|
|
bottom: ys[rowEnd],
|
|
};
|
|
}
|
|
|
|
export interface BorderSegment {
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
}
|
|
|
|
/**
|
|
* 그려야 할 경계선. 바깥 테두리는 항상 긋고, 안쪽 선은 양옆(위아래) 칸이 같은 병합 셀에
|
|
* 속하면 긋지 않는다 — 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다.
|
|
*/
|
|
export function tableBorders(
|
|
origin: { x: number; y: number },
|
|
columnWidths: number[],
|
|
rowHeights: number[],
|
|
cells: TableCells
|
|
): BorderSegment[] {
|
|
const rows = rowHeights.length;
|
|
const columns = columnWidths.length;
|
|
if (!rows || !columns) return [];
|
|
const xs = columnEdges(origin.x, columnWidths);
|
|
const ys = rowEdges(origin.y, rowHeights);
|
|
const owners = cellOwners(cells, rows, columns);
|
|
const borders: BorderSegment[] = [];
|
|
|
|
// 바깥 테두리
|
|
borders.push({ x1: xs[0], y1: ys[0], x2: xs[columns], y2: ys[0] });
|
|
borders.push({ x1: xs[0], y1: ys[rows], x2: xs[columns], y2: ys[rows] });
|
|
borders.push({ x1: xs[0], y1: ys[0], x2: xs[0], y2: ys[rows] });
|
|
borders.push({ x1: xs[columns], y1: ys[0], x2: xs[columns], y2: ys[rows] });
|
|
|
|
// 안쪽 세로선
|
|
for (let column = 1; column < columns; column += 1) {
|
|
for (let row = 0; row < rows; row += 1) {
|
|
const left = owners[row][column - 1];
|
|
const right = owners[row][column];
|
|
if (left !== null && left === right) continue; // 병합 구간 — 선을 끊는다
|
|
borders.push({ x1: xs[column], y1: ys[row], x2: xs[column], y2: ys[row + 1] });
|
|
}
|
|
}
|
|
|
|
// 안쪽 가로선
|
|
for (let row = 1; row < rows; row += 1) {
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const above = owners[row - 1][column];
|
|
const below = owners[row][column];
|
|
if (above !== null && above === below) continue;
|
|
borders.push({ x1: xs[column], y1: ys[row], x2: xs[column + 1], y2: ys[row] });
|
|
}
|
|
}
|
|
|
|
return borders;
|
|
}
|
|
|
|
/** 격자 교차점 — 스냅점으로 쓴다 */
|
|
export function gridPoints(
|
|
origin: { x: number; y: number },
|
|
columnWidths: number[],
|
|
rowHeights: number[]
|
|
): { x: number; y: number }[] {
|
|
const points: { x: number; y: number }[] = [];
|
|
for (const x of columnEdges(origin.x, columnWidths)) {
|
|
for (const y of rowEdges(origin.y, rowHeights)) points.push({ x, y });
|
|
}
|
|
return points;
|
|
}
|
|
|
|
/** 셀 문자를 놓을 자리. align에 따라 좌우 여백을 준다 */
|
|
export function cellTextAnchor(
|
|
rect: CellRect,
|
|
align: TableCell['align'] = 'center',
|
|
padding = 1
|
|
): { x: number; y: number } {
|
|
const y = (rect.top + rect.bottom) / 2;
|
|
if (align === 'left') return { x: rect.left + padding, y };
|
|
if (align === 'right') return { x: rect.right - padding, y };
|
|
return { x: (rect.left + rect.right) / 2, y };
|
|
}
|
|
|
|
/** 클릭 지점이 들어 있는 칸 (앵커 위치로 돌려준다). 표 밖이면 null */
|
|
export function cellAt(
|
|
origin: { x: number; y: number },
|
|
columnWidths: number[],
|
|
rowHeights: number[],
|
|
cells: TableCells,
|
|
point: { x: number; y: number }
|
|
): { row: number; column: number } | null {
|
|
const xs = columnEdges(origin.x, columnWidths);
|
|
const ys = rowEdges(origin.y, rowHeights);
|
|
if (point.x < xs[0] || point.x > xs[xs.length - 1]) return null;
|
|
if (point.y > ys[0] || point.y < ys[ys.length - 1]) return null;
|
|
|
|
let column = columnWidths.length - 1;
|
|
for (let index = 0; index < columnWidths.length; index += 1) {
|
|
if (point.x <= xs[index + 1]) {
|
|
column = index;
|
|
break;
|
|
}
|
|
}
|
|
let row = rowHeights.length - 1;
|
|
for (let index = 0; index < rowHeights.length; index += 1) {
|
|
if (point.y >= ys[index + 1]) {
|
|
row = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const owner = cellOwners(cells, rowHeights.length, columnWidths.length)[row][column];
|
|
if (!owner) return { row, column };
|
|
const [ownerRow, ownerColumn] = owner.split(':').map(Number);
|
|
return { row: ownerRow, column: ownerColumn };
|
|
}
|
|
|
|
/** 모든 칸이 자기 자리를 갖도록 채운 셀 표 (행·열을 늘렸을 때 쓴다) */
|
|
export function normalizeCells(cells: TableCells, rows: number, columns: number): TableCells {
|
|
// 병합 관계는 원본에서 먼저 읽는다 — 빈 칸을 채운 뒤에 읽으면 병합이 풀린다
|
|
const owners = cellOwners(cells, rows, columns);
|
|
const normalized: TableCells = [];
|
|
for (let row = 0; row < rows; row += 1) {
|
|
const line: (TableCell | null)[] = [];
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const owner = owners[row][column];
|
|
const coveredByMerge = owner !== null && owner !== `${row}:${column}`;
|
|
line.push(coveredByMerge ? null : (cells[row]?.[column] ?? { text: '' }));
|
|
}
|
|
normalized.push(line);
|
|
}
|
|
return normalized;
|
|
}
|