Improve drawing export and foundation quantity views

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-06 15:35:56 +09:00
1 parent a0ec74b09b
commit d22c5008f0
72 files changed
+24346 -12

No files matched your search

+32
View File
@@ -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')
+150
View File
@@ -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)
+34
View File
@@ -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)}
}
+36
View File
@@ -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')
+7
View File
@@ -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()));
+68
View File
@@ -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)
+3
View File
@@ -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;
+17
View File
@@ -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()}
+6
View File
@@ -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()));
+28
View File
@@ -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))