From d8c75722f47e4daf4dea6c68da2d25114e902a31 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:08:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EA=B3=A0=EB=A5=B8=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=E2=86=92=20=ED=91=9C=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EC=9D=B8=EC=8B=9D=20=ED=95=A8=EC=88=98=20=C2=B7=20=ED=91=9C=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=C2=B7=20=EC=84=A0=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=C2=B7=20=EA=B8=80=20=EC=9E=90=EB=A6=AC=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1(83-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../src/helpers/table-from-drawing.ts | 381 ++++++++++++++++++ .../tester/cad/table-from-drawing.test.ts | 192 +++++++++ 2 files changed, 573 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts create mode 100644 resources/tester/cad/table-from-drawing.test.ts diff --git a/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts b/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts new file mode 100644 index 000000000..0f88531ff --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts @@ -0,0 +1,381 @@ +/** + * 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다. + * ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이) + * ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 · + * 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글 + * ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백 + * 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다. + * 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`. + * 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types). + */ +import { cellOwners, type TableCells } from './table-geometry.ts'; + +export type DrawingCellValue = string | number; + +export interface DrawingTableMerge { + row: number; + col: number; + rowSpan: number; + colSpan: number; +} + +export interface DrawingTable { + /** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */ + kind: 'table' | 'grid' | 'text'; + rows: DrawingCellValue[][]; + merges: DrawingTableMerge[]; + /** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */ + colWidths: number[]; + rowHeights: number[]; +} + +/** 엔티티 JSON 중 읽는 칸만 */ +export interface DrawingEntityJson { + type: string; + // biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림 + shapeData?: any; + children?: DrawingEntityJson[] | null; +} + +interface Seg { + x1: number; + y1: number; + x2: number; + y2: number; +} +interface Label { + text: string; + x: number; + y: number; + size: number; +} +interface Interval { + at: number; + from: number; + to: number; +} + +const NUMBER = /^[+\-−]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-−]?\.\d+$/; + +/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */ +export function cellValue(text: string): DrawingCellValue { + const trimmed = text.trim(); + if (!NUMBER.test(trimmed)) return trimmed; + const value = Number(trimmed.replace(/,/g, '').replace('−', '-')); + return Number.isFinite(value) ? value : trimmed; +} + +const span = (value: number | undefined): number => Math.max(1, Math.round(value ?? 1)); + +/** ① 표 엔티티 → 표 자료 */ +function fromTableEntity(data: { + columnWidths: number[]; + rowHeights: number[]; + cells: TableCells; +}): DrawingTable { + const rowCount = data.rowHeights.length; + const colCount = data.columnWidths.length; + const rows: DrawingCellValue[][] = []; + const merges: DrawingTableMerge[] = []; + for (let row = 0; row < rowCount; row += 1) { + const line: DrawingCellValue[] = []; + for (let col = 0; col < colCount; col += 1) { + const cell = data.cells[row]?.[col]; + line.push(cell ? cellValue(cell.text ?? '') : ''); + if (!cell) continue; + const rowSpan = Math.min(span(cell.rowSpan), rowCount - row); + const colSpan = Math.min(span(cell.colSpan), colCount - col); + if (rowSpan > 1 || colSpan > 1) merges.push({ row, col, rowSpan, colSpan }); + } + rows.push(line); + } + return { + kind: 'table', + rows, + merges, + colWidths: [...data.columnWidths], + rowHeights: [...data.rowHeights], + }; +} + +/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */ +function collect(entities: DrawingEntityJson[], segs: Seg[], labels: Label[]): void { + for (const entity of entities) { + const data = entity.shapeData; + if (entity.type === 'Line' && data?.startPoint && data?.endPoint) { + segs.push({ + x1: data.startPoint.x, + y1: data.startPoint.y, + x2: data.endPoint.x, + y2: data.endPoint.y, + }); + } else if (entity.type === 'Rectangle' && Array.isArray(data?.points)) { + const points = data.points as { x: number; y: number }[]; + for (let index = 0; index < points.length; index += 1) { + const a = points[index]; + const b = points[(index + 1) % points.length]; + segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }); + } + } else if (entity.type === 'PolyLine' && entity.children) { + collect(entity.children, segs, labels); + } else if (entity.type === 'Text' && data?.basePoint && String(data.label ?? '').trim()) { + labels.push(labelOf(data)); + } + } +} + +/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */ +function labelOf(data: { + label: string; + basePoint: { x: number; y: number }; + options?: { fontSize?: number; boxWidth?: number; boxHeight?: number }; +}): Label { + const size = data.options?.fontSize ?? 2.2; + const boxed = Boolean(data.options?.boxWidth && data.options?.boxHeight); + return { + text: data.label, + x: data.basePoint.x, + y: boxed ? data.basePoint.y : data.basePoint.y + size * 0.35, + size, + }; +} + +/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */ +function cluster(values: number[], tol: number): number[] { + const sorted = [...values].sort((a, b) => a - b); + const groups: number[][] = []; + for (const value of sorted) { + const last = groups[groups.length - 1]; + if (last && value - last[last.length - 1] <= tol) last.push(value); + else groups.push([value]); + } + return groups.map((group) => group.reduce((sum, value) => sum + value, 0) / group.length); +} + +const nearest = (edges: number[], value: number, tol: number): number => { + let best = -1; + let bestGap = tol; + for (let index = 0; index < edges.length; index += 1) { + const gap = Math.abs(edges[index] - value); + if (gap <= bestGap) { + best = index; + bestGap = gap; + } + } + return best; +}; + +/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */ +function covers(intervals: Interval[], from: number, to: number, tol: number): boolean { + const parts = intervals + .map((part) => [Math.max(part.from, from), Math.min(part.to, to)] as const) + .filter(([a, b]) => b > a) + .sort((p, q) => p[0] - q[0]); + let covered = 0; + let reach = from; + for (const [a, b] of parts) { + const start = Math.max(a, reach); + if (b > start) covered += b - start; + reach = Math.max(reach, b); + } + return covered >= (to - from) * 0.9 - tol; +} + +/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */ +function fromGrid(segs: Seg[], labels: Label[]): DrawingTable | null { + const box = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity }; + for (const s of segs) { + box.x0 = Math.min(box.x0, s.x1, s.x2); + box.x1 = Math.max(box.x1, s.x1, s.x2); + box.y0 = Math.min(box.y0, s.y1, s.y2); + box.y1 = Math.max(box.y1, s.y1, s.y2); + } + const extent = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-9); + const tol = extent * 0.003; + const horizontal: Seg[] = []; + const vertical: Seg[] = []; + for (const s of segs) { + const dx = Math.abs(s.x2 - s.x1); + const dy = Math.abs(s.y2 - s.y1); + if (Math.max(dx, dy) <= tol) continue; // 점 같은 선 + if (dy <= dx * 0.01) horizontal.push(s); + else if (dx <= dy * 0.01) vertical.push(s); + } + let colEdges = cluster( + vertical.map((s) => (s.x1 + s.x2) / 2), + tol + ); + let rowEdges = cluster( + horizontal.map((s) => (s.y1 + s.y2) / 2), + tol + ).reverse(); // 위 → 아래 + const lineOf = (list: Seg[], edges: number[], vertical: boolean): Interval[][] => { + const byEdge: Interval[][] = edges.map(() => []); + for (const s of list) { + const at = vertical ? (s.x1 + s.x2) / 2 : (s.y1 + s.y2) / 2; + const index = nearest(edges, at, tol); + if (index < 0) continue; + const [a, b] = vertical ? [s.y1, s.y2] : [s.x1, s.x2]; + byEdge[index].push({ at, from: Math.min(a, b), to: Math.max(a, b) }); + } + return byEdge; + }; + // 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음 + for (let pass = 0; pass < 2; pass += 1) { + if (colEdges.length < 2 || rowEdges.length < 2) return null; + const vLines = lineOf(vertical, colEdges, true); + const hLines = lineOf(horizontal, rowEdges, false); + const keepCol = colEdges.map((_, i) => + rowEdges.slice(1).some((y, j) => covers(vLines[i], y + tol, rowEdges[j] - tol, tol)) + ); + const keepRow = rowEdges.map((_, j) => + colEdges.slice(1).some((x, i) => covers(hLines[j], colEdges[i] + tol, x - tol, tol)) + ); + colEdges = colEdges.filter((_, i) => keepCol[i]); + rowEdges = rowEdges.filter((_, j) => keepRow[j]); + } + if (colEdges.length < 2 || rowEdges.length < 2) return null; + const colCount = colEdges.length - 1; + const rowCount = rowEdges.length - 1; + const vLines = lineOf(vertical, colEdges, true); + const hLines = lineOf(horizontal, rowEdges, false); + + // 안쪽 선 빠진 곳 = 같은 묶음(union-find) + const parent = Array.from({ length: rowCount * colCount }, (_, i) => i); + const find = (i: number): number => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + const join = (a: number, b: number): void => { + parent[find(a)] = find(b); + }; + for (let r = 0; r < rowCount; r += 1) { + for (let c = 1; c < colCount; c += 1) { + if (!covers(vLines[c], rowEdges[r + 1] + tol, rowEdges[r] - tol, tol)) + join(r * colCount + c - 1, r * colCount + c); + } + } + for (let r = 1; r < rowCount; r += 1) { + for (let c = 0; c < colCount; c += 1) { + if (!covers(hLines[r], colEdges[c] + tol, colEdges[c + 1] - tol, tol)) + join((r - 1) * colCount + c, r * colCount + c); + } + } + // 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로 + const groups = new Map(); + for (let i = 0; i < rowCount * colCount; i += 1) { + const root = find(i); + const members = groups.get(root); + if (members) members.push(i); + else groups.set(root, [i]); + } + const cells: TableCells = Array.from({ length: rowCount }, () => + Array.from({ length: colCount }, () => ({ text: '' })) + ); + for (const members of groups.values()) { + if (members.length < 2) continue; + const rs = members.map((i) => Math.floor(i / colCount)); + const cs = members.map((i) => i % colCount); + const [r0, r1, c0, c1] = [Math.min(...rs), Math.max(...rs), Math.min(...cs), Math.max(...cs)]; + if ((r1 - r0 + 1) * (c1 - c0 + 1) !== members.length) continue; + cells[r0][c0] = { text: '', rowSpan: r1 - r0 + 1, colSpan: c1 - c0 + 1 }; + for (let r = r0; r <= r1; r += 1) + for (let c = c0; c <= c1; c += 1) if (r !== r0 || c !== c0) cells[r][c] = null; + } + + // 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음 + const owners = cellOwners(cells, rowCount, colCount); + const texts = new Map(); + let outside = 0; + for (const label of labels) { + const c = colEdges.findIndex( + (x, i) => i < colCount && label.x >= x && label.x <= colEdges[i + 1] + ); + const r = rowEdges.findIndex( + (y, j) => j < rowCount && label.y <= y && label.y >= rowEdges[j + 1] + ); + if (c < 0 || r < 0) { + outside += 1; + continue; + } + const key = owners[r][c] ?? `${r}:${c}`; + const inCell = texts.get(key); + if (inCell) inCell.push(label); + else texts.set(key, [label]); + } + if (!labels.length || outside * 2 > labels.length) return null; + const rows: DrawingCellValue[][] = cells.map((line) => line.map(() => '')); + const merges: DrawingTableMerge[] = []; + cells.forEach((line, r) => + line.forEach((cell, c) => { + if (!cell) return; + if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1) + merges.push({ row: r, col: c, rowSpan: cell.rowSpan ?? 1, colSpan: cell.colSpan ?? 1 }); + const inCell = texts.get(`${r}:${c}`) ?? []; + const sorted = sortReading(inCell); + if (sorted.length) rows[r][c] = cellValue(sorted.map((label) => label.text.trim()).join(' ')); + }) + ); + return { + kind: 'grid', + rows, + merges, + colWidths: colEdges.slice(1).map((x, i) => x - colEdges[i]), + rowHeights: rowEdges.slice(1).map((y, j) => rowEdges[j] - y), + }; +} + +/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */ +function textLines(labels: Label[]): Label[][] { + const sorted = [...labels].sort((a, b) => b.y - a.y); + const lines: { y: number; size: number; items: Label[] }[] = []; + for (const label of sorted) { + const last = lines[lines.length - 1]; + if (last && last.y - label.y <= Math.max(last.size, label.size) * 0.5) last.items.push(label); + else lines.push({ y: label.y, size: label.size, items: [label] }); + } + return lines.map((line) => line.items.sort((a, b) => a.x - b.x)); +} + +const sortReading = (labels: Label[]): Label[] => textLines(labels).flat(); + +/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */ +function fromText(labels: Label[]): DrawingTable { + const lines = textLines(labels); + const width = Math.max(...lines.map((line) => line.length)); + return { + kind: 'text', + rows: lines.map((line) => { + const values: DrawingCellValue[] = line.map((label) => cellValue(label.text)); + while (values.length < width) values.push(''); + return values; + }), + merges: [], + colWidths: [], + rowHeights: [], + }; +} + +/** + * 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리. + * 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null. + */ +export function tableFromDrawing(entities: DrawingEntityJson[]): DrawingTable | null { + const tables = entities.filter( + (entity) => entity.type === 'Table' && entity.shapeData?.columnWidths?.length + ); + if (tables.length) { + const size = (entity: DrawingEntityJson) => + entity.shapeData.columnWidths.length * entity.shapeData.rowHeights.length; + const biggest = tables.reduce((best, entity) => (size(entity) > size(best) ? entity : best)); + return fromTableEntity(biggest.shapeData); + } + const segs: Seg[] = []; + const labels: Label[] = []; + collect(entities, segs, labels); + if (!labels.length) return null; + return (segs.length ? fromGrid(segs, labels) : null) ?? fromText(labels); +} diff --git a/resources/tester/cad/table-from-drawing.test.ts b/resources/tester/cad/table-from-drawing.test.ts new file mode 100644 index 000000000..0a49213f3 --- /dev/null +++ b/resources/tester/cad/table-from-drawing.test.ts @@ -0,0 +1,192 @@ +/** + * 고른 범위 → 표 자료(`helpers/table-from-drawing.ts` · M02 83-1) 검증. + * 실행: node --experimental-strip-types resources/tester/cad/table-from-drawing.test.ts + * 견본 셋 = 표 엔티티 · 선 표(병합 · PolyLine · 끊긴 선) · 글만 + 빈 범위 · 글 없는 선 · 회전된 표. + */ +import assert from "node:assert/strict"; +import { + cellValue, + tableFromDrawing, + type DrawingEntityJson, +} from "../../../B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts"; + +const line = (x1: number, y1: number, x2: number, y2: number): DrawingEntityJson => ({ + type: "Line", + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, +}); +const text = (label: string, x: number, y: number, fontSize = 2.5): DrawingEntityJson => ({ + type: "Text", + shapeData: { label, basePoint: { x, y }, options: { fontSize, textAlign: "left" } }, +}); + +// 숫자 글 +assert.equal(cellValue("1,500"), 1500, "천 단위 쉼표"); +assert.equal(cellValue(" -2.75 "), -2.75, "음수 소수"); +assert.equal(cellValue("12,34"), "12,34", "쉼표 자리 틀리면 글"); +assert.equal(cellValue("D600"), "D600", "글 그대로"); +assert.equal(cellValue("1,234,567.5"), 1234567.5); + +// ① 표 엔티티 — 칸 그대로 · 병합 · 폭 · 높이 +const tableEntity: DrawingEntityJson = { + type: "Table", + shapeData: { + origin: { x: 0, y: 100 }, + columnWidths: [30, 20, 25], + rowHeights: [8, 6], + cells: [ + [{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null], + [null, { text: "1,500" }, { text: "m2" }], + ], + }, +}; +const t1 = tableFromDrawing([tableEntity, text("밖 글", 500, 500)]); +assert.ok(t1); +assert.equal(t1.kind, "table"); +assert.deepEqual(t1.rows, [ + ["구분", "수량", ""], + ["", 1500, "m2"], +]); +assert.deepEqual(t1.merges, [ + { row: 0, col: 0, rowSpan: 2, colSpan: 1 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 2 }, +]); +assert.deepEqual(t1.colWidths, [30, 20, 25]); +assert.deepEqual(t1.rowHeights, [8, 6]); + +// ② 선 표 — 3열 × 3행 · 맨 위 줄 가운데 세로선 빠짐(병합) · 바깥 테두리는 PolyLine · 가로선 한 줄은 끊겨 둘 +// x 경계 0 · 40 · 70 · 100 / y 경계 60 · 50 · 40 · 30 +const outline: DrawingEntityJson = { + type: "PolyLine", + shapeData: null, + children: [ + line(0, 60, 100, 60), + line(100, 60, 100, 30), + line(100, 30, 0, 30), + line(0, 30, 0, 60), + ], +}; +const gridEntities: DrawingEntityJson[] = [ + outline, + line(0, 50, 55, 50), // 끊긴 가로선 둘(겹침 5) + line(50, 50.05, 100, 50.05), // 살짝 어긋남(허용오차 안) + line(0, 40, 100, 40), + line(40, 60, 40, 30), // 세로선 — 맨 위 줄도 지남 + line(70, 50, 70, 30), // 맨 위 줄엔 없음 → (0,1)~(0,2) 병합 + text("구분", 2, 53), + text("수량", 45, 53), + text("흙깎기", 2, 43), + text("1,500", 42, 43), + text("m3", 72, 43), + text("돌쌓기", 2, 33), + text("2.5", 42, 33), + text("m2", 72, 33), + text("(전면)", 2, 31), // 같은 칸 두 줄 → 「돌쌓기 (전면)」 +]; +const t2 = tableFromDrawing(gridEntities); +assert.ok(t2); +assert.equal(t2.kind, "grid"); +assert.deepEqual(t2.rows, [ + ["구분", "수량", ""], + ["흙깎기", 1500, "m3"], + ["돌쌓기 (전면)", 2.5, "m2"], +]); +assert.deepEqual(t2.merges, [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }]); +assert.deepEqual( + t2.colWidths.map((w) => Math.round(w)), + [40, 30, 30], +); +assert.deepEqual( + t2.rowHeights.map((h) => Math.round(h)), + [10, 10, 10], +); + +// 사각형 엔티티 칸 · 짧은 눈금 선은 경계로 안 침 +const rectGrid = tableFromDrawing([ + { + type: "Rectangle", + shapeData: { + points: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 10 }, + { x: 0, y: 10 }, + ], + }, + }, + { + type: "Rectangle", + shapeData: { + points: [ + { x: 20, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 10 }, + { x: 20, y: 10 }, + ], + }, + }, + line(35, 0, 35, 1), // 눈금 + text("가", 2, 4), + text("나", 22, 4), +]); +assert.ok(rectGrid); +assert.equal(rectGrid.kind, "grid"); +assert.deepEqual(rectGrid.rows, [["가", "나"]]); +assert.deepEqual(rectGrid.colWidths, [20, 30]); + +// ③ 글만 — 줄(y 묶음) · 칸(x 차례) · 모자란 칸은 빈 칸 +const t3 = tableFromDrawing([ + text("합계", 0, 10), + text("3,000", 40, 10.3), + text("비고", 80, 9.8), + text("소계", 0, 0), + text("1,000", 40, 0), +]); +assert.ok(t3); +assert.equal(t3.kind, "text"); +assert.deepEqual(t3.rows, [ + ["합계", 3000, "비고"], + ["소계", 1000, ""], +]); +assert.deepEqual(t3.merges, []); + +// 빈 범위 · 글 없는 선 = null +assert.equal(tableFromDrawing([]), null, "빈 범위"); +assert.equal( + tableFromDrawing([line(0, 0, 10, 0), line(0, 5, 10, 5), line(0, 0, 0, 5), line(10, 0, 10, 5)]), + null, + "글 없는 선", +); + +// 회전된 표(30°) — 축 선이 없어 글 자리 폴백 +const rot = (x: number, y: number): [number, number] => { + const a = Math.PI / 6; + return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)]; +}; +const rline = (x1: number, y1: number, x2: number, y2: number) => + line(...rot(x1, y1), ...rot(x2, y2)); +const t4 = tableFromDrawing([ + rline(0, 0, 40, 0), + rline(0, 10, 40, 10), + rline(0, 0, 0, 10), + rline(40, 0, 40, 10), + rline(20, 0, 20, 10), + text("A", ...rot(2, 4)), + text("B", ...rot(22, 4)), +]); +assert.ok(t4); +assert.equal(t4.kind, "text"); +assert.equal(t4.rows.flat().filter(Boolean).length, 2, "글 둘 다 살아 있음"); + +// 격자 밖 글이 반 넘으면 폴백(표가 아닌 범위) +const t5 = tableFromDrawing([ + line(0, 0, 10, 0), + line(0, 10, 10, 10), + line(0, 0, 0, 10), + line(10, 0, 10, 10), + text("안", 2, 4), + text("밖1", 50, 50), + text("밖2", 80, 50), +]); +assert.equal(t5?.kind, "text"); + +console.log("table-from-drawing: 통과");