import JSZip from 'jszip'; // ─── XML 이스케이프 ────────────────────────────────────────── function esc(str: string): string { return str .replace(/&/g, '&') .replace(//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(extraManifestItems = ''): string { const now = new Date().toISOString(); return ( '' + '' + '' + '' + 'ko' + 'WING-OPS' + '' + '' + 'WING-OPS' + `${now}` + `${now}` + '' + '' + '' + '' + '' + extraManifestItems + '' + '' + '' + '' + '' + '' ); } /** * 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 // id=1: 기본 (테두리 없음) // id=2: 기본 + fillBrush 없음 // id=3: 테이블 값 셀 (실선 테두리) // id=4: 테이블 라벨 셀 (실선 테두리 + 배경색 #f0f4f8) '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + // id=3: 값 셀 — 실선 테두리, 배경 없음 '' + '' + '' + '' + '' + '' + '' + '' + '' + // id=4: 라벨 셀 — 실선 테두리 + 연한 파란 배경 '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + // ── 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 ( `` + '' + '' ); } /** * 이미지(인라인 그림) 단락 생성 * binDataId: hh:binData id 값, widthHwp/heightHwp: HWPUNIT 크기 */ function buildPicParagraph(binDataId: number, widthHwp: number, heightHwp: number): string { const pId = nextId(); const picId = 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, isLabel = false, ): string { // 라벨 셀: borderFill id=4 (테두리 + 배경), 값 셀: id=3 (테두리만) const bfId = isLabel ? 4 : 3; 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; /** * HTML 콘텐츠 여부 판별 (reportUtils의 __tide, __weather 등 키가 HTML 테이블 반환) */ function isHtmlContent(text: string): boolean { return text.trimStart().startsWith('<'); } /** * 파싱된 HTML Element → HWPX hp:tbl XML 변환 */ function buildHwpxFromHtmlTableElement(table: Element): string { const rows = Array.from(table.querySelectorAll('tr')); if (rows.length === 0) return ''; // 최대 열 수 산출 (colspan 고려) let colCount = 0; for (const row of rows) { let rowCols = 0; for (const cell of Array.from(row.children)) { const span = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1; rowCols += span; } if (rowCols > colCount) colCount = rowCols; } if (colCount === 0) colCount = 1; const colWidth = Math.floor(CONTENT_WIDTH / colCount); const rowCnt = rows.length; let rowsXml = ''; rows.forEach((row, rowIdx) => { let colAddr = 0; let cells = ''; Array.from(row.children).forEach((cell) => { const isLabel = cell.tagName.toLowerCase() === 'th'; const colSpan = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1; const text = ((cell as HTMLElement).textContent || '').trim(); const cellWidth = colWidth * colSpan; cells += buildCell(text, colAddr, rowIdx, colSpan, 1, cellWidth, isLabel); colAddr += colSpan; }); rowsXml += '' + cells + ''; }); const pId = nextId(); const tblId = nextId(); const tblHeight = rowCnt * 564; return ( `` + '' + `` + `` + '' + '' + '' + rowsXml + '' + '' + '' ); } /** * HTML 문자열 → HWPX XML 변환 *
→ hp:tbl,

