feat(741-1): 불러오기 엑셀 보기 열 폭 — xlsx 파일 열 폭 · 폭 없는 열은 글 길이로 · 머리 경계 끌기 · 두 번 눌러 맞춤

- 서버: xlsx 시트 XML 열 폭 · 숨김을 보냄(엑셀 보기 격자 `열`)
- 화면: 폭 없는 열은 글 길이로 처음 폭(최소 6 · 최대 60 · 여러 열 병합 제목 칸은 뺌)
- 읽기 전용 격자도 열 폭 끌기 · 경계 두 번 누름 맞춤(보기만 · 저장 · 되돌리기 · onChange 없음)
- 열 경계 오른쪽 반에서도 경계로 읽음 · 단가 카드 미리보기는 사본으로 보임
- 시험: test_741_sheet_widths · test_stage2_autofit(esbuild 묶어 node)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 18:56:36 +09:00
co-authored by Claude Sonnet 5.5
parent aaea39e23f
commit afb9710423
9 changed files with 164 additions and 24 deletions
+3 -3
View File
@@ -225,10 +225,10 @@ export function createSpreadsheet(
}
function dispatch(command: Command): void {
if (readOnly) return;
if (readOnly && command.종류 !== "열폭") return; // 읽기 전용도 열 폭은 바꿈(보기만 · 저장 · 되돌리기 없음)
const eff = apply(command);
if (!eff) return;
ctx.history.push(command, eff.undo);
if (!readOnly) ctx.history.push(command, eff.undo);
after(command, eff);
}
@@ -252,7 +252,7 @@ export function createSpreadsheet(
else grid.render();
}
select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2)
opts.onChange?.(structuredClone(ctx.book));
if (!readOnly) opts.onChange?.(structuredClone(ctx.book));
}
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
+33 -12
View File
@@ -13,7 +13,8 @@
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
* ========================================================================== */
import type { Workbook } from "./spreadsheet_types";
import { colIndex, colName, parseRange } from "./spreadsheet_address";
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type TextMeasurer = (text: string) => number;
@@ -30,13 +31,27 @@ const defaultMeasure: TextMeasurer = (text) => {
const colFromKey = (key: string): number => {
const m = /^([A-Z]+)\d+$/.exec(key);
if (!m) return -1;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return col - 1;
return m ? colIndex(m[1]) : -1;
};
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
/** 열마다 가장 넓은 칸 글자 폭(식 칸은 식 글로 어림 — 계산값은 여기서 모름) · 여러 열 병합 칸은 뺌(제목 줄). */
function widestByColumn(sheet: Sheet, measure: TextMeasurer): Map<number, number> {
const wide = new Set<string>();
for (const text of sheet.병합 ?? []) {
const r = parseRange(text);
if (r && r.c1 > r.c0) wide.add(`${colName(r.c0)}${r.r0 + 1}`);
}
const out = new Map<number, number>();
for (const [key, cell] of Object.entries(sheet.칸)) {
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
const col = colFromKey(key);
if (!text || col < 0 || wide.has(key)) continue;
out.set(col, Math.max(out.get(col) ?? 0, measure(text)));
}
return out;
}
/** 그 열에 든 칸 글자 중 가장 넓은 것 + 여백. */
export function estimateColumnWidth(
book: Workbook,
sheetId: string,
@@ -45,13 +60,19 @@ export function estimateColumnWidth(
): number {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return MIN_WIDTH;
let widest = 0;
for (const [key, cell] of Object.entries(sheet.칸)) {
if (colFromKey(key) !== col) continue;
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
if (text) widest = Math.max(widest, measure(text));
return Math.max(MIN_WIDTH, Math.ceil((widestByColumn(sheet, measure).get(col) ?? 0) + PADDING));
}
/** 폭 정보 없는 열(엑셀 폭을 못 읽은 파일)에 글 길이로 처음 폭을 채움 — 최소 · 최대 안(읽기 전용 보기). */
export function fillMissingWidths(book: Workbook, min = 6, max = 60): void {
for (const sheet of book.시트) {
for (const [col, widest] of widestByColumn(sheet, defaultMeasure)) {
const letter = colName(col);
if (sheet.열?.[letter]?.폭 !== undefined) continue;
const 폭 = Math.min(max, Math.max(min, Math.ceil(widest + PADDING)));
sheet.열 = { ...sheet.열, [letter]: { ...sheet.열?.[letter], 폭 } };
}
}
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
}
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
+4 -2
View File
@@ -572,8 +572,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const contentY =
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
if (y < headRowH()) {
const c = colGeo.indexAt(contentX);
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
const at = colGeo.indexAt(contentX);
const after = at > 0 && contentX - colGeo.offset(at) <= RESIZE_HOTZONE; // 경계 오른쪽 반 = 앞 열 경계
const c = after ? at - 1 : at;
const edge = after || Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
}
if (x < headColW()) {
+2 -2
View File
@@ -233,9 +233,9 @@ export function attachMouse(
}
function onDbl(e: MouseEvent): void {
if (e.target === ed.ta || ctx.readOnly) return;
if (e.target === ed.ta) return;
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (!hit || (ctx.readOnly && hit.kind !== "colEdge")) return; // 읽기 전용 = 열 경계 맞춤만
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
+47 -3
View File
@@ -6,16 +6,19 @@ A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
xlsx = 값 · 열 폭(`<cols>`) · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 ·
STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
"""
from __future__ import annotations
import io
import zipfile
from typing import Any
from openpyxl.utils import get_column_letter
from openpyxl.xml.functions import fromstring
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
@@ -178,9 +181,42 @@ def _xls(data: bytes) -> dict[str, Any]:
}
def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
"""시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML `<cols>` 를 읽음(read_only 는 폭을 안 줌)."""
out: dict[str, dict[str, dict[str, Any]]] = {}
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
tag = lambda node: node.tag.rsplit("}", 1)[-1] # noqa: E731
rels = {
r.get("Id"): r.get("Target", "")
for r in fromstring(zf.read("xl/_rels/workbook.xml.rels"))
}
for sheet in fromstring(zf.read("xl/workbook.xml")).iter():
if tag(sheet) != "sheet":
continue
rid = next((v for k, v in sheet.attrib.items() if k.endswith("}id")), None)
path = (rels.get(rid) or "").lstrip("/")
path = path if path.startswith("xl/") else f"xl/{path}"
cols: dict[str, dict[str, Any]] = {}
for col in fromstring(zf.read(path)).iter():
if tag(col) != "col":
continue
first, last = int(col.get("min", 0)), min(int(col.get("max", 0)), 200)
one = {"폭": round(float(col.get("width", 0)), 2)}
if col.get("hidden") in ("1", "true"):
one["숨김"] = True
for c in range(first, last + 1):
cols[get_column_letter(c)] = one
out[sheet.get("name", "").strip()] = cols
except (KeyError, ValueError, zipfile.BadZipFile):
pass # 폭을 못 읽으면 폭 없음 — 화면이 글 길이로 맞춤
return out
def _xlsx(name: str, data: bytes) -> dict[str, Any]:
# ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
# ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
found = hopyo_excel._sheets(name, data)
widths = _xlsx_widths(data)
sheets = []
for at, title in enumerate((n for n in SHEETS if n in found), 1):
rows = found[title]
@@ -196,7 +232,15 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]:
for c, v in enumerate(row[:width])
if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글
}
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
sheets.append(
{
"id": f"s{at}",
"이름": title,
"칸": cells,
"열": widths.get(title, {}),
"틀고정": {"행": HEAD_ROWS},
}
)
return {"서식": [{}], "시트": sheets}
@@ -336,8 +336,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
async function mountSheet(grid: HopyoGrid): Promise<void> {
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
const { fillMissingWidths } = await import("../A00_Common/spreadsheet/spreadsheet_autofit");
if (closed) return;
const book = structuredClone(grid.책);
fillMissingWidths(book); // 엑셀 폭이 없는 열 = 글 길이로 처음 폭
const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0];
if (!first) return void left.replaceChildren(note("시트 없음"));
ilwi = first.id;
@@ -449,7 +449,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const { createSpreadsheet } = await loadSpreadsheet();
if (mine !== seq || !c.doc) return;
if (opts.sheetEdit?.can()) return await paintEditable(c, mine, createSpreadsheet);
c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false });
c.handle = createSpreadsheet(c.host, structuredClone(c.doc), {
readOnly: true,
tabs: false,
}); // 열 폭을 끌어 바꿔도 원본 안 건드림
} catch (error) {
if (mine === seq) c.shell.fail(fill(TEXT.noSheet, why(error)), reload);
}
@@ -1,10 +1,11 @@
/* =============================================================================
* test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: node --experimental-strip-types <이 파일>
* test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: esbuild --bundle --platform=node <이 파일> → node (주소 import 가 확장자 없음)
* ========================================================================== */
import {
autofitColumn,
estimateColumnWidth,
fillMissingWidths,
} from "../../../A00_Common/spreadsheet/spreadsheet_autofit.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
@@ -28,5 +29,23 @@ assertEqual(sheet.열?.A.폭, widthA, "autofitColumn 이 열폭 명령으로 반
const fixedWidth = estimateColumnWidth(book, "s1", 0, () => 7);
assertEqual(fixedWidth, 9, "measure 를 넘기면 그걸로 어림(7 + 여백 2)");
// 741 — 폭 없는 열만 글 길이로 채움(최소 · 최대) · 있는 폭 · 여러 열 병합(제목) 칸은 그대로 · 뺌
const longText = "가".repeat(80);
const sheet741 = makeSheet("s2", {
A1: v("제목 한 줄이 매우 길어도 병합이라 뺌 ".repeat(5)),
A2: v("짧음"),
B1: v(longText),
C1: v("x"),
D1: v("이미 있음"),
});
sheet741.병합 = ["A1:D1"];
sheet741.열 = { D: { 폭: 33 } };
const book741 = makeBook([sheet741]);
fillMissingWidths(book741);
assertEqual(sheet741.열.D.폭, 33, "있는 폭은 그대로");
assertEqual(sheet741.열.B.폭, 60, "너무 긴 글은 최대 60");
assertEqual(sheet741.열.C.폭, 6, "짧은 글은 최소 6");
assertEqual(sheet741.열.A.폭 < 20, true, "병합 제목 칸은 폭에서 뺌");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autofit 시험 끝");
+49
View File
@@ -0,0 +1,49 @@
"""741-1 지킴 — 일위대가 엑셀 보기 열 폭: xlsx 도 파일 열 폭을 서버가 보냄 · 열 끌기 · 두 번 눌러
맞춤은 읽기 전용에서도 열림(화면 수치 판정은 tmp 일회 검증)."""
import io
from pathlib import Path
import openpyxl
from M02_MasterTemplete import M02_Hopyo_Grid as grid
ROOT = Path(__file__).parents[2]
SS = ROOT / "A00_Common/spreadsheet"
def _xlsx(widths: dict[str, float]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "일위대가"
ws["A1"] = "x"
for letter, width in widths.items():
ws.column_dimensions[letter].width = width
ws.column_dimensions["E"].hidden = True
out = io.BytesIO()
wb.save(out)
return out.getvalue()
def test_xlsx_widths_read_from_cols():
got = grid._xlsx_widths(_xlsx({"A": 30, "C": 12.5}))["일위대가"]
assert got["A"] == {"폭": 30.0} and got["C"] == {"폭": 12.5}
assert got["E"].get("숨김") is True
def test_xlsx_without_widths_is_empty_and_garbage_is_safe():
assert grid._xlsx_widths(_xlsx({}))["일위대가"].keys() <= {"E"}
assert grid._xlsx_widths(b"not a zip") == {}
def test_xlsx_sheet_carries_widths():
data = _xlsx({"A": 30})
sheets = grid._xlsx("a.xlsx", data)["시트"]
assert sheets and sheets[0]["열"]["A"]["폭"] == 30.0
def test_readonly_still_resizes_columns():
spread = (SS / "spreadsheet.ts").read_text(encoding="utf-8")
assert 'readOnly && command.종류 !== "열폭"' in spread
mouse = (SS / "spreadsheet_mouse.ts").read_text(encoding="utf-8")
assert 'ctx.readOnly && hit.kind !== "colEdge"' in mouse