Files
Aislo/A00_Common/spreadsheet/spreadsheet_print_html.ts
T
eomsangdonandClaude Opus 5.5 f20aea412a feat(534-2): 내장 엑셀 내보내기 — 인쇄 HTML(Node print) · xlsx · SVG→PNG · PDF
- spreadsheet_print_html: 통합문서 → 인쇄 HTML(DOM 없음 · 화면 엔진 값 · formatValue · 병합 · colgroup 폭 · 쪽 나눔 덩이 · thead 머리행 반복 · 그림 img · 시트마다 @page)
- 서식 → CSS 를 클립보드에서 떼어 공용(medium · thick · double 굵기 반영)
- 서버 Node 진입 prints · 파이썬 print_htmls · workbook_pdf(공용 PDF 엔진 page_size=None)
- common_util_workbook_xlsx: openpyxl 로 값 · 식 · 서식 · 병합 · 폭 · 높이 · 틀 고정 · 인쇄 설정 · 쪽 나눔 · 그림(두 칸 닻)
- common_util_svg_png: Playwright 로 SVG → PNG(PyMuPDF 안 씀)
- 시험 test_spreadsheet_print(인쇄 HTML · xlsx 되읽기 · PNG 크기 · PDF 쪽 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:33:57 +09:00

239 lines
11 KiB
TypeScript

/* =============================================================================
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>`
);
}