- 새 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
214 lines
8.1 KiB
Python
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 []],
|
|
}
|