/* ============================================================================= * 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 이름 = 시트마다 용지 · 방향 · 여백) · 숨긴 시트 · 빈 시트 · 숨긴 행열은 안 찍음. * `인쇄.쪽나눔` 행 아래에서 새 쪽(덩이마다 표 하나) · `머리행` = 덩이마다 (자동 쪽 나눔 때도 되풀이) · * 머리행 위 행(제목 등)은 첫 덩이 앞 표 하나로. 병합 = rowspan · colspan(덩이 경계에서 잘림 · 이어진 쪽은 빈 칸). * 열 폭 = (화면과 같은 px 환산) · 행 높이 = pt · `맞춤.폭` = 폭에 맞춰 줄임 · 아니면 `배율` %. * 그림 = 닻 왼위 칸 안 (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 = { 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, """); } 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); /** 시트 한 장 → `
`(덩이 표 여럿) · 표 폭 px */ function sheetHtml( book: Workbook, sheet: Sheet, value: (r: number, c: number) => string, figures: Record, 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 = `${cols.map((c) => ``).join("")}`; 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(); /** 행 몇 줄 → 들(병합은 이 줄 묶음 안에서만) */ const rowsHtml = (part: number[]): string => { const skip = new Set(); const spans = new Map(); 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 += `${escapeHtml(f.alt)}`; }); if (imgs) css += ";position:relative;overflow:visible"; const attrs = (rs > 1 ? ` rowspan="${rs}"` : "") + (cs > 1 ? ` colspan="${cs}"` : ""); return `${escapeHtml(text)}${imgs}`; }); return `${tds.join("")}`; }) .join(""); }; const pr = sheet.인쇄 ?? {}; const head = pr.머리행 ? rows.filter((r) => r >= pr.머리행![0] - 1 && r <= pr.머리행![1] - 1) : []; const thead = head.length ? `${rowsHtml(head)}` : ""; 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) => `${colgroup}${inner}
`; 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 ? `${rowsHtml(main)}` : ""; return ( `
${pre.length ? table(`${rowsHtml(pre)}`) : ""}` + `${thead || tbody ? table(thead + tbody) : ""}
` ); }); 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 `
${html.join("")}
`; } 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 { 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 ( `${sections.join("")}` ); }