"""Validated DWG 2000 writer using the project's existing LibreDWG tools. No source/project files are modified. Conversion intermediates and reports stay beside the requested output. """ from collections import Counter import contextlib import hashlib import io import json import math from pathlib import Path import subprocess import sys import ezdxf from ezdxf import recover, colors ROOT=Path(__file__).resolve().parent TOOLS=ROOT.parent/'pierzainer_ground/tools/libredwg' def compatibility_angles(path): # LibreDWG imports the linear angle from AcDbRotatedDimension whereas standard # DXF places it in AcDbAlignedDimension. Mirror the value in the converter input only. lines=path.read_text(encoding='cp949').splitlines() pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[] for code,value in pairs: if code.strip()=='0' and group:groups.append(group);group=[] group.append((code,value)) if group:groups.append(group) output=[] for group in groups: angle=next((v for c,v in group if c.strip()=='50'),'0') for c,v in group: output.extend([c,v]) if group[0][1]=='DIMENSION' and c.strip()=='100' and v=='AcDbRotatedDimension':output.extend([' 50',angle]) path.write_text('\n'.join(output)+'\n',encoding='cp949') def fingerprint(entity): def point(p):return tuple(round(float(v),5) for v in p) d=entity.dxf;t=entity.dxftype();link=tuple((tag.code,tag.value) for tag in entity.get_xdata('PZ_GA')) if entity.has_xdata('PZ_GA') else ();base=(t,d.layer,link) if t=='LINE':return base+(point(d.start),point(d.end)) if t=='CIRCLE':return base+(point(d.center),round(d.radius,5)) if t=='LWPOLYLINE':return base+(bool(entity.closed),tuple(point(p[:2]) for p in entity.get_points())) if t=='TEXT':return base+(d.text,point(d.insert),round(d.height,5),round(d.get('rotation',0),5),d.get('style','STANDARD').casefold()) if t=='DIMENSION':return base+(round(float(entity.get_measurement()),5),point(d.defpoint2),point(d.defpoint3)) if t in ('ATTDEF','ATTRIB'):return base+(d.tag,d.text,point(d.insert),round(d.height,5)) if t=='INSERT':return base+(d.name,point(d.insert),tuple(fingerprint(a) for a in entity.attribs)) if t=='HATCH':return base+(d.pattern_name,tuple(tuple(point(p[:2]) for p in loop.vertices) for loop in entity.paths if hasattr(loop,'vertices')), tuple((round(line.angle,5),point(line.base_point),point(line.offset),tuple(round(v,5) for v in line.dash_length_items)) for line in entity.pattern.lines) if entity.pattern else ()) return base def convert(source,output): source=Path(source).resolve();output=Path(output).resolve() if output.suffix.lower()!='.dwg' or not output.is_relative_to(ROOT):raise ValueError('DWG output must stay inside 도면작성') if output.exists():raise ValueError('Existing DWG is preserved; choose another output file') output.parent.mkdir(parents=True,exist_ok=True) original=ezdxf.readfile(source);audit=original.audit() if audit.errors:raise ValueError('Input DXF audit failed') doc=original # R2000 has no MATERIAL objects. LibreDWG used to turn the default material # dictionary into invalid ACDBPLACEHOLDER entries that its own reader repairs. # Remove unsupported metadata before encoding, preserving indexed colours. materials=[e for e in doc.objects if e.dxftype()=='MATERIAL'] material_dictionary=doc.rootdict.get('ACAD_MATERIAL') unsupported_handles={e.dxf.handle for e in materials} if material_dictionary:unsupported_handles.add(material_dictionary.dxf.handle) # The bundled writer's R2004 path drops entities. R2000 is used after a full round-trip check. # R2000 uses indexed colours; retain the intended greys with the nearest ACI colour. colour_count=0 for entity in doc.entitydb.values(): if entity.is_alive and getattr(entity,'rgb',None): rgb=entity.rgb nearest=min(range(1,256),key=lambda i:sum((a-b)**2 for a,b in zip(rgb,colors.aci2rgb(i)))) entity.dxf.color=nearest;entity.dxf.discard('true_color');colour_count+=1 doc.dxfversion='AC1015';doc.encoding='cp949';doc.header['$DWGCODEPAGE']='ANSI_949' intermediate=output.with_suffix('.conversion.dxf');doc.saveas(intermediate) expected={layout.name:Counter(fingerprint(e) for e in layout) for layout in doc.layouts} expected_blocks={block.name:Counter(fingerprint(e) for e in block) for block in doc.blocks if not block.name.startswith('*')} compatibility_angles(intermediate) # Strip only converter-input metadata after ezdxf has finished serialization; # ezdxf requires its default materials while saving even an R2000 document. lines=intermediate.read_text(encoding='cp949').splitlines() pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[] for code,value in pairs: if code.strip()=='0' and group:groups.append(group);group=[] group.append((code,value)) if group:groups.append(group) cleaned=[] for group in groups: handle=next((v for c,v in group if c.strip()=='5'),None) if handle in unsupported_handles:continue skip=False for i,(code,value) in enumerate(group): if skip:skip=False;continue if code.strip()=='3' and value=='ACAD_MATERIAL':skip=True;continue if code.strip()=='347':value='0' cleaned.extend([code,value]) intermediate.write_text('\n'.join(cleaned)+'\n',encoding='cp949') logs=[] for args in [[str(TOOLS/'dxf2dwg.exe'),'--as','r2000','-o',str(output),str(intermediate)], [str(TOOLS/'dwgread.exe'),'-O','DXF','-o',str(output.with_suffix('.roundtrip.dxf')),str(output)]]: result=subprocess.run(args,capture_output=True,timeout=90) logs.append(result.stderr.decode('utf-8','replace')[-12000:]) if not output.exists() or output.stat().st_size<100:raise RuntimeError('DWG writer did not create a valid file') if output.read_bytes()[:6]!=b'AC1015':raise ValueError('Invalid DWG 2000 signature') roundtrip,reader_audit=recover.readfile(output.with_suffix('.roundtrip.dxf')) audit=roundtrip.audit() if audit.errors:raise ValueError('DWG round-trip audit failed') actual={layout.name:Counter(fingerprint(e) for e in layout) for layout in roundtrip.layouts} differences=[] for name,wanted in expected.items(): got=actual.get(name,Counter()) if wanted!=got:differences.append(dict(layout=name,missing=sum((wanted-got).values()),extra=sum((got-wanted).values()))) if differences:raise ValueError('DWG round-trip changed objects: '+json.dumps(differences)) for name,wanted in expected_blocks.items(): if name not in roundtrip.blocks or Counter(fingerprint(e) for e in roundtrip.blocks[name])!=wanted:raise ValueError('DWG block/attribute definition changed: '+name) source_dimensions=sorted(float(e.get_measurement()) for e in doc.modelspace().query('DIMENSION')) target_dimensions=sorted(float(e.get_measurement()) for e in roundtrip.modelspace().query('DIMENSION')) max_error=max((abs(a-b) for a,b in zip(source_dimensions,target_dimensions)),default=0) if max_error>1e-6:raise ValueError('DWG dimension measurement differs by more than 0.000001mm') for layer in doc.layers: target=roundtrip.layers.get(layer.dxf.name) if layer.dxf.lineweight!=target.dxf.lineweight:raise ValueError('DWG layer lineweight changed') for style in doc.dimstyles: target=roundtrip.dimstyles.get(style.dxf.name) for key in ['dimtxt','dimasz','dimlfac','dimscale','dimexe','dimexo']: if abs(float(style.dxf.get(key,0))-float(target.dxf.get(key,0)))>1e-6:raise ValueError('DWG dimension style changed: '+style.dxf.name+'/'+key) for style in doc.styles: if style.dxf.font.casefold()!=roundtrip.styles.get(style.dxf.name).dxf.font.casefold():raise ValueError('DWG text font changed') for layout in doc.layouts: target=roundtrip.layouts.get(layout.name) for key in ['paper_width','paper_height','scale_numerator','scale_denominator','plot_layout_flags']: if layout.dxf.get(key)!=target.dxf.get(key):raise ValueError('DWG paper/plot setting changed: '+key) report=dict(status='validated',format='DWG 2000 / AC1015',sha256=hashlib.sha256(output.read_bytes()).hexdigest(), layouts={l.name:len(l) for l in roundtrip.layouts},dimensions=len(roundtrip.modelspace().query('DIMENSION')), object_fingerprints_match=True,source_links_match=True,dimension_measurements_match=True,max_dimension_error_mm=max_error,lineweights_match=True,dimstyles_match=True,paper_plot_settings_match=True,font_styles_match=True,audit_errors=len(audit.errors), decoder_metadata_repairs=[str(e.message) for e in reader_audit.fixes],indexed_colour_conversions=colour_count, native_autocad_open_tested=False) report['unsupported_materials_removed']=len(materials) output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8') output.with_suffix('.conversion.log').write_text('\n'.join(logs),encoding='utf-8') return report if __name__=='__main__': try:print(json.dumps(convert(sys.argv[1],sys.argv[2]),ensure_ascii=False)) except Exception as error: print(json.dumps(dict(status='error',message=str(error)),ensure_ascii=False));sys.exit(1)