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/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", {