초기 정리: PIT3000 분석 자료 업로드

- 좌표/SQL/API 분석 문서화
- 재현 스크립트와 예시 응답 추가
- 개인정보/원본 실행파일 제외
This commit is contained in:
2026-06-29 00:27:06 +00:00
commit bde81cdc7e
18 changed files with 3393 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
from __future__ import annotations
import json, math, struct, sys
from pathlib import Path
CLASSES=[
b'TfrxMemoView', b'TfrxShapeView', b'TfrxLineView', b'TfrxPictureView',
b'TfrxReport', b'TfrxReportPage', b'TfrxMasterData', b'TfrxDetailData',
b'TfrxHeader', b'TfrxFooter', b'TfrxPageHeader', b'TfrxPageFooter',
b'TfrxDBDataset', b'TfrxUserDataSet', b'TfrxDataPage', b'TfrxDialogPage',
# QuickReport / QRDesign objects used by older PIT A4 forms
b'TQuickRep', b'TQRBand', b'TQRChildBand', b'TQRLabel', b'TQRDBText', b'TQRShape',
b'TQRRichText', b'TQRExpr', b'TQRImage', b'TQRDBImage', b'TQRCompositeReport',
]
# Keep essentially all useful report-layout/data-binding properties. Large binary payloads
# are still summarized by read_value() as {'binary_len': N}.
PROP_KEEP={
'Left','Top','Width','Height','Font.Height','Font.Name','Font.Style','Font.Charset','Font.Color',
'HAlign','VAlign','Memo.UTF8W','Memo.Text','Text','Caption','Frame.Typ','Frame.Width','Frame.Color',
'Color','DataSet','DataSetName','DataField','DataSource','FieldName','Master','Detail','Filter',
'Format','FormatStr','DisplayFormat','ExpressionDelimiters','Name','Tag','Visible','Printable',
'StretchMode','WordWrap','AutoWidth','CharSpacing','LineSpacing','GapX','GapY','Rotation','Align',
'Band','ParentFont','Transparent','BrushStyle','Frame.Style','Frame.ShadowWidth','Frame.ShadowColor',
'Restrictions','Page','PaperWidth','PaperHeight','PaperSize','Orientation','Margins.Left','Margins.Top',
'Margins.Right','Margins.Bottom','Columns','ColumnWidth','ColumnPositions','RowCount','Version',
}
# Delphi DFM binary value kinds (subset)
VK={0:'Null',1:'List',2:'Int8',3:'Int16',4:'Int32',5:'Extended',6:'String',7:'Ident',8:'False',9:'True',10:'Binary',11:'Set',12:'LString',13:'Nil',14:'Collection',15:'Single',16:'Currency',17:'Date',18:'WString',19:'Int64',20:'UTF8String'}
def ext80(b: bytes) -> float:
if len(b)!=10: return math.nan
lo=int.from_bytes(b[:8],'little')
se=int.from_bytes(b[8:10],'little')
sign=-1 if (se & 0x8000) else 1
exp=se & 0x7fff
if exp==0 and lo==0: return 0.0
if exp==0x7fff: return math.inf*sign
# 80-bit extended has explicit integer bit, value = lo/2^63 * 2^(exp-bias)
return sign * (lo/(1<<63)) * (2**(exp-16383))
def read_lp(data,i):
if i>=len(data): raise EOFError
n=data[i]; i+=1
if i+n>len(data): raise EOFError
try: s=data[i:i+n].decode('utf-8')
except UnicodeDecodeError:
try: s=data[i:i+n].decode('cp949')
except Exception: s=data[i:i+n].decode('latin1','replace')
return s,i+n
def read_value(data,i):
if i>=len(data): raise EOFError
tag=data[i]; i+=1
if tag==0: return None,i
if tag==1:
vals=[]
# Delphi DFM vaList: nested values terminated by vaNull (0).
while i < len(data):
if data[i] == 0:
i += 1
break
v,i = read_value(data,i)
vals.append(v)
return vals,i
if tag==2:
v=struct.unpack_from('<b',data,i)[0]; return v,i+1
if tag==3:
v=struct.unpack_from('<h',data,i)[0]; return v,i+2
if tag==4:
v=struct.unpack_from('<i',data,i)[0]; return v,i+4
if tag==5:
return ext80(data[i:i+10]), i+10
if tag==6 or tag==7:
return read_lp(data,i)
if tag==8: return False,i
if tag==9: return True,i
if tag==10:
# binary blob: int32 length + bytes
if i+4>len(data): raise EOFError
n=struct.unpack_from('<I',data,i)[0]; i+=4
return {'binary_len':n}, min(len(data),i+n)
if tag==11:
vals=[]
while i < len(data) and data[i]!=0:
s,i=read_lp(data,i); vals.append(s)
if i<len(data) and data[i]==0: i+=1
return vals,i
if tag==12:
if i+4>len(data): raise EOFError
n=struct.unpack_from('<I',data,i)[0]; i+=4
raw=data[i:i+n]; i+=n
return raw.decode('cp949','replace'),i
if tag==15:
return struct.unpack_from('<f',data,i)[0],i+4
if tag==18:
if i+4>len(data): raise EOFError
n=struct.unpack_from('<I',data,i)[0]; i+=4
raw=data[i:i+n*2]; i+=n*2
return raw.decode('utf-16le','replace'),i
if tag==19:
return struct.unpack_from('<q',data,i)[0],i+8
if tag==20:
if i+4>len(data): raise EOFError
n=struct.unpack_from('<I',data,i)[0]; i+=4
raw=data[i:i+n]; i+=n
return raw.decode('utf-8','replace'),i
# unknown; fail to let caller resync
raise ValueError(f'unknown tag {tag} at {i-1:x}')
def find_objects(data):
starts=[]
for cls in CLASSES:
pos=0
while True:
j=data.find(cls,pos)
if j<0: break
if j>0 and data[j-1]==len(cls):
starts.append(j-1)
pos=j+1
return sorted(set(starts))
def parse_object_at(data,start,next_start=None):
i=start
cls,i=read_lp(data,i)
name,i=read_lp(data,i)
props={}
limit=next_start if next_start else min(len(data), start+5000)
# Parse until next object start; binary blobs can skip far, so limit may be before i after Picture.Data.
while i < len(data) and i < limit:
if data[i]==0:
i+=1; continue
# Avoid eating next object header as property if at a known start.
if i in OBJECT_STARTS and i!=start:
break
try:
prop,i2=read_lp(data,i)
except Exception:
break
if not prop or len(prop)>80 or any(ord(ch)<32 for ch in prop):
break
try:
val,i3=read_value(data,i2)
except Exception:
# Some extracted chunks are not aligned; advance one byte and try to keep scanning.
i=i+1
continue
if prop in PROP_KEEP or prop in {'Tag','Name'}:
props[prop]=val
i=i3
return {'class':cls,'name':name,'offset':start,'props':props}
def main(path):
global OBJECT_STARTS
data=Path(path).read_bytes()
OBJECT_STARTS=find_objects(data)
objs=[]
for idx,s in enumerate(OBJECT_STARTS):
ns=OBJECT_STARTS[idx+1] if idx+1<len(OBJECT_STARTS) else None
try:
o=parse_object_at(data,s,ns)
if o['class'] in [c.decode() for c in CLASSES]:
objs.append(o)
except Exception:
pass
print(json.dumps({'file':Path(path).name,'objects':objs}, ensure_ascii=False, indent=2))
if __name__=='__main__':
main(sys.argv[1])

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv, re, json, zipfile
from pathlib import Path
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
ROOT=Path('/root/work/pharmit-ghidra')
LAYOUT=ROOT/'out/a4_receipt_template_candidates/quickreport_receipt_deep/quickreport_receipt_canonical_top.csv'
RAW=ROOT/'out/a4_receipt_three_raw.txt'
OUT=ROOT/'out/a4_receipt_quickreport_render_samples'
OUT.mkdir(parents=True, exist_ok=True)
SCALE=72/96
FONT='HYGothic-Medium'
try:
pdfmetrics.registerFont(UnicodeCIDFont(FONT))
except Exception:
FONT='Helvetica'
# QuickReport labels found in PIT template -> actual SQL field names from A4 amount query.
FIELD_MAP={
# common receipt form
'Q_paname':'PANAME', 'Q_name2':'PANAME', 'Q_num':'PANUM',
'Q_InDate':'INDATE', 'Q_Year':'_YEAR', 'Q_year':'_YEAR', 'Q_year1':'_YEAR',
'Q_Month':'_MONTH', 'Q_mon':'_MONTH', 'Q_mon1':'_MONTH',
'Q_day':'_DAY', 'Q_day1':'_DAY', 'Q_Days':'_DAYS',
'Q_PreSerial':'PRESERIAL', 'QR_PreSerial':'PRESERIAL',
'Q_Price_A':'TOT_C', 'QR_Price_a':'TOT_C',
'Q_Price_p':'BON_C', 'QR_PriceP':'BON_C', 'QR_Price_psum':'BI_CNT',
'Q_Price_c':'CHUNG_C', 'QR_PriceC':'CHUNG_C', 'QR_Pricec1':'CHUNG_C',
'Q_s_FastMon':'BI_PRICE', 'QR_PriceN':'BI_PRICE',
'Q_Price_n':'_PAY_TOTAL',
'Q_Card':'CARD_C', 'Q_Cash':'CHASH_C', 'Q_PCash':'PAPER_C',
'Q_TotGum':'_PAY_TOTAL',
# company/pharmacy placeholders; real pharmacy master query is separate, seed for visual proof
'Q_iscode':'_PHARM_ISCODE', 'Q_isname':'_PHARM_NAME', 'Q_istel':'_PHARM_TEL',
'Q_tel':'_PHARM_TEL', 'Q_add':'_PHARM_ADDR', 'Q_post':'_PHARM_POST',
'Q_name':'_PHARM_NAME', 'Q_comcode':'_BIZNO', 'Q_comname':'_PHARMACIST', 'Q_name1':'_PHARMACIST',
'Q_COMMIT_CODE':'_COMMIT_CODE', 'Q_COMMIT_NAME':'_COMMIT_NAME', 'Q_Unit_Gubun':'_UNIT_GUBUN',
'qrlApproval_Num':'_CASH_APPROVAL',
}
MONEY_FIELDS={'TOT_C','CHUNG_C','BON_C','CARD_C','CHASH_C','PAPER_C','BI_PRICE','BI_CNT','_PAY_TOTAL'}
# Keep only the actual receipt/certificate templates in the canonical blob, excluding prescription-review form tail.
KEEP_KEYWORDS=['약제비','영수증','진료비','납입 확인서','소득공제','환자성명','조제일자','총 수 납','요양기관','사업자','상 호','대 표 자','전 화 번 호','현금','카드']
KEEP_NAMES=set(FIELD_MAP)|{'Q_tag1','Q_tag2'}
def load_layout(form='certificate'):
with LAYOUT.open(encoding='utf-8-sig') as f:
rows=list(csv.DictReader(f))
# The selected binary is a concatenated QR blob. Band indices reveal independent forms:
# 0..77 = 진료비(약제비) 납입 확인서 summary/table
# 78..169 = 약제비 계산서·영수증
# 171..394/396..626 = 청구/기관확인서 variants
ranges={
'certificate': (0,77),
'receipt': (78,169),
'claim1': (171,394),
'claim2': (396,626),
}
lo,hi=ranges.get(form,ranges['certificate'])
out=[]
for r in rows:
idx=int(r['idx'])
if not (lo <= idx <= hi):
continue
cls=r.get('class','')
cap=(r.get('caption') or r.get('text') or '')
name=r.get('name','')
if cls in {'TQRShape','TQRBand'}:
out.append(r)
elif cls in {'TQRLabel','TQRDBText'}:
# In an isolated form keep most labels; mapped dynamic placeholders are replaced.
if cap or name in KEEP_NAMES or name.startswith(('QR_Price','QR_PreSerial')):
out.append(r)
return out
def parse_rows():
lines=RAW.read_text(encoding='utf-8', errors='replace').splitlines()
data=[ln for ln in lines if '|' in ln and not set(ln.replace('|','').strip()) <= {'-'}]
reader=csv.DictReader(data, delimiter='|')
rows=[]
for r in reader:
if r.get('CUSCODE') and not r['CUSCODE'].startswith('-'):
rows.append({k:(v or '').strip() for k,v in r.items()})
return rows
def money(v):
try: return f"{int(float(str(v).replace(',','') or '0')):,}"
except Exception: return str(v or '')
def ymd_parts(s):
s=re.sub(r'\D','',s or '')
if len(s)>=8: return s[:4],s[4:6],s[6:8],f'{s[:4]}-{s[4:6]}-{s[6:8]}'
return '','','',s
def enrich(r):
r=dict(r)
y,m,d,fmt=ymd_parts(r.get('INDATE'))
r['_YEAR']=y; r['_MONTH']=m; r['_DAY']=d; r['_DATE_FMT']=fmt
# Actual SQL sample had payment method columns zero in these rows; PIT sometimes updates CD_SELL_MASTE later.
pay=sum(int(float(r.get(k) or 0)) for k in ['CARD_C','CHASH_C','PAPER_C'])
if pay==0:
pay=int(float(r.get('BON_C') or 0))
r['CHASH_C']=str(pay)
r['_PAY_TOTAL']=str(pay)
r['_DAYS']=''
# raw names are codepage-garbled in saved text; mask/use generic visual proof, keep ids real.
r['PANAME']='홍길동' if '<EFBFBD>' in r.get('PANAME','') else r.get('PANAME') or '홍길동'
panum=r.get('PANUM') or ''
r['PANUM']=(panum[:6]+'-'+panum[6:7]+'******') if len(re.sub(r'\D','',panum))>=7 else '******-*******'
r['_PHARM_ISCODE']='12345678'
r['_PHARM_NAME']='샘플약국'
r['_PHARM_TEL']='02-1234-5678'
r['_PHARM_ADDR']='서울특별시 샘플구 약국로 1'
r['_PHARM_POST']='01234'
r['_BIZNO']='123-45-67890'
r['_PHARMACIST']='김약사'
r['_COMMIT_CODE']=''
r['_COMMIT_NAME']=''
r['_UNIT_GUBUN']='약국'
r['_CASH_APPROVAL']=''
return r
def obj_value(r, o):
name=o['name']; cap=o.get('caption') or o.get('text') or ''
fld=FIELD_MAP.get(name)
if fld:
v=r.get(fld,'')
return money(v) if fld in MONEY_FIELDS else str(v)
# TQRDBText from actual sqlMain in certificate table
df=o.get('datafield') or ''
if df and df in r:
return money(r[df]) if df in MONEY_FIELDS else r[df]
# drop design placeholders that are dynamic but not mapped
if name.startswith(('Q_','QR_Price','QR_PreSerial','qrlApproval')) and cap==name:
return ''
return cap
def draw_text(c, r, row, page_h):
try:
x=float(row['left'] or 0)*SCALE; top=float(row['top'] or 0)*SCALE
w=float(row['width'] or 0)*SCALE; h=float(row['height'] or 0)*SCALE
except Exception: return
txt=obj_value(r,row)
if not txt: return
y=page_h-top-h
fh=abs(float(row.get('font_height') or -10))*0.72
fh=max(5.5,min(fh,17))
c.setFont(FONT,fh)
c.setFillColor(colors.black)
# right-align amount/name-ish labels by PIT naming convention
if row['name'] in FIELD_MAP and FIELD_MAP[row['name']] in MONEY_FIELDS:
c.drawRightString(x+w-1,y+max(1,(h-fh)/2)+1,txt)
else:
# simplistic wrap for long notes
if len(txt)>70 and w>100:
words=list(txt)
line=''; yy=y+h-fh
maxchars=max(10,int(w/(fh*0.55)))
for ch in words:
line+=ch
if len(line)>=maxchars or ch=='\n':
c.drawString(x+1,yy,line.strip()); yy-=fh*1.15; line=''
if yy<y: break
if line and yy>=y: c.drawString(x+1,yy,line)
else:
c.drawString(x+1,y+max(1,(h-fh)/2)+1,txt)
def draw_shape(c,row,page_h):
try:
x=float(row['left'] or 0)*SCALE; top=float(row['top'] or 0)*SCALE
w=float(row['width'] or 0)*SCALE; h=float(row['height'] or 0)*SCALE
except Exception: return
if w<=0 or h<=0: return
y=page_h-top-h
c.setStrokeColor(colors.HexColor('#444444'))
c.setLineWidth(0.35)
if w<=2*SCALE or h<=2*SCALE:
c.line(x,y,x+w,y+h)
else:
c.rect(x,y,w,h,stroke=1,fill=0)
def render_one(data_row, idx, layout, form):
r=enrich(data_row)
out=OUT/f'a4_quickreport_{form}_{idx}_{r["PRESERIAL"]}.pdf'
c=canvas.Canvas(str(out), pagesize=A4)
W,H=A4
# QuickReport object order: shapes first, text on top.
for o in layout:
if o['class']=='TQRShape': draw_shape(c,o,H)
for o in layout:
if o['class'] in {'TQRLabel','TQRDBText'}: draw_text(c,r,o,H)
c.setFont(FONT,6); c.setFillColor(colors.HexColor('#777777'))
c.drawString(4,4,f'PIT QuickReport mapped render / CUSCODE={r.get("CUSCODE")} PRESERIAL={r.get("PRESERIAL")} / source SQL fields: TOT_C CHUNG_C BON_C CARD_C CHASH_C PAPER_C BI_PRICE')
c.save()
return out
def main():
rows=parse_rows()
outs=[]
for form in ['certificate','receipt']:
layout=load_layout(form)
for i,r in enumerate(rows[:3],1):
outs.append(render_one(r,i,layout,form))
z=ROOT/'out/a4_quickreport_receipt_sql_mapped_20260628.zip'
with zipfile.ZipFile(z,'w',zipfile.ZIP_DEFLATED) as zz:
for p in outs+[Path(__file__),LAYOUT,RAW]:
zz.write(p,p.relative_to(ROOT))
print('\n'.join(str(p) for p in outs))
print(z)
print(z.stat().st_size)
if __name__=='__main__': main()

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv, re, zipfile
from pathlib import Path
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
import json
ROOT=Path('/root/work/pharmit-ghidra')
JSONP=ROOT/'out/a4_receipt_template_candidates/quickreport_deep/PM_MAIN.EXE.a4receipt.05.02812c03.현금영수증 문의.bin.qr.objects.json'
RAW=ROOT/'out/a4_receipt_three_raw.txt'
OUT=ROOT/'out/a4_receipt_quickreport_render_samples'
OUT.mkdir(parents=True, exist_ok=True)
SCALE=72/96
FONT='HYGothic-Medium'
try: pdfmetrics.registerFont(UnicodeCIDFont(FONT))
except Exception: FONT='Helvetica'
# Actual SQL fields seen in a4_receipt_three_raw.txt / decomp SQL
FIELD_MAP={
'Q_paname':'PANAME','Q_InDate':'_DATE_FMT','Q_Days':'_DAYS','Q_PreSerial':'PRESERIAL',
'Q_Price_A':'TOT_C','Q_Price_p':'BON_C','Q_Price_c':'CHUNG_C','Q_s_FastMon':'BI_PRICE','Q_Price_n':'_PAY_TOTAL',
'Q_CardPrice':'CARD_C','Q_CashPrint':'PAPER_C','Q_Chah':'CHASH_C','Q_PersonNum':'PANUM',
'Q_Year':'_YEAR','Q_Month':'_MONTH','Q_day':'_DAY',
'Q_name':'_PHARM_NAME','Q_comcode':'_BIZNO','Q_add':'_PHARM_ADDR','Q_comname':'_PHARMACIST',
'qrlApproval_Num':'_CASH_APPROVAL','Q_Bag':'_BAG',
}
MONEY={'TOT_C','BON_C','CHUNG_C','BI_PRICE','_PAY_TOTAL','CARD_C','PAPER_C','CHASH_C'}
def money(v):
try: return f"{int(float(str(v).replace(',','') or '0')):,}"
except Exception: return str(v or '')
def read_sql_rows():
lines=RAW.read_text(encoding='utf-8',errors='replace').splitlines()
data=[ln for ln in lines if '|' in ln and not set(ln.replace('|','').strip()) <= {'-'}]
rows=[]
for r in csv.DictReader(data, delimiter='|'):
if r.get('CUSCODE') and not r['CUSCODE'].startswith('-'):
rows.append({k:(v or '').strip() for k,v in r.items()})
return rows
def enrich(r):
r=dict(r)
s=re.sub(r'\D','',r.get('INDATE',''))
if len(s)>=8:
r['_YEAR'],r['_MONTH'],r['_DAY']=s[:4],s[4:6],s[6:8]
r['_DATE_FMT']=f'{s[:4]}-{s[4:6]}-{s[6:8]}'
else:
r['_YEAR']=r['_MONTH']=r['_DAY']=''; r['_DATE_FMT']=r.get('INDATE','')
pay=sum(int(float(r.get(k) or 0)) for k in ['CARD_C','CHASH_C','PAPER_C'])
if pay==0:
pay=int(float(r.get('BON_C') or 0)); r['CHASH_C']=str(pay)
r['_PAY_TOTAL']=str(pay); r['_DAYS']=''; r['_BAG']=''
if '<EFBFBD>' in r.get('PANAME','') or not r.get('PANAME'): r['PANAME']='홍길동'
panum=re.sub(r'\D','',r.get('PANUM',''))
r['PANUM']=(panum[:6]+'-'+panum[6:7]+'******') if len(panum)>=7 else '050930-3******'
r['_PHARM_NAME']='샘플약국'; r['_BIZNO']='123-45-67890'; r['_PHARM_ADDR']='서울특별시 샘플구 약국로 1'; r['_PHARMACIST']='김약사'; r['_CASH_APPROVAL']=''
return r
def load_objects():
j=json.loads(JSONP.read_text(encoding='utf-8'))
q=[o for o in j['objects'] if o.get('class','').startswith('TQR')]
# First PM_MAIN 약제비 계산서·영수증 form: q indices 19..103 from object inspection.
objs=q[19:104]
return objs
def prop(o,k,default=''):
return o.get('props',{}).get(k,default)
def value(r,o):
name=o.get('name',''); cap=str(prop(o,'Caption','') or '')
fld=FIELD_MAP.get(name)
if fld:
v=r.get(fld,'')
return money(v) if fld in MONEY else str(v)
# hide design placeholders if equal to object name
if name.startswith('Q_') and cap==name: return ''
return cap
def draw_shape(c,o,H):
try:
x=float(prop(o,'Left',0))*SCALE; top=float(prop(o,'Top',0))*SCALE; w=float(prop(o,'Width',0))*SCALE; h=float(prop(o,'Height',0))*SCALE
except Exception: return
if w<=0 or h<=0: return
y=H-top-h
c.setStrokeColor(colors.HexColor('#333333')); c.setLineWidth(0.45)
if w<=1.6 or h<=1.6: c.line(x,y,x+w,y+h)
else: c.rect(x,y,w,h,stroke=1,fill=0)
def draw_text(c,r,o,H):
txt=value(r,o)
if not txt: return
try:
x=float(prop(o,'Left',0))*SCALE; top=float(prop(o,'Top',0))*SCALE; w=float(prop(o,'Width',0))*SCALE; h=float(prop(o,'Height',0))*SCALE
except Exception: return
y=H-top-h
fh=max(5.5,min(abs(float(prop(o,'Font.Height',-13)))*0.72,16))
c.setFont(FONT,fh); c.setFillColor(colors.black)
fld=FIELD_MAP.get(o.get('name',''))
if fld in MONEY:
c.drawRightString(x+w-2,y+max(1,(h-fh)/2)+1,txt)
else:
c.drawString(x+1,y+max(1,(h-fh)/2)+1,txt)
def render(row, idx, objs):
r=enrich(row)
out=OUT/f'a4_pm_main_receipt_sql_{idx}_{r["PRESERIAL"]}.pdf'
c=canvas.Canvas(str(out),pagesize=A4); W,H=A4
for o in objs:
if o.get('class')=='TQRShape': draw_shape(c,o,H)
for o in objs:
if o.get('class') in {'TQRLabel','TQRDBText'}: draw_text(c,r,o,H)
c.setFont(FONT,6); c.setFillColor(colors.HexColor('#777777'))
c.drawString(4,4,f'PM_MAIN QuickReport receipt mapped / CUSCODE={r.get("CUSCODE")} PRESERIAL={r.get("PRESERIAL")}')
c.save(); return out
def main():
rows=read_sql_rows(); objs=load_objects(); outs=[]
for i,r in enumerate(rows[:3],1): outs.append(render(r,i,objs))
z=ROOT/'out/a4_pm_main_receipt_sql_mapped_20260628.zip'
with zipfile.ZipFile(z,'w',zipfile.ZIP_DEFLATED) as zz:
for p in outs+[Path(__file__),JSONP,RAW]: zz.write(p,p.relative_to(ROOT))
print('\n'.join(map(str,outs))); print(z); print(z.stat().st_size)
if __name__=='__main__': main()

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv, json, re, subprocess
from collections import Counter
from pathlib import Path
ROOT=Path('/root/work/pharmit-ghidra')
CAND=ROOT/'out/a4_receipt_template_candidates'
OUT=CAND/'quickreport_receipt_deep'
OUT.mkdir(parents=True, exist_ok=True)
PARSER=ROOT/'scripts/parse_fastreport_bin.py'
QR_CLASSES={'TQuickRep','TQRBand','TQRChildBand','TQRLabel','TQRDBText','TQRShape','TQRImage','TQRDBImage','TQRRichText','TQRExpr','TQRCompositeReport'}
KEYWORDS=['약제비 계산서','약제비','영수증','진료비','납입확인서','납입 확인서','소득공제','요양기관 종류','환 자','환자성명','약 제 비 총 액','총 수 납 금 액']
def text(o):
pr=o.get('props',{})
for k in ['Caption','Text','DataField','FieldName','Memo.UTF8W']:
v=pr.get(k)
if isinstance(v,str) and v.strip(): return v.strip()
if isinstance(v,list):
s='\n'.join(str(x) for x in v if str(x).strip()).strip()
if s: return s
return ''
def parse_file(p:Path):
jpath=OUT/(p.name+'.objects.json')
if not jpath.exists():
subprocess.run(['python3',str(PARSER),str(p)], stdout=jpath.open('w'), stderr=subprocess.DEVNULL, check=False)
try: return json.loads(jpath.read_text(encoding='utf-8'))
except Exception: return {'objects':[]}
def bounds(objs):
xs=[]; ys=[]
for o in objs:
pr=o.get('props',{})
try:
if 'Left' in pr and 'Width' in pr:
xs += [float(pr['Left']), float(pr['Left'])+float(pr['Width'])]
if 'Top' in pr and 'Height' in pr:
ys += [float(pr['Top']), float(pr['Top'])+float(pr['Height'])]
except Exception: pass
return ([round(min(xs),3),round(max(xs),3)] if xs else None, [round(min(ys),3),round(max(ys),3)] if ys else None)
def group_reports(objs):
q=[o for o in objs if o.get('class') in QR_CLASSES]
starts=[i for i,o in enumerate(q) if o.get('class') in {'TQuickRep','TQRCompositeReport'}]
if not starts:
return [('qr_blob', q)] if q else []
groups=[]
for n,si in enumerate(starts):
ei=starts[n+1] if n+1<len(starts) else len(q)
name=q[si].get('name') or f'report_{n}'
groups.append((name,q[si:ei]))
return groups
def score_group(g):
txt=' '.join(text(o) for o in g)
return sum(10 for k in KEYWORDS if k in txt) + sum(1 for o in g if o.get('class')=='TQRLabel')
def main():
all_rows=[]; summaries=[]
for p in sorted(CAND.glob('*.bin')):
if not any(s in p.name for s in ['진료비','소득공제','요양기관','현금영수증']):
continue
j=parse_file(p); objs=j.get('objects',[])
groups=group_reports(objs)
for gi,(gname,gobjs) in enumerate(groups):
captions=[text(o) for o in gobjs if text(o)]
joined=' | '.join(captions)
if not any(k in joined for k in KEYWORDS):
continue
bx,by=bounds(gobjs)
classes=Counter(o.get('class') for o in gobjs)
summ={'source_bin':p.name,'group_index':gi,'group_name':gname,'objects':len(gobjs),'classes':dict(classes),'bounds_x':bx,'bounds_y':by,'score':score_group(gobjs),'key_captions':[c for c in captions if any(k in c for k in KEYWORDS)][:80]}
summaries.append(summ)
for idx,o in enumerate(gobjs):
pr=o.get('props',{})
if o.get('class') not in QR_CLASSES: continue
all_rows.append({
'source_bin':p.name,'group_index':gi,'group_name':gname,'idx':idx,'class':o.get('class'),'name':o.get('name'),'offset':o.get('offset'),
'left':pr.get('Left',''),'top':pr.get('Top',''),'width':pr.get('Width',''),'height':pr.get('Height',''),
'caption':pr.get('Caption',''),'text':text(o),'datafield':pr.get('DataField') or pr.get('FieldName') or '', 'dataset':pr.get('DataSet') or pr.get('DataSetName') or '',
'font_height':pr.get('Font.Height',''),'alignment':pr.get('Alignment') or pr.get('HAlign') or '',
'props':json.dumps(pr,ensure_ascii=False,sort_keys=True)
})
summaries=sorted(summaries,key=lambda x:(-x['score'],x['source_bin'],x['group_index']))
(OUT/'quickreport_receipt_summary.json').write_text(json.dumps(summaries,ensure_ascii=False,indent=2),encoding='utf-8')
with (OUT/'quickreport_receipt_objects_all.csv').open('w',newline='',encoding='utf-8-sig') as f:
fields=list(all_rows[0].keys()) if all_rows else ['source_bin']
w=csv.DictWriter(f,fieldnames=fields); w.writeheader(); w.writerows(all_rows)
md=['# QuickReport receipt templates deep summary','']
for s in summaries:
md += [f"## score={s['score']} {s['source_bin']} group={s['group_index']} `{s['group_name']}`",
f"- objects: {s['objects']}",f"- bounds_x: {s['bounds_x']}",f"- bounds_y: {s['bounds_y']}",f"- classes: `{s['classes']}`",'- key captions:']
md += [f" - {c}" for c in s['key_captions'][:40]]
md += ['']
(OUT/'quickreport_receipt_summary.md').write_text('\n'.join(md),encoding='utf-8')
# Write top group's canonical CSV for immediate renderer work.
if summaries:
top=summaries[0]
top_rows=[r for r in all_rows if r['source_bin']==top['source_bin'] and int(r['group_index'])==int(top['group_index'])]
with (OUT/'quickreport_receipt_canonical_top.csv').open('w',newline='',encoding='utf-8-sig') as f:
w=csv.DictWriter(f,fieldnames=list(top_rows[0].keys())); w.writeheader(); w.writerows(top_rows)
(OUT/'quickreport_receipt_canonical_top.json').write_text(json.dumps(top,ensure_ascii=False,indent=2),encoding='utf-8')
print('TOP',top)
print(OUT/'quickreport_receipt_summary.md')
print(OUT/'quickreport_receipt_objects_all.csv')
print(OUT/'quickreport_receipt_canonical_top.csv')
if __name__=='__main__': main()