feat(B07): 표제란에 로고·서명 자리 신설 + 시행청·담당자 DB 확장

사용자 지시(2026-09-02) — 로고·서명을 임의로 만들어 표제란에 넣고, 위치·크기는
도각을 고치면 따라오게 하며, 시행청·과업책임자 등은 DB를 넓혀 채울 것.

도각(표제란)
- `00_template_A1.json` 에 그림 자리 2개 추가 — 회사 로고는 용역회사 칸 왼쪽
  (316~348 × 20~36mm), 설계자 서명은 설계 칸 아래(638~680 × 17.5~25.5mm).
  자리·크기가 **템플릿 좌표로만** 정해지므로 도각을 고치면 그대로 따라옴.
- `_fill_placeholders` 가 그림 자리(`imageData`)의 `{{키}}` 도 채움. 값을 못 구하면
  빈 문자열을 남기지 않고 **엔티티째 제거** — 빈 값은 CAD 가 깨진 그림으로 그림.
- `_transform_entity` 가 `points` 배열도 옮김. 그림(로고·서명)과 띠(Hatch)가 이 키를
  쓰는데 여태 변환 대상이 아니라 도각을 옮기면 제자리에 남았음.

DB (`011_title_block.sql`, 전부 ADD COLUMN·NULL 허용)
- `projects` — `client_org`(시행청), `pm_user_id`·`field_lead_user_id`·`designer_user_id`
- `companies.logo_path` · `users.signature_path` — 그림은 파일로 두고 경로만 담음
  (기존 `storage_path`·`input_files` 와 같은 방식)
- FK 는 걸지 않음 — 사유를 파일 머리말에 적음(소프트 삭제·LEFT JOIN·4환경 공유).

배선
- `_title_block_fields` 가 시행청·과업책임자·분야별책임자·설계자(배정 없으면 소유자)와
  로고·서명 data URL 까지 실어 보냄.
- `read_stored_asset()` 신설 — `storage/` 기준 상대 경로의 **파일**을 읽음.
  `resolve_stored_project_path()` 는 폴더를 만드는 프로젝트 루트용이라 파일에 못 씀.

CAD 결함 1건 (이번 작업에서 드러남)
- `screenCanvas.drawController.drawImage` 가 `(width, height)` 를 좌표처럼 변환해
  화면 오프셋과 y 뒤집기가 섞여 들어갔음 — 그림이 제 자리를 벗어나고 비율이 무너짐.
  크기는 배율만 곱하고 자리는 세계 중심으로 잡도록 고침(SVG 컨트롤러와 같은 방식).
- 검증 창구 `__aisloCad.screen(x, y)` 추가 — 세계→화면 좌표. 그림·글자가 제 자리에
  그려졌는지 픽셀로 판정할 때 씀.