→ buildPara, 복합 구조도 처리 */ function htmlContentToHwpx(html: string): string { // 태그 제거: HWPX 이미지는 buildPicParagraph로 별도 처리됨 // DOMParser에 대용량 base64 data URL 전달 방지 const cleanHtml = html.replace(/]*>/gi, ''); const parser = new DOMParser(); const doc = parser.parseFromString(`

${cleanHtml}
`, 'text/html'); const container = doc.body.firstElementChild; if (!container) return buildPara('-', 0); let xml = ''; for (const child of Array.from(container.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE) { const el = child as Element; const tag = el.tagName.toLowerCase(); if (tag === 'table') { xml += buildHwpxFromHtmlTableElement(el); } else { const text = ((el as HTMLElement).textContent || '').trim(); if (text) xml += buildPara(text, 0); } } else if (child.nodeType === Node.TEXT_NODE) { const text = (child.textContent || '').trim(); if (text) xml += buildPara(text, 0); } } return xml || buildPara('-', 0); } function buildFieldTable( fields: { key: string; label: string }[], getVal: (key: string) => string, ): string { const rowCnt = fields.length; if (rowCnt === 0) return ''; // 단일 필드 + 빈 label + HTML 값인 경우 → HTML→HWPX 변환 if (fields.length === 1 && !fields[0].label) { const value = getVal(fields[0].key) || '-'; if (isHtmlContent(value)) { return htmlContentToHwpx(value); } } 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, true) + buildCell(value, 1, rowIdx, 1, 1, VALUE_WIDTH, false) + ''; } else { // 1열 전체 너비 (textarea 류) rows += '' + buildCell(value, 0, rowIdx, 2, 1, CONTENT_WIDTH, false) + ''; } }); 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, imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number }, ): 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); // __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리 const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps'); const hasSensitive = section.fields.some(f => f.key === '__sensitive'); if (hasSpreadMaps && imageBinIds) { const regularFields = section.fields.filter(f => f.key !== '__spreadMaps'); if (imageBinIds.step3) { body += buildPara('3시간 후 예측', 0); body += buildPicParagraph(imageBinIds.step3, CONTENT_WIDTH, 24000); } if (imageBinIds.step6) { body += buildPara('6시간 후 예측', 0); body += buildPicParagraph(imageBinIds.step6, CONTENT_WIDTH, 24000); } if (regularFields.length > 0) { body += buildFieldTable(regularFields, getVal); } } else if (hasSensitive) { // 민감자원 분포 지도 — 테이블 앞 if (imageBinIds?.sensitiveMap) { body += buildPara('민감자원 분포 지도', 0); body += buildPicParagraph(imageBinIds.sensitiveMap, CONTENT_WIDTH, 24000); } body += buildFieldTable(section.fields, getVal); // 통합민감도 평가 지도 — 테이블 뒤 if (imageBinIds?.sensitivityMap) { body += buildPara('통합민감도 평가 지도', 0); body += buildPicParagraph(imageBinIds.sensitivityMap, CONTENT_WIDTH, 24000); } } else { // 필드 테이블 const fields = section.fields.filter(f => f.key !== '__spreadMaps'); if (hasSpreadMaps) { // 이미지 없는 경우 __spreadMaps 필드 제외하고 나머지만 출력 if (fields.length > 0) body += buildFieldTable(fields, getVal); } else 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 raw = getVal(field.key) || '-'; const value = isHtmlContent(raw) ? raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() || '-' : raw; 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, images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: 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); // 이미지 처리 let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined; let extraManifestItems = ''; let binDataListXml = ''; let binCount = 0; const processImage = (src: string, binId: number, fileId: string) => { // 실제 이미지 포맷 감지 (JPEG vs PNG) const isJpeg = src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg'); const ext = isJpeg ? 'jpg' : 'png'; const mediaType = isJpeg ? 'image/jpeg' : 'image/png'; const filePath = `BinData/image${binId}.${ext}`; const base64 = src.replace(/^data:image\/\w+;base64,/, ''); zip.file(filePath, base64, { base64: true }); extraManifestItems += ``; // inMemory="NO": 데이터는 ZIP 내 파일로 저장, 요소 내용은 파일 경로 binDataListXml += `${filePath}`; binCount++; }; if (images?.step3 || images?.step6) { imageBinIds = {}; if (images.step3) { imageBinIds.step3 = 1; processImage(images.step3, 1, 'image1'); } if (images.step6) { imageBinIds.step6 = 2; processImage(images.step6, 2, 'image2'); } } if (images?.sensitiveMap) { imageBinIds = imageBinIds ?? {}; imageBinIds.sensitiveMap = 3; processImage(images.sensitiveMap, 3, 'image3'); } if (images?.sensitivityMap) { imageBinIds = imageBinIds ?? {}; imageBinIds.sensitivityMap = 4; processImage(images.sensitivityMap, 4, 'image4'); } // header.xml: binDataList를 hh:refList 뒤에 삽입 (HWPML 스펙 준수) let headerXml = HEADER_XML; if (binCount > 0) { const binDataList = `` + binDataListXml + ''; // refList 닫힘 태그 직후에 삽입 (binDataList는 head의 직접 자식) headerXml = HEADER_XML.replace('', '' + binDataList); } // Contents zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems)); zip.file('Contents/header.xml', headerXml); zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds)); // 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); }