diff --git a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx
index 3050cc48..46476910 100644
--- a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx
+++ b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx
@@ -1,14 +1,23 @@
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
-import type { FC } from 'react';
+import { type FC, useEffect, useState } from 'react';
+import { HtmlEvent } from '../App.types';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
+import { getHostTitle } from '../state';
-export const QuickAccessBar: FC = () => (
+export const QuickAccessBar: FC = () => {
+ const [hostTitle, setTitle] = useState(getHostTitle());
+ useEffect(() => {
+ const refresh = () => setTitle(getHostTitle());
+ window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
+ return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
+ }, []);
+ return (
Aislo CAD
- B07 상세 설계
+ {hostTitle}
{QUICK_ACCESS_COMMANDS.map((id) => {
@@ -32,3 +41,4 @@ export const QuickAccessBar: FC = () => (
);
+};
diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts
index 3ffebc53..755abd0f 100644
--- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts
+++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts
@@ -21,6 +21,7 @@ import {
setEntities,
setFrameEditMode,
setHostReadOnly,
+ setHostTitle,
setLayers,
} from '../state.ts';
import { toast } from 'react-toastify';
@@ -50,6 +51,8 @@ interface DrawingLoadMessage {
recoveryScope?: string;
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
readOnly?: boolean;
+ /** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
+ hostTitle?: string;
}
interface DrawingSaveRequestMessage {
@@ -193,6 +196,7 @@ export function registerAisloDrawingBridge() {
setDesignMeta(event.data.meta ?? null);
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
+ setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts
index aa51cd94..785555dd 100644
--- a/B07_DesignDetail/openwebcad/src/state.ts
+++ b/B07_DesignDetail/openwebcad/src/state.ts
@@ -192,6 +192,7 @@ let frameEditMode = false;
let frameFields: Record = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false;
+let hostTitle = 'B07 상세 설계';
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -496,6 +497,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
};
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
+/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
+export const getHostTitle = (): string => hostTitle;
+export const setHostTitle = (title: string) => {
+ hostTitle = title;
+ notifyWindow(HtmlEvent.UPDATE_STATE);
+};
export const setHostReadOnly = (readOnly: boolean) => {
hostReadOnly = readOnly;
notifyWindow(HtmlEvent.UPDATE_STATE);
diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts
index 5c7d42ea..b3bbbecb 100644
--- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts
+++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts
@@ -24,12 +24,42 @@ export interface DrawingTemplateDoc extends CadHostDrawing {
}
export interface DrawingTemplateOptions {
- /** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
+ /** 쓰지 않음 — 저장은 페이지 머리 [저장] 한 곳(`getDoc()` 으로 편집본을 가져감 · 판 · 409 흐름). */
onSave?: (doc: DrawingTemplateDoc) => void | Promise;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
+ /** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */
+ layer?: string;
+ /** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
+ projectId?: string | null;
+}
+
+const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
+
+/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
+function recoveryScopeOf(options: DrawingTemplateOptions): string {
+ const name = options.name ?? "drawing";
+ if (!options.layer) return `m02:${name}`;
+ const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
+ return `m02:${options.layer}${project}:${name}`;
+}
+
+/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */
+function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void {
+ const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`;
+ if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return;
+ try {
+ const value = localStorage.getItem(old);
+ if (value === null) return;
+ if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) {
+ localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value);
+ }
+ localStorage.removeItem(old);
+ } catch {
+ // 저장소를 못 써도 편집은 됨
+ }
}
export interface DrawingTemplateHandle {
@@ -79,7 +109,8 @@ export function mountDrawingTemplate(
options: DrawingTemplateOptions = {},
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
- const recoveryScope = `m02:${options.name ?? "drawing"}`;
+ const recoveryScope = recoveryScopeOf(options);
+ migrateOldRecovery(options, recoveryScope);
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
@@ -139,7 +170,13 @@ export function mountDrawingTemplate(
const cad = createCadHost({ title: "도면 양식" });
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
- cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
+ cad.load(
+ drawing,
+ null,
+ !readOnly,
+ {},
+ { recoveryScope, readOnly, hostTitle: "도면 양식 편집" },
+ );
};
const actions = document.createElement("div");
@@ -184,27 +221,6 @@ export function mountDrawingTemplate(
return base;
};
- if (options.onSave && !readOnly) {
- const onSave = options.onSave;
- const saveButton = createButton({
- label: "저장",
- variant: "filled",
- onClick: () => {
- saveButton.disabled = true;
- getDoc()
- .then((next) => onSave(next))
- .catch((error) =>
- showToast(
- error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
- "error",
- ),
- )
- .finally(() => (saveButton.disabled = false));
- },
- });
- actions.append(saveButton);
- }
-
toolbar.append(area, fields, actions);
root.append(toolbar, cad.element);
container.append(root);
diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
index 75b39afe..5418cd04 100644
--- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
+++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
@@ -116,6 +116,8 @@ export function createMain(isAdmin: boolean): MainHandle {
onSave: () => void doSave(),
readOnly,
name: at.name,
+ layer: at.layer,
+ projectId: at.projectId,
});
return;
}
diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py
index d5f32951..44dd9804 100644
--- a/M02_MasterTemplete/M02_Table_Fill.py
+++ b/M02_MasterTemplete/M02_Table_Fill.py
@@ -6,7 +6,8 @@
B06 횡단 설계 `design.pipe_length_m` 관 연장(부르는 쪽이 DB 에서 읽어 넘김)
- 펼침 열 — 마스터 한 칸 → 설계에 쓰인 값마다 열(choices 차례) · 쓰인 값이 없으면 열을 뺌.
- 열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로).
+ 열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로 · 빈 값은 `-`).
+ 한 묶음 열은 값마다 한데 모음 · 계산 열은 `펼침틀` 로 묶음을 따라 나뉨(바인딩 없음).
- 줄 — 측점마다 한 줄(같은 측점 구조물은 한 줄) · 점 `NO.x+y` · 구간 `NO.a~NO.b` ·
같은 측점에 관이 둘이면 줄을 나눔 · 줄 id 는 측점에서 지어 손 값이 다시 채워도 붙어 있음.
- 손 열 값 · 전구간 줄 · 사용자가 더한 줄(`손`)은 작업본 것을 그대로 지킴.
@@ -35,7 +36,10 @@ FACILITY_TYPES = {
"ford_bridge": "ford_bridge",
"revetment": "revetment",
}
-EMPTY_LABEL = "-"
+EMPTY_LABEL = "-" # 열 id 안 빈 값(저장본과 맞춤) — 머리 글은 `빈값`
+EMPTY_TEXT = "미입력"
+#: 계산 열이 설계값 열 묶음을 따라 나뉠 때 쓰는 틀(바인딩 아님 — 표 부품이 설계값으로 안 봄).
+FRAME_KEY = "펼침틀"
ORPHAN_NOTE = "설계에서 측점 없어짐"
#: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀.
RULE_COLUMNS_KEY = "양식열"
@@ -271,12 +275,31 @@ def _blank_vars(formula: str, variables: dict[str, Any]) -> list[str]:
return [name for name in names if variables.get(name) in (None, "")]
+def _frame(column: dict[str, Any]) -> dict[str, Any]:
+ """펼침 틀 — 설계값 열은 `바인딩` · 따라 나뉘는 계산 열은 `펼침틀`(설계값 딱지 없음)."""
+ return column.get("바인딩") or column.get(FRAME_KEY) or {}
+
+
+def _group(column: dict[str, Any]) -> str:
+ return _frame(column).get("묶음") or column["id"]
+
+
+def _head_part(frame: dict[str, Any], index: int, value: str) -> str:
+ """펼친 값 한 자리 머리 글 — `값꼴`(`H={}`) · 빈 값은 `빈값`(`높이 미입력`)."""
+ if value == EMPTY_LABEL:
+ empties = frame.get("빈값") or []
+ return (empties[index] if index < len(empties) else "") or EMPTY_TEXT
+ shapes = frame.get("값꼴") or []
+ shape = (shapes[index] if index < len(shapes) else "") or "{}"
+ return shape.replace("{}", str((frame.get("이름") or {}).get(value, value)))
+
+
def _unexpand(columns: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""채운 표가 작업본으로 저장됐으면 펼친 열을 마스터 칸(`원열`)으로 되돌림 — 두 번 안 펼침."""
out: list[dict[str, Any]] = []
seen: set[str] = set()
for column in columns:
- origin = (column.get("바인딩") or {}).get("원열")
+ origin = _frame(column).get("원열")
if origin is None:
out.append(column)
elif origin["id"] not in seen:
@@ -291,62 +314,82 @@ def _expand_columns(
variables: dict[str, Any],
choices: dict[str, list[str]],
) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]:
- """펼침 열 → 값마다 열. 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`."""
+ """펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
+
+ 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.
+ """
columns = _unexpand(columns)
groups: dict[str, set[tuple[str, ...]]] = {}
orders_of: dict[str, list[list[str]]] = {}
+ members: dict[str, list[dict[str, Any]]] = {}
for column in columns:
- binding = column.get("바인딩") or {}
- if not column.get("펼침") or not binding:
+ if not column.get("펼침") or not _frame(column):
continue
- group = binding.get("묶음") or column["id"]
- orders_of.setdefault(group, _position_orders(binding, choices))
- found = groups.setdefault(group, set())
+ members.setdefault(_group(column), []).append(column)
+ binding = column.get("바인딩")
+ if not binding:
+ continue # 계산 열 — 설계값 열이 연 값을 따름
+ orders_of.setdefault(_group(column), _position_orders(binding, choices))
+ found = groups.setdefault(_group(column), set())
for source in _sources(binding):
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
found.update(_expansion(source, item) for item in items if _matches(source, item))
- members: dict[str, set[str]] = {}
- for column in columns:
- binding = column.get("바인딩") or {}
- if column.get("펼침") and binding:
- members.setdefault(binding.get("묶음") or column["id"], set()).add(column["id"])
-
out: list[dict[str, Any]] = []
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
for column in columns:
- binding = column.get("바인딩") or {}
- if not column.get("펼침") or not binding:
+ if not column.get("펼침") or not _frame(column):
out.append(copy.deepcopy(column))
continue
- group = binding.get("묶음") or column["id"]
- ordered = sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders_of[group]))
- names = binding.get("이름") or {}
- for values in ordered:
- shown = [names.get(value, value) for value in values]
- suffix = "|".join(values)
- new = copy.deepcopy(column)
- new["id"] = f"{column['id']}|{suffix}"
- new["펼침"] = False
- new["머리"] = [
- None if part is None else part.format(*shown)
- for part in (binding.get("머리틀") or column["머리"])
- ]
- new["바인딩"] = {**binding, "펼친값": list(values), "원열": copy.deepcopy(column)}
- formula = binding.get("식틀")
- formula = formula.format(*values) if formula else column.get("식")
- if formula:
- formula = _rewrite(formula, members.get(group, set()), suffix)
- if _blank_vars(formula, variables):
- new.pop("식", None)
- new["설명"] = f"{column.get('설명', '')} · 변수가 빈칸이라 계산 안 함".strip()
- else:
- new["식"] = formula
- out.append(new)
- made.setdefault(column["id"], []).append((new["id"], suffix, new))
+ group = _group(column)
+ if members[group][0] is not column:
+ continue # 묶음 첫 열 자리에서 값마다 묶음 열을 한데 냄
+ orders = orders_of.get(group, [])
+ sibling_ids = {member["id"] for member in members[group]}
+ for values in sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders)):
+ for member in members[group]:
+ new = _expand_one(member, values, sibling_ids, variables)
+ out.append(new)
+ made.setdefault(member["id"], []).append((new["id"], "|".join(values), new))
return out, made
+def _expand_one(
+ column: dict[str, Any],
+ values: tuple[str, ...],
+ siblings: set[str],
+ variables: dict[str, Any],
+) -> dict[str, Any]:
+ frame = _frame(column)
+ suffix = "|".join(values)
+ shown = [_head_part(frame, index, value) for index, value in enumerate(values)]
+ new = copy.deepcopy(column)
+ new["id"] = f"{column['id']}|{suffix}"
+ new["펼침"] = False
+ new["머리"] = [
+ None if part is None else part.format(*shown)
+ for part in (frame.get("머리틀") or column["머리"])
+ ]
+ key = "바인딩" if column.get("바인딩") else FRAME_KEY
+ new[key] = {**frame, "펼친값": list(values), "원열": copy.deepcopy(column)}
+ template = frame.get("식틀")
+ used = [int(i) for i in re.findall(r"\{(\d+)\}", template or "")]
+ blank = ""
+ if any(values[i] == EMPTY_LABEL for i in used if i < len(values)):
+ formula, blank = None, "설계값이"
+ else:
+ formula = template.format(*values) if template else column.get("식")
+ if formula:
+ formula = _rewrite(formula, siblings, suffix)
+ blank = "변수가" if _blank_vars(formula, variables) else ""
+ if blank:
+ new.pop("식", None)
+ new["설명"] = f"{column.get('설명', '')} · {blank} 빈칸이라 계산 안 함".strip()
+ elif formula:
+ new["식"] = formula
+ return new
+
+
def fill_document(
document: dict[str, Any],
items: list[dict[str, Any]],
@@ -400,8 +443,8 @@ def fill_document(
spacing = _number(item["options"].get("joint_spacing_m"))
if item["type_id"] != "pavement_concrete" or spacing in (None, base_spacing):
continue
- for new_id, _, new in made.get("pv_jt", []):
- if list(_expansion({"펼침": new["바인딩"]["펼침"]}, item)) == new["바인딩"]["펼친값"]:
+ for new_id, suffix, new in made.get("pv_jt", []):
+ if "|".join(_expansion({"펼침": ["thickness_cm"]}, item)) == suffix:
text = new.get("식", "").replace("[$수축줄눈_간격_m]", _label("", spacing))
slots[slot].setdefault("식", {})[new_id] = text
@@ -452,11 +495,11 @@ def fill_document(
def _hand_ids(columns: list[dict[str, Any]]) -> set[str]:
- """사람이 적는 열 — 바인딩 없는 열(손 열 · 비고 · 사용자가 더한 열) · 차례 · 측점 빼고."""
+ """사람이 적는 열 — 설계값 · 계산 열 말고(손 열 · 비고 · 더한 열) · 차례 · 측점 빼고."""
return {
column["id"]
for column in columns
- if not column.get("바인딩") and column["id"] not in ("no", "sta")
+ if not _frame(column) and not column.get("식") and column["id"] not in ("no", "sta")
}
diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py
index 2e1fb345..82adfe26 100644
--- a/M02_MasterTemplete/M02_Template_Layers.py
+++ b/M02_MasterTemplete/M02_Template_Layers.py
@@ -209,6 +209,15 @@ def _write_manifest(layer_dir: str | Path, entries: dict[str, Any]) -> None:
atomic_write_json(Path(layer_dir) / MANIFEST_NAME, {"양식": entries})
+def note_source(
+ layer_dir: str | Path, kind: str, name: str, source_layer: str, version: str
+) -> None:
+ """manifest 한 줄 — 복사가 아니라 고쳐 쓴 양식의 출처 · 판."""
+ manifest = read_manifest(layer_dir)
+ manifest[f"{kind}/{name}"] = _stamp(source_layer, name, version)
+ _write_manifest(layer_dir, manifest)
+
+
def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str, Any]:
entry = {
"층": layer,
diff --git a/M02_MasterTemplete/M02_Template_Migrate.py b/M02_MasterTemplete/M02_Template_Migrate.py
index 652fe2ae..294cdd08 100644
--- a/M02_MasterTemplete/M02_Template_Migrate.py
+++ b/M02_MasterTemplete/M02_Template_Migrate.py
@@ -3,14 +3,17 @@
프로젝트마다 `seed_project(only_missing=True)` — 작업본 · `_initial/` 에 **없는 것만 더함**.
이미 있는 양식 · 다른 파일은 안 건드림 · 폴더가 없는 프로젝트는 건너뜀(만들지 않음).
다시 돌려도 됨 — 시스템 양식이 늘었으면 그 몫만 더해짐.
+`--refresh-tables` — 옛 표 틀(계산 열에 바인딩)만 새 판으로 · 손 값은 지킴.
- ./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py [--dry-run] [--report 파일]
+ ./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py \\
+ [--dry-run] [--refresh-tables] [--report 파일]
"""
from __future__ import annotations
import argparse
import asyncio
+import copy
import os
import sys
from datetime import datetime
@@ -21,6 +24,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import config_system # noqa: E402
from config.config_db import close_db_pool, get_db_pool, init_db_pool # noqa: E402
+from M02_MasterTemplete import M02_Table_Fill as fill # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
@@ -51,13 +55,62 @@ def project_root(storage_path: str | None) -> Path | None:
return path
-def migrate(projects: list[dict[str, Any]], *, dry_run: bool = False) -> list[dict[str, Any]]:
- """프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 상태}]`."""
+def _stale_frame(document: Any) -> bool:
+ """옛 표 틀 — 계산 열에 바인딩(설계값 딱지)이 붙은 판."""
+ return isinstance(document, dict) and any(
+ isinstance(column, dict)
+ and column.get("바인딩")
+ and (column.get("식") or column["바인딩"].get("값") == "식")
+ for column in document.get("열") or []
+ )
+
+
+def refresh_tables(root: Path, *, dry_run: bool = False) -> list[str]:
+ """옛 표 틀을 시스템 새 판으로 — 작업본 · `_initial/` 둘 다 · 줄(손 값) · 변수 · 보기는 지킴.
+
+ 손댄 것이 없으면 시스템 파일을 그대로 복사(판이 시스템과 같음).
+ """
+ done: list[str] = []
+ for row in layers.list_templates(layers.system_dir()):
+ if row["종류"] != "table":
+ continue
+ system = layers.read_template(layers.system_dir(), "table", row["이름"])
+ for folder in (layers.project_dir(root), layers.initial_dir(root)):
+ got = layers.read_template(folder, "table", row["이름"])
+ if not system or not got or not _stale_frame(got["문서"]):
+ continue
+ done.append(f"{folder.name}/table/{row['이름']}")
+ if dry_run:
+ continue
+ old = fill.strip_design(got["문서"])
+ doc = copy.deepcopy(system["문서"])
+ doc["변수"] = {**(doc.get("변수") or {}), **(old.get("변수") or {})}
+ doc.update({key: old[key] for key in ("줄", "보기") if key in old})
+ if doc == system["문서"]:
+ layers.copy_templates(
+ layers.system_dir(),
+ folder,
+ source_layer="system",
+ kind="table",
+ name=row["이름"],
+ )
+ else:
+ layers.write_template(folder, "table", row["이름"], doc)
+ layers.note_source(folder, "table", row["이름"], "system", system["판"])
+ return done
+
+
+def migrate(
+ projects: list[dict[str, Any]], *, dry_run: bool = False, refresh: bool = False
+) -> list[dict[str, Any]]:
+ """프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 표갱신, 상태}]`."""
system = {f"{row['종류']}/{row['이름']}" for row in layers.list_templates(layers.system_dir())}
report = []
for project in projects:
root = project_root(project.get("storage_path"))
- entry = {**project, "작업본": [], "초기": [], "상태": ""}
+ entry = {**project, "작업본": [], "초기": [], "표갱신": [], "상태": ""}
+ if root is not None and refresh:
+ entry["표갱신"] = refresh_tables(root, dry_run=dry_run)
if root is None:
entry["상태"] = "폴더 없음 — 건너뜀"
elif dry_run:
@@ -85,14 +138,14 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str:
"- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로",
f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개",
"",
- "| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 상태 |",
- "| --- | --- | --- | --- | --- | --- |",
+ "| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 표 틀 새 판 | 상태 |",
+ "| --- | --- | --- | --- | --- | --- | --- |",
]
for row in report:
added = " · ".join(row["작업본"]) or "—"
lines.append(
f"| {row['company_id']} | {row.get('owner') or row['user_id']} | {row['name']} "
- f"| `{row['id']}` | {added} | {row['상태']} |"
+ f"| `{row['id']}` | {added} | {' · '.join(row['표갱신']) or '—'} | {row['상태']} |"
)
return "\n".join(lines) + "\n"
@@ -100,6 +153,7 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str:
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--refresh-tables", action="store_true")
parser.add_argument("--report", type=Path)
args = parser.parse_args()
await init_db_pool()
@@ -107,7 +161,7 @@ async def main() -> None:
projects = await _projects()
finally:
await close_db_pool()
- report = migrate(projects, dry_run=args.dry_run)
+ report = migrate(projects, dry_run=args.dry_run, refresh=args.refresh_tables)
text = render(report, layers.list_templates(layers.system_dir()))
if args.report:
args.report.write_text(text, encoding="utf-8")
diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json
index 0417e27f..6b91bff4 100644
--- a/resources/master_template/table/구조물집계표.json
+++ b/resources/master_template/table/구조물집계표.json
@@ -3,7 +3,7 @@
"종류": "표",
"판": 1,
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
- "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값)",
+ "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)",
"층": ["종류", "공법", "규격"],
"변수": {
"파형강관_1본_m": 8,
@@ -23,7 +23,7 @@
"일위대가": "UA000004",
"바인딩": {
"종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv",
- "머리틀": ["돌기슭막이", "{0}", "H={1}"],
+ "머리틀": ["돌기슭막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형태 미입력", "높이 미입력"],
"이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" },
"더함": [
{ "종류": "pipe", "펼침": ["inlet_revet_form", "inlet_revet_height_m"], "값": "inlet_revet_length_m", "조건": { "inlet_type": "기슭막이" } },
@@ -35,18 +35,18 @@
{
"id": "pv_b", "머리": ["콘크리트포장", "T=(두께마다)", "B"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 폭",
- "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "B"] }
+ "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "B"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이",
"일위대가": "UA000007",
- "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "L"] }
+ "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "L"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "설계값 — 확폭 면적",
- "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "확폭"] }
+ "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "확폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true,
@@ -54,14 +54,14 @@
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — B × L + 확폭",
"일위대가": "UA000006",
- "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "A"] }
+ "펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "A"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true,
"식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")",
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)",
- "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "수축줄눈"] }
+ "펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "수축줄눈"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
@@ -69,14 +69,14 @@
"설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔",
"일위대가": "UA000036",
"일위대가후보": ["UA000036", "UA000027"],
- "바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "관매설"] }
+ "바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} {1}", "관매설"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"] }
},
{
"id": "pp_cp", "머리": ["관공", "(관종·관경마다)", "커플링밴드"], "단위": "개소", "꼴": "수", "펼침": true,
"식": "IF([pp_len]>0,ROUNDUP([pp_len]/[$파형강관_1본_m],0)-1,\"\")",
"설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)",
- "바인딩": {
- "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "식", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "커플링밴드"],
+ "펼침틀": {
+ "묶음": "pp", "머리틀": ["관공", "{0} {1}", "커플링밴드"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"],
"식틀": "IF([pp_len]>0,ROUNDUP([pp_len]/[${0}_1본_m],0)-1,\"\")"
}
},
@@ -87,7 +87,7 @@
"일위대가": "UA000008",
"일위대가후보": ["UA000008", "UA000025", "UA000029"],
"바인딩": {
- "종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"],
+ "종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"], "빈값": ["형식 미입력"],
"이름": { "A-TYPE": "날개벽 A형", "C-TYPE": "날개벽 C형", "A-TYPE+집수정": "날개벽 A형+집수정" },
"더함": [ { "종류": "pipe", "펼침": ["inlet_basin_form"], "값": "개소", "조건": { "inlet_type": "집수정" } } ]
}
@@ -97,14 +97,14 @@
"id": "bx_len", "머리": ["BOX암거", "(폭×높이마다)", "연장"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — BOX 연장(계류 방향)",
"일위대가": "UA000014",
- "바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"] }
+ "바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"], "빈값": ["폭 미입력", "높이 미입력"] }
},
{
"id": "bx_wing", "머리": ["BOX암거", "(폭×높이마다)", "날개벽"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 날개벽 유입·유출 있음 수",
"일위대가": "UA000025",
"바인딩": {
- "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"],
+ "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"], "빈값": ["폭 미입력", "높이 미입력"],
"조건": { "wing_in": "있음" },
"더함": [ { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "조건": { "wing_out": "있음" } } ]
}
@@ -113,32 +113,32 @@
{
"id": "fb", "머리": ["세월교", "(관경 × 련수마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 세월교 개소",
- "바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "Φ{0}×{1}련", null] }
+ "바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "{0}×{1}", null], "값꼴": ["Φ{}", "{}련"], "빈값": ["관경 미입력", "련수 미입력"] }
},
{
"id": "fp_w", "머리": ["물넘이포장", "T=(두께마다)", "폭"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 월류 폭",
- "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "폭"] }
+ "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이(노폭 방향)",
- "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "길이"] }
+ "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "길이"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "od_len", "머리": ["횡단개거", "(규격마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 연장",
"일위대가": "UA000026",
- "바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"] }
+ "바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"], "빈값": ["규격 미입력"] }
},
{
"id": "od_cnt", "머리": ["횡단개거", "(규격마다)", "개소"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 · 노출형 횡단수로 개소",
"일위대가": "UA000003",
"바인딩": {
- "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"],
+ "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"], "빈값": ["규격 미입력"],
"더함": [ { "종류": "cross_drain_exposed", "펼침": ["=노출형"], "값": "개소" } ]
}
},
@@ -146,13 +146,13 @@
{
"id": "rw", "머리": ["옹벽", "(형식마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 옹벽 길이",
- "바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "H={1}"] }
+ "바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
},
{
"id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 돌쌓기 길이",
"바인딩": {
- "종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "H={1}"],
+ "종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["", "높이 미입력"],
"더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ]
}
},
@@ -160,19 +160,19 @@
"id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만",
"일위대가": "UA000032",
- "바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "H={1}"] }
+ "바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
},
{
"id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 큰돌쌓기 길이",
- "바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "길이"] }
+ "바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "길이"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"] }
},
{
"id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "계산 — L × H (H = 펼친 높이)",
"일위대가": "UA000031",
- "바인딩": {
- "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "식", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "면적"],
+ "펼침틀": {
+ "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "면적"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"],
"식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")"
}
},
@@ -180,7 +180,7 @@
{
"id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 골막이 개소",
- "바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null] }
+ "바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null], "빈값": ["형식 미입력"] }
},
{
"id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수",
@@ -200,7 +200,7 @@
{
"id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 기타 구조물 개소",
- "바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null] }
+ "바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null], "빈값": ["이름 미입력"] }
},
{ "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" },
diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py
index bffc7425..a3086885 100644
--- a/resources/tester/test_m02_table_fill.py
+++ b/resources/tester/test_m02_table_fill.py
@@ -92,10 +92,37 @@ def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> No
assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음
# 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값
assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids
- assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "H=-"]
+ assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "높이 미입력"]
+ assert _col(doc, "fb|1000|-")["머리"] == ["세월교", "Φ1000×련수 미입력", None]
assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음
+def test_묶음_열은_값마다_한데(project: Path) -> None:
+ ids = [column["id"] for column in _filled(project)["열"]]
+ assert [i for i in ids if i.startswith("pp_")] == [
+ "pp_len|흄관|1000",
+ "pp_cp|흄관|1000",
+ "pp_len|파형강관|1000",
+ "pp_cp|파형강관|1000",
+ ]
+ assert [i for i in ids if i.startswith("pv_")] == [
+ f"pv_{k}|20" for k in ("b", "l", "w", "a", "jt")
+ ]
+
+
+def test_계산_열은_설계값_딱지_없음(project: Path) -> None:
+ master = _master()
+ assert not [c["id"] for c in master["열"] if c.get("식") and c.get("바인딩")]
+ doc = _filled(project)
+ for col_id in ("pv_a|20", "pv_jt|20", "pp_cp|파형강관|1000", "bm_area|메쌓기|40~60|2.5"):
+ column = _col(doc, col_id)
+ assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0]
+ assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"]
+ # 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림)
+ again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0})
+ assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]]
+
+
def test_계산_열_식은_같은_묶음_열로_바뀌고_빈_변수면_안_셈(project: Path) -> None:
doc = _filled(project)
assert _col(doc, "pv_a|20")["식"] == 'IF([pv_l|20]>0,[pv_b|20]*[pv_l|20]+[pv_w|20],"")'
diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py
index 52d2b591..e1d56911 100644
--- a/resources/tester/test_m02_template_layers.py
+++ b/resources/tester/test_m02_template_layers.py
@@ -295,3 +295,44 @@ def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dic
stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"]
assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}}
assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"])
+
+
+def test_옛_표_틀은_새_판으로_손_값은_지킴(world: dict[str, Any]) -> None:
+ from M02_MasterTemplete import M02_Template_Migrate as migrate
+
+ new = {
+ "판": 2,
+ "열": [{"id": "a", "식": "1", "펼침틀": {"묶음": "g"}}],
+ "줄": [],
+ "변수": {"k": 1},
+ }
+ old = {
+ "판": 1,
+ "열": [{"id": "a", "식": "1", "바인딩": {"종류": "pipe", "값": "식"}}, {"id": "memo"}],
+ "줄": [{"id": "s10.00", "값": {"sta": "NO.0+10", "memo": "손"}}],
+ "변수": {"k": 5},
+ }
+ layers.write_template(layers.system_dir(), "table", "구조물집계표", new)
+ root2, root1 = _root(world, P2, user=43), _root(world, P1)
+ for folder in (root1 / "templates", root1 / "templates/_initial", root2 / "templates"):
+ layers.write_template(folder, "table", "구조물집계표", old)
+ layers.write_template(
+ root2 / "templates", "table", "구조물집계표", {**old, "줄": [], "변수": {"k": 1}}
+ )
+
+ assert migrate.refresh_tables(root1, dry_run=True) == [
+ "templates/table/구조물집계표",
+ "_initial/table/구조물집계표",
+ ]
+ migrate.refresh_tables(root1)
+ work = layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"]
+ assert work["열"] == new["열"] and work["변수"] == {"k": 5} # 틀만 새 판 · 변수 · 손 값 지킴
+ assert work["줄"] == old["줄"]
+ system_version = layers.version_of(layers.system_dir() / "table/구조물집계표.json")
+ initial = root1 / "templates/_initial"
+ assert layers.version_of(initial / "table/구조물집계표.json") != system_version # 변수 다름
+ assert layers.read_manifest(root1 / "templates")["table/구조물집계표"]["판"] == system_version
+ assert migrate.refresh_tables(root1) == [] # 다시 돌려도 그대로
+ # 손댄 것 없는 작업본은 시스템 파일 그대로(판이 같음)
+ migrate.refresh_tables(root2)
+ assert layers.version_of(root2 / "templates/table/구조물집계표.json") == system_version
diff --git a/resources/tester/test_sheet_pages.py b/resources/tester/test_sheet_pages.py
new file mode 100644
index 00000000..afeedecb
--- /dev/null
+++ b/resources/tester/test_sheet_pages.py
@@ -0,0 +1,81 @@
+"""표 쪽 나눔 — `ui_template_sheet_ops.ts` `pagePlan` 을 Node 로 바로 돌려 봄.
+
+격자(`_render`)가 쪽을 이 계획대로 그림.
+
+줄 120 문서 → project 쪽 3(50 · 50 · 20) · 쪽마다 머리(격자가 쪽마다 thead) · 합계는 마지막 쪽만 ·
+「전구간」 줄은 맨 위 · master 는 한 쪽.
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
+
+SCRIPT = """
+const { pagePlan } = await import(process.argv[1]);
+const doc = JSON.parse(process.argv[2]);
+const out = {};
+for (const mode of ["project", "master"]) {
+ out[mode] = pagePlan(doc, mode).map((p) => ({
+ ids: p.rows.map((r) => r.id), first: p.first, totals: p.totals,
+ }));
+}
+console.log(JSON.stringify(out));
+"""
+
+
+def _plan(doc: dict) -> dict:
+ result = subprocess.run(
+ [
+ "node",
+ "--experimental-strip-types",
+ "--no-warnings",
+ "--input-type=module",
+ "-e",
+ SCRIPT,
+ OPS.as_uri(),
+ json.dumps(doc, ensure_ascii=False),
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=60,
+ )
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def _doc(n: int, **head) -> dict:
+ rows = [{"id": f"p{i}", "값": {}} for i in range(1, n)]
+ rows.insert(7, {"id": "all", "값": {}, "고정": "전구간"}) # 문서 가운데 있어도 맨 위로
+ return {"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": rows, **head}
+
+
+def test_120_rows_make_three_pages_totals_last():
+ plan = _plan(_doc(120))["project"]
+ assert [len(p["ids"]) for p in plan] == [50, 50, 20]
+ assert [p["first"] for p in plan] == [1, 51, 101]
+ assert [p["totals"] for p in plan] == [False, False, True]
+ assert plan[0]["ids"][0] == "all"
+ assert sum((p["ids"] for p in plan), []).count("all") == 1
+
+
+def test_page_rows_from_doc_and_master_single_page():
+ got = _plan(_doc(55, 쪽줄=20))
+ assert [len(p["ids"]) for p in got["project"]] == [20, 20, 15]
+ assert [len(p["ids"]) for p in got["master"]] == [55]
+ assert got["master"][0]["totals"] is True
+
+
+def test_empty_doc_still_one_page_with_totals():
+ plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"]
+ assert plan == [{"ids": [], "first": 1, "totals": True}]
diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts
index 9651c3e8..34e4a674 100644
--- a/ui_template/cad_host/cad_host.ts
+++ b/ui_template/cad_host/cad_host.ts
@@ -56,6 +56,8 @@ export interface CadLoadExtra {
recoveryScope?: string;
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
readOnly?: boolean;
+ /** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
+ hostTitle?: string;
}
export interface CadHost {
diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css
index 8255b423..35ee2794 100644
--- a/ui_template/sheet/ui_template_sheet.css
+++ b/ui_template/sheet/ui_template_sheet.css
@@ -144,6 +144,14 @@
font-weight: 600;
}
+.ui-sheet__clamp {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+}
+
.ui-sheet__chip {
display: inline-block;
margin-right: 4px;
diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts
index 6545381b..4b013afb 100644
--- a/ui_template/sheet/ui_template_sheet.ts
+++ b/ui_template/sheet/ui_template_sheet.ts
@@ -23,7 +23,6 @@ import {
} from "./ui_template_sheet_ops";
import { recalcSheet } from "./ui_template_sheet_recalc";
import {
- columnWidth,
KEY_UNIT,
keyEditable,
navRows,
@@ -280,7 +279,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
event.preventDefault();
handle.setPointerCapture(event.pointerId);
const total = parseFloat(scroll.querySelector("table")?.style.width ?? "0");
- drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total };
+ const now = scroll.querySelector(`col[data-col="${CSS.escape(col.id)}"]`);
+ drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total };
};
const onPointerMove = (event: PointerEvent): void => {
if (!drag) return;
diff --git a/ui_template/sheet/ui_template_sheet_labels.ts b/ui_template/sheet/ui_template_sheet_labels.ts
new file mode 100644
index 00000000..e86c37dc
--- /dev/null
+++ b/ui_template/sheet/ui_template_sheet_labels.ts
@@ -0,0 +1,103 @@
+/* =============================================================================
+ * ui_template_sheet_labels.ts
+ * 사람이 읽는 글 — 식의 열 id 를 열 이름(머리 글)으로 · 머리 글이 안 잘리는 최소 열 폭.
+ *
+ * 열 이름 = 맨 위 글(종류) + 맨 아래 글 · 다른 열과 겹치면 가운데 층을 더 붙임(자리 글 「(…마다)」 는 뺌).
+ * 보이기만 바꿈 — 문서의 식은 그대로 id 식(고칠 때도 id 식).
+ * ========================================================================== */
+
+import { layoutHead } from "./ui_template_sheet_header";
+import type { SheetColumn } from "./ui_template_sheet_types";
+
+const PLACEHOLDER = /[((][^))]*마다[))]/;
+const NAME_PAD = 16;
+const MAX_MIN_WIDTH = 220;
+
+export interface ColumnName {
+ /** 맨 위 글(종류) + 아래 글 — 표 전체에서 안 겹침. */
+ full: string;
+ /** 같은 종류 안에서 부를 이름 — 겹치면 `full`. */
+ short: string;
+ top: string;
+}
+
+/** 열 id → 열 이름. */
+export function columnNames(cols: SheetColumn[]): Map {
+ const paths = cols.map((c) => {
+ const path = c.머리.filter((label): label is string => !!label && !PLACEHOLDER.test(label));
+ return path.length ? path : [c.id];
+ });
+ /** 맨 위 + 아래서 `take` 층 — 모자라면 통째. */
+ const nameOf = (path: string[], take: number): string =>
+ (take + 1 >= path.length ? path : [path[0], ...path.slice(-take)]).join(" ");
+ const names = new Map();
+ cols.forEach((col, j) => {
+ const path = paths[j];
+ let full = path.join(" ");
+ for (let take = 1; take + 1 < path.length; take += 1) {
+ const name = nameOf(path, take);
+ if (!paths.some((other, k) => k !== j && nameOf(other, take) === name)) {
+ full = name;
+ break;
+ }
+ }
+ const leaf = path.length > 1 ? path.slice(1).join(" ") : path[0];
+ const clash = paths.some(
+ (other, k) =>
+ k !== j &&
+ other[0] === path[0] &&
+ (other.length > 1 ? other.slice(1).join(" ") : other[0]) === leaf,
+ );
+ names.set(col.id, { full, short: clash ? full : leaf, top: path[0] });
+ });
+ return names;
+}
+
+/** 식 → 읽는 글 — `[열id]` → 열 이름 · `[열id@줄]` → 이름@줄 · `[$변수]` → 변수 이름 · `*` `/` → × ÷.
+ * `host` 를 주면 같은 종류 열은 짧은 이름. */
+export function formulaLabel(
+ formula: string,
+ names: Map,
+ host?: string,
+): string {
+ const top = host ? names.get(host)?.top : undefined;
+ return formula
+ .replace(/\[([^\]]+)\]/g, (_, ref: string) => {
+ const text = ref.trim();
+ if (text.startsWith("$")) return text.slice(1).replace(/_/g, " ");
+ const [col, row] = text.split("@");
+ const hit = names.get(col.trim());
+ const name = !hit ? col.trim() : top !== undefined && hit.top === top ? hit.short : hit.full;
+ return row ? `${name}@${row.trim()}` : name;
+ })
+ .replace(/\s*\*\s*/g, " × ")
+ .replace(/\s*\/\s*/g, " ÷ ");
+}
+
+let ruler: CanvasRenderingContext2D | null = null;
+
+function textWidth(text: string, font: string): number {
+ ruler ??= document.createElement("canvas").getContext("2d");
+ if (!ruler) return text.length * 13;
+ ruler.font = font;
+ return ruler.measureText(text).width;
+}
+
+/** 열마다 머리 글 · 단위가 안 잘리는 최소 폭(px) — 여러 열을 덮는 칸은 모자란 만큼 나눠 얹음. */
+export function headMinWidths(
+ cols: SheetColumn[],
+ depth: number,
+ font: string,
+): Map {
+ const mins = cols.map((c) => textWidth(c.단위 ?? "", font) + NAME_PAD);
+ for (const level of layoutHead(cols, depth).reverse()) {
+ for (const cell of level) {
+ const need = textWidth(cell.label, font) + NAME_PAD;
+ const have = mins.slice(cell.col, cell.col + cell.colspan).reduce((a, b) => a + b, 0);
+ if (need <= have) continue;
+ const extra = (need - have) / cell.colspan;
+ for (let k = cell.col; k < cell.col + cell.colspan; k += 1) mins[k] += extra;
+ }
+ }
+ return new Map(cols.map((c, j) => [c.id, Math.min(MAX_MIN_WIDTH, Math.ceil(mins[j]))]));
+}
diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts
index e9f1d59e..a4892174 100644
--- a/ui_template/sheet/ui_template_sheet_ops.ts
+++ b/ui_template/sheet/ui_template_sheet_ops.ts
@@ -2,6 +2,7 @@
* ui_template_sheet_ops.ts
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
+ * ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
* ========================================================================== */
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
@@ -9,6 +10,7 @@ import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_s
export type SheetMode = "master" | "project";
export const ROW_FIXED_ALL = "전구간";
+const PAGE_ROWS = 50;
/** 겹치지 않는 새 id — `c1` · `c2` … */
function freshId(prefix: string, used: Iterable): string {
@@ -24,6 +26,26 @@ export function orderedRows(doc: SheetDoc): SheetRow[] {
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
}
+export interface SheetPage {
+ rows: SheetRow[];
+ /** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */
+ first: number;
+ /** 합계 줄을 붙이나 — 마지막 쪽만. */
+ totals: boolean;
+}
+
+/** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */
+export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] {
+ const rows = orderedRows(doc);
+ const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length);
+ const count = Math.max(1, Math.ceil(rows.length / size));
+ return Array.from({ length: count }, (_, p) => ({
+ rows: rows.slice(p * size, (p + 1) * size),
+ first: p * size + 1,
+ totals: p === count - 1,
+ }));
+}
+
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts
index 25f9ddb0..2d9ab72c 100644
--- a/ui_template/sheet/ui_template_sheet_render.ts
+++ b/ui_template/sheet/ui_template_sheet_render.ts
@@ -9,11 +9,13 @@
import { el } from "@ui/ui_template_elements";
import { headDepth, layoutHead } from "./ui_template_sheet_header";
+import { columnNames, formulaLabel, headMinWidths } from "./ui_template_sheet_labels";
import {
cellEditable,
isBoundColumn,
isCalcColumn,
orderedRows,
+ pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
@@ -24,7 +26,6 @@ export const KEY_UNIT = "s:unit";
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
const GUTTER = 64;
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
-const PAGE_ROWS = 50;
export interface RenderState {
doc: SheetDoc;
@@ -33,8 +34,18 @@ export interface RenderState {
sel: { r: string; c: string } | null;
}
-export const columnWidth = (doc: SheetDoc, col: SheetColumn): number =>
- doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"];
+/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
+const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number =>
+ Math.max(
+ doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"],
+ mins.get(col.id) ?? 0,
+ );
+
+/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
+function headFont(): string {
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
+ return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
+}
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
@@ -57,9 +68,13 @@ export function keyEditable(state: RenderState, key: string, col: SheetColumn):
const shown = (text: string | undefined): string =>
!text || text === "0" ? "" : groupDigits(text);
+/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */
function kindOf(col: SheetColumn): { cls: string; label: string } | null {
- if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") };
if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") };
+ if (isBoundColumn(col)) {
+ const label = st("Kind_Bound");
+ return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label };
+ }
if (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
return null;
}
@@ -69,6 +84,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const cols = doc.열;
const depth = headDepth(cols, doc.층);
const head = layoutHead(cols, depth);
+ const names = columnNames(cols);
+ const mins = headMinWidths(cols, depth, headFont());
const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭]));
for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭);
const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length);
@@ -76,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement {
let left = GUTTER;
for (const col of cols) {
lefts.push(left);
- left += columnWidth(doc, col);
+ left += columnWidth(doc, col, mins);
}
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
@@ -133,19 +150,34 @@ export function renderSheet(state: RenderState): HTMLElement {
cols.forEach((col, j) => {
const text =
key === "s:formula"
- ? (col.식 ?? "")
+ ? formulaLabel(col.식 ?? "", names, col.id)
: key === "s:desc"
- ? (col.설명 ?? "")
+ ? ""
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
+ if (key === "s:formula" && col.식) {
+ // 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
+ td.textContent = "";
+ td.append(el("div", { className: "ui-sheet__clamp", text }));
+ td.title = `${formulaLabel(col.식, names)}\n${col.식}`;
+ }
const kind = kindOf(col);
- if (key === "s:desc" && kind) {
- const chip = el("span", {
- className: `ui-sheet__chip ${kind.cls}`,
- text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label,
- });
- td.prepend(chip);
+ if (key === "s:desc") {
+ // 두세 줄로 자름 — 전문은 마우스를 올리면(title)
+ const desc = col.설명 ?? "";
+ td.title = desc;
+ td.append(
+ el("div", {
+ className: "ui-sheet__clamp",
+ children: [
+ ...(kind
+ ? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })]
+ : []),
+ desc,
+ ],
+ }),
+ );
}
tr.append(td);
});
@@ -159,6 +191,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const th = el("th", {
text: cell.label,
attrs: {
+ title: cell.label,
colspan: String(cell.colspan),
rowspan: String(cell.rowspan),
"data-level": String(cell.level),
@@ -189,7 +222,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] });
cols.forEach((col, j) => {
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
- if (totalFormula(total, col)) td.title = totalFormula(total, col)!;
+ const formula = totalFormula(total, col);
+ if (formula) td.title = formulaLabel(formula, names);
tr.append(td);
});
return tr;
@@ -199,22 +233,22 @@ export function renderSheet(state: RenderState): HTMLElement {
children: [
el("col", { attrs: { style: `width:${GUTTER}px` } }),
...cols.map((col) =>
- el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }),
+ el("col", {
+ attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id },
+ }),
),
],
});
- const data = orderedRows(doc);
- const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length);
- const pageCount = Math.max(1, Math.ceil(data.length / size));
+ const plan = pagePlan(doc, mode);
+ const pageCount = plan.length;
const pages: HTMLElement[] = [];
- for (let p = 0; p < pageCount; p += 1) {
- const slice = data.slice(p * size, (p + 1) * size);
+ for (const [p, page] of plan.entries()) {
const body = el("tbody", {
children: [
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
- ...slice.map((row, i) => dataRow(row, p * size + i + 1)),
- ...(p === pageCount - 1 ? totalRows() : []),
+ ...page.rows.map((row, i) => dataRow(row, page.first + i)),
+ ...(page.totals ? totalRows() : []),
],
});
const table = el("table", {