Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Table.ts
T

166 lines
5.7 KiB
TypeScript

/* =============================================================================
* 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<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
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) =>
`<figure class="sheet-figure"><img src="${escHtml(im.src)}" alt="">` +
(im.caption ? `<figcaption>${escHtml(im.caption)}</figcaption>` : "") +
`</figure>`,
)
.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<PrintSheet> {
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<string>((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) };
}