/* ============================================================================= * B09_Draft_Print_Table.ts * 인쇄 시트 → 서버 몸(PLAN 302-1 · 초안) — 화면과 같은 표 짜기(DataTableOptions)를 그대로 씀. * sheetHtml = `renderDataTableHtml`(누름 · 입력 뺀 보기 전용) + 그림(data: 주소) → 서버 표 PDF * sheetGrid = 머리 줄(묶음 병합) · 값 줄(숫자 = 숫자 · 합계 = 끝 줄) · 열 폭 · 소수 · 레벨 · 세로 병합 → 서버 엑셀 * 그림 = 엑셀엔 안 넣음(PDF 만) * ========================================================================== */ import { mergeSpans, renderDataTableHtml, SCALE, type DataTableOptions, } from "@ui/ui_template_data_table"; import type { PrintSheet } from "./B09_Draft_Print_Types"; export interface GridHead { r: number; c: number; rs: number; cs: number; text: string; } export interface SheetGrid { head_rows: number; head: GridHead[]; /** 열 폭 px · 소수 자리(글 열 = null) */ cols: { w: number; dec: number | null }[]; rows: (string | number | null)[][]; bold: boolean[]; levels: (number | null)[]; level_col: number | null; /** 본문 세로 병합(mergeSame) — r = 값 줄 번호 */ merges: { r: number; c: number; rs: number }[]; frozen: number; } const ESC_HTML: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; const escHtml = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC_HTML[c]); /** 보기 전용 — 누름 · 입력 · 강조를 뺀 같은 표 */ const still = (t: DataTableOptions): DataTableOptions => ({ ...t, onRowClick: undefined, onCellClick: undefined, editable: undefined, onEdit: undefined, editOnDouble: false, editing: null, focus: null, fillViewport: false, }); export function sheetHtml(s: PrintSheet): string { const figs = (s.images ?? []) .map( (im) => `
` + (im.caption ? `
${escHtml(im.caption)}
` : "") + `
`, ) .join(""); return renderDataTableHtml(still(s.table)) + figs; } function cellValue(v: unknown): string | number | null { if (v === null || v === undefined || v === "") return null; if (typeof v === "number") return Number.isFinite(v) ? v : null; return String(v); } export function sheetGrid(table: DataTableOptions): SheetGrid { const t = still(table); const cols = t.columns; const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0)); const head: GridHead[] = []; const same = (a: string[] | undefined, b: string[] | undefined, n: number) => (b?.length ?? 0) > n && a!.slice(0, n + 1).join("\u0000") === b!.slice(0, n + 1).join("\u0000"); // 화면 머리(`headHtml`)와 같은 칸 나눔 — 묶음 = 옆으로 합침 · 맨 아래 머리 = 아래로 합침 for (let lv = 0; lv <= depth; lv++) { for (let j = 0; j < cols.length; j++) { const g = cols[j].group?.length ?? 0; if (g > lv) { let k = j; while (k + 1 < cols.length && same(cols[j].group, cols[k + 1].group, lv)) k++; head.push({ r: lv, c: j, rs: 1, cs: k - j + 1, text: cols[j].group![lv] }); j = k; } else if (g === lv) { head.push({ r: lv, c: j, rs: depth - lv + 1, cs: 1, text: cols[j].label }); } } } const n = t.rows.length; const rows = t.rows.map((r) => cols.map((c) => cellValue(r[c.key]))); const bold = t.rows.map((_r, i) => /\bis-title\b/.test(t.rowClass?.(i) ?? "")); const levels = t.rows.map((_r, i) => (t.level ? (t.level.of(i) ?? 1) : null)); if (t.total) { rows.push(cols.map((c) => cellValue(t.total![c.key]))); bold.push(true); levels.push(null); } const merges: SheetGrid["merges"] = []; if (t.mergeSame?.length) mergeSpans(t, 0, n).forEach((spans, i) => spans.forEach((rs, c) => rs > 1 && merges.push({ r: i, c, rs })), ); const lvCol = t.level ? cols.findIndex((c) => c.key === t.level!.key) : -1; return { head_rows: depth + 1, head, cols: cols.map((c) => ({ w: Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE), dec: c.kind === "number" ? (c.decimals ?? 0) : null, })), rows, bold, levels, level_col: lvCol >= 0 ? lvCol : null, merges, frozen: Math.min(t.frozen ?? 0, cols.length), }; } /** 서버 몸 — 함수 칸(rowClass · level.of …)은 여기서 펼쳐 글 · 값으로만 보냄 */ export const pdfBody = (title: string, sheets: PrintSheet[]) => ({ title, sheets: sheets.map((s) => ({ name: s.name, orientation: s.orientation ?? "portrait", html: sheetHtml(s), })), }); export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({ title, sheets: sheets.map((s) => ({ name: s.name, orientation: s.orientation ?? "portrait", grid: sheetGrid(s.table), })), }); /** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ export async function inlineImages(s: PrintSheet): Promise { if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; const images = await Promise.all( s.images.map(async (im) => { if (im.src.startsWith("data:")) return im; try { const blob = await (await fetch(im.src, { credentials: "include" })).blob(); const src = await new Promise((done, fail) => { const reader = new FileReader(); reader.onload = () => done(String(reader.result)); reader.onerror = () => fail(reader.error); reader.readAsDataURL(blob); }); return { ...im, src }; } catch { return { ...im, src: "" }; } }), ); return { ...s, images: images.filter((im) => im.src) }; }