"""SVG 서명·로고를 CAD 폴리선으로 옮긴다 (2026-09-03 사용자 결정: 벡터로 변환). 표제란 서명은 종전에 `ImageEntity`(data URL)로 들어갔다. 화면·PNG 는 문제없지만 DXF 로 나가면 래스터 그림이 되고 확대하면 깨진다. **SVG 로 올라온 자산만** 벡터로 바꾼다 — PNG·JPG 는 그릴 선이 없으므로 종전대로 그림으로 둔다. 지원 도형: `path`(M·L·H·V·C·Q·S·T·A 제외·Z, 대소문자 = 절대·상대) · `polyline` · `polygon` · `line`. 곡선은 고정 분할로 펴서 폴리선 하나로 만든다 — CAD 쪽에 곡선 엔티티가 없고, 서명은 짧은 획이라 16분할이면 눈으로 구분되지 않는다. """ from __future__ import annotations import logging import re from xml.etree import ElementTree logger = logging.getLogger(__name__) # 베지에 한 구간을 몇 조각으로 펼지 — 서명 획 길이(수 mm)에서 충분한 값. _CURVE_STEPS = 16 _NUMBER = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") _COMMAND = re.compile(r"([MmLlHhVvCcQqSsTtZz])") _SVG_NS = "{http://www.w3.org/2000/svg}" Point = tuple[float, float] def _numbers(text: str) -> list[float]: return [float(value) for value in _NUMBER.findall(text or "")] def _bezier(points: list[Point], steps: int = _CURVE_STEPS) -> list[Point]: """드 카스텔조 분할 — 3차·2차 모두 같은 식으로 편다(제어점 개수만 다르다).""" flattened: list[Point] = [] for index in range(1, steps + 1): t = index / steps current = points while len(current) > 1: current = [ (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) for a, b in zip(current, current[1:]) ] flattened.append(current[0]) return flattened def _path_polylines(data: str) -> list[list[Point]]: """`d` 속성 → 폴리선 목록. 미지원 명령(A 등)을 만나면 그 자리에서 획을 끊는다.""" runs: list[list[Point]] = [] current: list[Point] = [] cursor: Point = (0.0, 0.0) start: Point = (0.0, 0.0) previous_control: Point | None = None tokens = [token for token in _COMMAND.split(data or "") if token.strip()] index = 0 while index < len(tokens): command = tokens[index] args = _numbers(tokens[index + 1]) if index + 1 < len(tokens) else [] index += 2 if index + 1 < len(tokens) else 1 relative = command.islower() upper = command.upper() def absolute(x: float, y: float) -> Point: return (cursor[0] + x, cursor[1] + y) if relative else (x, y) if upper == "M": for pair in range(0, len(args) - 1, 2): point = absolute(args[pair], args[pair + 1]) if pair == 0: if len(current) >= 2: runs.append(current) current = [point] start = point else: current.append(point) cursor = point previous_control = None elif upper in {"L", "T"}: for pair in range(0, len(args) - 1, 2): point = absolute(args[pair], args[pair + 1]) current.append(point) cursor = point previous_control = None elif upper == "H": for value in args: point = (cursor[0] + value, cursor[1]) if relative else (value, cursor[1]) current.append(point) cursor = point previous_control = None elif upper == "V": for value in args: point = (cursor[0], cursor[1] + value) if relative else (cursor[0], value) current.append(point) cursor = point previous_control = None elif upper in {"C", "S", "Q"}: stride = {"C": 6, "S": 4, "Q": 4}[upper] for offset in range(0, len(args) - stride + 1, stride): chunk = args[offset : offset + stride] if upper == "C": control1 = absolute(chunk[0], chunk[1]) control2 = absolute(chunk[2], chunk[3]) end = absolute(chunk[4], chunk[5]) elif upper == "S": mirrored = previous_control or cursor control1 = (2 * cursor[0] - mirrored[0], 2 * cursor[1] - mirrored[1]) control2 = absolute(chunk[0], chunk[1]) end = absolute(chunk[2], chunk[3]) else: control1 = absolute(chunk[0], chunk[1]) control2 = control1 end = absolute(chunk[2], chunk[3]) current.extend(_bezier([cursor, control1, control2, end])) previous_control = control2 cursor = end elif upper == "Z": if current: current.append(start) runs.append(current) current = [] cursor = start previous_control = None else: # 지원하지 않는 명령(원호 A 등) — 여기서 획을 끊고 다음 M 을 기다린다. if len(current) >= 2: runs.append(current) current = [] previous_control = None if len(current) >= 2: runs.append(current) return runs def svg_polylines(svg_text: str) -> tuple[tuple[float, float, float, float], list[list[Point]]]: """SVG 원문 → (viewBox, 폴리선 목록). 읽지 못하면 빈 목록. viewBox 가 없으면 도형 전체 bbox 를 대신 쓴다 — 자리 맞추기에만 쓰는 값이다. """ try: root = ElementTree.fromstring(svg_text) except ElementTree.ParseError as exc: logger.warning("서명 SVG 를 읽지 못했습니다 — %s", exc) return ((0.0, 0.0, 1.0, 1.0), []) runs: list[list[Point]] = [] for element in root.iter(): tag = element.tag.replace(_SVG_NS, "") if tag == "path": runs.extend(_path_polylines(element.get("d", ""))) elif tag in {"polyline", "polygon"}: values = _numbers(element.get("points", "")) points = [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)] if tag == "polygon" and len(points) > 2: points.append(points[0]) if len(points) >= 2: runs.append(points) elif tag == "line": runs.append( [ (float(element.get("x1", 0)), float(element.get("y1", 0))), (float(element.get("x2", 0)), float(element.get("y2", 0))), ] ) box = _numbers(root.get("viewBox", "")) if len(box) == 4 and box[2] > 0 and box[3] > 0: return ((box[0], box[1], box[2], box[3]), runs) xs = [x for run in runs for x, _y in run] ys = [y for run in runs for _x, y in run] if not xs or not ys: return ((0.0, 0.0, 1.0, 1.0), runs) width = max(max(xs) - min(xs), 1e-6) height = max(max(ys) - min(ys), 1e-6) return ((min(xs), min(ys), width, height), runs) def fit_polylines( runs: list[list[Point]], view_box: tuple[float, float, float, float], rect: tuple[float, float, float, float], ) -> list[list[Point]]: """폴리선을 도면 사각형(x0, y0, x1, y1) 안에 **비율 유지**로 앉힌다. SVG 는 y 가 아래로 자라고 도면은 위로 자라므로 세로를 뒤집는다. """ vx, vy, vw, vh = view_box x0, y0, x1, y1 = rect box_w = abs(x1 - x0) box_h = abs(y1 - y0) if vw <= 0 or vh <= 0 or box_w <= 0 or box_h <= 0: return [] scale = min(box_w / vw, box_h / vh) pad_x = (box_w - vw * scale) / 2.0 pad_y = (box_h - vh * scale) / 2.0 left = min(x0, x1) bottom = min(y0, y1) top = bottom + box_h return [ [(left + pad_x + (x - vx) * scale, top - pad_y - (y - vy) * scale) for x, y in run] for run in runs ]