Files
Aislo/M02_MasterTemplete/M02_Hopyo_Grid.py
T
eomsangdonandClaude Opus 5.5 193e03927b feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글)
- 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0
- Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21
- Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안)
- Grid STmate 시트 · 숨은 칸 글 뺌
- 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:28:57 +09:00

214 lines
8.1 KiB
Python

"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트
(STmate = 「일위대가표」 · 「단가산출근거」 · 338) →
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
읽기 전용 보기).
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
"""
from __future__ import annotations
import io
from typing import Any
from openpyxl.utils import get_column_letter
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC)
HEAD_ROWS = 4
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
_VALIGN = {0: "top", 1: "center"}
_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"}
_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right"))
class _Styles:
"""서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`."""
def __init__(self) -> None:
self.table: list[dict[str, Any]] = [{}]
self._index: dict[str, int] = {"{}": 0}
def add(self, style: dict[str, Any]) -> int:
key = repr(sorted(style.items()))
if not style:
return 0
if key not in self._index:
self._index[key] = len(self.table)
self.table.append(style)
return self._index[key]
def _rgb(book: Any, index: int) -> str | None:
got = book.colour_map.get(index)
return "#{:02X}{:02X}{:02X}".format(*got) if got else None
def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]:
font = book.font_list[xf.font_index]
out: dict[str, Any] = {}
if font.name != base.name:
out["글꼴"] = font.name
if font.height != base.height:
out["크기"] = font.height / 20
for key, on in (
("굵게", font.bold),
("기울임", font.italic),
("밑줄", font.underline_type),
("취소선", font.struck_out),
):
if on:
out[key] = True
color = _rgb(book, font.colour_index)
if color and color != "#000000":
out["글자색"] = color
if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)):
out["채움"] = fill
align = xf.alignment
if align.hor_align in _HALIGN:
out["가로"] = _HALIGN[align.hor_align]
if align.vert_align in _VALIGN:
out["세로"] = _VALIGN[align.vert_align]
if align.text_wrapped:
out["줄바꿈"] = True
sides = {}
for name, side in _SIDES:
line = getattr(xf.border, f"{side}_line_style")
if line:
one = {"선": _LINES.get(line, "dashed")}
color = _rgb(book, getattr(xf.border, f"{side}_colour_index"))
if color and color != "#000000":
one["색"] = color
sides[name] = one
if sides:
out["테두리"] = sides
fmt = book.format_map.get(xf.format_key)
if fmt and fmt.format_str and fmt.format_str != "General":
out["형식"] = fmt.format_str
return out
def _value(cell: Any) -> Any:
import xlrd
if cell.ctype == xlrd.XL_CELL_TEXT:
return cell.value or None
if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE):
return int(cell.value) if cell.value == int(cell.value) else cell.value
if cell.ctype == xlrd.XL_CELL_BOOLEAN:
return bool(cell.value)
if cell.ctype == xlrd.XL_CELL_ERROR:
return xlrd.error_text_from_code.get(cell.value, "#N/A")
return None
def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]:
base = book.font_list[0]
by_xf: dict[int, int] = {}
cells: dict[str, dict[str, Any]] = {}
for r in range(sh.nrows):
for c in range(sh.ncols):
cell = sh.cell(r, c)
if cell.xf_index not in by_xf:
by_xf[cell.xf_index] = styles.add(
_xf_style(book, book.xf_list[cell.xf_index], base)
)
one: dict[str, Any] = {}
if (value := _value(cell)) is not None:
one["값"] = value
if by_xf[cell.xf_index]:
one["서식"] = by_xf[cell.xf_index]
if one:
cells[f"{get_column_letter(c + 1)}{r + 1}"] = one
merged = [
f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}"
for r0, r1, c0, c1 in sh.merged_cells
]
cols = {
get_column_letter(c + 1): {
"폭": round(info.width / 256, 2),
**({"숨김": True} if info.hidden else {}),
}
for c, info in sh.colinfo_map.items()
if c < sh.ncols
}
rows = {
str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})}
for r, info in sh.rowinfo_map.items()
if r < sh.nrows and (info.height != sh.default_row_height or info.hidden)
}
frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos)
freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS}
out = {
"id": sid,
"이름": sh.name.strip(),
"칸": cells,
"병합": merged,
"열": cols,
"행": rows,
"틀고정": {k: v for k, v in freeze.items() if v},
}
if sh.default_row_height:
out["기본"] = {"행높이": sh.default_row_height / 20}
return out
def _xls(data: bytes) -> dict[str, Any]:
import xlrd
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
styles = _Styles()
found = {sh.name.strip(): sh for sh in book.sheets()}
sheets = [
_xls_sheet(book, found[name], styles, f"s{at}")
for at, name in enumerate((n for n in SHEETS if n in found), 1)
]
font = book.font_list[0]
return {
"기본": {"글꼴": font.name, "크기": font.height / 20},
"서식": styles.table,
"시트": sheets,
}
def _xlsx(name: str, data: bytes) -> dict[str, Any]:
# ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
found = hopyo_excel._sheets(name, data)
sheets = []
for at, title in enumerate((n for n in SHEETS if n in found), 1):
rows = found[title]
width = max(
(c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")),
default=0,
)
cells = {
f"{get_column_letter(c + 1)}{r + 1}": {
"값": v if isinstance(v, (int, float, bool)) else str(v)
}
for r, row in enumerate(rows)
for c, v in enumerate(row[:width])
if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글
}
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
return {"서식": [{}], "시트": sheets}
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
"""엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과
(행 범위)."""
xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx")
book = _xlsx(name, data) if xlsx else _xls(data)
keep = ("번", "이름", "행")
return {
"책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"},
"호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []],
"단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []],
}