→ 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);
}