diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 25e3ed4..fe7c3cd 100755
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,6 +25,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
+ "jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
diff --git a/frontend/package.json b/frontend/package.json
index a439898..7014a82 100755
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -27,6 +27,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
+ "jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx
index 52af3a5..014c1a6 100755
--- a/frontend/src/tabs/reports/components/ReportsView.tsx
+++ b/frontend/src/tabs/reports/components/ReportsView.tsx
@@ -16,7 +16,8 @@ import {
analysisCatColors,
inferAnalysisCategory,
type ViewState,
-} from './reportUtils'
+} from './reportUtils';
+import type { TemplateType } from './reportTypes';
import TemplateFormEditor from './TemplateFormEditor'
import ReportGenerator from './ReportGenerator'
@@ -296,7 +297,7 @@ export function ReportsView() {
diff --git a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx
index 9ac4401..b3bf134 100644
--- a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx
+++ b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx
@@ -67,15 +67,14 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
}
const doExport = (format: 'pdf' | 'hwp') => {
- const html = generateReportHTML(
- template.label,
- { writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction },
- template.sections,
- getVal
- )
+ const meta = { writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction }
const filename = formData['incident.name'] || `${template.label}_${reportMeta.writeTime.replace(/[\s:]/g, '_')}`
- if (format === 'pdf') exportAsPDF(html, filename)
- else exportAsHWP(html, filename)
+ if (format === 'pdf') {
+ const html = generateReportHTML(template.label, meta, template.sections, getVal)
+ exportAsPDF(html, filename)
+ } else {
+ exportAsHWP(template.label, meta, template.sections, getVal, filename)
+ }
}
return (
@@ -185,7 +184,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
-
+
/g, '>')
+ .replace(/"/g, '"');
+}
+
+// βββ ID μμ± βββββββββββββββββββββββββββββββββββββββββββββββββ
+let _idSeq = 1000000000;
+function nextId(): number {
+ return _idSeq++;
+}
+
+// βββ νμ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+interface ReportSection {
+ title: string;
+ fields: { key: string; label: string }[];
+}
+
+interface ReportMeta {
+ writeTime: string;
+ author: string;
+ jurisdiction: string;
+}
+
+// βββ μ μ νμΌ (Skeleton.hwpx κΈ°λ°) βββββββββββββββββββββββββ
+
+/**
+ * mimetype: STORE (무μμΆ)λ‘ μΆκ°ν΄μΌ ν¨
+ */
+const MIMETYPE = 'application/hwp+zip';
+
+/**
+ * version.xml: Skeleton.hwpx κ·Έλλ‘
+ */
+const VERSION_XML =
+ '' +
+ '';
+
+/**
+ * settings.xml: Skeleton.hwpx κ·Έλλ‘
+ */
+const SETTINGS_XML =
+ '' +
+ '' +
+ '' +
+ '';
+
+/**
+ * META-INF/container.xml: Skeleton.hwpx κ·Έλλ‘
+ */
+const CONTAINER_XML =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+
+/**
+ * META-INF/container.rdf: Skeleton.hwpx κ·Έλλ‘
+ */
+const CONTAINER_RDF =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+
+/**
+ * META-INF/manifest.xml: Skeleton.hwpx κ·Έλλ‘
+ */
+const MANIFEST_XML =
+ '' +
+ '';
+
+/**
+ * Contents/content.hpf: Skeleton.hwpx κΈ°λ°, λ μ§λ§ λμ μμ±
+ */
+function buildContentHpf(): string {
+ const now = new Date().toISOString();
+ return (
+ '' +
+ '' +
+ '' +
+ '' +
+ 'ko' +
+ 'WING-OPS' +
+ '' +
+ '' +
+ 'WING-OPS' +
+ `${now}` +
+ `${now}` +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ''
+ );
+}
+
+/**
+ * Contents/header.xml: Skeleton.hwpx κ·Έλλ‘ (μμ 볡μ¬)
+ * charPr μ μ:
+ * id=0: 10pt, κ²μ (κΈ°λ³Έ λ³Έλ¬Έ)
+ * id=1: 10pt, κ²μ (λ°ν체 λ³ν - νμ¬ λμΌ)
+ * id=2: 9pt, κ²μ
+ * id=3: 9pt, κ²μ (ν¨μ΄λ‘¬λμ)
+ * id=4: 9pt, κ²μ (μκ° -5)
+ * id=5: 16pt, νλμ (#2E74B5)
+ * id=6: 11pt, κ²μ
+ */
+const HEADER_XML =
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ ν°νΈ (ν¨μ΄λ‘¬λμ/ν¨μ΄λ‘¬λ°ν, 7κ° μΈμ΄ κ·Έλ£Ή)
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ borderFill
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ charProperties (Skeleton 7κ° κ·Έλλ‘)
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // charPr id=5: 16pt νλμ (μ λͺ©μ©μΌλ‘ νμ©)
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // charPr id=6: 11pt κ²μ
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ tabProperties
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ numberings (Skeleton κ·Έλλ‘)
+ '' +
+ '' +
+ '^1.' +
+ '^2.' +
+ '^3)' +
+ '^4)' +
+ '(^5)' +
+ '(^6)' +
+ '^7' +
+ '^8' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ paraProperties (Skeleton id=0λ§ μ¬μ©)
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ // ββ styles (Skeleton λ°νκΈ μ€νμΌλ§)
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '{"name":""}' +
+ '' +
+ '';
+
+// βββ κ³΅ν΅ λ€μμ€νμ΄μ€ μ μΈ (section0.xml λ£¨νΈ μμ±) βββββββββ
+const SEC_NS =
+ 'xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" ' +
+ 'xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" ' +
+ 'xmlns:hp10="http://www.hancom.co.kr/hwpml/2016/paragraph" ' +
+ 'xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" ' +
+ 'xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core" ' +
+ 'xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" ' +
+ 'xmlns:hhs="http://www.hancom.co.kr/hwpml/2011/history" ' +
+ 'xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page" ' +
+ 'xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf" ' +
+ 'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
+ 'xmlns:opf="http://www.idpf.org/2007/opf/" ' +
+ 'xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart" ' +
+ 'xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar" ' +
+ 'xmlns:epub="http://www.idpf.org/2007/ops" ' +
+ 'xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"';
+
+// βββ section0.xml λ³Έλ¬Έ λΉλ ββββββββββββββββββββββββββββββββββ
+
+/**
+ * 첫 λ²μ§Έ λ¨λ½: secPr (νμ΄μ§ μμ±) + colPr ν¬ν¨
+ * Skeleton.hwpxμ 첫 λ¨λ½μ κ·Έλλ‘ μ¬ν
+ */
+function buildSecPrParagraph(): string {
+ const id = nextId();
+ return (
+ `` +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ''
+ );
+}
+
+/**
+ * ν
μ€νΈ λ¨λ½ μμ± (charPrIDRef=0 κΈ°λ³Έ 10pt)
+ */
+function buildPara(text: string, charPrId = 0): string {
+ const id = nextId();
+ return (
+ `` +
+ `` +
+ `${esc(text)}` +
+ '' +
+ ''
+ );
+}
+
+/**
+ * λΉ λ¨λ½ (μ€λ°κΏμ©)
+ */
+function buildEmptyPara(): string {
+ const id = nextId();
+ return (
+ `` +
+ '' +
+ ''
+ );
+}
+
+/**
+ * ν
μ΄λΈ μ
λ΄ λ¨λ½ (subList λ΄λΆμ©)
+ */
+function buildCellPara(text: string): string {
+ const id = nextId();
+ return (
+ `` +
+ '' +
+ `${esc(text)}` +
+ '' +
+ ''
+ );
+}
+
+/**
+ * ν
μ΄λΈ μ
μμ±
+ * OWPML μ€ν: μμ μμ: subList β cellAddr β cellSpan β cellSz β cellMargin
+ */
+function buildCell(
+ text: string,
+ colAddr: number,
+ rowAddr: number,
+ colSpan: number,
+ rowSpan: number,
+ width: number,
+ isHeader = false,
+): string {
+ return (
+ `` +
+ `` +
+ buildCellPara(text) +
+ '' +
+ `` +
+ `` +
+ `` +
+ '' +
+ ''
+ );
+}
+
+/**
+ * 2μ΄ ν
μ΄λΈ (λΌλ²¨ | κ°) ꡬ쑰λ₯Ό κ°μ§ μΉμ
ν
μ΄λΈ μμ±
+ * A4 컨ν
μΈ λλΉ 42520 HWPUNIT κΈ°μ€:
+ * λΌλ²¨ μ΄: 33% = 14032, κ° μ΄: 67% = 28488
+ */
+const CONTENT_WIDTH = 42520;
+const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
+const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH;
+
+function buildFieldTable(
+ fields: { key: string; label: string }[],
+ getVal: (key: string) => string,
+): string {
+ const rowCnt = fields.length;
+ if (rowCnt === 0) return '';
+
+ let rows = '';
+ fields.forEach((field, rowIdx) => {
+ const value = getVal(field.key) || '-';
+ if (field.label) {
+ // 2μ΄: λΌλ²¨ | κ°
+ rows +=
+ '' +
+ buildCell(field.label, 0, rowIdx, 1, 1, LABEL_WIDTH) +
+ buildCell(value, 1, rowIdx, 1, 1, VALUE_WIDTH) +
+ '';
+ } else {
+ // 1μ΄ μ 체 λλΉ (textarea λ₯)
+ rows +=
+ '' +
+ buildCell(value, 0, rowIdx, 2, 1, CONTENT_WIDTH) +
+ '';
+ }
+ });
+
+ const colCnt = 2;
+ const pId = nextId();
+ const tblId = nextId();
+ // ν
μ΄λΈ λμ΄ μΆμ : κ° ν 564 HWPUNIT
+ const tblHeight = rowCnt * 564;
+
+ // ν
μ΄λΈμ > μμ κ°μΈμΌ ν¨
+ // OWPML μ€ν: hp:tbl μμ± + μμ(sz, pos, outMargin, inMargin) ν ν
+ return (
+ `` +
+ '' +
+ `` +
+ `` +
+ '' +
+ '' +
+ '' +
+ rows +
+ '' +
+ '' +
+ ''
+ );
+}
+
+/**
+ * section0.xml μ 체 μμ±
+ */
+function buildSection0Xml(
+ templateLabel: string,
+ meta: ReportMeta,
+ sections: ReportSection[],
+ getVal: (key: string) => string,
+): string {
+ // ID μνμ€ μ΄κΈ°ν (μ¬μ¬μ© μ μΆ©λ λ°©μ§)
+ _idSeq = 1000000000;
+
+ let body = '';
+
+ // 1) 첫 λ¨λ½: secPr (νμ - νμ΄μ§ μ€μ ν¬ν¨)
+ body += buildSecPrParagraph();
+
+ // 2) λ©μΈ μ λͺ© (16pt νλμ = charPrId 5)
+ body += buildPara('ν΄μμ€μΌλ°©μ μ§μμμ€ν
', 5);
+
+ // 3) λΆμ λͺ© (11pt = charPrId 6)
+ body += buildPara(templateLabel, 6);
+
+ // 4) λ©ν μ 보 (κΈ°λ³Έ 10pt = charPrId 0)
+ const metaLine =
+ `μμ±μΌμ: ${meta.writeTime}` +
+ ` | μμ±μ: ${meta.author || '-'}` +
+ ` | κ΄ν : ${meta.jurisdiction}`;
+ body += buildPara(metaLine, 0);
+
+ // 5) λΉ μ€
+ body += buildEmptyPara();
+
+ // 6) κ° μΉμ
+ for (const section of sections) {
+ // μΉμ
μ λͺ© (11pt = charPrId 6)
+ body += buildPara(section.title, 6);
+
+ // νλ ν
μ΄λΈ
+ if (section.fields.length > 0) {
+ body += buildFieldTable(section.fields, getVal);
+ }
+
+ // μΉμ
ν λΉ μ€
+ body += buildEmptyPara();
+ }
+
+ return (
+ '' +
+ `` +
+ body +
+ ''
+ );
+}
+
+/**
+ * Preview/PrvText.txt μμ± (μΌλ° ν
μ€νΈ 미리보기)
+ */
+function buildPrvText(
+ templateLabel: string,
+ meta: ReportMeta,
+ sections: ReportSection[],
+ getVal: (key: string) => string,
+): string {
+ const lines: string[] = [];
+ lines.push('ν΄μμ€μΌλ°©μ μ§μμμ€ν
');
+ lines.push(templateLabel);
+ lines.push(`μμ±μΌμ: ${meta.writeTime} | μμ±μ: ${meta.author || '-'} | κ΄ν : ${meta.jurisdiction}`);
+ lines.push('');
+
+ for (const section of sections) {
+ lines.push(`[${section.title}]`);
+ for (const field of section.fields) {
+ const value = getVal(field.key) || '-';
+ if (field.label) {
+ lines.push(` ${field.label}: ${value}`);
+ } else {
+ lines.push(` ${value}`);
+ }
+ }
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+// βββ λ©μΈ Export ν¨μ ββββββββββββββββββββββββββββββββββββββββ
+
+export async function exportAsHWPX(
+ templateLabel: string,
+ meta: ReportMeta,
+ sections: ReportSection[],
+ getVal: (key: string) => string,
+ filename: string,
+): Promise {
+ const zip = new JSZip();
+
+ // mimetype: STORE (무μμΆ) + 첫 λ²μ§Έ νλͺ©
+ zip.file('mimetype', MIMETYPE, { compression: 'STORE' });
+
+ // μ μ λ©ν νμΌ
+ zip.file('version.xml', VERSION_XML);
+ zip.file('settings.xml', SETTINGS_XML);
+ zip.file('META-INF/container.xml', CONTAINER_XML);
+ zip.file('META-INF/container.rdf', CONTAINER_RDF);
+ zip.file('META-INF/manifest.xml', MANIFEST_XML);
+
+ // Contents
+ zip.file('Contents/content.hpf', buildContentHpf());
+ zip.file('Contents/header.xml', HEADER_XML);
+ zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal));
+
+ // Preview
+ zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal));
+
+ // ZIP μμ± λ° λ€μ΄λ‘λ
+ const blob = await zip.generateAsync({
+ type: 'blob',
+ mimeType: 'application/hwp+zip',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 6 },
+ });
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${filename}.hwpx`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/src/tabs/reports/components/reportUtils.ts b/frontend/src/tabs/reports/components/reportUtils.ts
index 347a185..149de5f 100644
--- a/frontend/src/tabs/reports/components/reportUtils.ts
+++ b/frontend/src/tabs/reports/components/reportUtils.ts
@@ -41,16 +41,15 @@ export function exportAsPDF(html: string, _filename: string) {
setTimeout(() => URL.revokeObjectURL(url), 30000)
}
-export function exportAsHWP(html: string, filename: string) {
- const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${filename}.doc`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
+export async function exportAsHWP(
+ templateLabel: string,
+ meta: { writeTime: string; author: string; jurisdiction: string },
+ sections: { title: string; fields: { key: string; label: string }[] }[],
+ getVal: (key: string) => string,
+ filename: string,
+) {
+ const { exportAsHWPX } = await import('./hwpxExport');
+ await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
}
export type ViewState =