DMF_Crawler/docs/research/06-xlsx-linking-and-formatting.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

164 KiB

xlsx 탭 연동과 포맷팅 방법론 정본

이 문서의 역할: DMF_Crawler 가 매일 06:00 에 생성하는 xlsx 리포트의 시트(탭) 간 연동 구조·서식·조건부 서식·차트/스파크라인·파일 갱신 전략을 확정하는 단일 정본(SSOT)이며, 이 문서만 읽고 리포트 생성 모듈을 구현할 수 있어야 한다.

0. 한눈에 보기

  • 라이브러리 최종 선택: xlsxwriter 를 기본 엔진으로 삼아 매일 "새 파일"을 통째로 생성한다. 스파크라인은 xlsxwriter 에만 있고, openpyxl 은 기존 파일을 load_workbooksave 하는 순간 차트·이미지·도형을 잃는다. 누적 갱신 대신 매일 재생성하면 이 손실 문제 자체가 사라진다.
  • 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. 목차


2. 실측된 실행 환경

리서치 단계에서 대상 PC 에서 실제로 실행한 명령과 결과다. 지어낸 값이 아니다.

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 "<string>", line 1, in <module>
    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 재계산 경로 사용 가능

부트스트랩에 넣어야 할 줄:

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_workbooksave 하면 그것들이 사라진다. 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 목차 시트 → 각 탭 하이퍼링크

세 가지 방법이 있고, 셋 다 같은 결과를 만든다.

=HYPERLINK("#'01_신규'!A1", "신규 등록 보기")
  • #통합문서 내부 참조를 의미한다.
  • 시트명에 공백·특수문자·숫자 시작이 있으면 작은따옴표로 감싼다. 01_신규 처럼 숫자로 시작하는 이름도 감싸는 편이 안전하다.
  • 장점: 엔진 독립적이고 텍스트도 수식 인자로 같이 준다.
  • 단점: 수식이므로 일부 뷰어에서 링크가 죽고, 파이썬은 값을 못 만든다(표시 텍스트는 수식의 2번째 인자라 Excel 이 렌더링).

(b) xlsxwriter write_url()이 프로젝트 채택

# 같은 통합문서 내부 링크
worksheet.write_url("A1", "internal:Sheet2!A1")

# 시트명에 공백이 있으면 작은따옴표
worksheet.write_url("A3", "internal:'Sales Data'!A1")

# 범위로 링크
worksheet.write_url("A2", "internal:Sheet2!A1:B2")

전체 시그니처:

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).

가장 짧은 형태(공식 메일링리스트에서 권장된 방식):

currentCell.value = 'Index Tab'
currentCell.hyperlink = '#Index!D3'

문자열 포매팅 형태:

currentCell.hyperlink = '#%s!%s' % ('Index', 'D3')

Hyperlink 객체를 쓰는 형태:

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.hyperlinkCell 객체를 넣으면 AttributeError 가 난다. 반드시 문자열(또는 Hyperlink 객체).
  • 셀에 보이는 텍스트는 cell.value 로 따로 넣어야 한다. 하이퍼링크만 설정하면 빈 셀에 링크만 걸린 상태가 된다.
  • 시트명에 공백이 있으면 '#\'Sheet Name\'!D3' 처럼 작은따옴표로 감싼다(스레드에서 명시적으로 다뤄지진 않았으나 Excel 표준 관례. ⚠️ 미검증 — 실측 권장).

5.2 각 탭 → 목차 복귀 링크

각 데이터 시트 A1 에 항상 같은 링크를 심는다.

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: 두 방법.

# 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

# 전역(통합문서 스코프)
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

전역 이름 만들기:

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 으로 고쳐 써야 동작한다.

시트 스코프 이름:

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)

읽기:

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"]

인쇄 영역으로 활용:

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_namesDefinedNameDict 클래스이며, 이름으로 접근 가능하고 전역/스코프 이름을 분리하며 .add() 로 키·이름 일치를 자동 처리한다.

5.5 시트 간 수식: COUNTIFS / SUMIFS / INDEX-MATCH / XLOOKUP

목차 시트의 KPI 카드가 이 수식들로 각 데이터 시트를 참조한다.

# 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 는 두 가지 길을 준다.

# 방법 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 은 자동 처리가 없다. 직접 붙인다.

ws["A1"] = "=_xlfn.NEWFUNCTION()"

openpyxl 이 아는 함수 이름 집합을 조회할 수 있다.

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 배열 수식 만들기:

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 는 중괄호 {} 를 보여주지만 코드에는 절대 중괄호를 넣지 않는다.

조회:

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 는 전용 메서드를 제공한다.

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() — 채택

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.

