diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css index 0b7a5e242..a9f0bfb1a 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.css +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -50,3 +50,31 @@ font-size: var(--text-body-sm); cursor: pointer; } + +/* 좌측 하단 두 줄(302-5 · B09 Dock 꼴) — 단추는 줄 폭을 나눠 가짐 · 가로 넘침 없음 */ +.b09-print__actions.ui-sidebar-actions { + flex-direction: column; +} + +.b09-print__actions.ui-sidebar-actions > * { + flex: none; +} + +.b09-print__actions .b09-print__row { + display: flex; + gap: var(--btn-group-gap); + min-width: 0; +} + +.ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: 0; + padding-inline: var(--spacing-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b09-print__actions .b09-print__row > .ui-btn[hidden] { + display: none; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 5007b325c..913f74859 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -268,9 +268,17 @@ export function renderB09Print(root: HTMLElement): MountedPage { ); }; + // 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음 + const files = document.createElement("div"); + files.className = "b09-print__row"; + files.append(pdfBtn, xlsxBtn); + const nav = document.createElement("div"); + nav.className = "b09-print__row"; + nav.dataset.helpRow = ""; + nav.append(back); const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions"; - actions.append(pdfBtn, xlsxBtn, back); + actions.className = "ui-sidebar-actions b09-print__actions"; + actions.append(files, nav); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-print__panel"; left.append(areaCard, sheetCard, actions); diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py index ab7c347f0..dd7841c95 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Router.py +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -8,7 +8,7 @@ POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · - 레벨 = 들여쓰기 · 열 폭 + 레벨 = 들여쓰기 · 열 폭 · 인쇄 = 시트 방향 · A4 · 폭 1쪽 · 머리 줄 반복 `B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 (같은 머리 · 같은 권한 = 회사 · 프로젝트). @@ -199,6 +199,22 @@ def number_format(decimals: int | None) -> str: return "#,##0" + ("." + "0" * decimals if decimals else "") +def print_setup(ws: Any, orientation: str, head_rows: int) -> None: + """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" + from openpyxl.worksheet.page import PageMargins + + ws.page_setup.orientation = orientation + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) + if head_rows: + ws.print_title_rows = f"1:{head_rows}" + ws.oddHeader.left.text = "&A" # 시트 이름 + ws.oddFooter.center.text = "&P / &N" # 쪽 번호 + + def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: """grid 시트들 → 엑셀 바이트(차례 그대로).""" from openpyxl import Workbook @@ -255,6 +271,7 @@ def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: 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) + print_setup(ws, sheet.get("orientation") or "portrait", top) out = io.BytesIO() wb.save(out) return out.getvalue() @@ -292,6 +309,7 @@ class PdfSheet(BaseModel): class XlsxSheet(BaseModel): name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" grid: dict[str, Any] diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts index 43c3db7c3..0aaa5a710 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Table.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -134,7 +134,11 @@ export const pdfBody = (title: string, sheets: PrintSheet[]) => ({ export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({ title, - sheets: sheets.map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), + sheets: sheets.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + grid: sheetGrid(s.table), + })), }); /** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index d64b69631..5c7b89347 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -214,15 +214,30 @@ def test_table_pdf_바깥_요청_막음(): pytest.skip(str(error)) +def _assert_print(wb, orientations: list[str], heads: list[int]) -> None: + """엑셀 인쇄 설정(302-5) — 시트 방향 = PDF · A4 · 폭 1쪽 · 높이 자유 · 머리 줄 반복.""" + assert len(wb.worksheets) == len(orientations) + for ws, orient, head in zip(wb.worksheets, orientations, heads): + ps = ws.page_setup + assert ps.orientation == orient, ws.title + assert int(ps.paperSize) == 9 # A4 + assert (int(ps.fitToWidth), int(ps.fitToHeight)) == (1, 0), ws.title + assert ws.sheet_properties.pageSetUpPr.fitToPage is True + assert ws.print_title_rows.replace("$", "") == f"1:{head}", ws.title + assert ws.page_margins.left < 0.5 and ws.page_margins.bottom < 0.6 + + def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸(): from openpyxl import load_workbook sheets = _sheets() - body = {"title": "수량", "sheets": [{"name": s["name"], "grid": s["grid"]} for s in sheets]} + keys = ("name", "orientation", "grid") + body = {"title": "수량", "sheets": [{k: s[k] for k in keys} 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)) + _assert_print(wb, [*[s["orientation"] for s in sheets], "portrait"], [2, 2, 2]) names = wb.sheetnames assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _ assert ( @@ -313,6 +328,11 @@ def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies): first = wb[wb.sheetnames[0]] assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감 assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None) + _assert_print( # 엑셀 인쇄 방향 = 그 시트 PDF 방향(302-5) + wb, + [s["orientation"] for s in cost_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) def test_원가_영역_PDF_시트_이름_가로(cost_bodies): @@ -375,6 +395,11 @@ def test_수량_영역_엑셀_차례_그대로(quantity_bodies): assert all( wb[n].max_row >= s["grid"]["head_rows"] for n, s in zip(wb.sheetnames, body["sheets"]) ) + _assert_print( + wb, + [s["orientation"] for s in quantity_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies): @@ -401,3 +426,17 @@ def test_B09_좌측_하단_단추는_초안_입구에서만(): assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell dock = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Dock.ts") assert "DRAFT.actions.map((make) => make())" in dock and "Print" not in dock + + +def test_좌측_하단_두_줄_가로_넘침_없음(): + """302-5 — 위 = [PDF][엑셀] · 아래 = [?](도움말 줄) [B09 로 돌아가기] · 줄 폭을 나눠 가짐.""" + page = _text(DRAFT / "B09_Draft_Print_Page.ts") + assert "files.append(pdfBtn, xlsxBtn)" in page + assert "nav.dataset.helpRow" in page and "nav.append(back)" in page + assert "actions.append(files, nav)" in page + css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css")) + assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css + btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :] + btn = btn[: btn.index("}")] + # 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표 + assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn