feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다

원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로
들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다.

- 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10
- B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이
  아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로
  정규화해 한글 속성을 살린다.
- 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에
  crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는
  값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다.
- shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에
  남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다.
  find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다.
- 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수.
- UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가
  같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다.

자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과,
ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서
동일하게 재현되는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:28:34 +09:00
co-authored by Claude Opus 5
parent 9bc722dfd6
commit fe72ea041b
18 changed files with 582 additions and 48 deletions
@@ -0,0 +1,244 @@
"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계.
GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후**
메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지
않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은
.shp 하나만으로 기하를 읽어 낸다.
좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨
"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정).
"""
import logging
import struct
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_HEADER_BYTES = 100
_FILE_CODE = 9994
# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다.
_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"}
_SHAPE_TYPE_NAMES = {
0: "Null",
1: "Point",
3: "PolyLine",
5: "Polygon",
8: "MultiPoint",
11: "PointZ",
13: "PolyLineZ",
15: "PolygonZ",
18: "MultiPointZ",
21: "PointM",
23: "PolyLineM",
25: "PolygonM",
28: "MultiPointM",
31: "MultiPatch",
}
def _normalize_codepage(text: str) -> str | None:
""".cpg 내용을 파이썬 인코딩 이름으로 바꾼다.
실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도
아니라서 그대로 넘기면 LookupError가 난다.
"""
value = (text or "").strip()
if not value:
return None
if value.isdigit():
return f"cp{value}"
upper = value.upper().replace("-", "").replace("_", "")
if upper in {"ANSI", "OEM", "SYSTEM"}:
return "cp949"
return value
def shapefile_encoding(path: Path) -> str:
"""짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949."""
cpg_path = path.with_suffix(".cpg")
if cpg_path.exists():
try:
encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore"))
if encoding:
"".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError
return encoding
except (OSError, LookupError):
logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name)
return "cp949"
def shapefile_crs_input(path: Path) -> str | None:
"""짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from common_util.common_util_crs import crs_input_from_prj
return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
def shapefile_epsg_label(path: Path) -> int | None:
"""짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
text = prj_path.read_text(encoding="utf-8", errors="ignore")
try:
return identify_epsg(CRS.from_wkt(text), text)
except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다
return None
def _read_header(blob: bytes) -> dict[str, Any]:
if len(blob) < _HEADER_BYTES:
raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.")
file_code = struct.unpack(">i", blob[0:4])[0]
if file_code != _FILE_CODE:
raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.")
declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2
shape_type = struct.unpack("<i", blob[32:36])[0]
x_min, y_min, x_max, y_max = struct.unpack("<4d", blob[36:68])
z_min, z_max = struct.unpack("<2d", blob[68:84])
return {
"shape_type": shape_type,
"shape_type_name": _SHAPE_TYPE_NAMES.get(shape_type, f"Unknown({shape_type})"),
"declared_bytes": declared_bytes,
"bounds": {
"x_min": x_min,
"x_max": x_max,
"y_min": y_min,
"y_max": y_max,
"z_min": z_min,
"z_max": z_max,
},
}
def _read_polyline_record(blob: bytes, offset: int, content_bytes: int) -> list[list[tuple]]:
"""폴리라인 레코드 하나를 파트별 정점 목록으로 푼다."""
record_type = struct.unpack("<i", blob[offset : offset + 4])[0]
if record_type == 0: # Null shape — 규격상 건너뛴다
return []
if record_type not in _POLYLINE_TYPES:
raise ValueError(f"계획노선은 폴리라인이어야 합니다(받은 형상: {record_type}).")
cursor = offset + 4 + 32 # 형상종류 + 레코드 bbox
part_count, point_count = struct.unpack("<2i", blob[cursor : cursor + 8])
cursor += 8
parts = list(struct.unpack(f"<{part_count}i", blob[cursor : cursor + part_count * 4]))
cursor += part_count * 4
flat_xy = struct.unpack(f"<{point_count * 2}d", blob[cursor : cursor + point_count * 16])
cursor += point_count * 16
zs: tuple[float, ...] = ()
if record_type == 13:
cursor += 16 # Z 범위
end = cursor + point_count * 8
if end - offset <= content_bytes:
zs = struct.unpack(f"<{point_count}d", blob[cursor:end])
points = [
(flat_xy[index * 2], flat_xy[index * 2 + 1], zs[index] if zs else 0.0)
for index in range(point_count)
]
boundaries = [*parts, point_count]
return [points[boundaries[i] : boundaries[i + 1]] for i in range(part_count)]
def read_shapefile_parts(path: str | Path) -> list[list[tuple]]:
""".shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다."""
source = Path(path)
blob = source.read_bytes()
header = _read_header(blob)
if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0:
raise ValueError(
f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})."
)
limit = min(len(blob), header["declared_bytes"] or len(blob))
parts: list[list[tuple]] = []
offset = _HEADER_BYTES
while offset + 8 <= limit:
content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2
if content_bytes <= 0:
break
parts.extend(_read_polyline_record(blob, offset + 8, content_bytes))
offset += 8 + content_bytes
return [part for part in parts if len(part) >= 2]
def read_shapefile_attributes(path: str | Path) -> dict[str, str]:
"""짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict."""
dbf_path = Path(path).with_suffix(".dbf")
if not dbf_path.exists():
return {}
encoding = shapefile_encoding(Path(path))
try:
blob = dbf_path.read_bytes()
header_bytes, record_bytes = struct.unpack("<2H", blob[8:12])
fields: list[tuple[str, int]] = []
cursor = 32
while cursor < header_bytes - 1 and blob[cursor] != 0x0D:
descriptor = blob[cursor : cursor + 32]
name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip()
fields.append((name, descriptor[16]))
cursor += 32
record = blob[header_bytes : header_bytes + record_bytes]
if not record:
return {}
values: dict[str, str] = {}
position = 1 # 첫 바이트는 삭제 표시
for name, width in fields:
raw = record[position : position + width]
values[name] = raw.decode(encoding, errors="replace").strip()
position += width
return values
except (OSError, struct.error, ValueError):
logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name)
return {}
def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str:
for key in ("대상지", "노선명", "route_name", "NAME", "name"):
value = attributes.get(key)
if value:
return value
return fallback
def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]:
"""계획노선 shapefile의 B03 메타데이터를 만든다."""
source = Path(path)
header = _read_header(source.read_bytes()[:_HEADER_BYTES])
parts = read_shapefile_parts(source)
attributes = read_shapefile_attributes(source)
point_count = sum(len(part) for part in parts)
missing = [
extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists()
]
return {
"file": source.name,
"extension": "shp",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": _route_name_from_attributes(attributes, source.stem),
"shape_type": header["shape_type_name"],
"part_count": len(parts),
"point_count": point_count,
"epsg": shapefile_epsg_label(source),
"crs_input": shapefile_crs_input(source),
"encoding": shapefile_encoding(source),
"attributes": attributes,
"missing_members": missing,
"bounds": header["bounds"],
"start_point": list(parts[0][0]) if parts else None,
"end_point": list(parts[-1][-1]) if parts else None,
}