feat(spreadsheet): 칸 맞춤 기본 = 가로 가운데 · 세로 가운데 (237-1)

안 준 칸(가로 없음 · 일반 · 세로 없음)의 칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML 기본을 가운데로 · 준 칸은 그대로 · 새 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-10 20:03:39 +09:00
co-authored by Claude Sonnet 5.5
parent be1c707da0
commit b099add036
10 changed files with 176 additions and 22 deletions
@@ -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) =>
@@ -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<HAlign, string> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
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<VAlign, string> = {
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" : "";
}
@@ -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) => {
+1 -1
View File
@@ -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";
@@ -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;
@@ -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<string, CellRange> {
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
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";
@@ -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];
@@ -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<typeof applyCellStyle>[3]) {
const text = { classList: { contains: () => true }, style: {} as Record<string, string> };
const cell = {
style: {} as Record<string, string>,
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(/<td[^>]*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 }));
+21 -3
View File
@@ -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
@@ -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