feat(est-test): preserve nested JDL details

This commit is contained in:
2026-11-09 19:25:37 +09:00
parent cdf0e46914
commit 7317dbe4e5
12 changed files with 405 additions and 32 deletions
+163
View File
@@ -55,6 +55,26 @@ def test_jdl_duplicate_unit_codes_do_not_select_a_wrong_unit_group():
assert result == {"units": 0, "total": 300, "warning": True}
def test_jdl_duplicate_unit_detail_groups_do_not_select_the_last_group():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='groups';
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}];
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:200,TC:200}];
p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200},
{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:1,GU:100,GC:100}];
p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:200,GC:200,TC:200},{LinkIndex:54}];
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length,
total:q.tables.EstMainDat.at(-1).TC}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result == {"units": 0, "total": 200}
def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_money():
p = run(
"math",
@@ -165,6 +185,92 @@ def test_basic_jdl_unit_price_parts_export_and_reimport():
assert result["specialUnits"] == 0 and result["specialWarnings"] > 0
def test_jdl_unit_price_with_machine_detail_survives_round_trip():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='machine unit';
p.tables.MachTitleDat=[{Code:'J',Name:'machine',GC:100,NC:50,TC:150}];
p.tables.MachMainDat=[{Index:1,Code:'J',Name:'machine',JdlSummary:true},
{Index:0,CalcIndex:1,JdlSummary:true,Name:'fuel',GC:100,NC:50,TC:150}];
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:50,TC:150}];
p.tables.HeadMainDat=[{Index:1,Code:'U'},
{Index:0,Code:'J',Name:'machine',LinkIndex:105,GN:1,GU:100,NU:50,GC:100,NC:50,TC:150}];
p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:2,GU:100,NU:50,GC:200,NC:100,TC:300},{LinkIndex:54}];
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
process.stdout.write(JSON.stringify({unit:j.parse('unitpart.txt',files.get('unitpart.txt')),
rows:q.tables.HeadMainDat,machine:q.tables.MachMainDat,total:q.tables.EstMainDat.at(-1).TC}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result["unit"][0]["F_CODE1"].startswith("J")
assert result["rows"][1]["LinkIndex"] == 105
assert result["machine"][1]["Name"] == "fuel"
assert result["total"] == 300
def test_jdl_unit_price_preserves_other_source_costs_as_fixed_resources():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='sources';
for(const [table,code] of [['SilJukDat','S'],['EstiMateDat','E'],['CostTitleDat','D'],['MachTitleDat','J']])
p.tables[table]=[{Code:code,Name:code,GC:10,TC:10}];
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:40,TC:40}];
p.tables.HeadMainDat=[{Index:1,Code:'U'},...[['S',21],['E',101],['D',104],['J',105]].map(([Code,LinkIndex])=>
({Index:0,Code,Name:Code,LinkIndex,GN:1,GU:10,GC:10,TC:10}))];
p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:1,GU:40,GC:40,TC:40},{LinkIndex:54}];
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',files.get('unitpart.txt')).length,
links:q.tables.HeadMainDat.slice(1).map(r=>r.LinkIndex),total:q.tables.EstMainDat.at(-1).TC}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result == {"parts": 4, "links": [101, 101, 101, 101], "total": 40}
def test_jdl_nested_units_preserve_detail_or_warn_on_forward_reference():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const blank=JSON.parse(fs.readFileSync(0,'utf8'));
function convert(order,self=false,cycle=false){const p=structuredClone(blank);p.name='nested';
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:10}];
const titles={A:{Code:'A',Name:'parent',GC:60,TC:60},B:{Code:'B',Name:'child',GC:20,TC:20}};
p.tables.HeadTitleDat=order.map(code=>titles[code]);
p.tables.HeadMainDat=[{Index:1,Code:'B'}, {Index:0,Code:cycle?'A':'M',Name:'material',LinkIndex:cycle?103:5,GN:2,GU:10,GC:20},
{Index:1,Code:'A'}, {Index:0,Code:self?'A':'B',Name:'child',LinkIndex:103,GN:3,GU:20,GC:60}];
p.tables.EstMainDat=[{Code:'A',Name:'parent',LinkIndex:103,GN:2,GU:60,GC:120,TC:120},{LinkIndex:54}];
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,structuredClone(blank));
return {parts:j.parse('unitpart.txt',files.get('unitpart.txt')),total:q.tables.EstMainDat.at(-1).TC,
units:j.parse('unit.txt',files.get('unit.txt')),warnings:out.warnings};}
process.stdout.write(JSON.stringify({ordered:convert(['B','A']),forward:convert(['A','B']),
self:convert(['A','B'],true),cycle:convert(['A','B'],false,true)}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result["ordered"]["total"] == result["forward"]["total"] == 120
assert result["ordered"]["parts"][-1]["F_CODE1"].startswith("U")
assert result["forward"]["parts"][-1]["F_CODE1"].startswith("U")
assert not any("하위 일위대가" in warning for warning in result["forward"]["warnings"])
assert len(result["self"]["units"]) == 1
assert result["cycle"]["total"] == 120
assert any("하위 일위대가" in warning for warning in result["cycle"]["warnings"])
def test_jdl_nested_unit_order_handles_deep_dependencies():
script = """const {orderUnits}=require('./tools/est-test/jdl-unit-order.cjs'),n=15000;
const titles=Array.from({length:n},(_,i)=>({Code:String(i)})),groups=new Map();
for(let i=0;i<n-1;i++)groups.set(String(i),[{LinkIndex:103,Code:String(i+1)}]);
const result=orderUnits(titles,groups);
process.stdout.write(JSON.stringify([result.length,result[0].Code,result.at(-1).Code]));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
assert result == [15000, "14999", "0"]
def test_jdl_machine_saved_amount_is_editable_and_survives_recalculation():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
@@ -227,6 +333,63 @@ def test_jdl_machine_simple_detail_is_editable_and_mismatches_remain_summary():
assert result["warnings"] and not result["errors"]
def test_jdl_machine_quantity_detail_uses_consistent_amounts_only():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty);
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50},
{F_CODE:'J2',F_DES:'ambiguous',F_MPRICE:50},{F_CODE:'J3',F_DES:'both match',F_MPRICE:150}])],
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'part',F_QQTY:2,F_MPRICE:50,F_LPRICE:25},
{F_CODE:'J2',F_SEQ:'1',F_DES:'uncertain',F_QQTY:2,F_MPRICE:50},
{F_CODE:'J3',F_SEQ:'1',F_DES:'first',F_QQTY:2,F_MPRICE:50},
{F_CODE:'J3',F_SEQ:'2',F_DES:'second',F_QQTY:0.5,F_MPRICE:100}])],
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50}])]]);
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);
p.options.AutoCalc=true;p.options.SourceRefresh=true;p.tables.MachMainDat[1].GC=110;const q=s.calculate(p);
const warnings=p.importWarnings.slice(),out=j.output(q),back=j.read(out.bytes,blank);
process.stdout.write(JSON.stringify({before,rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat,
saved:back.tables.MachMainDat,total:back.tables.EstMainDat.at(-1).TC,warnings,errors:q.calculation.errors}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result["before"][1]["GC"] == 100
assert result["before"][1]["NC"] == 50
assert result["before"][1]["JdlSourceQuantity"] == 2
assert result["titles"][0]["TC"] == 160
assert result["saved"][1]["Name"] == "part"
assert result["saved"][1]["GC"] == 110
assert result["saved"][1]["NC"] == 50
assert result["total"] == 320
assert "상세 산식 미변환" in result["rows"][-3]["Name"]
assert "상세 산식 미변환" in result["rows"][-1]["Name"]
assert any("수량×단가" in warning for warning in result["warnings"])
assert not result["errors"]
def test_jdl_machine_export_rejects_ambiguous_or_inconsistent_detail_groups():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const p=JSON.parse(fs.readFileSync(0,'utf8'));p.name='machine';
p.tables.MachTitleDat=[{Code:'J1',Name:'duplicate',GC:10},{Code:'J2',Name:'mismatch',GC:20}];
p.tables.MachMainDat=[{Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10},
{Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10},
{Index:1,Code:'J2'},{Index:0,CalcIndex:1,JdlSummary:true,GC:5}];
const out=j.output(p),files=a.unpack(out.bytes);
process.stdout.write(JSON.stringify({units:j.parse('junit.txt',files.get('junit.txt')).length,
parts:j.parse('jpart.txt',files.get('jpart.txt')).length}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result == {"units": 0, "parts": 0}
def test_jdl_cost_saved_amount_calculates_and_can_be_edited():
project = run("math", "EstNative.empty()")
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
@@ -0,0 +1,22 @@
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
def test_estx_rejects_corrupted_last_engine_signature_byte_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),a=require('./tools/est-test/archive.cjs');
const bytes=fs.readFileSync(process.argv[1]),files=m.unzip(bytes),table=files.get('estmaindat.dat');
const valid=m.importEstx(bytes).tables.EstMainDat.length;table[24]^=1;let rejected=false;
try{m.importEstx(a.pack(files))}catch(e){rejected=e.message.includes('DBISAM')}
table[24]^=1;files.get('stuffcdat.dat')[9]^=1;let missingRejected=false;
try{m.importEstx(a.pack(files))}catch(e){missingRejected=e.message.includes('stuffcdat.dat')}
process.stdout.write(JSON.stringify({valid:valid>0,rejected,missingRejected}));"""
result = json.loads(subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT))
assert result == {"valid": True, "rejected": True, "missingRejected": True}
@@ -46,6 +46,22 @@ def test_move_checks_shared_tags_without_confusing_similar_numbers():
assert result == {"rows": True, "rejected": True}
def test_row_parser_preserves_global_and_local_tags_with_many_rows():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),{rowParser}=require('./tools/est-test/wpt-parse.cjs');
const one=(tag,code)=>'<div><c nr=2/><begin Tag='+tag+' style="Name:&#39;'+code+'&#39;;"/>1<end Tag='+tag+'/></div>';
const styles=Array.from({length:3000},(_,i)=>'<cs nr='+(i+2)+' CharStyleON:0/>').join('');
const doc='<cs nr=2 CharStyleON:16/>'+styles+Array.from({length:3000},(_,i)=>one(i,'r'+String(i).padStart(6,'0'))).join('')+
'<div><end Tag=1/>2</div>'+one(1,'r009999');
const declarations=[...doc.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map(m=>m[0]).join('');
const divs=[...doc.matchAll(/<div\b[^>]*>[\s\S]*?<\/div>/gi)].map(m=>m[0]),parse=rowParser(doc);
const samples=[divs[0],divs[1],divs[100],divs.at(-2),divs.at(-1)];
const same=samples.every(div=>JSON.stringify(parse(div))===JSON.stringify(q.fromWpt('','',declarations+div,[],{}).rows));
const count=divs.reduce((n,div)=>n+parse(div).length,0);
process.stdout.write(JSON.stringify({same,count}));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
assert result == {"same": True, "count": 3002}
def test_move_and_edit_keeps_original_row_marker():
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
const div=(tag,code,value)=>'<div cs=1><c nr=1/>Q='+value+'=<begin Tag='+tag+' style="Name:&#39;'+code+'&#39;;"/>'+value+'<end Tag='+tag+'/></div>';
+26 -1
View File
@@ -423,6 +423,30 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] DBISAM 엔진 서명 전체를 확인해 손상된 ESTX 테이블을 정상 자료로 읽지 않도록 하고 실제 파일·손상 사례를 검증한다.
- 자체 검증: 기존 16바이트 접두 일치 후 마지막 해시 바이트가 다른 테이블과 자재 등 주요 테이블의 서명 손상을 거부해 조용한 누락을 방지. 설치 ESTX 24개 읽기, 정상 main.estx 변조 거부, 실제 원본 저장·인덱스 보존 포함 관련 시험 18개와 추가 손상·산식 회귀 39개 통과.
- [x] JDL 중첩 일위대가를 호표의 입력 순서와 관계없이 하위부터 출력한다. 순환 참조는 고정 단가와 경고로 보호하고 실제 금액을 검증한다.
- 자체 검증: 부모 선행·하위 선행 순서 모두 하위 일위 연결과 120원 재가져오기 유지. 순환 참조는 고정 단가 및 경고, 직접 자기 참조는 상세 출력 차단. 15,000단계 의존성 순서 계산과 JDL·업무 시험 38개 통과. 원본 JDL 검사 DLL 인증은 미확인.
- [x] 중첩 일위대가를 JDL 상세로 내보내고, 상세를 연결할 수 없는 하위 호표는 고정 단가와 경고로 보존한다. 자기 참조 보호와 재가져오기를 검증한다.
- 자체 검증: 부모·하위 순서를 바꾸어도 재가져오기 합계 120원과 하위 연결을 유지한다. 자기 참조 및 순환 시 안전한 고정 원천으로 보존한다. JDL·업무 시험 38개 통과.
- [x] 수량 문서 행별 해석에서 필요한 위첨자 서식만 조회하고 중복 서식과 실제 저장 결과를 검증한다.
- 자체 검증: 3,000개 서식과 3,002행, 중복 서식 선언·표지 재정의에서 기존 해석과 일치. 설치 ESTX 저장·재읽기·인덱스 보존을 포함한 수량·원본 저장 시험 56개 통과(6분 18초).
- [x] JDL 일위대가 상세의 기계·단가산출 등 일반 연결을 가능한 범위에서 출력하고, 연결과 금액의 재가져오기를 검증한다.
- 자체 검증: 기계 상세→일위대가→내역 연결을 JDL 왕복해 300원과 기계 연결을 확인. 실적·견적·산근·일반 기계는 고정 원천으로 전개해 40원 유지, 중복 일위 상세는 상세 출력 방지. JDL·업무 시험 36개 통과. 설치 교량 자료 JDL 왕복 내역 합계 7,989,852,162원 유지. 원본 DLL 검증은 미확인.
- [x] JDL에서 가져와 금액을 편집한 일반 기계 상세를 조건이 충족될 때 JDL 기계 상세로 출력하고 재가져오기·합계를 검증한다. 복잡하거나 불일치한 구성은 기존 고정 단가를 유지한다.
- 자체 검증: 합성 JDL 기계 상세 금액 110·50을 편집 후 내역 수량 2에 반영하고 JDL로 저장·재가져와 상세·단가 합계 160과 내역 합계 320 확인. 중복 호표·비목 합계 불일치의 상세 출력 차단 및 JDL·업무 회귀 33개 통과. 상시 테스트 서버의 실제 JDL 가져오기·변경·내보내기·재가져오기 API에서도 상세 110과 내역 합계 320 확인. 세부 금액은 고정 단가로 출력하며 원본 상세 산식·수량 관계는 복원하지 않는다. 실제 JDL과 원본 검사 DLL 인증은 미확인.
- [x] 대용량 수량산출서의 저장 시 각 행에 필요한 표지만 읽도록 변경하고 원본 서식·공유 표지·실제 파일 저장을 검증한다.
- 자체 검증: 3,002행 합성 문서에서 전역·행별 표지 재정의와 위첨자를 기존 해석과 대조. 설치 ESTX 추가·삭제·이동·편집 후 재읽기 및 인덱스 보존을 포함한 관련 회귀 56개 통과(6분 12초). 큰 ESTX의 다른 처리 시간은 남아 있다.
- [x] JDL 기계 상세의 수량·단가를 곱한 비목별 합계가 제목과 일치하는 경우 상세 금액을 편집 가능하게 가져온다. 해석이 모호하거나 불일치한 경우 기존 합계 보존을 유지한다.
- 자체 검증: 합성 JDL의 수량 2 상세를 비목별 100·50으로 계산해 가져오고 금액 편집 후 합계 160 확인. 단순합과 수량 적용 합이 모두 일치하는 모호한 경우 등은 합계 보존. JDL·업무·오류 검색 시험 45개 및 모호성 추가 시험 통과. 실제 JDL 자료와 원본 실행 비교는 하지 못함.
- [x] JDL 출력에서 중복 자원 코드와 내부 고정금액 코드 충돌로 다른 단가에 연결되는 문제를 차단하고 왕복 검증한다.
- 자체 검증: 중복 자원 세 개를 모두 내보내며 모호한 내역은 저장 금액으로 독립 전개하고, 기존 `LITERAL2` 코드와 내부 고정금액이 충돌하지 않음 확인. JDL·업무 회귀 30개, 중복 자원 세 건과 중복 일위대가의 잘못된 상세 연결 방지 별도 시험 통과. 설치 교량 자료의 JDL 출력·재가져오기 내역 합계 7,989,852,162원 유지. 변환 불가 구성 경고는 유지한다.
@@ -792,7 +816,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 자체 검증(2026-11-06): Edge에서 XML 다운로드→실제 XSD 통과→재열기 금액/상하위 계층/0원 도급액 보존, JDL 다운로드/재열기 직접비·원가 보존, 예정공정 입력/저장 복원, CAD 제외 40개 서식 선택 시 JS 오류 0. Excel 왕복 시 바이너리·소수 유지, 66행 20행/쪽 분할과 쪽 번호 5~8, 본문/설정 저장 재열기 통과. 별도 openpyxl 읽기로 xlsx 구조/자료형 확인. 설치 sample.estx의 수량 변경→수정 저장→독립 재읽기 통과, 모든 .idx 바이트 불변. 원본 EST 실행 검증은 아님.
- [ ] ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성 구현 가능성 조사 및 점검.
- 조사 및 한계 분석(2026-11-08):
1. 파일 구조 및 암호화 특성: ESTX 내 각 테이블의 `.idx` 파일은 DBISAM 독자적 B-Tree 인덱스 형식으로, 헤더 8~24바이트에 서명(`06 8a be 8e 59 23 64 cb 40 3d 71 d2 e3 bc 64 d0`)이 포함되어 있으며 전체 블록(4,096바이트 단위)이 암호화·해싱되어 있어 단순 XOR 또는 비트 역변환으로 역공학할 수 없음.
1. 파일 구조: 설치 ESTX 24개에 든 `.idx` 468개의 오프셋 9~24는 모두 `8abe8e592364cb403d71d2e3bc64d001`이다. 이 값은 DBISAM 공식 문서의 기본 엔진 서명 `DBISAM_SIG`를 MD5 해시한 값과 일치한다. 바로 앞 `06`은 해시의 일부가 아니다. 교량 샘플에서는 비어 있는 페이지와 레코드 번호처럼 보이는 평문 바이트열도 확인되어 전체가 암호화됐다는 기존 추정은 잘못됐다. 페이지·트리 갱신 규칙은 미확인이므로 쓰기에는 사용하지 않는다: https://www.elevatesoft.com/manual?action=viewtopic&id=dbisam4&product=rscpp&topic=customizing_engine&version=XE .
- 추가 조사: DBISAM은 `REPAIR TABLE ... FORCEINDEXREBUILD`를 제공한다(https://www.elevatesoft.com/manual?action=viewtopic&id=dbisam4&product=rsdelphiwin64&topic=repair_table_statement&version=XE5). 이 PC의 32·64비트 ODBC 등록 목록에서 DBISAM 드라이버를 찾지 못했다. 공식 시험판 다운로드는 로그인 후 가능하며, 원본 엔진 서명·버전과의 호환성은 확인하지 못했다.
2. 무결성 검증 제약: `exportEstx`는 동일 행 수 및 비인덱스 필드(수치, 일반 텍스트, BLOB 데이터 등)의 수정 저장을 안전하게 지원하지만, 행 추가/삭제나 인덱스 키(`PkID`, `Code`, `Index`, `RecNo` 등)를 변경하면 `.idx`의 B-Tree 구조와 불일치가 발생함. 원본 Delphi/DBISAM 엔진 외부에서 인덱스 재생성을 구현할 수 있는 바이너리 규격이나 소스 근거가 부재함.
3. 구현 범위 및 처리: 따라서 ESTX 내 행 추가/삭제 및 인덱스 키 변경은 기존처럼 명확한 예외 메시지(`{key}: 행 추가/삭제는 인덱스 재생성이 필요합니다`, `{key}.{name}: 인덱스 키 변경은 지원하지 않습니다`)로 안전 차단하고, 신규 파일/행 증감/자유로운 편집이 필요한 작업은 JSON 저장소(`project.tables`)에서 완전 무결하게 지원·보존하도록 범위를 명확히 유지함.
- [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
+3
View File
@@ -45,6 +45,9 @@
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 일위대가 상세는 기본 플래그와 연결 자원이 확인된 행을 불러오고 미변환 구성은 저장 금액으로 보존합니다. 기계경비와 단가산출은 저장된 비목별 금액을 편집 가능한 호표로 가져오며 상세 산식은 원본 JDL에 보존합니다. 자재·노무로 구성된 기본 일위대가는 상세표로 내보냅니다. 가져온 작업을 변경하지 않았다면 원본 JDL을 바이트 그대로 내보내 확장 자료를 보존합니다. 변경된 작업의 특수 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
- 중복 자원 코드로 연결 대상을 구분할 수 없는 JDL 출력은 해당 내역의 저장 금액을 독립 단가로 기록하고 경고합니다. 자원 행 자체는 각각 보존합니다.
- JDL 기계 상세의 수량과 단가를 곱한 금액이 비목별 저장 합계와 맞는 경우 각 상세 금액을 편집할 수 있습니다. 상세 산식과 원천 단가 연결은 원본에 보존하며, 수량 해석이 모호하면 합계만 가져옵니다.
- 가져온 JDL 기계 상세의 편집 금액과 호표 합계가 맞으면 JDL로 다시 저장할 때 각 상세를 고정 단가로 기록합니다. 원래의 상세 산식과 수량 관계는 복원하지 않습니다.
- 일위대가에 연결된 기계·실적·견적·단가산출 자료도 일반 상세 조건을 만족하면 JDL로 출력합니다. 기계 상세가 변환되지 않거나 그 외 복합 원천은 고정 단가로 전개합니다.
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 수량산출서의 이름과 항목을 수정하고, 확인된 형식의 항목과 단일 결과 표지 산출 행을 추가·삭제할 수 있습니다. 고유한 일반 행 삭제와 조건에 맞는 산출 행 이동도 지원합니다. 연결된 항목이나 중복·복합 표지, 불명확한 서식 구조는 보호를 위해 일부 작업을 거부합니다. 기존 서식 구간의 텍스트를 편집할 때 새 글에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 수량산출서, 인덱스 키 변경, 수량의 내역 연결 변경, 웹 기성·연차·당초 사본은 ESTX 저장을 지원하지 않아 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
- **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
- **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다.
+2 -2
View File
@@ -4,7 +4,7 @@ const { pack } = require("./archive.cjs");
const { updateOptions } = require("./export-options.cjs");
const { updateQuantities, quantityFields } = require("./export-quantity.cjs");
const { updatePrintOptions } = require("./export-print.cjs");
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex");
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d001", "hex");
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
let characters;
function cp949(text) {
@@ -223,7 +223,7 @@ function exportEstx(project) {
}
const oldByLower = new Map(Object.keys(base.tables).map((key) => [key.toLowerCase(), key]));
for (const [filename, data] of files) {
if (!filename.endsWith(".dat") || !data.subarray(8, 24).equals(signature)) continue;
if (!filename.endsWith(".dat") || !data.subarray(8, 25).equals(signature)) continue;
const key = oldByLower.get(filename.slice(0, -4));
if (!key) continue;
const rows = project.tables[key],
+10 -15
View File
@@ -1,5 +1,6 @@
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const { rowParser } = require("./wpt-parse.cjs");
const { calculateQuantity } = require("./quantity-calculate.cjs");
const { memoryRows } = require("./import-estx.cjs");
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -162,8 +163,7 @@ function editDocument(text, oldSheet, sheet) {
oldSheet.rows.length !== sheet.rows.length
)
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
const parse = (div) => quantity.fromWpt("", "", declarations + div, [], {}).rows;
const parse = rowParser(text);
let rowIndex = 0;
const result = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
const parsed = parse(div);
@@ -258,11 +258,11 @@ function insertRows(text, sheet, inserts) {
staged = next;
return { at, snippet };
});
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
const parse = rowParser(text);
let index = 0;
const stylePosition = text.search(/<cs\s+nr=1\b/i);
const updated = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div, offset) => {
if (!quantity.fromWpt("", "", declarations + div, [], {}).rows.length) return div;
if (!parse(div).length) return div;
const before = snippets
.filter((item) => item.at === index)
.map((item) => item.snippet)
@@ -282,13 +282,13 @@ function insertRows(text, sheet, inserts) {
}
function removeRows(text, sheet, codes, plainIndexes = new Set()) {
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
const parse = rowParser(text);
const tags = tagCounts(text);
let index = 0;
const removed = new Set();
const removedPlain = new Set();
const document = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
const parsed = quantity.fromWpt("", "", declarations + div, [], {}).rows;
const parsed = parse(div);
if (!parsed.length) return div;
const rowIndex = index++,
row = sheet.rows[rowIndex],
@@ -368,17 +368,12 @@ function reorderRows(text, sheet, rows) {
}
if (order.length !== sheet.rows.length || order.every((value, index) => value === index))
return { text, rows: sheet.rows };
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
const parse = rowParser(text);
const divs = [...text.matchAll(/<div\b[^>]*>[\s\S]*?<\/div>/gi)].filter(
(match) => quantity.fromWpt("", "", declarations + match[0], [], {}).rows.length,
(match) => parse(match[0]).length,
);
if (divs.length !== sheet.rows.length) throw Error("수량산출서 원본 행 대응 오류");
if (
divs.some(
(match, index) =>
!same(quantity.fromWpt("", "", declarations + match[0], [], {}).rows[0], sheet.rows[index]),
)
)
if (divs.some((match, index) => !same(parse(match[0])[0], sheet.rows[index])))
throw Error("수량산출서 원본 행 대응 오류");
const changed = order
.map((source, index) => (source === index ? -1 : index))
@@ -413,7 +408,7 @@ function reorderRows(text, sheet, rows) {
}
let index = 0;
const document = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
if (!quantity.fromWpt("", "", declarations + div, [], {}).rows.length) return div;
if (!parse(div).length) return div;
return divs[order[index++]][0];
});
return { text: document, rows: order.map((source) => sheet.rows[source]) };
+12 -4
View File
@@ -4,6 +4,7 @@ const quantity = require("./native-quantity.cjs");
const { readPrintOptions } = require("./import-print.cjs");
const { existingTargets } = require("./native-links.cjs");
const decoder = new TextDecoder("euc-kr", { fatal: true });
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d001", "hex");
const LIMIT = 256 * 1024 * 1024;
const crcTable = Array.from({ length: 256 }, (_, n) => {
for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
@@ -271,11 +272,18 @@ function importEstx(buffer, name = "가져온 내역서") {
warnings = new Set(),
templates = [];
for (const [filename, data] of files) {
if (
!filename.endsWith(".dat") ||
!data.subarray(8, 24).equals(Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"))
)
if (!filename.endsWith(".dat")) continue;
if (data.subarray(8, 24).equals(signature.subarray(0, 16)) && data[24] !== signature[16])
throw Error(`${filename}: DBISAM 엔진 서명이 올바르지 않습니다.`);
if (!data.subarray(8, 25).equals(signature)) {
if (
/^(?:stuffcdat|laborcdat|siljukdat|estimatedat|machgijundat|machmaindat|machtitledat|costmaindat|costtitledat|headmaindat|headtitledat|estmaindat|mulmaindat|multitledat|wongadat)\.dat$/.test(
filename,
)
)
throw Error(`${filename}: DBISAM 테이블 서명이 올바르지 않습니다.`);
continue;
}
let rows;
try {
rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
+60
View File
@@ -0,0 +1,60 @@
function exportMachines(project, tables, map, ambiguous, num, next, source, warnings) {
const groups = new Map();
let group;
for (const row of project.tables.MachMainDat || []) {
if (num(row.Index) > 0) {
group = [];
if (groups.has(row.Code)) groups.set(row.Code, null);
else groups.set(row.Code, group);
} else if (group) group.push(row);
}
for (const title of project.tables.MachTitleDat || []) {
const parts = groups.get(title.Code);
if (
!title.Code ||
ambiguous.has("105:" + title.Code) ||
!parts?.length ||
num(title.SC) ||
parts.some((row) => !row.JdlSummary || num(row.CalcIndex) !== 1 || num(row.SC)) ||
["GC", "NC", "JC"].some(
(field) =>
Math.abs(parts.reduce((sum, row) => sum + num(row[field]), 0) - num(title[field])) > 1e-6,
)
)
continue;
const code = "J" + String(next()).padStart(10, "0");
map.set("105:" + title.Code, code);
tables["junit.txt"].push({
F_CODE: code,
F_DES: title.Name,
F_SIZE: title.Size,
F_UNIT: title.Unite,
F_MPRICE: num(title.GC),
F_LPRICE: num(title.NC),
F_EPRICE: num(title.JC),
F_ORICODE: "00000000000",
});
for (const [index, row] of parts.entries()) {
const code1 = source(0, { ...row, Code: "MACHINE-PART-" + next() });
tables["jpart.txt"].push({
F_CODE: code,
F_SEQ: String(index + 1),
F_CODE1: code1,
F_DES: row.Name,
F_SIZE: row.Size,
F_UNIT: row.Unite,
F_QQTY: 1,
F_MPRICE: num(row.GC),
F_LPRICE: num(row.NC),
F_EPRICE: num(row.JC),
F_SPROPER: "0",
F_LOADBM: "T",
F_SAVEBM: "T",
F_ORICODE: "00000000000",
});
}
warnings.push(`${title.Code}: 기계 상세를 고정 금액으로 내보냅니다.`);
}
}
module.exports = { exportMachines };
+29
View File
@@ -0,0 +1,29 @@
function orderUnits(titles, groups) {
const byCode = new Map(titles.filter((title) => title.Code).map((title) => [title.Code, title]));
const state = new Map(),
ordered = [];
for (const title of titles) {
if (!title.Code || state.has(title.Code)) continue;
const stack = [{ title, expanded: false }];
while (stack.length) {
const item = stack.pop(),
code = item.title.Code;
if (item.expanded) {
state.set(code, 2);
ordered.push(item.title);
continue;
}
if (state.has(code)) continue;
state.set(code, 1);
stack.push({ title: item.title, expanded: true });
for (const row of groups.get(code) || []) {
const dependency = Number(row.LinkIndex) === 103 && byCode.get(row.Code);
if (dependency && !state.has(dependency.Code))
stack.push({ title: dependency, expanded: false });
}
}
}
return ordered;
}
module.exports = { orderUnits };
+38 -10
View File
@@ -3,6 +3,8 @@ const { pack, unpack } = require("./archive.cjs");
const { createHash } = require("node:crypto");
const { cp949 } = require("./export-estx.cjs");
const schema = require("./jdl-schema.json");
const { exportMachines } = require("./jdl-machine-export.cjs");
const { orderUnits } = require("./jdl-unit-order.cjs");
const decoder = new TextDecoder("euc-kr", { fatal: true });
function fingerprint(project) {
const { originalJdlState, id, ...content } = project;
@@ -113,15 +115,21 @@ function output(project) {
for (const table of ["CostTitleDat", "MachTitleDat", "HeadTitleDat"])
for (const row of project.tables[table] || [])
source({ CostTitleDat: 104, MachTitleDat: 105, HeadTitleDat: 103 }[table], row);
exportMachines(project, tables, map, ambiguous, num, () => ++serial, source, warnings);
const unitGroups = new Map();
let unitGroup;
for (const row of project.tables.HeadMainDat || []) {
if (num(row.Index) > 0) {
unitGroup = [];
unitGroups.set(row.Code, unitGroup);
if (unitGroups.has(row.Code)) {
if (unitGroups.get(row.Code))
warnings.push(`${row.Code}: 중복 일위 상세는 고정 단가로 전개합니다.`);
unitGroups.set(row.Code, null);
} else unitGroups.set(row.Code, unitGroup);
} else if (unitGroup) unitGroup.push(row);
}
for (const title of project.tables.HeadTitleDat || []) {
const exportedUnits = new Set();
for (const title of orderUnits(project.tables.HeadTitleDat || [], unitGroups)) {
const parts = unitGroups.get(title.Code);
if (
!title.Code ||
@@ -130,7 +138,8 @@ function output(project) {
!parts?.length ||
parts.some(
(row) =>
![5, 6].includes(num(row.LinkIndex)) ||
![5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) ||
(num(row.LinkIndex) === 103 && row.Code === title.Code) ||
!map.has(num(row.LinkIndex) + ":" + row.Code) ||
["CalcIndex", "SKind", "Gangub", "OCheck", "Biul", "SC", "SU"].some((key) =>
num(row[key]),
@@ -140,6 +149,10 @@ function output(project) {
continue;
const code = "U" + String(++serial).padStart(10, "0");
map.set("103:" + title.Code, code);
for (const row of parts)
if (num(row.LinkIndex) === 103 && !exportedUnits.has(row.Code))
warnings.push(`${title.Code}/${row.Code}: 하위 일위대가를 고정 단가로 전개합니다.`);
exportedUnits.add(title.Code);
tables["unit.txt"].push({
F_CODE: code,
F_DES: title.Name,
@@ -333,6 +346,7 @@ function read(bytes, project) {
machineParts.get(part.F_CODE).push(part);
}
let machineDetailed = false,
machineWeighted = false,
machineFallback = false;
for (const title of rows["junit.txt"]) {
const gc = num(title.F_MPRICE),
@@ -341,13 +355,13 @@ function read(bytes, project) {
const amounts = { GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc };
const parts = machineParts.get(title.F_CODE) || [];
const uniqueSequences = new Set(parts.map((part) => part.F_SEQ));
const basic =
const ordinary =
parts.length > 0 &&
uniqueSequences.size === parts.length &&
parts.every(
(part) =>
Boolean(part.F_SEQ) &&
num(part.F_QQTY) === 1 &&
num(part.F_QQTY) > 0 &&
!["%", "%"].includes(part.F_UNIT) &&
["", "0"].includes(part.F_SPROPER) &&
["", "T"].includes(part.F_LOADBM) &&
@@ -355,15 +369,24 @@ function read(bytes, project) {
!num(part.F_SRATE) &&
["", "0"].includes(part.F_KWAN) &&
["", "0"].includes(part.F_CLASS),
) &&
);
const matches = (weighted) =>
[
["F_MPRICE", gc],
["F_LPRICE", nc],
["F_EPRICE", jc],
].every(
([field, value]) =>
Math.abs(parts.reduce((sum, part) => sum + num(part[field]), 0) - value) < 1e-6,
Math.abs(
parts.reduce(
(sum, part) => sum + num(part[field]) * (weighted ? num(part.F_QQTY) : 1),
0,
) - value,
) < 1e-6,
);
const direct = ordinary && matches(false);
const weighted = ordinary && !direct && matches(true);
const basic = (direct && parts.every((part) => num(part.F_QQTY) === 1)) || weighted;
project.tables.MachMainDat.push({
PkID: project.tables.MachMainDat.length + 1,
Index: 1,
@@ -376,16 +399,19 @@ function read(bytes, project) {
});
if (basic) {
machineDetailed = true;
machineWeighted ||= weighted;
for (const part of parts) {
const GC = num(part.F_MPRICE),
NC = num(part.F_LPRICE),
JC = num(part.F_EPRICE);
const factor = weighted ? num(part.F_QQTY) : 1,
GC = num(part.F_MPRICE) * factor,
NC = num(part.F_LPRICE) * factor,
JC = num(part.F_EPRICE) * factor;
project.tables.MachMainDat.push({
PkID: project.tables.MachMainDat.length + 1,
Index: 0,
CalcIndex: 1,
JdlSummary: true,
JdlSourceCode: part.F_CODE1,
JdlSourceQuantity: num(part.F_QQTY),
Name: part.F_DES,
Size: part.F_SIZE,
Unite: part.F_UNIT,
@@ -413,6 +439,8 @@ function read(bytes, project) {
unitWarnings.push(
"일반 기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.",
);
if (machineWeighted)
unitWarnings.push("일부 기계 상세는 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다.");
if (machineFallback)
unitWarnings.push(
"일부 기계경비는 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.",
+24
View File
@@ -0,0 +1,24 @@
const quantity = require("./native-quantity.cjs");
function rowParser(text) {
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
.filter((match) => (Number(match[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
.map((match) => match[1]),
);
const tags = new Map();
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
if (quantity.entities(match[0]).match(/Name:["']([^"']+)["']/)) tags.set(match[1], match[0]);
}
return (div) => {
const ids = new Set([...div.matchAll(/\bTag=(\d+)/gi)].map((match) => match[1]));
const declarations = [...ids].map((id) => tags.get(id) || "").join("");
const styles = [...new Set([...div.matchAll(/<c\s+nr=(\d+)\s*\/>/gi)].map((m) => m[1]))]
.filter((id) => superscripts.has(id))
.map((id) => `<cs nr=${id} CharStyleON:16/>`)
.join("");
return quantity.fromWpt("", "", styles + declarations + div, [], {}).rows;
};
}
module.exports = { rowParser };