diff --git a/A00_Common/spreadsheet/spreadsheet_area_table.ts b/A00_Common/spreadsheet/spreadsheet_area_table.ts index 660642655..ec86a2d38 100644 --- a/A00_Common/spreadsheet/spreadsheet_area_table.ts +++ b/A00_Common/spreadsheet/spreadsheet_area_table.ts @@ -8,7 +8,7 @@ * ========================================================================== */ import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; -import { cellStyleAt } from "./spreadsheet_cellstyle"; +import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle"; import { createCalcEngine } from "./spreadsheet_graph"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; import { resolveName } from "./spreadsheet_names"; @@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) { : { 글: "", 정렬: "left" as const }; const key = `${sheet.id}!${toA1(r, c)}`; 값[key] = shown.글; - const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); + const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1) return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; }; const cells = rows.map((r) => diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 05c8fc686..31a380977 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string { } } -/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ +/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */ const HALIGN_CSS: Record = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -149,13 +149,23 @@ const HALIGN_CSS: Record = { centerContinuous: "center", }; +/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */ +export function shownHAlign(h: HAlign | undefined): HAlign { + return !h || h === "general" ? "center" : h; +} + +/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */ +export function shownVAlign(v: VAlign | undefined): VAlign { + return v ?? "center"; +} + const VALIGN_CSS: Record = { top: "flex-start", center: "center", bottom: "flex-end", }; -/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데). * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ export function applyCellStyle( cell: HTMLElement, @@ -178,7 +188,7 @@ export function applyCellStyle( s.borderLeft = borderCss(borders.왼, scale); s.borderBottom = borderCss(borders.아래, scale); s.borderRight = borderCss(borders.오른, scale); - s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; + s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)]; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; let text = cell.firstElementChild as HTMLElement | null; @@ -187,7 +197,7 @@ export function applyCellStyle( text.className = "aislo-cell__text"; cell.replaceChildren(text); } - text.style.textAlign = HALIGN_CSS[hAlign]; + text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)]; text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; } diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 1eef6e5c0..9164ce8c4 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -14,6 +14,7 @@ import { showToast } from "@ui/ui_template_elements"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { formatValue } from "./spreadsheet_numfmt"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; @@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string { if (deco) parts.push(`text-decoration:${deco}`); if (style.글자색) parts.push(`color:${style.글자색}`); if (style.채움) parts.push(`background-color:${style.채움}`); - if (style.가로 && style.가로 !== "general") { - parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); - } - if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); + // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양 + const h = shownHAlign(style.가로); + parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`); + const v = shownVAlign(style.세로); + parts.push(`vertical-align:${v === "center" ? "middle" : v}`); if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.테두리) { (["위", "아래", "왼", "오른"] as const).forEach((side) => { diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 8cf4f81a8..b7a23f3a0 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { ta.style.fontSize = s.크기 ? `${s.크기}pt` : ""; ta.style.fontWeight = s.굵게 ? "bold" : ""; ta.style.fontStyle = s.기울임 ? "italic" : ""; - ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : ""; + ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center"; // 글이 길면 오른쪽 · 아래로 늘림(엑셀) ta.style.width = "0"; ta.style.height = "0"; diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index 51a2b5769..66d199ee7 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -23,7 +23,7 @@ import { type NegStyle, } from "./spreadsheet_format_code"; import { dialogKeys } from "./spreadsheet_actions"; -import { isAutoInk } from "./spreadsheet_cellstyle"; +import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { formatValue } from "./spreadsheet_numfmt"; import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; @@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["centerContinuous", "선택 영역 가운데"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - hSelect.value = draft.가로 ?? "general"; + hSelect.value = shownHAlign(draft.가로); hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); const vSelect = el("select", { @@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["bottom", "아래"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - vSelect.value = draft.세로 ?? "bottom"; + vSelect.value = shownVAlign(draft.세로); vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts index a98943c5c..21160b525 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -13,6 +13,7 @@ import { isAutoInk, resolveBoxBorders, setCellText, + shownHAlign, type ResolvedBorders, } from "./spreadsheet_cellstyle"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; @@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map { } const SPILL_ALIGN: Partial> = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -218,7 +219,7 @@ function cellDisplay( const scalar = scalarAt(ctx, sheet, r, c); const formatted = formatValue(scalar, code); const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; - const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; + const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1) const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; const isNumeric = !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5c450eade..d213b4a61 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -13,6 +13,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { confirmMerge } from "./spreadsheet_actions"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { cellToText, textToCell } from "./spreadsheet_editor"; import { st } from "./spreadsheet_text"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; @@ -528,8 +529,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { strikeBtn.classList.toggle("is-on", !!style.취소선); textColor.value = style.글자색 ?? "#000000"; fillColor.value = style.채움 ?? "#ffffff"; - hAlignSelect.value = style.가로 ?? "general"; - vAlignSelect.value = style.세로 ?? "bottom"; + hAlignSelect.value = shownHAlign(style.가로); + vAlignSelect.value = shownVAlign(style.세로); wrapBtn.classList.toggle("is-on", !!style.줄바꿈); numfmtInput.value = style.형식 ?? ""; const range = ctx.selection.범위[0]; diff --git a/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts new file mode 100644 index 000000000..54417bd46 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts @@ -0,0 +1,65 @@ +/* 맞춤 기본 = 가운데 · 가운데(237-1) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_align_default.py` 가 읽음) */ +import { + applyCellStyle, + shownHAlign, + shownVAlign, + type ResolvedBorders, +} from "../../../A00_Common/spreadsheet/spreadsheet_cellstyle"; +import { blockToClipboard } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard"; +import type { CellStyle, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +// 도구줄 · 대화상자가 보이는 값 — 안 준 칸(없음 · 일반) = 가운데 · 준 칸 그대로 +eq("가로 없음", shownHAlign(undefined), "center"); +eq("가로 일반", shownHAlign("general"), "center"); +eq("가로 왼쪽", shownHAlign("left"), "left"); +eq("가로 오른쪽", shownHAlign("right"), "right"); +eq("가로 균등", shownHAlign("distributed"), "distributed"); +eq("세로 없음", shownVAlign(undefined), "center"); +eq("세로 위", shownVAlign("top"), "top"); +eq("세로 아래", shownVAlign("bottom"), "bottom"); + +// 칸 CSS — 가짜 칸(DOM 없이 style 만 봄) +function paint(style: CellStyle, hAlign: Parameters[3]) { + const text = { classList: { contains: () => true }, style: {} as Record }; + const cell = { + style: {} as Record, + firstElementChild: text, + replaceChildren: () => undefined, + }; + applyCellStyle(cell as unknown as HTMLElement, style, {} as ResolvedBorders, hAlign); + return { v: cell.style.justifyContent, h: text.style.textAlign }; +} +eq("칸 안 준 = 가운데 · 가운데", paint({}, "general"), { v: "center", h: "center" }); +eq("칸 왼쪽 · 아래 그대로", paint({ 세로: "bottom" }, "left"), { v: "flex-end", h: "left" }); +eq("칸 오른쪽 · 위 그대로", paint({ 세로: "top" }, "right"), { v: "flex-start", h: "right" }); + +// 내보내기(복사 HTML) — 안 준 칸 = 가운데 · 가운데 · 준 칸 그대로 +const book = { + 종류: "통합문서", + 판: 1, + 열: "rv", + 서식: [{}, { 가로: "left", 세로: "top" }], + 시트: [{ id: "s1", 이름: "시트1", 칸: { A1: { 값: "가" }, B1: { 값: "나", 서식: 1 } } }], + 활성: "s1", +} as unknown as Workbook; +const { html } = blockToClipboard(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 1 }, (_r, c) => + c === 0 ? "가" : "나", +); +const tds = [...html.matchAll(/]*style="([^"]*)"/g)].map((m) => m[1]); +eq("내보내기 칸 수", tds.length, 2); +eq( + "내보내기 안 준 칸", + /text-align:center/.test(tds[0]) && /vertical-align:middle/.test(tds[0]), + true, +); +eq("내보내기 준 칸", /text-align:left/.test(tds[1]) && /vertical-align:top/.test(tds[1]), true); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 8a699f868..5826398ac 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -33,6 +33,7 @@ SHEET = { "A2": {"값": "합"}, "B2": {"식": "B1*2", "서식": 1}, "A3": {"값": "숨김행"}, + "F1": {"값": "왼쪽", "서식": 3}, "C1": {"값": "x"}, }, "병합": ["A1:A2", "C1:E5"], @@ -43,7 +44,7 @@ BOOK = { "종류": "통합문서", "판": 1, "열": "rv", - "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}, {"가로": "left"}], "시트": [SHEET], "활성": "s1", } @@ -56,7 +57,12 @@ def _one(**ask) -> dict: def test_표시_글_형식_굵게_정렬_칸_key(): got = _one(시트="면적산출", 범위="A1:B2") cells = got["표"]["shapeData"]["cells"] - assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"} + assert cells[0][1] == { + "text": "1,234.50", + "align": "center", + "bold": True, + "key": "s1!B1", + } # 안 준 수 칸 = 가운데(237-1) assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식 assert cells[0][0]["align"] == "center" # 가로 서식 assert (got["시트"], got["범위"]) == ("s1", "A1:B2") @@ -64,6 +70,14 @@ def test_표시_글_형식_굵게_정렬_칸_key(): assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""} +def test_맞춤_안_준_칸은_가운데_준_칸은_그대로_237_1(): + cells = _one(시트="s1", 범위="A1:F1")["표"]["shapeData"]["cells"][0] + by_key = {c["key"]: c["align"] for c in cells if c} + assert by_key["s1!A1"] == "center" # 가로 center 를 준 칸 + assert by_key["s1!B1"] == "center" # 안 준 수 칸 (옛: right) + assert by_key["s1!F1"] == "left" # 가로 left 를 준 칸 그대로 + + def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"] cells = shape["cells"] @@ -71,7 +85,11 @@ def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김) assert cells[1][2] is None and cells[2][2] is None - assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸 + assert cells[2][0] == { + "text": "", + "align": "center", + "key": "s1!A4", + } # 빈 칸 · 안 준 칸 = 가운데 assert shape["style"]["fontSize"] == 5 assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율 assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3 diff --git a/resources/tester/test_spreadsheet_align_default.py b/resources/tester/test_spreadsheet_align_default.py new file mode 100644 index 000000000..e32979c5e --- /dev/null +++ b/resources/tester/test_spreadsheet_align_default.py @@ -0,0 +1,57 @@ +"""맞춤 기본 = 가운데 · 가운데(237-1) — 안 준 칸 · 준 칸 · 도구줄 기본 표시 · 내보내기(복사 HTML). + +`spreadsheet_align_default_entry.ts` 를 vite `--ssr` 로 번들해 Node 로 돌림. 영역 표 맞춤은 +`test_m02_area_table.py`. 화면(도구줄 · 칸 · 편집창)은 사용자가 확인. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_align_default_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_맞춤_기본_가운데_237_1(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_align_default_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_align_default_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 14