검증: `pytest tmp/tests/ -q` 135 passed / 0 failed(그림 자리 3건 신규),
`npx vitest run` 87 passed / 0 failed, `check-types`·`build` 통과.
화면 실측(5174, wdw): 표준도 API Text 24개 `{{` 잔존 0, Image 2개가 도각 좌표
(316,20)-(348,36)·(638,18)-(680,26)에 실림. 캔버스 픽셀 판정 — 두 자리 모두 배경색
외 픽셀이 그려짐(로고 88px·서명 64px), 로고 파랑(20,70,140)이 슬롯 안에서만 검출.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 08:35:25 +09:00
co-authored by Claude Opus 5
parent 23fea9846b
commit b1593bce83
8 changed files with 200 additions and 31 deletions
@@ -55,10 +55,9 @@ def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) -
if isinstance(shape, dict):
placed["shapeData"] = dict(shape)
entities.append(placed)
_fill_placeholders(entities, fields or {})
return {
"format": DRAWING_FORMAT,
"entities": entities,
"entities": _fill_placeholders(entities, fields or {}),
"layers": [
_layer(NOTE_LAYER_ID, "주기"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
@@ -206,6 +206,14 @@ def _transform_entity(
p = shape.get(key)
if isinstance(p, dict):
new_shape[key] = {"x": p["x"] * scale + dx, "y": p["y"] * scale + dy}
# 꼭짓점 배열을 쓰는 엔티티(Image 로고·서명, Hatch 띠)도 함께 옮긴다.
points = shape.get("points")
if isinstance(points, list):
new_shape["points"] = [
{"x": point["x"] * scale + dx, "y": point["y"] * scale + dy}
for point in points
if isinstance(point, dict)
]
if "radius" in shape:
new_shape["radius"] = shape["radius"] * scale
options = shape.get("options")
@@ -263,23 +271,37 @@ def usable_area() -> tuple[float, float]:
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def _fill_placeholders(entities: list[dict[str, Any]], fields: dict[str, str]) -> None:
"""도각 텍스트의 {{키}}를 값으로 바꾼다. 값이 없으면 빈칸 — 남의 값이 남지 않는다.
def _fill_placeholders(
entities: list[dict[str, Any]], fields: dict[str, str]
) -> list[dict[str, Any]]:
"""도각의 {{키}}를 값으로 바꾼 엔티티 목록을 낸다. 값이 없으면 빈칸 — 남의 값이 남지 않는다.
요청 문맥의 표제란 값(`use_title_fields`)이 바탕이고, 인자로 준 값(도면마다 다른
도면명 등)이 위에 얹힌다. 합치는 자리를 **치환 함수 한 곳**에 둬야 도각을 두르지
않는 표지처럼 다른 경로로 들어온 도면도 같은 값을 받는다(2026-09-02 표지 누락).
"""
fields = {**_title_fields.get(), **(fields or {})}
def substitute(text: str) -> str:
return _PLACEHOLDER.sub(lambda match: str(fields.get(match.group(1), "")), text)
for entity in entities:
if entity.get("type") != "Text":
continue
shape = entity.get("shapeData") or {}
# 글자 자리
label = shape.get("label")
if isinstance(label, str) and "{{" in label:
shape["label"] = _PLACEHOLDER.sub(
lambda match: str(fields.get(match.group(1), "")), label
)
if entity.get("type") == "Text" and isinstance(label, str) and "{{" in label:
shape["label"] = substitute(label)
# 그림 자리(회사 로고·개인 서명) — 값은 data URL. 못 구하면 그림을 통째로
# 빼서 빈 칸으로 둔다(빈 문자열을 남기면 CAD가 깨진 그림으로 그린다).
image = shape.get("imageData")
if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image:
shape["imageData"] = substitute(image)
# 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다.
return [
entity
for entity in entities
if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData")
]
def frame_entities(
@@ -323,5 +345,4 @@ def frame_entities(
_transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy)
for index, entity in enumerate(template.get("entities", []))
]
_fill_placeholders(placed, fields or {})
return placed
return _fill_placeholders(placed, fields or {})
+42 -8
View File
@@ -3,7 +3,8 @@
import asyncio
import logging
import re
from pathlib import Path
from base64 import b64encode
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import UUID
@@ -54,7 +55,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
FrameTemplateSaveResponse,
)
from common_util.common_util_drainage_context import load_drainage_context
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
@@ -97,27 +98,50 @@ async def _company_dir(project_id: UUID) -> Path:
return root.parent.parent
def _asset_data_url(relative_path: str | None) -> str:
"""회사 로고·개인 서명 파일을 CAD `ImageEntity`가 읽는 data URL로 만든다.
파일이 없거나 경로가 수상하면 빈 문자열 — 그 자리는 그림째 빠진다
(`_fill_placeholders`). 그림은 DB에 경로만 담는 기존 방식 그대로다.
"""
blob = read_stored_asset(relative_path)
if not blob:
return ""
suffix = PurePosixPath(str(relative_path)).suffix.lower()
mime = "image/svg+xml" if suffix == ".svg" else f"image/{suffix.lstrip('.') or 'png'}"
return f"data:{mime};base64,{b64encode(blob).decode('ascii')}"
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적).
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
아직 못 채우는 자리와 이유:
- 시행청·과업책임자·분야별책임자 — `projects`에 칸이 없다. B02 등록 화면과
마이그레이션이 서야 채워진다(공유 DB 변경이라 사용자 확정 대기).
- 축척(A1/A3)·사업량·연도기번 — 값을 지어내지 않는다(임의 수치 금지).
- 설계일자 — "확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다.
채울 시점 정의가 미결이라 비워 둔다.
- 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다
횡단 장 계획을 재계산하게 된다).
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""
SELECT p.name, p.region, c.name, u.name
SELECT p.name, p.region, p.client_org, c.name, c.logo_path,
COALESCE(designer.name, owner.name),
COALESCE(designer.signature_path, owner.signature_path),
pm.name, lead.name
FROM projects p
LEFT JOIN companies c ON c.id = p.company_id
LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN users owner ON owner.id = p.user_id
LEFT JOIN users designer ON designer.id = p.designer_user_id
LEFT JOIN users pm ON pm.id = p.pm_user_id
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
WHERE p.id = %s AND p.deleted_at IS NULL
""",
(str(project_id),),
@@ -125,8 +149,18 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
row = await cursor.fetchone()
if not row:
return {}
name, region, company_name, designer = row
fields = {"공사명": name, "위치": region, "용역회사": company_name, "설계자": designer}
name, region, client_org, company, logo_path, designer, signature_path, pm, lead = row
fields = {
"공사명": name,
"위치": region,
"시행청": client_org,
"용역회사": company,
"설계자": designer,
"과업책임자": pm,
"분야별책임자": lead,
"회사로고": _asset_data_url(logo_path),
"설계자서명": _asset_data_url(signature_path),
}
return {key: str(value) for key, value in fields.items() if value}
@@ -596,16 +596,14 @@ export class ScreenCanvasDrawController implements DrawController {
angle: number
): void {
if (this.batching) this.flushBatch();
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
new Point(xMin, yMin),
new Point(width, height),
]);
const screenXMin = screenBasePoint.x;
const screenYMin = screenBasePoint.y;
const screenWidth = screenDimensions.x;
const screenHeight = screenDimensions.y;
const screenCenterX = screenXMin + screenWidth / 2;
const screenCenterY = screenYMin + screenHeight / 2;
// 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과
// y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02
// 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다.
const screenWidth = width * this.screenScale;
const screenHeight = height * this.screenScale;
const screenCenter = this.worldToTarget(new Point(xMin + width / 2, yMin + height / 2));
const screenCenterX = screenCenter.x;
const screenCenterY = screenCenter.y;
// Rotate and translate context
this.context.translate(screenCenterX, screenCenterY);
@@ -8,10 +8,12 @@ import {
getHighlightedEntityIds,
getLayers,
getSelectedEntityIds,
getScreenCanvasDrawController,
getSnapPoint,
isDrawingDirty,
isDrawingReadOnly,
} from '../state';
import { Point } from '@flatten-js/core';
import { isEntityHidden } from './visibility';
export function registerCadDebugHook(): void {
@@ -45,5 +47,10 @@ export function registerCadDebugHook(): void {
readOnly: () => isDrawingReadOnly(),
dirty: () => isDrawingDirty(),
meta: () => getDesignMeta(),
// 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다.
screen: (x: number, y: number) => {
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
return { x: point.x, y: point.y };
},
};
}
+25
View File
@@ -120,3 +120,28 @@ def resolve_stored_project_path(relative_path: str) -> str:
os.makedirs(path, exist_ok=True)
ensure_project_storage_layout(path)
return path
def read_stored_asset(relative_path: str | None) -> bytes | None:
"""`storage/` 기준 상대 경로의 **파일**을 읽는다. 없거나 수상하면 None.
회사 로고·개인 서명처럼 DB에 경로만 담아 두는 자산용이다.
`resolve_stored_project_path()`는 폴더를 만들어 주는 프로젝트 루트용이라 파일에는
쓸 수 없다. 검증 규칙(상대 경로·`storage/` 시작·루트 밖 금지)은 같다.
"""
if not relative_path:
return None
normalized = PurePosixPath(str(relative_path).replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
return None
if not normalized.parts or normalized.parts[0] != "storage":
return None
storage_root = os.path.realpath(STORAGE_BASE_DIR)
path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
return None
if not os.path.isfile(path):
return None
with open(path, "rb") as file:
return file.read()
+29
View File
@@ -0,0 +1,29 @@
-- 011_title_block.sql
-- 도면 표제란(도각)에 채울 값 (2026-09-02 사용자 지시)
--
-- 표제란 칸 중 DB에 자리가 없어 빈칸으로 나가던 것들을 만든다.
-- 시행청 · 과업책임자 · 분야별책임자 · 설계자 → projects
-- 회사 로고 → companies, 개인 서명 → users
-- 그림은 파일로 두고 **경로만** 담는다(기존 input_files·storage_path와 같은 결).
--
-- 전부 ADD COLUMN(NULL 허용)이라 기존 행·기존 동작은 그대로다. 값이 없으면 표제란은
-- 지금처럼 빈칸으로 나간다.
--
-- 사람 배정 컬럼에 FK를 걸지 않은 이유: users는 소프트 삭제(deleted_at)라 행이 실제로
-- 지워지는 일이 드물고, 조회가 LEFT JOIN이라 id가 떠 있어도 이름이 빈칸으로 나갈 뿐
-- 도면이 깨지지 않는다("값이 없으면 빈칸" 규칙과 같은 결과). FK를 걸면 4환경이 공유하는
-- DB에서 users 삭제·복구가 서로 막힌다.
USE aislo_db;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS client_org VARCHAR(255) NULL COMMENT '시행청(발주처)' AFTER road_type,
ADD COLUMN IF NOT EXISTS pm_user_id INT NULL COMMENT '과업책임자 (users.id)',
ADD COLUMN IF NOT EXISTS field_lead_user_id INT NULL COMMENT '분야별책임자 (users.id)',
ADD COLUMN IF NOT EXISTS designer_user_id INT NULL COMMENT '설계자 (users.id, 없으면 소유자)';
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS logo_path VARCHAR(500) NULL COMMENT '회사 로고 파일 경로 (storage/ 기준 상대)';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS signature_path VARCHAR(500) NULL COMMENT '개인 서명 파일 경로 (storage/ 기준 상대)';
@@ -1221,6 +1221,62 @@
"fontFamily": "sans-serif"
}
}
},
{
"id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca",
"type": "Image",
"lineColor": "#f5f7fa",
"lineWidth": 1,
"layerId": "-00.기본BOX TEXT",
"shapeData": {
"points": [
{
"x": 316.0,
"y": 20.0
},
{
"x": 348.0,
"y": 20.0
},
{
"x": 348.0,
"y": 36.0
},
{
"x": 316.0,
"y": 36.0
}
],
"imageData": "{{회사로고}}"
}
},
{
"id": "9452b160-d6c4-5437-8a47-7824b6df62ea",
"type": "Image",
"lineColor": "#f5f7fa",
"lineWidth": 1,
"layerId": "-00.기본BOX TEXT",
"shapeData": {
"points": [
{
"x": 638.0,
"y": 17.5
},
{
"x": 680.0,
"y": 17.5
},
{
"x": 680.0,
"y": 25.5
},
{
"x": 638.0,
"y": 25.5
}
],
"imageData": "{{설계자서명}}"
}
}
],
"layers": [
@@ -1237,4 +1293,4 @@
"isLocked": false
}
]
}
}