Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-25 19:41:43 +09:00
9 changed files with 850 additions and 50 deletions
+16
View File
@@ -24,6 +24,7 @@ import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition";
import type { FigureSrc } from "./spreadsheet_figures";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
@@ -69,6 +70,8 @@ export interface SpreadsheetOptions {
preview?: boolean;
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
conditions?: ConditionOptions;
/** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */
figureSrc?: FigureSrc;
}
export interface SpreadsheetHandle {
@@ -185,9 +188,21 @@ export function createSpreadsheet(
});
}
/** 그림 겹판 · 쪽 나눔 점선 — 문서에 그림 · 쪽 나눔이 있을 때만 불러옴(크기 예산) */
let figuring = false;
function loadFigures(): void {
const any = ctx.book.시트.some((s) => s.그림?.length || s.인쇄?.쪽나눔?.length);
if (figuring || !any) return;
figuring = true;
void import("./spreadsheet_figures").then((m) => {
if (root.isConnected) others.push(m.attachFigures(ctx, opts.figureSrc));
});
}
grid.render();
select(ctx.selection, false, true);
loadLinks();
loadFigures();
if (opts.conditions && !readOnly)
void import("./spreadsheet_condition").then((m) => {
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
@@ -292,6 +307,7 @@ export function createSpreadsheet(
grid.render();
select(ctx.selection, false, true);
loadLinks();
loadFigures();
},
recalc() {
ctx.engine.rebuild(ctx.book);
@@ -14,9 +14,9 @@
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 { escapeHtml, styleToCss } from "./spreadsheet_print_html";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
import { st } from "./spreadsheet_text";
import type {
@@ -38,16 +38,6 @@ import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
const BORDER_CSS: Record<BorderLine, string> = {
hair: "dotted",
thin: "solid",
medium: "solid",
thick: "solid",
double: "double",
dotted: "dotted",
dashed: "dashed",
};
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const cell = sheet.칸[toA1(r, c)];
const idx =
@@ -59,44 +49,6 @@ function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle
return book.서식[idx] ?? {};
}
function styleToCss(style: CellStyle): string {
const parts: string[] = [];
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
if (style.크기) parts.push(`font-size:${style.크기}pt`);
if (style.굵게) parts.push("font-weight:bold");
if (style.기울임) parts.push("font-style:italic");
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${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) => {
const s = style.테두리?.[side];
if (!s) return;
const prop = {
위: "border-top",
아래: "border-bottom",
왼: "border-left",
오른: "border-right",
}[side];
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
});
}
return parts.join(";");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
const { r0, c0, r1, c1 } = range;
@@ -0,0 +1,105 @@
/* =============================================================================
* spreadsheet_figures.ts (주인 P · 534 sub6)
* 그림 겹판 · 쪽 나눔 점선(요구 347-5) — 시트 `그림`(범위 닻)을 칸 위에 얹고 `인쇄.쪽나눔` 행 아래에 점선.
* 격자 overlay 맨 아래 층(고름 테두리가 위) · 누름은 칸으로 통과 · 스크롤 · 확대 · 다시 그림마다 자리를 다시 셈.
* 그림 주소 = 부른 쪽 `figureSrc(열쇠)` · 없으면 이름 적힌 빈 상자. 그림 옮기기 · 나눔 고치기 화면은 없음(1차).
* `createSpreadsheet` 가 그림 · 쪽 나눔이 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import { parseRange } from "./spreadsheet_address";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
export type FigureSrc = (열쇠: string) => string | undefined;
const css = (node: HTMLElement, style: Partial<CSSStyleDeclaration>) =>
Object.assign(node.style, style);
export function attachFigures(ctx: SpreadsheetContext, figureSrc?: FigureSrc): PartHandle {
const layer = document.createElement("div");
layer.className = "ss-figures";
css(layer, { position: "absolute", inset: "0", overflow: "hidden", pointerEvents: "none" });
const imgs = new Map<string, HTMLImageElement>(); // 열쇠 → 그림(다시 그려도 다시 안 받음)
function draw(): void {
const overlay = ctx.grid.root.querySelector<HTMLElement>(".aislo-grid__overlay");
if (!overlay) return;
if (overlay.firstChild !== layer) overlay.prepend(layer);
// editorSlot 상자 = 격자 뿌리 기준 → overlay 기준(머리 칸만큼 밀림)
const box = (r: number, c: number) => {
const b = ctx.grid.editorSlot(r, c).box;
return { ...b, x: b.x - overlay.offsetLeft, y: b.y - overlay.offsetTop };
};
const sheet = ctx.sheet();
const parts: HTMLElement[] = [];
for (const f of sheet.그림 ?? []) {
const rg = parseRange(f.닻);
if (!rg) continue;
const a = box(rg.r0, rg.c0);
const b = box(rg.r1, rg.c1);
const frame = document.createElement("div");
frame.className = "ss-figure";
frame.dataset.key = f.열쇠;
css(frame, {
position: "absolute",
left: `${a.x}px`,
top: `${a.y}px`,
width: `${Math.max(0, b.x + b.w - a.x)}px`,
height: `${Math.max(0, b.y + b.h - a.y)}px`,
boxSizing: "border-box",
background: "#fff",
});
const src = figureSrc?.(f.열쇠);
if (src) {
let img = imgs.get(f.열쇠);
if (!img || img.getAttribute("src") !== src) {
img = document.createElement("img");
img.src = src;
img.alt = f.이름 ?? f.열쇠;
css(img, { width: "100%", height: "100%", objectFit: "contain", display: "block" });
imgs.set(f.열쇠, img);
}
frame.append(img);
} else {
css(frame, {
border: "1px dashed #888",
color: "#666",
font: "12px sans-serif",
padding: "4px",
});
frame.textContent = f.이름 ?? f.열쇠;
}
parts.push(frame);
}
const view = ctx.grid.visibleRange();
for (const k of sheet.인쇄?.쪽나눔 ?? []) {
const at = box(k - 1, view.c0);
const line = document.createElement("div");
line.className = "ss-pagebreak";
line.dataset.row = String(k);
css(line, {
position: "absolute",
left: "0",
right: "0",
top: `${at.y + at.h - 1}px`,
borderTop: "2px dashed #3b6fd8",
});
parts.push(line);
}
layer.replaceChildren(...parts);
}
const onScroll = () => draw();
ctx.grid.root.addEventListener("scroll", onScroll, true);
ctx.grid.root.addEventListener("aislo-grid-zoom", onScroll);
draw();
return {
root: null,
refresh: draw,
destroy() {
ctx.grid.root.removeEventListener("scroll", onScroll, true);
ctx.grid.root.removeEventListener("aislo-grid-zoom", onScroll);
layer.remove();
},
};
}
@@ -0,0 +1,238 @@
/* =============================================================================
* spreadsheet_print_html.ts (주인 P · 534 sub6)
* 통합문서 → 인쇄 HTML 한 벌(요구 347-5) — 서버 Node 진입 `print` 이 부르고 PDF 는
* `common_util_print_pdf.render_html_to_pdf(page_size=None)` 이 만듦(쪽 크기 = CSS @page).
* DOM 없음 · 값 = 화면과 같은 엔진(`createCalcEngine`) · 보이는 글 = `formatValue` · 서식 = `cellStyleAt`.
*
* 규칙:
* 시트마다 새 쪽(@page 이름 = 시트마다 용지 · 방향 · 여백) · 숨긴 시트 · 빈 시트 · 숨긴 행열은 안 찍음.
* `인쇄.쪽나눔` 행 아래에서 새 쪽(덩이마다 표 하나) · `머리행` = 덩이마다 <thead>(자동 쪽 나눔 때도 되풀이) ·
* 머리행 위 행(제목 등)은 첫 덩이 앞 표 하나로. 병합 = rowspan · colspan(덩이 경계에서 잘림 · 이어진 쪽은 빈 칸).
* 열 폭 = <colgroup>(화면과 같은 px 환산) · 행 높이 = pt · `맞춤.폭` = 폭에 맞춰 줄임 · 아니면 `배율` %.
* 그림 = 닻 왼위 칸 안 <img>(data: 주소 · 범위 크기) — 주소가 없는 그림은 안 찍음.
* 화면 · 클립보드가 같이 쓰는 서식 → CSS(`styleToCss`)도 여기 둠(DOM 없는 파일).
* ========================================================================== */
import { colName, parseRange } from "./spreadsheet_address";
import { cellStyleAt, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph";
import { colCharsToPx, DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, CellRange, CellStyle, Sheet, Workbook } from "./spreadsheet_types";
const BORDER_CSS: Record<BorderLine, string> = {
hair: "1px dotted",
thin: "1px solid",
medium: "2px solid",
thick: "3px solid",
double: "3px double",
dotted: "1px dotted",
dashed: "1px dashed",
};
const SIDES = { 위: "border-top", 아래: "border-bottom", 왼: "border-left", 오른: "border-right" };
/** 서식 → 인라인 CSS — 안 준 맞춤 = 가운데 · 가운데(237-1, 화면 · 엑셀 붙여넣기 · 인쇄 공통). */
export function styleToCss(style: CellStyle): string {
const parts: string[] = [];
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
if (style.크기) parts.push(`font-size:${style.크기}pt`);
if (style.굵게) parts.push("font-weight:bold");
if (style.기울임) parts.push("font-style:italic");
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
const h = shownHAlign(style.가로);
parts.push(
`text-align:${h === "centerContinuous" ? "center" : h === "distributed" ? "justify" : h}`,
);
const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
for (const side of ["위", "아래", "왼", "오른"] as const) {
const s = style.테두리?.[side];
if (s) parts.push(`${SIDES[side]}:${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
}
return parts.join(";");
}
export function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const PAPER_MM = { A4: [210, 297], A3: [297, 420] } as const;
const PX_PER_MM = 96 / 25.4;
/** 시트가 쓰는 마지막 행 · 열(0 부터) — 칸 · 병합 · 그림 닻. 빈 시트 = null */
function usedExtent(sheet: Sheet): { rows: number; cols: number } | null {
let r1 = -1;
let c1 = -1;
const grow = (rg: CellRange | null) => {
if (rg) [r1, c1] = [Math.max(r1, rg.r1), Math.max(c1, rg.c1)];
};
for (const a1 of Object.keys(sheet.칸)) grow(parseRange(a1));
for (const m of sheet.병합 ?? []) grow(parseRange(m));
for (const f of sheet.그림 ?? []) grow(parseRange(f.닻));
return r1 < 0 ? null : { rows: r1 + 1, cols: c1 + 1 };
}
const range = (n: number) => Array.from({ length: n }, (_, i) => i);
/** 시트 한 장 → `<section>`(덩이 표 여럿) · 표 폭 px */
function sheetHtml(
book: Workbook,
sheet: Sheet,
value: (r: number, c: number) => string,
figures: Record<string, string>,
pageName: string,
printWidthPx: number,
): string | null {
const ext = usedExtent(sheet);
if (!ext) return null;
const cols = range(ext.cols).filter((c) => !sheet.열?.[colName(c)]?.숨김);
const rows = range(ext.rows).filter((r) => {
const info = sheet.행?.[String(r + 1)];
return !info?.숨김 && !info?.걸러짐;
});
const colPx = (c: number) =>
colCharsToPx(
sheet.열?.[colName(c)]?.폭 ?? sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS,
);
const rowPt = (r: number) =>
sheet.행?.[String(r + 1)]?.높이 ?? sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
const tableW = cols.reduce((sum, c) => sum + colPx(c), 0);
const colgroup = `<colgroup>${cols.map((c) => `<col style="width:${colPx(c)}px">`).join("")}</colgroup>`;
const merges = (sheet.병합 ?? []).map((m) => parseRange(m)).filter((m): m is CellRange => !!m);
const figs = (sheet.그림 ?? [])
.map((f) => ({ src: figures[f.열쇠], rg: parseRange(f.닻), alt: f.이름 ?? f.열쇠 }))
.filter((f): f is { src: string; rg: CellRange; alt: string } => !!f.src && !!f.rg);
const placed = new Set<number>();
/** 행 몇 줄 → <tr> 들(병합은 이 줄 묶음 안에서만) */
const rowsHtml = (part: number[]): string => {
const skip = new Set<string>();
const spans = new Map<string, { rs: number; cs: number; origin: boolean }>();
for (const m of merges) {
const rIn = part.filter((r) => r >= m.r0 && r <= m.r1);
const cIn = cols.filter((c) => c >= m.c0 && c <= m.c1);
if (!rIn.length || !cIn.length) continue;
for (const r of rIn) for (const c of cIn) skip.add(`${r},${c}`);
const origin = rIn[0] === m.r0 && cIn[0] === m.c0;
spans.set(`${rIn[0]},${cIn[0]}`, { rs: rIn.length, cs: cIn.length, origin });
}
return part
.map((r) => {
const tds = cols.map((c) => {
const key = `${r},${c}`;
const span = spans.get(key);
if (skip.has(key) && !span) return "";
const rs = span?.rs ?? 1;
const cs = span?.cs ?? 1;
let css = styleToCss(cellStyleAt(book, sheet, r, c));
const text = span && !span.origin ? "" : value(r, c);
// 이 칸 상자에 닻 왼위가 든 그림
const boxRows = part.slice(part.indexOf(r), part.indexOf(r) + rs);
const boxCols = cols.slice(cols.indexOf(c), cols.indexOf(c) + cs);
let imgs = "";
figs.forEach((f, i) => {
if (placed.has(i) || !boxRows.includes(f.rg.r0) || !boxCols.includes(f.rg.c0)) return;
placed.add(i);
const w = cols
.filter((x) => x >= f.rg.c0 && x <= f.rg.c1)
.reduce((s, x) => s + colPx(x), 0);
const h = rows
.filter((x) => x >= f.rg.r0 && x <= f.rg.r1)
.reduce((s, x) => s + rowPt(x), 0);
imgs +=
`<img src="${escapeHtml(f.src)}" alt="${escapeHtml(f.alt)}" style="position:absolute;` +
`left:0;top:0;width:${w}px;height:${h}pt;object-fit:contain">`;
});
if (imgs) css += ";position:relative;overflow:visible";
const attrs = (rs > 1 ? ` rowspan="${rs}"` : "") + (cs > 1 ? ` colspan="${cs}"` : "");
return `<td${attrs} style="${escapeHtml(css)}">${escapeHtml(text)}${imgs}</td>`;
});
return `<tr style="height:${rowPt(r)}pt">${tds.join("")}</tr>`;
})
.join("");
};
const pr = sheet.인쇄 ?? {};
const head = pr.머리행
? rows.filter((r) => r >= pr.머리행![0] - 1 && r <= pr.머리행![1] - 1)
: [];
const thead = head.length ? `<thead>${rowsHtml(head)}</thead>` : "";
const body = rows.filter((r) => !head.includes(r));
// 덩이 = 쪽 나눔 행 아래에서 끊음
const cuts = [...new Set(pr.쪽나눔 ?? [])].sort((a, b) => a - b);
const chunks: number[][] = [[]];
for (const r of body) {
if (
chunks[chunks.length - 1].length &&
cuts.some((k) => r >= k && chunks[chunks.length - 1].at(-1)! < k)
)
chunks.push([]);
chunks[chunks.length - 1].push(r);
}
const table = (inner: string) => `<table style="width:${tableW}px">${colgroup}${inner}</table>`;
const html = chunks.map((part, i) => {
const pre = i === 0 && head.length ? part.filter((r) => r < head[0]) : [];
const main = part.filter((r) => !pre.includes(r));
const tbody = main.length ? `<tbody>${rowsHtml(main)}</tbody>` : "";
return (
`<div class="chunk">${pre.length ? table(`<tbody>${rowsHtml(pre)}</tbody>`) : ""}` +
`${thead || tbody ? table(thead + tbody) : ""}</div>`
);
});
const zoom = pr.맞춤?.폭
? Math.min(1, printWidthPx / Math.max(1, tableW))
: (pr.배율 ?? 100) / 100;
const style = `page:${pageName}${zoom !== 1 ? `;zoom:${zoom.toFixed(4)}` : ""}`;
return `<section class="sheet" data-sheet="${escapeHtml(sheet.id)}" style="${style}">${html.join("")}</section>`;
}
const BASE_CSS = `html,body{margin:0}
body{font-family:"Malgun Gothic","맑은 고딕",sans-serif;font-size:10pt;color:#000}
.sheet{break-after:page}.sheet:last-child{break-after:auto}
.chunk+.chunk{break-before:page}
table{table-layout:fixed;border-collapse:collapse}
td{padding:0 2px;overflow:hidden;white-space:nowrap}
thead{display:table-header-group}
tr{break-inside:avoid}`;
/** 통합문서 → 인쇄 HTML 문서 — `figures` = 그림 열쇠 → data: 주소 */
export function printHtml(book: Workbook, figures: Record<string, string> = {}): string {
const engine = createCalcEngine(book);
const pages: string[] = [];
const sections: string[] = [];
book.시트.forEach((sheet, i) => {
if (sheet.숨김) return;
const pr = sheet.인쇄 ?? {};
const [pw, ph] = PAPER_MM[pr.용지 ?? "A4"];
const land = pr.방향 === "landscape";
const m = { 위: 10, 아래: 10, 왼: 10, 오른: 10, ...pr.여백 };
const name = `p${i}`;
pages.push(
`@page ${name}{size:${pr.용지 ?? "A4"} ${land ? "landscape" : "portrait"};` +
`margin:${m.위}mm ${m.오른}mm ${m.아래}mm ${m.왼}mm}`,
);
const widthPx = ((land ? ph : pw) - m.왼 - m.오른) * PX_PER_MM;
const value = (r: number, c: number) =>
formatValue(engine.value(sheet.id, r, c), cellStyleAt(book, sheet, r, c).형식).글;
const html = sheetHtml(book, sheet, value, figures, name, widthPx);
if (html) sections.push(html);
});
const fontCss =
book.기본?.글꼴 || book.기본?.크기
? `body{${book.기본.글꼴 ? `font-family:${book.기본.글꼴};` : ""}${book.기본.크기 ? `font-size:${book.기본.크기}pt` : ""}}`
: "";
return (
`<!doctype html><html><head><meta charset="utf-8"><style>${BASE_CSS}\n${pages.join("\n")}` +
`${fontCss}</style></head><body>${sections.join("")}</body></html>`
);
}