feat(est): read project print settings

This commit is contained in:
2026-11-06 22:37:43 +09:00
parent 8f9251d627
commit c4c3be0a7c
7 changed files with 136 additions and 7 deletions
@@ -8,6 +8,29 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_estx_print_catalog_uses_project_options_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),p=require('./tools/est-test/import-print.cjs'),s=require('./tools/est-test/server.cjs');
const bytes=fs.readFileSync(process.argv[1]),project=m.importEstx(bytes),form=project.printCatalog.find(r=>r.Index===1);
let invalid=false;try{p.readPrintOptions(Buffer.from([1,2,3]))}catch{invalid=true}
process.stdout.write(JSON.stringify({count:project.printCatalog.length,name:form.Name,project:form.ProjectName,
lines:form.PrintLineCount,left:form.PrintLeft,preserved:s.calculate(project).printCatalog.length,invalid}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result == {
"count": 41,
"name": "내역서",
"project": "연습공사",
"lines": 21,
"left": 20,
"preserved": 41,
"invalid": True,
}
def test_quantity_document_edits_keep_styles_and_bookmarks_and_block_cross_style_edits():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1/><div><c nr=1/>폭= <begin Tag=2 wpsty=[[Name:&#34;r000001&#34;;]]/>6<end Tag=2/> m</div>';
+30
View File
@@ -272,6 +272,19 @@ def test_arrow_to_sketch_is_not_a_fraction():
assert result["rows"][2]["TC"] == 10.4
def test_mixed_brackets_in_annotation():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'datum'},
{LineData:' t1 = 5분 〔토운선을 준설선에 접선 소요시간]'},
{LineData:' Q = t1 × 2 = \x1210\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 10
def test_greek_variables_and_implicit_multiplication_in_marine_formula():
result = run(
"math",
@@ -318,6 +331,23 @@ def test_braced_arithmetic_and_survey_point_units():
assert result["rows"][4]["TC"] == 130.43
def test_marine_compound_units_and_therefore_symbol():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'units'},
{LineData:' 시험 : 2지점×2개항목×4회×11,000= \x12176,000\x12'},
{LineData:' 보고서 : \x0350\x03×70×4분기×5권 = \x1270,000\x12', SCheck:-2},
{LineData:' Q = 4hr/일 × 15일/월 × 65월 = \x123,900\x12'},
{LineData:' ∴ 0.22 ÷ 6㎥ = \x120.04\x12 hr'},
{LineData:' C = ((1,294.95 + 1,174.6) ÷ 1.1) ÷ 5DM/Ton = \x12449\x12원/DM', Precision:0},
{LineData:' 행거 11조당 중량 4.69 × 2 = \x129.38\x12 ㎏'}
])""",
)
assert result["errors"] == []
assert [row["TC"] for row in result["rows"][1:]] == [176000, 70000, 3900, 0.04, 449, 9.38]
def test_spaced_metre_unit_is_not_a_second_variable_declaration():
result = run(
"math",
+2 -2
View File
@@ -4,7 +4,7 @@
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개, 예정공정표 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV·Excel·조달청 XML·JDL 기본 표 입출력과 브라우저 인쇄를 사용할 수 있습니다.
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. 원본 인쇄 도형·병합·글꼴 배치와 동일하다는 의미는 아닙니다.
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. 웹에서 변경한 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다.
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다.
@@ -55,7 +55,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
이후 해양 자료에 나타나는 단위·도식 표기, 그리스 문자 변수, 괄호 앞 생략 곱셈과 중괄호 산식을 처리해 저장값 134,159/134,208 일치·해석 오류 37건으로 개선했습니다. 원본에서 선언값과 숫자 대입식의 부호가 충돌하거나 변수가 누락된 사례는 임의로 수정하지 않았습니다.
이후 해양 자료에 나타나는 단위·도식 표기, 그리스 문자 변수, 괄호 앞 생략 곱셈과 중괄호 산식을 처리해 저장값 134,159/134,208 일치·해석 오류 22건으로 개선했습니다. 원본에서 선언값과 숫자 대입식의 부호가 충돌하거나 변수가 누락된 사례는 임의로 수정하지 않았습니다.
2026-11-06 지정 범위인 설계변경·기성·물가변동·연차의 웹앱 구현을 완료했습니다. 설계변경은 당초 보존·재계산 0/1/2와 신규·삭제·변경 비교를 제공합니다. 삭제된 당초/기성 행 번호를 새 행에 재사용하지 않습니다.
+10
View File
@@ -1,6 +1,7 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const { readPrintOptions } = require("./import-print.cjs");
const decoder = new TextDecoder("euc-kr", { fatal: true });
const LIMIT = 256 * 1024 * 1024;
const crcTable = Array.from({ length: 256 }, (_, n) => {
@@ -339,6 +340,14 @@ function importEstx(buffer, name = "가져온 내역서") {
OldRecalcMethod: props.TChangeOption?.OldRecalcMethod || 0,
};
const quantitySheets = [];
let printCatalog = [];
if (files.has("datprintoption.opt")) {
try {
printCatalog = readPrintOptions(files.get("datprintoption.opt"));
} catch (error) {
warnings.add(`인쇄 설정: ${error.message}`);
}
}
for (const row of tables.MulMainDat || []) {
if (!row.Editor?.binary || !row.MulList?.binary) continue;
try {
@@ -367,6 +376,7 @@ function importEstx(buffer, name = "가져온 내역서") {
options,
quantities: [],
quantitySheets,
printCatalog,
progress: [],
annual: [],
drawings: [],
+60
View File
@@ -0,0 +1,60 @@
// DatPrintOption.opt memory table, as documented in ESTX_파일구조.md.
function readPrintOptions(buffer) {
if (buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996)
throw Error("인쇄 설정 헤더 오류");
const count = buffer.readUInt16LE(12),
rows = buffer.readUInt16LE(14),
start = buffer.readUInt16LE(22);
if (count > 1024 || rows > 1000 || start > buffer.length) throw Error("인쇄 설정 크기 오류");
const decoder = new TextDecoder("euc-kr", { fatal: true });
let at = 24;
const take = (n) => {
if (n < 0 || at + n > buffer.length) throw Error("인쇄 설정 범위 오류");
const result = buffer.subarray(at, at + n);
at += n;
return result;
};
const byte = () => take(1)[0];
const short = () => take(2).readUInt16LE();
const fields = [];
for (let i = 0; i < count; i++) {
const name = take(byte()).toString("ascii"),
width = short(),
kind = short();
take(2);
const attributes = short();
for (let j = 0; j < attributes; j++) {
take(byte());
const size = short(),
type = short();
if (type === 73) take(short());
else take(size);
}
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) throw Error("인쇄 설정 필드명 오류");
fields.push({ name, width, kind });
}
if (at > start) throw Error("인쇄 설정 설명자 범위 오류");
at = start;
const result = [];
for (let r = 0; r < rows; r++) {
take(1);
const status = take(Math.ceil(count / 4)),
row = {};
for (let i = 0; i < count; i++) {
const { name, width, kind } = fields[i],
state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
if (state === 1 || state === 2) continue;
if (state !== 0) throw Error("인쇄 설정 필드 상태 오류");
if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
else if (kind === 4 && width === 8) row[name] = take(8).readDoubleLE();
else if (kind === 73 && width === 1) row[name] = decoder.decode(take(byte()));
else if (kind === 75 && width === 4) take(take(4).readUInt32LE());
else throw Error(`인쇄 설정 필드형 오류: ${name}/${kind}/${width}`);
}
result.push(row);
}
if (at !== buffer.length) throw Error("인쇄 설정 잔여 데이터 오류");
return result;
}
module.exports = { readPrintOptions };
+7 -2
View File
@@ -119,7 +119,11 @@ const EstReport = (() => {
return pages.join("");
}
async function open() {
catalog = await EstApp.api("/api/print-catalog");
const defaults = await EstApp.api("/api/print-catalog");
const original = new Map(
(EstApp.state.project.printCatalog || []).map((row) => [row.Index, row]),
);
catalog = defaults.map((row) => ({ ...row, ...original.get(row.Index) }));
const table = EstApp.state.project.tables[EstApp.state.view];
const s = setting(),
{ esc } = EstApp;
@@ -176,7 +180,8 @@ const EstReport = (() => {
1,
Math.min(100, Number(original.PrintLineCount) || 30),
);
next.margin = Math.min(50, Number(original.PrintLeft) || 12);
if (Number.isFinite(original.PrintLeft))
next.margin = Math.max(0, Math.min(50, original.PrintLeft));
}
}
if (key === "notes") {
+4 -3
View File
@@ -31,19 +31,20 @@ const EstText = (() => {
mathBraces(text)
.replace(///g, "/")
.replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|[〔[][^〕]]*[〕]]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
)
.replace(/^\s*[가-힣]+\s+\d+조당\s+[가-힣]+\s+(?=\d)/, "")
.replace(
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|set|kwh|kw|km|knot|ton|kg|㎏|㎞|ℓ|구멍|구역|지점|마일|척|열|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|배|번|줄|μ|₩|₩|\\|EA|㎜|㎝|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|구멍|지점|개|대|본|인|분|포|매|주|톤|공|회|조|span|set|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|set|kwh|kw|km|knot|ton|kg|㎏|㎞|ℓ|구멍|구역|지점|마일|척|열|회|개소|개항목|개|대|분기|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|배|번|줄|권|μ|₩|₩|\\|EA|DM|㎜|㎝|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|구멍|지점|개|대|본|인|분|포|매|주|톤|공|회|조|줄|월|span|set|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/\s*(?:일|㎥|㎡|hr)(?=\s|$|[×*+)])/gi, "")
.replace(/[】]〕]\s*$/, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단|회|㎥|㎡)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/^\s*[-*•※⊙◎]?\s*[가-힣㎡㎥][가-힣㎡㎥\s]*\s+(?=[\d.(])/, "")
.replace(/^\s*[⊙◎]\s*/, "")
.replace(/^\s*[⊙◎∴]\s*/, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}