합계행 + 구조적 참조 예제(문서 원문):

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 를 켜도 캡션과 함수는 자동으로 안 채워진다. 반드시 columnstotal_string/total_function 을 지정해야 한다.
  • total_function 은 SUBTOTAL 계열: sum, average, count, count_nums, max, min, std_dev, var.

openpyxl Table

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

인라인 목록:

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'

워크시트에 부착:

ws.add_data_validation(dv)
dv.add(c1)                 # 개별 셀
dv.add('B1:B1048576')      # 범위

다른 시트 범위 참조 (핵심):

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

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_formulavalue 인자로 캐시값을 박는다 (2순위)

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-skillsrecalc.py):

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 이므로 절대경로를 직접 넘겨야 한다(실측으로 존재 확인됨).

타임아웃:

timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30

macOS 는 gtimeout, Linux 는 timeout 을 쓰고 Windows 에는 타임아웃 래퍼가 없다 → 파이썬 subprocess.run(..., timeout=...) 로 직접 걸어야 한다.

C-2. --convert-to 방식:

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 아님을 검증한다.

재계산 후 오류 스캔

excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A']

load_workbook(filename, data_only=True) 로 열어 모든 셀 문자열에서 위 토큰을 찾는다(참조 구현은 오류 타입당 위치 20개까지 기록).

anthropics/skillsxlsx SKILL 이 정리한 운영 규칙:

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 를 흰색과 혼합):

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 로 한정된다는 전제에서만 안전하다.
import xlsxwriter

workbook = xlsxwriter.Workbook('report.xlsx')
worksheet = workbook.add_worksheet()

# 맑은 고딕 포맷
fmt = workbook.add_format({'font_name': 'Malgun Gothic'})
worksheet.write('A1', '원료의약품 등록 현황', fmt)

openpyxl:

from openpyxl.styles import Font
font = Font(name='Malgun Gothic', size=10, bold=False, color='FF000000')

7.3 헤더 스타일

xlsxwriter:

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:

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 로 재사용:

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'      # 등록 후에는 이름으로 참조

행/열 단위 스타일:

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:

worksheet.autofilter(2, 0, last_row, last_col)     # (first_row, first_col, last_row, last_col)

Excel 표(add_table)를 쓰면 autofilter 가 기본 True 라 별도로 부를 필요가 없다.

openpyxl:

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" — 숫자·날짜 서식을 고려하지 않는다. 한글에서 좁게 나온다.
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 용 구현 원문 — 폰트 크기 보정까지 포함:

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:

ws.column_dimensions['A'].width = 50
ws.row_dimensions[1].height = 50

이 프로젝트용 xlsxwriter 유틸 (완결 코드)

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 행 높이

# 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 — 열 단위로 거는 것이 가장 싸다:

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:

ws['A2'] = 0.123456
ws['A2'].number_format = '0.00'
# 한국어 블로그 예: cell.number_format = "#,##0"

pandas 로 엔진에 날짜 기본 서식 위임:

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 테두리 최소화와 지브라 행

원칙: 격자선은 끄고, 헤더 아래 굵은 선 하나 + 행 줄무늬로 읽기 흐름을 만든다. 셀마다 사방 테두리를 그리면 오히려 읽기 어렵다.

worksheet.hide_gridlines(2)      # 0=표시, 1=화면만 숨김, 2=화면+인쇄 모두 숨김

지브라(줄무늬)는 세 가지 방법이 있다.

  1. Excel 표의 banded_rows(기본 True) — 가장 싸고 필터/정렬 후에도 자동 유지된다. 1순위 채택.
  2. 조건부 서식 수식 =MOD(ROW(),2)=0 — 표를 안 쓰는 시트에서.
  3. 행마다 다른 포맷 객체로 직접 쓰기 — 필터링하면 줄무늬가 어긋난다. 비추천.

조건부 서식 방식(xlsxwriter):

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:

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:

# 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 정렬과 줄바꿈

# 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) 표시 형식

생성:

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:

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:

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 를 켜야 한다:

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 시트 탭 색상

# xlsxwriter
worksheet.set_tab_color('#0072B2')

# openpyxl
ws.sheet_properties.tabColor = "1072BA"

openpyxl 워크시트 프로퍼티 전체 예제:

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 시트 뷰 (확대 / 눈금선 / 최초 활성 시트)

# 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_rowoptions dict:

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})

시그니처:

set_column(first_col, last_col, width, cell_format, options)
set_row(row, height, cell_format, options)

openpyxl:

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.ruleColorScaleRule, DataBarRule, IconSetRule, CellIsRule, FormulaRule, Rule.

