fix(sheet): 마스터 딱지는 식 먼저 · 식 줄은 열 이름 · 머리 글 안 잘리는 최소 폭 · 쪽 나눔 시험 (M02 브레인 확인)
- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력 - 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름 - 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선 - 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
This commit is contained in:
@@ -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}]
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLElement>("table")?.style.width ?? "0");
|
||||
drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total };
|
||||
const now = scroll.querySelector<HTMLElement>(`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;
|
||||
|
||||
@@ -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<string, ColumnName> {
|
||||
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<string, ColumnName>();
|
||||
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<string, ColumnName>,
|
||||
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<string, number> {
|
||||
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]))]));
|
||||
}
|
||||
@@ -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>): 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.바인딩;
|
||||
|
||||
|
||||
@@ -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<string, number>): 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", {
|
||||
|
||||
Reference in New Issue
Block a user