fix(b09-print): 인쇄 미리보기 좌측 하단 두 줄(가로 넘침 없음) · 엑셀 시트 인쇄 설정 = PDF 방향 · A4 · 폭 1쪽 · 머리 줄 반복 (302-5)

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 08:24:05 +09:00
co-authored by Claude Opus 5.5
parent 13ba1fe298
commit d4ad0d6685
5 changed files with 102 additions and 5 deletions
@@ -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;
}
+10 -2
View File
@@ -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);
+19 -1
View File
@@ -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]
@@ -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: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */
+40 -1
View File
@@ -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