Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user