Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -44,7 +44,7 @@ export interface ResolvedBorders {
|
||||
오른?: BorderSide;
|
||||
}
|
||||
|
||||
/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */
|
||||
/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */
|
||||
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
|
||||
const cell = sheet.칸[key];
|
||||
if (cell?.서식 !== undefined) return cell.서식;
|
||||
@@ -52,6 +52,7 @@ export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): n
|
||||
if (rowFmt !== undefined) return rowFmt;
|
||||
const colFmt = sheet.열?.[colName(c)]?.서식;
|
||||
if (colFmt !== undefined) return colFmt;
|
||||
if (sheet.기본?.서식 !== undefined) return sheet.기본.서식;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,22 +7,7 @@
|
||||
|
||||
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
|
||||
/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */
|
||||
function colLetters(c: number): string {
|
||||
let n = c + 1;
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function a1(r: number, c: number): string {
|
||||
return `${colLetters(c)}${r + 1}`;
|
||||
}
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
|
||||
function detectDirection(source: CellRange, target: CellRange): FillDirection {
|
||||
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
|
||||
@@ -64,7 +49,7 @@ export function fillCells(
|
||||
const targetEnd = vertical ? target.r1 : target.c1;
|
||||
const lineLen = sourceEnd - sourceStart + 1;
|
||||
|
||||
const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos));
|
||||
const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos));
|
||||
const sourceCells: (Cell | undefined)[] = [];
|
||||
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
|
||||
|
||||
|
||||
@@ -172,6 +172,19 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */
|
||||
.aislo-cell.has-comment::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 6px 6px 0;
|
||||
border-color: transparent #c00 transparent transparent;
|
||||
}
|
||||
|
||||
.aislo-grid__ref-highlight {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -130,6 +130,7 @@ interface CellDisplay {
|
||||
hAlign: HAlign;
|
||||
title: string;
|
||||
isError: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function cellDisplay(
|
||||
@@ -151,7 +152,7 @@ function cellDisplay(
|
||||
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
|
||||
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
|
||||
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
|
||||
return { text: formatted.글, hAlign, title, isError };
|
||||
return { text: formatted.글, hAlign, title, isError, color: formatted.색 };
|
||||
}
|
||||
|
||||
export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
@@ -214,6 +215,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
let frozenCols = 0;
|
||||
let frozenW = 0;
|
||||
let frozenH = 0;
|
||||
let lastSheetId = "";
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
@@ -313,6 +315,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.r = String(r0);
|
||||
cellEl.dataset.c = String(c0);
|
||||
const addr = toA1(r0, c0);
|
||||
cellEl.dataset.addr = addr;
|
||||
const style = cellStyleAt(book, sheet, r0, c0);
|
||||
const borders: ResolvedBorders = resolveBoxBorders(
|
||||
book,
|
||||
@@ -326,8 +330,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
);
|
||||
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign);
|
||||
setCellText(cellEl, display.text, display.title || undefined);
|
||||
if (display.color) cellEl.style.color = display.color;
|
||||
const comment = sheet.comments?.[addr];
|
||||
setCellText(cellEl, display.text, display.title || comment || undefined);
|
||||
cellEl.classList.toggle("is-error", display.isError);
|
||||
cellEl.classList.toggle("has-comment", !!comment);
|
||||
cellEl.classList.toggle(
|
||||
"is-selected",
|
||||
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
|
||||
@@ -478,6 +485,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
|
||||
function render(): void {
|
||||
const sheet = ctx.sheet();
|
||||
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
|
||||
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : "";
|
||||
colGeo = buildColGeometry(ctx.book, sheet);
|
||||
rowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
extent = computeExtent(sheet);
|
||||
@@ -486,8 +495,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
|
||||
spacer.style.width = "1px";
|
||||
spacer.style.height = "1px";
|
||||
paneBR.scrollLeft = 0;
|
||||
paneBR.scrollTop = 0;
|
||||
if (sheet.id !== lastSheetId) {
|
||||
lastSheetId = sheet.id;
|
||||
paneBR.scrollLeft = 0;
|
||||
paneBR.scrollTop = 0;
|
||||
}
|
||||
layoutPanes();
|
||||
}
|
||||
|
||||
@@ -667,8 +679,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */
|
||||
function paneLayerOf(r: number, c: number): HTMLElement {
|
||||
const inFrozenRow = r < frozenRows;
|
||||
const inFrozenCol = c < frozenCols;
|
||||
if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner;
|
||||
return inFrozenCol ? paneBLInner : paneBRInner;
|
||||
}
|
||||
|
||||
/** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */
|
||||
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
|
||||
return { layer: overlay, box: screenBoxOf(r, c) };
|
||||
return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) };
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
|
||||
@@ -100,7 +100,7 @@ class SparseGeometry implements LineGeometry {
|
||||
}
|
||||
|
||||
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS);
|
||||
const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
|
||||
const c = colIndex(key);
|
||||
@@ -112,7 +112,7 @@ export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
}
|
||||
|
||||
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT);
|
||||
const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
|
||||
const r = Number(key) - 1;
|
||||
|
||||
@@ -64,7 +64,12 @@ function extractColor(section: string): { rest: string; color?: string } {
|
||||
let color: string | undefined;
|
||||
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
|
||||
const lower = inner.toLowerCase();
|
||||
if (COLOR_NAMES.includes(lower)) color = lower;
|
||||
if (COLOR_NAMES.includes(lower)) {
|
||||
color = lower;
|
||||
return "";
|
||||
}
|
||||
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
|
||||
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
|
||||
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
|
||||
});
|
||||
return { rest, color };
|
||||
@@ -78,6 +83,7 @@ type Tok =
|
||||
| { k: "point" }
|
||||
| { k: "percent" }
|
||||
| { k: "text" }
|
||||
| { k: "general" }
|
||||
| { k: "lit"; ch: string };
|
||||
|
||||
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
|
||||
@@ -85,6 +91,12 @@ function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
|
||||
let fillChar: string | undefined;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
|
||||
if (s.slice(i, i + 7).toLowerCase() === "general") {
|
||||
toks.push({ k: "general" });
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < s.length && s[j] !== '"') {
|
||||
@@ -214,6 +226,10 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string {
|
||||
continue;
|
||||
}
|
||||
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
|
||||
if (t.k === "general") {
|
||||
out += generalFormat(magnitude);
|
||||
continue;
|
||||
}
|
||||
out += t.ch;
|
||||
}
|
||||
return out;
|
||||
@@ -238,34 +254,34 @@ function chooseSection(sections: string[], value: Frac): { code: string; forceMi
|
||||
}
|
||||
|
||||
function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
||||
const trimmed = code?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "general") {
|
||||
if (code === undefined || code === "" || code.trim().toLowerCase() === "general") {
|
||||
return { 글: generalFormat(value), 정렬: "right" };
|
||||
}
|
||||
const sections = splitSections(trimmed);
|
||||
const sections = splitSections(code);
|
||||
const { code: chosen, forceMinus } = chooseSection(sections, value);
|
||||
const { rest, color } = extractColor(chosen);
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
if (!toks.some((t) => t.k === "digit")) {
|
||||
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
||||
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
|
||||
let out = "";
|
||||
for (const t of toks) if (t.k === "lit") out += t.ch;
|
||||
return { 글: out, 정렬: "right", ...extra };
|
||||
}
|
||||
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
||||
const body = renderNumericSection(toks, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
|
||||
function formatText(value: string, code: string | undefined): FormattedText {
|
||||
const trimmed = code?.trim();
|
||||
if (!trimmed) return { 글: value, 정렬: "left" };
|
||||
const sections = splitSections(trimmed);
|
||||
// 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로.
|
||||
const textCode =
|
||||
sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined;
|
||||
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
|
||||
const sections = splitSections(code);
|
||||
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
|
||||
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
|
||||
let textCode: string | undefined;
|
||||
if (sections.length >= 4) textCode = sections[3];
|
||||
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
|
||||
if (textCode === undefined) return { 글: value, 정렬: "left" };
|
||||
const { rest, color } = extractColor(textCode);
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
|
||||
@@ -56,7 +56,10 @@ export function mountSpreadsheetTrial(
|
||||
readSpreadsheetTrial(),
|
||||
]);
|
||||
if (!alive) return;
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) });
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, {
|
||||
onChange: () => onDirty(true),
|
||||
inspect: true, // ⚠ 임시 — 브레인 검증용 문맥(window.__aisloSheet) · 산출근거 카드는 안 넘김
|
||||
});
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) }));
|
||||
|
||||
@@ -32,6 +32,10 @@ _BASIS_HEAD_STYLE = {
|
||||
}
|
||||
_NUMBER = re.compile(r"^구-(\d+)$")
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
|
||||
# of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침)
|
||||
# 뿌리도 처음부터 채움
|
||||
_BLANK_LAYER = {"id": "기본", "name": "기본", "isVisible": True, "isLocked": False}
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
@@ -158,7 +162,7 @@ def create_structure(column: str) -> dict[str, Any]:
|
||||
"판": 1,
|
||||
"열": column,
|
||||
"도번": f"구-{number:02d}",
|
||||
"도면": {"format": 6, "entities": []},
|
||||
"도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]},
|
||||
}
|
||||
atomic_write_json(path, doc)
|
||||
ensure_basis(column)
|
||||
|
||||
@@ -45,7 +45,13 @@ function stubDoc(kind: Kind, name: string): unknown {
|
||||
합계줄: [],
|
||||
보기: {},
|
||||
}
|
||||
: { format: 6, entities: [] };
|
||||
: {
|
||||
format: 6,
|
||||
entities: [],
|
||||
// 기본 도면층 하나 — 없으면 B07 CAD 가 못 엶("Cannot read properties of
|
||||
// undefined (reading 'length')", 2026-09-27 실측)
|
||||
layers: [{ id: "기본", name: "기본", isVisible: true, isLocked: false }],
|
||||
};
|
||||
}
|
||||
|
||||
export interface SideOptions {
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-25",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-33",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-23",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-22",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-12",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-13",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-35",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-24",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-36",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-14",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-16",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-15",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-30",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-29",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-31",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-47",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-45",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-37",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-43",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-42",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-38",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-39",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-46",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-44",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-41",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-40",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-48",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-49",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-32",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-20",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-01",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-18",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-17",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-11",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-10",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-09",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-34",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-07",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-04",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-08",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-05",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-06",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-26",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-03",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-19",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-21",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-02",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-28",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"도번": "구-27",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,34 @@ ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n)
|
||||
ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0"));
|
||||
ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,"));
|
||||
ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%"));
|
||||
|
||||
// 실무 구조도 xlsx 세 벌 실측 코드(2026-09-27 대조) — 회귀 방지.
|
||||
ok(
|
||||
"_x 폭띄움 끝쉼표",
|
||||
fmt(frac(3n), "0.00_ ") === "3.00 ",
|
||||
JSON.stringify(fmt(frac(3n), "0.00_ ")),
|
||||
);
|
||||
ok(
|
||||
"_x 글에는 안 먹음(순수 수 형식)",
|
||||
fmt("외부벽체", "0.00_ ") === "외부벽체",
|
||||
fmt("외부벽체", "0.00_ "),
|
||||
);
|
||||
ok(
|
||||
"General 단위접미(실무 버릇)",
|
||||
fmt(frac(4n), 'General"열"') === "4열",
|
||||
fmt(frac(4n), 'General"열"'),
|
||||
);
|
||||
ok(
|
||||
"회계형(끝 구역 글)",
|
||||
fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-') === " 1.04 ",
|
||||
fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-'),
|
||||
);
|
||||
ok(
|
||||
"통화 태그([$기호-로캘])",
|
||||
fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-') === " €2.00 ",
|
||||
fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-'),
|
||||
);
|
||||
ok("따옴표 접두·접미", fmt(frac(800n), '"φ"###') === "φ800", fmt(frac(800n), '"φ"###'));
|
||||
ok(
|
||||
"음수 회계형",
|
||||
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)",
|
||||
@@ -278,6 +306,15 @@ function bookWith(cells: Record<string, Cell>): Workbook {
|
||||
JSON.stringify(out),
|
||||
);
|
||||
}
|
||||
{
|
||||
const book = bookWith({ B1: { 식: "A1*2" } });
|
||||
const out = fillCells(book, "s1", { r0: 0, c0: 1, r1: 0, c1: 1 }, { r0: 0, c0: 1, r1: 2, c1: 1 });
|
||||
ok(
|
||||
"채우기 식 상대 이동(A moveFormula)",
|
||||
out.B2?.식 === "A2*2" && out.B3?.식 === "A3*2",
|
||||
JSON.stringify(out),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 요약 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>C 격자 시험 틀</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
#app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./spreadsheet_grid_test.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid_test.ts
|
||||
* C(격자) 화면 시험 틀 — 병합 · 틀고정 · 숨김 · 폭 · hair 선 · 균등 분할 · 선택 영역 가운데 ·
|
||||
* [Red] 음수 서식 · 1만 행 스크롤 때 DOM 칸 수를 ORCA 로 확인. D · 엔진이 없으니 이 파일이
|
||||
* `SpreadsheetContext` 를 최소로 흉내 낸다(엔진 값은 안 씀 — 식 칸이 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createGrid } from "../../../A00_Common/spreadsheet/spreadsheet_grid";
|
||||
import type {
|
||||
CalcEngine,
|
||||
Command,
|
||||
History,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { colIndex } from "../../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import type {
|
||||
GridHandle,
|
||||
SpreadsheetContext,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
|
||||
|
||||
const sheet: Sheet = {
|
||||
id: "s1",
|
||||
이름: "시험",
|
||||
칸: {
|
||||
A1: { 값: "□격자 시험 — 병합 · 틀고정 · 숨김 · hair 선", 서식: 1 },
|
||||
A3: { 값: "공종", 서식: 2 },
|
||||
B3: { 값: "규격", 서식: 2 },
|
||||
C3: { 값: "산출 내역", 서식: 2 },
|
||||
G3: { 값: "단위", 서식: 2 },
|
||||
H3: { 값: "수량", 서식: 2 },
|
||||
A4: { 값: "터파기", 서식: 3 },
|
||||
C4: { 값: "균등 분할 정렬 보기 글", 서식: 4 },
|
||||
H4: { 값: -12.5, 서식: 6 },
|
||||
A6: { 값: "선택 영역 가운데(centerContinuous) 보기", 서식: 5 },
|
||||
A8: { 값: "hair 테두리 칸", 서식: 3 },
|
||||
B8: { 값: 3.14159, 서식: 7 },
|
||||
J10000: { 값: "맨 끝 칸(1만 행 스크롤 확인용)" },
|
||||
},
|
||||
comments: { H4: "메모 — ROUNDDOWN 끝수 확인" },
|
||||
병합: ["A1:H1", "B8:C9"],
|
||||
열: { A: { 폭: 14 }, D: { 숨김: true } },
|
||||
행: { "1": { 높이: 22 }, "5": { 숨김: true } },
|
||||
틀고정: { 행: 3, 열: 1 },
|
||||
보기: { 눈금선: true },
|
||||
};
|
||||
|
||||
const book: Workbook = {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "test",
|
||||
기본: { 글꼴: "굴림", 크기: 10, 열폭: 8.43, 행높이: 15 },
|
||||
서식: [
|
||||
{},
|
||||
{ 크기: 14, 굵게: true },
|
||||
{
|
||||
가로: "center",
|
||||
세로: "center",
|
||||
채움: "#F2F2F2",
|
||||
테두리: {
|
||||
위: { 선: "thin" },
|
||||
아래: { 선: "thin" },
|
||||
왼: { 선: "thin" },
|
||||
오른: { 선: "thin" },
|
||||
},
|
||||
},
|
||||
{ 세로: "center", 테두리: { 위: { 선: "hair" }, 아래: { 선: "hair" } } },
|
||||
{ 가로: "distributed", 세로: "center" },
|
||||
{ 가로: "centerContinuous", 세로: "center" },
|
||||
{ 형식: "0.00;[Red]-0.00" },
|
||||
{
|
||||
형식: "0.0000",
|
||||
테두리: {
|
||||
위: { 선: "hair" },
|
||||
아래: { 선: "hair" },
|
||||
왼: { 선: "hair" },
|
||||
오른: { 선: "hair" },
|
||||
},
|
||||
},
|
||||
],
|
||||
시트: [sheet],
|
||||
활성: "s1",
|
||||
};
|
||||
|
||||
function noopEngine(): CalcEngine {
|
||||
return {
|
||||
update: () => [],
|
||||
rebuild: () => {},
|
||||
value: () => null,
|
||||
precedents: () => [],
|
||||
snapshot: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function noopHistory(): History {
|
||||
return {
|
||||
push: () => {},
|
||||
undo: () => null,
|
||||
redo: () => null,
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
let grid!: GridHandle;
|
||||
|
||||
const ctx: SpreadsheetContext = {
|
||||
root: document.getElementById("app")!,
|
||||
book,
|
||||
readOnly: false,
|
||||
engine: noopEngine(),
|
||||
history: noopHistory(),
|
||||
selection: {
|
||||
시트: "s1",
|
||||
범위: [{ r0: 2, c0: 2, r1: 2, c1: 2 }],
|
||||
활성: { r: 2, c: 2 },
|
||||
기준: { r: 2, c: 2 },
|
||||
},
|
||||
sheet: () => sheet,
|
||||
dispatch: (command: Command) => {
|
||||
if (command.종류 === "열폭") {
|
||||
const c0 = command.열[0];
|
||||
const key = c0 >= 0 ? String.fromCharCode(65 + c0) : "A"; // 시험 데이터가 A~J 안쪽이라 단순 변환으로 충분
|
||||
sheet.열 = { ...sheet.열, [key]: { ...sheet.열?.[key], 폭: command.폭 ?? undefined } };
|
||||
} else if (command.종류 === "행높이") {
|
||||
const r0 = command.행[0];
|
||||
sheet.행 = {
|
||||
...sheet.행,
|
||||
[String(r0 + 1)]: { ...sheet.행?.[String(r0 + 1)], 높이: command.높이 ?? undefined },
|
||||
};
|
||||
}
|
||||
grid.render();
|
||||
},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
select: () => grid.renderSelection(),
|
||||
showSheet: () => {},
|
||||
get grid() {
|
||||
return grid;
|
||||
},
|
||||
editor: {
|
||||
editing: () => false,
|
||||
begin: () => {},
|
||||
commit: () => {},
|
||||
cancel: () => {},
|
||||
sync: () => {},
|
||||
},
|
||||
} as SpreadsheetContext;
|
||||
|
||||
grid = createGrid(ctx);
|
||||
ctx.root.append(grid.root);
|
||||
|
||||
// ORCA 수치 확인용 — 눈에 안 보이는 전역에 걸어 둠(콘솔 · eval 로 셈).
|
||||
(window as unknown as { __gridTest: unknown }).__gridTest = {
|
||||
grid,
|
||||
sheet,
|
||||
book,
|
||||
colIndex,
|
||||
countCells: () => grid.root.querySelectorAll(".aislo-cell").length,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_numfmt_survey_entry.ts (B 자기 시험)
|
||||
* 실무 구조도 xlsx 세 벌에서 뽑은 실제 숫자 형식 코드 전부를 numfmt 로 돌려 사람이 대조.
|
||||
* 입력 = 시험이 openpyxl 로 미리 뽑은 JSON(코드 · 실측 수 · 실측 글 견본).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt";
|
||||
|
||||
interface CodeSample {
|
||||
code: string;
|
||||
count: number;
|
||||
num: number | null;
|
||||
str: string | null;
|
||||
}
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
const samples: CodeSample[] = JSON.parse(readFileSync(inputPath, "utf-8"));
|
||||
|
||||
const rows = samples.map((s) => {
|
||||
let numOut: string | null = null;
|
||||
let numErr: string | null = null;
|
||||
if (s.num !== null) {
|
||||
try {
|
||||
numOut = formatValue(toFrac(s.num), s.code).글;
|
||||
} catch (e) {
|
||||
numErr = String(e);
|
||||
}
|
||||
}
|
||||
let strOut: string | null = null;
|
||||
let strErr: string | null = null;
|
||||
if (s.str !== null) {
|
||||
try {
|
||||
strOut = formatValue(s.str, s.code).글;
|
||||
} catch (e) {
|
||||
strErr = String(e);
|
||||
}
|
||||
}
|
||||
return { ...s, numOut, numErr, strOut, strErr };
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(rows, null, 1));
|
||||
@@ -60,7 +60,10 @@ def test_M02_만_늦게_불러옴():
|
||||
check=True,
|
||||
).stdout.splitlines()
|
||||
for rel in listed:
|
||||
if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file():
|
||||
# 시험용 진입(`resources/tester/`)은 앱 번들 밖
|
||||
if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")):
|
||||
continue
|
||||
if not (ROOT / rel).is_file():
|
||||
continue
|
||||
text = (ROOT / rel).read_text(encoding="utf-8", errors="replace")
|
||||
if "spreadsheet/spreadsheet" not in text:
|
||||
|
||||
@@ -35,8 +35,10 @@ def test_새로_도번_자동_409_없는_열(client):
|
||||
"pp_len",
|
||||
"구-01",
|
||||
)
|
||||
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역
|
||||
assert doc["도면"] == {"format": 6, "entities": []}
|
||||
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶)
|
||||
assert doc["도면"]["format"] == 6
|
||||
assert doc["도면"]["entities"] == []
|
||||
assert len(doc["도면"]["layers"]) == 1
|
||||
# 산출근거는 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서
|
||||
assert "산출근거" not in doc
|
||||
assert client.get("/api/m02/templates/basis/pp_len").json()["문서"]["종류"] == "통합문서"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""numfmt — 실무 구조도 xlsx 세 벌 실측 숫자 형식 코드 전부를 돌려 안 죽는지 대조(2026-09-27).
|
||||
|
||||
`resources/knowledge/original/실무문서/` 세 벌(openpyxl 로 읽기만 · 안 고침)에서 쓰인 서로 다른
|
||||
숫자 형식 코드를 모아 `spreadsheet_numfmt_survey_entry.ts`(vite ssr → node)로 돌린다.
|
||||
값별 정답은 실제 엑셀이 있어야 재는데(이 환경엔 없음) `resources/tester/spreadsheet/
|
||||
spreadsheet_b_test_entry.ts` 에 실측 코드 몇 개를 손으로 대조한 회귀 시험을 이미 넣었다 —
|
||||
여기는 "실무에 쓰인 코드 전부가 죽지 않고 뭔가 보이는 글을 냄"만 잰다(폭넓은 그물).
|
||||
|
||||
알려진 빈 자리(1단계 밖 · `9_엑셀검토.md` 형식 부분집합에 없음):
|
||||
- 분수 형식(`0/0`, `# ?/?` 류) — 미지원 · 자리표시만 있고 값은 틀리게 나옴.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_numfmt_survey_entry.ts"
|
||||
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
|
||||
FILES = [
|
||||
ROOT
|
||||
/ "resources/knowledge/original/실무문서/25년 산불진화임도(기번8)(울진소광)/1. 설계원본/02-수량/07-구조도-소광리.xlsx",
|
||||
ROOT
|
||||
/ "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/02 산출자료/5. 구조도(기번3).xlsx",
|
||||
ROOT
|
||||
/ "resources/knowledge/original/실무문서/20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/02.엑셀자료/구조도/구조도(변경).xlsx",
|
||||
]
|
||||
|
||||
# 미지원으로 이미 아는 코드(1단계 밖 · 분수 형식) — 죽지 않아도 값이 틀리므로 미리 뺌.
|
||||
KNOWN_UNSUPPORTED = {"0/0"}
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not all(f.is_file() for f in FILES) or shutil.which("node") is None,
|
||||
reason="실무 xlsx 세 벌 또는 node 가 없음",
|
||||
)
|
||||
|
||||
|
||||
def _collect_codes() -> list[dict]:
|
||||
codes: dict[str, dict] = {}
|
||||
for f in FILES:
|
||||
wb = openpyxl.load_workbook(f, data_only=True, read_only=True)
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value is None or isinstance(cell.value, bool):
|
||||
continue
|
||||
fmt = cell.number_format
|
||||
if fmt == "General":
|
||||
continue
|
||||
info = codes.setdefault(fmt, {"count": 0, "num": None, "str": None})
|
||||
info["count"] += 1
|
||||
if isinstance(cell.value, (int, float)) and info["num"] is None:
|
||||
info["num"] = cell.value
|
||||
if isinstance(cell.value, str) and info["str"] is None:
|
||||
info["str"] = cell.value
|
||||
wb.close()
|
||||
return [{"code": k, **v} for k, v in codes.items()]
|
||||
|
||||
|
||||
def _run_survey(samples: list[dict]) -> list[dict]:
|
||||
with tempfile.TemporaryDirectory(prefix="numfmt_survey_") 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}"
|
||||
bundle = Path(outdir) / "spreadsheet_numfmt_survey_entry.js"
|
||||
input_path = Path(outdir) / "codes.json"
|
||||
input_path.write_text(json.dumps(samples, ensure_ascii=False), encoding="utf-8")
|
||||
run = subprocess.run(
|
||||
["node", str(bundle), str(input_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}"
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_real_world_format_codes_do_not_error():
|
||||
samples = _collect_codes()
|
||||
assert len(samples) > 30, f"실무 코드가 너무 적게 걸림: {len(samples)}"
|
||||
results = _run_survey(samples)
|
||||
broken = [
|
||||
r
|
||||
for r in results
|
||||
if r["code"] not in KNOWN_UNSUPPORTED and (r["numErr"] or r["strErr"])
|
||||
]
|
||||
assert broken == [], json.dumps(broken, ensure_ascii=False, indent=1)
|
||||
Reference in New Issue
Block a user