- 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>
239 lines
11 KiB
TypeScript
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, "&")
|
|
.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);
|
|
|
|
/** 시트 한 장 → `<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>`
|
|
);
|
|
}
|