diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 56ae1a236..571f02c10 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -20,6 +20,7 @@ import { renderCurrentRoute, ensureInitialHash, setLeaveGuard, + draftRoute, showWithinPage, takeLeaveGuard, } from "./router"; @@ -70,7 +71,8 @@ function bootstrap(): void { shown = currentRoute(); shownHash = window.location.hash; if (within) return; - const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); + const showFooter = + !FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless; const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 }; diff --git a/A00_Common/router.ts b/A00_Common/router.ts index f41d74244..c5680ec2d 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -74,6 +74,29 @@ const routeTable: Partial Promise>> = { (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, }; +/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음. + * 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */ +export interface DraftRoute { + path: string; + load: () => Promise; + /** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */ + workflow?: boolean; + protected?: boolean; + /** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */ + project?: boolean; + footerless?: boolean; +} + +const DRAFT_ROUTES = new Map( + Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })) + .map((m) => m.ROUTE) + .filter(Boolean) + .map((r) => [r.path, r] as const), +); + +/** 초안 길 선언(없으면 undefined) */ +export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route); + /** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */ let leaveGuard: (() => Promise) | null = null; export const setLeaveGuard = (guard: (() => Promise) | null): void => { @@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { mounted?.dispose(); mounted = null; - if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { + const draft = draftRoute(route); + const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow); + if (guarded && !(await isAuthenticated())) { navigateTo(ROUTES.A06_LOGIN); return; } @@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { ROUTES.B11_LOADING, ]; - if (workflowRoutes.includes(route)) { + if (workflowRoutes.includes(route) || draft?.workflow) { const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); try { const user = await fetchSessionUser(); @@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. - if (PROJECT_ROUTES.includes(route)) { + if (PROJECT_ROUTES.includes(route) || draft?.project) { const { dropGoneProject } = await import("./b_workflow_nav"); if (await dropGoneProject()) { if (seq !== renderSeq) return; @@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } } - const loader = routeTable[route]; + const loader = routeTable[route] ?? draft?.load; if (!loader) { renderPlaceholder(outlet, route); return; diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 10e53f93e..392b11005 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -29,6 +29,8 @@ from __future__ import annotations import asyncio +import importlib +import logging from pathlib import Path from typing import Any from uuid import UUID @@ -341,3 +343,19 @@ async def library_inputs( # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) + + +# 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 +def include_drafts(target: APIRouter, folder: Path, package: str) -> list[str]: + """folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀).""" + done = [] + for path in sorted(folder.glob("*_Router.py")): + try: + target.include_router(importlib.import_module(f"{package}.{path.stem}").router) + done.append(path.stem) + except (ImportError, AttributeError) as error: # 한 초안이 막혀도 B09 는 뜸 + logging.getLogger(__name__).warning("초안 길 %s 못 읽음 — %s", path.name, error) + return done + + +include_drafts(router, Path(__file__).parent / "draft", "B09_Estimation.draft") diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css new file mode 100644 index 000000000..0b7a5e242 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -0,0 +1,52 @@ +/* 인쇄 미리보기(PLAN 302-1 · 초안) — 상태 한 줄 + PDF 보기 칸(창 아래 끝까지). 색은 테마 변수만. */ + +.b09-print { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: calc(100vh - var(--spacing-64)); + padding: var(--spacing-16) var(--spacing-24); + box-sizing: border-box; +} + +.b09-print__status { + margin: 0; + min-height: 1.4em; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b09-print__status.is-error { + color: var(--color-danger); +} + +.b09-print__frame { + flex: 1; + width: 100%; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface, var(--color-canvas)); +} + +.b09-print__areas { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.b09-print__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 50vh; + overflow: auto; +} + +.b09-print__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); + cursor: pointer; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts new file mode 100644 index 000000000..247cfcf8a --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -0,0 +1,309 @@ +/* ============================================================================= + * B09_Draft_Print_Page.ts + * 인쇄 미리보기 페이지(PLAN 302-1 · 요구 192-3 · 초안) — 주소 #/b09-print(`…_Print_Route`). + * 좌측 = 영역 셋(도면 · 수량산출 · 원가계산) 고르기 + 그 영역 쪽 목록(체크로 빼고 넣기 · 차례 그대로) + + * [PDF 내려받기] [엑셀 내려받기](도면 = PDF 만) + [B09 로 돌아가기] + * 본문 = 고른 쪽들의 PDF 를 브라우저 PDF 보기로(보는 것 = 받는 것) · 만드는 동안 작은 표시(잠금 없음) · + * 실패 = 까닭 한 줄 · 체크를 바꾸면 다시 만듦(최신 요청만 채택) + * 영역 = 다른 창 파일(`…_Print_Quantity` collectQuantitySheets · `…_Print_Cost` collectCostSheets · + * `…_Print_Drawings` fetchDrawingList · fetchDrawingsPdf) — 없으면 「준비 중」 + * 표 영역 PDF · 엑셀 = 서버(`B09_Draft_Print_Router` table-pdf · table-xlsx) · 서버는 꼴만 바꿈 + * ========================================================================== */ + +import "@ui/ui_template_general_layout.css"; +import "./B09_Draft_Print_Page.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import type { MountedPage } from "../../A00_Common/router"; +import { navigateTo } from "../../A00_Common/router"; +import { workflowSteps } from "../../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; +import { inlineImages, sheetGrid, sheetHtml } from "./B09_Draft_Print_Table"; +import type { + CollectSheets, + DrawingItem, + FetchDrawingList, + FetchDrawingsPdf, + PrintSheet, +} from "./B09_Draft_Print_Types"; + +type AreaId = "drawings" | "quantity" | "cost"; + +const AREAS: { id: AreaId; label: string; file: string }[] = [ + { id: "drawings", label: "도면", file: "./B09_Draft_Print_Drawings.ts" }, + { id: "quantity", label: "수량산출", file: "./B09_Draft_Print_Quantity.ts" }, + { id: "cost", label: "원가계산", file: "./B09_Draft_Print_Cost.ts" }, +]; + +/** 영역 파일(다른 창 몫) — 없는 파일은 목록에 없음 = 「준비 중」 */ +const MODS = import.meta.glob([ + "./B09_Draft_Print_Drawings.ts", + "./B09_Draft_Print_Quantity.ts", + "./B09_Draft_Print_Cost.ts", +]); + +/** 영역 한 벌 — 쪽 목록 · 고른 쪽 PDF · 엑셀(도면 없음) */ +interface Area { + items: DrawingItem[]; + pdf: (ids: string[]) => Promise; + xlsx?: (ids: string[]) => Promise; +} + +async function post(projectId: string, path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/print/${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = ((await res.json().catch(() => null)) as { detail?: unknown } | null)?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${res.status}`); + } + return res.blob(); +} + +/** 표 영역 — 쪽 = PrintSheet · PDF · 엑셀 = 서버 꼴 바꿈 */ +function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area { + const pick = (ids: string[]) => ids.map((id) => sheets.find((s) => s.id === id)!); + return { + items: sheets.map(({ id, name }) => ({ id, name })), + pdf: async (ids) => { + const picked = await Promise.all(pick(ids).map(inlineImages)); + return post(projectId, "table-pdf", { + title, + sheets: picked.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + html: sheetHtml(s), + })), + }); + }, + xlsx: (ids) => + post(projectId, "table-xlsx", { + title, + sheets: pick(ids).map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), + }), + }; +} + +/** 영역 파일 읽기 — 없음 = null(준비 중) */ +async function loadArea(projectId: string, id: AreaId): Promise { + const def = AREAS.find((a) => a.id === id)!; + const load = MODS[def.file]; + if (!load) return null; + const mod = (await load()) as Record; + if (id === "drawings") { + const list = mod.fetchDrawingList as FetchDrawingList | undefined; + const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined; + if (!list || !pdf) return null; + return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids) }; + } + const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as + CollectSheets | undefined; + return collect ? tableArea(projectId, def.label, await collect(projectId)) : null; +} + +function save(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function renderB09Print(root: HTMLElement): MountedPage { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + + const status = document.createElement("p"); + status.className = "b09-print__status"; + status.dataset.b09PrintStatus = ""; + const frame = document.createElement("iframe"); + frame.className = "b09-print__frame"; + frame.title = "인쇄 미리보기"; + const body = document.createElement("div"); + body.className = "b09-print"; + body.append(status, frame); + + const areaCard = createPanelCard("영역"); + const areaRow = document.createElement("div"); + areaRow.className = "b09-print__areas"; + areaCard.append(areaRow); + const sheetCard = createPanelCard("쪽 목록"); + const list = document.createElement("div"); + list.className = "b09-print__list"; + sheetCard.append(list); + + let area: Area | null = null; + let areaId: AreaId = AREAS[0].id; + let off = new Set(); // 체크 뺀 쪽 + let seq = 0; + let shown: { url: string; blob: Promise } | null = null; + let timer = 0; + + const label = () => AREAS.find((a) => a.id === areaId)!.label; + const say = (text: string, error = false) => { + status.textContent = text; + status.classList.toggle("is-error", error); + }; + const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id)); + const clearFrame = () => { + if (shown?.url) URL.revokeObjectURL(shown.url); + shown = null; + frame.removeAttribute("src"); + }; + + /** 고른 쪽 PDF 다시 만듦 — 최신 요청만 보임 · 내려받기는 같은 PDF */ + const rebuild = () => { + const my = ++seq; + const ids = picked(); + clearFrame(); + if (!area) return; + if (!ids.length) return say("고른 쪽이 없습니다."); + say(`${label()} PDF 만드는 중…`); + const blob = area.pdf(ids); + shown = { url: "", blob }; + blob.then( + (b) => { + if (my !== seq) return; + shown!.url = URL.createObjectURL(b); + frame.src = shown!.url; + say(`${label()} · ${ids.length}쪽 묶음`); + }, + (error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true), + ); + }; + const later = () => { + clearTimeout(timer); + timer = window.setTimeout(rebuild, 300); + }; + + const pdfBtn = createButton({ + label: "PDF 내려받기", + onClick: () => { + if (!shown) return; + const name = `${label()}.pdf`; + shown.blob.then( + (b) => save(b, name), + () => undefined, + ); + }, + }); + pdfBtn.dataset.b09PrintPdf = ""; + const xlsxBtn = createButton({ + label: "엑셀 내려받기", + variant: "ghost", + onClick: () => { + const ids = picked(); + if (!area?.xlsx || !ids.length) return; + const name = `${label()}.xlsx`; + say(`${label()} 엑셀 만드는 중…`); + area.xlsx(ids).then( + (b) => (save(b, name), say(`${label()} 엑셀을 내려받았습니다.`)), + (error: unknown) => say(`엑셀을 못 만들었습니다 — ${errText(error)}`, true), + ); + }, + }); + xlsxBtn.dataset.b09PrintXlsx = ""; + const back = createButton({ + label: "B09 로 돌아가기", + variant: "ghost", + onClick: () => navigateTo(ROUTES.B09_ESTIMATION), + }); + + const drawList = () => { + list.replaceChildren( + ...(area?.items ?? []).map((it) => { + const row = document.createElement("label"); + row.className = "b09-print__item"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = !off.has(it.id); + box.dataset.b09PrintItem = it.id; + box.addEventListener("change", () => { + if (box.checked) off.delete(it.id); + else off.add(it.id); + later(); + }); + const name = document.createElement("span"); + name.textContent = it.name; + row.append(box, name); + return row; + }), + ); + }; + + const pickArea = (id: AreaId) => { + areaId = id; + area = null; + off = new Set(); + const my = ++seq; + clearFrame(); + areaRow.replaceChildren( + ...AREAS.map((a) => { + const b = createButton({ + label: a.label, + size: "sm", + variant: a.id === id ? "filled" : "ghost", + onClick: () => a.id !== areaId && pickArea(a.id), + }); + b.dataset.b09PrintArea = a.id; + return b; + }), + ); + xlsxBtn.hidden = id === "drawings"; + drawList(); + if (!projectId) return say("현재 프로젝트가 없습니다.", true); + say(`${label()} 쪽 목록 읽는 중…`); + loadArea(projectId, id).then( + (got) => { + if (my !== seq) return; + if (!got) return say(`${label()} — 준비 중`); + area = got; + drawList(); + if (!got.items.length) return say(`${label()} — 쪽이 없습니다.`); + rebuild(); + }, + (error: unknown) => + my === seq && say(`${label()} 목록을 못 읽었습니다 — ${errText(error)}`, true), + ); + }; + + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(pdfBtn, xlsxBtn, back); + const left = document.createElement("div"); + left.className = "ui-sidebar-panel b09-print__panel"; + left.append(areaCard, sheetCard, actions); + attachCollapsible(left); + + const layout = createWorkflowLayout({ + title: "인쇄 미리보기", + steps: workflowSteps(), + activeStep: 6, + mainContent: body, + leftPanel: left, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex: number) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, + }); + root.append(layout.root); + pickArea(areaId); + + return { + covers: () => false, + show: () => undefined, + dispose: () => { + seq++; + clearTimeout(timer); + clearFrame(); + }, + }; +} + +const errText = (error: unknown): string => + error instanceof Error ? error.message : String(error); diff --git a/B09_Estimation/draft/B09_Draft_Print_Route.ts b/B09_Estimation/draft/B09_Draft_Print_Route.ts new file mode 100644 index 000000000..de86318d3 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Route.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B09_Draft_Print_Route.ts + * 인쇄 미리보기 길 선언(PLAN 302-1 · 초안) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 주소 = #/b09-print · 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 — 폴더째 지우면 길도 없음 + * ========================================================================== */ + +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: "b09-print", + workflow: true, + project: true, + footerless: true, + load: async () => (await import("./B09_Draft_Print_Page")).renderB09Print, +}; diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py new file mode 100644 index 000000000..ab7c347f0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -0,0 +1,334 @@ +"""인쇄 미리보기 표 출력 길(PLAN 302-1 · 초안) — 서버는 꼴만 바꿈(값 셈 · 저장 없음 · 정본 아님). + + POST /api/projects/{id}/print/table-pdf {title, sheets:[{name, orientation, html}]} + html = 브라우저가 `renderDataTableHtml` 로 만든 표(화면과 같은 꼴) → 인쇄 CSS 를 입혀 + 헤드리스 Edge(없으면 Chrome)로 A4 PDF 한 벌 · 시트 = 차례대로 새 쪽 · 방향 = 시트마다 · + 쪽 머리 = 시트 이름(표 머리와 함께 쪽마다 되풀이) · 쪽 바닥 = 쪽 번호 · + 넓은 표 = 쪽 폭에 맞춰 줄임 + POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} + grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · + 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · + 레벨 = 들여쓰기 · 열 폭 + +`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 +(같은 머리 · 같은 권한 = 회사 · 프로젝트). +PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — +서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). +헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +""" + +from __future__ import annotations + +import html as htmlmod +import io +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any, Literal + +TIMEOUT_S = 90 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +#: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘 +PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96} +MIN_ZOOM = 0.3 + +PRINT_CSS = """ +@page { size: A4 portrait; margin: 10mm 10mm 14mm; } +@page wide { size: A4 landscape; margin: 10mm 10mm 14mm; } +html, body { margin: 0; } +body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11px; color: #000; } +.sheet { break-after: page; } +.sheet:last-child { break-after: auto; } +.sheet.is-landscape { page: wide; } +.ui-data-table__table { + width: var(--dt-w); table-layout: fixed; border-collapse: collapse; line-height: 1.3; +} +.ui-data-table__table caption { display: none; } +.ui-data-table__table th, .ui-data-table__table td { + height: 20px; padding: 0 4px; overflow: hidden; border: 0.5pt solid #666; + white-space: nowrap; white-space-collapse: preserve-spaces; text-overflow: clip; +} +.ui-data-table__table [data-num] { text-align: right; font-variant-numeric: tabular-nums; } +.ui-data-table__table td.is-empty { text-align: center; color: #555; } +.ui-data-table__table thead { display: table-header-group; } +.ui-data-table__table thead th { background: #eee; font-weight: 600; text-align: center; } +.ui-data-table__table thead th.sheet-title { + background: #fff; border: 0; text-align: left; font-size: 13px; font-weight: 700; height: 26px; +} +.ui-data-table__table tfoot { display: table-row-group; } +.ui-data-table__table tfoot td { background: #f3f3f3; font-weight: 700; } +.ui-data-table__table tbody tr.is-title td { font-weight: 700; } +.ui-data-table__table td.is-merged { vertical-align: middle; } +.ui-data-table__table tr { break-inside: avoid; } +.ui-data-table__table td.ui-data-table__lv { + text-align: left; padding-left: calc(4px + var(--dt-lv, 0) * 1em); +} +.ui-data-table__table td.ui-data-table__lv.is-node { font-weight: 700; } +.ui-data-table__guide, .ui-data-table__icon { display: none; } +.sheet-figure { margin: 8px 0 0; break-inside: avoid; } +.sheet-figure img { max-width: 100%; max-height: 240mm; } +.sheet-figure figcaption { font-size: 11px; margin-top: 2px; } +""" +FOOTER = ( + '
' + ' /
' +) + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 + + +class PrintError(Exception): + """까닭 한 줄(예외 원문 없음).""" + + +def _titled(table: str, name: str) -> str: + """표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨.""" + span = max(1, table.count("{htmlmod.escape(name)}' + return table.replace("", f"{row}", 1) if "" in table else table + + +def document_html(title: str, sheets: list[dict[str, Any]]) -> str: + """시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름.""" + parts = [ + f'
' + f"{_titled(s['html'], s['name'])}
" + for s in sheets + ] + return ( + f'{htmlmod.escape(title)}' + f"{''.join(parts)}" + ) + + +#: 시트 덩이마다 쪽 폭에 맞춰 줄임(zoom · 넓은 표만) +FIT_JS = ( + """(w) => { for (const s of document.querySelectorAll('.sheet')) { + const width = s.scrollWidth, max = w[s.classList.contains('is-landscape') ? 1 : 0]; + if (width > max) s.style.zoom = String(Math.max(%s, max / width)); } }""" + % MIN_ZOOM +) + + +def render_pdf(doc: str) -> bytes: + """헤드리스 브라우저 → PDF 바이트. 실패 = PrintError.""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import sync_playwright + except ImportError as error: + raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True) + break + except PwError: + continue + if browser is None: + raise PrintError(NO_BROWSER) + try: + context = browser.new_context(java_script_enabled=False) + context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) + page = context.new_page() + page.set_content(doc, wait_until="load") + page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]]) + return page.pdf( + prefer_css_page_size=True, + print_background=True, + display_header_footer=True, + header_template="", + footer_template=FOOTER, + ) + finally: + browser.close() + except PrintError: + raise + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise PrintError(NO_BROWSER) from error + + +def spawn_pdf(doc: str) -> bytes: + """자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음.""" + with _lock, tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "out.pdf" + try: + done = subprocess.run( + [sys.executable, __file__], + input=json.dumps({"doc": doc, "out": str(out)}).encode(), + capture_output=True, + timeout=TIMEOUT_S, + ) + except subprocess.TimeoutExpired as error: + raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error + except OSError as error: + raise PrintError(NO_BROWSER) from error + try: + result = json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + result = {"reason": NO_BROWSER} + if result.get("reason"): + raise PrintError(result["reason"]) + return out.read_bytes() + + +# ── 엑셀 ────────────────────────────────────────────────────────────────── + +BAD_TITLE = re.compile(r"[\[\]:*?/\\]") + + +def sheet_title(name: str, used: set[str]) -> str: + """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" + base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" + title, n = base[:31], 1 + while title.lower() in used: + n += 1 + tail = f" ({n})" + title = base[: 31 - len(tail)] + tail + used.add(title.lower()) + return title + + +def number_format(decimals: int | None) -> str: + return "#,##0" + ("." + "0" * decimals if decimals else "") + + +def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: + """grid 시트들 → 엑셀 바이트(차례 그대로).""" + from openpyxl import Workbook + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + from openpyxl.utils import get_column_letter + + thin = Side(style="thin", color="666666") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + head_fill = PatternFill("solid", fgColor="EEEEEE") + wb = Workbook() + wb.remove(wb.active) + used: set[str] = set() + for sheet in sheets: + ws = wb.create_sheet(sheet_title(sheet["name"], used)) + g = sheet["grid"] + cols, top = g["cols"], g["head_rows"] + for j, col in enumerate(cols, 1): + ws.column_dimensions[get_column_letter(j)].width = max(4, round(col["w"] / 7, 1)) + for r in range(1, top + 1): + for j in range(1, len(cols) + 1): + cell = ws.cell(r, j) + cell.font, cell.fill, cell.border = Font(bold=True), head_fill, box + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + for h in g["head"]: + ws.cell(h["r"] + 1, h["c"] + 1, h["text"]) + if h["rs"] > 1 or h["cs"] > 1: + ws.merge_cells( + start_row=h["r"] + 1, + start_column=h["c"] + 1, + end_row=h["r"] + h["rs"], + end_column=h["c"] + h["cs"], + ) + lv_col = g.get("level_col") + for i, row in enumerate(g["rows"]): + r = top + 1 + i + bold = bool(g["bold"][i]) if i < len(g["bold"]) else False + level = g["levels"][i] if i < len(g["levels"]) else None + for j, value in enumerate(row): + cell = ws.cell(r, j + 1, value) + cell.border = box + if bold: + cell.font = Font(bold=True) + if isinstance(value, (int, float)) and not isinstance(value, bool): + cell.number_format = number_format(cols[j].get("dec")) + if j == lv_col and level: + cell.alignment = Alignment(indent=max(0, int(level) - 1)) + for m in g.get("merges", []): + ws.merge_cells( + start_row=top + 1 + m["r"], + start_column=m["c"] + 1, + end_row=top + m["r"] + m["rs"], + end_column=m["c"] + 1, + ) + ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center") + if top: + ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1) + out = io.BytesIO() + wb.save(out) + return out.getvalue() + + +if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음 + job = json.loads(sys.stdin.buffer.read()) + try: + Path(job["out"]).write_bytes(render_pdf(job["doc"])) + print(json.dumps({"reason": None})) + except PrintError as error: + print(json.dumps({"reason": str(error)}, ensure_ascii=False)) + sys.exit(0) + + +# ── 길 ──────────────────────────────────────────────────────────────────── + +import asyncio # noqa: E402 +from urllib.parse import quote # noqa: E402 +from uuid import UUID # noqa: E402 + +from fastapi import APIRouter, HTTPException # noqa: E402 +from fastapi.responses import Response # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +router = APIRouter() +MAX_SHEETS = 200 + + +class PdfSheet(BaseModel): + name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" + html: str + + +class XlsxSheet(BaseModel): + name: str = Field(max_length=200) + grid: dict[str, Any] + + +class PdfBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[PdfSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +class XlsxBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[XlsxSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +def _file(data: bytes, media: str, title: str, ext: str) -> Response: + name = quote(f"{title}.{ext}") + return Response( + content=data, + media_type=media, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{name}"}, + ) + + +@router.post("/{project_id}/print/table-pdf") +async def table_pdf(project_id: UUID, body: PdfBody) -> Response: + doc = document_html(body.title, [s.model_dump() for s in body.sheets]) + try: + data = await asyncio.to_thread(spawn_pdf, doc) + except PrintError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + return _file(data, "application/pdf", body.title, "pdf") + + +@router.post("/{project_id}/print/table-xlsx") +async def table_xlsx(project_id: UUID, body: XlsxBody) -> Response: + try: + data = await asyncio.to_thread(build_xlsx, [s.model_dump() for s in body.sheets]) + except (KeyError, TypeError, ValueError, IndexError) as error: + raise HTTPException(status_code=400, detail=f"표 꼴이 맞지 않음 — {error}") from error + media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + return _file(data, media, body.title, "xlsx") diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts new file mode 100644 index 000000000..8f5255cc3 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B09_Draft_Print_Table.ts + * 인쇄 시트 → 서버 몸(PLAN 302-1 · 초안) — 화면과 같은 표 짜기(DataTableOptions)를 그대로 씀. + * sheetHtml = `renderDataTableHtml`(누름 · 입력 뺀 보기 전용) + 그림(data: 주소) → 서버 표 PDF + * sheetGrid = 머리 줄(묶음 병합) · 값 줄(숫자 = 숫자 · 합계 = 끝 줄) · 열 폭 · 소수 · 레벨 · 세로 병합 → 서버 엑셀 + * 그림 = 엑셀엔 안 넣음(PDF 만) + * ========================================================================== */ + +import { + mergeSpans, + renderDataTableHtml, + SCALE, + type DataTableOptions, +} from "@ui/ui_template_data_table"; +import type { PrintSheet } from "./B09_Draft_Print_Types"; + +export interface GridHead { + r: number; + c: number; + rs: number; + cs: number; + text: string; +} + +export interface SheetGrid { + head_rows: number; + head: GridHead[]; + /** 열 폭 px · 소수 자리(글 열 = null) */ + cols: { w: number; dec: number | null }[]; + rows: (string | number | null)[][]; + bold: boolean[]; + levels: (number | null)[]; + level_col: number | null; + /** 본문 세로 병합(mergeSame) — r = 값 줄 번호 */ + merges: { r: number; c: number; rs: number }[]; + frozen: number; +} + +const ESC_HTML: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const escHtml = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC_HTML[c]); + +/** 보기 전용 — 누름 · 입력 · 강조를 뺀 같은 표 */ +const still = (t: DataTableOptions): DataTableOptions => ({ + ...t, + onRowClick: undefined, + onCellClick: undefined, + editable: undefined, + onEdit: undefined, + editOnDouble: false, + editing: null, + focus: null, + fillViewport: false, +}); + +export function sheetHtml(s: PrintSheet): string { + const figs = (s.images ?? []) + .map( + (im) => + `
` + + (im.caption ? `
${escHtml(im.caption)}
` : "") + + `
`, + ) + .join(""); + return renderDataTableHtml(still(s.table)) + figs; +} + +function cellValue(v: unknown): string | number | null { + if (v === null || v === undefined || v === "") return null; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + return String(v); +} + +export function sheetGrid(table: DataTableOptions): SheetGrid { + const t = still(table); + const cols = t.columns; + const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0)); + const head: GridHead[] = []; + const same = (a: string[] | undefined, b: string[] | undefined, n: number) => + (b?.length ?? 0) > n && a!.slice(0, n + 1).join("\u0000") === b!.slice(0, n + 1).join("\u0000"); + // 화면 머리(`headHtml`)와 같은 칸 나눔 — 묶음 = 옆으로 합침 · 맨 아래 머리 = 아래로 합침 + for (let lv = 0; lv <= depth; lv++) { + for (let j = 0; j < cols.length; j++) { + const g = cols[j].group?.length ?? 0; + if (g > lv) { + let k = j; + while (k + 1 < cols.length && same(cols[j].group, cols[k + 1].group, lv)) k++; + head.push({ r: lv, c: j, rs: 1, cs: k - j + 1, text: cols[j].group![lv] }); + j = k; + } else if (g === lv) { + head.push({ r: lv, c: j, rs: depth - lv + 1, cs: 1, text: cols[j].label }); + } + } + } + const n = t.rows.length; + const rows = t.rows.map((r) => cols.map((c) => cellValue(r[c.key]))); + const bold = t.rows.map((_r, i) => /\bis-title\b/.test(t.rowClass?.(i) ?? "")); + const levels = t.rows.map((_r, i) => (t.level ? (t.level.of(i) ?? 1) : null)); + if (t.total) { + rows.push(cols.map((c) => cellValue(t.total![c.key]))); + bold.push(true); + levels.push(null); + } + const merges: SheetGrid["merges"] = []; + if (t.mergeSame?.length) + mergeSpans(t, 0, n).forEach((spans, i) => + spans.forEach((rs, c) => rs > 1 && merges.push({ r: i, c, rs })), + ); + const lvCol = t.level ? cols.findIndex((c) => c.key === t.level!.key) : -1; + return { + head_rows: depth + 1, + head, + cols: cols.map((c) => ({ + w: Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE), + dec: c.kind === "number" ? (c.decimals ?? 0) : null, + })), + rows, + bold, + levels, + level_col: lvCol >= 0 ? lvCol : null, + merges, + frozen: Math.min(t.frozen ?? 0, cols.length), + }; +} + +/** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ +export async function inlineImages(s: PrintSheet): Promise { + if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; + const images = await Promise.all( + s.images.map(async (im) => { + if (im.src.startsWith("data:")) return im; + try { + const blob = await (await fetch(im.src, { credentials: "include" })).blob(); + const src = await new Promise((done, fail) => { + const reader = new FileReader(); + reader.onload = () => done(String(reader.result)); + reader.onerror = () => fail(reader.error); + reader.readAsDataURL(blob); + }); + return { ...im, src }; + } catch { + return { ...im, src: "" }; + } + }), + ); + return { ...s, images: images.filter((im) => im.src) }; +} diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py new file mode 100644 index 000000000..654e48399 --- /dev/null +++ b/resources/tester/test_302_1_print_frame.py @@ -0,0 +1,251 @@ +"""PLAN 302-1 — 인쇄 미리보기 틀(초안 길 등록 · 서버 표 PDF · 엑셀 · 표 → 서버 이음매).""" + +import io +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Router as b09 # noqa: E402 +from B09_Estimation.draft import B09_Draft_Print_Router as pr # noqa: E402 + +DRAFT = ROOT / "B09_Estimation/draft" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +PID = "00000000-0000-0000-0000-000000000001" + + +def _text(path: Path) -> str: + return path.read_text("utf-8") + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(pr.router, prefix="/api/projects") + return TestClient(app) + + +def _ts(expr: str) -> object: + """표 짜기(공용 표 + 인쇄 표) TS 를 노드로 — expr 결과 JSON.""" + js = "".join( + IMPORTS.sub("", _text(p)) + for p in ( + ROOT / "ui_template/ui_template_data_table.ts", + DRAFT / "B09_Draft_Print_Table.ts", + ) + ) + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +#: 머리 두 줄(묶음) · 숫자 열 · 레벨 · 세로 병합 · 합계 · 제목 줄 +TABLE = """{ + columns: [ + { key: "kind", label: "공종" }, + { key: "name", label: "이름", width: 120 }, + { key: "q", label: "수량", group: ["물량"], kind: "number", decimals: 2 }, + { key: "a", label: "금액", group: ["물량"], kind: "number" }, + ], + rows: [ + { kind: "토공", name: "토공", q: null, a: null }, + { kind: "토공", name: "터파기", q: 12.5, a: 1500 }, + { kind: "토공", name: "되메우기 ", q: 3, a: 300 }, + { kind: "포장", name: "포장", q: 1234.567, a: 2000000 }, + ], + total: { name: "합계", a: 2001800 }, + frozen: 1, + mergeSame: ["kind"], + level: { key: "name", of: (i) => (i === 0 ? 1 : 2) }, + rowClass: (i) => (i === 0 ? "is-title" : null), + onRowClick: () => {}, +}""" + + +def _sheets(): + return _ts( + f"(() => {{ const t = {TABLE}; return [" + "{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," + " grid: sheetGrid(t)}," + "{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," + " html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," + " caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()" + ) + + +# ── 길 등록 ────────────────────────────────────────────────────────────── + + +def test_초안_길은_선언으로_등록_정적_표엔_이름_없음(): + router = _text(ROOT / "A00_Common/router.ts") + assert ( + 'import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })' + in router + ) + assert "routeTable[route] ?? draft?.load" in router + assert "draft?.workflow" in router and "draft?.project" in router + assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts") + route = _text(DRAFT / "B09_Draft_Print_Route.ts") + assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route + # 가짜 대조 — 정적 표 · 셸 · 문구에 초안 이름이 없음(폴더째 지우면 길이 함께 사라짐) + for rel in ( + "config/config_frontend.ts", "A00_Common/router.ts", "A00_Common/main.ts", + "ui_template/ui_template_loupe.ts", "B09_Estimation/B09_Estimation_UI_Shell.ts", + "B09_Estimation/B09_Estimation_UI_Tabs.ts", + ): # fmt: skip + body = _text(ROOT / rel) + assert "b09-print" not in body and "Draft_Print" not in body, rel + # 돋보기 · 기본 메뉴 막음 = 주소 꼴(b0x-)로 함께 걸림 + assert re.search(r"/\^\(b0\[1-9\]", _text(ROOT / "ui_template/ui_template_loupe.ts")) + + +def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monkeypatch): + paths = {r.path for r in b09.router.routes} + assert "/api/projects/{project_id}/print/table-pdf" in paths + assert "/api/projects/{project_id}/print/table-xlsx" in paths + # 가짜 폴더 — 비면 아무것도 안 끼움 · *_Router.py 가 있으면 그 길만 · 깨진 초안은 건너뜀 + pkg = tmp_path / "fakepkg" + (pkg / "draft").mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "draft/__init__.py").write_text("") + monkeypatch.syspath_prepend(str(tmp_path)) + empty = APIRouter() + assert b09.include_drafts(empty, pkg / "draft", "fakepkg.draft") == [] + assert empty.routes == [] + (pkg / "draft/X_Router.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/x')\ndef x():\n return 1\n" + ) + (pkg / "draft/Y_Router.py").write_text("import 없는_모듈\n") + got = APIRouter() + assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"] + assert [r.path for r in got.routes] == ["/x"] + + +def test_로그인_회사_권한은_B09_길과_같음(): + main = _text(ROOT / "main.py") + assert "app.include_router(b09_estimation_router, dependencies=protected_with_company)" in main + assert "Depends(require_project_access)" in main + # 초안 router 는 머리 · 의존성 없이 B09 router 안으로만 들어감 + assert "APIRouter()" in _text(DRAFT / "B09_Draft_Print_Router.py") + + +# ── 서버 표 출력 ────────────────────────────────────────────────────────── + + +def test_table_pdf_쪽_방향_시트_이름_쪽_번호(): + pymupdf = pytest.importorskip("pymupdf") + sheets = _sheets() + res = _client().post( + f"/api/projects/{PID}/print/table-pdf", + json={"title": "수량산출", "sheets": [{k: s[k] for k in ("name", "orientation", "html")} + for s in sheets]}, + ) # fmt: skip + if res.status_code == 503: + pytest.skip(res.json()["detail"]) # 서버에 Edge · Chrome 없음 + assert res.status_code == 200, res.text + assert res.content[:5] == b"%PDF-" and res.headers["content-type"] == "application/pdf" + doc = pymupdf.open(stream=res.content) + assert doc.page_count == 2 + p1, p2 = doc[0], doc[1] + assert p1.rect.width < p1.rect.height and p2.rect.width > p2.rect.height # 시트마다 방향 + t1, t2 = p1.get_text(), p2.get_text() + assert "갑 / 수량[1]" in t1 and "을: 원가표" in t2 # 쪽 머리 = 시트 이름 + assert "1 / 2" in t1 and "2 / 2" in t2 # 쪽 번호 + assert "되메우기 " in t1 and "1,234.57" in t1 and "2,001,800" in t1 # 화면과 같은 글 + assert "그림 <1>" in t2 + + +def test_table_pdf_넓은_표는_쪽_폭에_맞춤_긴_표는_머리_되풀이(): + pymupdf = pytest.importorskip("pymupdf") + rows = "".join(f"줄{i}{i}" for i in range(120)) + html = ( + '' + '' + f"{rows}
이름값
" + ) + doc = pr.document_html("t", [{"name": "넓은 표", "orientation": "portrait", "html": html}]) + try: + data = pr.spawn_pdf(doc) + except pr.PrintError as error: + pytest.skip(str(error)) + pdf = pymupdf.open(stream=data) + words = [w for p in pdf for w in p.get_text("words")] + assert max(w[2] for w in words) <= pdf[0].rect.width # 오른쪽이 쪽 밖으로 안 나감 + texts = [p.get_text() for p in pdf] + assert "줄119" in texts[-1] + assert all("넓은 표" in t and "이름" in t for t in texts) # 쪽마다 머리 + + +def test_table_pdf_바깥_요청_막음(): + html = '
' + doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}]) + assert "" and ws["C3"].value is None + assert ws["B4"].alignment.indent == 1 and ws["B3"].font.b # 레벨 2 들여씀 · 제목 줄 굵게 + assert ( + ws["B7"].value == "합계" and ws["D7"].value == 2001800 and ws["D7"].font.b + ) # 합계 = 끝 줄 + assert ws["A3"].border.left.style == "thin" + assert ws.freeze_panes == "B3" + + +def test_table_xlsx_꼴_틀리면_400(): + res = _client().post( + f"/api/projects/{PID}/print/table-xlsx", json={"sheets": [{"name": "a", "grid": {}}]} + ) + assert res.status_code == 400 + res = _client().post(f"/api/projects/{PID}/print/table-pdf", json={"sheets": []}) + assert res.status_code == 422 + + +def test_시트_이름_다듬기(): + used: set[str] = set() + assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h" + assert pr.sheet_title("", used) == "시트" + assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름