From afb971042380d11a9f573c76876a20cfce65438d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 18:56:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(741-1):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=91=EC=85=80=20=EB=B3=B4=EA=B8=B0=20=EC=97=B4?= =?UTF-8?q?=20=ED=8F=AD=20=E2=80=94=20xlsx=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=B4=20=ED=8F=AD=20=C2=B7=20=ED=8F=AD=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EC=97=B4=EC=9D=80=20=EA=B8=80=20=EA=B8=B8=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=A8=B8=EB=A6=AC=20=EA=B2=BD=EA=B3=84=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=91=90=20=EB=B2=88=20=EB=88=8C=EB=9F=AC?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: xlsx 시트 XML 열 폭 · 숨김을 보냄(엑셀 보기 격자 `열`) - 화면: 폭 없는 열은 글 길이로 처음 폭(최소 6 · 최대 60 · 여러 열 병합 제목 칸은 뺌) - 읽기 전용 격자도 열 폭 끌기 · 경계 두 번 누름 맞춤(보기만 · 저장 · 되돌리기 · onChange 없음) - 열 경계 오른쪽 반에서도 경계로 읽음 · 단가 카드 미리보기는 사본으로 보임 - 시험: test_741_sheet_widths · test_stage2_autofit(esbuild 묶어 node) Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/spreadsheet/spreadsheet.ts | 6 +-- A00_Common/spreadsheet/spreadsheet_autofit.ts | 45 ++++++++++++----- A00_Common/spreadsheet/spreadsheet_grid.ts | 6 ++- A00_Common/spreadsheet/spreadsheet_mouse.ts | 4 +- M02_MasterTemplete/M02_Hopyo_Grid.py | 50 +++++++++++++++++-- .../M02_MasterTemplete_Hopyo_Sheet.ts | 2 + .../M02_MasterTemplete_ItemCards.ts | 5 +- .../tester/spreadsheet/test_stage2_autofit.ts | 21 +++++++- resources/tester/test_741_sheet_widths.py | 49 ++++++++++++++++++ 9 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_741_sheet_widths.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index ef629b80f..4ae39fd85 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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)); } /** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */ diff --git a/A00_Common/spreadsheet/spreadsheet_autofit.ts b/A00_Common/spreadsheet/spreadsheet_autofit.ts index aea51e494..fd65722b5 100644 --- a/A00_Common/spreadsheet/spreadsheet_autofit.ts +++ b/A00_Common/spreadsheet/spreadsheet_autofit.ts @@ -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 { + const wide = new Set(); + 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(); + 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)); } /** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */ diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 416f5dd62..08aa55635 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -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()) { diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 9bff078dd..86e77b8bf 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -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") { // 고른 열 전체의 경계면 고른 열 모두(엑셀) diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index b41f477f2..4c63b1d4a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -6,16 +6,19 @@ A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). -xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. +xlsx = 값 · 열 폭(``) · 넓은 시트는 머리 글(앞 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 `` 를 읽음(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} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index f8f5b65c5..e585953e5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -336,8 +336,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { async function mountSheet(grid: HopyoGrid): Promise { 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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 8c41aceb2..d84d38233 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -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); } diff --git a/resources/tester/spreadsheet/test_stage2_autofit.ts b/resources/tester/spreadsheet/test_stage2_autofit.ts index ec62db198..4482db19f 100644 --- a/resources/tester/spreadsheet/test_stage2_autofit.ts +++ b/resources/tester/spreadsheet/test_stage2_autofit.ts @@ -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 시험 끝"); diff --git a/resources/tester/test_741_sheet_widths.py b/resources/tester/test_741_sheet_widths.py new file mode 100644 index 000000000..6c25f8d35 --- /dev/null +++ b/resources/tester/test_741_sheet_widths.py @@ -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