색 스케일 (ColorScaleRule)

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)

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)

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)

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) 예:

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)

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

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 리포트의 핵심 규칙

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)

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.

수식 기반:

worksheet.conditional_format('A1:A4', {'type':     'formula',
                                       'criteria': '=$A$1>5',
                                       'format':   format1})

중복 강조:

worksheet.conditional_format('A1:A4', {'type':   'duplicate',
                                       'format': format1})

DMF 리포트 조건부 서식 세트 (완결 코드)

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 를 기본 엔진으로 택한 결정적 이유다.

기본 사용:

worksheet.add_sparkline("F1", {"range": "Sheet1!A1:E1", "markers": True})

3가지 타입: line(기본), column, win_loss.

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 여러 셀 위치 배열 (그룹 스파크라인)

고급 예제 (문서 원문)

# 최고/최저점 강조
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." 그룹 스파크라인은 locationrange 양쪽에 배열을 넘기면 연속된 셀들에 같은 설정을 한 번에 적용한다.

DMF 리포트 적용

00_목차 시트에서 각 카테고리(신규/변경/취하)의 최근 30일 일별 건수 추이를 한 셀 스파크라인으로 보여준다. 데이터 원본은 05_추이 시트.

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 차트

기본 흐름

chart = workbook.add_chart({'type': 'column'})
chart.add_series({'values': ['Sheet1', 1, 1, max_row, 1]})
worksheet.insert_chart(1, 3, chart)

pandas 와 조합할 때:

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)

차트 크기·위치

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 픽셀.

삽입 시 오프셋:

worksheet.insert_chart("D2", chart1, {"x_offset": 25, "y_offset": 10})

제목·축·범례·스타일

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.

결합 차트

column_chart = workbook.add_chart({'type': 'column'})
line_chart   = workbook.add_chart({'type': 'line'})
column_chart.combine(line_chart)

DMF 적용: 일별 신규 건수(막대) + 누적 건수(선)를 한 차트로.

데이터 라벨

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 하위 속성 전체:

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
    }
})

도넛 차트

chart = workbook.add_chart({'type': 'doughnut'})

세그먼트별 색 지정 (파이/도넛은 각 세그먼트가 point 로 표현되므로 시리즈가 아니라 포인트마다 서식을 준다):

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.

기본 예제

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 전체 예제

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 전체 예제

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")

⚠️ 공식 문서 예제에 holeSizeDataLabelList 사용은 포함돼 있지 않다.

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."

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 모드

전체 시그니처:

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)

코드 예제:

# 기본 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 로 서식을 입히는 표준 패턴:

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()

엔진 객체 접근:

workbook  = writer.book
worksheet = writer.sheets['Sheet1']

열 서식:

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 가 굵은 테두리 헤더를 강제로 넣는 문제 회피):

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)

메모리 출력:

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 연동:

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 역변환:

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

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')

스타일 적용:

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 스트리밍 조합:

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

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 초과 파일 지원

계산 모드:

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 프로젝트 권장 옵션:

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 원자적 저장 + 잠금 처리 참조.


11. 완결 코드 스니펫

모든 스니펫은 생략 부호 없이 그대로 복사해 실행할 수 있는 수준으로 작성했다. 공통 전제: pip install XlsxWriter openpyxl pandas.

11.1 스니펫 1 — xlsxwriter 로 DMF 리포트 전체 생성 (목차 + 링크 + 표 + 조건부서식 + 틀고정 + 한글 열너비 + 인쇄설정 + 탭색상)

"""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_목차 를 만든 직후 호출하면 되는 함수다.

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})

호출:

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 — 원자적 저장 + 파일 잠금 처리

"""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)

"""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 를 못 쓰는 상황(기존 템플릿에 데이터만 채워야 하는 경우)을 위한 완결 예제다.

"""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 — 배열 수식과 미래 함수를 안전하게 다루는 헬퍼

"""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 선언적 문법으로 스타일링된 엑셀 리포트 생성. 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 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 / PyPI openpyxl 래퍼. 재사용 가능한 스타일을 만들어 write-only 모드에서도 쓸 수 있게 해 준다 참고 (write_only 스타일링이 필요해지면)
xlsxwriter-tables xlsxwriter 표 생성 보조 참고
polars write_excel polars DataFrame → xlsx (내부적으로 xlsxwriter) 참고 (pandas 대신 polars 를 쓰게 되면)

xpyxl 사용 예(문서에서 확인):

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건) 전량을 근거로 작성되었다. 문서에 인용된 모든 코드·문장은 해당 출처에서 직접 확인한 것이며, 확인하지 못한 항목은 ⚠️ 미검증 으로 명시했다.