Improve drawing export and foundation quantity views
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
a0ec74b09b
commit
d22c5008f0
72 files changed
+24346
-12
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import json,re,hashlib,zipfile,warnings
|
||||
from pathlib import Path
|
||||
import openpyxl
|
||||
warnings.filterwarnings('ignore',category=UserWarning)
|
||||
root=Path(__file__).resolve().parent.parent
|
||||
source=root/'교각수량산출.xlsx';w=openpyxl.load_workbook(source,data_only=False);v=openpyxl.load_workbook(source,data_only=True)
|
||||
data={s.title:{c.coordinate:{'value':c.value,'cached':v[s.title][c.coordinate].value,'format':c.number_format} for row in s for c in row if c.value is not None} for s in w}
|
||||
variables=[]
|
||||
for r in range(18,47):
|
||||
variables.append(dict(key=f'r{r}',row=r,name=w['3.2 관련변수'][f'G{r}'].value,symbol=w['3.2 관련변수'][f'L{r}'].value,unit='%' if r==28 else '개' if r in (39,42) else 'm'))
|
||||
def merged_label(sheet,column,row):
|
||||
cell=sheet.cell(row,column)
|
||||
for merged in sheet.merged_cells.ranges:
|
||||
if merged.min_row<=row<=merged.max_row and merged.min_col<=column<=merged.max_col:return sheet.cell(merged.min_row,merged.min_col).value
|
||||
return cell.value
|
||||
rows=[]
|
||||
for r in list(range(6,32))+list(range(50,77)):
|
||||
s=w['3.3 수량산출_P1'];b=merged_label(s,2,r);e=merged_label(s,5,r);g=merged_label(s,7,r)
|
||||
rows.append(dict(row=r,cell=f'W{r}',name=' / '.join(str(x).replace('\n',' ') for x in [b,e,g] if x),unit=s[f'J{r}'].value,description=s[f'K{r}'].value))
|
||||
meta=dict(sha256=hashlib.sha256(source.read_bytes()).hexdigest(),sheets=[dict(name=s.title,state=s.sheet_state,rows=s.max_row,cols=s.max_column,formulas=sum(c.data_type=='f' for row in s for c in row),merged=len(s.merged_cells.ranges),print_area=str(s.print_area),hidden_rows=[i for i,d in s.row_dimensions.items() if d.hidden],hidden_columns=[i for i,d in s.column_dimensions.items() if d.hidden]) for s in w],names={k:str(x.attr_text) for k,x in w.defined_names.items()},external_links=len(w._external_links),macros=any('vbaProject' in n for n in zipfile.ZipFile(source).namelist()))
|
||||
(root/'template-data.ts').write_text('export type Cell={value:string|number|boolean|null;cached:string|number|boolean|null;format:string};\nexport const template:Record<string,Record<string,Cell>>='+json.dumps(data,ensure_ascii=False,separators=(',',':'),default=str)+';\nexport const variables='+json.dumps(variables,ensure_ascii=False)+';\nexport const quantityRows='+json.dumps(rows,ensure_ascii=False)+';\nexport const templateHash='+json.dumps(meta['sha256'])+';\n',encoding='utf8')
|
||||
(root/'docs/template-analysis.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2),encoding='utf8')
|
||||
md=['# 원본 수량산출서 분석','',f"원본 SHA256: `{meta['sha256']}`",'', '## 시트 구성','']
|
||||
for s in meta['sheets']:md.append(f"- {s['name']}: {s['rows']}행 × {s['cols']}열, 수식 {s['formulas']}개, 병합 {s['merged']}개, {s['state']}")
|
||||
md+=['','## 최소 입력과 원본 수식 매핑','','|변수|기호|단위|입력셀(P1)|','|---|---|---|---|']
|
||||
for x in variables:md.append(f"|{x['name']}|{x['symbol']}|{x['unit']}|3.2 관련변수!N{x['row']}|")
|
||||
md+=['','## 수량항목','','|항목|필요 입력(직접 참조)|실제 수식/고정값|원본 위치|','|---|---|---|---|']
|
||||
for r in rows:
|
||||
f=data['3.3 수량산출_P1'][r['cell']]['value'];refs=sorted(set(re.findall(r'\$?BG\$?\d+',str(f))))
|
||||
md.append(f"|{r['name']}|{', '.join(refs)}|`{f}`|3.3 수량산출_P1!{r['cell']}|")
|
||||
md+=['','## 원본의 중요한 제약','','- 설명식과 실제 수식이 불일치하는 부분을 수정하지 않는다. W51은 표시 Hcol 설명과 달리 Zb-H1-H2-Zs+Zf를 사용한다. 원본 P1 값은 236.80068785698427㎡이다. 기둥 실제 높이와 일치한다고 간주하지 않는다.','- AO8은 풍화암 BG10을 사용하고 AO12는 연암 BG11을 사용한다. 그대로 재현한다.','- W50 설명에는 2(Ac1+Ac2+Ac3)이 있으나 실제 수식은 해당 부분에 계수 2가 없다. W58과 공유되는 곡률 ASIN 식을 그대로 보존한다.','- W63의 규격 표기는 T=300mm이지만 수식은 0.05m를 사용한다. 규격 텍스트와 계산 기준을 검토해야 한다.','- W67은 수식이 아닌 철근량 직접값, K68/K69/K74는 시험/시설 횟수 직접값이다. 자동 수량으로 가장하지 않는다.','- 실제 수식에 ROUND 계열은 없다. 원값으로 계산 후 원본 표시형식(상세 주로 소수3자리, 집계 주로2자리)만 적용한다.','- 13시트 모두 visible, 데이터 유효성 규칙 없음, 외부통합문서 링크와 매크로 없음. 이름 정의 3개는 원본 보존하며 계산 수식에서 사용되지 않는다.','- 토공 심도는 0~8m 구간만 있다. 8m 초과는 원본 적용 범위 밖이다.','- 단일 원형기둥·단일 직사각형기초·균일 말뚝의 곡률 두부보 산식이다. 다기둥/꺾인/비대칭 구조의 수량을 임의 배수로 확장하지 않는다.']
|
||||
(root/'docs/ANALYSIS.md').write_text('\n'.join(md),encoding='utf8')
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,34 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
function Retry([scriptblock]$Action){for($i=0;$i -lt 40;$i++){try{return & $Action}catch{Start-Sleep -Milliseconds 250}};throw 'Excel busy'}
|
||||
$root=Split-Path $PSScriptRoot -Parent
|
||||
$excel=$null;$book=$null
|
||||
try {
|
||||
$excel=New-Object -ComObject Excel.Application
|
||||
Retry {$excel.Visible=$true}
|
||||
Retry {$excel.DisplayAlerts=$false}
|
||||
Retry {$excel.AskToUpdateLinks=$false}
|
||||
Retry {$excel.AutomationSecurity=3}
|
||||
$book=Retry {$excel.Workbooks.Open((Join-Path $root '교각수량산출.xlsx'),0,$true)}
|
||||
$excel.ActiveWindow.DisplayHeadings=$false
|
||||
$excel.ActiveWindow.DisplayGridlines=$false
|
||||
$items=@(
|
||||
@('3.1 수량산출기준_토공','A2:AA95','criteria-earth'),
|
||||
@('3.1 수량산출기준_구조체 현황','A2:AC51','criteria-status'),
|
||||
@('3.1 수량산출기준_구조체','A2:AC102','criteria-structure'),
|
||||
@('3.2 관련변수','A2:AA16','variables-diagram')
|
||||
)
|
||||
foreach($item in $items){
|
||||
$sheet=$book.Worksheets.Item($item[0])
|
||||
$sheet.PageSetup.PrintArea=$item[1]
|
||||
$sheet.PageSetup.PrintHeadings=$false
|
||||
$sheet.PageSetup.PrintGridlines=$false
|
||||
$sheet.PageSetup.Zoom=$false
|
||||
$sheet.PageSetup.FitToPagesWide=1
|
||||
$sheet.PageSetup.FitToPagesTall=1
|
||||
$sheet.ExportAsFixedFormat(0,(Join-Path $root ('.cache/'+$item[2]+'.pdf')))
|
||||
Write-Output $item[2]
|
||||
}
|
||||
} finally {
|
||||
if($book){Retry {$book.Close($false)};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($book)}
|
||||
if($excel){Retry {$excel.Quit()};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Extract original worksheet layout for the in-app preview without saving the template."""
|
||||
from pathlib import Path
|
||||
import json,warnings,openpyxl
|
||||
from openpyxl.utils import get_column_letter
|
||||
warnings.filterwarnings('ignore')
|
||||
root=Path(__file__).resolve().parent.parent;w=openpyxl.load_workbook(root/'교각수량산출.xlsx')
|
||||
layouts={};styles={}
|
||||
def color(c,fallback):
|
||||
if c is None:return fallback
|
||||
if c.type=='rgb':return '#'+c.rgb[-6:]
|
||||
if c.type=='indexed':
|
||||
from openpyxl.styles.colors import COLOR_INDEX
|
||||
return '#'+COLOR_INDEX[c.indexed][-6:] if c.indexed<len(COLOR_INDEX) else fallback
|
||||
return fallback
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
for s in w:
|
||||
name=s.title
|
||||
area=str(s.print_area).split('!')[-1].replace('$','').replace("'",'')
|
||||
firstcol,firstrow,lastcol,lastrow=range_boundaries(area)
|
||||
cells={};widths=[]
|
||||
for c in range(1,lastcol+1):
|
||||
width=s.sheet_format.defaultColWidth or 8.43
|
||||
for d in s.column_dimensions.values():
|
||||
if (d.min or 0)<=c<=(d.max or d.min or 0):width=d.width
|
||||
widths.append(round(width*7+5,2))
|
||||
for row in s.iter_rows(min_row=firstrow,max_row=lastrow,max_col=lastcol):
|
||||
for c in row:
|
||||
border={}
|
||||
for side in ['top','bottom','left','right']:
|
||||
b=getattr(c.border,side)
|
||||
if b and b.style:border[side]={'width':2 if b.style in ['medium','thick','double'] else 1,'style':'dashed' if b.style in ['dashed','dotted','dashDot'] else 'solid','color':color(b.color,'#222222')}
|
||||
style={'font':c.font.name or 'Malgun Gothic','size':c.font.sz or 8,'bold':bool(c.font.bold),'italic':bool(c.font.italic),'color':color(c.font.color,'#111111'),'background':color(c.fill.fgColor,'#ffffff') if c.fill.patternType=='solid' else '#ffffff','align':c.alignment.horizontal or ('right' if isinstance(c.value,(int,float)) else 'left'),'valign':c.alignment.vertical or 'center','border':border,'wrap':bool(c.alignment.wrap_text)}
|
||||
if c.value is not None or c.has_style:
|
||||
styles[c.style_id]=style;cells[c.coordinate]=c.style_id
|
||||
layouts[name]={'firstRow':firstrow,'rows':lastrow,'cols':lastcol,'widths':widths,'heights':[round((s.row_dimensions[r].height or s.sheet_format.defaultRowHeight or 15)*4/3,2) for r in range(1,lastrow+1)],'merges':[[m.min_row,m.min_col,m.max_row,m.max_col] for m in s.merged_cells.ranges if m.min_row<=lastrow and m.min_col<=lastcol],'cells':cells}
|
||||
(root/'template-layout.ts').write_text('export const sheetLayouts='+json.dumps(layouts,ensure_ascii=False,separators=(',',':'))+';\nexport const excelStyles='+json.dumps(styles,ensure_ascii=False,separators=(',',':'))+';\n',encoding='utf8')
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
|
||||
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
|
||||
console.log((await w.inspect({kind:'sheet',include:'id,name',maxChars:1500})).ndjson);
|
||||
console.log((await w.inspect({kind:'region',sheetId:'3.3 수량산출_P1',range:'W50:W64',maxChars:1600})).ndjson);
|
||||
const preview=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});
|
||||
await fs.writeFile('수량산출/.cache/template-preview.png',new Uint8Array(await preview.arrayBuffer()));
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Keep the original OOXML package (including WMF and shapes); patch selected cells only."""
|
||||
from pathlib import Path
|
||||
import zipfile,json,sys,re,hashlib,html,base64
|
||||
from xml.etree import ElementTree as ET
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
NS={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
def write(payload_path,output_path):
|
||||
source=ROOT/'교각수량산출.xlsx';output=Path(output_path).resolve()
|
||||
if not output.is_relative_to((ROOT/'outputs').resolve()) or output.exists():raise ValueError('결과 경로는 수량산출/outputs 내부의 새 파일이어야 합니다.')
|
||||
data=json.loads(Path(payload_path).read_text(encoding='utf-8-sig'))
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest()!=data['templateHash']:raise ValueError('원본 템플릿이 변경되었습니다. 분석을 다시 실행하세요.')
|
||||
with zipfile.ZipFile(source) as zin:
|
||||
wb=ET.fromstring(zin.read('xl/workbook.xml'));rels=ET.fromstring(zin.read('xl/_rels/workbook.xml.rels'))
|
||||
targets={r.attrib['Id']:r.attrib['Target'] for r in rels}
|
||||
paths={s.attrib['name']:('xl/'+targets[s.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']].lstrip('/').removeprefix('xl/')) for s in wb.find('s:sheets',NS)}
|
||||
updates={}
|
||||
for sheet,edits in data['edits'].items():
|
||||
path=paths[sheet];xml=zin.read(path).decode('utf8')
|
||||
for cell,change in edits.items():
|
||||
pattern=r'<c\b([^>]*\br="'+re.escape(cell)+r'"[^>]*)(?:>(.*?)</c>|/>)'
|
||||
def replace(match):
|
||||
attrs=match[1];body=match[2] or ''
|
||||
if 'value' in change:
|
||||
attrs=re.sub(r'\s+t="[^"]*"','',attrs);v=change['value']
|
||||
if v is None:return '<c'+attrs+'/>'
|
||||
if isinstance(v,str):return '<c'+attrs+' t="inlineStr"><is><t xml:space="preserve">'+html.escape(v)+'</t></is></c>'
|
||||
return '<c'+attrs+'><v>'+str(v)+'</v></c>'
|
||||
if '<f' not in body:raise ValueError('수식 캐시 대상에 수식이 없습니다: '+sheet+'!'+cell)
|
||||
if 'formula' in change:body=re.sub(r'<f\b[^>]*>.*?</f>',lambda _: '<f>'+html.escape(change['formula'].removeprefix('='))+'</f>',body,flags=re.S)
|
||||
body=re.sub(r'<v(?:\s[^>]*)?>.*?</v>|<v\s*/>','',body,flags=re.S)
|
||||
return '<c'+attrs+'>'+body+'<v>'+str(change['cached'])+'</v></c>'
|
||||
xml,count=re.subn(pattern,replace,xml,count=1,flags=re.S)
|
||||
if count!=1:raise ValueError('기존 입력셀을 찾을 수 없습니다: '+sheet+'!'+cell)
|
||||
updates[path]=xml.encode('utf8')
|
||||
xml=zin.read('xl/workbook.xml').decode('utf8')
|
||||
xml=re.sub(r'<calcPr\b[^>]*/>', '<calcPr calcId="191029" fullCalcOnLoad="1" forceFullCalc="1"/>',xml)
|
||||
updates['xl/workbook.xml']=xml.encode('utf8')
|
||||
updates['xl/_rels/workbook.xml.rels']=re.sub(r'<Relationship\b[^>]*Type="[^"]*/calcChain"[^>]*/>','',zin.read('xl/_rels/workbook.xml.rels').decode('utf8')).encode('utf8')
|
||||
updates['[Content_Types].xml']=re.sub(r'<Override\b[^>]*PartName="/xl/calcChain.xml"[^>]*/>','',zin.read('[Content_Types].xml').decode('utf8')).encode('utf8')
|
||||
additions={}
|
||||
if data.get('planPng'):
|
||||
image=base64.b64decode(data['planPng'],validate=True)
|
||||
if not image.startswith(b'\x89PNG\r\n\x1a\n') or len(image)>1200000:raise ValueError('KEY PLAN PNG가 올바르지 않습니다.')
|
||||
# Only the visible 3.2 sample alignment picture is replaced. Source
|
||||
# workbook and all calculation cells / other figures remain intact.
|
||||
drawing=ET.fromstring(zin.read('xl/drawings/drawing5.xml'))
|
||||
dns={'x':'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing','a':'http://schemas.openxmlformats.org/drawingml/2006/main'}
|
||||
target=next(a for a in drawing if a.find('x:from/x:col',dns).text=='0')
|
||||
rid=target.find('.//a:blip',dns).get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
|
||||
relpath='xl/drawings/_rels/drawing5.xml.rels'
|
||||
relxml=zin.read(relpath).decode('utf8')
|
||||
pattern=r'<Relationship\b(?=[^>]*\bId="'+re.escape(rid)+r'")[^>]*/>'
|
||||
relxml,count=re.subn(pattern,lambda m:re.sub(r'Target="[^"]*"','Target="../media/quantity-project-plan.png"',m[0]),relxml)
|
||||
if count!=1:raise ValueError('3.2 KEY PLAN 연결을 찾지 못했습니다.')
|
||||
updates[relpath]=relxml.encode('utf8')
|
||||
additions['xl/media/quantity-project-plan.png']=image
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||
for info in zin.infolist():
|
||||
if info.filename=='xl/calcChain.xml':continue
|
||||
zout.writestr(info,updates.get(info.filename,zin.read(info.filename)))
|
||||
for name,content in additions.items():zout.writestr(name,content)
|
||||
report={'status':'generated','sheets':len(paths),'patched_cells':sum(map(len,data['edits'].values())),'original_preserved':True,'straight_limit_formulas':sum('formula' in c for cells in data['edits'].values() for c in cells.values()),'unchanged_package_parts':len(zin.namelist())-len(updates)-('xl/calcChain.xml' in zin.namelist()),'formula_policy':'P1 source formulas with explicit straight-limit overrides when applicable; P2-P6 unused blank; native full recalculation on open'}
|
||||
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
|
||||
return report
|
||||
if __name__=='__main__':
|
||||
try:print(json.dumps(write(sys.argv[1],sys.argv[2]),ensure_ascii=False))
|
||||
except Exception as e:print(json.dumps({'error':str(e)},ensure_ascii=False));sys.exit(1)
|
||||
@@ -0,0 +1,3 @@
|
||||
import fs from 'node:fs/promises';import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
|
||||
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
|
||||
const png=await w.render({sheetName:'3.1 수량산출기준_구조체',range:'A1:AD90',scale:1.3,format:'png'});await fs.writeFile('수량산출/.cache/criteria-reference.png',new Uint8Array(await png.arrayBuffer()));
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs/promises';import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
|
||||
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
|
||||
for(const [sheetName,range,name]of [['3.1 수량산출기준_토공','A2:AA95','criteria-earth'],['3.1 수량산출기준_구조체 현황','A2:AC51','criteria-status'],['3.1 수량산출기준_구조체','A2:AC102','criteria-structure'],['3.2 관련변수','A2:AA16','variables-diagram']]){
|
||||
w.worksheets.getItem(sheetName).showGridLines=false;const image=await w.render({sheetName,range,scale:1.25,format:'png'});await fs.writeFile('수량산출/assets/'+name+'.png',new Uint8Array(await image.arrayBuffer()));console.log(name);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
|
||||
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/outputs/verification/운곡교P1_모델연동.xlsx'));
|
||||
w.recalculate();
|
||||
const checks=JSON.parse(await fs.readFile('수량산출/.cache/artifact-expected.json','utf8')),differences=[];
|
||||
for(const c of checks){const actual=Number(w.worksheets.getItem(c.sheet).getRange(c.cell).values[0][0]);if(!Number.isFinite(actual)||Math.abs(actual-c.value)>1e-8*Math.max(1,Math.abs(c.value)))differences.push({...c,actual});}
|
||||
await fs.writeFile('수량산출/docs/artifact-verification.json',JSON.stringify({checked:checks.length,differences,engine:'Artifact Tool recalculation of generated model workbook'},null,2));
|
||||
const png=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});await fs.writeFile('수량산출/.cache/model-output-preview.png',new Uint8Array(await png.arrayBuffer()));console.log({checked:checks.length,differences:differences.length});if(differences.length)process.exitCode=1;
|
||||
@@ -0,0 +1,17 @@
|
||||
param([string]$Source,[string]$Destination,[string]$Expected,[string]$Report)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(Test-Path -LiteralPath $Destination){throw '검증 대상 파일이 이미 있습니다.'}
|
||||
Copy-Item -LiteralPath $Source -Destination $Destination
|
||||
function Invoke-ExcelCall([scriptblock]$Action){for($attempt=0;$attempt -lt 30;$attempt++){try{return & $Action}catch{if($_.Exception.HResult -notin @(-2147418111,-2147417846)){throw};Start-Sleep -Milliseconds 300}};throw 'Excel COM 호출 대기시간 초과'}
|
||||
$excel=$null;$book=$null
|
||||
try{
|
||||
$excel=New-Object -ComObject Excel.Application
|
||||
Invoke-ExcelCall {$excel.Visible=$false;$excel.DisplayAlerts=$false;$excel.AskToUpdateLinks=$false;$excel.AutomationSecurity=3}
|
||||
$book=Invoke-ExcelCall {$excel.Workbooks.Open($Destination,0,$false)}
|
||||
Invoke-ExcelCall {$excel.CalculateFullRebuild()}
|
||||
$checks=Get-Content -LiteralPath $Expected -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$errors=@();$count=0
|
||||
foreach($item in $checks){$sheet=Invoke-ExcelCall {$book.Worksheets.Item($item.sheet)};$v=Invoke-ExcelCall {$sheet.Range($item.cell).Value2};$count++;if($v -isnot [double] -and $v -isnot [int]){$errors+="$($item.sheet)!$($item.cell): $v"}elseif([Math]::Abs([double]$v-$item.value) -gt 1e-8*[Math]::Max(1,[Math]::Abs($item.value))){$errors+="$($item.sheet)!$($item.cell): $v != $($item.value)"};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($sheet)}
|
||||
Invoke-ExcelCall {$book.Save()}
|
||||
@{engine='Microsoft Excel';checked=$count;differences=$errors;worksheets=$book.Worksheets.Count}|ConvertTo-Json -Depth 4|Set-Content -LiteralPath $Report -Encoding utf8
|
||||
}finally{if($book){try{Invoke-ExcelCall {$book.Close($false)}}catch{};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($book)};if($excel){try{Invoke-ExcelCall {$excel.Quit()}}catch{};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)};[GC]::Collect();[GC]::WaitForPendingFinalizers()}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
|
||||
import fs from 'node:fs/promises';
|
||||
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/outputs/verification/원본P1_수식검증.xlsx'));
|
||||
w.recalculate();
|
||||
console.log((await w.inspect({kind:'region',sheetId:'3.3 수량산출_P1',range:'W50:W64',maxChars:1800})).ndjson);
|
||||
const png=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});await fs.writeFile('수량산출/.cache/output-preview.png',new Uint8Array(await png.arrayBuffer()));
|
||||
@@ -0,0 +1,28 @@
|
||||
import zipfile,hashlib,xml.etree.ElementTree as E,json,re,warnings
|
||||
from pathlib import Path
|
||||
warnings.filterwarnings('ignore')
|
||||
import openpyxl
|
||||
root=Path(__file__).resolve().parent.parent
|
||||
source=root/'교각수량산출.xlsx';output=root/'outputs/verification/운곡교P1_모델연동.xlsx'
|
||||
ns={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
with zipfile.ZipFile(source) as a,zipfile.ZipFile(output) as b:
|
||||
required=[p for p in a.namelist() if any(t in p for t in ('xl/media/','xl/drawings/','xl/styles.xml','xl/theme/','xl/printerSettings/'))]
|
||||
changed=[p for p in required if a.read(p)!=b.read(p)]
|
||||
assert not changed,changed
|
||||
sheet='xl/worksheets/sheet8.xml';sa=E.fromstring(a.read(sheet));sb=E.fromstring(b.read(sheet))
|
||||
formula=lambda s:{c.attrib['r']:E.tostring(c.find('s:f',ns),encoding='unicode') for c in s.findall('.//s:c',ns) if c.find('s:f',ns)is not None}
|
||||
original_formulas=formula(sa);output_formulas=formula(sb)
|
||||
changed_formulas=[c for c in original_formulas if original_formulas[c]!=output_formulas.get(c)]
|
||||
assert set(changed_formulas).issubset({'W50','W58'}),changed_formulas
|
||||
for p in [p for p in a.namelist() if re.fullmatch(r'xl/worksheets/sheet\d+.xml',p)]:
|
||||
ra=E.fromstring(a.read(p));rb=E.fromstring(b.read(p))
|
||||
for tag in ['mergeCells','pageSetup','pageMargins','printOptions','rowBreaks','colBreaks','cols']:
|
||||
x=ra.find('s:'+tag,ns);y=rb.find('s:'+tag,ns)
|
||||
assert (E.tostring(x)if x is not None else None)==(E.tostring(y)if y is not None else None),(p,tag)
|
||||
w=openpyxl.load_workbook(output,data_only=False);v=openpyxl.load_workbook(output,data_only=True)
|
||||
errors=[(s.title,c.coordinate,c.value) for s in v for row in s for c in row if c.data_type=='e']
|
||||
assert not errors,errors
|
||||
assert all(w['3.3 수량산출_P2'][f'W{r}'].value is None for r in list(range(6,32))+list(range(50,77)))
|
||||
report={'sheets':len(w.sheetnames),'protected_parts_byte_identical':len(required),'active_pier_formulas_total':len(formula(sa)),'active_pier_formulas_unchanged':len(formula(sa))-len(changed_formulas),'straight_limit_formulas':changed_formulas,'merges_print_settings_column_widths_preserved':True,'cached_excel_errors':errors,'unused_pier_quantities_blank':True,'template_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model_linked_values':{f'N{r}':v['3.2 관련변수'][f'N{r}'].value for r in [18,19,24,25,31,32,33,34,35,36,38,39,42]},'native_excel_recalculation':'not_verified: Excel COM rejected calls after initialization; artifact recalculation and original cached-result comparison used'}
|
||||
(root/'docs/package-verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
|
||||
print(json.dumps(report,ensure_ascii=False))
|
||||
Reference in new issue
Block a user