Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
166 lines
5.7 KiB
TypeScript
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> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
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) };
|
|
}
|