feat(b09-print): 인쇄 미리보기 틀 — draft 길 선언 glob 등록 · 페이지(영역 셋 · 쪽 체크 · PDF 보기 · 내려받기) · 서버 표 PDF(Playwright) · 엑셀(openpyxl) · B09 Router 초안 길 끼우기 (302-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-13 07:52:49 +09:00
co-authored by Claude Opus 5.5
parent 5034fa4ea8
commit ed47306209
9 changed files with 1157 additions and 5 deletions
+3 -1
View File
@@ -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); // 아울렛에 페이지 렌더
};
+29 -4
View File
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
export interface DraftRoute {
path: string;
load: () => Promise<PageRenderer>;
/** 로그인 · 회사 필요(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<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
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<void> {
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<void> {
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 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<void> {
}
}
const loader = routeTable[route];
const loader = routeTable[route] ?? draft?.load;
if (!loader) {
renderPlaceholder(outlet, route);
return;
+18
View File
@@ -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")
@@ -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;
}
@@ -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<Blob>;
xlsx?: (ids: string[]) => Promise<Blob>;
}
async function post(projectId: string, path: string, body: unknown): Promise<Blob> {
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<Area | null> {
const def = AREAS.find((a) => a.id === id)!;
const load = MODS[def.file];
if (!load) return null;
const mod = (await load()) as Record<string, unknown>;
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<string>(); // 체크 뺀 쪽
let seq = 0;
let shown: { url: string; blob: Promise<Blob> } | 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);
@@ -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,
};
@@ -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 = (
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
)
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나
class PrintError(Exception):
"""까닭 한 줄(예외 원문 없음)."""
def _titled(table: str, name: str) -> str:
"""표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨."""
span = max(1, table.count("<col "))
row = f'<tr><th class="sheet-title" colspan="{span}">{htmlmod.escape(name)}</th></tr>'
return table.replace("<thead>", f"<thead>{row}", 1) if "<thead>" in table else table
def document_html(title: str, sheets: list[dict[str, Any]]) -> str:
"""시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름."""
parts = [
f'<section class="sheet is-{s.get("orientation") or "portrait"}">'
f"{_titled(s['html'], s['name'])}</section>"
for s in sheets
]
return (
f'<!doctype html><html><head><meta charset="utf-8"><title>{htmlmod.escape(title)}</title>'
f"<style>{PRINT_CSS}</style></head><body>{''.join(parts)}</body></html>"
)
#: 시트 덩이마다 쪽 폭에 맞춰 줄임(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="<span></span>",
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")
@@ -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<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
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) =>
`<figure class="sheet-figure"><img src="${escHtml(im.src)}" alt="">` +
(im.caption ? `<figcaption>${escHtml(im.caption)}</figcaption>` : "") +
`</figure>`,
)
.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<PrintSheet> {
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<string>((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) };
}
+251
View File
@@ -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: "되메우기 <A&B>", 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 "되메우기 <A&B>" 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"<tr><td>줄{i}</td><td data-num>{i}</td></tr>" for i in range(120))
html = (
'<table class="ui-data-table__table" style="--dt-w:3000px"><colgroup>'
'<col style="width:1500px"><col style="width:1500px"></colgroup>'
f"<thead><tr><th>이름</th><th>값</th></tr></thead><tbody>{rows}</tbody></table>"
)
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 = '<table><thead></thead></table><img src="http://127.0.0.1:9/x.png">'
doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}])
assert "<script" not in pr.PRINT_CSS
try:
assert pr.spawn_pdf(doc)[:5] == b"%PDF-" # 막힌 그림 = 빈 칸 · PDF 는 나옴
except pr.PrintError as error:
pytest.skip(str(error))
def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸():
from openpyxl import load_workbook
sheets = _sheets()
body = {"title": "수량", "sheets": [{"name": s["name"], "grid": s["grid"]} for s in sheets]}
body["sheets"].append({"name": "갑 _ 수량_1_", "grid": sheets[0]["grid"]}) # 같은 이름
res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body)
assert res.status_code == 200, res.text
wb = load_workbook(io.BytesIO(res.content))
names = wb.sheetnames
assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _
assert (
names[1] == "을_ 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만"[:31]
and len(names[1]) == 31
)
assert names[2] == "갑 _ 수량_1_ (2)"
ws = wb[names[0]]
merged = {str(m) for m in ws.merged_cells.ranges}
# 머리 두 줄 — 「물량」 = 옆 둘 · 공종 · 이름 = 아래 둘 / 본문 공종 「토공」 세 줄 세로 병합
assert {"C1:D1", "A1:A2", "B1:B2", "A3:A5"} <= merged
assert (ws["C1"].value, ws["C2"].value, ws["A1"].value) == ("물량", "수량", "공종")
assert ws["A1"].font.b and ws["C2"].font.b # 머리 굵게
assert (
ws["C4"].value == 12.5
and ws["C4"].data_type == "n"
and ws["C4"].number_format == "#,##0.00"
)
assert ws["D6"].value == 2000000 and ws["D6"].number_format == "#,##0"
assert ws["B5"].value == "되메우기 <A&B>" 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)" # 대소문자 같은 이름