# Doc Number: Korea JTC1/SC2 k2677_882_export_vba_chunks_to_png.py # Date: 2026.06.11. # Author: SHIN, SangHyun import win32com.client import os import sys import time import pythoncom from pathlib import Path from PIL import Image # CopyPicture Appearance: xlScreen=1, xlPrinter=2 / Format: xlBitmap=2 _XL_SCREEN, _XL_PRINTER = 1, 2 _XL_BITMAP = 2 # 기호표 N열(0.13)과 동일한 캡처용 더미 열 너비 — PNG 바깥 테두리만 제거하고 데이터 오른쪽 선 유지 _SPACER_COL_WIDTH = 0.13 _MM_TO_PT = 72.0 / 25.4 def _create_excel_app(visible=True): """Excel 인스턴스 생성. 기존 프로세스에 붙을 때 Visible 설정이 실패할 수 있어 예외 무시.""" try: excel = win32com.client.DispatchEx("Excel.Application") except Exception: excel = win32com.client.Dispatch("Excel.Application") if visible: try: excel.Visible = True except Exception: pass try: excel.DisplayAlerts = False except Exception: pass return excel def export_vba_chunks(excel_path, output_dir): print("==================================================") print(" VBA 매크로 분할 로직(페이지 조각) 기반 PNG 추출") print("==================================================") if not os.path.exists(output_dir): os.makedirs(output_dir) print(f"저장 폴더 생성 완료: {output_dir}") excel = _create_excel_app(visible=True) try: excel.EnableEvents = False excel.ScreenUpdating = True except Exception: pass excel.ErrorCheckingOptions.BackgroundChecking = False excel.DisplayCommentIndicator = 0 try: wb = excel.Workbooks.Open(excel_path) print("\n[1] 기호테이블 조각 추출 시작...") ws_symbol = wb.Sheets("기호표") ws_symbol_name = wb.Sheets("기호명") ws_symbol.Activate() excel.ActiveWindow.Zoom = 150 ws_symbol_name.Activate() excel.ActiveWindow.Zoom = 150 pages = [4, 2, 2, 2, 2, 2, 4, 4, 2] i1 = 1 j1 = 1 img_idx = 1 for i in range(9): i2 = i1 + 35 ws_symbol.Activate() rng = ws_symbol.Range(ws_symbol.Cells(i1, 1), ws_symbol.Cells(i2 - 1, 13)) export_range_to_png(rng, ws_symbol, os.path.join(output_dir, f"01_기호_{img_idx:03d}_기호표_파트{i+1}.png")) img_idx += 1 for j in range(1, pages[i] + 1): j2 = j1 + 50 ws_symbol_name.Activate() rng = ws_symbol_name.Range(ws_symbol_name.Cells(j1, 1), ws_symbol_name.Cells(j2 - 1, 5)) export_range_to_png(rng, ws_symbol_name, os.path.join(output_dir, f"01_기호_{img_idx:03d}_기호명.png")) img_idx += 1 j1 = j1 + 51 i1 = i1 + 37 print("\n[2] 한글테이블 조각 추출 시작...") ws_hangul = wb.Sheets("한글표") ws_hangul.Activate() excel.ActiveWindow.Zoom = 150 i1 = 1 for i in range(9): i2 = i1 + 35 rng = ws_hangul.Range(ws_hangul.Cells(i1, 1), ws_hangul.Cells(i2 - 1, 19)) export_range_to_png(rng, ws_hangul, os.path.join(output_dir, f"02_한글_{i+1:03d}_한글표.png")) i1 = i1 + 37 print("\n[3] 한자테이블 조각 추출 시작...") ws_hanja = wb.Sheets("한자표") ws_hanja.Activate() excel.ActiveWindow.Zoom = 150 i1 = 1 for i in range(26): i2 = i1 + 35 rng = ws_hanja.Range(ws_hanja.Cells(i1, 1), ws_hanja.Cells(i2 - 1, 25)) export_range_to_png(rng, ws_hanja, os.path.join(output_dir, f"03_한자_{i+1:03d}_한자표.png")) i1 = i1 + 37 print("\n[4] 부속서 조각 추출 시작...") ws_annex_a = wb.Sheets("부속서") ws_annex_a.Activate() excel.ActiveWindow.Zoom = 150 annex_a_ranges = [ (3, 6), (7, 43), (44, 49), (50, 75), (76, 90), (91, 129), (130, 139), (140, 189), (190, 232), (233, 238), (239, 288), (289, 317), (318, 334), (335, 384), (385, 392), ] for i, (start_row, end_row) in enumerate(annex_a_ranges): rng = ws_annex_a.Range(ws_annex_a.Cells(start_row, 1), ws_annex_a.Cells(end_row, 5)) # 부속서는 사용자가 원하는대로 값 있는 마지막 행까지만 엄격 trim rng = _trim_range_to_last_value_row_strict(rng, keep_tail_rows=0) export_range_to_png( rng, ws_annex_a, os.path.join(output_dir, f"04_부속서_{i+1:03d}_부속서.png"), ) # 부속서는 011 파일의 하단 여백을 기준으로 전체 하단 여백을 통일 _normalize_annex_bottom_margins(output_dir) print("\n[5] 동형표 조각 추출 시작...") ws_annex_b = wb.Sheets("동형표") ws_annex_b.Activate() excel.ActiveWindow.Zoom = 150 # 마지막 조각: B93:S100 — 부호 3개/4개 한자 표(테두리·더미열 조작 없음) annex_b_chunks = [ {"rows": (1, 42), "cols": (1, 21), "keep_bottom_line": True}, {"rows": (47, 88), "cols": (1, 21), "keep_bottom_line": True}, { "rows": (93, 100), "cols": (2, 19), "strip_borders": False, "spacer": False, "prepare": True, # 3번 청크는 현재 결과 유지 "keep_bottom_line": None, }, ] for i, chunk in enumerate(annex_b_chunks): if chunk.get("prepare"): _prepare_donghyeong_last_table(ws_annex_b) r1, r2 = chunk["rows"] c1, c2 = chunk["cols"] rng = ws_annex_b.Range( ws_annex_b.Cells(r1, c1), ws_annex_b.Cells(r2, c2) ) export_range_to_png( rng, ws_annex_b, os.path.join(output_dir, f"05_동형표_{i+1:03d}_동형표.png"), use_spacer_column=chunk.get("spacer", True), strip_outer_borders=chunk.get("strip_borders"), keep_bottom_line=chunk.get("keep_bottom_line"), ) print("\n모든 시트(부속서/동형표 포함) 조각(Chunk) 보내기 완벽 성공!") finally: excel.Quit() _XL_EDGE_LEFT, _XL_EDGE_TOP, _XL_EDGE_BOTTOM, _XL_EDGE_RIGHT = 7, 8, 9, 10 _RANGE_EDGES = (_XL_EDGE_LEFT, _XL_EDGE_RIGHT, _XL_EDGE_TOP, _XL_EDGE_BOTTOM) _XL_NONE = -4142 def _border_snapshot(border): return {"LineStyle": border.LineStyle, "Weight": border.Weight, "Color": border.Color} def _restore_border(border, snap): if snap["LineStyle"] == _XL_NONE: border.LineStyle = _XL_NONE return border.LineStyle = snap["LineStyle"] try: border.Weight = snap["Weight"] except Exception: pass try: border.Color = snap["Color"] except Exception: pass def _strip_outer_borders_for_capture(rng, keep_bottom_line=False): saved = [] edges = list(_RANGE_EDGES) if keep_bottom_line and _XL_EDGE_BOTTOM in edges: edges.remove(_XL_EDGE_BOTTOM) for edge in edges: b = rng.Borders(edge) saved.append(("range", rng, edge, _border_snapshot(b))) b.LineStyle = _XL_NONE nrows, ncols = rng.Rows.Count, rng.Columns.Count for c in range(1, ncols + 1): row_edges = [(1, _XL_EDGE_TOP), (nrows, _XL_EDGE_BOTTOM)] if keep_bottom_line: row_edges = [(1, _XL_EDGE_TOP)] for row, edge in row_edges: cell = rng.Cells(row, c) b = cell.Borders(edge) saved.append(("cell", cell, edge, _border_snapshot(b))) b.LineStyle = _XL_NONE for r in range(1, nrows + 1): for col, edge in ((1, _XL_EDGE_LEFT), (ncols, _XL_EDGE_RIGHT)): cell = rng.Cells(r, col) b = cell.Borders(edge) saved.append(("cell", cell, edge, _border_snapshot(b))) b.LineStyle = _XL_NONE return saved def _restore_outer_borders(saved): for kind, target, edge, snap in saved: _restore_border(target.Borders(edge), snap) def _spacer_col_is_usable(ws, col, first_row, last_row): """데이터 오른쪽 열이 비어 있으면 삽입 없이 캡처 범위만 확장.""" rng = ws.Range(ws.Cells(first_row, col), ws.Cells(last_row, col)) v = rng.Value if v is None: return True if not isinstance(v, tuple): return v in (None, "") for row in v: if isinstance(row, tuple): if any(cell not in (None, "") for cell in row): return False elif row not in (None, ""): return False return True def _spacer_sides_for_sheet(ws_name): """더미 열: 기호명=양쪽, 기호표 등=오른쪽만, 부속서=없음.""" if ws_name == "부속서": return "none" if ws_name == "기호명": return "both" return "right" def _strip_outer_borders_for_sheet(ws_name): """부속서는 상하좌우 테두리를 그대로 둔다.""" return ws_name != "부속서" def _keep_bottom_line_for_sheet(ws_name): """요청 시트는 PNG 하단선이 반드시 보이도록 외곽선 제거 시 bottom은 유지.""" return ws_name in ("기호표", "한글표", "한자표", "기호명") def _trim_range_to_last_value_row(rng, keep_tail_rows=0): """범위 하단의 빈 행을 줄인다. 값이 없는 행이라도 '하단 테두리'가 있으면 표의 마지막 선이므로 유지한다. """ ws = rng.Worksheet r1 = rng.Row r2 = rng.Row + rng.Rows.Count - 1 c1 = rng.Column c2 = rng.Column + rng.Columns.Count - 1 last_val_row = r1 for r in range(r1, r2 + 1): row_has_bottom_border = False for c in range(c1, c2 + 1): v = ws.Cells(r, c).Value if v is not None and str(v).strip() != "": last_val_row = r break try: if ws.Cells(r, c).Borders(_XL_EDGE_BOTTOM).LineStyle != _XL_NONE: row_has_bottom_border = True except Exception: pass if row_has_bottom_border: last_val_row = r keep_until = min(r2, last_val_row + max(0, keep_tail_rows)) if keep_until < r2: return ws.Range(ws.Cells(r1, c1), ws.Cells(keep_until, c2)) return rng def _trim_range_to_last_value_row_strict(rng, keep_tail_rows=0): """값 있는 마지막 행까지만 유지(테두리-only 행 제외).""" ws = rng.Worksheet r1 = rng.Row r2 = rng.Row + rng.Rows.Count - 1 c1 = rng.Column c2 = rng.Column + rng.Columns.Count - 1 last_val_row = r1 for r in range(r1, r2 + 1): for c in range(c1, c2 + 1): v = ws.Cells(r, c).Value if v is not None and str(v).strip() != "": last_val_row = r break keep_until = min(r2, last_val_row + max(0, keep_tail_rows)) if keep_until < r2: return ws.Range(ws.Cells(r1, c1), ws.Cells(keep_until, c2)) return rng def _expand_range_with_bottom_spacer_row(rng, spacer_mm=1.0): """범위 하단에 더미 1행을 삽입하고(기본 1mm), 그 행까지 캡처 범위를 확장.""" ws = rng.Worksheet r1 = rng.Row r2 = rng.Row + rng.Rows.Count - 1 c1 = rng.Column c2 = rng.Column + rng.Columns.Count - 1 spacer_row = r2 + 1 ws.Rows(spacer_row).Insert() ws.Rows(spacer_row).RowHeight = max(1.0, spacer_mm * _MM_TO_PT) expanded = ws.Range(ws.Cells(r1, c1), ws.Cells(spacer_row, c2)) return expanded, {"ws": ws, "spacer_row": spacer_row} def _restore_bottom_spacer_row(state): if not state: return try: state["ws"].Rows(state["spacer_row"]).Delete() except Exception: pass _XL_CENTER = -4108 def _prepare_donghyeong_last_table(ws): """동형표 마지막 표: '부호값이 4개인 한자' 제목을 M93:S93 병합으로 전부 노출.""" try: title_val = None for addr in ("M93", "P93", "N93"): v = ws.Range(addr).Value if v is not None and str(v).strip(): title_val = v break if title_val is None: return dst = ws.Range("M93:S93") if dst.MergeCells: dst.UnMerge() dst.Merge() dst.Value = title_val dst.HorizontalAlignment = _XL_CENTER dst.WrapText = False except Exception: pass def _narrow_spacer_column(ws, col, first_row, last_row, insert_if_occupied=True): inserted = False if not _spacer_col_is_usable(ws, col, first_row, last_row): if not insert_if_occupied: raise ValueError(f"spacer column {col} is not empty") ws.Columns(col).Insert() inserted = True width_before = ws.Columns(col).ColumnWidth ws.Columns(col).ColumnWidth = _SPACER_COL_WIDTH return {"col": col, "width_before": width_before, "inserted": inserted} def _insert_left_spacer(ws, at_col): ws.Columns(at_col).Insert() width_before = ws.Columns(at_col).ColumnWidth ws.Columns(at_col).ColumnWidth = _SPACER_COL_WIDTH return {"col": at_col, "width_before": width_before, "inserted": True} def _expand_range_with_spacers(rng, sides="right"): """캡처 범위 앞·뒤에 0.13 더미 열을 붙여 PNG 바깥 테두리만 제거한다. - right: 데이터 오른쪽 열 1개 (기호표·한글표 등) - both: 데이터 양옆 열 1개씩 (기호명 — 좌·우 세로선 유지) """ ws = rng.Worksheet first_row = rng.Row last_row = rng.Row + rng.Rows.Count - 1 first_col = rng.Column last_col = rng.Column + rng.Columns.Count - 1 pads = [] capture_first = first_col capture_last = last_col if sides == "both": if first_col > 1 and _spacer_col_is_usable(ws, first_col - 1, first_row, last_row): pads.append(_narrow_spacer_column(ws, first_col - 1, first_row, last_row, insert_if_occupied=False)) capture_first = first_col - 1 else: pads.append(_insert_left_spacer(ws, first_col)) capture_first = first_col last_col += 1 if sides in ("right", "both"): right_col = last_col + 1 pads.append(_narrow_spacer_column(ws, right_col, first_row, last_row, insert_if_occupied=True)) capture_last = right_col capture_rng = ws.Range( ws.Cells(first_row, capture_first), ws.Cells(last_row, capture_last), ) return capture_rng, {"ws": ws, "pads": pads} def _restore_spacer_columns(state): if not state: return ws = state["ws"] for pad in sorted(state["pads"], key=lambda p: p["col"], reverse=True): try: if pad["inserted"]: ws.Columns(pad["col"]).Delete() else: ws.Columns(pad["col"]).ColumnWidth = pad["width_before"] except Exception: pass def _hide_chart_frame(chart): for area_name in ("ChartArea", "PlotArea"): try: area = getattr(chart, area_name) area.Format.Line.Visible = 0 except Exception: try: area = getattr(chart, area_name) area.Border.LineStyle = _XL_NONE except Exception: pass def _scroll_range_into_view(rng): try: rng.Application.Goto(rng, True) time.sleep(0.08) except Exception: pass def _purge_chart_objects(ws): for _ in range(50): try: if ws.ChartObjects().Count == 0: return ws.ChartObjects(1).Delete() except Exception: return def _chart_has_pasted_picture(chart): try: return chart.Shapes.Count > 0 except Exception: return False def _copy_range_picture(rng, appearance=_XL_PRINTER): app = rng.Application _scroll_range_into_view(rng) was_updating = app.ScreenUpdating app.ScreenUpdating = True try: rng.CopyPicture(Appearance=appearance, Format=_XL_BITMAP) finally: app.ScreenUpdating = was_updating time.sleep(0.35) pythoncom.PumpWaitingMessages() def _paste_into_chart(chart, chart_obj, rng): appearances = (_XL_PRINTER, _XL_PRINTER, _XL_SCREEN) last_err = None for i in range(5): appearance = appearances[min(i, len(appearances) - 1)] try: try: chart_obj.Activate() except Exception: pass time.sleep(0.12) chart.Paste() time.sleep(0.15) if _chart_has_pasted_picture(chart): return last_err = RuntimeError("차트에 붙은 그림 없음") except Exception as e: last_err = e time.sleep(0.25) try: _copy_range_picture(rng, appearance) except Exception: pass raise last_err def _png_looks_valid(png_path, min_bytes=8000): if not os.path.exists(png_path): return False return os.path.getsize(png_path) >= min_bytes def _detect_bottom_blank_rows(img, white_threshold=245): gray = img.convert("L") w, h = gray.size px = gray.load() last_nonwhite = -1 for y in range(h - 1, -1, -1): has_nonwhite = False for x in range(w): if px[x, y] < white_threshold: has_nonwhite = True break if has_nonwhite: last_nonwhite = y break if last_nonwhite < 0: return h return h - 1 - last_nonwhite def _normalize_png_bottom_blank_rows(png_path, target_blank_rows): with Image.open(png_path) as img: img = img.convert("RGB") w, h = img.size current_blank = _detect_bottom_blank_rows(img) if current_blank == target_blank_rows: return delta = target_blank_rows - current_blank if delta < 0: # 여백이 더 크면 하단을 잘라낸다. new_h = max(1, h + delta) out = img.crop((0, 0, w, new_h)) else: # 여백이 더 작으면 흰색 행을 하단에 붙인다. out = Image.new("RGB", (w, h + delta), "white") out.paste(img, (0, 0)) out.save(png_path) def _normalize_annex_bottom_margins(output_dir): annex_files = [ os.path.join(output_dir, f"04_부속서_{i:03d}_부속서.png") for i in range(1, 16) ] annex_files = [p for p in annex_files if os.path.exists(p)] if not annex_files: return ref_path = os.path.join(output_dir, "04_부속서_011_부속서.png") if os.path.exists(ref_path): with Image.open(ref_path) as ref_img: target_blank = _detect_bottom_blank_rows(ref_img) else: blanks = [] for p in annex_files: with Image.open(p) as img: blanks.append(_detect_bottom_blank_rows(img)) blanks.sort() target_blank = blanks[len(blanks) // 2] for p in annex_files: _normalize_png_bottom_blank_rows(p, target_blank) def _export_range_once( rng, ws, png_path, use_spacer_column=True, strip_outer_borders=None, keep_bottom_line=None, ): app = rng.Application ws.Activate() try: app.ActiveWindow.Zoom = 150 except Exception: pass aw = app.ActiveWindow grid_was_on = aw.DisplayGridlines aw.DisplayGridlines = False _purge_chart_objects(ws) spacer_state = None capture_rng = rng if use_spacer_column: sides = _spacer_sides_for_sheet(ws.Name) if sides != "none": capture_rng, spacer_state = _expand_range_with_spacers(rng, sides=sides) if strip_outer_borders is None: strip_outer_borders = _strip_outer_borders_for_sheet(ws.Name) saved_borders = [] if keep_bottom_line is None: keep_bottom_line = _keep_bottom_line_for_sheet(ws.Name) if strip_outer_borders: saved_borders = _strip_outer_borders_for_capture( capture_rng, keep_bottom_line=keep_bottom_line, ) chart_obj = None try: copied = False for appearance in (_XL_PRINTER, _XL_PRINTER, _XL_SCREEN): for _ in range(3): try: _copy_range_picture(capture_rng, appearance) copied = True break except Exception: time.sleep(0.3) if copied: break if not copied: return False # 차트 캔버스에 여유 패딩 추가 (하단 선 짤림 방지) # CopyPicture 이미지가 rng 치수보다 미세하게 클 수 있어 # 캔버스가 정확히 같으면 하단/우측 테두리 선이 잘림 _H_PAD = 4 # pt (하단 선 짤림 방지 — 주요 원인) _W_PAD = 2 # pt (우측 선 짤림 방지 — 보조) chart_obj = ws.ChartObjects().Add( capture_rng.Left, capture_rng.Top, capture_rng.Width + _W_PAD, capture_rng.Height + _H_PAD, ) chart = chart_obj.Chart _hide_chart_frame(chart) try: _paste_into_chart(chart, chart_obj, capture_rng) except Exception: return False if os.path.exists(png_path): os.remove(png_path) chart.Export(png_path, "PNG") return _png_looks_valid(png_path) finally: if chart_obj is not None: try: chart_obj.Delete() except Exception: pass if saved_borders: _restore_outer_borders(saved_borders) _restore_spacer_columns(spacer_state) aw.DisplayGridlines = grid_was_on try: app.CutCopyMode = False except Exception: pass _purge_chart_objects(ws) pythoncom.PumpWaitingMessages() def export_range_to_png( rng, ws, png_path, use_spacer_column=True, strip_outer_borders=None, keep_bottom_line=None, ): print(f" -> 추출 중: {os.path.basename(png_path)} ... ", end="", flush=True) app = rng.Application for attempt in range(3): if _export_range_once( rng, ws, png_path, use_spacer_column=use_spacer_column, strip_outer_borders=strip_outer_borders, keep_bottom_line=keep_bottom_line, ): print(f"완료 ({os.path.getsize(png_path):,} bytes)") return try: app.CutCopyMode = False except Exception: pass _purge_chart_objects(ws) time.sleep(0.4 + attempt * 0.3) print("실패 (하얀 공백 이미지 발생)") def export_spacer_test(excel_path, output_dir, sheet_name, row_start, row_end, last_col, tag): os.makedirs(output_dir, exist_ok=True) excel = _create_excel_app(visible=True) try: wb = excel.Workbooks.Open(excel_path) ws = wb.Sheets(sheet_name) ws.Activate() excel.ActiveWindow.Zoom = 150 rng = ws.Range(ws.Cells(row_start, 1), ws.Cells(row_end, last_col)) sides = _spacer_sides_for_sheet(sheet_name) strip = _strip_outer_borders_for_sheet(sheet_name) out = os.path.join(output_dir, f"TEST_{tag}_{sides}.png") export_range_to_png(rng, ws, out, use_spacer_column=True) print(f"시험({sheet_name}, spacer={sides}, strip={strip}): {out}") wb.Close(False) finally: excel.Quit() if __name__ == "__main__": base_dir = Path(__file__).resolve().parent excel_file = os.path.join(base_dir, "KSX1001_excel.xls") output_folder = os.path.join(base_dir, "vba_chunk_images") if not os.path.exists(excel_file): print("엑셀 파일을 찾을 수 없습니다.") elif len(sys.argv) > 1 and sys.argv[1] == "--test-giho": export_spacer_test(excel_file, output_folder, "기호표", 1, 35, 13, "기호표") elif len(sys.argv) > 1 and sys.argv[1] == "--test-both": export_spacer_test(excel_file, output_folder, "기호명", 1, 49, 5, "기호명") export_spacer_test(excel_file, output_folder, "부속서", 7, 43, 5, "부속서") elif len(sys.argv) > 1 and sys.argv[1] == "--test-donghyeong": os.makedirs(output_folder, exist_ok=True) excel = _create_excel_app(visible=True) try: wb = excel.Workbooks.Open(excel_file) ws = wb.Sheets("동형표") ws.Activate() excel.ActiveWindow.Zoom = 150 _prepare_donghyeong_last_table(ws) rng = ws.Range(ws.Cells(93, 2), ws.Cells(100, 19)) out = os.path.join(output_folder, "TEST_동형표_마지막표.png") export_range_to_png( rng, ws, out, use_spacer_column=False, strip_outer_borders=False ) print(f"시험 출력: {out}") wb.Close(False) finally: excel.Quit() else: export_vba_chunks(excel_file, output_folder)