""" TXT file reader/writer. Auto-detects delimiters (space, tab, comma) and column names. Handles optional header line and comment lines (#, //). """ import csv import io from pathlib import Path from core.data_model import CoordData, CoordPoint class TxtHandler: EXTENSIONS = {".txt", ".csv"} @classmethod def read(cls, path: str) -> CoordData: path = Path(path) raw = path.read_text(encoding="utf-8-sig") # Strip comment lines lines = [l for l in raw.splitlines() if l.strip() and not l.strip().startswith(("#", "//"))] if not lines: raise ValueError(f"文件为空或全为注释: {path}") # Detect delimiter delim = cls._detect_delimiter(lines) reader = csv.reader(io.StringIO("\n".join(lines)), delimiter=delim) all_rows = list(reader) if not all_rows: raise ValueError(f"无法解析文件: {path}") # Check if first row looks like a header (contains non-numeric first field # or known column names) first_row = all_rows[0] has_header = cls._looks_like_header(first_row) if has_header: col_names = first_row data_rows = all_rows[1:] else: # No header — auto-generate column names num_cols = len(first_row) col_names = [f"COL{i+1}" for i in range(num_cols)] data_rows = all_rows # Detect X, Y, Z columns x_col, y_col, z_col = cls._find_coord_cols(col_names) records = [] for row in data_rows: record = {} for i, name in enumerate(col_names): val = row[i].strip() if i < len(row) else None record[name] = val records.append(record) return CoordData.from_records(records, x_col=x_col, y_col=y_col, z_col=z_col) @classmethod def write(cls, data: CoordData, path: str): records = data.to_records() if not records: raise ValueError("无数据可写入") delim = cls._detect_delimiter_from_ext(path) fieldnames = list(records[0].keys()) with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=delim) writer.writeheader() writer.writerows(records) @staticmethod def _looks_like_header(row: list[str]) -> bool: """Heuristic: header if first few entries are non-numeric strings.""" if not row: return False # If any column name matches known coord patterns, it's a header lower = {c.lower().strip() for c in row[:5]} coord_keywords = {"x", "y", "lon", "lat", "经度", "纬度", "点号", "id"} if lower & coord_keywords: return True # If the first entry is not a valid number, it's probably a header try: float(row[0].strip()) return False except (ValueError, AttributeError): return True @staticmethod def _detect_delimiter(lines: list[str]) -> str: """Auto-detect: tab > comma > space.""" sample = "\n".join(lines[:20]) tab_count = sample.count("\t") comma_count = sample.count(",") # Check if commas are between spaces (likely CSV) vs within text if comma_count > 3 or comma_count > tab_count: return "," if tab_count > 0: return "\t" return " " # default: space-separated @staticmethod def _detect_delimiter_from_ext(path: str) -> str: return "," if path.lower().endswith(".csv") else "\t" @staticmethod def _find_coord_cols(cols: list[str]) -> tuple[str, str, str | None]: """Find X, Y, Z columns.""" x_patterns = ["x", "lon", "经度", "lng", "easting"] y_patterns = ["y", "lat", "纬度", "northing"] z_patterns = ["z", "h", "高程", "高度", "alt", "elevation"] x_col = y_col = z_col = None for c in cols: lc = c.lower().replace(" ", "").replace("_", "").replace("-", "") if any(p == lc or lc.startswith(p) or lc.endswith(p) for p in x_patterns): x_col = c elif any(p == lc or lc.startswith(p) or lc.endswith(p) for p in y_patterns): y_col = c elif any(p == lc or lc.startswith(p) or lc.endswith(p) for p in z_patterns): z_col = c # Fallback: first two numeric-ish columns if no match if not x_col and len(cols) >= 2: x_col, y_col = cols[0], cols[1] return x_col, y_col, z_col