Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -24,6 +24,7 @@ import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { FigureSrc } from "./spreadsheet_figures";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
@@ -69,6 +70,8 @@ export interface SpreadsheetOptions {
|
||||
preview?: boolean;
|
||||
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
|
||||
conditions?: ConditionOptions;
|
||||
/** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */
|
||||
figureSrc?: FigureSrc;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -185,9 +188,21 @@ export function createSpreadsheet(
|
||||
});
|
||||
}
|
||||
|
||||
/** 그림 겹판 · 쪽 나눔 점선 — 문서에 그림 · 쪽 나눔이 있을 때만 불러옴(크기 예산) */
|
||||
let figuring = false;
|
||||
function loadFigures(): void {
|
||||
const any = ctx.book.시트.some((s) => s.그림?.length || s.인쇄?.쪽나눔?.length);
|
||||
if (figuring || !any) return;
|
||||
figuring = true;
|
||||
void import("./spreadsheet_figures").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachFigures(ctx, opts.figureSrc));
|
||||
});
|
||||
}
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection, false, true);
|
||||
loadLinks();
|
||||
loadFigures();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
|
||||
@@ -292,6 +307,7 @@ export function createSpreadsheet(
|
||||
grid.render();
|
||||
select(ctx.selection, false, true);
|
||||
loadLinks();
|
||||
loadFigures();
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
|
||||
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import { escapeHtml, styleToCss } from "./spreadsheet_print_html";
|
||||
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type {
|
||||
@@ -38,16 +38,6 @@ import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const BORDER_CSS: Record<BorderLine, string> = {
|
||||
hair: "dotted",
|
||||
thin: "solid",
|
||||
medium: "solid",
|
||||
thick: "solid",
|
||||
double: "double",
|
||||
dotted: "dotted",
|
||||
dashed: "dashed",
|
||||
};
|
||||
|
||||
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx =
|
||||
@@ -59,44 +49,6 @@ function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
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.채움}`);
|
||||
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
|
||||
const h = shownHAlign(style.가로);
|
||||
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
|
||||
const v = shownVAlign(style.세로);
|
||||
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
|
||||
if (style.줄바꿈) parts.push("white-space:pre-wrap");
|
||||
if (style.테두리) {
|
||||
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
|
||||
const s = style.테두리?.[side];
|
||||
if (!s) return;
|
||||
const prop = {
|
||||
위: "border-top",
|
||||
아래: "border-bottom",
|
||||
왼: "border-left",
|
||||
오른: "border-right",
|
||||
}[side];
|
||||
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
|
||||
});
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
|
||||
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_figures.ts (주인 P · 534 sub6)
|
||||
* 그림 겹판 · 쪽 나눔 점선(요구 347-5) — 시트 `그림`(범위 닻)을 칸 위에 얹고 `인쇄.쪽나눔` 행 아래에 점선.
|
||||
* 격자 overlay 맨 아래 층(고름 테두리가 위) · 누름은 칸으로 통과 · 스크롤 · 확대 · 다시 그림마다 자리를 다시 셈.
|
||||
* 그림 주소 = 부른 쪽 `figureSrc(열쇠)` · 없으면 이름 적힌 빈 상자. 그림 옮기기 · 나눔 고치기 화면은 없음(1차).
|
||||
* `createSpreadsheet` 가 그림 · 쪽 나눔이 있는 문서일 때만 불러옴(크기 예산).
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseRange } from "./spreadsheet_address";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export type FigureSrc = (열쇠: string) => string | undefined;
|
||||
|
||||
const css = (node: HTMLElement, style: Partial<CSSStyleDeclaration>) =>
|
||||
Object.assign(node.style, style);
|
||||
|
||||
export function attachFigures(ctx: SpreadsheetContext, figureSrc?: FigureSrc): PartHandle {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "ss-figures";
|
||||
css(layer, { position: "absolute", inset: "0", overflow: "hidden", pointerEvents: "none" });
|
||||
const imgs = new Map<string, HTMLImageElement>(); // 열쇠 → 그림(다시 그려도 다시 안 받음)
|
||||
|
||||
function draw(): void {
|
||||
const overlay = ctx.grid.root.querySelector<HTMLElement>(".aislo-grid__overlay");
|
||||
if (!overlay) return;
|
||||
if (overlay.firstChild !== layer) overlay.prepend(layer);
|
||||
// editorSlot 상자 = 격자 뿌리 기준 → overlay 기준(머리 칸만큼 밀림)
|
||||
const box = (r: number, c: number) => {
|
||||
const b = ctx.grid.editorSlot(r, c).box;
|
||||
return { ...b, x: b.x - overlay.offsetLeft, y: b.y - overlay.offsetTop };
|
||||
};
|
||||
const sheet = ctx.sheet();
|
||||
const parts: HTMLElement[] = [];
|
||||
for (const f of sheet.그림 ?? []) {
|
||||
const rg = parseRange(f.닻);
|
||||
if (!rg) continue;
|
||||
const a = box(rg.r0, rg.c0);
|
||||
const b = box(rg.r1, rg.c1);
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "ss-figure";
|
||||
frame.dataset.key = f.열쇠;
|
||||
css(frame, {
|
||||
position: "absolute",
|
||||
left: `${a.x}px`,
|
||||
top: `${a.y}px`,
|
||||
width: `${Math.max(0, b.x + b.w - a.x)}px`,
|
||||
height: `${Math.max(0, b.y + b.h - a.y)}px`,
|
||||
boxSizing: "border-box",
|
||||
background: "#fff",
|
||||
});
|
||||
const src = figureSrc?.(f.열쇠);
|
||||
if (src) {
|
||||
let img = imgs.get(f.열쇠);
|
||||
if (!img || img.getAttribute("src") !== src) {
|
||||
img = document.createElement("img");
|
||||
img.src = src;
|
||||
img.alt = f.이름 ?? f.열쇠;
|
||||
css(img, { width: "100%", height: "100%", objectFit: "contain", display: "block" });
|
||||
imgs.set(f.열쇠, img);
|
||||
}
|
||||
frame.append(img);
|
||||
} else {
|
||||
css(frame, {
|
||||
border: "1px dashed #888",
|
||||
color: "#666",
|
||||
font: "12px sans-serif",
|
||||
padding: "4px",
|
||||
});
|
||||
frame.textContent = f.이름 ?? f.열쇠;
|
||||
}
|
||||
parts.push(frame);
|
||||
}
|
||||
const view = ctx.grid.visibleRange();
|
||||
for (const k of sheet.인쇄?.쪽나눔 ?? []) {
|
||||
const at = box(k - 1, view.c0);
|
||||
const line = document.createElement("div");
|
||||
line.className = "ss-pagebreak";
|
||||
line.dataset.row = String(k);
|
||||
css(line, {
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
top: `${at.y + at.h - 1}px`,
|
||||
borderTop: "2px dashed #3b6fd8",
|
||||
});
|
||||
parts.push(line);
|
||||
}
|
||||
layer.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
const onScroll = () => draw();
|
||||
ctx.grid.root.addEventListener("scroll", onScroll, true);
|
||||
ctx.grid.root.addEventListener("aislo-grid-zoom", onScroll);
|
||||
draw();
|
||||
|
||||
return {
|
||||
root: null,
|
||||
refresh: draw,
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("scroll", onScroll, true);
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onScroll);
|
||||
layer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* =============================================================================
|
||||
* 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>`
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
@@ -77,3 +78,39 @@ def define_names(asks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if out is None:
|
||||
raise RecalcError("스프레드시트 계산 번들 실행 실패")
|
||||
return out["names"]
|
||||
|
||||
|
||||
def print_htmls(asks: list[dict[str, Any]]) -> list[str]:
|
||||
"""통합문서 → 인쇄 HTML(534-2 · 번들 `printHtml` 한 벌) —
|
||||
ask = `{book, figures?: {열쇠: PNG 바이트}}`.
|
||||
물음 차례 그대로 HTML. 하나라도 못 만들면 `RecalcError`."""
|
||||
if _stale() and not build_bundle(NPM_SCRIPT):
|
||||
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
|
||||
prints = [
|
||||
{
|
||||
"book": ask["book"],
|
||||
"figures": {
|
||||
key: "data:image/png;base64," + base64.b64encode(png).decode()
|
||||
for key, png in (ask.get("figures") or {}).items()
|
||||
},
|
||||
}
|
||||
for ask in asks
|
||||
]
|
||||
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"prints": prints})
|
||||
if out is None:
|
||||
raise RecalcError("스프레드시트 계산 번들 실행 실패")
|
||||
htmls: list[str] = []
|
||||
for result in out["prints"]:
|
||||
if not result.get("ok"):
|
||||
raise RecalcError(f"인쇄 HTML 실패 — {result.get('까닭')}")
|
||||
htmls.append(result["html"])
|
||||
return htmls
|
||||
|
||||
|
||||
def workbook_pdf(book: dict[str, Any], figures: dict[str, bytes] | None = None) -> bytes:
|
||||
"""통합문서 → PDF(요구 347-5) — 인쇄 HTML → 공용 PDF 엔진(쪽 크기 · 여백 = 시트마다 CSS @page).
|
||||
실패 = `RecalcError` 또는 `common_util_print_pdf.PrintError`."""
|
||||
from common_util.common_util_print_pdf import render_html_to_pdf
|
||||
|
||||
html = print_htmls([{"book": book, "figures": figures}])[0]
|
||||
return render_html_to_pdf(html, page_size=None)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂
|
||||
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 풀이는 `A00_Common/spreadsheet/spreadsheet_area_table.ts`
|
||||
* (화면 도면 패널의 저장 전 실시간 표 값과 같은 코드 · 112-1) · 저장 안 함.
|
||||
* 인쇄(534-2 · 요구 347-5) = 입력 `prints: {book, figures?}[]` → 출력 `prints: ({ ok: true, html } | { ok: false, 까닭 })[]` —
|
||||
* `A00_Common/spreadsheet/spreadsheet_print_html.ts` 한 벌 · figures = 그림 열쇠 → data: 주소 · PDF 는 파이썬이 만듦.
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
|
||||
* ========================================================================== */
|
||||
@@ -18,6 +20,7 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { parseRange } from "../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { areaTable, type TableAsk } from "../A00_Common/spreadsheet/spreadsheet_area_table";
|
||||
import { printHtml } from "../A00_Common/spreadsheet/spreadsheet_print_html";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
@@ -52,6 +55,7 @@ const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
books?: Workbook[];
|
||||
tables?: TableAsk[];
|
||||
names?: NameAsk[];
|
||||
prints?: { book: Workbook; figures?: Record<string, string> }[];
|
||||
};
|
||||
const results = (input.books ?? []).map((book) => {
|
||||
try {
|
||||
@@ -74,4 +78,11 @@ const names = (input.names ?? []).map((ask) => {
|
||||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ results, tables, names }));
|
||||
const prints = (input.prints ?? []).map((ask) => {
|
||||
try {
|
||||
return { ok: true, html: printHtml(ask.book, ask.figures) };
|
||||
} catch (error) {
|
||||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ results, tables, names, prints }));
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""SVG → PNG(요구 347-5 · 요구 350 — PyMuPDF 안 씀) — 헤드리스 Edge(없으면 Chrome) Playwright 찍기.
|
||||
|
||||
svgs_to_pngs([svg 글], scale=2) → [PNG 바이트] 브라우저 한 번으로 여러 장(차례 그대로)
|
||||
|
||||
크기 = SVG 의 width · height(없으면 viewBox) × scale. 보고서 그림(단면 · 평면 위험 띠)을
|
||||
내장 엑셀 그림(xlsx · 화면)으로 넣을 때 씀 — PDF 는 인쇄 HTML 이 data: 주소로 그대로 씀.
|
||||
브라우저 띄우는 규칙 · 잠금 · 까닭 글은 공용 PDF 엔진(`common_util_print_pdf`)과 한 벌 —
|
||||
자식 파이썬 프로세스(서버 스레드에선 Playwright 드라이버가 못 뜸) · 스크립트 끔 · 바깥 요청 막음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_print_pdf import CHANNELS, NO_BROWSER, TIMEOUT_S, PrintError, _lock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _shoot(svgs: list[str], scale: float) -> list[bytes]:
|
||||
"""자식 프로세스 안 — SVG 마다 <svg> 요소만 찍음. 실패 = PrintError."""
|
||||
try:
|
||||
from playwright.sync_api import Error as PwError
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as error:
|
||||
raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = None
|
||||
for channel in CHANNELS:
|
||||
try:
|
||||
browser = p.chromium.launch(channel=channel, headless=True)
|
||||
break
|
||||
except PwError:
|
||||
continue
|
||||
if browser is None:
|
||||
raise PrintError(NO_BROWSER)
|
||||
try:
|
||||
context = browser.new_context(java_script_enabled=False, device_scale_factor=scale)
|
||||
context.route("**/*", lambda route: route.abort())
|
||||
page = context.new_page()
|
||||
out = []
|
||||
for svg in svgs:
|
||||
page.set_content(
|
||||
f'<html><body style="margin:0;background:#fff">{svg}</body></html>'
|
||||
)
|
||||
out.append(page.locator("svg").first.screenshot())
|
||||
return out
|
||||
finally:
|
||||
browser.close()
|
||||
except PrintError:
|
||||
raise
|
||||
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
|
||||
raise PrintError(NO_BROWSER) from error
|
||||
|
||||
|
||||
def svgs_to_pngs(svgs: list[str], scale: float = 2, timeout_s: float = TIMEOUT_S) -> list[bytes]:
|
||||
"""SVG 여러 장 → PNG 여러 장(자식 프로세스 한 번)."""
|
||||
if not svgs:
|
||||
return []
|
||||
with _lock:
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-m", "common_util.common_util_svg_png"], # 묶음 import 되게
|
||||
input=json.dumps({"svgs": svgs, "scale": scale}).encode(),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise PrintError(f"그림 만들기가 {timeout_s:.0f}초 안에 안 끝남") from error
|
||||
except OSError as error:
|
||||
raise PrintError(NO_BROWSER) from error
|
||||
try:
|
||||
result: dict[str, Any] = json.loads(done.stdout.decode().strip().splitlines()[-1])
|
||||
except (ValueError, IndexError): # 자식이 죽음 — stderr 는 내지 않음
|
||||
result = {"reason": NO_BROWSER}
|
||||
if result.get("reason"):
|
||||
raise PrintError(result["reason"])
|
||||
return [base64.b64decode(png) for png in result["pngs"]]
|
||||
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 몸통
|
||||
job = json.loads(sys.stdin.buffer.read())
|
||||
try:
|
||||
pngs = _shoot(job["svgs"], job["scale"])
|
||||
print(json.dumps({"reason": None, "pngs": [base64.b64encode(p).decode() for p in pngs]}))
|
||||
except PrintError as error:
|
||||
print(json.dumps({"reason": str(error)}, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""내장 엑셀 통합문서(Workbook JSON) → xlsx 바이트(요구 347-5 · PLAN 534-2).
|
||||
|
||||
계약 = `A00_Common/spreadsheet/spreadsheet_types.ts` — 서식 이름이 엑셀 이름 그대로라 1:1 로 옮김.
|
||||
값 · 식(`=` 붙여 살아 있는 수식 · 열 때 엑셀이 다시 셈) ·
|
||||
서식(글꼴 · 채움 · 맞춤 · 줄바꿈 · 테두리 · 숫자 형식) ·
|
||||
병합 · 열 폭(글자 단위) · 행 높이(pt) · 숨김 · 틀 고정 · 눈금선 · 시트 숨김 · 탭 색 ·
|
||||
인쇄(용지 · 방향 · 배율/맞춤 · 여백 · 머리 줄 반복 · 쪽 나눔) · 그림(범위 두 칸 닻).
|
||||
서식 차례는 화면과 같음(칸 → 행 → 열 → 시트 기본 → 0 · `spreadsheet_cellstyle.styleIndexAt`) ·
|
||||
안 준 맞춤 = 가운데 · 가운데(237-1, 화면 · 붙여넣기 · 인쇄 공통).
|
||||
그림 = `figures` {열쇠: PNG 바이트} · 없는 열쇠는 건너뜀.
|
||||
값 셈 없음(서버 재계산은 부른 쪽 `with_server_values`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook as XlBook
|
||||
from openpyxl.drawing.image import Image
|
||||
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import column_index_from_string, get_column_letter
|
||||
from openpyxl.worksheet.page import PageMargins
|
||||
from openpyxl.worksheet.pagebreak import Break
|
||||
|
||||
from common_util.common_util_print_pdf import sheet_title
|
||||
|
||||
A1 = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
PAPER = {"A4": 9, "A3": 8}
|
||||
MM_PER_INCH = 25.4
|
||||
|
||||
|
||||
def _rc(a1: str) -> tuple[int, int]:
|
||||
"""A1 → (행, 열) 1 부터."""
|
||||
m = A1.match(a1.upper())
|
||||
if not m:
|
||||
raise ValueError(f"칸 주소 꼴 아님 「{a1}」")
|
||||
return int(m.group(2)), column_index_from_string(m.group(1))
|
||||
|
||||
|
||||
def _span(text: str) -> tuple[int, int, int, int]:
|
||||
"""`B5:H20` · `B5` → (r0, c0, r1, c1) 1 부터."""
|
||||
a, _, b = text.partition(":")
|
||||
r0, c0 = _rc(a)
|
||||
r1, c1 = _rc(b) if b else (r0, c0)
|
||||
return min(r0, r1), min(c0, c1), max(r0, r1), max(c0, c1)
|
||||
|
||||
|
||||
def _color(hex_: str | None) -> str | None:
|
||||
return hex_.lstrip("#").upper() if hex_ else None
|
||||
|
||||
|
||||
def _style(st: dict[str, Any]) -> dict[str, Any]:
|
||||
"""서식 한 벌 → openpyxl 속성(font · fill · alignment · border · number_format)."""
|
||||
h = st.get("가로")
|
||||
side = {
|
||||
k: Side(style=v["선"], color=_color(v.get("색")) or "000000")
|
||||
for k, v in (st.get("테두리") or {}).items()
|
||||
}
|
||||
out: dict[str, Any] = {
|
||||
"font": Font(
|
||||
name=st.get("글꼴"),
|
||||
size=st.get("크기"),
|
||||
bold=st.get("굵게"),
|
||||
italic=st.get("기울임"),
|
||||
underline="single" if st.get("밑줄") else None,
|
||||
strike=st.get("취소선"),
|
||||
color=_color(st.get("글자색")),
|
||||
),
|
||||
"alignment": Alignment(
|
||||
horizontal="center" if not h or h == "general" else h,
|
||||
vertical=st.get("세로") or "center",
|
||||
wrap_text=st.get("줄바꿈"),
|
||||
),
|
||||
"border": Border(
|
||||
top=side.get("위"), bottom=side.get("아래"), left=side.get("왼"), right=side.get("오른")
|
||||
),
|
||||
}
|
||||
if st.get("채움"):
|
||||
out["fill"] = PatternFill("solid", fgColor=_color(st["채움"]))
|
||||
if st.get("형식"):
|
||||
out["number_format"] = st["형식"]
|
||||
return out
|
||||
|
||||
|
||||
def _extent(sheet: dict[str, Any]) -> tuple[int, int]:
|
||||
"""쓰는 마지막 행 · 열(1 부터) — 칸 · 병합 · 그림 닻."""
|
||||
spans = [_span(a1) for a1 in sheet.get("칸", {})]
|
||||
spans += [_span(m) for m in sheet.get("병합") or []]
|
||||
spans += [_span(f["닻"]) for f in sheet.get("그림") or []]
|
||||
return max((s[2] for s in spans), default=0), max((s[3] for s in spans), default=0)
|
||||
|
||||
|
||||
def _print(ws: Any, pr: dict[str, Any]) -> None:
|
||||
ws.page_setup.paperSize = PAPER.get(pr.get("용지") or "A4", 9)
|
||||
ws.page_setup.orientation = pr.get("방향") or "portrait"
|
||||
fit = pr.get("맞춤")
|
||||
if fit:
|
||||
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||
ws.page_setup.fitToWidth = fit.get("폭", 0)
|
||||
ws.page_setup.fitToHeight = fit.get("높이", 0)
|
||||
elif pr.get("배율"):
|
||||
ws.page_setup.scale = pr["배율"]
|
||||
m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})}
|
||||
ws.page_margins = PageMargins(
|
||||
top=m["위"] / MM_PER_INCH,
|
||||
bottom=m["아래"] / MM_PER_INCH,
|
||||
left=m["왼"] / MM_PER_INCH,
|
||||
right=m["오른"] / MM_PER_INCH,
|
||||
)
|
||||
if pr.get("머리행"):
|
||||
a, b = pr["머리행"]
|
||||
ws.print_title_rows = f"{a}:{b}"
|
||||
for k in sorted(set(pr.get("쪽나눔") or [])):
|
||||
ws.row_breaks.append(Break(id=k))
|
||||
|
||||
|
||||
def _sheet(ws: Any, book: dict[str, Any], sheet: dict[str, Any], styles: list, figures) -> None:
|
||||
cells = sheet.get("칸", {})
|
||||
rows_info = sheet.get("행") or {}
|
||||
cols_info = sheet.get("열") or {}
|
||||
base = sheet.get("기본") or {}
|
||||
n_rows, n_cols = _extent(sheet)
|
||||
for r in range(1, n_rows + 1):
|
||||
for c in range(1, n_cols + 1):
|
||||
letter = get_column_letter(c)
|
||||
cell = cells.get(f"{letter}{r}")
|
||||
idx = next(
|
||||
(
|
||||
i
|
||||
for i in (
|
||||
(cell or {}).get("서식"),
|
||||
rows_info.get(str(r), {}).get("서식"),
|
||||
cols_info.get(letter, {}).get("서식"),
|
||||
base.get("서식"),
|
||||
)
|
||||
if i is not None
|
||||
),
|
||||
0,
|
||||
)
|
||||
if cell is None and not idx:
|
||||
continue
|
||||
xl = ws.cell(row=r, column=c)
|
||||
if cell and cell.get("식") is not None:
|
||||
xl.value = "=" + cell["식"]
|
||||
elif cell and "값" in cell:
|
||||
xl.value = cell["값"]
|
||||
if isinstance(cell["값"], str) and cell["값"].startswith("="):
|
||||
xl.data_type = "s" # 글로 친 「=…」 — 수식 아님
|
||||
for attr, value in (styles[idx] if 0 <= idx < len(styles) else {}).items():
|
||||
setattr(xl, attr, value)
|
||||
for m in sheet.get("병합") or []:
|
||||
ws.merge_cells(m)
|
||||
width = base.get("열폭") or (book.get("기본") or {}).get("열폭")
|
||||
if width:
|
||||
ws.sheet_format.defaultColWidth = width
|
||||
height = base.get("행높이") or (book.get("기본") or {}).get("행높이")
|
||||
if height:
|
||||
ws.sheet_format.defaultRowHeight = height
|
||||
ws.sheet_format.customHeight = True
|
||||
for letter, info in cols_info.items():
|
||||
dim = ws.column_dimensions[letter]
|
||||
if info.get("폭") is not None:
|
||||
dim.width = info["폭"]
|
||||
dim.hidden = bool(info.get("숨김"))
|
||||
for key, info in rows_info.items():
|
||||
dim = ws.row_dimensions[int(key)]
|
||||
if info.get("높이") is not None:
|
||||
dim.height = info["높이"]
|
||||
dim.hidden = bool(info.get("숨김") or info.get("걸러짐"))
|
||||
frozen = sheet.get("틀고정") or {}
|
||||
if frozen.get("행") or frozen.get("열"):
|
||||
ws.freeze_panes = ws.cell(
|
||||
row=(frozen.get("행") or 0) + 1, column=(frozen.get("열") or 0) + 1
|
||||
)
|
||||
if (sheet.get("보기") or {}).get("눈금선") is False:
|
||||
ws.sheet_view.showGridLines = False
|
||||
if sheet.get("숨김"):
|
||||
ws.sheet_state = "hidden"
|
||||
if sheet.get("탭색"):
|
||||
ws.sheet_properties.tabColor = _color(sheet["탭색"])
|
||||
_print(ws, sheet.get("인쇄") or {})
|
||||
for fig in sheet.get("그림") or []:
|
||||
png = figures.get(fig["열쇠"])
|
||||
if not png:
|
||||
continue
|
||||
r0, c0, r1, c1 = _span(fig["닻"])
|
||||
img = Image(io.BytesIO(png))
|
||||
img.anchor = TwoCellAnchor(
|
||||
editAs="twoCell",
|
||||
_from=AnchorMarker(col=c0 - 1, row=r0 - 1),
|
||||
to=AnchorMarker(col=c1, row=r1),
|
||||
)
|
||||
ws.add_image(img)
|
||||
|
||||
|
||||
def workbook_to_xlsx(book: dict[str, Any], figures: dict[str, bytes] | None = None) -> bytes:
|
||||
"""통합문서 → xlsx 바이트 — 시트 차례 그대로 · 열 때 활성 시트 = `활성`."""
|
||||
xl = XlBook()
|
||||
xl.remove(xl.active)
|
||||
styles = [_style(st or {}) for st in book.get("서식") or [{}]]
|
||||
used: set[str] = set()
|
||||
active = 0
|
||||
for i, sheet in enumerate(book.get("시트") or []):
|
||||
ws = xl.create_sheet(sheet_title(sheet.get("이름") or "시트", used))
|
||||
_sheet(ws, book, sheet, styles, figures or {})
|
||||
if sheet.get("id") == book.get("활성"):
|
||||
active = i
|
||||
if not xl.worksheets:
|
||||
xl.create_sheet("시트")
|
||||
xl.active = active
|
||||
xl.calculation.fullCalcOnLoad = True # 식 값은 엑셀이 열 때 셈(캐시 값 없음)
|
||||
out = io.BytesIO()
|
||||
xl.save(out)
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""534-2 — 내장 엑셀 내보내기: 인쇄 HTML(Node 번들 `printHtml`) · xlsx 되읽기 · SVG → PNG · PDF 쪽 수.
|
||||
|
||||
책 하나(시트 둘 · 병합 · 열 폭 · 숨긴 열 · 머리 행 · 쪽 나눔 둘 · 그림 · 식)로 셋을 함께 봄.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_print_pdf import PrintError
|
||||
from common_util.common_util_svg_png import svgs_to_pngs
|
||||
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60">'
|
||||
'<rect width="120" height="60" fill="#0a0"/></svg>'
|
||||
)
|
||||
BREAKS = [6, 12]
|
||||
|
||||
|
||||
def _book() -> dict:
|
||||
cells = {"A1": {"값": "제목", "서식": 1}, "A2": {"값": "측점", "서식": 2}, "B2": {"값": "Fs"}}
|
||||
for r in range(3, 31):
|
||||
cells[f"A{r}"] = {"값": f"No.{r}"}
|
||||
cells[f"B{r}"] = {"값": r / 10, "서식": 3}
|
||||
cells["C3"] = {"식": "SUM(B3:B30)", "서식": 3}
|
||||
cells["D3"] = {"값": "숨김"}
|
||||
return {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "stab_report",
|
||||
"서식": [
|
||||
{},
|
||||
{"크기": 14, "굵게": True},
|
||||
{"채움": "#EEEEEE", "테두리": {"아래": {"선": "medium"}}},
|
||||
{"형식": "0.00"},
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "측점표",
|
||||
"칸": cells,
|
||||
"병합": ["A1:C1", "E5:E8"],
|
||||
"열": {"A": {"폭": 12}, "B": {"폭": 6}, "D": {"숨김": True}},
|
||||
"행": {"1": {"높이": 24}},
|
||||
"틀고정": {"행": 2},
|
||||
"인쇄": {
|
||||
"방향": "landscape",
|
||||
"맞춤": {"폭": 1, "높이": 0},
|
||||
"머리행": [2, 2],
|
||||
"쪽나눔": BREAKS,
|
||||
},
|
||||
"그림": [{"열쇠": "fig/a.png", "닻": "F3:H10", "이름": "단면"}],
|
||||
},
|
||||
{"id": "s2", "이름": "숨김", "칸": {"A1": {"값": 1}}, "숨김": True},
|
||||
],
|
||||
"활성": "s1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def png() -> bytes:
|
||||
try:
|
||||
return svgs_to_pngs([SVG])[0]
|
||||
except PrintError as error:
|
||||
pytest.skip(f"브라우저 없음 — {error}")
|
||||
|
||||
|
||||
def test_svg_png_크기(png):
|
||||
from PIL import Image
|
||||
|
||||
assert Image.open(io.BytesIO(png)).size == (240, 120) # scale 2
|
||||
|
||||
|
||||
def test_인쇄_html(png):
|
||||
html = spreadsheet.print_htmls([{"book": _book(), "figures": {"fig/a.png": png}}])[0]
|
||||
sheets = re.findall(r'<section class="sheet" data-sheet="(\w+)"', html)
|
||||
assert sheets == ["s1"] # 숨긴 시트 안 찍음
|
||||
chunks = html.split('<div class="chunk">')[1:]
|
||||
assert len(chunks) == len(BREAKS) + 1
|
||||
for chunk in chunks: # 덩이마다 머리 행 한 줄
|
||||
thead = re.search(r"<thead>(.*?)</thead>", chunk).group(1)
|
||||
assert thead.count("<tr") == 1 and "측점" in thead
|
||||
# 열 폭 = 화면 px 환산(글자 × 7 + 5) · 숨긴 D 빠짐(A B C E F G H = 7)
|
||||
widths = re.findall(r'<col style="width:(\d+)px">', chunks[0])
|
||||
assert widths[:3] == ["89", "47", "64"] and len(widths) == 7 * 2 # 앞 표(제목) + 본 표
|
||||
# 병합 A1:C1 · E5:E8 = 쪽 나눔(6 아래)에서 잘림 — 첫 덩이 5~6 · 둘째 덩이 7~8
|
||||
assert 'colspan="3"' in chunks[0] and 'rowspan="2"' in chunks[0] and 'rowspan="2"' in chunks[1]
|
||||
assert "rowspan" not in chunks[2]
|
||||
assert html.count("<img ") == 1 and "data:image/png;base64," in html
|
||||
assert "@page p0{size:A4 landscape" in html
|
||||
assert ">46.20<" in html # 식 값 = 화면 엔진 · 형식 0.00(SUM 0.3..3.0)
|
||||
|
||||
|
||||
def test_xlsx_되읽기(png):
|
||||
data = workbook_to_xlsx(_book(), {"fig/a.png": png})
|
||||
wb = load_workbook(io.BytesIO(data))
|
||||
assert wb.sheetnames == ["측점표", "숨김"]
|
||||
ws = wb["측점표"]
|
||||
assert sorted(str(m) for m in ws.merged_cells.ranges) == ["A1:C1", "E5:E8"]
|
||||
assert [b.id for b in ws.row_breaks.brk] == BREAKS
|
||||
assert len(ws._images) == 1
|
||||
assert ws.print_title_rows == "$2:$2"
|
||||
assert ws["C3"].value == "=SUM(B3:B30)"
|
||||
assert ws["B3"].number_format == "0.00" and ws["A1"].font.b
|
||||
assert ws.column_dimensions["A"].width == 12 and ws.column_dimensions["D"].hidden
|
||||
assert ws.row_dimensions[1].height == 24 and ws.freeze_panes == "A3"
|
||||
assert ws.page_setup.orientation == "landscape" and ws.page_setup.fitToWidth == 1
|
||||
assert wb["숨김"].sheet_state == "hidden"
|
||||
|
||||
|
||||
def test_pdf_쪽수(png):
|
||||
book = _book()
|
||||
try:
|
||||
pdf = spreadsheet.workbook_pdf(book, {"fig/a.png": png})
|
||||
except PrintError as error:
|
||||
pytest.skip(f"브라우저 없음 — {error}")
|
||||
from pypdf import PdfReader
|
||||
|
||||
pages = PdfReader(io.BytesIO(pdf)).pages
|
||||
assert len(pages) == len(BREAKS) + 1
|
||||
assert all(float(p.mediabox.width) > float(p.mediabox.height) for p in pages) # 가로
|
||||
assert all("측점" in p.extract_text() for p in pages) # 머리 행 쪽마다
|
||||
Reference in New Issue
Block a user