# -*- coding: utf-8 -*- """D~J 카테고리 셀병합 정상화(off-by-one/누락/고아셀) + 선택적 행높이17. 값/스타일/K하이퍼링크/행수/B/L/M/N 보존. 자체검증 실패시 롤백. 사용: python _remerge.py [--apply] [--height] """ import openpyxl, os, shutil, sys, io, json HDR=3 def load(path): return openpyxl.load_workbook(path) def process(path, apply=False, height=False): rep={"file":path,"ok":False} wb=load(path); ws=wb.active; mx=ws.max_row if mx=HDR and mr.min_col>=3 and mr.max_col<=10 and mr.min_col==mr.max_col: v=ws.cell(mr.min_row,mr.min_col).value for r in range(mr.min_row,mr.max_row+1): eff[(r,mr.min_col)]=v if mr.min_col>=4: cat_merges.append(str(mr)) elif mr.min_row>=HDR and mr.min_col>=4 and mr.max_col<=10: # multi-col merge in cat area (unusual) -> fill & remove v=ws.cell(mr.min_row,mr.min_col).value for r in range(mr.min_row,mr.max_row+1): for c in range(mr.min_col,mr.max_col+1): eff[(r,c)]=v cat_merges.append(str(mr)) def E(r,c): v=eff.get((r,c)); return str(v).strip() if v not in(None,"") else "" def path_of(r): return tuple(E(r,c) for c in range(4,11)) orig_path={r:path_of(r) for r in range(HDR,mx+1)} orig_K=[(r,ws.cell(r,11).value) for r in range(HDR,mx+1)] orig_LMN=[(r,ws.cell(r,12).value,ws.cell(r,13).value,ws.cell(r,14).value) for r in range(HDR,mx+1)] orig_B=[(r,ws.cell(r,2).value) for r in range(HDR,mx+1)] # orphan fill: blank cat cell with deeper content -> inherit from row above orphans=[] for r in range(HDR+1,mx+1): for c in range(4,11): if E(r,c)=="" and any(E(r,cc)!="" for cc in range(c+1,11)) and E(r-1,c)!="": eff[(r,c)]=eff[(r-1,c)]; orphans.append((r,c)) def parent(r,c): return tuple(E(r,cc) for cc in range(3,c)) # C..c-1 target=[] for c in range(4,11): r=HDR while r<=mx: v=E(r,c) if v=="": r+=1; continue r2=r while r2+1<=mx and E(r2+1,c)==v and parent(r2+1,c)==parent(r,c): r2+=1 if r2>r: target.append((c,r,r2)) r=r2+1 existing=set() for s in cat_merges: # parse "X1:Y2" from openpyxl.utils.cell import range_boundaries c1,r1,c2,r2=range_boundaries(s) if c1==c2: existing.add((c1,r1,r2)) tgt=set(target) rep["orphans"]=[(r,openpyxl.utils.get_column_letter(c)) for (r,c) in orphans] rep["add"]=sorted([(openpyxl.utils.get_column_letter(c),r1,r2) for (c,r1,r2) in (tgt-existing)]) rep["removed"]=sorted([(openpyxl.utils.get_column_letter(c),r1,r2) for (c,r1,r2) in (existing-tgt)]) rep["n_target"]=len(tgt); rep["n_existing"]=len(existing); rep["n_change"]=len(tgt-existing) if not apply: rep["ok"]=True; rep["dry"]=True; return rep if not (tgt-existing) and not orphans and not height: rep["ok"]=True; rep["nochange"]=True; return rep # ---- APPLY ---- bdir=os.path.join(os.path.dirname(path),"_backup") os.makedirs(bdir,exist_ok=True) bpath=os.path.join(bdir, os.path.splitext(os.path.basename(path))[0]+"_backup_셀병합전.xlsx") shutil.copy2(path,bpath); rep["backup"]=bpath for s in cat_merges: try: ws.unmerge_cells(s) except Exception: pass # write eff values to all cat cells for r in range(HDR,mx+1): for c in range(4,11): v=eff.get((r,c)) ws.cell(r,c).value=(v if v not in(None,"") else None) # blank non-top cells of runs for (c,r1,r2) in target: for r in range(r1+1,r2+1): ws.cell(r,c).value=None # re-merge for (c,r1,r2) in target: ws.merge_cells(start_row=r1,start_column=c,end_row=r2,end_column=c) if height: ws.sheet_format.defaultRowHeight=17; ws.sheet_format.customHeight=True for r in range(1,mx+1): ws.row_dimensions[r].height=17 wb.save(path) # ---- VERIFY (reload) ---- wb2=load(path); ws2=wb2.active e2={} for r in range(HDR,ws2.max_row+1): for c in range(3,11): e2[(r,c)]=ws2.cell(r,c).value for mr in ws2.merged_cells.ranges: if mr.min_row>=HDR and mr.min_col>=3 and mr.max_col<=10: v=ws2.cell(mr.min_row,mr.min_col).value for r in range(mr.min_row,mr.max_row+1): for c in range(mr.min_col,mr.max_col+1): e2[(r,c)]=v def E2(r,c): v=e2.get((r,c)); return str(v).strip() if v not in(None,"") else "" fails=[] if ws2.max_row!=mx: fails.append("rowcount %d!=%d"%(ws2.max_row,mx)) orphan_set=set(orphans) for r in range(HDR,mx+1): npath=tuple(E2(r,c) for c in range(4,11)) if npath!=orig_path[r]: # allowed only if diff positions are orphan-filled diffcols=[c for i,c in enumerate(range(4,11)) if npath[i]!=orig_path[r][i]] if not all((r,c) in orphan_set for c in diffcols): fails.append("path r%d %s->%s"%(r,orig_path[r],npath)) for (r,k) in orig_K: if ws2.cell(r,11).value!=k: fails.append("K r%d"%r) for (r,b) in orig_B: if ws2.cell(r,2).value!=b: fails.append("B r%d"%r) for (r,l,m,n) in orig_LMN: if (ws2.cell(r,12).value,ws2.cell(r,13).value,ws2.cell(r,14).value)!=(l,m,n): fails.append("LMN r%d"%r) # merge overlap / boundary check ranges=[(mr.min_col,mr.min_row,mr.max_row) for mr in ws2.merged_cells.ranges if mr.min_col==mr.max_col and 4<=mr.min_col<=10] seen={} for (c,r1,r2) in ranges: for r in range(r1,r2+1): if (c,r) in seen: fails.append("overlap c%d r%d"%(c,r)) seen[(c,r)]=1 rep["fails"]=fails[:20] if fails: shutil.copy2(bpath,path) # rollback rep["rolledback"]=True; rep["ok"]=False else: rep["ok"]=True; rep["n_merges_after"]=len(ranges) return rep if __name__=="__main__": args=sys.argv[1:] path=args[0] apply="--apply" in args; height="--height" in args r=process(path,apply=apply,height=height) print(json.dumps(r,ensure_ascii=False,indent=1))