From 41c1771fad8c82969342cab23e858b2603753333 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:16:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EB=B6=80=EC=86=8D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B9=B4=EB=93=9C=EC=97=90=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=86=8D=20=EA=B0=92=20=ED=91=9C=EC=8B=9C=C2=B7=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=A0=90?= =?UTF-8?q?=EC=84=A0=20=ED=85=8C=EB=91=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개수만 보이던 부속 파일 카드에 파일 안의 중요한 값을 함께 보임. - .shx — 도형 종류(폴리라인 등)와 범위 크기를 머리글에서 읽음. - .dbf — 속성 이름 목록을 머리글 서술자에서 읽음(앞 4개). - .cpg — 같은 세트 .dbf 의 첫 글자 속성을 그 인코딩으로 읽어 견본으로 보임. - .prj — 중앙자오선(투영 매개변수)과 길이 단위. proj 문자열 변환을 쓰지 않아 정보 손실 경고가 나지 않음. - 카드 표시 줄 수를 2 → 3 으로 늘리고, 길이 단위 표기를 m 으로 통일. - GeoTIFF 썸네일에 다른 미리보기와 같은 점선 테두리. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Engine_Analyze.py | 114 +++++++++++++++++- B03_FileInput/B03_FileInput_UI_Preview.ts | 75 ++++++++++-- B03_FileInput/B03_FileInput_UI_Style.css | 5 +- 3 files changed, 180 insertions(+), 14 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 67336adb..60c043a3 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,11 +1,13 @@ """B03 원본 입력 파일 메타데이터 분석.""" import base64 +import codecs import csv import io import logging import math import re +import struct from pathlib import Path from threading import get_ident from typing import Any @@ -255,11 +257,43 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]: "authority": crs.to_authority(), "custom_authority_codes": custom_authority_codes, "is_valid": True, + # 카드에 보일 값 — 좌표계 이름만으로는 어느 원점인지 안 보인다(2026-09-04 지시). + **_prj_facts(crs), } ) return metadata +def _prj_facts(crs: Any) -> dict[str, Any]: + """좌표계에서 카드에 보일 값 — 중앙자오선(원점 경도)·길이 단위.""" + from common_util.common_util_crs import strip_bound + + flattened = strip_bound(crs) + horizontal = next( + ( + strip_bound(item) + for item in (flattened.sub_crs_list or [flattened]) + if strip_bound(item).is_projected or strip_bound(item).is_geographic + ), + None, + ) + if horizontal is None: + return {} + facts: dict[str, Any] = {} + # 원점 경도는 투영 매개변수에서 곧장 읽는다 — proj 문자열로 바꾸면 정보가 깎인다. + operation = horizontal.coordinate_operation + for param in operation.params if operation is not None else []: + label = (param.name or "").lower() + if "longitude" in label and ("origin" in label or "meridian" in label): + if isinstance(param.value, (int, float)): + facts["central_meridian_deg"] = float(param.value) + break + axes = list(horizontal.axis_info or []) + if axes and axes[0].unit_name: + facts["unit_name"] = axes[0].unit_name + return facts + + def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: """TFW의 affine 변환 계수와 유효성을 분석한다.""" source = Path(path) @@ -467,13 +501,32 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: } -def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: - """DBF 머리글만 읽어 레코드 수·속성 수를 낸다 (2026-09-04 카드 표시용). +# DBF 머리글 최대 크기 — 32바이트 고정 + 속성 255개 × 32바이트 + 끝 표시. +_DBF_HEADER_MAX = 32 + 255 * 32 + 1 - 머리글 32바이트가 전부라 파일 크기와 무관하게 즉시 끝난다. + +def _dbf_field_defs(head: bytes) -> list[tuple[str, str, int]]: + """머리글의 속성 서술자에서 (이름, 형식, 길이)를 뽑는다.""" + fields: list[tuple[str, str, int]] = [] + for start in range(32, len(head) - 31, 32): + block = head[start : start + 32] + if block[0] in (0x0D, 0x00): + break + name = block[:11].partition(bytes(1))[0].decode("cp949", errors="replace").strip() + if name: + fields.append((name, chr(block[11]), block[16])) + return fields + + +def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: + """DBF 머리글만 읽어 레코드 수·속성 수·**속성 이름**을 낸다 (2026-09-04 카드 표시용). + + 머리글은 최대 8KB라 파일 크기와 무관하게 즉시 끝난다. 속성 이름은 카드에서 + 「이 표에 무엇이 들었나」를 한눈에 보이는 값이다(2026-09-04 사용자 지시). """ source = Path(path) - head = source.read_bytes()[:32] + with source.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) metadata: dict[str, Any] = { "file": source.name, "extension": "dbf", @@ -485,6 +538,7 @@ def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: metadata["record_count"] = record_count # 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트. metadata["field_count"] = max(0, (header_length - 33) // 32) + metadata["field_names"] = [name for name, _type, _length in _dbf_field_defs(head)] return metadata @@ -504,19 +558,67 @@ def analyze_shx_metadata(path: str | Path) -> dict[str, Any]: # 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안). words = int.from_bytes(head[24:28], "big") metadata["shape_count"] = max(0, (words * 2 - 100) // 8) + if len(head) >= 68: + # 32~35바이트: 도형 종류. 36~67바이트: 범위(Xmin, Ymin, Xmax, Ymax). + metadata["shape_type"] = int.from_bytes(head[32:36], "little") + x_min, y_min, x_max, y_max = struct.unpack("<4d", head[36:68]) + if x_max > x_min and y_max > y_min: + metadata["extent_width_m"] = x_max - x_min + metadata["extent_height_m"] = y_max - y_min return metadata +def _dbf_text_sample(dbf: Path, encoding: str) -> str | None: + """같은 세트 DBF 의 첫 글자 속성 값을 그 인코딩으로 읽어 본다. + + CPG 는 이름 한 줄이 전부라, 그 이름이 맞는지는 **글자가 깨지는지**로만 보인다 + (2026-09-04 사용자 지시). 머리글 + 레코드 한 줄만 읽는다. + """ + try: + with dbf.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) + if len(head) < 32: + return None + header_length = int.from_bytes(head[8:10], "little") + fields = _dbf_field_defs(head) + offset = 1 # 레코드 첫 바이트는 삭제 표시 + for _name, kind, length in fields: + if kind == "C": + handle.seek(header_length + offset) + raw = handle.read(length) + return raw.decode(encoding, errors="replace").strip() or None + offset += length + except (OSError, LookupError, ValueError): + return None + return None + + +def _codec_name(label: str) -> str | None: + """CPG 가 적은 이름을 파이썬 코덱 이름으로 바꾼다 (숫자만 적힌 것은 코드페이지).""" + candidate = label.strip() + if candidate.isdigit(): + candidate = f"cp{candidate}" + try: + return codecs.lookup(candidate).name + except LookupError: + return None + + def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]: - """CPG 는 인코딩 이름 한 줄이 전부다 (2026-09-04 카드 표시용).""" + """CPG 는 인코딩 이름 한 줄이 전부다 — 같은 세트 DBF 의 글자 견본을 함께 낸다.""" source = Path(path) text = source.read_text(encoding="utf-8", errors="replace").strip() - return { + metadata: dict[str, Any] = { "file": source.name, "extension": "cpg", "size_bytes": source.stat().st_size, "encoding": text or None, } + codec = _codec_name(text) if text else None + dbf = source.with_suffix(".dbf") + if codec and dbf.is_file(): + metadata["encoding_sample"] = _dbf_text_sample(dbf, codec) + return metadata def analyze_input_metadata(path: str | Path) -> dict[str, Any]: diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts index 6c60af26..a9fefe44 100644 --- a/B03_FileInput/B03_FileInput_UI_Preview.ts +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -99,9 +99,33 @@ export function readThumbnail(metadata: Record | undefined): st return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null; } +/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */ +const SHAPE_TYPE_LABELS: Record = { + 0: "빈 도형", + 1: "점", + 3: "폴리라인", + 5: "폴리곤", + 8: "다중점", + 11: "점(3D)", + 13: "폴리라인(3D)", + 15: "폴리곤(3D)", + 18: "다중점(3D)", + 21: "점(M)", + 23: "폴리라인(M)", + 25: "폴리곤(M)", + 28: "다중점(M)", + 31: "복합면", +}; + +/** 129.002890277778 → 「129.0029°E」. */ +function meridianLabel(degrees: number): string { + const rounded = Math.abs(Math.round(degrees * 10000) / 10000); + return `${rounded}°${degrees < 0 ? "W" : "E"}`; +} + /** - * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다 - * (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다. + * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **파일 안의 중요한 값**을 뽑는다 + * (2026-09-04 사용자 지시 — 개수만이 아니라 내용이 보이게). 첫 줄은 굵게, 나머지는 작게. */ export function readFacts(metadata: Record | undefined): string[] { if (!metadata) return []; @@ -109,15 +133,38 @@ export function readFacts(metadata: Record | undefined): string const number = (value: unknown): number | null => finite(value); const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null; + const round = (value: number): string => Math.round(value).toLocaleString(); + // SHX — 도형 종류·개수와 그 도형이 덮는 범위. const shapes = number(metadata.shape_count); - if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`); + if (shapes !== null) { + const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1]; + const count = `도형 ${shapes.toLocaleString()}개`; + facts.push(kind ? `${kind} · ${count}` : count); + const width = number(metadata.extent_width_m); + const height = number(metadata.extent_height_m); + if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`); + } + + // DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지). const records = number(metadata.record_count); - if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`); const fields = number(metadata.field_count); - if (fields !== null) facts.push(`속성 ${fields}개`); + if (records !== null) { + const count = `레코드 ${records.toLocaleString()}개`; + facts.push(fields !== null ? `${count} · 속성 ${fields}개` : count); + } + const names = Array.isArray(metadata.field_names) + ? metadata.field_names.filter((name): name is string => typeof name === "string") + : []; + if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : "")); + + // CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다). const encoding = text(metadata.encoding); if (encoding) facts.push(`인코딩 ${encoding}`); + const sample = text(metadata.encoding_sample); + if (sample) facts.push(`견본 「${sample}」`); + + // TFW — 픽셀 크기와 회전. const pixelX = number(metadata.pixel_size_x); const pixelY = number(metadata.pixel_size_y); if (pixelX !== null && pixelY !== null) { @@ -126,12 +173,26 @@ export function readFacts(metadata: Record | undefined): string (number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0; facts.push(rotated ? "회전 있음" : "회전 없음"); } - // PRJ 는 좌표계 이름이 곧 구분값이다. + + // PRJ — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위. const name = text(metadata.name); const epsgLabel = readCrsLabel(metadata); if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name); else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`); - return facts.slice(0, 2); + const meridian = number(metadata.central_meridian_deg); + const unit = text(metadata.unit_name); + const origin = [ + meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`, + unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null, + ].filter((part): part is string => part !== null); + if (origin.length) facts.push(origin.join(" · ")); + const vertical = metadata.vertical_crs as Record | null | undefined; + if (vertical && typeof vertical === "object") { + const verticalName = text(vertical.name); + if (verticalName) facts.push(`높이 기준 ${verticalName}`); + } + + return facts.slice(0, 3); } /** diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 254f9634..098c1fba 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -633,10 +633,13 @@ stroke-width: 0.7; } -/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. */ +/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라 + 다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다 + (2026-09-04 사용자 지적). */ .b03-file__preview-thumb { max-width: 100%; max-height: 100%; + border: 1px dashed var(--color-border, #9aa); image-rendering: pixelated; object-fit: contain; }