feat(B03): 부속 파일 카드에 파일 속 값 표시·래스터 썸네일 점선 테두리
개수만 보이던 부속 파일 카드에 파일 안의 중요한 값을 함께 보임. - .shx — 도형 종류(폴리라인 등)와 범위 크기를 머리글에서 읽음. - .dbf — 속성 이름 목록을 머리글 서술자에서 읽음(앞 4개). - .cpg — 같은 세트 .dbf 의 첫 글자 속성을 그 인코딩으로 읽어 견본으로 보임. - .prj — 중앙자오선(투영 매개변수)과 길이 단위. proj 문자열 변환을 쓰지 않아 정보 손실 경고가 나지 않음. - 카드 표시 줄 수를 2 → 3 으로 늘리고, 길이 단위 표기를 m 으로 통일. - GeoTIFF 썸네일에 다른 미리보기와 같은 점선 테두리. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user