Word 변환 WYSIWYG 개선 - 위치/크기/줄바꿈/가로배치 지원
This commit is contained in:
@@ -12,6 +12,22 @@ import {
|
||||
} from "../types/report";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
ImageRun,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
AlignmentType,
|
||||
VerticalAlign,
|
||||
BorderStyle,
|
||||
PageOrientation,
|
||||
convertMillimetersToTwip,
|
||||
} from "docx";
|
||||
|
||||
export class ReportController {
|
||||
/**
|
||||
@@ -534,6 +550,739 @@ export class ReportController {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 데이터를 WORD(DOCX)로 변환
|
||||
* POST /api/admin/reports/export-word
|
||||
*/
|
||||
async exportToWord(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
||||
|
||||
if (!layoutConfig || !layoutConfig.pages) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 데이터가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// mm를 twip으로 변환
|
||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||
// px를 twip으로 변환 (1px = 15twip at 96DPI)
|
||||
const pxToTwip = (px: number) => Math.round(px * 15);
|
||||
|
||||
// 쿼리 결과 맵
|
||||
const queryResultsMap: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> =
|
||||
queryResults || {};
|
||||
|
||||
// 컴포넌트 값 가져오기
|
||||
const getComponentValue = (component: any): string => {
|
||||
if (component.queryId && component.fieldName) {
|
||||
const queryResult = queryResultsMap[component.queryId];
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const value = queryResult.rows[0][component.fieldName];
|
||||
if (value !== null && value !== undefined) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return `{${component.fieldName}}`;
|
||||
}
|
||||
return component.defaultValue || "";
|
||||
};
|
||||
|
||||
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
|
||||
// px * 0.75 * 2 = px * 1.5
|
||||
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
||||
|
||||
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
|
||||
const createCellContent = (
|
||||
component: any,
|
||||
displayValue: string,
|
||||
pxToHalfPtFn: (px: number) => number,
|
||||
pxToTwipFn: (px: number) => number,
|
||||
queryResultsMapRef: Record<string, { fields: string[]; rows: Record<string, unknown>[] }>,
|
||||
AlignmentTypeRef: typeof AlignmentType,
|
||||
VerticalAlignRef: typeof VerticalAlign,
|
||||
BorderStyleRef: typeof BorderStyle,
|
||||
ParagraphRef: typeof Paragraph,
|
||||
TextRunRef: typeof TextRun,
|
||||
ImageRunRef: typeof ImageRun,
|
||||
TableRef: typeof Table,
|
||||
TableRowRef: typeof TableRow,
|
||||
TableCellRef: typeof TableCell
|
||||
): (Paragraph | Table)[] => {
|
||||
const result: (Paragraph | Table)[] = [];
|
||||
|
||||
// Text/Label
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13);
|
||||
const alignment =
|
||||
component.textAlign === "center"
|
||||
? AlignmentTypeRef.CENTER
|
||||
: component.textAlign === "right"
|
||||
? AlignmentTypeRef.RIGHT
|
||||
: AlignmentTypeRef.LEFT;
|
||||
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
alignment,
|
||||
children: [
|
||||
new TextRunRef({
|
||||
text: displayValue,
|
||||
size: fontSizeHalfPt,
|
||||
color: (component.fontColor || "#000000").replace("#", ""),
|
||||
bold: component.fontWeight === "bold" || component.fontWeight === "600",
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Image
|
||||
else if (component.type === "image" && component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
new ImageRunRef({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(component.width * 0.75),
|
||||
height: Math.round(component.height * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
result.push(new ParagraphRef({ children: [] }));
|
||||
}
|
||||
}
|
||||
|
||||
// Signature
|
||||
else if (component.type === "signature") {
|
||||
const sigFontSize = pxToHalfPtFn(component.fontSize || 12);
|
||||
const textRuns: TextRun[] = [];
|
||||
if (component.showLabel !== false) {
|
||||
textRuns.push(new TextRunRef({ text: (component.labelText || "서명:") + " ", size: sigFontSize, font: "맑은 고딕" }));
|
||||
}
|
||||
if (component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
...textRuns,
|
||||
new ImageRunRef({
|
||||
data: imageBuffer,
|
||||
transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75) },
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" }));
|
||||
result.push(new ParagraphRef({ children: textRuns }));
|
||||
}
|
||||
} else {
|
||||
textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" }));
|
||||
result.push(new ParagraphRef({ children: textRuns }));
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp
|
||||
else if (component.type === "stamp") {
|
||||
const stampFontSize = pxToHalfPtFn(component.fontSize || 12);
|
||||
const textRuns: TextRun[] = [];
|
||||
if (component.personName) {
|
||||
textRuns.push(new TextRunRef({ text: component.personName + " ", size: stampFontSize, font: "맑은 고딕" }));
|
||||
}
|
||||
if (component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
...textRuns,
|
||||
new ImageRunRef({
|
||||
data: imageBuffer,
|
||||
transformation: { width: Math.round(Math.min(component.width, component.height) * 0.75), height: Math.round(Math.min(component.width, component.height) * 0.75) },
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" }));
|
||||
result.push(new ParagraphRef({ children: textRuns }));
|
||||
}
|
||||
} else {
|
||||
textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" }));
|
||||
result.push(new ParagraphRef({ children: textRuns }));
|
||||
}
|
||||
}
|
||||
|
||||
// Divider - 테이블 셀로 감싸서 정확한 너비 적용
|
||||
else if (component.type === "divider" && component.orientation === "horizontal") {
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
border: {
|
||||
bottom: {
|
||||
color: (component.lineColor || "#000000").replace("#", ""),
|
||||
space: 1,
|
||||
style: BorderStyleRef.SINGLE,
|
||||
size: (component.lineWidth || 1) * 8,
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 (빈 paragraph)
|
||||
else {
|
||||
result.push(new ParagraphRef({ children: [] }));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 섹션 생성 (페이지별)
|
||||
const sections = layoutConfig.pages
|
||||
.sort((a: any, b: any) => a.page_order - b.page_order)
|
||||
.map((page: any) => {
|
||||
const pageWidthTwip = mmToTwip(page.width);
|
||||
const pageHeightTwip = mmToTwip(page.height);
|
||||
const marginTopMm = page.margins?.top || 10;
|
||||
const marginBottomMm = page.margins?.bottom || 10;
|
||||
const marginLeftMm = page.margins?.left || 10;
|
||||
const marginRightMm = page.margins?.right || 10;
|
||||
|
||||
const marginTop = mmToTwip(marginTopMm);
|
||||
const marginBottom = mmToTwip(marginBottomMm);
|
||||
const marginLeft = mmToTwip(marginLeftMm);
|
||||
const marginRight = mmToTwip(marginRightMm);
|
||||
|
||||
// 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI)
|
||||
const marginLeftPx = marginLeftMm * 3.78;
|
||||
const marginTopPx = marginTopMm * 3.78;
|
||||
|
||||
// 컴포넌트를 Y좌표순으로 정렬
|
||||
const sortedComponents = [...(page.components || [])].sort(
|
||||
(a: any, b: any) => a.y - b.y
|
||||
);
|
||||
|
||||
// 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화
|
||||
const Y_GROUP_THRESHOLD = 30; // px
|
||||
const componentGroups: any[][] = [];
|
||||
let currentGroup: any[] = [];
|
||||
let groupBaseY = -Infinity;
|
||||
|
||||
for (const comp of sortedComponents) {
|
||||
const compY = comp.y - marginTopPx;
|
||||
if (currentGroup.length === 0) {
|
||||
currentGroup.push(comp);
|
||||
groupBaseY = compY;
|
||||
} else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) {
|
||||
currentGroup.push(comp);
|
||||
} else {
|
||||
componentGroups.push(currentGroup);
|
||||
currentGroup = [comp];
|
||||
groupBaseY = compY;
|
||||
}
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
componentGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
// 컴포넌트를 Paragraph/Table로 변환
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
|
||||
// Y좌표를 spacing으로 변환하기 위한 추적 변수
|
||||
let lastBottomY = 0;
|
||||
|
||||
// 각 그룹 처리
|
||||
for (const group of componentGroups) {
|
||||
// 그룹 내 컴포넌트들을 X좌표 순으로 정렬
|
||||
const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x);
|
||||
|
||||
// 그룹의 Y 좌표 (첫 번째 컴포넌트 기준)
|
||||
const groupY = Math.max(0, sortedGroup[0].y - marginTopPx);
|
||||
const groupHeight = Math.max(...sortedGroup.map((c: any) => c.height));
|
||||
|
||||
// spacing 계산
|
||||
const gapFromPrevious = Math.max(0, groupY - lastBottomY);
|
||||
const spacingBefore = pxToTwip(gapFromPrevious);
|
||||
|
||||
// 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치
|
||||
if (sortedGroup.length > 1) {
|
||||
// spacing을 위한 빈 paragraph
|
||||
if (spacingBefore > 0) {
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] }));
|
||||
}
|
||||
|
||||
// 각 컴포넌트를 셀로 변환
|
||||
const cells: TableCell[] = [];
|
||||
let prevEndX = 0;
|
||||
|
||||
for (const component of sortedGroup) {
|
||||
const adjustedX = Math.max(0, component.x - marginLeftPx);
|
||||
const displayValue = getComponentValue(component);
|
||||
|
||||
// 이전 셀과의 간격을 위한 빈 셀 추가
|
||||
if (adjustedX > prevEndX + 5) {
|
||||
const gapWidth = adjustedX - prevEndX;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [] })],
|
||||
width: { size: pxToTwip(gapWidth), type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트 셀 생성
|
||||
const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell);
|
||||
cells.push(
|
||||
new TableCell({
|
||||
children: cellContent,
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
verticalAlign: VerticalAlign.TOP,
|
||||
})
|
||||
);
|
||||
prevEndX = adjustedX + component.width;
|
||||
}
|
||||
|
||||
// 테이블 행 생성
|
||||
const rowTable = new Table({
|
||||
rows: [new TableRow({ children: cells })],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
children.push(rowTable);
|
||||
lastBottomY = groupY + groupHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단일 컴포넌트 처리 (기존 로직)
|
||||
const component = sortedGroup[0];
|
||||
const displayValue = getComponentValue(component);
|
||||
const adjustedX = Math.max(0, component.x - marginLeftPx);
|
||||
const adjustedY = groupY;
|
||||
|
||||
// X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기)
|
||||
const indentLeft = pxToTwip(adjustedX);
|
||||
|
||||
// Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13);
|
||||
const alignment =
|
||||
component.textAlign === "center"
|
||||
? AlignmentType.CENTER
|
||||
: component.textAlign === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT;
|
||||
|
||||
// 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈
|
||||
const textCell = new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: displayValue,
|
||||
size: fontSizeHalfPt,
|
||||
color: (component.fontColor || "#000000").replace("#", ""),
|
||||
bold: component.fontWeight === "bold" || component.fontWeight === "600",
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
verticalAlign: VerticalAlign.TOP,
|
||||
});
|
||||
|
||||
const textTable = new Table({
|
||||
rows: [new TableRow({ children: [textCell] })],
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
indent: { size: indentLeft, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
|
||||
// spacing을 위한 빈 paragraph
|
||||
if (spacingBefore > 0) {
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] }));
|
||||
}
|
||||
children.push(textTable);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Image 컴포넌트
|
||||
else if (component.type === "image" && component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
const paragraph = new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(component.width * 0.75),
|
||||
height: Math.round(component.height * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
});
|
||||
children.push(paragraph);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
} catch (imgError) {
|
||||
console.error("이미지 처리 오류:", imgError);
|
||||
}
|
||||
}
|
||||
|
||||
// Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용
|
||||
else if (component.type === "divider") {
|
||||
if (component.orientation === "horizontal") {
|
||||
// spacing을 위한 빈 paragraph
|
||||
if (spacingBefore > 0) {
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] }));
|
||||
}
|
||||
|
||||
// 테이블 셀로 감싸서 너비 제한
|
||||
const dividerCell = new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
border: {
|
||||
bottom: {
|
||||
color: (component.lineColor || "#000000").replace("#", ""),
|
||||
space: 1,
|
||||
style: BorderStyle.SINGLE,
|
||||
size: (component.lineWidth || 1) * 8,
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
}),
|
||||
],
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
|
||||
const dividerTable = new Table({
|
||||
rows: [new TableRow({ children: [dividerCell] })],
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
indent: { size: indentLeft, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
children.push(dividerTable);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
}
|
||||
|
||||
// Signature 컴포넌트
|
||||
else if (component.type === "signature") {
|
||||
const labelText = component.labelText || "서명:";
|
||||
const showLabel = component.showLabel !== false;
|
||||
const sigFontSize = pxToHalfPt(component.fontSize || 12);
|
||||
const textRuns: TextRun[] = [];
|
||||
|
||||
if (showLabel) {
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: labelText + " ",
|
||||
size: sigFontSize,
|
||||
font: "맑은 고딕",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
const paragraph = new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
...textRuns,
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(component.width * 0.75),
|
||||
height: Math.round(component.height * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("서명 이미지 오류:", imgError);
|
||||
textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" }));
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns }));
|
||||
}
|
||||
} else {
|
||||
textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" }));
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns }));
|
||||
}
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Stamp 컴포넌트
|
||||
else if (component.type === "stamp") {
|
||||
const personName = component.personName || "";
|
||||
const stampFontSize = pxToHalfPt(component.fontSize || 12);
|
||||
const textRuns: TextRun[] = [];
|
||||
|
||||
if (personName) {
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: personName + " ",
|
||||
size: stampFontSize,
|
||||
font: "맑은 고딕",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (component.imageBase64) {
|
||||
try {
|
||||
const base64Data = component.imageBase64.split(",")[1] || component.imageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
const paragraph = new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
...textRuns,
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(Math.min(component.width, component.height) * 0.75),
|
||||
height: Math.round(Math.min(component.width, component.height) * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("도장 이미지 오류:", imgError);
|
||||
textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" }));
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns }));
|
||||
}
|
||||
} else {
|
||||
textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" }));
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns }));
|
||||
}
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Table 컴포넌트
|
||||
else if (component.type === "table" && component.queryId) {
|
||||
const queryResult = queryResultsMap[component.queryId];
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
// 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가
|
||||
if (spacingBefore > 0 || indentLeft > 0) {
|
||||
children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [] }));
|
||||
}
|
||||
|
||||
const columns =
|
||||
component.tableColumns && component.tableColumns.length > 0
|
||||
? component.tableColumns
|
||||
: queryResult.fields.map((field: string) => ({
|
||||
field,
|
||||
header: field,
|
||||
align: "left",
|
||||
width: undefined,
|
||||
}));
|
||||
|
||||
// 테이블 폰트 사이즈 (기본 12px)
|
||||
const tableFontSize = pxToHalfPt(component.fontSize || 12);
|
||||
|
||||
// 헤더 행
|
||||
const headerCells = columns.map(
|
||||
(col: { header: string; align?: string }) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment:
|
||||
col.align === "center"
|
||||
? AlignmentType.CENTER
|
||||
: col.align === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: col.header,
|
||||
bold: true,
|
||||
size: tableFontSize,
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
shading: {
|
||||
fill: (component.headerBackgroundColor || "#f3f4f6").replace("#", ""),
|
||||
},
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
})
|
||||
);
|
||||
const headerRow = new TableRow({ children: headerCells });
|
||||
|
||||
// 데이터 행
|
||||
const dataRows = queryResult.rows.map(
|
||||
(row: Record<string, unknown>) =>
|
||||
new TableRow({
|
||||
children: columns.map(
|
||||
(col: { field: string; align?: string }) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment:
|
||||
col.align === "center"
|
||||
? AlignmentType.CENTER
|
||||
: col.align === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: String(row[col.field] ?? ""),
|
||||
size: tableFontSize,
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const table = new Table({
|
||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||
indent: { size: indentLeft, type: WidthType.DXA },
|
||||
rows: [headerRow, ...dataRows],
|
||||
});
|
||||
children.push(table);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 빈 페이지 방지
|
||||
if (children.length === 0) {
|
||||
children.push(new Paragraph({ children: [] }));
|
||||
}
|
||||
|
||||
return {
|
||||
properties: {
|
||||
page: {
|
||||
size: {
|
||||
width: pageWidthTwip,
|
||||
height: pageHeightTwip,
|
||||
orientation:
|
||||
page.width > page.height ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT,
|
||||
},
|
||||
margin: {
|
||||
top: marginTop,
|
||||
bottom: marginBottom,
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
},
|
||||
},
|
||||
},
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
// Document 생성
|
||||
const doc = new Document({
|
||||
sections,
|
||||
});
|
||||
|
||||
// Buffer로 변환
|
||||
const docxBuffer = await Packer.toBuffer(doc);
|
||||
|
||||
// 파일명 인코딩 (한글 지원)
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`);
|
||||
|
||||
// DOCX 파일로 응답
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${safeFileName}`
|
||||
);
|
||||
res.setHeader("Content-Length", docxBuffer.length);
|
||||
|
||||
return res.send(docxBuffer);
|
||||
} catch (error: any) {
|
||||
console.error("WORD 변환 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "WORD 변환에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportController();
|
||||
|
||||
@@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
||||
reportController.uploadImage(req, res, next)
|
||||
);
|
||||
|
||||
// WORD(DOCX) 내보내기
|
||||
router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
|
||||
Reference in New Issue
Block a user