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:
@@ -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> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user