Word 변환 WYSIWYG 개선 - 위치/크기/줄바꿈/가로배치 지원
This commit is contained in:
@@ -13,21 +13,6 @@ import { Printer, FileDown, FileText } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
// @ts-ignore - docx 라이브러리 타입 이슈
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
WidthType,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
VerticalAlign,
|
||||
convertInchesToTwip,
|
||||
} from "docx";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
interface ReportPreviewModalProps {
|
||||
@@ -282,270 +267,93 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||
});
|
||||
};
|
||||
|
||||
// Base64를 Uint8Array로 변환
|
||||
const base64ToUint8Array = (base64: string): Uint8Array => {
|
||||
const base64Data = base64.split(",")[1] || base64;
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
// 이미지 URL을 Base64로 변환
|
||||
const imageUrlToBase64 = async (url: string): Promise<string> => {
|
||||
try {
|
||||
// 이미 Base64인 경우 그대로 반환
|
||||
if (url.startsWith("data:")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 컴포넌트를 TableCell로 변환
|
||||
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
|
||||
const cellWidth = widthPercent || 100;
|
||||
// 서버 이미지 URL을 fetch하여 Base64로 변환
|
||||
const fullUrl = getFullImageUrl(url);
|
||||
const response = await fetch(fullUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const value = getComponentValue(component);
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: value,
|
||||
size: (component.fontSize || 13) * 2,
|
||||
color: component.fontColor?.replace("#", "") || "000000",
|
||||
bold: component.fontWeight === "bold",
|
||||
}),
|
||||
],
|
||||
alignment:
|
||||
component.textAlign === "center"
|
||||
? AlignmentType.CENTER
|
||||
: component.textAlign === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} else if (component.type === "signature" || component.type === "stamp") {
|
||||
if (component.imageUrl) {
|
||||
try {
|
||||
const imageData = base64ToUint8Array(component.imageUrl);
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageData,
|
||||
transformation: {
|
||||
width: component.width || 150,
|
||||
height: component.height || 50,
|
||||
},
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
|
||||
size: 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (component.type === "table" && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
const headerCells = queryResult.fields.map(
|
||||
(field) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: field })],
|
||||
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
|
||||
}),
|
||||
);
|
||||
|
||||
const dataRows = queryResult.rows.map(
|
||||
(row) =>
|
||||
new TableRow({
|
||||
children: queryResult.fields.map(
|
||||
(field) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: String(row[field] ?? "") })],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const table = new Table({
|
||||
rows: [new TableRow({ children: headerCells }), ...dataRows],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
});
|
||||
|
||||
return new TableCell({
|
||||
children: [table],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이미지 변환 실패:", error);
|
||||
return "";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// WORD 다운로드
|
||||
// WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
|
||||
const handleDownloadWord = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// 페이지별로 섹션 생성
|
||||
const sections = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => {
|
||||
// 페이지 크기 설정 (A4 기준)
|
||||
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
|
||||
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
|
||||
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
|
||||
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
|
||||
const marginLeft = convertInchesToTwip(page.margins.left / 96);
|
||||
const marginRight = convertInchesToTwip(page.margins.right / 96);
|
||||
|
||||
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
|
||||
const sortedComponents = [...page.components].sort((a, b) => {
|
||||
// Y좌표 우선, 같으면 X좌표
|
||||
if (Math.abs(a.y - b.y) < 5) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
|
||||
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
|
||||
const rowTolerance = 20; // Y 좌표 허용 오차
|
||||
|
||||
for (const component of sortedComponents) {
|
||||
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
|
||||
if (existingRow) {
|
||||
existingRow.push(component);
|
||||
} else {
|
||||
rows.push([component]);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 행 내에서 X좌표로 정렬
|
||||
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
|
||||
|
||||
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
|
||||
const tableRows: TableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.length === 1) {
|
||||
// 단일 컴포넌트 - 전체 너비 사용
|
||||
const component = row[0];
|
||||
const cell = createTableCell(component, pageWidth);
|
||||
if (cell) {
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: [cell],
|
||||
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 여러 컴포넌트 - 가로 배치
|
||||
const cells: TableCell[] = [];
|
||||
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
for (const component of row) {
|
||||
const widthPercent = (component.width / totalWidth) * 100;
|
||||
const cell = createTableCell(component, pageWidth, widthPercent);
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const maxHeight = Math.max(...row.map((c) => c.height));
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: cells,
|
||||
height: { value: maxHeight * 15, rule: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
properties: {
|
||||
page: {
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
margin: {
|
||||
top: marginTop,
|
||||
bottom: marginBottom,
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
},
|
||||
},
|
||||
},
|
||||
children:
|
||||
tableRows.length > 0
|
||||
? [
|
||||
new Table({
|
||||
rows: tableRows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [new Paragraph({ text: "" })],
|
||||
};
|
||||
});
|
||||
|
||||
// 문서 생성
|
||||
const doc = new Document({
|
||||
sections,
|
||||
toast({
|
||||
title: "처리 중",
|
||||
description: "WORD 파일을 생성하고 있습니다...",
|
||||
});
|
||||
|
||||
// Blob 생성 및 다운로드
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
|
||||
const pagesWithBase64 = await Promise.all(
|
||||
layoutConfig.pages.map(async (page) => {
|
||||
const componentsWithBase64 = await Promise.all(
|
||||
page.components.map(async (component) => {
|
||||
// 이미지가 있는 컴포넌트는 Base64로 변환
|
||||
if (component.imageUrl) {
|
||||
try {
|
||||
const base64 = await imageUrlToBase64(component.imageUrl);
|
||||
return { ...component, imageBase64: base64 };
|
||||
} catch {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
return { ...page, components: componentsWithBase64 };
|
||||
}),
|
||||
);
|
||||
|
||||
// 쿼리 결과 수집
|
||||
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
|
||||
for (const page of layoutConfig.pages) {
|
||||
for (const component of page.components) {
|
||||
if (component.queryId) {
|
||||
const result = getQueryResult(component.queryId);
|
||||
if (result) {
|
||||
queryResults[component.queryId] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
||||
|
||||
// 백엔드 API 호출 (컴포넌트 데이터 전송)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.post(
|
||||
"/admin/reports/export-word",
|
||||
{
|
||||
layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
|
||||
queryResults,
|
||||
fileName,
|
||||
},
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
// Blob 다운로드
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
});
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -558,6 +366,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||
description: "WORD 파일이 다운로드되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("WORD 변환 오류:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
|
||||
toast({
|
||||
title: "오류",
|
||||
|
||||
Reference in New Issue
Block a user