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:
2026-09-04 22:16:14 +09:00
co-authored by Claude Opus 5
parent 973ac7138f
commit 41c1771fad
3 changed files with 180 additions and 14 deletions
+108 -6
View File
@@ -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]:
+68 -7
View File
@@ -99,9 +99,33 @@ export function readThumbnail(metadata: Record<string, unknown> | undefined): st
return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null;
}
/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */
const SHAPE_TYPE_LABELS: Record<number, string> = {
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<string, unknown> | undefined): string[] {
if (!metadata) return [];
@@ -109,15 +133,38 @@ export function readFacts(metadata: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null | undefined;
if (vertical && typeof vertical === "object") {
const verticalName = text(vertical.name);
if (verticalName) facts.push(`높이 기준 ${verticalName}`);
}
return facts.slice(0, 3);
}
/**
+4 -1
View File
@@ -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;
}