- 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get
- 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」
- 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로
- 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
212 lines
7.8 KiB
Python
212 lines
7.8 KiB
Python
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
|
|
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
|
|
읽기 전용 보기).
|
|
|
|
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
|
|
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
|
|
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
|
|
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
|
|
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 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
|
|
|
|
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
|
|
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, "")
|
|
}
|
|
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 []],
|
|
}
|