diff --git a/src/core/project-info.ts b/src/core/project-info.ts new file mode 100644 index 0000000..ed7227a --- /dev/null +++ b/src/core/project-info.ts @@ -0,0 +1,54 @@ +/** + * 프로젝트명 · 교량명 from the input workbook (2026-11-06). Pure. + * + * The new input form (운곡교AA.xlsx, 운곡교(완도,광주)데이터) has them at the top of '형상정보': B1 '프로젝트명:' → C1, + * B2 '교량명:' → C2. The label is searched in the first 20 rows × 10 columns of every sheet (form layouts move), and the + * value is the text after the colon in the same cell, else the first non-empty cell to its right (up to 4 columns). + * Workbooks without the labels (운곡교P1입력.xlsx) give nothing, and the dialog keeps what the user typed. + */ +import * as XLSX from 'xlsx'; + +export interface ProjectInfo { + projectName?: string; + bridgeName?: string; + /** Where the values were read, e.g. "형상정보!C1 · C2". */ + source?: string; +} + +const LABELS: { key: 'projectName' | 'bridgeName'; re: RegExp }[] = [ + { key: 'projectName', re: /^\s*프로젝트\s*명\s*[::]?\s*(.*)$/ }, + { key: 'bridgeName', re: /^\s*교량\s*명\s*[::]?\s*(.*)$/ }, +]; +const ROWS = 20, COLS = 10, LOOK_RIGHT = 4; + +export function readProjectInfo(wb: XLSX.WorkBook): ProjectInfo { + const info: ProjectInfo = {}; + const where: string[] = []; + for (const sheetName of wb.SheetNames) { + const ws = wb.Sheets[sheetName]; + if (!ws) continue; + const at = (r: number, c: number) => { + const cell = ws[XLSX.utils.encode_cell({ r, c })]; + const v = cell?.v; + return v === undefined || v === null ? '' : String(v).trim(); + }; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const text = at(r, c); + if (!text) continue; + for (const { key, re } of LABELS) { + if (info[key] !== undefined) continue; + const m = re.exec(text); + if (!m) continue; + let value = m[1].trim(), col = c; + for (let k = 1; !value && k <= LOOK_RIGHT; k++) { value = at(r, c + k); col = c + k; } + // A neighbouring label is not a value (e.g. an empty form row). + if (!value || LABELS.some((l) => l.re.test(value))) continue; + info[key] = value; + where.push(`${sheetName}!${XLSX.utils.encode_cell({ r, c: col })}`); + } + } + if (info.projectName !== undefined && info.bridgeName !== undefined) break; + } + if (where.length) info.source = where.join(' · '); + return info; +} diff --git a/src/main.ts b/src/main.ts index 0d63eb2..b090712 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ import { import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing'; import { lenText, skewAngle } from './core/abut/rebar/skew-layout'; import { defaultJointCount, jointPositions } from './core/abut/joints'; +import { type ProjectInfo as WorkbookNames, readProjectInfo } from './core/project-info'; import { ABUT_BLOCK_PAD_MM, ABUT_BLOCK_REBAR_DEFAULT, abutBlockRebar, type AbutBlockRebar, type AbutBlockSettings } from './core/abut/rebar/block'; import { ABUT_BLOCK_COLORS, abutBlockPlanSvg as abutBlockRebarPlanSvg, abutBlockSectionSvg as abutBlockRebarSectionSvg } from './view/abut-block-rebar-svg'; import { ABUT_SLAB_REBAR_DEFAULT, ABUT_SLAB_SIZES, abutSlabGeom, abutSlabRebar, type AbutSlabGeom, type AbutSlabRebar, type AbutSlabRebarSettings } from './core/abut/rebar/slab'; @@ -190,6 +191,8 @@ interface Pending { abutments: AbutRecord[]; abutIssues: Issue[]; selectedAbutments: Set; + /** 프로젝트명 · 교량명 read from the workbook ('형상정보' B1 · B2 form), when present (2026-11-06). */ + projectInfo: WorkbookNames; } const state = { @@ -5779,7 +5782,9 @@ function renderDialog(): void { const p = state.pending; const sheetsBox = $('sheets'); sheetsBox.replaceChildren(); - $('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : ''; + $('file-name').textContent = p + ? `선택된 파일: ${p.fileName}${p.projectInfo.source ? ` · 프로젝트명 · 교량명: 엑셀 ${p.projectInfo.source} (직접 입력한 칸은 그대로 둡니다)` : ''}` + : ''; if (!p) { sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.')); renderPreview([]); @@ -5946,7 +5951,9 @@ function loadBytes(bytes: Uint8Array, fileName: string): void { for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E')); sheet.ok = sheet.ok && own.every((e) => e.model !== null); } - const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues, selectedAbutments: new Set() }; + const projectInfo = readProjectInfo(wb); + fillNamesFromWorkbook(projectInfo); + const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues, selectedAbutments: new Set(), projectInfo }; for (const e of entries) e.checked = selectable(pending, e); for (const rec of pending.abutments) if (selectableAbut(pending, rec)) pending.selectedAbutments.add(rec.name); state.pending = pending; @@ -6129,6 +6136,8 @@ $('edit-regen').addEventListener('click', () => { }); $('project-name').addEventListener('input', updateApply); +// A typed name is the user's: a later workbook no longer replaces it (fillNamesFromWorkbook). +for (const id of ['project-name', 'bridge-name']) $(id).addEventListener('input', (e) => { delete (e.target as HTMLInputElement).dataset.auto; (e.target as HTMLInputElement).title = ''; }); $('pick-file').addEventListener('click', () => $('file').click()); $('download-template').addEventListener('click', () => { @@ -6144,9 +6153,27 @@ $('file').addEventListener('change', async (ev) => { /** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */ const SAMPLE_PROJECT = { name: '운곡교P1', bridge: '운곡교' } as const; +/** + * 프로젝트명 · 교량명 from the workbook (2026-11-06): fill a field that is empty or still holds a value filled from a + * workbook (data-auto), never one the user typed. Workbooks without the labels leave the fields as they are. + */ +function fillNamesFromWorkbook(info: WorkbookNames): void { + for (const [id, value] of [['project-name', info.projectName], ['bridge-name', info.bridgeName]] as const) { + if (!value) continue; + const input = $(id); + if (input.value.trim() && input.dataset.auto !== '1') continue; + input.value = value; + input.dataset.auto = '1'; + input.title = `엑셀에서 읽음 (${info.source ?? ''})`; + } + updateApply(); +} + function fillSampleNames(): void { $('project-name').value = SAMPLE_PROJECT.name; $('bridge-name').value = SAMPLE_PROJECT.bridge; + // Filled by the app, not typed: a workbook with names may replace them (fillNamesFromWorkbook). + for (const id of ['project-name', 'bridge-name']) $(id).dataset.auto = '1'; // Setting .value does not fire 'input', so the apply button must be refreshed here. updateApply(); } diff --git a/tests/project-info.test.ts b/tests/project-info.test.ts new file mode 100644 index 0000000..fba5f32 --- /dev/null +++ b/tests/project-info.test.ts @@ -0,0 +1,30 @@ +import * as XLSX from 'xlsx'; +import { describe, expect, it } from 'vitest'; +import { readProjectInfo } from '../src/core/project-info'; +import { loadInputWorkbook } from './helpers'; + +const book = (sheets: Record) => { + const wb = XLSX.utils.book_new(); + for (const [name, rows] of Object.entries(sheets)) XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name); + return wb; +}; + +describe('프로젝트명 · 교량명 (엑셀 입력)', () => { + it("the '형상정보' form: B1 '프로젝트명:' → C1, B2 '교량명:' → C2 (운곡교AA · 완도·광주 데이터 양식)", () => { + const wb = book({ 형상정보: [[null, '프로젝트명:', '고속국도 제25호선 강진~광주간 건설공사 제5공구'], [null, '교량명:', '운곡교'], [null, '교대명', '형식']], IP: [['교각명']] }); + expect(readProjectInfo(wb)).toEqual({ projectName: '고속국도 제25호선 강진~광주간 건설공사 제5공구', bridgeName: '운곡교', source: '형상정보!C1 · 형상정보!C2' }); + }); + + it('the value may follow the colon in the same cell, or sit a few columns to the right, on any sheet', () => { + const wb = book({ 표지: [['교량명 : 운곡교'], [], ['프로젝트 명', null, null, '과업 A']] }); + expect(readProjectInfo(wb)).toMatchObject({ projectName: '과업 A', bridgeName: '운곡교' }); + }); + + it('an empty form row or a neighbouring label is not taken as the value', () => { + expect(readProjectInfo(book({ 형상정보: [['프로젝트명:', '교량명:']] }))).toEqual({}); + }); + + it('the sample workbook (교각 · IP · 받침) has no names: nothing is read', () => { + expect(readProjectInfo(loadInputWorkbook())).toEqual({}); + }); +});