69 lines
5.8 KiB
Python
69 lines
5.8 KiB
Python
"""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)
|