Files
Pier_v1/수량산출/tools/capture-diagrams.py
T

151 lines
9.4 KiB
Python

"""Read-only capture preparation: original DrawingML + native rasterized EMF/WMF.
Builds header-free SVG pages for the browser to capture; never edits the workbook.
"""
import base64, html, json, math, posixpath, re, zipfile
from pathlib import Path
from xml.etree import ElementTree as E
import openpyxl
ROOT=Path(__file__).resolve().parents[1]
NS={'a':'http://schemas.openxmlformats.org/drawingml/2006/main','x':'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing','s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main','r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships'}
Z=zipfile.ZipFile(ROOT/'교각수량산출.xlsx')
WB=openpyxl.load_workbook(ROOT/'교각수량산출.xlsx',data_only=True)
THEME={}
for entry in E.fromstring(Z.read('xl/theme/theme1.xml')).find('a:themeElements/a:clrScheme',NS):
value=next(iter(entry));THEME[entry.tag.split('}')[-1]]='#'+value.get('val',value.get('lastClr','000000'))
esc=lambda s:html.escape(str(s),quote=True)
def child(n,path): return n.find(path,NS) if n is not None else None
def num(n,key,default=0): return float(n.get(key,default)) if n is not None else default
def color(n,default='black'):
if n is None:return default
rgb=child(n,'a:srgbClr'); scheme=child(n,'a:schemeClr')
if rgb is not None:return '#'+rgb.get('val')
if scheme is not None:
val=THEME.get({'tx1':'dk1','bg1':'lt1'}.get(scheme.get('val'),scheme.get('val')),default)
mod=child(scheme,'a:lumMod')
if mod is not None and val.startswith('#'):val='#'+''.join(f'{round(int(val[i:i+2],16)*num(mod,"val")/100000):02x}' for i in (1,3,5))
return val
return default
def frame(n):
f=child(n,'a:off');e=child(n,'a:ext')
return num(f,'x'),num(f,'y'),num(e,'cx',1),num(e,'cy',1)
def render(n,rels):
kind=n.tag.split('}')[-1]
if kind=='grpSp':
xf=child(n,'x:grpSpPr/a:xfrm');x,y,w,h=frame(xf);co=child(xf,'a:chOff');ce=child(xf,'a:chExt')
tr=f'translate({x} {y}) '
rot=num(xf,'rot')/60000
if rot:tr+=f'translate({w/2} {h/2}) rotate({rot}) translate({-w/2} {-h/2}) '
tr+=f'scale({w/max(1,num(ce,"cx",w))} {h/max(1,num(ce,"cy",h))}) translate({-num(co,"x")} {-num(co,"y")})'
return f'<g transform="{tr}">'+''.join(render(c,rels) for c in n if c.tag.split('}')[-1] in ('grpSp','pic','sp','cxnSp'))+'</g>'
sp=child(n,'x:spPr');xf=child(sp,'a:xfrm');x,y,w,h=frame(xf)
tr=f'translate({x} {y})';rot=num(xf,'rot')/60000
if rot:tr+=f' translate({w/2} {h/2}) rotate({rot}) translate({-w/2} {-h/2})'
if xf is not None and (xf.get('flipH')=='1' or xf.get('flipV')=='1'):
sx=-1 if xf.get('flipH')=='1' else 1;sy=-1 if xf.get('flipV')=='1' else 1
tr+=f' translate({w if sx<0 else 0} {h if sy<0 else 0}) scale({sx} {sy})'
out=[]
if kind=='pic':
blip=child(n,'x:blipFill/a:blip');rid=blip.get('{'+NS['r']+'}embed') if blip is not None else None
if rid in rels:
name=posixpath.basename(rels[rid]);p=ROOT/'.cache/source-media'/name
if p.suffix in ('.emf','.wmf'):p=Path(str(p)+'.png')
mime='image/png' if p.suffix=='.png' else 'image/jpeg'
out.append(f'<image width="{w}" height="{h}" preserveAspectRatio="none" href="data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}"/>')
else:
fill='none' if child(sp,'a:noFill') is not None else color(child(sp,'a:solidFill'),'none')
ln=child(sp,'a:ln');stroke='none' if ln is None or child(ln,'a:noFill') is not None else color(child(ln,'a:solidFill'))
width=num(ln,'w',9525)
attr=f'fill="{fill}" stroke="{stroke}" stroke-width="{width}"'
paths=sp.findall('a:custGeom/a:pathLst/a:path',NS) if sp is not None else []
for path in paths:
cmds=[]
for cmd in path:
t=cmd.tag.split('}')[-1];pts=cmd.findall('a:pt',NS);v=' '.join(f'{num(p,"x")} {num(p,"y")}' for p in pts)
if t in ('moveTo','lnTo','cubicBezTo','quadBezTo'):cmds.append({'moveTo':'M','lnTo':'L','cubicBezTo':'C','quadBezTo':'Q'}[t]+v)
elif t=='close':cmds.append('Z')
pw=num(path,'w',w);ph=num(path,'h',h)
out.append(f'<path transform="scale({w/max(1,pw)} {h/max(1,ph)})" d="{" ".join(cmds)}" {attr}/>')
preset=child(sp,'a:prstGeom');prst=preset.get('prst') if preset is not None else ''
if prst=='ellipse':out.append(f'<ellipse cx="{w/2}" cy="{h/2}" rx="{w/2}" ry="{h/2}" {attr}/>')
elif prst=='line':out.append(f'<path d="M0 0 L{w} {h}" {attr}/>')
elif prst and not paths:out.append(f'<rect width="{w}" height="{h}" {attr}/>')
body=child(n,'x:txBody')
if body is not None:
bp=child(body,'a:bodyPr');paras=body.findall('a:p',NS);fs=127000;lineY=num(bp,'tIns',45720)
for para in paras:
runs=para.findall('a:r',NS);defaults=child(para,'a:pPr/a:defRPr');end=child(para,'a:endParaRPr')
if runs:fs=num(child(runs[0],'a:rPr'),'sz',1000)*127
else:fs=num(defaults if defaults is not None else end,'sz',1000)*127
lineY+=fs
pp=child(para,'a:pPr');align=pp.get('algn','l') if pp is not None else 'l'
tx=w/2 if align=='ctr' else w-num(bp,'rIns',91440) if align=='r' else num(bp,'lIns',91440)
spans=[]
for run in runs:
rp=child(run,'a:rPr');t=child(run,'a:t');size=num(rp,'sz',fs/127)*127
spans.append(f'<tspan font-size="{size/9525}" font-weight="{"700" if rp is not None and rp.get("b")=="1" else "400"}" fill="{color(child(rp,"a:solidFill"))}">{esc(t.text or "") if t is not None else ""}</tspan>')
out.append(f'<text transform="scale(9525)" x="{tx/9525}" y="{lineY/9525}" text-anchor="{ "middle" if align=="ctr" else "end" if align=="r" else "start"}" font-family="Malgun Gothic,Arial" font-size="{fs/9525}">{"".join(spans)}</text>')
lineY+=fs*.12
return f'<g transform="{tr}">'+''.join(out)+'</g>'
items=[(3,'criteria-earth',95,27),(4,'criteria-status',51,29),(5,'criteria-structure',102,29),(6,'variables-diagram',16,27)]
for index,name,last,colcount in items:
ws=WB.worksheets[index-1];widths=[]
for c in range(1,colcount+1):
letter=openpyxl.utils.get_column_letter(c);dim=ws.column_dimensions.get(letter)
# Grouped column ranges share the first dimension entry.
if dim is None:dim=next((d for d in ws.column_dimensions.values() if d.min and d.min<=c<=d.max),None)
width=dim.width if dim is not None else ws.sheet_format.defaultColWidth or 8.43
widths.append(math.floor(width*7+5))
heights=[(ws.row_dimensions[r].height or ws.sheet_format.defaultRowHeight or 15)*4/3 for r in range(1,last+1)]
xs=[0];ys=[0]
for v in widths:xs.append(xs[-1]+v)
for v in heights:ys.append(ys[-1]+v)
offset=ys[1];out=[];covered=set();merges={}
for m in ws.merged_cells.ranges:
merges[(m.min_row,m.min_col)]=(m.max_row,m.max_col)
covered.update((r,c) for r in range(m.min_row,m.max_row+1) for c in range(m.min_col,m.max_col+1) if (r,c)!=(m.min_row,m.min_col))
for row in ws.iter_rows(min_row=2,max_row=last,max_col=colcount):
for cell in row:
r,c=cell.row,cell.column
if (r,c) in covered:continue
rr,cc=merges.get((r,c),(r,c));cc=min(cc,colcount);rr=min(rr,last)
x,y,w,h=xs[c-1],ys[r-1]-offset,xs[cc]-xs[c-1],ys[rr]-ys[r-1]
fill=cell.fill.fgColor.rgb if cell.fill.patternType=='solid' and cell.fill.fgColor.type=='rgb' else None
if fill:out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="#{fill[-6:]}"/>')
for side,x1,y1,x2,y2 in [('left',x,y,x,y+h),('right',x+w,y,x+w,y+h),('top',x,y,x+w,y),('bottom',x,y+h,x+w,y+h)]:
b=getattr(cell.border,side)
if b and b.style:out.append(f'<path d="M{x1} {y1} L{x2} {y2}" stroke="black" stroke-width="{1 if b.style=="thin" else 1.4}"/>')
if cell.value is not None:
size=(cell.font.sz or 10)*4/3;align=cell.alignment.horizontal
tx=x+w/2 if align=='center' else x+w-3 if align=='right' else x+3
fc=cell.font.color;rgb='#'+fc.rgb[-6:] if fc and fc.type=='rgb' else 'black'
out.append(f'<text x="{tx}" y="{y+h/2}" dominant-baseline="central" text-anchor="{"middle" if align=="center" else "end" if align=="right" else "start"}" font-family="Malgun Gothic,Arial" font-size="{size}" font-weight="{700 if cell.font.b else 400}" fill="{rgb}">{esc(cell.value)}</text>')
relfile=f'xl/worksheets/_rels/sheet{index}.xml.rels';dre=None
for rel in E.fromstring(Z.read(relfile)):
if rel.get('Type','').endswith('/drawing'):dre=posixpath.normpath(posixpath.join('xl/worksheets',rel.get('Target')))
rels={}
if dre:
for rel in E.fromstring(Z.read(posixpath.dirname(dre)+'/_rels/'+posixpath.basename(dre)+'.rels')):
if rel.get('TargetMode')!='External':rels[rel.get('Id')]=rel.get('Target')
for anchor in E.fromstring(Z.read(dre)):
fr=child(anchor,'x:from');to=child(anchor,'x:to')
def point(p):
c=int(child(p,'x:col').text);r=int(child(p,'x:row').text)
return sum(widths[:c])+float(child(p,'x:colOff').text)/9525,sum(heights[:r])+float(child(p,'x:rowOff').text)/9525-offset
if fr is None:continue
left,top=point(fr)
if to is not None:right,bottom=point(to);aw,ah=right-left,bottom-top
else:ext=child(anchor,'x:ext');aw,ah=num(ext,'cx')/9525,num(ext,'cy')/9525
for shape in anchor:
kind=shape.tag.split('}')[-1]
if kind not in ('grpSp','sp','pic','cxnSp'):continue
xf=child(shape,'x:grpSpPr/a:xfrm' if kind=='grpSp' else 'x:spPr/a:xfrm');x,y,w,h=frame(xf)
# xfrm stores the unrotated source geometry. Cell anchors describe its rotated
# bounding box; rescaling to that box distorts rotated text and grouped figures.
out.append(f'<g transform="translate(0 {-offset}) scale({1/9525})">{render(shape,rels)}</g>')
svg=f'<svg xmlns="http://www.w3.org/2000/svg" width="{xs[-1]}" height="{ys[-1]-offset}" viewBox="0 0 {xs[-1]} {ys[-1]-offset}"><rect width="100%" height="100%" fill="white"/>{"".join(out)}</svg>'
(ROOT/'.cache'/f'{name}.svg').write_text(svg,encoding='utf-8')
print(name,xs[-1],ys[-1]-offset)