# xlsx 탭 연동과 포맷팅 방법론 정본 > **이 문서의 역할**: DMF_Crawler 가 매일 06:00 에 생성하는 xlsx 리포트의 시트(탭) 간 연동 구조·서식·조건부 서식·차트/스파크라인·파일 갱신 전략을 확정하는 단일 정본(SSOT)이며, 이 문서만 읽고 리포트 생성 모듈을 구현할 수 있어야 한다. ## 0. 한눈에 보기 - **라이브러리 최종 선택: `xlsxwriter` 를 기본 엔진으로 삼아 매일 "새 파일"을 통째로 생성한다.** 스파크라인은 xlsxwriter 에만 있고, openpyxl 은 기존 파일을 `load_workbook` → `save` 하는 순간 차트·이미지·도형을 잃는다. 누적 갱신 대신 매일 재생성하면 이 손실 문제 자체가 사라진다. - **openpyxl 은 "이미 존재하는 xlsx 를 읽는 용도"와 "재계산 후 오류 스캔(`data_only=True`)" 용도로만 보조 사용한다.** 쓰기 경로에서는 쓰지 않는다. - **시트 간 연동은 4층으로 구성한다**: ① 목차(00_INDEX) 시트 → 각 탭으로 가는 내부 하이퍼링크, ② 각 탭 A1 → 목차로 돌아가는 링크, ③ 시트 간 집계 수식(COUNTIFS/SUMIFS/XLOOKUP), ④ 정의된 이름(Defined Name)으로 범위를 의미 있는 이름으로 고정. - **수식 값은 파이썬이 계산해 주지 않는다.** xlsxwriter 는 결과 자리에 `0` 을 쓰고 "열 때 재계산" 플래그를 세우며, openpyxl 은 캐시값 없이 문자열만 쓴다. → **원칙: 리포트에 들어가는 모든 숫자는 파이썬(pandas)에서 미리 계산해 값으로 쓰고, 수식은 "사용자가 필터를 바꿨을 때 살아 움직여야 하는 셀"에만 쓴다.** 그런 셀에는 `write_formula(..., value=미리계산값)` 로 캐시값을 같이 박아 넣는다. - **동적 배열(FILTER/UNIQUE/SORT/XLOOKUP)은 이 프로젝트에서 쓰지 않는다.** openpyxl 은 spill 메타데이터(`cm="1"`, `metadata.xml`)를 못 써서 저장 시 legacy CSE 배열/`@` 암시적 교차로 깨지고, xlsxwriter 는 `write_dynamic_array_formula()` 로 쓸 수 있지만 값 캐시가 없어 뷰어에서 0 으로 보인다. 필요한 필터링은 파이썬에서 하고, 사용자 인터랙션은 **Excel 표(ListObject) + 자동필터 + 슬라이서 대신 드롭다운**으로 대체한다. - **LibreOffice headless 재계산은 "보험"으로만 둔다.** `C:\Program Files\LibreOffice\program\soffice.exe` 존재는 이 PC 에서 실측 확인됨(`Test-Path` = True). 수식을 최소화하는 정책이라 상시 필요는 없지만, 요약 시트에 수식을 남기기로 했다면 재계산 단계를 파이프라인에 넣고 `#REF!/#NAME?` 스캔까지 해야 한다. - **한글 열 너비는 반드시 직접 계산한다.** xlsxwriter `autofit()` 은 Calibri 11 메트릭 기준이라 한글에서 좁게 나온다. `unicodedata.east_asian_width()` 로 W/F/A 를 2폭으로 세는 유틸을 만들어 `set_column()` 에 넘긴다. - **파일 잠금은 "임시 파일 → `os.replace`" 원자적 교체 + 재시도 + 폴백 파일명**으로 처리한다. Excel 은 읽기 전용으로 열어도 `~$파일명.xlsx` 잠금 파일을 만들고 배타 잠금을 유지하므로, 열려 있으면 `PermissionError` 가 확정적으로 난다. - **색 팔레트는 Okabe-Ito 8색(색각 이상 안전)** 을 채택한다: `#000000 #E69F00 #56B4E9 #009E73 #F0E442 #0072B2 #D55E00 #CC79A7`. - **폰트는 `Malgun Gothic`(맑은 고딕)** 으로 고정한다. Windows Vista 이상 기본 탑재라 배포처에서 깨지지 않는다. - **⚠️ 현재 PC 에 `openpyxl` 이 설치돼 있지 않다** (실측: `ModuleNotFoundError: No module named 'openpyxl'`, Python 3.14.6). `pip install openpyxl xlsxwriter pandas` 가 부트스트랩 단계에 반드시 들어가야 한다. --- ## 1. 목차 - [0. 한눈에 보기](#0-한눈에-보기) - [1. 목차](#1-목차) - [2. 실측된 실행 환경](#2-실측된-실행-환경) - [3. 라이브러리 선택 결정](#3-라이브러리-선택-결정) - [4. DMF 리포트 시트 구조 설계](#4-dmf-리포트-시트-구조-설계) - [5. 시트 간 연동 기법 전집](#5-시트-간-연동-기법-전집) - [5.1 목차 시트 → 각 탭 하이퍼링크](#51-목차-시트--각-탭-하이퍼링크) - [5.2 각 탭 → 목차 복귀 링크](#52-각-탭--목차-복귀-링크) - [5.3 하이퍼링크 시각 스타일](#53-하이퍼링크-시각-스타일) - [5.4 정의된 이름(Defined Names)](#54-정의된-이름defined-names) - [5.5 시트 간 수식: COUNTIFS / SUMIFS / INDEX-MATCH / XLOOKUP](#55-시트-간-수식-countifs--sumifs--index-match--xlookup) - [5.6 동적 배열(FILTER/UNIQUE)과 `_xlfn` 접두어·ArrayFormula](#56-동적-배열filterunique과-_xlfn-접두어arrayformula) - [5.7 Excel 표(ListObject)와 구조적 참조](#57-excel-표listobject와-구조적-참조) - [5.8 다른 시트 범위를 원본으로 하는 데이터 유효성 드롭다운](#58-다른-시트-범위를-원본으로-하는-데이터-유효성-드롭다운) - [6. 수식 값이 파이썬에서 계산되지 않는 문제](#6-수식-값이-파이썬에서-계산되지-않는-문제) - [7. 포맷팅 레시피](#7-포맷팅-레시피) - [8. 조건부 서식](#8-조건부-서식) - [9. 차트와 스파크라인](#9-차트와-스파크라인) - [10. 파일 갱신 전략과 잠금 처리](#10-파일-갱신-전략과-잠금-처리) - [11. 완결 코드 스니펫](#11-완결-코드-스니펫) - [12. 참고 라이브러리·템플릿 프로젝트](#12-참고-라이브러리템플릿-프로젝트) - [부록 A. 출처 목록](#부록-a-출처-목록) - [부록 B. 미해결 질문 / 실측 필요 항목](#부록-b-미해결-질문--실측-필요-항목) --- ## 2. 실측된 실행 환경 리서치 단계에서 대상 PC 에서 실제로 실행한 명령과 결과다. 지어낸 값이 아니다. ```powershell python -c "import sys; print(sys.version); import openpyxl; print('openpyxl', openpyxl.__version__); import xlsxwriter; print('xlsxwriter', xlsxwriter.__version__); import pandas; print('pandas', pandas.__version__)"; Test-Path "C:\Program Files\LibreOffice\program\soffice.exe" ``` 결과 (Exit code 1): ``` Traceback (most recent call last): File "", line 1, in import sys; print(sys.version); import openpyxl; print('openpyxl', openpyxl.__version__); import xlsxwriter; print('xlsxwriter', xlsxwriter.__version__); import pandas; print('pandas', pandas.__version__) ^^^^^^^^^^^^^^^ ModuleNotFoundError: No module named 'openpyxl' 3.14.6 (tags/v3.14.6:c63aec6, Jun 10 2026, 10:26:10) [MSC v.1944 64 bit (AMD64)] True ``` | 항목 | 실측 결과 | 시사점 | |---|---|---| | Python | `3.14.6 (tags/v3.14.6:c63aec6, Jun 10 2026)` MSC v.1944 64bit | 최신. 휠 미제공 패키지 주의 | | openpyxl | **미설치** (`ModuleNotFoundError`) | 부트스트랩에서 설치 필요 | | xlsxwriter | 미확인 (openpyxl import 에서 먼저 죽음) | 부트스트랩에서 설치 필요 | | pandas | 미확인 (동일) | 부트스트랩에서 설치 필요 | | LibreOffice `soffice.exe` | `C:\Program Files\LibreOffice\program\soffice.exe` **존재(True)** | headless 재계산 경로 사용 가능 | 부트스트랩에 넣어야 할 줄: ```powershell python -m pip install --upgrade pip python -m pip install "openpyxl>=3.1" "XlsxWriter>=3.1" "pandas>=2.2" ``` --- ## 3. 라이브러리 선택 결정 ### 3.1 기능 대조표 | 기능 | openpyxl | xlsxwriter | pandas `ExcelWriter` | |---|---|---|---| | 기존 xlsx 읽기 | ✅ `load_workbook()` | ❌ **불가** — "XlsxWriter creates new Excel files only—it cannot modify existing workbooks." | 읽기는 `read_excel` 별도 | | 기존 xlsx 수정/추가 저장 | ✅ 단, 차트·이미지·도형 손실 | ❌ | ✅ `mode='a'` (엔진은 openpyxl 강제) | | 차트 | ✅ Bar/Line/Pie/Doughnut/Scatter/Radar/Stock/Surface/Bubble/Area | ✅ + `combine()`, `set_size`, 상세 `data_labels` | ❌ (엔진 객체로 위임) | | **스파크라인** | ❌ **없음** | ✅ `add_sparkline()` (line/column/win_loss) | ❌ | | 조건부 서식 | ✅ `ColorScaleRule/DataBarRule/IconSetRule/CellIsRule/FormulaRule/Rule` | ✅ `conditional_format()` 18종 타입 | ❌ | | Excel 표(ListObject) | ✅ `Table` + `TableStyleInfo` | ✅ `add_table()` (+ `total_row`, 컬럼 `formula`) | ❌ | | 표 합계행 / 구조적 참조 수식 | 수동 | ✅ `total_function`, `'=SUM(Table10[@[Q1]:[Q4]])'` | ❌ | | 자동 열너비 | ❌ 없음(직접 계산) | ✅ `autofit([max_width])` — 단 Calibri 11 기준 추정 | ❌ | | 하이퍼링크(내부) | ✅ `cell.hyperlink = "#'시트'!A1"` / `Hyperlink(location=...)` | ✅ `write_url("A1", "internal:'Sales Data'!A1")` | ❌ | | 데이터 유효성 | ✅ `DataValidation` | ✅ `data_validation()` | ❌ | | 정의된 이름 | ✅ `wb.defined_names.add(DefinedName(...))`, 시트 스코프 `ws.defined_names` | ✅ `workbook.define_name('Sales', '=Sheet1!$G$1:$H$10')` | ❌ | | 인쇄 설정 | ✅ `page_setup`, `print_title_rows`, `print_area`, `print_options` | ✅ `set_landscape()`, `fit_to_pages()`, `repeat_rows()`, `print_area()`, `set_paper()` | ❌ | | 탭 색 | ✅ `ws.sheet_properties.tabColor = "1072BA"` | ✅ `set_tab_color(color)` | ❌ | | 행/열 그룹화 | ✅ `ws.column_dimensions.group('A','D', hidden=True)` | ✅ `set_column(..., {'level':1})`, `set_row(..., {'level':1})` | ❌ | | 대용량 스트리밍 | ✅ `Workbook(write_only=True)` | ✅ `Workbook(path, {'constant_memory': True})` | 엔진 위임 | | 수식 결과 캐시값 지정 | ❌ (불가) | ✅ `write_formula('A1','=2+2', fmt, 4)` | ❌ | | 미래 함수 `_xlfn` 자동 처리 | ❌ 수동 접두 | ✅ `Workbook(path, {'use_future_functions': True})` | ❌ | | 동적 배열 spill | ❌ 저장 시 legacy 배열로 붕괴 | ✅ `write_dynamic_array_formula()` | ❌ | | VBA 보존 | ✅ `load_workbook(keep_vba=True)` (편집 불가) | 별도 `add_vba_project` | ❌ | ### 3.2 이 프로젝트의 최종 선택 > **결정: 쓰기는 100% `xlsxwriter`, 매일 새 파일 생성. openpyxl 은 (a) 어제 파일을 읽어 diff 하는 용도, (b) 재계산 후 오류 스캔 용도로만 읽기 전용 사용. pandas 는 데이터 정제·집계 전담이며 `to_excel` 로 서식 있는 시트를 직접 쓰지 않는다.** 근거를 하나씩: 1. **스파크라인이 요구사항에 있다.** openpyxl 에는 스파크라인 API 자체가 없다. 요구를 만족하는 유일한 파이썬 라이브러리가 xlsxwriter 다. 2. **누적 갱신(append)이 함정이다.** openpyxl 공식 문서: *"openpyxl does currently not read all possible items in an Excel file so shapes will be lost from existing files if they are opened and saved with the same name."* 차트·스파크라인이 든 리포트를 `load_workbook` → `save` 하면 그것들이 사라진다. pandas `mode='a'` 도 내부적으로 `openpyxl.load_workbook` 을 호출하므로 동일하게 손실된다. 3. **매일 새 파일이 감사·추적에 더 낫다.** DMF 공고는 "그날 스냅샷"이 증빙 가치를 가진다. `DMF_리포트_2026-09-02.xlsx` 처럼 날짜 파일명으로 남기고, 별도로 `DMF_리포트_최신.xlsx` 를 원자적 복사로 갱신하면 "매일 새 파일 + 하나의 고정 링크" 를 동시에 얻는다. 4. **xlsxwriter 가 표(ListObject) 합계행·구조적 참조·조건부 서식·차트·스파크라인·autofit 을 한 API 로 다 제공**한다. openpyxl 로 같은 결과를 만들려면 코드량이 배로 늘고, 표 합계행은 수동 구현해야 한다. 5. **읽기가 필요할 때만 openpyxl.** xlsxwriter 는 읽기를 아예 못 하므로, "어제 리포트에서 이전 상태를 읽어 신규/변경/취하를 판정"하려면 openpyxl(또는 pandas `read_excel`)이 필요하다. 다만 diff 원본은 xlsx 가 아니라 **SQLite/파케이 같은 별도 상태 저장소**를 쓰는 편이 안전하다(xlsx 는 사람이 열어 편집하다가 오염될 수 있다). 예외 규칙: - 사용자가 손으로 만든 **템플릿 xlsx(로고·서식만 있고 차트 없음)** 에 데이터만 채워 넣는 요구가 생기면 그때만 openpyxl 을 쓴다. 차트가 없으면 손실 문제도 없다. - 100만 행급 원장 시트가 생기면 `Workbook(path, {'constant_memory': True})` 로 전환한다(행을 순차로 쓰고 버림 → 메모리 상수). --- ## 4. DMF 리포트 시트 구조 설계 이 문서의 모든 기법이 조립되는 최종 형태다. 시트 이름은 정렬을 위해 숫자 접두어를 쓴다. | # | 시트명 | 탭 색 | 역할 | 주요 기법 | |---|---|---|---|---| | 1 | `00_목차` | `#0072B2` | 대시보드 겸 목차. 각 탭 링크, KPI 카드, 차트 2개, 스파크라인 열 | `write_url(internal:)`, 차트, 스파크라인, KPI | | 2 | `01_신규` | `#009E73` | 오늘 새로 등록된 DMF | 표(ListObject), 조건부 서식, 틀고정 | | 3 | `02_변경` | `#E69F00` | 항목이 바뀐 DMF (변경 전/후 병기) | 수식 기반 행 강조, 중복 강조 | | 4 | `03_취하` | `#D55E00` | 취하·취소된 DMF | 행 전체 강조 | | 5 | `04_전체현황` | `#56B4E9` | 전체 스냅샷(원장) | `constant_memory` 후보, 자동필터, 그룹화 | | 6 | `05_추이` | `#CC79A7` | 일자별 건수 시계열(스파크라인 원본) | 차트 원본 데이터 | | 7 | `99_메타` | `#7F7F7F` | 수집 시각, 소스 URL, 실행 로그, 해시 | 숨김 후보, 드롭다운 원본 | 레이아웃 규칙: - **각 데이터 시트의 1행은 "← 목차로" 링크 + 시트 제목 + 생성 시각**, 2행은 공백, **3행이 헤더**, 4행부터 데이터. → `freeze_panes(3, 0)` (0-index 로 3행까지 고정). - 헤더행 아래 전체를 Excel 표로 등록하여 자동필터·줄무늬·구조적 참조를 한 번에 얻는다. - 숫자·날짜 열은 시트 단위 `set_column()` 으로 서식을 걸어 셀마다 포맷 객체를 만들지 않는다(포맷 객체 재사용은 파일 크기와 속도에 직결). --- ## 5. 시트 간 연동 기법 전집 ### 5.1 목차 시트 → 각 탭 하이퍼링크 세 가지 방법이 있고, 셋 다 같은 결과를 만든다. #### (a) 워크시트 `HYPERLINK()` 수식 — 엔진 무관 ``` =HYPERLINK("#'01_신규'!A1", "신규 등록 보기") ``` - `#` 이 **통합문서 내부 참조**를 의미한다. - 시트명에 공백·특수문자·숫자 시작이 있으면 **작은따옴표로 감싼다**. `01_신규` 처럼 숫자로 시작하는 이름도 감싸는 편이 안전하다. - 장점: 엔진 독립적이고 텍스트도 수식 인자로 같이 준다. - 단점: 수식이므로 일부 뷰어에서 링크가 죽고, 파이썬은 값을 못 만든다(표시 텍스트는 수식의 2번째 인자라 Excel 이 렌더링). #### (b) xlsxwriter `write_url()` — **이 프로젝트 채택** ```python # 같은 통합문서 내부 링크 worksheet.write_url("A1", "internal:Sheet2!A1") # 시트명에 공백이 있으면 작은따옴표 worksheet.write_url("A3", "internal:'Sales Data'!A1") # 범위로 링크 worksheet.write_url("A2", "internal:Sheet2!A1:B2") ``` 전체 시그니처: ```python write_url(row, col, url[, cell_format[, string[, tip]]]) ``` - `string` 이 셀에 보이는 텍스트, `tip` 이 마우스오버 툴팁이다. - 접두어 `internal:` 이 내부 링크, `external:` 이 파일 링크, `http(s)://`/`mailto:` 가 외부 링크. - `Workbook` 옵션 `strings_to_urls` 는 기본 True 라, 평범한 문자열이 URL 처럼 생겼으면 자동으로 링크가 된다. DMF 품목명에 URL 비슷한 문자열이 섞일 가능성이 있으면 `{'strings_to_urls': False}` 로 끄고 명시적으로만 링크를 건다. - `max_url_length` 기본 2079자(최소 255). #### (c) openpyxl `cell.hyperlink` 가장 짧은 형태(공식 메일링리스트에서 권장된 방식): ```python currentCell.value = 'Index Tab' currentCell.hyperlink = '#Index!D3' ``` 문자열 포매팅 형태: ```python currentCell.hyperlink = '#%s!%s' % ('Index', 'D3') ``` `Hyperlink` 객체를 쓰는 형태: ```python from openpyxl.worksheet.hyperlink import Hyperlink hyperlink = Hyperlink(location="#'Sheet'!A1") cell.hyperlink = hyperlink ``` `Hyperlink` 클래스 생성자 파라미터(모두 `str`): | 파라미터 | 의미 | |---|---| | `ref` | 하이퍼링크가 붙는 셀 참조 | | `location` | **내부 링크 목적지** | | `target` | 외부 URL / 파일 경로 | | `tooltip` | 마우스오버 텍스트 | | `display` | 표시 텍스트 | | `id` | 관계(relationship) 식별자 | 주의사항 (메일링리스트 스레드에서 확인된 것): - `cell.hyperlink` 에 **Cell 객체를 넣으면 `AttributeError`** 가 난다. 반드시 문자열(또는 `Hyperlink` 객체). - 셀에 보이는 텍스트는 `cell.value` 로 따로 넣어야 한다. 하이퍼링크만 설정하면 빈 셀에 링크만 걸린 상태가 된다. - 시트명에 공백이 있으면 `'#\'Sheet Name\'!D3'` 처럼 작은따옴표로 감싼다(스레드에서 명시적으로 다뤄지진 않았으나 Excel 표준 관례. ⚠️ 미검증 — 실측 권장). ### 5.2 각 탭 → 목차 복귀 링크 각 데이터 시트 `A1` 에 항상 같은 링크를 심는다. ```python back_fmt = workbook.add_format({ 'font_name': 'Malgun Gothic', 'font_size': 10, 'font_color': '#0072B2', 'underline': 1, }) worksheet.write_url("A1", "internal:'00_목차'!A1", back_fmt, "← 목차로", "목차 시트로 이동") ``` `freeze_panes(3, 0)` 을 걸면 A1 은 스크롤해도 늘 보이는 영역에 남는다. ### 5.3 하이퍼링크 시각 스타일 **xlsxwriter**: 링크에 서식을 주지 않으면 기본 파란 밑줄이 적용된다. 커스텀하려면 위처럼 `font_color` + `underline` 을 지정한다. **openpyxl**: 두 방법. ```python # 1) 내장/사용자 정의 NamedStyle from openpyxl.styles import NamedStyle, Font hyperlink_style = NamedStyle(name='Hyperlink', font=Font(color="FF0000FF", underline="single")) wb.add_named_style(hyperlink_style) ws['A1'].style = 'Hyperlink' # 2) 직접 Font 지정 ws['A1'].font = Font(color="FF0000FF", underline="single") ``` 색상 문자열이 8자리(`FF0000FF`)면 앞 2자리가 알파(FF=불투명)다. ### 5.4 정의된 이름(Defined Names) 정의된 이름은 시트 간 수식을 **읽기 쉽고 깨지지 않게** 만든다. `COUNTIFS('04_전체현황'!$G$4:$G$99999, "신규")` 대신 `COUNTIFS(현황_상태, "신규")`. #### xlsxwriter ```python # 전역(통합문서 스코프) workbook.define_name('Sales', '=Sheet1!$G$1:$H$10') # 시트 스코프 workbook.define_name('Sheet2!Sales', '=Sheet2!$G$1:$G$10') # 시트명에 공백이 있으면 작은따옴표 workbook.define_name("'New Data'!Sales", "='New Data'!$G$1:$G$10") ``` #### openpyxl 3.1 전역 이름 만들기: ```python from openpyxl import Workbook from openpyxl.workbook.defined_name import DefinedName from openpyxl.utils import quote_sheetname, absolute_coordinate wb = Workbook() ws = wb.active ref = f"{quote_sheetname(ws.title)}!{absolute_coordinate('A1:A5')}" defn = DefinedName("global_range", attr_text=ref) wb.defined_names["global_range"] = defn # 또는 키/이름 불일치를 신경 쓰지 않아도 되는 add() wb.defined_names.add(defn) ``` > ⚠️ 공식 문서 예제 자체에 오타가 있다. 문서에는 `ref = "{quote_sheetname(ws.title)}!{absolute_coordinate('A1:A5')}"` 처럼 **f 접두어가 빠진 문자열**과, 정의되지 않은 변수 `new_range` 를 넘기는 `wb.defined_names.add(new_range)` 가 함께 실려 있다. 위 코드처럼 f-string 과 `defn` 으로 고쳐 써야 동작한다. 시트 스코프 이름: ```python ws = wb["Sheet"] ws.title = "My Sheet" ref = f"{quote_sheetname(ws.title)}!{absolute_coordinate('A6')}" defn = DefinedName("private_range", attr_text=ref) ws.defined_names.add(defn) print(ws.defined_names["private_range"].attr_text) ``` 읽기: ```python defn = wb.defined_names["my_range"] dests = defn.destinations # (worksheet title, cell range) 튜플 제너레이터 cells = [] for title, coord in dests: ws = wb[title] cells.append(ws[coord]) # 시트 스코프 ws = wb["Sheet"] defn = ws.defined_names["private_range"] ``` 인쇄 영역으로 활용: ```python from openpyxl import load_workbook wb = load_workbook("Example.xlsx") ws = wb.active area = ws.defined_names["TestArea"] ws.print_area = area.value ``` openpyxl 3.1 에서 `workbook.defined_names` 는 `DefinedNameDict` 클래스이며, 이름으로 접근 가능하고 전역/스코프 이름을 분리하며 `.add()` 로 키·이름 일치를 자동 처리한다. ### 5.5 시트 간 수식: COUNTIFS / SUMIFS / INDEX-MATCH / XLOOKUP 목차 시트의 KPI 카드가 이 수식들로 각 데이터 시트를 참조한다. ```python # 00_목차 시트에서, 04_전체현황 시트의 상태 열을 세기 worksheet.write_formula( "C5", "=COUNTIFS('04_전체현황'!$G$4:$G$100000, \"신규\")", kpi_fmt, len(df_new), # ← 캐시값을 반드시 같이 준다 ) # 정의된 이름을 쓰면 훨씬 읽기 쉽다 worksheet.write_formula("C6", "=COUNTIFS(현황_상태, \"변경\")", kpi_fmt, len(df_chg)) # SUMIFS: 특정 업체의 품목 수 합 worksheet.write_formula( "C7", "=SUMIFS('05_추이'!$C$4:$C$400, '05_추이'!$A$4:$A$400, \">=\"&$B$2)", kpi_fmt, int(df_trend.loc[df_trend['일자'] >= start, '건수'].sum()), ) # INDEX-MATCH (전 버전 호환) worksheet.write_formula( "E5", "=INDEX('04_전체현황'!$C$4:$C$100000, MATCH($D$5, '04_전체현황'!$A$4:$A$100000, 0))", body_fmt, lookup_value, ) ``` **XLOOKUP 은 미래 함수**라 `_xlfn.` 접두어가 필요하다. xlsxwriter 는 두 가지 길을 준다. ```python # 방법 1: 워크북 옵션으로 자동 접두 (권장) workbook = xlsxwriter.Workbook('report.xlsx', {'use_future_functions': True}) worksheet.write_formula("E6", '=XLOOKUP($D$6, 현황_등록번호, 현황_품목명)', body_fmt, cached) # 방법 2: 직접 접두 worksheet.write_formula("E6", '=_xlfn.XLOOKUP($D$6, 현황_등록번호, 현황_품목명)', body_fmt, cached) ``` openpyxl 은 자동 처리가 없다. 직접 붙인다. ```python ws["A1"] = "=_xlfn.NEWFUNCTION()" ``` openpyxl 이 아는 함수 이름 집합을 조회할 수 있다. ```python from openpyxl.utils import FORMULAE "HEX2DEC" in FORMULAE # True ``` 수식 작성 공통 규칙 (openpyxl·xlsxwriter 동일): - **영어 함수명만** 쓴다. `SOMME` 같은 로케일 함수명 금지. - **인수 구분자는 반드시 쉼표.** `=SUM(1, 2, 3)` ✅ / `=SUM(1; 2; 3)` ❌ - openpyxl 은 *"openpyxl **never** evaluates formula"* — 절대 계산하지 않는다. 미래 함수 목록 예시(모두 `_xlfn.` 필요): `STDEV.S`, `CONFIDENCE.NORM`, `TEXTJOIN`, `FILTER`, `UNIQUE`, `XLOOKUP`, `SORT`, `SEQUENCE`, `LAMBDA`. ### 5.6 동적 배열(FILTER/UNIQUE)과 `_xlfn` 접두어·ArrayFormula #### openpyxl 쪽 진실 openpyxl 은 배열 수식 2종(Array Formulae, Data Table Formulae)을 지원하지만 *"Support for these kinds of formulae is limited to preserving them in Excel files"* — **보존이 목적이지 생성이 목적이 아니다.** 레거시 CSE 배열 수식 만들기: ```python from openpyxl import Workbook from openpyxl.worksheet.formula import ArrayFormula wb = Workbook() ws = wb.active ws["E2"] = ArrayFormula("E2:E11", "=SUM(C2:C11*D2:D11)") ``` - 첫 인자는 **적용 범위**, 두 번째가 수식. - 범위의 **좌상단 셀이 대입 대상 셀과 반드시 같아야** 한다(`ws["E2"]` ↔ `"E2:E11"`). - Excel UI 는 중괄호 `{}` 를 보여주지만 **코드에는 절대 중괄호를 넣지 않는다.** 조회: ```python ws.array_formulae # 배열 수식이 든 셀 → 적용 범위 dict ws.table_formulae # 데이터 테이블 정의 ``` #### 동적 배열(spill)이 깨지는 이유 — 반드시 알아야 할 함정 openpyxl-users 메일링리스트에서 메인테이너 Harald 가 정리한 내용: > "If a dynamic array is present in a workbook that is loaded and saved it will be converted to a legacy array formula." 즉 **동적 배열이 있는 파일을 openpyxl 로 열었다 저장만 해도 legacy 로 강등**된다. 결과 수식은 `@` 암시적 교차 접두가 붙거나 `{}` CSE 배열로 감싸지고, 의도대로 동작하지 않는다. 제대로 지원하려면 다음 5가지가 전부 필요하다(Harald 정리): 1. `_rels/workbook.xml.rels` 에 metadata 스펙 참조 관계 추가 2. `metadata.xml` 파일 추가 (futureMetadata `XLDAPR` 속성 포함) 3. `workbook.xml` 네임스페이스 추가 4. 해당 시트 XML 네임스페이스 추가 5. **동적 배열 함수가 든 셀마다 `cm` 속성을 `"1"` 로 설정** Charlie Clark 코멘트: openpyxl 3.2 에 메타데이터 지원이 들어갔지만 **동적 배열에 필요한 확장은 아직 없다.** #### xlsxwriter 쪽 xlsxwriter 는 전용 메서드를 제공한다. ```python worksheet.write_dynamic_array_formula("B1:B3", "=LEN(A1:A3)") ``` 문서 예제에 나오는 함수들: | 함수 | 예제 수식 | |---|---| | FILTER | `"=FILTER(A1:D17,C1:C17=K2)"` | | UNIQUE | `"=UNIQUE(B2:B17)"` | | SORT | `"=SORT(B2:B17)"`, `"=SORT(FILTER(C2:D17,D2:D17>5000,\"\"),2,1)"` | | SORTBY | `"=SORTBY(A2:B9,B2:B9)"` | | XLOOKUP | `"=XLOOKUP(E1,A2:A9,C2:C9)"` | | XMATCH | `"=XMATCH(C2,A2:A6)"` | | SEQUENCE | `"=SEQUENCE(4,5)"` | | RANDARRAY | `"=RANDARRAY(5,3,1,100, TRUE)"` | | ANCHORARRAY | `"=ANCHORARRAY(F2)"` (spill 범위 참조) | #### 이 프로젝트의 결정 > **동적 배열을 리포트에 넣지 않는다.** > 이유: (1) 결과값 캐시가 없어 Excel 로 열기 전까지 0/None 으로 보인다 — 리포트를 Teams/메일 미리보기로 볼 사용자에게 치명적, (2) openpyxl 로 후처리(재계산·검사)하는 순간 legacy 로 강등된다, (3) 필터링은 파이썬 pandas 가 훨씬 잘한다. > 대안: 필터 결과를 **파이썬에서 만들어 별도 시트에 값으로 쓰고**, 사용자 인터랙션은 Excel 표의 자동필터와 드롭다운으로 제공한다. ### 5.7 Excel 표(ListObject)와 구조적 참조 #### xlsxwriter `add_table()` — 채택 ```python worksheet.add_table('B3:F7', {options}) ``` | 파라미터 | 설명 | |---|---| | `data` | 행 데이터(list of lists) | | `autofilter` | 헤더 필터 드롭다운 (기본 True) | | `header_row` | 헤더행 표시 (기본 True) | | `banded_rows` | 행 줄무늬 (기본 True) | | `banded_columns` | 열 줄무늬 (기본 False) | | `first_column` | 첫 열 강조 (기본 False) | | `last_column` | 마지막 열 강조 (기본 False) | | `style` | 표 스타일 이름. 기본 `'Table Style Medium 9'` | | `total_row` | 합계행 (기본 False) | | `name` | 표 이름 (기본 Table1, Table2, …) | | `columns` | 열 설정 dict 리스트 | `columns` 하위 속성: `header`, `header_format`, `formula`, `total_string`, `total_function`, `total_value`, `format`. 합계행 + 구조적 참조 예제(문서 원문): ```python options = {'data': data, 'total_row': 1, 'columns': [{'header': 'Product', 'total_string': 'Totals'}, {'header': 'Quarter 1', 'total_function': 'sum'}, {'header': 'Quarter 2', 'total_function': 'sum'}, {'header': 'Quarter 3', 'total_function': 'sum'}, {'header': 'Quarter 4', 'total_function': 'sum'}, {'header': 'Year', 'formula': '=SUM(Table10[@[Quarter 1]:[Quarter 4]])', 'total_function': 'sum'}]} worksheet.add_table('B3:G8', options) ``` 구조적 참조 제약 (문서 명시): - **Excel 2007 스타일 `[#This Row]` 와 Excel 2010 스타일 `@` 만 지원**한다. - 그 외 Excel 2010 이후에 추가된 구조적 참조 확장은 지원하지 않는다. 수식은 Excel 2007 문법을 따라야 한다. - `total_row` 를 켜도 **캡션과 함수는 자동으로 안 채워진다.** 반드시 `columns` 로 `total_string`/`total_function` 을 지정해야 한다. - `total_function` 은 SUBTOTAL 계열: `sum`, `average`, `count`, `count_nums`, `max`, `min`, `std_dev`, `var`. #### openpyxl `Table` ```python from openpyxl import Workbook from openpyxl.worksheet.table import Table, TableStyleInfo wb = Workbook() ws = wb.active data = [ ['Apples', 10000, 5000, 8000, 6000], ['Pears', 2000, 3000, 4000, 5000], ['Bananas', 6000, 6000, 6500, 6000], ['Oranges', 500, 300, 200, 700], ] ws.append(["Fruit", "2011", "2012", "2013", "2014"]) for row in data: ws.append(row) tab = Table(displayName="Table1", ref="A1:E5") style = TableStyleInfo(name="TableStyleMedium9", showFirstColumn=False, showLastColumn=False, showRowStripes=True, showColumnStripes=True) tab.tableStyleInfo = style ws.add_table(tab) wb.save("table.xlsx") ``` 규칙: - **표 이름은 통합문서 내 유일**해야 한다. `ws.add_table()` 이 유일성을 검사한다. - *"column headings must always contain strings"* — 헤더는 반드시 문자열. 숫자 연도를 헤더로 쓰려면 `"2011"` 처럼 문자열로. - *"Filters will be added automatically to tables that contain header rows. It is **not** possible to create tables with header rows without filters."* — 헤더행 있는 표는 필터가 강제된다. - write_only 모드에서는 열 헤딩을 수동으로 초기화해야 하고, 값이 실제 셀과 일치해야 파일이 유효하다. ### 5.8 다른 시트 범위를 원본으로 하는 데이터 유효성 드롭다운 #### openpyxl 인라인 목록: ```python from openpyxl.worksheet.datavalidation import DataValidation dv = DataValidation(type="list", formula1='"Dog,Cat,Bat"', allow_blank=True) dv.error = 'Your entry is not in the list' dv.errorTitle = 'Invalid Entry' dv.prompt = 'Please select from the list' dv.promptTitle = 'List Selection' ``` 워크시트에 부착: ```python ws.add_data_validation(dv) dv.add(c1) # 개별 셀 dv.add('B1:B1048576') # 범위 ``` **다른 시트 범위 참조** (핵심): ```python from openpyxl.utils import quote_sheetname dv = DataValidation(type="list", formula1="{0}!$B$1:$B$10".format(quote_sheetname(sheetname))) ``` 중요 주의사항: - *"Excel and LibreOffice interpret the parameter showDropDown=True as the dropdown arrow should be hidden."* — **`showDropDown=True` 는 화살표를 "숨긴다"**. 직관과 반대다. 드롭다운 화살표를 보이게 하려면 건드리지 말거나 `False` 로 둔다. - `"B4" in dv` 로 셀 포함 여부를 검사할 수 있다. - **셀 범위가 없는 유효성 규칙은 저장 시 사라진다.** `dv.add(...)` 를 반드시 호출한다. #### xlsxwriter ```python worksheet.data_validation('B2', { 'validate': 'list', 'source': '=Sheet2!$A$1:$A$10' }) ``` 시그니처: `data_validation(first_row, first_col, last_row, last_col, options)` (A1 표기 문자열도 허용). DMF 리포트 적용 예: `99_메타` 시트에 상태값 목록(`신규/변경/취하/유지`)을 두고, 사용자가 메모 열에 상태를 손으로 기입할 때 드롭다운을 제공한다. --- ## 6. 수식 값이 파이썬에서 계산되지 않는 문제 ### 6.1 증상과 원인 | 라이브러리 | 파일에 쓰이는 것 | 파이썬/뷰어가 읽는 값 | |---|---|---| | openpyxl | 수식 문자열만. **캐시값 없음** | `load_workbook(data_only=True)` → `None`, pandas → `NaN` | | xlsxwriter | 수식 + **결과 자리에 `0`** + "열 때 재계산" 플래그 | Excel/LibreOffice 로 열면 정상. 그 외에는 `0` | 원문 근거: - openpyxl: *"openpyxl writes formulas as strings with no cached values. Until you recalculate, every formula cell reads back as `None` to anything reading cached values — pandas, `load_workbook(data_only=True)`, and most previewers."* - openpyxl: *"data_only controls whether cells with formulae have either the formula (default) or the value stored the last time Excel read the sheet."* → **마지막으로 Excel 이 읽었을 때** 저장된 값이라, 파이썬이 만든 파일에는 애초에 그 값이 없다. - xlsxwriter: 수식 결과를 계산하지 않고 `0` 을 저장한 뒤 Excel 에게 재계산하라고 플래그를 세운다. 계산 기능이 없는 앱(Excel Viewer, PDF 변환기, 모바일 앱)은 **0 만 보여준다**. ### 6.2 해결책 3종 #### 해결책 A — 값을 파이썬에서 미리 계산한다 (**이 프로젝트 1순위**) 리포트에 들어가는 숫자는 pandas 로 계산해 **값으로** 쓴다. 수식은 최소화한다. #### 해결책 B — xlsxwriter `write_formula` 의 `value` 인자로 캐시값을 박는다 (**2순위**) ```python worksheet.write_formula('A1', '=2+2', num_format, 4) ``` - 4번째 위치 인자가 미리 계산된 결과다. - 숫자·문자열·불리언은 물론 **Excel 오류 코드 문자열(`#DIV/0!`, `#N/A`, `#NAME?` …)** 도 넣을 수 있다. - 이렇게 하면 "수식은 살아 있고(사용자가 필터를 바꾸면 재계산), 미리보기에서도 올바른 값이 보이는" 두 마리 토끼를 잡는다. - **openpyxl 에는 이 기능이 없다.** openpyxl 로 쓰면 캐시값을 넣을 방법이 없다. #### 해결책 C — LibreOffice headless 재계산 (**보험**) 두 가지 방식이 실무에서 쓰인다. **C-1. Basic 매크로 방식** (`ComposioHQ/awesome-claude-skills` 의 `recalc.py`): ```python cmd = [ 'soffice', '--headless', '--norestore', 'vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application', abs_path ] ``` 매크로 본체는 `ThisComponent.calculateAll()` 후 `ThisComponent.store()` 를 호출해 **파일을 제자리에서 갱신**한다. 매크로 설치 디렉터리(플랫폼별): - macOS: `~/Library/Application Support/LibreOffice/4/user/basic/Standard` - Linux: `~/.config/libreoffice/4/user/basic/Standard` - ⚠️ 해당 스크립트는 Windows 경로를 하드코딩하지 않고 `soffice` 가 PATH 에 있다고 가정한다. 우리 환경은 `C:\Program Files\LibreOffice\program\soffice.exe` 이므로 **절대경로를 직접 넘겨야 한다**(실측으로 존재 확인됨). 타임아웃: ```python timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 ``` macOS 는 `gtimeout`, Linux 는 `timeout` 을 쓰고 **Windows 에는 타임아웃 래퍼가 없다** → 파이썬 `subprocess.run(..., timeout=...)` 로 직접 걸어야 한다. **C-2. `--convert-to` 방식**: ```python import subprocess soffice_path = r"C:\Program Files\LibreOffice\program\soffice.exe" input_file = r"C:\path\to\input.ods" output_dir = r"C:\path\to\output" subprocess.run([ soffice_path, "--headless", "--convert-to", "xlsx", input_file, "--outdir", output_dir ]) ``` - `--outdir` 를 안 주면 **현재 작업 디렉터리**에 떨어진다. 반드시 지정한다. - 손상된 입력은 실패가 아니라 **행(hang)** 할 수 있으니 타임아웃으로 감싼다. - 종료 코드만 믿지 말고 **출력 파일 존재 + 크기 0 아님**을 검증한다. #### 재계산 후 오류 스캔 ```python excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A'] ``` `load_workbook(filename, data_only=True)` 로 열어 모든 셀 문자열에서 위 토큰을 찾는다(참조 구현은 오류 타입당 위치 20개까지 기록). `anthropics/skills` 의 `xlsx` SKILL 이 정리한 운영 규칙: ```bash python scripts/recalc.py output.xlsx [timeout_seconds] # default 30 ``` - 반환 JSON: `status`(`success` | `errors_found`), `total_formulas`, `total_errors`, `error_summary`(오류 타입당 최대 100셀, `locations_truncated` 로 생략 수 표기). - **`status` 대신 `error` 키가 오면 아무것도 재계산되지 않은 것이며, 오직 이 경우만 non-zero exit.** `errors_found` 는 exit 0 이다 → **깨끗한 종료 코드를 깨끗한 워크북으로 오해하면 안 된다.** - *"A green recalc proves your formulas evaluate, not that they are right."* 범위가 한 칸 어긋나도 오류 없이 틀린 숫자가 나온다. **수식 2~3개를 먼저 쓰고 기대값이 나오는지 확인한 뒤 그리드를 확장하라.** - *"Recalculate is mandatory whenever the file contains formulas."* #### 재계산의 한계 — 반드시 알아야 할 3가지 1. **LibreOffice 는 Excel 보다 함수가 적다.** 평가하지 못한 함수는 결과 파일에 `#NAME?` 로 **박혀서 배포된다.** 2. **외부 링크가 파괴된다.** openpyxl 로 재저장하면 외부 참조 링크가 사라진다. `='[1]Returns Analysis'!$B$2` 의 `[1]` 은 외부 참조 목록 인덱스인데, 그 파일이 없으면 LibreOffice 가 해석에 실패해 `#NAME?` 을 쓰고 링크를 전부 삭제한다. 참조 구현의 `recalc.py` 는 이 상태에서 실행을 **거부**하며 `--force` 로만 강행할 수 있다. 3. **동적 배열은 재계산 과정에서 legacy 로 강등된다**(5.6 참조). #### 대안 (⚠️ 미검증) - `formualizer` — LibreOffice/Excel 없이 수식을 평가한다는 Rust 엔진. CI/컨테이너에서 동작한다고 소개됨. 실사용 검증 필요. ### 6.3 이 프로젝트의 재계산 정책 > **정책: 리포트에 수식을 "쓰긴 쓰되 반드시 `value=` 캐시값을 동봉"하고, LibreOffice 재계산은 CI/검증 단계에서만 선택적으로 돌린다.** > 매일 06:00 파이프라인의 크리티컬 패스에 LibreOffice(수 초~수십 초, 행 가능성)를 넣지 않는다. 대신 `--verify` 플래그를 준 수동 실행 때만 재계산+오류 스캔을 수행한다. --- ## 7. 포맷팅 레시피 ### 7.1 색 팔레트 (접근성) **Okabe-Ito 8색** — Masataka Okabe · Kei Ito 의 Color Universal Design 가이드. 모든 흔한 색각 이상(CVD)에서 구별 가능한, 과학 도표용 표준 팔레트다. | 이름 | HEX | 용도(본 프로젝트) | |---|---|---| | Black | `#000000` | 본문 텍스트 | | Orange | `#E69F00` | 변경 | | Sky Blue | `#56B4E9` | 전체현황 | | Bluish Green | `#009E73` | 신규 | | Yellow | `#F0E442` | 강조 배경(연한 버전 사용) | | Blue | `#0072B2` | 헤더/목차/링크 | | Vermillion | `#D55E00` | 취하 | | Reddish Purple | `#CC79A7` | 추이 | 비교용 **Tableau 10**: `#1F77B4 #FF7F0E #2CA02C #D62728 #9467BD #8C564B #E377C2 #7F7F7F #BCBD22 #17BECF`. → Tableau 10 은 **색각 안전이 아니다.** 인접한 빨강·초록이 2형 색각(deuteranope)에게 충돌한다. **Okabe-Ito 를 채택한다.** 파생 톤(연한 배경용, Okabe-Ito 를 흰색과 혼합): ```python PALETTE = { "black": "#000000", "orange": "#E69F00", "sky": "#56B4E9", "green": "#009E73", "yellow": "#F0E442", "blue": "#0072B2", "verm": "#D55E00", "purple": "#CC79A7", # 배경용 연한 톤 "bg_new": "#D9F0E7", # green 20% "bg_chg": "#FBEBD1", # orange 20% "bg_del": "#F7DCD0", # vermillion 20% "bg_header": "#0072B2", "bg_zebra": "#F5F7FA", "grid": "#D6DCE4", } ``` ### 7.2 폰트 - **`Malgun Gothic`(맑은 고딕)** 을 표준으로 한다. Windows Vista 이상 기본 탑재, ClearType 기반이라 화면 가독성이 좋다. - 폰트명에 공백이 있으므로 반드시 따옴표로 감싼 문자열로 전달한다. - **Excel 은 그 PC 에 설치된 폰트만 렌더링한다.** 배포처가 Windows 로 한정된다는 전제에서만 안전하다. ```python import xlsxwriter workbook = xlsxwriter.Workbook('report.xlsx') worksheet = workbook.add_worksheet() # 맑은 고딕 포맷 fmt = workbook.add_format({'font_name': 'Malgun Gothic'}) worksheet.write('A1', '원료의약품 등록 현황', fmt) ``` openpyxl: ```python from openpyxl.styles import Font font = Font(name='Malgun Gothic', size=10, bold=False, color='FF000000') ``` ### 7.3 헤더 스타일 xlsxwriter: ```python header_fmt = workbook.add_format({ 'font_name': 'Malgun Gothic', 'font_size': 10, 'bold': True, 'font_color': '#FFFFFF', 'bg_color': '#0072B2', 'align': 'center', 'valign': 'vcenter', 'text_wrap': True, 'border': 1, 'border_color': '#0A5A8C', }) ``` openpyxl: ```python from openpyxl.styles import Font, PatternFill, Alignment, Border, Side thin = Side(border_style='thin', color='0A5A8C') for cell in ws[3]: # 3행이 헤더 cell.font = Font(name='Malgun Gothic', size=10, bold=True, color='FFFFFFFF') cell.fill = PatternFill(fill_type='solid', fgColor='0072B2') cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) cell.border = Border(top=thin, left=thin, right=thin, bottom=thin) ``` > openpyxl 주의: *"Cell styles are shared between objects and once they have been assigned they cannot be changed."* 스타일 객체는 공유되며 한 번 할당하면 변경할 수 없다. 바꾸려면 `copy()` 로 복제한다. `NamedStyle` 로 재사용: ```python from openpyxl.styles import NamedStyle highlight = NamedStyle(name='highlight') highlight.font = Font(bold=True, size=20) wb.add_named_style(highlight) ws['A1'].style = highlight ws['D5'].style = 'highlight' # 등록 후에는 이름으로 참조 ``` 행/열 단위 스타일: ```python col = ws.column_dimensions['A'] col.font = Font(bold=True) row = ws.row_dimensions[1] row.font = Font(underline='single') ``` ### 7.4 틀 고정(freeze panes) | 엔진 | API | 의미 | |---|---|---| | xlsxwriter | `worksheet.freeze_panes(3, 0)` | 0-index. 1~3행 고정, 열 고정 없음 | | xlsxwriter | `freeze_panes(row, col[, top_row[, left_col]])` | `top_row`/`left_col` 로 최초 표시 셀 지정 | | openpyxl | `ws.freeze_panes = 'A4'` | 그 셀의 **위·왼쪽**이 고정. `'B2'` 는 1행+A열 고정 | | openpyxl | `ws.freeze_panes = None` 또는 `'A1'` | 고정 해제 | DMF 리포트 표준: 헤더가 3행이므로 **xlsxwriter `freeze_panes(3, 1)`** (1~3행 + A열 고정 → 등록번호 열이 항상 보임). ### 7.5 자동 필터 xlsxwriter: ```python worksheet.autofilter(2, 0, last_row, last_col) # (first_row, first_col, last_row, last_col) ``` Excel 표(`add_table`)를 쓰면 `autofilter` 가 기본 True 라 별도로 부를 필요가 없다. openpyxl: ```python from openpyxl.worksheet.filters import FilterColumn, Filters filters = ws.auto_filter filters.ref = "A1:B15" col = FilterColumn(colId=0) col.filters = Filters(filter=["Kiwi", "Apple", "Mango"]) filters.filterColumn.append(col) ws.auto_filter.add_sort_condition("B2:B15") ``` 간단히는 `ws.auto_filter.ref = "A1:E10"` 한 줄이면 된다. > **핵심 주의**: *"This will add the relevant instructions to the file but will **neither actually filter nor sort**."* openpyxl 은 필터 "설정"만 저장하고 실제로 행을 숨기거나 정렬하지 않는다. 실제 적용은 Excel 이 파일을 열 때 한다. 한국어 블로그(minyeamer)는 실제 행 숨기기를 **ZIP/XML 직접 조작**으로 구현했다고 기록하고 있다. ### 7.6 열 너비 자동 맞춤 — 한글 2배폭 계산 #### 왜 직접 만들어야 하나 - **openpyxl 에는 auto-fit 이 아예 없다.** 개발자들이 의도적으로 넣지 않았다. 이유: Excel 파일 포맷 자체가 auto-fit 정보를 저장하지 않고, 폰트 메트릭·렌더링에 의존해 시스템마다 달라지며, 텍스트 폭을 정확히 계산하기 어렵다. - **xlsxwriter `autofit()` 은 있지만 Calibri 11 메트릭 추정**이며 *"doesn't take formatting of numbers or dates account"* — 숫자·날짜 서식을 고려하지 않는다. 한글에서 좁게 나온다. ```python worksheet.autofit() # 기본 최대 폭 1790 픽셀 worksheet.autofit(300) # 가독성을 위해 권장되는 상한 ``` 성능 주의: 대용량 시트에서는 100~200행쯤 쓴 뒤 호출해, 보이지 않는 데이터까지 계산하는 비용을 피하라는 권고가 있다. #### `unicodedata.east_asian_width()` 폭 매핑 | 분류 | 의미 | 폭 | |---|---|---| | `F` | Fullwidth | 2 | | `H` | Halfwidth | 1 | | `W` | Wide (한글·한자·가나) | 2 | | `Na` | Narrow | 1 | | `A` | Ambiguous | 2 | | `N` | Neutral | 1 | Qiita(Nomisugi) 의 openpyxl 용 구현 원문 — 폰트 크기 보정까지 포함: ```python from unicodedata import east_asian_width width_dict = { 'F': 2, # Fullwidth 'H': 1, # Halfwidth 'W': 2, # Wide 'Na': 1, # Narrow 'A': 2, # Ambiguous 'N': 1 # Neutral } Font_depend = 1.2 def sheet_adjusted_width(ws): for col in ws.columns: max_length = 1 max_diameter = 1 column = col[1].column_letter for cell in col: diameter = (cell.font.size * Font_depend) / 10 if diameter > max_diameter: max_diameter = diameter if cell.value: chars = [char for char in str(cell.value)] east_asian_width_list = [east_asian_width(char) for char in chars] width_list = [width_dict[w] for w in east_asian_width_list] if sum(width_list) > max_length: max_length = sum(width_list) ws.column_dimensions[column].width = max_length * max_diameter + 1.2 ``` 한국어 블로그(minyeamer) 의 경험적 계수: **한글 1.8배, 공백 1.2배, 영문/숫자 1배**. `east_asian_width` 의 2.0배보다 살짝 좁게 잡아 실제 맑은 고딕 렌더링에 근접시킨 값이다. wikidocs 계열 자료의 원칙: 열 너비 = 그 열에서 가장 긴 셀의 폭 + margin, **margin 은 1 이상**이어야 빽빽하지 않다. 기본 API: ```python ws.column_dimensions['A'].width = 50 ws.row_dimensions[1].height = 50 ``` #### 이 프로젝트용 xlsxwriter 유틸 (완결 코드) ```python import unicodedata _EAW_STRICT = {'F': 2.0, 'H': 1.0, 'W': 2.0, 'Na': 1.0, 'A': 2.0, 'N': 1.0} _EAW_KO = {'F': 1.8, 'H': 1.0, 'W': 1.8, 'Na': 1.0, 'A': 1.2, 'N': 1.0} def display_width(text, table=_EAW_KO): """맑은 고딕 기준 셀 표시 폭(문자 단위)을 근사한다.""" if text is None: return 0.0 return sum(table.get(unicodedata.east_asian_width(ch), 1.0) for ch in str(text)) def compute_col_widths(header, rows, min_w=8.0, max_w=48.0, margin=2.0, sample=2000): """헤더 + 데이터 표본으로 열별 폭 리스트를 만든다. header: list[str] rows: list[list] (전체를 다 볼 필요 없으므로 sample 행만 검사) """ widths = [display_width(h) for h in header] for r in rows[:sample]: for i, v in enumerate(r): if i >= len(widths): widths.append(0.0) w = display_width(v) if w > widths[i]: widths[i] = w return [max(min_w, min(max_w, w + margin)) for w in widths] def apply_col_widths(worksheet, widths, formats=None): """xlsxwriter 워크시트에 열 너비(+열 기본 서식)를 적용한다.""" for i, w in enumerate(widths): fmt = formats[i] if formats else None worksheet.set_column(i, i, w, fmt) ``` ### 7.7 행 높이 ```python # xlsxwriter worksheet.set_row(0, 30) # 1행 높이 30pt worksheet.set_row(2, 34, header_fmt) # 헤더행 높이 + 서식 # openpyxl ws.row_dimensions[1].height = 30 ``` 권장값: 제목행 28~34, 헤더행 32(줄바꿈 2줄 수용), 본문 18. ### 7.8 숫자·날짜 표시 형식 xlsxwriter — 열 단위로 거는 것이 가장 싸다: ```python int_fmt = workbook.add_format({'num_format': '#,##0', 'font_name': 'Malgun Gothic'}) pct_fmt = workbook.add_format({'num_format': '0.0%', 'font_name': 'Malgun Gothic'}) money_fmt = workbook.add_format({'num_format': '#,##0.00', 'font_name': 'Malgun Gothic'}) date_fmt = workbook.add_format({'num_format': 'yyyy-mm-dd', 'font_name': 'Malgun Gothic'}) dt_fmt = workbook.add_format({'num_format': 'yyyy-mm-dd hh:mm', 'font_name': 'Malgun Gothic'}) worksheet.set_column(1, 1, 18, money_fmt) worksheet.set_column(2, 2, None, pct_fmt) # 폭은 그대로 두고 서식만 ``` openpyxl: ```python ws['A2'] = 0.123456 ws['A2'].number_format = '0.00' # 한국어 블로그 예: cell.number_format = "#,##0" ``` pandas 로 엔진에 날짜 기본 서식 위임: ```python writer = pd.ExcelWriter("pandas_datetime.xlsx", engine='xlsxwriter', datetime_format='mmm d yyyy hh:mm:ss', date_format='mmmm dd yyyy') ``` `pandas.ExcelWriter` 파라미터 정의: - **date_format** : str, default None — 엑셀에 쓰이는 날짜 서식 문자열 (예: `'YYYY-MM-DD'`) - **datetime_format** : str, default None — datetime 객체 서식 (예: `'YYYY-MM-DD HH:MM:SS'`) xlsxwriter 워크북 옵션 `default_date_format` 으로 datetime 기본 서식을 정할 수 있고, `remove_timezone` 으로 tz-aware datetime 의 시간대를 제거할 수 있다(Excel 에는 시간대 개념이 없다 → DMF 수집 시각은 KST naive 로 정규화해 쓴다). ### 7.9 테두리 최소화와 지브라 행 원칙: **격자선은 끄고**, 헤더 아래 굵은 선 하나 + 행 줄무늬로 읽기 흐름을 만든다. 셀마다 사방 테두리를 그리면 오히려 읽기 어렵다. ```python worksheet.hide_gridlines(2) # 0=표시, 1=화면만 숨김, 2=화면+인쇄 모두 숨김 ``` 지브라(줄무늬)는 세 가지 방법이 있다. 1. **Excel 표의 `banded_rows`(기본 True)** — 가장 싸고 필터/정렬 후에도 자동 유지된다. **1순위 채택.** 2. **조건부 서식 수식** `=MOD(ROW(),2)=0` — 표를 안 쓰는 시트에서. 3. **행마다 다른 포맷 객체로 직접 쓰기** — 필터링하면 줄무늬가 어긋난다. 비추천. 조건부 서식 방식(xlsxwriter): ```python zebra = workbook.add_format({'bg_color': '#F5F7FA'}) worksheet.conditional_format(3, 0, last_row, last_col, { 'type': 'formula', 'criteria': '=MOD(ROW(),2)=0', 'format': zebra, }) ``` openpyxl: ```python from openpyxl.formatting.rule import Rule from openpyxl.styles.differential import DifferentialStyle from openpyxl.styles import PatternFill dxf = DifferentialStyle(fill=PatternFill(bgColor="F5F7FA")) rule = Rule(type="expression", dxf=dxf) rule.formula = ['MOD(ROW(),2)=0'] ws.conditional_formatting.add("A4:N100000", rule) ``` 테두리 API: ```python # xlsxwriter — 굵은 아래선만 fmt = workbook.add_format({'bottom': 2, 'bottom_color': '#0072B2'}) # 스타일 번호: 1=thin, 2=medium, 3=dashed, 4=dotted, 5=thick ... # openpyxl from openpyxl.styles import Border, Side thin = Side(border_style='thin', color='000000') border = Border(top=thin, left=thin, right=thin, bottom=thin) ``` ### 7.10 정렬과 줄바꿈 ```python # xlsxwriter fmt = workbook.add_format({ 'align': 'left', # left | center | right | fill | justify | center_across 'valign': 'vcenter', # top | vcenter | bottom | vjustify 'text_wrap': True, 'indent': 1, }) # openpyxl from openpyxl.styles import Alignment alignment = Alignment(horizontal='center', vertical='center', wrap_text=False) ``` xlsxwriter `Format` 메서드 목록 (dict 방식이 공식 권장): | 메서드 | 역할 | |---|---| | `set_font_name(fontname)` | 폰트 | | `set_font_size(size)` | 크기(pt) | | `set_font_color(color)` | 글자색 | | `set_bold()` / `set_italic()` | 굵게 / 기울임 | | `set_underline(style)` | 밑줄 | | `set_bg_color(color)` | 배경색 | | `set_fg_color(color)` | 패턴 전경색 | | `set_pattern(index)` | 패턴 (0~18) | | `set_border(style)` | 사방 테두리 | | `set_align(alignment)` | 정렬 | | `set_text_wrap()` | 줄바꿈 | | `set_num_format(format_string)` | 표시 형식 | 생성: ```python cell_format1 = workbook.add_format() # 나중에 속성 설정 cell_format2 = workbook.add_format(props) # 생성 시 설정 cell_format = workbook.add_format({'bold': True, 'font_color': 'red'}) ``` 문서 원문: *"the key/value interface is more flexible and clearer than the object method and is the recommended method for setting format properties"* — dict 방식이 권장된다. > **치명적 주의**: *"a Format is applied to a cell not in its current state but in its final state."* 포맷 객체는 **최종 상태로** 적용된다. 이미 여러 셀에 쓴 뒤 그 포맷 객체의 속성을 바꾸면 **이전에 쓴 모든 셀이 같이 바뀐다.** 포맷은 만들고 나면 수정하지 말고, 변형이 필요하면 새 포맷을 만든다. ### 7.11 인쇄 설정 xlsxwriter: ```python worksheet.set_landscape() # 가로 worksheet.set_paper(9) # 9 = A4 worksheet.fit_to_pages(1, 0) # 너비 1페이지, 높이 무제한 worksheet.repeat_rows(0, 2) # 1~3행을 매 페이지 반복 worksheet.print_area(0, 0, last_row, last_col) ``` 시그니처 정리: | 메서드 | 시그니처 | |---|---| | 가로 방향 | `set_landscape()` | | 페이지 맞춤 | `fit_to_pages(width, height)` | | 반복 행 | `repeat_rows(first_row[, last_row])` | | 인쇄 영역 | `print_area(first_row, first_col, last_row, last_col)` | | 용지 | `set_paper(paper_type)` | | 눈금선 | `hide_gridlines([option])` | | 확대 | `set_zoom(scale)` — 10~400 | | 탭 색 | `set_tab_color(color)` | | 최초 활성 시트 | `activate()` | | 가장 왼쪽 탭 | `set_first_sheet()` | | 그룹 표시 설정 | `outline_settings(visible, symbols_below, symbols_right, auto_style)` | openpyxl: ```python ws.page_setup.orientation = ws.ORIENTATION_LANDSCAPE ws.page_setup.paperSize = ws.PAPERSIZE_A5 ws.print_options.horizontalCentered = True ws.print_options.verticalCentered = True ws.print_title_cols = 'A:B' ws.print_title_rows = '1:1' ws.print_area = 'A1:F10' ws.oddHeader.left.text = "Page &[Page] of &N" ws.oddHeader.left.size = 14 ws.oddHeader.left.font = "Tahoma,Bold" ws.oddHeader.left.color = "CC3366" ``` `evenHeader`/`evenFooter`, `firstHeader`/`firstFooter` 도 지원되지만 공식 문서에 코드 예제는 없다. **1페이지 너비 맞춤(openpyxl)** — `fitToWidth` 만 설정하면 안 되고 `fitToPage` 를 켜야 한다: ```python from openpyxl.worksheet.properties import PageSetupProperties ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True, autoPageBreaks=False) ws.page_setup.fitToWidth = 1 ws.page_setup.fitToHeight = 0 ``` `PrintPageSetup` 속성 전체: | 그룹 | 속성 | |---|---| | 크기/설정 | `orientation` ∈ {'portrait','default','landscape'}, `paperSize`(int), `scale`(int), `fitToWidth`(int), `fitToHeight`(int), `paperWidth`, `paperHeight` | | 인쇄 품질 | `horizontalDpi`, `verticalDpi`, `blackAndWhite`(bool), `draft`(bool) | | 페이지 번호 | `firstPageNumber`, `useFirstPageNumber`(bool), `copies` | | 내용 옵션 | `cellComments` ∈ {'atEnd','asDisplayed'}, `errors` ∈ {'dash','blank','displayed','NA'}, `pageOrder` ∈ {'downThenOver','overThenDown'}, `usePrinterDefaults`(bool) | | 프로퍼티 | `autoPageBreaks`, `fitToPage`, `sheet_properties` | `PageMargins` 기본값(인치): `left=0.75`, `right=0.75`, `top=1`, `bottom=1`, `header=0.5`, `footer=0.5`. `PrintOptions` 불리언: `horizontalCentered`, `verticalCentered`, `headings`, `gridLines`, `gridLinesSet`. ### 7.12 시트 탭 색상 ```python # xlsxwriter worksheet.set_tab_color('#0072B2') # openpyxl ws.sheet_properties.tabColor = "1072BA" ``` openpyxl 워크시트 프로퍼티 전체 예제: ```python from openpyxl.workbook import Workbook from openpyxl.worksheet.properties import WorksheetProperties, PageSetupProperties wb = Workbook() ws = wb.active wsprops = ws.sheet_properties wsprops.tabColor = "1072BA" wsprops.filterMode = False wsprops.pageSetUpPr = PageSetupProperties(fitToPage=True, autoPageBreaks=False) wsprops.outlinePr.summaryBelow = False wsprops.outlinePr.applyStyles = True wsprops.pageSetUpPr.autoPageBreaks = True ``` ### 7.13 시트 뷰 (확대 / 눈금선 / 최초 활성 시트) ```python # openpyxl ws.sheet_view.zoomScale = 85 # 85% 확대 ws.sheet_view.showFormulas = True ws.sheet_view.tabSelected = True # xlsxwriter worksheet.set_zoom(90) # 10~400 worksheet.hide_gridlines(2) worksheet.activate() # 파일 열 때 처음 보이는 시트 worksheet.set_first_sheet() # 탭이 많을 때 가장 왼쪽에 보이는 탭 ``` DMF 리포트: `00_목차` 에 `activate()` + `set_first_sheet()` 를 건다. ### 7.14 열·행 그룹화와 숨기기 xlsxwriter — `set_column`/`set_row` 의 `options` dict: ```python worksheet.set_column('B:G', None, None, {'level': 1}) worksheet.set_column('H:H', None, None, {'collapsed': 1}) worksheet.set_column('D:D', 20, format, {'hidden': 1}) worksheet.set_row(0, None, None, {'level': 1}) worksheet.set_row(3, None, None, {'collapsed': 1}) worksheet.set_row(1, None, None, {'hidden': 1, 'level': 1}) ``` 시그니처: ```python set_column(first_col, last_col, width, cell_format, options) set_row(row, height, cell_format, options) ``` openpyxl: ```python import openpyxl wb = openpyxl.Workbook() ws = wb.create_sheet() ws.column_dimensions.group('A','D', hidden=True) ws.row_dimensions.group(1,10, hidden=True) wb.save('group.xlsx') ``` DMF 적용: `04_전체현황` 의 부가 열(원문 URL, 해시, 수집 배치 ID)을 `{'level': 1, 'hidden': 1}` 로 접어 두고 필요할 때만 펼치게 한다. --- ## 8. 조건부 서식 ### 8.1 openpyxl API 임포트 경로: `openpyxl.formatting.rule` — `ColorScaleRule`, `DataBarRule`, `IconSetRule`, `CellIsRule`, `FormulaRule`, `Rule`. #### 색 스케일 (ColorScaleRule) ```python from openpyxl.formatting.rule import ColorScaleRule rule = ColorScaleRule( start_type='percentile', start_value=10, start_color='FFAA0000', mid_type='percentile', mid_value=50, mid_color='FF0000AA', end_type='percentile', end_value=90, end_color='FF00AA00' ) ws.conditional_formatting.add('A1:A10', rule) ``` 2색/3색 그라디언트 지원. `*_type` 허용값: `'num'`, `'percent'`, `'max'`, `'min'`, `'formula'`, `'percentile'`. #### 데이터 막대 (DataBarRule) ```python from openpyxl.formatting.rule import DataBarRule rule = DataBarRule( start_type='percentile', start_value=10, end_type='percentile', end_value='90', color="FF638EC6", showValue="None", minLength=None, maxLength=None ) ws.conditional_formatting.add('A1:A10', rule) ``` > 제약: openpyxl 의 DataBar 는 **원본 스펙(Excel 2007)** 기반이다. 테두리·방향 등은 이후 확장이 필요하다. 솔리드 채우기(`gradient=False`)나 x14 확장 네임스페이스는 고수준 API 에 노출되지 않는다. ⚠️ 미검증 — 검색으로 x14 확장/solid fill/gradient 파라미터 문서를 찾지 못했다. 필요하면 openpyxl 소스 또는 OOXML 스펙을 직접 확인해야 한다. #### 아이콘 세트 (IconSetRule) ```python from openpyxl.formatting.rule import IconSetRule rule = IconSetRule('5Arrows', 'percent', [10, 20, 30, 40, 50], showValue=None, percent=None, reverse=None) ws.conditional_formatting.add('A1:A10', rule) ``` 아이콘 세트 예: `'3Arrows'`, `'3TrafficLights1'`, `'4Arrows'`, `'5Rating'` 등. #### 셀 값 비교 (CellIsRule) ```python from openpyxl.formatting.rule import CellIsRule from openpyxl.styles import PatternFill redFill = PatternFill(start_color='EE1111', end_color='EE1111', fill_type='solid') # 미만 비교 rule = CellIsRule(operator='lessThan', formula=['C$1'], stopIfTrue=True, fill=redFill) ws.conditional_formatting.add('C2:C10', rule) # 사이 값 비교 rule = CellIsRule(operator='between', formula=['1','5'], stopIfTrue=True, fill=redFill) ws.conditional_formatting.add('D2:D10', rule) ``` 한국어 블로그(minyeamer) 예: ```python from openpyxl.formatting.rule import CellIsRule rule = CellIsRule(operator="greaterThanOrEqual", formula=["1000"], fill=PatternFill(start_color="FF0000")) ws.conditional_formatting.add("C3:C10", rule) ``` #### 수식 규칙 (FormulaRule) ```python from openpyxl.formatting.rule import FormulaRule from openpyxl.styles import Font, Border, PatternFill rule = FormulaRule(formula=['ISBLANK(E1)'], stopIfTrue=True, fill=redFill) ws.conditional_formatting.add('E1:E10', rule) # 스타일 조합 rule = FormulaRule(formula=['E1=0'], font=Font(), border=Border(), fill=redFill) ws.conditional_formatting.add('E1:E10', rule) ``` #### DifferentialStyle 기반 저수준 Rule ```python from openpyxl.formatting.rule import Rule from openpyxl.styles.differential import DifferentialStyle from openpyxl.styles import Font, PatternFill dxf = DifferentialStyle( font=Font(bold=True), fill=PatternFill(start_color='EE1111', end_color='EE1111') ) rule = Rule(type='cellIs', dxf=dxf, formula=["10"]) # 텍스트 포함 강조 red_text = Font(color="9C0006") red_fill = PatternFill(bgColor="FFC7CE") dxf = DifferentialStyle(font=red_text, fill=red_fill) rule = Rule(type="containsText", operator="containsText", text="highlight", dxf=dxf) rule.formula = ['NOT(ISERROR(SEARCH("highlight",A1)))'] ws.conditional_formatting.add('A1:F40', rule) ``` #### 행 전체 강조 — DMF 리포트의 핵심 규칙 ```python dxf = DifferentialStyle(fill=PatternFill(bgColor="FFC7CE")) rule = Rule(type="expression", dxf=dxf, stopIfTrue=True) rule.formula = ['$A2="Microsoft"'] # 열은 절대($A), 행은 상대(2) ws.conditional_formatting.add("A1:C10", rule) ``` 핵심 규칙: - 다중 행 범위에 적용할 때 수식은 **열 절대 참조(`$G`) + 행 상대 참조**로 쓴다. 그래야 각 행이 자기 행의 상태 열을 본다. 문서 원문: *"Formulas require absolute column references (`$A`) but relative row numbers for multi-row ranges."* - 수식의 행 번호는 **적용 범위의 첫 행**을 기준으로 쓴다. `A4:N100000` 에 적용한다면 `$G4="신규"`. - `stopIfTrue=True` 를 주면 매칭 시 이후 규칙 평가를 멈춘다 → 우선순위 제어. - 규칙은 컬렉션에 추가한 뒤에도 조정할 수 있다. - 문서 원문: *"The syntax for the different rules varies so much that it is not possible for openpyxl to know whether a rule makes sense"* — 규칙의 타당성을 라이브러리가 검사해 주지 않으니 반드시 Excel 로 열어 눈으로 확인하라. #### 중복 강조 (openpyxl) ```python from openpyxl.formatting.rule import Rule from openpyxl.styles.differential import DifferentialStyle from openpyxl.styles import PatternFill rule = Rule(type="duplicateValues", dxf=DifferentialStyle(fill=PatternFill(bgColor="FFF2CC"))) ws.conditional_formatting.add("B4:B100000", rule) ``` ⚠️ 미검증 — 공식 문서 발췌에 `duplicateValues` 예제가 없다. (xlsxwriter 의 `'type': 'duplicate'` 는 문서로 확인됨.) ### 8.2 xlsxwriter API 메서드: `worksheet.conditional_format(범위, {옵션})`. `type` 값 전체 (18종): | type | 설명 | |---|---| | `cell` | 셀 값 기준 | | `date` | 날짜 기준 | | `time_period` | Excel 의 "발생 날짜" 스타일 | | `text` | 문자열 매칭 | | `average` | 평균 기준 | | `duplicate` | 중복 강조 | | `unique` | 고유값 강조 | | `top` | 상위 n개/n% | | `bottom` | 하위 n개/n% | | `blanks` | 빈 셀 | | `no_blanks` | 비어있지 않은 셀 | | `errors` | 오류 셀 | | `no_errors` | 오류 아닌 셀 | | `formula` | 사용자 정의 수식 | | `2_color_scale` | 2색 그라디언트 | | `3_color_scale` | 3색 그라디언트 | | `data_bar` | 데이터 막대 | | `icon_set` | 아이콘 세트 | 공통 옵션 키: `criteria`, `value`, `minimum`, `maximum`, `format`, `min_color` / `mid_color` / `max_color`, `bar_color`, `bar_solid`, `bar_negative_color`, `icon_style`, `icons`, `reverse_icons`, `icons_only`, `stop_if_true`, `multi_range`. 수식 기반: ```python worksheet.conditional_format('A1:A4', {'type': 'formula', 'criteria': '=$A$1>5', 'format': format1}) ``` 중복 강조: ```python worksheet.conditional_format('A1:A4', {'type': 'duplicate', 'format': format1}) ``` #### DMF 리포트 조건부 서식 세트 (완결 코드) ```python new_fmt = workbook.add_format({'bg_color': '#D9F0E7', 'font_color': '#065F46'}) chg_fmt = workbook.add_format({'bg_color': '#FBEBD1', 'font_color': '#7A4A00'}) del_fmt = workbook.add_format({'bg_color': '#F7DCD0', 'font_color': '#8A2D00', 'font_strikeout': True}) dup_fmt = workbook.add_format({'bg_color': '#FFF2CC', 'font_color': '#7F6000'}) FIRST = 3 # 0-index: 헤더가 2행, 데이터는 3행부터 LAST = FIRST + len(rows) - 1 LAST_COL = len(header) - 1 STATUS_COL = 'G' # 상태 열 (A1 표기) # 1) 상태='신규' → 행 전체 초록 worksheet.conditional_format(FIRST, 0, LAST, LAST_COL, { 'type': 'formula', 'criteria': f'=${STATUS_COL}{FIRST + 1}="신규"', 'format': new_fmt, 'stop_if_true': False, }) # 2) 상태='변경' → 행 전체 주황 worksheet.conditional_format(FIRST, 0, LAST, LAST_COL, { 'type': 'formula', 'criteria': f'=${STATUS_COL}{FIRST + 1}="변경"', 'format': chg_fmt, }) # 3) 상태='취하' → 행 전체 붉은 배경 + 취소선 worksheet.conditional_format(FIRST, 0, LAST, LAST_COL, { 'type': 'formula', 'criteria': f'=${STATUS_COL}{FIRST + 1}="취하"', 'format': del_fmt, }) # 4) 등록번호 중복 강조 (B열) worksheet.conditional_format(FIRST, 1, LAST, 1, { 'type': 'duplicate', 'format': dup_fmt, }) # 5) 건수 열 데이터 막대 worksheet.conditional_format(FIRST, 8, LAST, 8, { 'type': 'data_bar', 'bar_color': '#56B4E9', 'bar_solid': True, }) # 6) 증감률 열 3색 스케일 worksheet.conditional_format(FIRST, 9, LAST, 9, { 'type': '3_color_scale', 'min_color': '#D55E00', 'mid_color': '#FFFFFF', 'max_color': '#009E73', }) # 7) 경과일 열 아이콘 세트 (3색 신호등) worksheet.conditional_format(FIRST, 10, LAST, 10, { 'type': 'icon_set', 'icon_style': '3_traffic_lights', 'reverse_icons': True, 'icons_only': False, }) ``` > **`criteria` 의 행 번호 주의**: xlsxwriter 의 `conditional_format(first_row, ...)` 은 0-index 지만, 수식 안의 셀 참조는 **A1 표기(1-index)** 다. 위 코드에서 `FIRST + 1` 로 보정한 이유다. 이 한 칸 실수가 "한 행씩 밀린 색칠"의 대부분 원인이다. --- ## 9. 차트와 스파크라인 ### 9.1 스파크라인 — xlsxwriter 전용 > **openpyxl 에는 스파크라인 API 가 없다.** 이것이 xlsxwriter 를 기본 엔진으로 택한 결정적 이유다. 기본 사용: ```python worksheet.add_sparkline("F1", {"range": "Sheet1!A1:E1", "markers": True}) ``` 3가지 타입: `line`(기본), `column`, `win_loss`. ```python worksheet.add_sparkline("F2", {"range": "Sheet1!A2:E2", "type": "column", "style": 12}) worksheet.add_sparkline("F3", {"range": "Sheet1!A3:E3", "type": "win_loss", "negative_points": True}) ``` #### `add_sparkline()` 옵션 전체 | 그룹 | 키 | 설명 | |---|---|---| | **필수** | `range` | 스파크라인이 그릴 데이터 범위 (시트명 포함 권장) | | 주요 | `type` | `line` / `column` / `win_loss` | | 주요 | `style` | 내장 스타일 번호 1~36 | | 주요 | `markers` | line 타입 마커 표시 | | 주요 | `negative_points` | 음수 값 강조 | | 주요 | `axis` | 수평 축 표시 | | 주요 | `reverse` | 오른쪽→왼쪽으로 그리기 | | 포인트 강조 | `high_point` | 최고값 강조 | | 포인트 강조 | `low_point` | 최저값 강조 | | 포인트 강조 | `first_point` | 첫 값 강조 | | 포인트 강조 | `last_point` | 마지막 값 강조 | | 스케일/데이터 | `max` / `min` | 수직축 범위 지정 | | 스케일/데이터 | `empty_cells` | 빈 셀 처리: `gaps` / `zero` / `connect` | | 스케일/데이터 | `show_hidden` | 숨겨진 행/열도 그림 | | 스케일/데이터 | `date_axis` | 날짜 축 범위 지정 | | 스케일/데이터 | `weight` | 선 굵기: 0.25, 0.5, 0.75, 1, 1.25, 2.25, 3, 4.25, 6 | | 색상 | `series_color` | 기본 색 (`#rrggbb`) | | 색상 | `negative_color` | 음수 색 | | 색상 | `markers_color` | 마커 색 | | 색상 | `first_color` / `last_color` | 처음/마지막 점 색 | | 색상 | `high_color` / `low_color` | 최고/최저 점 색 | | 그룹 | `location` | 여러 셀 위치 배열 (그룹 스파크라인) | #### 고급 예제 (문서 원문) ```python # 최고/최저점 강조 worksheet1.add_sparkline( "A7", {"range": "Sheet2!A1:J1", "high_point": True, "low_point": True} ) # 마커 worksheet1.add_sparkline("A6", {"range": "Sheet2!A1:J1", "markers": True}) # 음수 강조 worksheet1.add_sparkline("A9", {"range": "Sheet2!A1:J1", "negative_points": True}) # 커스텀 시리즈 색 worksheet1.add_sparkline( "A18", {"range": "Sheet2!A2:J2", "type": "column", "series_color": "#E965E0"} ) # 내장 스타일 적용 (컬럼) worksheet1.add_sparkline("A13", {"range": "Sheet2!A2:J2", "type": "column", "style": 2}) # 역방향 worksheet1.add_sparkline( "A24", {"range": "Sheet2!A4:J4", "type": "column", "style": 20, "reverse": True} ) # 그룹 스파크라인 — location 과 range 를 배열로 준다 worksheet1.add_sparkline( "A27", { "location": ["A27", "A28", "A29"], "range": ["Sheet2!A5:J5", "Sheet2!A6:J6", "Sheet2!A7:J7"], "markers": True, }, ) ``` > **호환성 경고 (문서 원문)**: *"Sparklines are a feature of Excel 2010+ only. You can write them to an XLSX file that can be read by Excel 2007 but they won't be displayed."* > 그룹 스파크라인은 `location` 과 `range` 양쪽에 배열을 넘기면 연속된 셀들에 같은 설정을 한 번에 적용한다. #### DMF 리포트 적용 `00_목차` 시트에서 각 카테고리(신규/변경/취하)의 **최근 30일 일별 건수 추이**를 한 셀 스파크라인으로 보여준다. 데이터 원본은 `05_추이` 시트. ```python for i, cat in enumerate(["신규", "변경", "취하"]): row = 8 + i worksheet.add_sparkline(row, 5, { "range": f"'05_추이'!$B${2 + i}:$AE${2 + i}", # 30일치 가로 범위 "type": "column", "style": 12, "high_point": True, "low_point": True, "negative_points": True, "empty_cells": "zero", }) worksheet.set_row(row, 22) ``` ### 9.2 xlsxwriter 차트 #### 기본 흐름 ```python chart = workbook.add_chart({'type': 'column'}) chart.add_series({'values': ['Sheet1', 1, 1, max_row, 1]}) worksheet.insert_chart(1, 3, chart) ``` pandas 와 조합할 때: ```python workbook = writer.book worksheet = writer.sheets['Sheet1'] chart = workbook.add_chart({'type': 'column'}) (max_row, max_col) = df.shape chart.add_series({'values': ['Sheet1', 1, 1, max_row, 1]}) worksheet.insert_chart(1, 3, chart) ``` #### 차트 크기·위치 ```python chart.set_size({'width': 720, 'height': 576}) # 또는 배율로 chart.set_size({'x_scale': 1.5, 'y_scale': 2}) ``` - 시그니처: `set_size({'width': int, 'height': int, 'x_scale': float, 'y_scale': float, 'x_offset': int, 'y_offset': int})` - **기본 크기는 480 x 288 픽셀.** 삽입 시 오프셋: ```python worksheet.insert_chart("D2", chart1, {"x_offset": 25, "y_offset": 10}) ``` #### 제목·축·범례·스타일 ```python chart.set_title({'name': 'Year End Results'}) chart.set_title({'none': True}) # 기본 제목 제거 chart.set_x_axis({ 'name': '일자', 'num_format': '#,##0.00', 'major_gridlines': { 'visible': True, 'line': {'width': 0.75, 'dash_type': 'dash'} } }) chart.set_y_axis({'num_format': '0.00%'}) chart.set_legend({'position': 'bottom'}) chart.set_legend({'none': True}) # 범례 숨김 chart.set_style(37) # 1~48, 기본 2 chart.set_plotarea({ 'border': {'color': 'red', 'width': 2, 'dash_type': 'dash'}, 'fill': {'color': '#FFFFC2'} }) chart.show_blanks_as('span') # 'gap'(기본) | 'zero' | 'span' ``` `set_title` 시그니처: `set_title({'name': str, 'font': dict, 'border': dict, 'fill': dict, 'overlay': bool, 'layout': dict, 'none': bool})` `set_legend` 시그니처: `set_legend({'position': str, 'none': bool, 'font': dict, 'delete_series': list, 'layout': dict})` 범례 위치 값: `top`, `bottom`, `left`, `right`, `overlay_left`, `overlay_right`, `none`. #### 결합 차트 ```python column_chart = workbook.add_chart({'type': 'column'}) line_chart = workbook.add_chart({'type': 'line'}) column_chart.combine(line_chart) ``` DMF 적용: 일별 신규 건수(막대) + 누적 건수(선)를 한 차트로. #### 데이터 라벨 ```python chart1.add_series( { "categories": "=Sheet1!$A$2:$A$7", "values": "=Sheet1!$B$2:$B$7", "data_labels": {"value": True}, } ) chart2.add_series( { "categories": "=Sheet1!$A$2:$A$7", "values": "=Sheet1!$B$2:$B$7", "data_labels": {"value": True, "category": True}, } ) chart3.add_series( { "categories": "=Sheet1!$A$2:$A$7", "values": "=Sheet1!$B$2:$B$7", "data_labels": { "value": True, "font": {"bold": True, "color": "red", "rotation": -30}, }, } ) ``` `data_labels` 하위 속성 전체: ```python chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', 'data_labels': { 'value': bool, 'category': bool, 'series_name': bool, 'position': str, 'num_format': str, 'font': dict, 'custom': list } }) ``` #### 도넛 차트 ```python chart = workbook.add_chart({'type': 'doughnut'}) ``` 세그먼트별 색 지정 (파이/도넛은 각 세그먼트가 **point** 로 표현되므로 시리즈가 아니라 포인트마다 서식을 준다): ```python chart.add_series({ 'categories': '=Sheet1!$A$2:$A$4', 'values': '=Sheet1!$B$2:$B$4', "points": [ {"fill": {"color": "#FA58D0"}}, {"fill": {"color": "#61210B"}}, {"fill": {"color": "#F5F6CE"}}, ], }) chart.set_title({'name': '상태별 비중'}) chart.set_style(26) chart.set_rotation(90) # 첫 세그먼트를 90도 회전 chart.set_hole_size(33) # 구멍 지름 비율(%) worksheet.insert_chart('C2', chart) ``` #### 사용 가능한 차트 예제 목록 (공식) Chart (Simple), Area, Bar, Column, Line, Pie, Doughnut, Scatter, Radar, Stock, Styles, Pattern Fills, Gradient Fills, Secondary Axis, Combined, Pareto, Gauge, Clustered, Date Axis, Charts with Data Tables, Charts with Data Tools, Charts with Data Labels. ### 9.3 openpyxl 차트 (보조 지식) #### 지원 차트 타입 Area (2D/3D), Bar/Column, Bubble, Line, Scatter, Pie (3D·Gradient 포함), Doughnut, Radar, Stock, Surface. #### 기본 예제 ```python from openpyxl import Workbook wb = Workbook() ws = wb.active for i in range(10): ws.append([i]) from openpyxl.chart import BarChart, Reference, Series values = Reference(ws, min_col=1, min_row=1, max_col=1, max_row=10) chart = BarChart() chart.add_data(values) ws.add_chart(chart, "E15") wb.save("SampleChart.xlsx") ``` 문서 원문: *"charts are composed of at least one series of one or more data points."* 기본 차트는 좌상단이 E15 에 앵커되고 크기는 대략 **15 x 7.5 cm(약 5열 x 14행)** 이다. `anchor`, `width`, `height` 로 조정한다. #### BarChart 전체 예제 ```python from openpyxl import Workbook from openpyxl.chart import BarChart, Series, Reference wb = Workbook(write_only=True) ws = wb.create_sheet() rows = [ ('Number', 'Batch 1', 'Batch 2'), (2, 10, 30), (3, 40, 60), (4, 50, 70), (5, 20, 10), (6, 10, 40), (7, 50, 30), ] for row in rows: ws.append(row) chart1 = BarChart() chart1.type = "col" chart1.style = 10 chart1.title = "Bar Chart" chart1.y_axis.title = 'Test number' chart1.x_axis.title = 'Sample length (mm)' data = Reference(ws, min_col=2, min_row=1, max_row=7, max_col=3) cats = Reference(ws, min_col=1, min_row=2, max_row=7) chart1.add_data(data, titles_from_data=True) chart1.set_categories(cats) chart1.shape = 4 ws.add_chart(chart1, "A10") ``` - `type`: `"col"`(세로) / `"bar"`(가로) - `style`: 10, 11, 12, 13 … - `grouping`: `"stacked"` / `"percentStacked"` (누적 시 `overlap = 100` 을 같이 설정) - `shape`: 숫자 (예: 4) #### DoughnutChart 전체 예제 ```python from openpyxl import Workbook from openpyxl.chart import DoughnutChart, Reference, Series from openpyxl.chart.series import DataPoint data = [ ['Pie', 2014, 2015], ['Plain', 40, 50], ['Jam', 2, 10], ['Lime', 20, 30], ['Chocolate', 30, 40], ] wb = Workbook() ws = wb.active for row in data: ws.append(row) chart = DoughnutChart() labels = Reference(ws, min_col=1, min_row=2, max_row=5) data = Reference(ws, min_col=2, min_row=1, max_row=5) chart.add_data(data, titles_from_data=True) chart.set_categories(labels) chart.title = "Doughnuts sold by category" chart.style = 26 slices = [DataPoint(idx=i) for i in range(4)] plain, jam, lime, chocolate = slices chart.series[0].data_points = slices plain.graphicalProperties.solidFill = "FAE1D0" jam.graphicalProperties.solidFill = "BB2244" lime.graphicalProperties.solidFill = "22DD22" chocolate.graphicalProperties.solidFill = "61210B" chocolate.explosion = 10 ws.add_chart(chart, "E1") ``` ⚠️ 공식 문서 예제에 `holeSize` 와 `DataLabelList` 사용은 **포함돼 있지 않다.** #### PieChart 요소 `PieChart()` 초기화 → `Reference()` 로 라벨/데이터 범위 → `add_data(data, titles_from_data=True)` → `set_categories(labels)` → `DataPoint(idx=0, explosion=20)` 로 조각 분리. `PieChart3D()` 도 동일 구조. 그라디언트 파이는 `GraphicalProperties()` + `GradientFillProperties()` + 다수의 `GradientStop()`, `SchemeColor()` 를 조합한다. #### DataLabelList 속성 | 그룹 | 속성 | |---|---| | 표시 불리언 | `showVal`, `showPercent`, `showCatName`, `showSerName`, `showLegendKey` | | 서식 | `dLblPos`(위치), `numFmt`(문자열) | `dLblPos` 허용값: `{'l', 't', 'bestFit', 'r', 'b', 'outEnd', 'inEnd', 'inBase', 'ctr'}` — 각각 left / top / right / bottom / center / 자동 / 바깥 끝 / 안쪽 끝 / 안쪽 기준선. #### 차트 레이아웃과 크기 (openpyxl) 공식 `chart_layout` 문서는 `chart.width` / `chart.height` 를 cm 단위로 문서화하지 **않는다.** 대신 비율 단위 수동 레이아웃을 쓴다. 원문: *"x and y adjust position, w and h adjust the size. The units are proportions of the container."* ```python from openpyxl.chart.layout import Layout, ManualLayout ch2 = deepcopy(ch1) ch2.layout = Layout( manualLayout=ManualLayout( x=0.25, y=0.25, h=0.5, w=0.5, ) ) ch4.legend.layout = Layout( manualLayout=ManualLayout( yMode='edge', xMode='edge', x=0, y=0.9, h=0.1, w=0.5 ) ) ``` 범례 위치는 직접 지정도 가능: `legend.position = 'tr'` (옵션: `r`, `l`, `t`, `b`). 차트 배치는 `ws.add_chart(chart_object, "B10")`. --- ## 10. 파일 갱신 전략과 잠금 처리 ### 10.1 매일 새 파일 vs 하나의 파일에 누적 | 전략 | 장점 | 단점 | 판정 | |---|---|---|---| | **매일 새 파일** (`DMF_리포트_YYYY-MM-DD.xlsx`) | 차트·스파크라인 손실 없음. xlsxwriter 사용 가능. 스냅샷 증빙. 실패해도 어제 파일 온전 | 파일 개수 증가 | **채택** | | 하나의 파일에 시트 누적 | 파일 하나 | openpyxl 필수 → **차트·이미지 소실**. 파일 비대. 손상 시 전량 손실 | 기각 | | 하나의 파일, 매일 전체 재생성 | 파일 하나 + 손실 없음 | 히스토리 없음. 사용자가 열어두면 잠김 | 부분 채택(최신 링크용) | **최종 운영 방식**: 1. `reports/2026/09/DMF_리포트_2026-09-02.xlsx` 를 xlsxwriter 로 새로 만든다. 2. 성공하면 `reports/DMF_리포트_최신.xlsx` 로 **원자적 교체**(`os.replace`)한다. 3. 90일 지난 일자 파일은 `reports/archive/` 로 이동하거나 zip 으로 묶는다. ### 10.2 openpyxl 로 기존 파일을 여닫을 때의 손실 공식 문서 원문: > "openpyxl does currently not read all possible items in an Excel file so shapes will be lost from existing files if they are opened and saved with the same name." 동일 취지의 다른 판본: > "openpyxl does currently not read all possible items in an Excel file so images and charts will be lost from existing files if they are opened and saved with the same name." 또한 셀(값·스타일·하이퍼링크·주석)과 일부 워크시트 속성만 복사되며, **이미지와 차트를 포함한 다른 통합문서/워크시트 속성은 복사되지 않는다.** 이것은 버그가 아니라 문서화된 동작이다. `load_workbook` 관련 파라미터: - `keep_vba` — *"controls whether any Visual Basic elements are preserved or not (default). If they are preserved they are still not editable."* - `data_only` — *"controls whether cells with formulae have either the formula (default) or the value stored the last time Excel read the sheet."* - 확장자 주의: `.xlsx` 를 `.xlsm` 으로 저장하거나 `keep_vba=True` 를 빠뜨리면 Excel 이 문서를 열지 못한다. ### 10.3 pandas `ExcelWriter` 의 append 모드 전체 시그니처: ```python class pandas.ExcelWriter( path, engine=None, date_format=None, datetime_format=None, mode='w', storage_options=None, if_sheet_exists=None, engine_kwargs=None ) ``` `mode` : {'w', 'a'}, default 'w' — 쓰기 또는 추가. **append 는 fsspec URL 에서 동작하지 않는다.** `if_sheet_exists` : {'error', 'new', 'replace', 'overlay'}, default 'error' — **append 모드에서만** 유효. | 값 | 의미 | |---|---| | `error` | `ValueError` 발생 | | `new` | 엔진이 정하는 이름으로 새 시트 생성 | | `replace` | 기존 시트 내용을 지우고 씀 | | `overlay` | 기존 내용을 지우지 않고 그 위에 덮어 씀 | `engine_kwargs` 전달 대상: - xlsxwriter: `xlsxwriter.Workbook(file, **engine_kwargs)` - openpyxl (쓰기 모드): `openpyxl.Workbook(**engine_kwargs)` - **openpyxl (append 모드): `openpyxl.load_workbook(file, **engine_kwargs)`** ← 여기서 차트가 사라진다 - odf: `odf.opendocument.OpenDocumentSpreadsheet(**engine_kwargs)` 코드 예제: ```python # 기본 append with pd.ExcelWriter("path_to_file.xlsx", mode="a", engine="openpyxl") as writer: df.to_excel(writer, sheet_name="Sheet3") # 기존 시트 교체 with pd.ExcelWriter( "path_to_file.xlsx", mode="a", engine="openpyxl", if_sheet_exists="replace", ) as writer: df.to_excel(writer, sheet_name="Sheet1") # 한 시트에 여러 DataFrame 을 나란히 with pd.ExcelWriter( "path_to_file.xlsx", mode="a", engine="openpyxl", if_sheet_exists="overlay", ) as writer: df1.to_excel(writer, sheet_name="Sheet1") df2.to_excel(writer, sheet_name="Sheet1", startcol=3) ``` **알려진 함정 (pandas issue #52189)**: `mode="a"` + `if_sheet_exists="overlay"` 로 두 DataFrame 을 쓰면, 두 번째가 아래에 이어지지 않고 **같은 시작 위치(A1)에 첫 번째를 덮어쓴다.** 보고 버전 pandas 1.5.3, PR #52222 로 연결됨. 회피책은 `startrow=len(df1)+1` 을 명시적으로 주는 것. 문서에는 "overlay 는 기존 내용을 제거하지 않고 쓴다"고 돼 있지만 **어디에 쓰는가**가 직관과 다르다. 공식 overlay 예제에도 `startrow` 는 없고 `startcol=3` 만 있다. > **결론: 이 프로젝트는 `mode='a'` 를 쓰지 않는다.** 시트가 늘어나는 구조가 아니라 매일 전체를 새로 만드는 구조이기 때문이다. ### 10.4 pandas 로 쓴 뒤 서식 입히기 (혼합 패턴) pandas 로 데이터를 붓고 xlsxwriter 로 서식을 입히는 표준 패턴: ```python import pandas as pd df = pd.DataFrame({'Data': [10, 20, 30, 20, 15, 30, 45]}) writer = pd.ExcelWriter('pandas_simple.xlsx', engine='xlsxwriter') df.to_excel(writer, sheet_name='Sheet1') writer.close() ``` 엔진 객체 접근: ```python workbook = writer.book worksheet = writer.sheets['Sheet1'] ``` 열 서식: ```python format1 = workbook.add_format({'num_format': '#,##0.00'}) format2 = workbook.add_format({'num_format': '0%'}) worksheet.set_column(1, 1, 18, format1) worksheet.set_column(2, 2, None, format2) ``` **pandas 기본 헤더 서식 무력화 후 직접 헤더 쓰기** (pandas 가 굵은 테두리 헤더를 강제로 넣는 문제 회피): ```python df.to_excel(writer, sheet_name='Sheet1', startrow=1, header=False) header_format = workbook.add_format({'bold': True, 'fg_color': '#D7E4BC'}) for col_num, value in enumerate(df.columns.values): worksheet.write(0, col_num + 1, value, header_format) ``` 메모리 출력: ```python import io output = io.BytesIO() writer = pd.ExcelWriter(output, engine='xlsxwriter') df.to_excel(writer, sheet_name='Sheet1') writer.close() xlsx_data = output.getvalue() ``` openpyxl 쪽 pandas 연동: ```python from openpyxl.utils.dataframe import dataframe_to_rows wb = Workbook() ws = wb.active for r in dataframe_to_rows(df, index=True, header=True): ws.append(r) for cell in ws['A'] + ws[1]: cell.style = 'Pandas' wb.save("pandas_openpyxl.xlsx") ``` 워크시트 → DataFrame 역변환: ```python df = DataFrame(ws.values) # 헤더 없는 경우 from itertools import islice # 헤더 + 인덱스가 있는 경우 data = ws.values cols = next(data)[1:] data = list(data) idx = [r[0] for r in data] data = (islice(r, 1, None) for r in data) df = DataFrame(data, index=idx, columns=cols) ``` ### 10.5 대용량: write_only / constant_memory #### openpyxl `write_only` ```python from openpyxl import Workbook wb = Workbook(write_only=True) ws = wb.create_sheet() for irow in range(100): ws.append(['%d' % i for i in range(200)]) wb.save('new_big_file.xlsx') ``` 스타일 적용: ```python from openpyxl import Workbook from openpyxl.cell import WriteOnlyCell from openpyxl.comments import Comment from openpyxl.styles import Font wb = Workbook(write_only=True) ws = wb.create_sheet() cell = WriteOnlyCell(ws, value="hello world") cell.font = Font(name='Courier', size=36) cell.comment = Comment(text="A comment", author="Author's Name") ws.append([cell, 3.14, None]) wb.save('write_only_file.xlsx') ``` 제약(문서 원문 포함): - *"Unlike a normal workbook, a newly-created write-only workbook does not contain any worksheets; a worksheet must be specifically created with the `create_sheet()` method."* - 행 추가는 `append()` 만 가능. `cell()` / `iter_rows()` 등 임의 셀 접근 금지. - *"A write-only workbook can only be saved once"* — **딱 한 번만 저장 가능.** - 틀 고정·열 너비 등 구조 요소는 **셀을 추가하기 전에** 설정해야 한다. pandas 스트리밍 조합: ```python from openpyxl.cell.cell import WriteOnlyCell wb = Workbook(write_only=True) ws = wb.create_sheet() cell = WriteOnlyCell(ws) cell.style = 'Pandas' def format_first_row(row, cell): for c in row: cell.value = c yield cell rows = dataframe_to_rows(df) first_row = format_first_row(next(rows), cell) ws.append(first_row) for row in rows: row = list(row) cell.value = row[0] row[0] = cell ws.append(row) wb.save("openpyxl_stream.xlsx") ``` #### openpyxl `read_only` ```python from openpyxl import load_workbook wb = load_workbook(filename='large_file.xlsx', read_only=True) ws = wb['big_data'] for row in ws.rows: for cell in row: print(cell.value) wb.close() ``` - *"The workbook must be explicitly closed with the `close()` method"* — 반드시 닫아야 한다. - lazy loading 으로 메모리 일정. `ReadOnlyCell` 객체 반환. #### xlsxwriter `constant_memory` `Workbook` 생성자 옵션 정리: | 그룹 | 옵션 | 설명 | |---|---|---| | 메모리 | `constant_memory` | 행을 순차로 쓰고 버려 대용량 파일을 효율적으로 씀 | | 메모리 | `in_memory` | 임시 파일 없이 전체를 메모리에 | | 메모리 | `tmpdir` | 기본 임시 디렉터리를 못 쓸 때 대체 경로 | | 데이터 | `strings_to_numbers` | `float()` 로 문자열→숫자 변환, Excel 경고 방지 | | 데이터 | `strings_to_formulas` | 기본 활성. 문자열을 수식으로 변환 | | 데이터 | `strings_to_urls` | 기본 활성. 문자열을 하이퍼링크로 변환 | | 데이터 | `nan_inf_to_errors` | NaN/inf 를 `#NUM!`, `#DIV/0!` 로 매핑 | | 날짜/수식 | `default_date_format` | datetime 기본 서식 | | 날짜/수식 | `remove_timezone` | datetime 의 시간대 제거 | | 날짜/수식 | `use_future_functions` | `_xlfn` 접두 없이 최신 함수 사용 | | 날짜/수식 | `date_1904` | Mac Excel 1904 에폭 | | 기타 | `max_url_length` | 기본 2079, 최소 255 | | 기타 | `use_zip64` | 4GB 초과 파일 지원 | 계산 모드: ```python workbook.set_calc_mode('auto') # 기본 workbook.set_calc_mode('manual') workbook.set_calc_mode('auto_except_tables') ``` > **핵심 제약 (문서 원문)**: *"XlsxWriter creates new Excel files only—it cannot modify existing workbooks."* DMF 프로젝트 권장 옵션: ```python workbook = xlsxwriter.Workbook(tmp_path, { 'strings_to_urls': False, # 품목명에 URL 유사 문자열이 있어도 링크로 안 바뀌게 'strings_to_formulas': False, # '=' 로 시작하는 원문 텍스트를 수식으로 오해하지 않게 'nan_inf_to_errors': True, 'default_date_format': 'yyyy-mm-dd', 'remove_timezone': True, 'use_future_functions': True, }) ``` `strings_to_formulas: False` 는 특히 중요하다. 크롤링한 텍스트가 `=` 로 시작하면 수식으로 해석돼 파일이 깨지거나, 최악의 경우 **CSV/수식 인젝션**이 된다. ### 10.6 사용자가 파일을 열어둔 경우 — 잠금 처리 #### 원인 - *"Excel enforces exclusive access to the file by default"* — Excel 은 파일을 열면 **배타 잠금**을 건다. - 워크북을 열면 숨김 잠금 파일 `~$파일명.xlsx` 를 만든다. *"Excel uses the lock file to track which user has the file open"*, *"prevent other users or programs from overwriting changes while the file is in use."* - **읽기 전용으로 열어도 소용없다**: *"Excel's 'read-only' mode does not disable the lock file. Excel still creates `~$filename.xlsx` and retains an exclusive lock, even for read-only sessions."* - 따라서 **읽기·쓰기 모두 실패**한다. `PermissionError: [Errno 13] Permission denied: 'Abc.xlsx'`. - 근본 해결은 "Excel 에서 파일을 닫는 것"뿐이다. *"It is impossible to concurrently write from Python into an open Excel file."* #### 대응 전략 (권장 순서) 1. **임시 파일에 쓰고 `os.replace` 로 원자적 교체** — 쓰기 중 크래시가 나도 기존 파일이 온전하다. 단, 대상이 Excel 에 열려 있으면 `os.replace` 자체가 `PermissionError` 로 실패한다(Windows). 2. **지수 백오프 재시도** — 사용자가 곧 닫을 수 있으니 몇 초 간격으로 재시도. 3. **잠금 파일 사전 감지** — `~$파일명.xlsx` 존재 확인으로 빠르게 판단(⚠️ 완전하지 않다. 다른 앱이 잠글 수도 있고, 잔여 잠금 파일이 남아 있을 수도 있다). 4. **폴백 파일명** — 끝까지 실패하면 `DMF_리포트_최신 (1).xlsx` 같은 대체 이름으로 저장하고 Windows 알림으로 사용자에게 알린다. 일자별 파일은 이미 저장돼 있으므로 데이터 손실은 없다. 5. **읽을 때는 복사본을 만들어 읽는다** — 파일을 복사한 뒤 복사본을 읽으면 잠금을 우회할 수 있는 경우가 있다(권장되는 회피책 중 하나). xlsxwriter 관련 알려진 이슈: `close()` 가 실패해 **손상된 워크북이 저장되는** 사례가 보고된 적 있다(GitHub issue #583). 따라서 **저장 성공 여부를 파일 크기와 재열기로 검증**한 뒤에만 `os.replace` 를 실행한다. pandas 관련: 손상 파일을 넣었을 때 pandas 가 잠금을 해제하지 않는 버그가 보고됐다(pandas issue #41778, 1.2.1 정상 → 1.2.4 문제). `with` 컨텍스트 매니저 사용을 표준으로 한다. 완결 구현은 [11.3 원자적 저장 + 잠금 처리](#113-스니펫-3--원자적-저장--파일-잠금-처리) 참조. --- ## 11. 완결 코드 스니펫 모든 스니펫은 생략 부호 없이 그대로 복사해 실행할 수 있는 수준으로 작성했다. 공통 전제: `pip install XlsxWriter openpyxl pandas`. ### 11.1 스니펫 1 — xlsxwriter 로 DMF 리포트 전체 생성 (목차 + 링크 + 표 + 조건부서식 + 틀고정 + 한글 열너비 + 인쇄설정 + 탭색상) ```python """build_report.py — DMF 일일 리포트 생성기 (xlsxwriter) 사용: python build_report.py 결과: ./reports/DMF_리포트_2026-09-02.xlsx """ from __future__ import annotations import datetime as dt import os import unicodedata from pathlib import Path import xlsxwriter # ---------------------------------------------------------------- 팔레트/상수 PALETTE = { "black": "#000000", "orange": "#E69F00", "sky": "#56B4E9", "green": "#009E73", "yellow": "#F0E442", "blue": "#0072B2", "verm": "#D55E00", "purple": "#CC79A7", "bg_new": "#D9F0E7", "bg_chg": "#FBEBD1", "bg_del": "#F7DCD0", "bg_zebra": "#F5F7FA", "grid": "#D6DCE4", "white": "#FFFFFF", } FONT = "Malgun Gothic" SHEETS = [ ("00_목차", PALETTE["blue"], "목차 · 요약 대시보드"), ("01_신규", PALETTE["green"], "오늘 신규 등록된 DMF"), ("02_변경", PALETTE["orange"], "변경된 DMF"), ("03_취하", PALETTE["verm"], "취하·취소된 DMF"), ("04_전체현황", PALETTE["sky"], "전체 DMF 스냅샷"), ("05_추이", PALETTE["purple"], "일자별 건수 추이"), ("99_메타", "#7F7F7F", "수집 메타데이터"), ] HEADER = ["연번", "등록번호", "품목명(성분명)", "업체명", "등록일자", "공고일자", "상태", "변경사유", "건수", "증감률", "경과일", "원문URL"] # ------------------------------------------------------------- 한글 폭 유틸 _EAW_KO = {"F": 1.8, "H": 1.0, "W": 1.8, "Na": 1.0, "A": 1.2, "N": 1.0} def display_width(text, table=_EAW_KO): if text is None: return 0.0 return sum(table.get(unicodedata.east_asian_width(ch), 1.0) for ch in str(text)) def compute_col_widths(header, rows, min_w=8.0, max_w=48.0, margin=2.0, sample=2000): widths = [display_width(h) for h in header] for r in rows[:sample]: for i, v in enumerate(r): if i >= len(widths): widths.append(0.0) w = display_width(v) if w > widths[i]: widths[i] = w return [max(min_w, min(max_w, w + margin)) for w in widths] # ------------------------------------------------------------------- 더미 데이터 def sample_rows(status, n): today = dt.date(2026, 9, 2) out = [] for i in range(1, n + 1): out.append([ i, f"DMF-2026-{i:05d}", f"아세트아미노펜 원료 {i}호", f"(주)한국원료제약 {i}", today - dt.timedelta(days=i * 3), today - dt.timedelta(days=i), status, "제조소 변경" if status == "변경" else "", 10 + (i * 7) % 40, ((i * 13) % 41 - 20) / 100.0, i * 3, f"https://nedrug.mfds.go.kr/bbs/117/{100000 + i}", ]) return out DATA = { "01_신규": sample_rows("신규", 24), "02_변경": sample_rows("변경", 11), "03_취하": sample_rows("취하", 4), "04_전체현황": sample_rows("유지", 120), } # --------------------------------------------------------------------- 빌더 def build(path: str | os.PathLike) -> str: wb = xlsxwriter.Workbook(str(path), { "strings_to_urls": False, "strings_to_formulas": False, "nan_inf_to_errors": True, "default_date_format": "yyyy-mm-dd", "remove_timezone": True, "use_future_functions": True, }) # ---- 포맷 정의 (한 번만 만들고 재사용한다. 절대 나중에 수정하지 않는다) ---- f_title = wb.add_format({"font_name": FONT, "font_size": 16, "bold": True, "font_color": PALETTE["blue"], "valign": "vcenter"}) f_sub = wb.add_format({"font_name": FONT, "font_size": 9, "font_color": "#666666", "valign": "vcenter"}) f_link = wb.add_format({"font_name": FONT, "font_size": 10, "font_color": PALETTE["blue"], "underline": 1, "valign": "vcenter"}) f_head = wb.add_format({"font_name": FONT, "font_size": 10, "bold": True, "font_color": PALETTE["white"], "bg_color": PALETTE["blue"], "align": "center", "valign": "vcenter", "text_wrap": True, "border": 1, "border_color": "#0A5A8C"}) f_text = wb.add_format({"font_name": FONT, "font_size": 10, "valign": "vcenter"}) f_int = wb.add_format({"font_name": FONT, "font_size": 10, "num_format": "#,##0", "align": "right", "valign": "vcenter"}) f_pct = wb.add_format({"font_name": FONT, "font_size": 10, "num_format": "0.0%", "align": "right", "valign": "vcenter"}) f_date = wb.add_format({"font_name": FONT, "font_size": 10, "num_format": "yyyy-mm-dd", "align": "center", "valign": "vcenter"}) f_url = wb.add_format({"font_name": FONT, "font_size": 9, "font_color": PALETTE["blue"], "underline": 1}) f_new = wb.add_format({"bg_color": PALETTE["bg_new"], "font_color": "#065F46"}) f_chg = wb.add_format({"bg_color": PALETTE["bg_chg"], "font_color": "#7A4A00"}) f_del = wb.add_format({"bg_color": PALETTE["bg_del"], "font_color": "#8A2D00", "font_strikeout": True}) f_dup = wb.add_format({"bg_color": "#FFF2CC", "font_color": "#7F6000"}) col_formats = [f_int, f_text, f_text, f_text, f_date, f_date, f_text, f_text, f_int, f_pct, f_int, f_url] now = dt.datetime.now().strftime("%Y-%m-%d %H:%M") # ---- 시트 생성 (목차를 먼저 만들어야 탭 순서가 맞다) ---- ws = {name: wb.add_worksheet(name) for name, _, _ in SHEETS} for name, color, _ in SHEETS: ws[name].set_tab_color(color) # ---- 데이터 시트들 ---- for name in ("01_신규", "02_변경", "03_취하", "04_전체현황"): w = ws[name] rows = DATA[name] desc = next(d for n, _, d in SHEETS if n == name) w.hide_gridlines(2) w.set_zoom(100) # 1행: 목차 복귀 링크 + 제목 + 생성 시각 w.set_row(0, 30) w.write_url(0, 0, "internal:'00_목차'!A1", f_link, "← 목차로", "목차 시트로 이동") w.write(0, 2, f"{name} · {desc}", f_title) w.write(0, 8, f"생성 {now}", f_sub) # 3행(0-index 2): 헤더 w.set_row(2, 32) for c, h in enumerate(HEADER): w.write(2, c, h, f_head) # 4행(0-index 3)부터 데이터 for r, row in enumerate(rows, start=3): for c, v in enumerate(row): if c == 11: w.write_url(r, c, str(v), f_url, "원문", "식약처 원문 보기") else: w.write(r, c, v, col_formats[c]) w.set_row(r, 18) first, last = 3, 3 + len(rows) - 1 last_col = len(HEADER) - 1 # 열 너비: 한글 폭 계산 widths = compute_col_widths(HEADER, rows) for i, wd in enumerate(widths): w.set_column(i, i, wd, col_formats[i]) # 원문URL 열은 접어 둔다 w.set_column(11, 11, 10, f_url, {"level": 1, "hidden": 1}) # Excel 표 등록 (자동필터 + 줄무늬 + 구조적 참조) w.add_table(2, 0, last, last_col, { "name": f"T_{name.split('_')[1]}", "style": "Table Style Light 9", "banded_rows": True, "autofilter": True, "columns": [{"header": h, "header_format": f_head} for h in HEADER], }) # 틀 고정: 헤더 3행 + A열 w.freeze_panes(3, 1) # 조건부 서식 (수식 안 셀 참조는 A1 표기 → first+1) for status, fmt in (("신규", f_new), ("변경", f_chg), ("취하", f_del)): w.conditional_format(first, 0, last, last_col, { "type": "formula", "criteria": f'=$G{first + 1}="{status}"', "format": fmt, }) w.conditional_format(first, 1, last, 1, {"type": "duplicate", "format": f_dup}) w.conditional_format(first, 8, last, 8, { "type": "data_bar", "bar_color": PALETTE["sky"], "bar_solid": True}) w.conditional_format(first, 9, last, 9, { "type": "3_color_scale", "min_color": PALETTE["verm"], "mid_color": "#FFFFFF", "max_color": PALETTE["green"]}) w.conditional_format(first, 10, last, 10, { "type": "icon_set", "icon_style": "3_traffic_lights", "reverse_icons": True}) # 인쇄 설정 w.set_landscape() w.set_paper(9) # A4 w.fit_to_pages(1, 0) # 너비 1페이지 w.repeat_rows(0, 2) # 1~3행 반복 w.print_area(0, 0, last, last_col) # ---- 05_추이 시트 (스파크라인/차트 원본) ---- w = ws["05_추이"] w.hide_gridlines(2) w.write_url(0, 0, "internal:'00_목차'!A1", f_link, "← 목차로") w.write(0, 2, "05_추이 · 일자별 건수", f_title) w.write(2, 0, "구분", f_head) for d in range(30): w.write(2, 1 + d, dt.date(2026, 9, 2) - dt.timedelta(days=29 - d), f_head) series = {"신규": 3, "변경": 5, "취하": 7} for i, (cat, seed) in enumerate(series.items()): w.write(3 + i, 0, cat, f_text) for d in range(30): w.write_number(3 + i, 1 + d, (d * seed) % 11, f_int) w.set_column(0, 0, 10, f_text) w.set_column(1, 30, 6, f_int) w.freeze_panes(3, 1) # ---- 99_메타 ---- w = ws["99_메타"] w.hide_gridlines(2) w.write_url(0, 0, "internal:'00_목차'!A1", f_link, "← 목차로") w.write(0, 2, "99_메타 · 수집 메타데이터", f_title) meta = [ ("수집 시각", now), ("소스", "https://nedrug.mfds.go.kr/bbs/117"), ("신규 건수", len(DATA["01_신규"])), ("변경 건수", len(DATA["02_변경"])), ("취하 건수", len(DATA["03_취하"])), ("전체 건수", len(DATA["04_전체현황"])), ] for r, (k, v) in enumerate(meta, start=3): w.write(r, 0, k, f_head) w.write(r, 1, v, f_text) # 드롭다운 원본 목록 for r, v in enumerate(["신규", "변경", "취하", "유지"], start=3): w.write(r, 4, v, f_text) w.set_column(0, 0, 16) w.set_column(1, 1, 52) w.set_column(4, 4, 10) # 다른 시트의 메모 열에 드롭다운을 건다 ws["04_전체현황"].data_validation(3, 7, 3 + len(DATA["04_전체현황"]) - 1, 7, { "validate": "list", "source": "='99_메타'!$E$4:$E$7", "input_title": "상태 선택", "input_message": "신규 / 변경 / 취하 / 유지 중 선택", }) # ---- 정의된 이름 ---- n_all = len(DATA["04_전체현황"]) wb.define_name("현황_상태", f"='04_전체현황'!$G$4:$G${3 + n_all}") wb.define_name("현황_등록번호", f"='04_전체현황'!$B$4:$B${3 + n_all}") wb.define_name("현황_품목명", f"='04_전체현황'!$C$4:$C${3 + n_all}") # ---- 00_목차 ---- idx = ws["00_목차"] idx.hide_gridlines(2) idx.set_column(0, 0, 4) idx.set_column(1, 1, 22) idx.set_column(2, 2, 40) idx.set_column(3, 4, 14) idx.set_row(0, 40) idx.write(0, 1, "원료의약품 등록(DMF) 일일 리포트", f_title) idx.write(1, 1, f"생성 시각 {now} · 출처 nedrug.mfds.go.kr", f_sub) idx.write(3, 1, "시트", f_head) idx.write(3, 2, "설명", f_head) idx.write(3, 3, "건수", f_head) for r, (name, color, desc) in enumerate(SHEETS[1:], start=4): idx.write_url(r, 1, f"internal:'{name}'!A1", f_link, name, f"{name} 시트로 이동") idx.write(r, 2, desc, f_text) cnt = len(DATA.get(name, [])) if name in DATA: # 수식 + 캐시값 동봉 (파이썬이 계산해 준 값) idx.write_formula(r, 3, f"=COUNTA('{name}'!$B$4:$B$100000)", f_int, cnt) else: idx.write(r, 3, "-", f_text) idx.set_row(r, 20) idx.activate() idx.set_first_sheet() wb.close() return str(path) if __name__ == "__main__": out_dir = Path("reports") out_dir.mkdir(parents=True, exist_ok=True) today = dt.date.today().isoformat() print(build(out_dir / f"DMF_리포트_{today}.xlsx")) ``` ### 11.2 스니펫 2 — xlsxwriter 대시보드: KPI + 차트 2종 + 스파크라인 11.1 의 `build()` 안에서 `00_목차` 를 만든 직후 호출하면 되는 함수다. ```python def add_dashboard(wb, idx, ws_trend_name, counts, fonts): """00_목차 시트에 KPI 카드 + 도넛 + 결합 차트 + 스파크라인을 얹는다. wb : xlsxwriter.Workbook idx : 목차 워크시트 ws_trend_name : 추이 시트 이름 (예: '05_추이') counts : {'신규': 24, '변경': 11, '취하': 4} fonts : {'title':fmt, 'sub':fmt, 'int':fmt, 'text':fmt} """ FONT = "Malgun Gothic" card_colors = {"신규": "#009E73", "변경": "#E69F00", "취하": "#D55E00"} # ---------------------------------------------------------- KPI 카드 3장 idx.write(11, 1, "오늘의 요약", fonts["title"]) for i, (label, value) in enumerate(counts.items()): col = 1 + i * 2 cap = wb.add_format({ "font_name": FONT, "font_size": 10, "bold": True, "font_color": "#FFFFFF", "bg_color": card_colors[label], "align": "center", "valign": "vcenter", "border": 0, }) num = wb.add_format({ "font_name": FONT, "font_size": 26, "bold": True, "font_color": card_colors[label], "bg_color": "#F5F7FA", "align": "center", "valign": "vcenter", "num_format": "#,##0", }) idx.merge_range(13, col, 13, col + 1, label, cap) idx.merge_range(14, col, 15, col + 1, value, num) idx.set_row(13, 20) idx.set_row(14, 26) idx.set_row(15, 26) # ------------------------------------------------------- 스파크라인 3줄 idx.write(17, 1, "최근 30일 추이", fonts["title"]) for i, label in enumerate(counts): row = 19 + i idx.write(row, 1, label, fonts["text"]) idx.add_sparkline(row, 2, { "range": f"'{ws_trend_name}'!$B${4 + i}:$AE${4 + i}", "type": "column", "style": 12, "high_point": True, "low_point": True, "negative_points": True, "empty_cells": "zero", }) idx.set_row(row, 22) idx.set_column(2, 2, 40) # --------------------------------------------------------------- 도넛 차트 donut = wb.add_chart({"type": "doughnut"}) donut.add_series({ "name": "상태 비중", "categories": ["00_목차", 13, 1, 13, 5], # 카드 캡션 3개 "values": ["00_목차", 14, 1, 14, 5], # 카드 숫자 3개 "points": [ {"fill": {"color": card_colors["신규"]}}, {"fill": {"color": card_colors["변경"]}}, {"fill": {"color": card_colors["취하"]}}, ], "data_labels": {"percentage": True, "font": {"name": FONT, "size": 9}}, }) donut.set_title({"name": "상태별 비중", "name_font": {"name": FONT, "size": 11}}) donut.set_hole_size(45) donut.set_rotation(90) donut.set_legend({"position": "bottom", "font": {"name": FONT, "size": 9}}) donut.set_size({"width": 340, "height": 240}) idx.insert_chart(23, 1, donut, {"x_offset": 4, "y_offset": 4}) # ------------------------------------------------- 결합 차트(막대 + 선) col_chart = wb.add_chart({"type": "column"}) col_chart.add_series({ "name": "신규", "categories": [ws_trend_name, 2, 1, 2, 30], "values": [ws_trend_name, 3, 1, 3, 30], "fill": {"color": "#009E73"}, "data_labels": {"value": False}, }) line_chart = wb.add_chart({"type": "line"}) line_chart.add_series({ "name": "변경", "categories": [ws_trend_name, 2, 1, 2, 30], "values": [ws_trend_name, 4, 1, 4, 30], "line": {"color": "#E69F00", "width": 2.0}, "marker": {"type": "circle", "size": 4}, }) col_chart.combine(line_chart) col_chart.set_title({"name": "최근 30일 신규/변경", "name_font": {"name": FONT, "size": 11}}) col_chart.set_x_axis({"num_format": "mm-dd", "num_font": {"name": FONT, "size": 8, "rotation": -45}}) col_chart.set_y_axis({"num_format": "#,##0", "major_gridlines": {"visible": True, "line": {"width": 0.75, "dash_type": "dash"}}}) col_chart.set_legend({"position": "bottom", "font": {"name": FONT, "size": 9}}) col_chart.set_style(37) col_chart.set_size({"width": 560, "height": 240}) col_chart.show_blanks_as("zero") idx.insert_chart(23, 7, col_chart, {"x_offset": 4, "y_offset": 4}) ``` 호출: ```python add_dashboard( wb, idx, "05_추이", {"신규": len(DATA["01_신규"]), "변경": len(DATA["02_변경"]), "취하": len(DATA["03_취하"])}, {"title": f_title, "sub": f_sub, "int": f_int, "text": f_text}, ) ``` ### 11.3 스니펫 3 — 원자적 저장 + 파일 잠금 처리 ```python """safe_write.py — Excel 이 파일을 잡고 있어도 데이터를 잃지 않는 저장 루틴.""" from __future__ import annotations import os import shutil import tempfile import time from pathlib import Path from typing import Callable class ExcelLockedError(RuntimeError): """대상 파일이 Excel 등에 의해 잠겨 있어 교체할 수 없음.""" def excel_lock_file(path: Path) -> Path: """Excel 이 만드는 숨김 잠금 파일 경로(~$name.xlsx)를 돌려준다.""" return path.with_name("~$" + path.name) def looks_locked(path: Path) -> bool: """빠른 사전 판단. 확정적이지 않으므로 실제 시도의 보조로만 쓴다.""" if excel_lock_file(path).exists(): return True if not path.exists(): return False try: # 쓰기 모드로 잠깐 열어본다. 열리면 잠겨 있지 않다. with open(path, "r+b"): return False except PermissionError: return True except OSError: return True def verify_xlsx(path: Path, min_bytes: int = 4096) -> None: """저장된 xlsx 가 실제로 온전한지 검사한다(크기 + zip 구조 + 시트 존재).""" import zipfile if not path.exists(): raise IOError(f"출력 파일이 생성되지 않았다: {path}") size = path.stat().st_size if size < min_bytes: raise IOError(f"출력 파일이 너무 작다({size} bytes): {path}") with zipfile.ZipFile(path) as zf: bad = zf.testzip() if bad is not None: raise IOError(f"손상된 zip 엔트리: {bad}") names = zf.namelist() if "xl/workbook.xml" not in names: raise IOError("xl/workbook.xml 이 없다. 올바른 xlsx 가 아니다.") def atomic_write_xlsx( dest: str | os.PathLike, builder: Callable[[str], None], retries: int = 6, base_delay: float = 2.0, fallback: bool = True, ) -> Path: """builder(tmp_path) 로 임시 파일을 만든 뒤 dest 로 원자적 교체한다. - 쓰기 도중 죽어도 dest 는 이전 상태 그대로 남는다. - dest 가 Excel 에 잠겨 있으면 지수 백오프로 재시도한다. - 끝까지 실패하면 fallback=True 일 때 '이름 (1).xlsx' 로 저장하고 그 경로를 반환한다. """ dest = Path(dest).resolve() dest.parent.mkdir(parents=True, exist_ok=True) # 같은 볼륨에 임시 파일을 만들어야 os.replace 가 원자적이다. fd, tmp_name = tempfile.mkstemp(prefix=".~dmf_", suffix=".xlsx", dir=str(dest.parent)) os.close(fd) tmp_path = Path(tmp_name) try: builder(str(tmp_path)) # 실제 xlsx 생성 (xlsxwriter 등) verify_xlsx(tmp_path) last_err: Exception | None = None for attempt in range(retries): if looks_locked(dest): last_err = ExcelLockedError(f"{dest.name} 이(가) 열려 있는 것으로 보인다") else: try: os.replace(tmp_path, dest) # 원자적 교체 (같은 볼륨) return dest except PermissionError as e: last_err = e delay = base_delay * (2 ** attempt) print(f"[safe_write] 잠김 감지, {delay:.0f}s 후 재시도 " f"({attempt + 1}/{retries}) — {dest.name}") time.sleep(delay) if not fallback: raise ExcelLockedError(str(last_err)) # 폴백 파일명으로 저장 for i in range(1, 100): alt = dest.with_name(f"{dest.stem} ({i}){dest.suffix}") if not alt.exists(): shutil.move(str(tmp_path), str(alt)) print(f"[safe_write] 원본이 잠겨 있어 대체 파일로 저장했다: {alt}") return alt raise ExcelLockedError("폴백 파일명을 100개까지 시도했으나 모두 실패했다") finally: if tmp_path.exists(): try: tmp_path.unlink() except OSError: pass def read_locked_safe(path: str | os.PathLike): """잠겨 있을 수 있는 xlsx 를 복사본으로 읽는다.""" from openpyxl import load_workbook src = Path(path) with tempfile.TemporaryDirectory() as td: copy = Path(td) / src.name shutil.copy2(src, copy) # 복사는 잠긴 파일에도 성공하는 경우가 많다 wb = load_workbook(copy, read_only=True, data_only=True) try: return [[c.value for c in row] for row in wb.active.rows] finally: wb.close() if __name__ == "__main__": import build_report # 11.1 의 모듈 final = atomic_write_xlsx( "reports/DMF_리포트_최신.xlsx", builder=lambda p: build_report.build(p), ) print("saved:", final) ``` 핵심 포인트: - `tempfile.mkstemp(dir=dest.parent)` — **같은 볼륨**에 임시 파일을 만들어야 `os.replace` 가 원자적이다. `%TEMP%` 가 다른 드라이브면 원자성이 깨진다. - `verify_xlsx()` 로 **교체 전에** 결과물을 검증한다. xlsxwriter `close()` 실패로 손상 파일이 만들어진 사례(issue #583)에 대한 방어다. - `looks_locked()` 는 `~$` 잠금 파일 + `r+b` 열기 시도의 이중 검사다. - 폴백 저장 후에는 Windows 토스트 알림으로 사용자에게 "원본이 열려 있어 대체 파일에 저장했다"고 알린다(별도 축의 알림 모듈 연동). ### 11.4 스니펫 4 — LibreOffice headless 재계산 + 오류 스캔 (Windows) ```python """recalc.py — LibreOffice headless 로 xlsx 의 모든 수식을 재계산하고 오류를 스캔한다. 사용: python recalc.py report.xlsx [timeout_seconds] 종료 코드: 0 = 재계산 성공 (오류가 발견돼도 0. status 를 반드시 확인할 것) 1 = 재계산 자체가 실패 (soffice 없음/타임아웃/파일 없음) """ from __future__ import annotations import json import os import shutil import subprocess import sys import tempfile from pathlib import Path EXCEL_ERRORS = ["#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A"] WINDOWS_CANDIDATES = [ r"C:\Program Files\LibreOffice\program\soffice.exe", r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", r"C:\Program Files\LibreOffice 7\program\soffice.exe", ] def find_soffice() -> str | None: """soffice 실행 파일을 찾는다. PATH → 알려진 Windows 경로 순.""" env = os.environ.get("SOFFICE_PATH") if env and Path(env).exists(): return env which = shutil.which("soffice") or shutil.which("soffice.exe") if which: return which for cand in WINDOWS_CANDIDATES: if Path(cand).exists(): return cand return None def recalc_via_convert(src: Path, soffice: str, timeout: int) -> Path: """--convert-to xlsx 로 다시 써서 수식 캐시를 채운다. LibreOffice 는 파일을 열 때 수식을 계산하고, 저장할 때 그 값을 캐시에 쓴다. 출력은 --outdir 에 원본과 같은 이름으로 떨어지므로 임시 디렉터리를 쓴다. """ outdir = Path(tempfile.mkdtemp(prefix="lo_recalc_")) cmd = [ soffice, "--headless", "--norestore", "--nolockcheck", "--nodefault", "--nofirststartwizard", "--convert-to", "xlsx:Calc MS Excel 2007 XML", "--outdir", str(outdir), str(src), ] proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) produced = outdir / src.name if not produced.exists() or produced.stat().st_size == 0: raise RuntimeError( f"LibreOffice 변환 실패 (rc={proc.returncode}). " f"stdout={proc.stdout.strip()[:400]} stderr={proc.stderr.strip()[:400]}" ) return produced def scan_errors(path: Path) -> dict: """재계산된 파일에서 Excel 오류 문자열을 찾는다.""" from openpyxl import load_workbook wb = load_workbook(path, data_only=True, read_only=True) total_cells = 0 summary: dict[str, list[str]] = {e: [] for e in EXCEL_ERRORS} truncated: dict[str, int] = {e: 0 for e in EXCEL_ERRORS} try: for ws in wb.worksheets: for row in ws.iter_rows(): for cell in row: v = cell.value total_cells += 1 if isinstance(v, str): for err in EXCEL_ERRORS: if err in v: loc = f"{ws.title}!{cell.coordinate}" if len(summary[err]) < 100: summary[err].append(loc) else: truncated[err] += 1 finally: wb.close() summary = {k: v for k, v in summary.items() if v} truncated = {k: v for k, v in truncated.items() if v} total_errors = sum(len(v) for v in summary.values()) + sum(truncated.values()) return { "status": "errors_found" if total_errors else "success", "scanned_cells": total_cells, "total_errors": total_errors, "error_summary": summary, "locations_truncated": truncated, } def main() -> int: if len(sys.argv) < 2: print(json.dumps({"error": "usage: python recalc.py file.xlsx [timeout]"}, ensure_ascii=False)) return 1 src = Path(sys.argv[1]).resolve() timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 120 if not src.exists(): print(json.dumps({"error": f"파일이 없다: {src}"}, ensure_ascii=False)) return 1 soffice = find_soffice() if not soffice: print(json.dumps({"error": "soffice 를 찾을 수 없다. LibreOffice 를 설치하거나 " "SOFFICE_PATH 환경변수를 설정하라."}, ensure_ascii=False)) return 1 try: produced = recalc_via_convert(src, soffice, timeout) except subprocess.TimeoutExpired: print(json.dumps({"error": f"LibreOffice 가 {timeout}s 안에 끝나지 않았다"}, ensure_ascii=False)) return 1 except Exception as e: # noqa: BLE001 print(json.dumps({"error": str(e)}, ensure_ascii=False)) return 1 # 원자적으로 원본 자리에 덮어쓴다 tmp_final = src.with_suffix(".recalc.tmp.xlsx") shutil.move(str(produced), str(tmp_final)) os.replace(tmp_final, src) result = scan_errors(src) result["soffice"] = soffice print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 # errors_found 도 0 이다 if __name__ == "__main__": raise SystemExit(main()) ``` 운영 규칙(참조 구현에서 그대로 가져온 것): - **`error` 키가 오면 아무것도 재계산되지 않은 것이며, 이때만 non-zero exit.** `errors_found` 는 exit 0 이므로 **종료 코드로 워크북 건전성을 판단하면 안 된다.** 반드시 JSON 의 `status` 를 확인한다. - 오류가 없다는 것은 **수식이 평가된다는 뜻이지 옳다는 뜻이 아니다.** - 외부 링크가 있는 워크북에는 실행하지 않는다(링크가 파괴되고 `#NAME?` 이 박힌다). ### 11.5 스니펫 5 — openpyxl 로 목차 시트 + 내부 하이퍼링크 + 조건부 서식 + 틀 고정 xlsxwriter 를 못 쓰는 상황(기존 템플릿에 데이터만 채워야 하는 경우)을 위한 완결 예제다. ```python """openpyxl_report.py — openpyxl 만으로 목차·링크·조건부서식·틀고정 리포트를 만든다.""" from __future__ import annotations import datetime as dt import unicodedata from openpyxl import Workbook from openpyxl.formatting.rule import (CellIsRule, ColorScaleRule, DataBarRule, IconSetRule, Rule) from openpyxl.styles import Alignment, Border, Font, NamedStyle, PatternFill, Side from openpyxl.styles.differential import DifferentialStyle from openpyxl.utils import absolute_coordinate, get_column_letter, quote_sheetname from openpyxl.workbook.defined_name import DefinedName from openpyxl.worksheet.datavalidation import DataValidation from openpyxl.worksheet.properties import PageSetupProperties from openpyxl.worksheet.table import Table, TableStyleInfo FONT = "Malgun Gothic" BLUE, GREEN, ORANGE, VERM = "0072B2", "009E73", "E69F00", "D55E00" HEADER = ["연번", "등록번호", "품목명", "업체명", "등록일자", "상태", "건수"] _EAW_KO = {"F": 1.8, "H": 1.0, "W": 1.8, "Na": 1.0, "A": 1.2, "N": 1.0} def display_width(text): if text is None: return 0.0 return sum(_EAW_KO.get(unicodedata.east_asian_width(ch), 1.0) for ch in str(text)) def autofit(ws, min_w=8.0, max_w=48.0, margin=2.0): """openpyxl 에는 auto-fit 이 없으므로 직접 계산한다.""" widths = {} for row in ws.iter_rows(): for cell in row: if cell.value is None: continue letter = cell.column_letter w = display_width(cell.value) if w > widths.get(letter, 0.0): widths[letter] = w for letter, w in widths.items(): ws.column_dimensions[letter].width = max(min_w, min(max_w, w + margin)) def make_styles(wb): link = NamedStyle(name="LinkKo") link.font = Font(name=FONT, size=10, color="FF0072B2", underline="single") link.alignment = Alignment(vertical="center") wb.add_named_style(link) head = NamedStyle(name="HeadKo") thin = Side(border_style="thin", color="0A5A8C") head.font = Font(name=FONT, size=10, bold=True, color="FFFFFFFF") head.fill = PatternFill(fill_type="solid", fgColor=BLUE) head.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) head.border = Border(top=thin, left=thin, right=thin, bottom=thin) wb.add_named_style(head) body = NamedStyle(name="BodyKo") body.font = Font(name=FONT, size=10) body.alignment = Alignment(vertical="center") wb.add_named_style(body) return link, head, body def write_sheet(wb, title, tab_color, rows): ws = wb.create_sheet(title) ws.sheet_properties.tabColor = tab_color ws.sheet_view.showGridLines = False ws.sheet_view.zoomScale = 100 # 1행: 목차 복귀 링크 ws["A1"] = "← 목차로" ws["A1"].hyperlink = "#'00_목차'!A1" ws["A1"].style = "LinkKo" ws["C1"] = f"{title} · {len(rows)}건" ws["C1"].font = Font(name=FONT, size=14, bold=True, color=f"FF{BLUE}") ws.row_dimensions[1].height = 28 # 3행: 헤더 for c, h in enumerate(HEADER, start=1): cell = ws.cell(row=3, column=c, value=h) cell.style = "HeadKo" ws.row_dimensions[3].height = 30 # 4행부터 데이터 for r, row in enumerate(rows, start=4): for c, v in enumerate(row, start=1): cell = ws.cell(row=r, column=c, value=v) cell.style = "BodyKo" if c == 5: cell.number_format = "yyyy-mm-dd" cell.alignment = Alignment(horizontal="center", vertical="center") if c in (1, 7): cell.number_format = "#,##0" cell.alignment = Alignment(horizontal="right", vertical="center") ws.row_dimensions[r].height = 18 last = 3 + len(rows) last_col_letter = get_column_letter(len(HEADER)) ref = f"A3:{last_col_letter}{last}" # Excel 표 (자동필터 + 줄무늬가 함께 붙는다) tab = Table(displayName=f"T_{title.split('_')[1]}", ref=ref) tab.tableStyleInfo = TableStyleInfo(name="TableStyleLight9", showFirstColumn=False, showLastColumn=False, showRowStripes=True, showColumnStripes=False) ws.add_table(tab) # 틀 고정: 3행 아래 + A열 오른쪽 ws.freeze_panes = "B4" # ---- 조건부 서식 ---- body_ref = f"A4:{last_col_letter}{last}" for status, bg in (("신규", "D9F0E7"), ("변경", "FBEBD1"), ("취하", "F7DCD0")): dxf = DifferentialStyle(fill=PatternFill(bgColor=bg)) rule = Rule(type="expression", dxf=dxf, stopIfTrue=False) rule.formula = [f'$F4="{status}"'] # 열 절대, 행 상대(범위 첫 행 기준) ws.conditional_formatting.add(body_ref, rule) # 중복 등록번호 강조 dup = Rule(type="duplicateValues", dxf=DifferentialStyle(fill=PatternFill(bgColor="FFF2CC"))) ws.conditional_formatting.add(f"B4:B{last}", dup) # 건수 열 데이터 막대 + 색 스케일 + 아이콘 ws.conditional_formatting.add( f"G4:G{last}", DataBarRule(start_type="min", end_type="max", color="FF638EC6", showValue=None, minLength=None, maxLength=None)) ws.conditional_formatting.add( f"A4:A{last}", ColorScaleRule(start_type="min", start_color="FFFFFFFF", end_type="max", end_color="FF56B4E9")) ws.conditional_formatting.add( f"G4:G{last}", IconSetRule("3TrafficLights1", "percent", [0, 33, 67], showValue=None, percent=None, reverse=None)) # 건수가 30 이상이면 빨강 ws.conditional_formatting.add( f"G4:G{last}", CellIsRule(operator="greaterThanOrEqual", formula=["30"], stopIfTrue=False, fill=PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid"))) # 인쇄 설정 ws.page_setup.orientation = ws.ORIENTATION_LANDSCAPE ws.page_setup.paperSize = 9 # A4 ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True, autoPageBreaks=False) ws.page_setup.fitToWidth = 1 ws.page_setup.fitToHeight = 0 ws.print_title_rows = "1:3" ws.print_area = ref ws.print_options.horizontalCentered = True ws.oddHeader.center.text = f"DMF 일일 리포트 — {title}" ws.oddFooter.right.text = "Page &[Page] of &N" autofit(ws) return ws def main(): wb = Workbook() wb.remove(wb.active) # 기본 'Sheet' 제거 make_styles(wb) today = dt.date(2026, 9, 2) def rows_for(status, n): return [[i, f"DMF-2026-{i:05d}", f"성분 {i}", f"(주)업체 {i}", today - dt.timedelta(days=i), status, (i * 7) % 45] for i in range(1, n + 1)] data = { "01_신규": (GREEN, rows_for("신규", 24)), "02_변경": (ORANGE, rows_for("변경", 11)), "03_취하": (VERM, rows_for("취하", 4)), } # 목차를 먼저 만들어 탭 순서를 맞춘다 idx = wb.create_sheet("00_목차", 0) idx.sheet_properties.tabColor = BLUE idx.sheet_view.showGridLines = False idx["B2"] = "원료의약품 등록(DMF) 일일 리포트" idx["B2"].font = Font(name=FONT, size=16, bold=True, color=f"FF{BLUE}") idx["B3"] = f"생성 {dt.datetime.now():%Y-%m-%d %H:%M} · 출처 nedrug.mfds.go.kr" idx["B3"].font = Font(name=FONT, size=9, color="FF666666") idx.row_dimensions[2].height = 34 for c, h in enumerate(["시트", "설명", "건수"], start=2): cell = idx.cell(row=5, column=c, value=h) cell.style = "HeadKo" for i, (name, (color, rows)) in enumerate(data.items()): write_sheet(wb, name, color, rows) r = 6 + i link_cell = idx.cell(row=r, column=2, value=name) link_cell.hyperlink = f"#'{name}'!A1" # 내부 링크 link_cell.style = "LinkKo" idx.cell(row=r, column=3, value=f"{name} 상세").style = "BodyKo" cnt = idx.cell(row=r, column=4) cnt.value = f"=COUNTA('{name}'!$B$4:$B$100000)" # 캐시값은 넣을 수 없다 cnt.style = "BodyKo" cnt.number_format = "#,##0" # ---- 메타 시트 + 드롭다운 원본 ---- meta = wb.create_sheet("99_메타") meta.sheet_properties.tabColor = "7F7F7F" meta["A1"] = "← 목차로" meta["A1"].hyperlink = "#'00_목차'!A1" meta["A1"].style = "LinkKo" for r, v in enumerate(["신규", "변경", "취하", "유지"], start=4): meta.cell(row=r, column=5, value=v).style = "BodyKo" # 다른 시트 범위를 원본으로 하는 드롭다운 dv = DataValidation( type="list", formula1=f"{quote_sheetname('99_메타')}!$E$4:$E$7", allow_blank=True, ) dv.error = "목록에 없는 값이다" dv.errorTitle = "잘못된 입력" dv.prompt = "신규 / 변경 / 취하 / 유지 중에서 고르라" dv.promptTitle = "상태 선택" ws_new = wb["01_신규"] ws_new.add_data_validation(dv) dv.add(f"F4:F{3 + len(data['01_신규'][1])}") # 범위 없이 두면 저장 시 사라진다 # ---- 정의된 이름 ---- ws_all = wb["01_신규"] ref = f"{quote_sheetname(ws_all.title)}!{absolute_coordinate('F4:F100000')}" wb.defined_names.add(DefinedName("현황_상태", attr_text=ref)) autofit(idx) idx.sheet_view.tabSelected = True wb.active = wb.index(idx) wb.save("DMF_리포트_openpyxl.xlsx") print("saved: DMF_리포트_openpyxl.xlsx") if __name__ == "__main__": main() ``` > 이 스니펫의 `COUNTA(...)` 수식은 **캐시값이 없어 Excel 로 열기 전까지 `None` 으로 읽힌다.** openpyxl 경로를 택하면 6장의 LibreOffice 재계산이 사실상 필수가 된다. 그래서 이 프로젝트는 xlsxwriter 를 기본으로 택했다. ### 11.6 스니펫 6 — 배열 수식과 미래 함수를 안전하게 다루는 헬퍼 ```python """formula_utils.py — 수식 문자열을 엔진 특성에 맞게 정규화한다.""" from __future__ import annotations # Excel 2010 이후 도입돼 _xlfn. 접두어가 필요한 '미래 함수' (문서에서 확인된 것들) FUTURE_FUNCTIONS = { "STDEV.S", "CONFIDENCE.NORM", "TEXTJOIN", "FILTER", "UNIQUE", "XLOOKUP", "SORT", "SORTBY", "XMATCH", "SEQUENCE", "RANDARRAY", "ANCHORARRAY", "LAMBDA", "LET", } def add_xlfn_prefix(formula: str) -> str: """미래 함수 앞에 _xlfn. 을 붙인다. 이미 붙어 있으면 그대로 둔다. xlsxwriter 는 Workbook(..., {'use_future_functions': True}) 로 자동 처리하지만, openpyxl 에는 자동 처리가 없어 이 함수가 필요하다. """ out = formula for fn in sorted(FUTURE_FUNCTIONS, key=len, reverse=True): if f"_xlfn.{fn}" in out: continue out = out.replace(f"{fn}(", f"_xlfn.{fn}(") return out def assert_formula_sane(formula: str) -> None: """openpyxl/xlsxwriter 공통 수식 규칙을 검사한다.""" if not formula.startswith("="): raise ValueError(f"수식은 '=' 로 시작해야 한다: {formula!r}") if ";" in formula: raise ValueError( "인수 구분자는 반드시 쉼표여야 한다. 세미콜론은 Excel 이 거부한다: " f"{formula!r}" ) if "{" in formula or "}" in formula: raise ValueError( "배열 수식의 중괄호는 코드에 넣지 않는다. " "openpyxl 은 ArrayFormula(범위, 수식) 를 쓰고, " f"xlsxwriter 는 write_array_formula 를 쓴다: {formula!r}" ) def known_function_check(formula: str) -> list[str]: """openpyxl 이 아는 함수 목록(FORMULAE)에 없는 이름을 돌려준다.""" import re from openpyxl.utils import FORMULAE names = set(re.findall(r"([A-Z][A-Z0-9_.]*)\s*\(", formula.upper())) unknown = [] for n in names: base = n.replace("_XLFN.", "") if base not in FORMULAE and base not in {f.upper() for f in FUTURE_FUNCTIONS}: unknown.append(n) return unknown # ------------------------------------------------------------------ 배열 수식 def write_legacy_array(ws, anchor: str, ref: str, formula: str) -> None: """openpyxl 로 레거시 CSE 배열 수식을 쓴다. ref 의 좌상단 셀과 anchor 가 반드시 같아야 한다. """ from openpyxl.worksheet.formula import ArrayFormula top_left = ref.split(":")[0] if top_left != anchor: raise ValueError(f"anchor({anchor}) 와 ref 좌상단({top_left}) 이 달라야 한다") assert_formula_sane(formula) ws[anchor] = ArrayFormula(ref, formula) if __name__ == "__main__": f = "=XLOOKUP($D$6, 현황_등록번호, 현황_품목명)" assert_formula_sane(f) print(add_xlfn_prefix(f)) # -> =_xlfn.XLOOKUP($D$6, 현황_등록번호, 현황_품목명) ``` --- ## 12. 참고 라이브러리·템플릿 프로젝트 리서치에서 실제로 열어 확인한 프로젝트들이다. **어느 것도 이 프로젝트의 필수 의존성이 아니다** — 참고용이며, 필요하면 아이디어만 가져온다. | 프로젝트 | 요약 | 지표 | 라이선스 | 판정 | |---|---|---|---|---| | [xpyxl](https://github.com/dakixr/xpyxl) | 선언적 문법으로 스타일링된 엑셀 리포트 생성. `row`/`col`/`cell`/`table`/`vstack`/`hstack` 프리미티브, Tailwind 풍 유틸리티 스타일 클래스, 3개 렌더링 엔진(hybrid 기본 / openpyxl / xlsxwriter), 시트 임포트(hybrid·openpyxl), Chromium 또는 ReportLab 으로 PDF/PNG 내보내기. 전체 타입 힌트, 순수 파이썬, CI diff 에 적합한 결정적 렌더링 | **⭐ 3**, 커밋 57, 이슈/PR 0 | 미표시 | 채택 안 함 (스타 3, 너무 이름) | | [openpyxl-templates](https://github.com/SverkerSbrg/openpyxl-templates) | `TemplatedWorkbook` + `TemplatedSheets` 로 엑셀 구조를 템플릿화. `TableSheet` 가 컬럼 정의·스타일링·엑셀 자료형 변환을 처리 | ⭐ 56, 포크 18, watcher 6, 커밋 148 | **MIT** | 채택 안 함 (베타 경고: *"This package is still in beta. The api may still be subject to change and the documentation is patchy."*) | | [openpyxl_style_writer](https://github.com/Zncl2222/openpyxl_style_writer) / [PyPI](https://pypi.org/project/openpyxl-style-writer) | openpyxl 래퍼. 재사용 가능한 스타일을 만들어 **write-only 모드에서도** 쓸 수 있게 해 준다 | — | — | 참고 (write_only 스타일링이 필요해지면) | | [xlsxwriter-tables](https://pypi.org/project/xlsxwriter-tables/) | xlsxwriter 표 생성 보조 | — | — | 참고 | | [polars `write_excel`](https://docs.pola.rs/docs/python/dev/reference/api/polars.DataFrame.write_excel.html) | polars DataFrame → xlsx (내부적으로 xlsxwriter) | — | — | 참고 (pandas 대신 polars 를 쓰게 되면) | xpyxl 사용 예(문서에서 확인): ```python import xpyxl as x report = ( x.workbook()[ x.sheet("Summary")[ x.row(style=[x.text_2xl, x.bold, x.text_blue])["Q3 Sales Overview"], x.row(style=[x.text_sm, x.text_gray])["Region", "Units", "Price"], x.row(style=[x.bg_primary, x.text_white, x.bold])["EMEA", 1200, 19.0], ] ] ) report.save("report.xlsx") ``` > **결론: 서드파티 리포트 프레임워크를 쓰지 않는다.** xpyxl 은 스타 3개로 검증되지 않았고, openpyxl-templates 는 베타이며 openpyxl 기반이라 차트 손실 문제를 그대로 물려받는다. 이 문서의 스니펫들로 직접 구현하는 편이 유지보수 가능하고 의존성이 적다. --- ## 부록 A. 출처 목록 리서치 과정에서 수집된 **모든 URL 197개**를 하나도 빠뜨리지 않고 기록한다. "확인여부" 열의 의미: - **FETCH ✅** — WebFetch 로 실제로 페이지를 열어 내용을 확인했다. 본문의 인용·코드는 여기서 나왔다. - **FETCH ❌(코드)** — 열려고 시도했으나 HTTP 오류로 실패했다. 내용은 이 문서에 반영되지 않았다. - **검색결과** — 검색 결과 목록에 등장했으나 직접 열지는 않았다. 참고용이며 내용 인용에 쓰지 않았다. ### A.1 실제로 열어 확인한 출처 (49건 시도, 44건 성공) | # | 제목 | URL | 확인여부 | |---|---|---|---| | F1 | Simple Formulae — openpyxl (수식, `_xlfn`, `ArrayFormula`, `FORMULAE`, `array_formulae`) | `https://openpyxl.readthedocs.io/en/stable/simple_formulae.html` | FETCH ✅ | | F2 | Defined Names — openpyxl (`DefinedName`, `wb.defined_names`, `destinations`) | `https://openpyxl.readthedocs.io/en/stable/defined_names.html` | FETCH ✅ | | F3 | Validating cells — openpyxl (`DataValidation`, `quote_sheetname`, `showDropDown`) | `https://openpyxl.readthedocs.io/en/stable/validation.html` | FETCH ✅ | | F4 | Print Settings — openpyxl (`page_setup`, `print_title_rows`, 헤더/푸터) | `https://openpyxl.readthedocs.io/en/stable/print_settings.html` | FETCH ✅ | | F5 | Conditional Formatting — openpyxl (`ColorScaleRule`/`DataBarRule`/`IconSetRule`/`CellIsRule`/`FormulaRule`/`Rule`) | `https://openpyxl.readthedocs.io/en/stable/formatting.html` | FETCH ✅ | | F6 | Additional Worksheet Properties — openpyxl (`tabColor`, `pageSetUpPr`, `outlinePr`, `sheet_view`) | `https://openpyxl.readthedocs.io/en/stable/worksheet_properties.html` | FETCH ✅ | | F7 | Tutorial — openpyxl (차트·도형 손실 경고, `keep_vba`, `data_only`) | `https://openpyxl.readthedocs.io/en/stable/tutorial.html` | FETCH ✅ | | F8 | Optimised Modes — openpyxl (`write_only`, `read_only`, `WriteOnlyCell`) | `https://openpyxl.readthedocs.io/en/stable/optimized.html` | FETCH ✅ | | F9 | Worksheet Tables — openpyxl (`Table`, `TableStyleInfo`, 헤더 문자열 규칙, 필터 강제) | `https://openpyxl.readthedocs.io/en/stable/worksheet_tables.html` | FETCH ✅ | | F10 | Charts Introduction — openpyxl (지원 차트 목록, 기본 앵커/크기) | `https://openpyxl.readthedocs.io/en/stable/charts/introduction.html` | FETCH ✅ | | F11 | Working with Sparklines — XlsxWriter (`add_sparkline` 옵션 전체) | `https://xlsxwriter.readthedocs.io/working_with_sparklines.html` | FETCH ✅ | | F12 | Working with Worksheet Tables — XlsxWriter (`add_table`, `total_row`, 구조적 참조) | `https://xlsxwriter.readthedocs.io/working_with_tables.html` | FETCH ✅ | | F13 | Working with Conditional Formats — XlsxWriter (18종 type, 옵션 키) | `https://xlsxwriter.readthedocs.io/working_with_conditional_formats.html` | FETCH ✅ | | F14 | Example: Dynamic array formulas — XlsxWriter (FILTER/UNIQUE/SORT/XLOOKUP/SEQUENCE) | `https://xlsxwriter.readthedocs.io/example_dynamic_arrays.html` | FETCH ✅ | | F15 | Example: Charts with Data Labels — XlsxWriter | `https://xlsxwriter.readthedocs.io/example_chart_data_labels.html` | FETCH ✅ | | F16 | pandas.ExcelWriter — 전체 시그니처, `mode`, `if_sheet_exists`, `engine_kwargs` | `https://pandas.pydata.org/docs/reference/api/pandas.ExcelWriter.html` | FETCH ✅ | | F17 | anthropics/skills — xlsx SKILL.md (recalc.py 운영 규칙, 외부 링크 경고) | `https://github.com/anthropics/skills/blob/main/skills/xlsx/SKILL.md` | FETCH ✅ | | F18 | Working with Formulas — XlsxWriter (`value` 캐시값, `use_future_functions`) | `https://xlsxwriter.readthedocs.io/working_with_formulas.html` | FETCH ✅ | | F19 | dakixr/xpyxl — 선언적 엑셀 리포트 라이브러리 (⭐3, 커밋 57) | `https://github.com/dakixr/xpyxl` | FETCH ✅ | | F20 | wikidocs — OpenPyXL로 자동 보고서 만들기 | `https://wikidocs.net/302402` | FETCH ❌ (403 Forbidden) | | F21 | Qiita — openpyxl 셀 폭 자동조정(일본어/동아시아 대응) | `https://qiita.com/Nomisugi/items/f451ad9f67d3e419c73b` | FETCH ✅ | | F22 | The Worksheet Class — XlsxWriter (`write_url`, `autofit`, `freeze_panes`, 그룹화, 인쇄) | `https://xlsxwriter.readthedocs.io/worksheet.html` | FETCH ✅ | | F23 | openpyxl-users — "On OpenPyXL and Dynamic Arrays!" (`cm="1"`, metadata.xml 5단계) | `https://groups.google.com/g/openpyxl-users/c/aacD5eRiP7w` | FETCH ✅ | | F24 | Working with Pandas — XlsxWriter (`writer.book`, `writer.sheets`, 헤더 오버라이드) | `https://xlsxwriter.readthedocs.io/working_with_pandas.html` | FETCH ✅ | | F25 | Working with Pandas and NumPy — openpyxl (`dataframe_to_rows`, 스트리밍) | `https://openpyxl.readthedocs.io/en/stable/pandas.html` | FETCH ✅ | | F26 | openpyxl.worksheet.hyperlink module (`Hyperlink` 파라미터) | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.worksheet.hyperlink.html` | FETCH ✅ | | F27 | Pie Charts — openpyxl (`DataPoint`, `explosion`, 그라디언트) | `https://openpyxl.readthedocs.io/en/stable/charts/pie.html` | FETCH ✅ | | F28 | Bar and Column Charts — openpyxl (전체 예제) | `https://openpyxl.readthedocs.io/en/stable/charts/bar.html` | FETCH ✅ | | F29 | openpyxl.worksheet.page module (`PrintPageSetup`/`PageMargins`/`PrintOptions` 속성) | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.worksheet.page.html` | FETCH ✅ | | F30 | Using filters and sorts — openpyxl (필터는 설정만 저장, 실제 필터링 안 함) | `https://openpyxl.readthedocs.io/en/stable/filters.html` | FETCH ✅ | | F31 | Working with styles — openpyxl (`Font`/`PatternFill`/`Border`/`Alignment`/`NamedStyle`, 불변성) | `https://openpyxl.readthedocs.io/en/stable/styles.html` | FETCH ✅ | | F32 | Example: Sparklines (Advanced) — XlsxWriter | `https://xlsxwriter.readthedocs.io/example_sparklines2.html` | FETCH ✅ | | F33 | Example: Doughnut Chart — XlsxWriter (`points`, `set_hole_size`, `set_rotation`) | `https://xlsxwriter.readthedocs.io/example_chart_doughnut.html` | FETCH ✅ | | F34 | anthropics/skills — xlsx/recalc.py 소스 | `https://github.com/anthropics/skills/blob/main/skills/xlsx/recalc.py` | FETCH ❌ (404 Not Found) | | F35 | The Format Class — XlsxWriter (메서드 목록, 포맷 불변성 경고) | `https://xlsxwriter.readthedocs.io/format.html` | FETCH ✅ | | F36 | python-forum.io — 기존 xlsx 저장 시 이미지/도형 소실 스레드 | `https://python-forum.io/thread-17562.html` | FETCH ❌ (403 Forbidden) | | F37 | pandas issue #52189 — `mode='a'` + `if_sheet_exists='overlay'` 예상 밖 동작 | `https://github.com/pandas-dev/pandas/issues/52189` | FETCH ✅ | | F38 | wikidocs — 엑셀 서식 설정하기 | `https://wikidocs.net/176169` | FETCH ❌ (403 Forbidden) | | F39 | SverkerSbrg/openpyxl-templates (⭐56, MIT, 베타 경고) | `https://github.com/SverkerSbrg/openpyxl-templates` | FETCH ✅ | | F40 | minyeamer — [OpenPyXL] 파이썬으로 엑셀 다루기 (한글 1.8배 폭 계수) | `https://minyeamer.github.io/blog/openpyxl-styles/` | FETCH ✅ | | F41 | Doughnut Charts — openpyxl (전체 예제, `DataPoint` 색상) | `https://openpyxl.readthedocs.io/en/stable/charts/doughnut.html` | FETCH ✅ | | F42 | ComposioHQ/awesome-claude-skills — xlsx/recalc.py (soffice Basic 매크로, 오류 스캔) | `https://github.com/ComposioHQ/awesome-claude-skills/blob/master/document-skills/xlsx/recalc.py` | FETCH ✅ | | F43 | openpyxl.chart.label module (`DataLabelList`, `dLblPos` 허용값) | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.chart.label.html` | FETCH ✅ | | F44 | Example: Pandas autofit — XlsxWriter | `https://xlsxwriter.readthedocs.io/example_pandas_autofit.html` | FETCH ❌ (404 Not Found) | | F45 | Chart Layout — openpyxl (`ManualLayout`, 범례 위치) | `https://openpyxl.readthedocs.io/en/stable/charts/chart_layout.html` | FETCH ✅ | | F46 | openpyxl-users — "Hyperlinks to another tab within the same workbook" | `https://groups.google.com/g/openpyxl-users/c/92JFYZEnyE8` | FETCH ✅ | | F47 | pythontutorials.net — Excel 파일 잠금(`~$` lock file, 배타 잠금) | `https://www.pythontutorials.net/blog/pd-read-excel-throws-permissionerror-if-file-is-open-in-excel/` | FETCH ✅ | | F48 | The Chart Class — XlsxWriter (`set_size`/`set_title`/`set_legend`/`combine`/`data_labels`) | `https://xlsxwriter.readthedocs.io/chart.html` | FETCH ✅ | | F49 | The Workbook Class — XlsxWriter (생성자 옵션, `define_name`, `set_calc_mode`, 신규 파일만 생성) | `https://xlsxwriter.readthedocs.io/workbook.html` | FETCH ✅ | ### A.2 검색 결과로 수집된 나머지 URL (148건) 아래 148건은 검색 결과 목록에 나타난 URL 전량이다. 직접 열지 않았으므로 이 문서의 사실 주장에 사용하지 않았지만, 후속 조사를 위해 하나도 버리지 않고 보존한다. | # | URL | 확인여부 | |---|---|---| | 1 | `http://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.ExcelWriter.html?highlight=excelwriter` | 검색결과 | | 2 | `https://automatetheboringstuff.com/2e/chapter13/` | 검색결과 | | 3 | `https://blog.pythonlibrary.org/2021/08/11/styling-excel-cells-with-openpyxl-and-python/` | 검색결과 | | 4 | `https://conceptviz.app/blog/okabe-ito-palette-hex-codes-complete-reference` | 검색결과 | | 5 | `https://conceptviz.app/blog/scientific-color-palette-for-research-papers-and-posters` | 검색결과 | | 6 | `https://docs.pola.rs/docs/python/dev/reference/api/polars.DataFrame.write_excel.html` | 검색결과 | | 7 | `https://docs.rs/rust_xlsxwriter/latest/rust_xlsxwriter/sparkline/index.html` | 검색결과 | | 8 | `https://en.wikipedia.org/wiki/Soffice.exe` | 검색결과 | | 9 | `https://fakeroot.sakura.ne.jp/weblog/2019/02/02/openpyxl%E3%81%A7autofit%E7%9A%84%E3%81%AA%E3%82%82%E3%81%AE/` | 검색결과 | | 10 | `https://figviz.com/blog/okabe-ito-palette-hex-codes-full-8-color-reference-with-code-examples-2026-wlt9th1n` | 검색결과 | | 11 | `https://formualizer.com/articles/openpyxl-calculate-formulas` | 검색결과 | | 12 | `https://forums.tomsguide.com/threads/hyperlink-in-excel-wont-open.56228/post-382391` | 검색결과 | | 13 | `https://gaussian37.github.io/python-etc-openpyxl/` | 검색결과 | | 14 | `https://gist.github.com/tnhu/33a762d8c76aad25189114dffd9df6a8` | 검색결과 | | 15 | `https://github.com/ComposioHQ/awesome-claude-skills/blob/master/document-skills/xlsx/recalc.py?plain=1` | 검색결과 | | 16 | `https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/scripts/libreoffice_recalc.py?plain=1` | 검색결과 | | 17 | `https://github.com/Zncl2222/openpyxl_style_writer` | 검색결과 | | 18 | `https://github.com/jmcnamara/XlsxWriter/blob/main/dev/docs/source/working_with_tables.rst` | 검색결과 | | 19 | `https://github.com/jmcnamara/XlsxWriter/blob/master/dev/docs/source/working_with_sparklines.rst` | 검색결과 | | 20 | `https://github.com/jmcnamara/XlsxWriter/blob/master/dev/docs/source/working_with_tables.rst` | 검색결과 | | 21 | `https://github.com/jmcnamara/XlsxWriter/blob/master/examples/chart_doughnut.py` | 검색결과 | | 22 | `https://github.com/jmcnamara/XlsxWriter/blob/master/examples/sparklines1.py` | 검색결과 | | 23 | `https://github.com/jmcnamara/XlsxWriter/blob/master/examples/sparklines2.py` | 검색결과 | | 24 | `https://github.com/jmcnamara/XlsxWriter/issues/435` | 검색결과 | | 25 | `https://github.com/jmcnamara/XlsxWriter/issues/583` | 검색결과 | | 26 | `https://github.com/jolny/LibreOfficeConverter` | 검색결과 | | 27 | `https://github.com/pandas-dev/pandas/issues/41778` | 검색결과 | | 28 | `https://github.com/pandas-dev/pandas/pull/11102` | 검색결과 | | 29 | `https://github.com/pandas-dev/pandas/pull/42222/files` | 검색결과 | | 30 | `https://github.com/pandas-dev/pandas/pull/62994.diff` | 검색결과 | | 31 | `https://github.com/scivision/office-headless` | 검색결과 | | 32 | `https://github.com/topics/openpyxl` | 검색결과 | | 33 | `https://glama.ai/mcp/servers/@mdz-axo/pt-mcp/blob/6fe5189927318535f6d368eb21b2d6bddd75139f/node_modules/eastasianwidth/README.md` | 검색결과 | | 34 | `https://groups.google.com/g/comp.lang.python/c/t2MuyjsyWPI` | 검색결과 | | 35 | `https://groups.google.com/g/openpyxl-users/c/-3Onhs5vq8A` | 검색결과 | | 36 | `https://groups.google.com/g/openpyxl-users/c/GbBOnOa8g7Y` | 검색결과 | | 37 | `https://groups.google.com/g/openpyxl-users/c/f8QAK_YuD7M` | 검색결과 | | 38 | `https://groups.google.com/g/openpyxl-users/c/rsy8W2epzVs` | 검색결과 | | 39 | `https://groups.google.com/g/python-excel/c/0vWPLht7K64` | 검색결과 | | 40 | `https://hatchjs.com/openpyxl-autofit-column-width/` | 검색결과 | | 41 | `https://honors.indianapolis.iu.edu/application-review/PHPExcel/Documentation/API/classes/PHPExcel_Cell_Hyperlink.html` | 검색결과 | | 42 | `https://huggingface.co/spaces/Shami96/NHVAS_Quote_Generator/commit/e2dfb92f4fc581e3dd6cb3291fcfbfb9ecef2f4b` | 검색결과 | | 43 | `https://jhbj.readthedocs.io/en/latest/api/openpyxl.workbook.defined_name.html` | 검색결과 | | 44 | `https://jingwen-z.github.io/writing-dataframes-into-an-excel-template/` | 검색결과 | | 45 | `https://learn.microsoft.com/en-us/answers/questions/1335579/slicer-issue-in-combination-with-dynamic-array` | 검색결과 | | 46 | `https://learn.microsoft.com/en-us/answers/questions/5139910/excel-filter-function-filter-based-on-an-array-of` | 검색결과 | | 47 | `https://learn.microsoft.com/en-us/javascript/api/excel/excel.rangehyperlink?view=excel-js-1.9` | 검색결과 | | 48 | `https://lua-users.org/lists/lua-l/2016-12/msg00209.html` | 검색결과 | | 49 | `https://lua-users.org/lists/lua-l/2016-12/msg00213.html` | 검색결과 | | 50 | `https://medium.com/@alice.yang_10652/add-update-extract-or-delete-hyperlinks-in-excel-with-python-168efce7f73d` | 검색결과 | | 51 | `https://medium.com/@rodney_ragan/xlsxwriter-simply-writing-excel-files-in-python-ed9673fc3a8d` | 검색결과 | | 52 | `https://namu.wiki/w/%EB%A7%91%EC%9D%80%20%EA%B3%A0%EB%94%95` | 검색결과 | | 53 | `https://namu.wiki/w/%EB%A7%91%EC%9D%80%20%EA%B3%A0%EB%94%95?uuid=5ad94048-c370-45a6-83e4-2e63c0ba662e` | 검색결과 | | 54 | `https://openpyxl-templates.readthedocs.io/en/latest/` | 검색결과 | | 55 | `https://openpyxl-templates.readthedocs.io/en/latest/quick_start.html` | 검색결과 | | 56 | `https://openpyxl.pages.heptapod.net/openpyxl/defined_names.html` | 검색결과 | | 57 | `https://openpyxl.pages.heptapod.net/openpyxl/formatting.html` | 검색결과 | | 58 | `https://openpyxl.readthedocs.io/en/2.4/styles.html` | 검색결과 | | 59 | `https://openpyxl.readthedocs.io/en/2.4/usage.html` | 검색결과 | | 60 | `https://openpyxl.readthedocs.io/en/2.4/validation.html` | 검색결과 | | 61 | `https://openpyxl.readthedocs.io/en/2.5/formatting.html` | 검색결과 | | 62 | `https://openpyxl.readthedocs.io/en/2.5/styles.html` | 검색결과 | | 63 | `https://openpyxl.readthedocs.io/en/2.5/validation.html` | 검색결과 | | 64 | `https://openpyxl.readthedocs.io/en/2.6/_modules/openpyxl/worksheet/hyperlink.html` | 검색결과 | | 65 | `https://openpyxl.readthedocs.io/en/2.6/styles.html` | 검색결과 | | 66 | `https://openpyxl.readthedocs.io/en/2.6/usage.html` | 검색결과 | | 67 | `https://openpyxl.readthedocs.io/en/3.0/formatting.html` | 검색결과 | | 68 | `https://openpyxl.readthedocs.io/en/3.0/usage.html` | 검색결과 | | 69 | `https://openpyxl.readthedocs.io/en/3.0/validation.html` | 검색결과 | | 70 | `https://openpyxl.readthedocs.io/en/3.1.1/_modules/openpyxl/formatting/rule.html` | 검색결과 | | 71 | `https://openpyxl.readthedocs.io/en/3.1.1/api/openpyxl.workbook.defined_name.html` | 검색결과 | | 72 | `https://openpyxl.readthedocs.io/en/3.1.1/formatting.html` | 검색결과 | | 73 | `https://openpyxl.readthedocs.io/en/3.1.2/api/openpyxl.workbook.defined_name.html` | 검색결과 | | 74 | `https://openpyxl.readthedocs.io/en/3.1.2/formatting.html` | 검색결과 | | 75 | `https://openpyxl.readthedocs.io/en/3.1.2/simple_formulae.html` | 검색결과 | | 76 | `https://openpyxl.readthedocs.io/en/3.1.3/_modules/openpyxl/workbook/defined_name.html` | 검색결과 | | 77 | `https://openpyxl.readthedocs.io/en/3.1.3/formatting.html` | 검색결과 | | 78 | `https://openpyxl.readthedocs.io/en/3.1/_modules/openpyxl/worksheet/page.html` | 검색결과 | | 79 | `https://openpyxl.readthedocs.io/en/3.1/_sources/validation.rst.txt` | 검색결과 | | 80 | `https://openpyxl.readthedocs.io/en/3.1/api/openpyxl.workbook.defined_name.html` | 검색결과 | | 81 | `https://openpyxl.readthedocs.io/en/3.1/api/openpyxl.worksheet.page.html` | 검색결과 | | 82 | `https://openpyxl.readthedocs.io/en/3.1/defined_names.html` | 검색결과 | | 83 | `https://openpyxl.readthedocs.io/en/3.1/formatting.html` | 검색결과 | | 84 | `https://openpyxl.readthedocs.io/en/3.1/print_settings.html` | 검색결과 | | 85 | `https://openpyxl.readthedocs.io/en/3.1/simple_formulae.html` | 검색결과 | | 86 | `https://openpyxl.readthedocs.io/en/3.1/styles.html` | 검색결과 | | 87 | `https://openpyxl.readthedocs.io/en/3.1/validation.html` | 검색결과 | | 88 | `https://openpyxl.readthedocs.io/en/latest/_modules/openpyxl/worksheet/datavalidation.html` | 검색결과 | | 89 | `https://openpyxl.readthedocs.io/en/latest/api/openpyxl.formatting.rule.html` | 검색결과 | | 90 | `https://openpyxl.readthedocs.io/en/stable/_modules/openpyxl/worksheet/page.html` | 검색결과 | | 91 | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.formatting.rule.html` | 검색결과 | | 92 | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.workbook.defined_name.html` | 검색결과 | | 93 | `https://openpyxl.readthedocs.io/en/stable/api/openpyxl.worksheet.datavalidation.html` | 검색결과 | | 94 | `https://pandas.pydata.org/docs/dev/reference/api/pandas.ExcelWriter.html` | 검색결과 | | 95 | `https://pandas.pydata.org/pandas-docs/dev/reference/api/pandas.ExcelWriter.html` | 검색결과 | | 96 | `https://pandas.pydata.org/pandas-docs/dev/reference/api/pandas.ExcelWriter.html?highlight=io+excel+read_excel` | 검색결과 | | 97 | `https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.ExcelWriter.html` | 검색결과 | | 98 | `https://pandas.pydata.org/pandas-docs/version/1.5/reference/api/pandas.ExcelWriter.html` | 검색결과 | | 99 | `https://prosperocoder.com/posts/science-with-python/openpyxl-part-10-freezing-rows-and-columns/` | 검색결과 | | 100 | `https://pypi.org/project/openpyxl-style-writer` | 검색결과 | | 101 | `https://pypi.org/project/openpyxl-templates/` | 검색결과 | | 102 | `https://pypi.org/project/openpyxl/1.8.0/` | 검색결과 | | 103 | `https://pypi.org/project/openpyxl/1.8.2/` | 검색결과 | | 104 | `https://pypi.org/project/openpyxl/1.8.3/` | 검색결과 | | 105 | `https://pypi.org/project/openpyxl/1.8.6` | 검색결과 | | 106 | `https://pypi.org/project/xlsxwriter-tables/` | 검색결과 | | 107 | `https://python-excel-automation.com/getting-started-with-python-excel-automation/using-openpyxl-for-excel-file-manipulation/read-cell-value-from-excel-with-openpyxl/` | 검색결과 | | 108 | `https://pytutorial.com/python-openpyxl-hyperlinks-external-references-guide/` | 검색결과 | | 109 | `https://pytutorial.com/python-openpyxl-troubleshooting-guide/` | 검색결과 | | 110 | `https://pytutorial.com/style-cells-and-fonts-in-excel-with-python-openpyxl/` | 검색결과 | | 111 | `https://qna.habr.com/q/1128734` | 검색결과 | | 112 | `https://qna.habr.com/q/1304708` | 검색결과 | | 113 | `https://qna.habr.com/q/491840` | 검색결과 | | 114 | `https://qna.habr.com/q/56030` | 검색결과 | | 115 | `https://qna.habr.com/q/851245` | 검색결과 | | 116 | `https://salivity.github.io/libre-office/article/how-to-run-libreoffice-headless-via-command-line` | 검색결과 | | 117 | `https://sci-draw.com/blog/colorblind-safe-palettes-okabe-ito-reference` | 검색결과 | | 118 | `https://scifig.ai/blog/color-palettes-scientific-figures` | 검색결과 | | 119 | `https://ssopenpyxl.readthedocs.io/en/2.5.0-b1/formatting.html` | 검색결과 | | 120 | `https://ssopenpyxl.readthedocs.io/en/stable/formatting.html` | 검색결과 | | 121 | `https://studyforus.com/study/23531` | 검색결과 | | 122 | `https://tariknazorek.medium.com/convert-office-files-to-pdf-with-libreoffice-and-python-a70052121c44` | 검색결과 | | 123 | `https://vizcept.com/blog/10-color-palettes-for-scientific-publication` | 검색결과 | | 124 | `https://vizcept.com/blog/okabe-ito-palette-guide` | 검색결과 | | 125 | `https://vizcept.com/tool/scientific-color-palette-generator` | 검색결과 | | 126 | `https://wikidocs.net/91661` | 검색결과 | | 127 | `https://woteq.com/how-to-add-a-hyperlink-to-a-cell-using-openpyxl` | 검색결과 | | 128 | `https://woteq.com/how-to-auto-fit-column-width-workaround-in-openpyxl` | 검색결과 | | 129 | `https://www.converterer.com/blog/libreoffice-headless/` | 검색결과 | | 130 | `https://www.e-iceblue.com/ko/xls/autofit-column-width-in-excel.html` | 검색결과 | | 131 | `https://www.fontyukle.net/font/Sunny-gothic` | 검색결과 | | 132 | `https://www.geeksforgeeks.org/python/adding-conditional-formatting-to-excel-using-python-openpyxl/` | 검색결과 | | 133 | `https://www.homedutech.com/program-example/python--adding-hyperlinks-in-some-cells-openpyxl.html` | 검색결과 | | 134 | `https://www.libreofficehelp.com/batch-convert-writer-documents-pdf-libreoffice/` | 검색결과 | | 135 | `https://www.linuxtut.com/en/65708e64dcb5eaead9fb/` | 검색결과 | | 136 | `https://www.pythoncentral.io/openpyxl-automate-excel-with-python/` | 검색결과 | | 137 | `https://www.scribd.com/document/450919023/Openpyxl-Doc` | 검색결과 | | 138 | `https://www.scribd.com/document/678616799/Formulae-Programing` | 검색결과 | | 139 | `https://www.tutorialspoint.com/python_xlsxwriter/python_xlsxwriter_adding_charts.htm` | 검색결과 | | 140 | `https://www.tutorialspoint.com/python_xlsxwriter/python_xlsxwriter_fonts_and_colors.htm` | 검색결과 | | 141 | `https://www.tutorialspoint.com/python_xlsxwriter/python_xlsxwriter_sparklines.htm` | 검색결과 | | 142 | `https://www.tutorialspoint.com/python_xlsxwriter/python_xlsxwriter_tables.htm` | 검색결과 | | 143 | `https://xlsxwriter.readthedocs.io/chart_examples.html` | 검색결과 | | 144 | `https://xlsxwriter.readthedocs.io/example_chart_data_tools.html` | 검색결과 | | 145 | `https://xlsxwriter.readthedocs.io/example_chart_pie.html` | 검색결과 | | 146 | `https://xlsxwriter.readthedocs.io/example_sparklines1.html` | 검색결과 | | 147 | `https://xlsxwriter.readthedocs.io/example_tables.html` | 검색결과 | | 148 | `https://zetcode.com/python/openpyxl/` | 검색결과 | **출처 집계**: 총 **197개 URL** (FETCH 시도 49 = 성공 44 + 실패 5, 검색결과 전용 148). --- ## 부록 B. 미해결 질문 / 실측 필요 항목 ### B.1 환경 부트스트랩 - [ ] `openpyxl` / `XlsxWriter` / `pandas` 를 대상 PC(Python 3.14.6)에 설치하고 각 버전을 기록한다. **현재 openpyxl 은 미설치 상태로 실측됨.** - [ ] Python 3.14 에서 `pandas` 휠이 제공되는지 확인한다. 없으면 소스 빌드가 필요하고, 그 경우 `polars` 로 대체하는 안을 검토한다. - [ ] `C:\Program Files\LibreOffice\program\soffice.exe` 의 **버전**을 확인한다(`soffice --version`). 존재는 확인됐으나 버전 미확인. - [ ] `Malgun Gothic` 폰트가 리포트 수신자 PC 전부에 있는지 확인한다(Windows Vista 이상 기본 탑재이므로 사실상 안전하나, 최소 1대 실측 권장). ### B.2 라이브러리 동작 실측 - [ ] xlsxwriter `write_formula(..., value=)` 로 캐시값을 넣은 파일을, **Excel 로 열지 않은 채** `openpyxl.load_workbook(data_only=True)` 로 읽었을 때 그 값이 나오는지 실측한다. (문서상 나와야 하지만 직접 확인 필요) - [ ] xlsxwriter `add_table()` 로 만든 표에 `conditional_format` 의 행 전체 강조를 걸었을 때, 표의 `banded_rows` 줄무늬와 **어느 쪽이 위로 그려지는지** 실측한다. 줄무늬가 이겨서 상태 색이 안 보이면 `banded_rows: False` 로 바꿔야 한다. - [ ] openpyxl 에서 시트명에 공백이 있을 때 `cell.hyperlink = "#'Sheet Name'!A1"` 형태(작은따옴표)가 실제로 동작하는지 실측한다. ⚠️ 메일링리스트 스레드에서 명시적으로 다뤄지지 않은 부분이다. - [ ] openpyxl `Rule(type="duplicateValues", ...)` 가 실제로 중복 강조를 만드는지 실측한다. ⚠️ 공식 문서 예제에 없다. - [ ] openpyxl `DataBarRule` 에서 **솔리드 채우기(gradient=False)** 와 x14 확장(테두리/방향)을 쓸 방법이 있는지 openpyxl 소스와 OOXML 스펙에서 확인한다. ⚠️ 검색으로 찾지 못했다. - [ ] xlsxwriter `worksheet.set_margins()` / `center_horizontally()` 의 정확한 시그니처를 공식 문서에서 확인한다. ⚠️ 이번 리서치의 문서 발췌에 포함되지 않아 코드에 넣지 않았다. - [ ] openpyxl 공식 문서의 Defined Names 예제 오타(f-string 누락, 미정의 `new_range`)가 최신 버전에서 수정됐는지 확인한다. ### B.3 재계산 파이프라인 - [ ] `--convert-to xlsx` 방식(11.4 스니펫)이 **수식 캐시값을 실제로 채우는지** 실측한다. Basic 매크로 방식(`calculateAll()` + `store()`)이 더 확실할 수 있다. 두 방식을 같은 파일에 돌려 비교한다. - [ ] LibreOffice 가 `COUNTIFS` / `SUMIFS` / `XLOOKUP` / `TEXTJOIN` 을 각각 평가할 수 있는지 실측한다. 평가 못 하는 함수는 `#NAME?` 이 파일에 박혀 배포된다. - [ ] LibreOffice headless 실행이 Windows 작업 스케줄러의 **사용자 세션 없는 환경**에서도 동작하는지 확인한다(GUI 없는 서비스 계정에서 `soffice` 가 프로파일 생성에 실패하는 사례가 있다). - [ ] `soffice` 가 이미 실행 중일 때(사용자가 LibreOffice 를 열어 둔 경우) headless 호출이 기존 인스턴스에 붙어 실패하는지 확인한다. `-env:UserInstallation=file:///...` 로 별도 프로파일을 쓰는 방식 검토. - [ ] `formualizer`(Rust 수식 엔진)가 실제로 존재하고 파이썬에서 쓸 수 있는지 확인한다. ⚠️ 검색 결과에만 언급됐고 저장소/패키지를 열어 확인하지 않았다. ### B.4 파일 잠금·배포 - [ ] Excel 로 파일을 열어 둔 상태에서 11.3 의 `atomic_write_xlsx` 가 폴백 경로로 정상 동작하는지 실측한다. - [ ] `looks_locked()` 의 `open(path, "r+b")` 시도가 OneDrive/네트워크 드라이브에서 오탐하지 않는지 확인한다. - [ ] 리포트 저장 위치가 OneDrive 동기화 폴더인 경우 `os.replace` 가 동기화 클라이언트와 충돌하는지 확인한다. - [ ] xlsxwriter `close()` 실패로 손상 파일이 만들어지는 이슈(#583)가 현재 버전에서 재현되는지 확인한다. ### B.5 리포트 설계 확정 필요 - [ ] DMF 데이터의 **실제 컬럼 목록**을 확정한다. 본 문서의 `HEADER` 12열은 예시이며 실제 크롤링 결과에 맞춰 교체해야 한다. - [ ] `04_전체현황` 의 예상 행 수를 확인한다. 10만 행을 넘으면 `constant_memory: True` 로 전환하고, 그 경우 **행을 순차로만 쓸 수 있다**는 제약에 맞춰 코드 구조를 바꿔야 한다. - [ ] 신규/변경/취하 판정의 기준 상태를 xlsx 에서 읽을지, 별도 SQLite 에서 읽을지 확정한다. (본 문서 권고: **SQLite**) - [ ] 일자별 파일 보존 기간(90일 제안)과 아카이브 정책을 확정한다. - [ ] `DMF_리포트_최신.xlsx` 를 공유 폴더/네트워크 드라이브에 둘지, 로컬에 두고 링크만 공유할지 확정한다. - [ ] 리포트를 메일/Teams 로 자동 발송할 것인지, 발송한다면 첨부 크기 상한을 확인한다. ### B.6 접근성·시각 디자인 - [ ] Okabe-Ito 팔레트의 연한 배경 톤(`#D9F0E7`, `#FBEBD1`, `#F7DCD0`)과 본문 글자색의 **대비비(contrast ratio)** 를 확인한다(WCAG AA 4.5:1 목표). - [ ] 조건부 서식 색만으로 상태를 구분하지 않도록, **상태 열의 텍스트 + 아이콘**을 함께 쓰는지 검토한다(색각 이상 사용자 대응의 기본). - [ ] 인쇄 시 배경색이 출력되지 않는 Excel 설정(기본값)에서도 리포트가 읽히는지 확인한다. --- *이 문서는 리서치 raw dump `agent-aae3a9663f8136c70.md` (3,047줄, SEARCH 21건 + FETCH 49건 + CMD 1건) 전량을 근거로 작성되었다. 문서에 인용된 모든 코드·문장은 해당 출처에서 직접 확인한 것이며, 확인하지 못한 항목은 `⚠️ 미검증` 으로 명시했다.*