Loading...
Loading...
02-reusable-code-java/06-service/HwpxExportService.java
/**
* 순수 Java로 한국 공문서 HWPX(ZIP+XML) 생성 서비스 — 외부 라이브러리 불필요
*
* <p>HWPX = ZIP(XML) 포맷 — 한컴 OWPML 표준 준수.
* 민원 접수 보고서 및 사용자 매뉴얼 두 가지 유형을 생성합니다.</p>
*
* <p>HWPX 구조:</p>
* <ul>
* <li>mimetype (STORED, CRC32 필수)</li>
* <li>version.xml, settings.xml, META-INF/container.xml 등</li>
* <li>Contents/header.xml — classpath:hwpx/header.xml 로드</li>
* <li>Contents/section0.xml — 동적 XML 생성</li>
* <li>Scripts/headerScripts.js — UTF-16LE 인코딩</li>
* </ul>
*
* <p>의존:</p>
* <ul>
* <li>Spring Framework (ClassPathResource, @Service)</li>
* <li>Lombok (@Slf4j)</li>
* <li>classpath:hwpx/header.xml (프로젝트 resources에 추가 필요)</li>
* </ul>
*
* @source 260307-kcsi-smpa-internal
* @extracted 2026-03-22
* @version 1.0.0
*/
package com.example.service;
import com.example.domain.sinmungo.SinmungoPost;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* HWPX 문서 생성 서비스
*
* <p>사용법 (Controller 예시):</p>
* <pre>{@code
* byte[] hwpx = hwpxExportService.generatePostHwpx(post);
* response.setHeader("Content-Disposition", "attachment; filename=report.hwpx");
* response.getOutputStream().write(hwpx);
* }</pre>
*
* <p>주의: src/main/resources/hwpx/header.xml 파일이 필요합니다.</p>
*/
@Slf4j
@Service
public class HwpxExportService {
private static final String MIMETYPE = "application/hwp+zip";
private static final String XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?>";
private static final DateTimeFormatter DOC_NUMBER_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmm");
private static final String[] DOW_HANJA = {"月", "火", "水", "木", "金", "土", "日"};
// 공통 네임스페이스
private static final String 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:epub=\"http://www.idpf.org/2007/ops\" "
+ "xmlns:config=\"urn:oasis:names:tc:opendocument:xmlns:config:1.0\"";
private static final String VERSION_XML = XML_DECL
+ "<hv:HCFVersion xmlns:hv=\"http://www.hancom.co.kr/hwpml/2011/version\" "
+ "tagetApplication=\"WORDPROCESSOR\" major=\"5\" minor=\"1\" micro=\"0\" "
+ "buildNumber=\"1\" os=\"1\" xmlVersion=\"1.3\" "
+ "application=\"Hancom Office Hangul\" appVersion=\"11, 0, 0, 4011 WIN32LEWindows_8\"/>";
private static final String SETTINGS_XML = XML_DECL
+ "<ha:HWPApplicationSetting xmlns:ha=\"http://www.hancom.co.kr/hwpml/2011/app\" "
+ "xmlns:config=\"urn:oasis:names:tc:opendocument:xmlns:config:1.0\">"
+ "<ha:CaretPosition listIDRef=\"0\" paraIDRef=\"0\" pos=\"0\"/>"
+ "</ha:HWPApplicationSetting>";
private static final String CONTAINER_XML = XML_DECL
+ "<ocf:container xmlns:ocf=\"urn:oasis:names:tc:opendocument:xmlns:container\" "
+ "xmlns:hpf=\"http://www.hancom.co.kr/schema/2011/hpf\">"
+ "<ocf:rootfiles>"
+ "<ocf:rootfile full-path=\"Contents/content.hpf\" media-type=\"application/hwpml-package+xml\"/>"
+ "<ocf:rootfile full-path=\"Preview/PrvText.txt\" media-type=\"text/plain\"/>"
+ "<ocf:rootfile full-path=\"META-INF/container.rdf\" media-type=\"application/rdf+xml\"/>"
+ "</ocf:rootfiles></ocf:container>";
private static final String MANIFEST_XML = XML_DECL
+ "<odf:manifest xmlns:odf=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
private static final String CONTAINER_RDF = XML_DECL
+ "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">"
+ "<rdf:Description rdf:about=\"\"><ns0:hasPart xmlns:ns0=\"http://www.hancom.co.kr/hwpml/2016/meta/pkg#\" rdf:resource=\"Contents/header.xml\"/></rdf:Description>"
+ "<rdf:Description rdf:about=\"Contents/header.xml\"><rdf:type rdf:resource=\"http://www.hancom.co.kr/hwpml/2016/meta/pkg#HeaderFile\"/></rdf:Description>"
+ "<rdf:Description rdf:about=\"\"><ns0:hasPart xmlns:ns0=\"http://www.hancom.co.kr/hwpml/2016/meta/pkg#\" rdf:resource=\"Contents/section0.xml\"/></rdf:Description>"
+ "<rdf:Description rdf:about=\"Contents/section0.xml\"><rdf:type rdf:resource=\"http://www.hancom.co.kr/hwpml/2016/meta/pkg#SectionFile\"/></rdf:Description>"
+ "<rdf:Description rdf:about=\"\"><rdf:type rdf:resource=\"http://www.hancom.co.kr/hwpml/2016/meta/pkg#Document\"/></rdf:Description>"
+ "</rdf:RDF>";
// header.xml (classpath:hwpx/header.xml 에서 로드)
private final String headerXml;
public HwpxExportService() {
try (InputStream is = new ClassPathResource("hwpx/header.xml").getInputStream()) {
this.headerXml = new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("HWPX header.xml 로드 실패 — src/main/resources/hwpx/header.xml 확인 필요", e);
}
}
// =====================================================================
// 민원 HWPX 생성
// =====================================================================
/**
* 민원 게시글을 HWPX 공문서로 생성합니다.
*
* @param post 민원 게시글 엔티티
* @return HWPX 바이트 배열
*/
public byte[] generatePostHwpx(SinmungoPost post) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
addEntry(zos, "mimetype", MIMETYPE, true);
addEntry(zos, "version.xml", VERSION_XML, false);
addEntry(zos, "settings.xml", SETTINGS_XML, false);
addEntry(zos, "META-INF/container.xml", CONTAINER_XML, false);
addEntry(zos, "META-INF/manifest.xml", MANIFEST_XML, false);
addEntry(zos, "META-INF/container.rdf", CONTAINER_RDF, false);
addEntry(zos, "Contents/content.hpf", buildPostContentHpf(post.getTitle()), false);
addEntry(zos, "Contents/header.xml", headerXml, false);
addEntry(zos, "Contents/section0.xml", buildPostSectionXml(post), false);
addEntry(zos, "Preview/PrvText.txt", buildPostPreview(post), false);
addBinaryEntry(zos, "Scripts/headerScripts.js", buildHeaderScriptsJs());
}
log.info("HWPX 생성: postId={}, size={}bytes", post.getId(), baos.size());
return baos.toByteArray();
}
// ===== content.hpf =====
private String buildPostContentHpf(String title) {
String now = LocalDateTime.now().toString().replaceAll("\\.\\d+$", "") + "Z";
return XML_DECL
+ "<opf:package " + NS + " version=\"\" unique-identifier=\"\" id=\"\">"
+ "<opf:metadata>"
+ "<opf:title>" + esc(title) + "</opf:title>"
+ "<opf:language>ko</opf:language>"
+ "<opf:meta name=\"creator\" content=\"text\">시스템</opf:meta>"
+ "<opf:meta name=\"subject\" content=\"text\"/>"
+ "<opf:meta name=\"description\" content=\"text\"/>"
+ "<opf:meta name=\"CreatedDate\" content=\"text\">" + now + "</opf:meta>"
+ "<opf:meta name=\"ModifiedDate\" content=\"text\">" + now + "</opf:meta>"
+ "<opf:meta name=\"keyword\" content=\"text\"/>"
+ "</opf:metadata>"
+ "<opf:manifest>"
+ "<opf:item id=\"header\" href=\"Contents/header.xml\" media-type=\"application/xml\"/>"
+ "<opf:item id=\"section0\" href=\"Contents/section0.xml\" media-type=\"application/xml\"/>"
+ "<opf:item id=\"headersc\" href=\"Scripts/headerScripts.js\" media-type=\"application/x-javascript ;charset=utf-16\"/>"
+ "<opf:item id=\"settings\" href=\"settings.xml\" media-type=\"application/xml\"/>"
+ "</opf:manifest>"
+ "<opf:spine>"
+ "<opf:itemref idref=\"header\" linear=\"yes\"/>"
+ "<opf:itemref idref=\"section0\" linear=\"yes\"/>"
+ "<opf:itemref idref=\"headersc\" linear=\"yes\"/>"
+ "</opf:spine></opf:package>";
}
// ===== headerScripts.js (UTF-16LE) =====
private byte[] buildHeaderScriptsJs() {
return ("var Documents = XHwpDocuments;\r\n"
+ "var Document = Documents.Active_XHwpDocument;\r\n")
.getBytes(StandardCharsets.UTF_16LE);
}
// ===== Preview 텍스트 =====
private String buildPostPreview(SinmungoPost post) {
LocalDateTime now = LocalDateTime.now();
return "접수 민원 보고\n"
+ " 민원 개요\n"
+ "<제목><" + post.getTitle() + ">\n"
+ "<분류><" + post.getCategory().getLabel() + "><민원인 정보>\n"
+ "<문서번호><" + now.format(DOC_NUMBER_FMT) + "><성명><" + ns(post.getAuthorName()) + ">\n"
+ "<접수일시><" + fmtDateTime(post.getCreatedAt()) + "><소속부서><" + ns(post.getAuthorDepartment()) + ">\n"
+ "<처리기한><" + fmtDate(post.getDueDate()) + "><연락처><" + ns(post.getAuthorPhone()) + ">\n"
+ " 민원 내용 \n"
+ " " + (post.getContent() != null ? post.getContent() : "") + "\n";
}
// =====================================================================
// section0.xml 생성
// =====================================================================
private String buildPostSectionXml(SinmungoPost post) {
LocalDateTime now = LocalDateTime.now();
String docNum = now.format(DOC_NUMBER_FMT);
String hdrDate = fmtHeaderDate(now);
String created = fmtDateTime(post.getCreatedAt());
String due = fmtDate(post.getDueDate());
StringBuilder s = new StringBuilder(16384);
s.append(XML_DECL).append("<hs:sec ").append(NS).append(">");
appendFirstParagraph(s, hdrDate);
appendSectionIcon(s, " 민원 개요", 1245639449, 1, 171897626, 171897627, 171897628);
appendDataTable(s, post, docNum, created, due);
appendSectionIcon(s, " 민원 내용 ", 2083004209, 4, 1009262386, 1009262387, 1009262388);
appendContent(s, post.getContent());
appendTrailing(s);
s.append("</hs:sec>");
return s.toString();
}
private void appendFirstParagraph(StringBuilder s, String hdrDate) {
s.append("<hp:p id=\"0\" paraPrIDRef=\"15\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"9\">");
appendSecPr(s);
s.append("<hp:ctrl><hp:colPr id=\"\" type=\"NEWSPAPER\" layout=\"LEFT\" colCount=\"1\" sameSz=\"1\" sameGap=\"0\"/></hp:ctrl>");
s.append("</hp:run>");
s.append("<hp:run charPrIDRef=\"9\">");
appendPageHeader(s, hdrDate);
appendTitleTable(s);
s.append("<hp:t/></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"1500\" vertsize=\"4181\" textheight=\"4181\" ")
.append("baseline=\"3554\" spacing=\"600\" horzpos=\"0\" horzsize=\"48188\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p>");
}
private void appendSecPr(StringBuilder s) {
s.append("<hp:secPr id=\"\" textDirection=\"HORIZONTAL\" spaceColumns=\"1134\" tabStop=\"8000\" ")
.append("outlineShapeIDRef=\"1\" memoShapeIDRef=\"0\" textVerticalWidthHead=\"0\" masterPageCnt=\"0\">");
s.append("<hp:grid lineGrid=\"0\" charGrid=\"0\" wonggojiFormat=\"0\"/>");
s.append("<hp:startNum pageStartsOn=\"BOTH\" page=\"0\" pic=\"0\" tbl=\"0\" equation=\"0\"/>");
s.append("<hp:visibility hideFirstHeader=\"0\" hideFirstFooter=\"0\" hideFirstMasterPage=\"0\" ")
.append("border=\"SHOW_ALL\" fill=\"SHOW_ALL\" hideFirstPageNum=\"0\" hideFirstEmptyLine=\"0\" showLineNumber=\"0\"/>");
s.append("<hp:lineNumberShape restartType=\"0\" countBy=\"0\" distance=\"0\" startNumber=\"0\"/>");
s.append("<hp:pagePr landscape=\"WIDELY\" width=\"59528\" height=\"84188\" gutterType=\"LEFT_ONLY\">");
s.append("<hp:margin header=\"5669\" footer=\"2834\" gutter=\"0\" left=\"5669\" right=\"5669\" top=\"1417\" bottom=\"2834\"/>");
s.append("</hp:pagePr>");
s.append("<hp:footNotePr><hp:autoNumFormat type=\"DIGIT\" userChar=\"\" prefixChar=\"\" suffixChar=\")\" supscript=\"0\"/>");
s.append("<hp:noteLine length=\"-1\" type=\"SOLID\" width=\"0.12 mm\" color=\"#000000\"/>");
s.append("<hp:noteSpacing betweenNotes=\"283\" belowLine=\"567\" aboveLine=\"850\"/>");
s.append("<hp:numbering type=\"CONTINUOUS\" newNum=\"1\"/><hp:placement place=\"EACH_COLUMN\" beneathText=\"0\"/></hp:footNotePr>");
s.append("<hp:endNotePr><hp:autoNumFormat type=\"DIGIT\" userChar=\"\" prefixChar=\"\" suffixChar=\")\" supscript=\"0\"/>");
s.append("<hp:noteLine length=\"14692344\" type=\"SOLID\" width=\"0.12 mm\" color=\"#000000\"/>");
s.append("<hp:noteSpacing betweenNotes=\"0\" belowLine=\"567\" aboveLine=\"850\"/>");
s.append("<hp:numbering type=\"CONTINUOUS\" newNum=\"1\"/><hp:placement place=\"END_OF_DOCUMENT\" beneathText=\"0\"/></hp:endNotePr>");
for (String t : new String[]{"BOTH", "EVEN", "ODD"}) {
s.append("<hp:pageBorderFill type=\"").append(t).append("\" borderFillIDRef=\"1\" textBorder=\"PAPER\" ")
.append("headerInside=\"0\" footerInside=\"0\" fillArea=\"PAPER\">")
.append("<hp:offset left=\"1417\" right=\"1417\" top=\"1417\" bottom=\"1417\"/></hp:pageBorderFill>");
}
s.append("</hp:secPr>");
}
private void appendPageHeader(StringBuilder s, String hdrDate) {
s.append("<hp:ctrl><hp:header id=\"1\" applyPageType=\"BOTH\">");
s.append("<hp:subList id=\"\" textDirection=\"HORIZONTAL\" lineWrap=\"BREAK\" vertAlign=\"TOP\" ")
.append("linkListIDRef=\"0\" linkListNextIDRef=\"0\" textWidth=\"48190\" textHeight=\"5669\" hasTextRef=\"0\" hasNumRef=\"0\">");
s.append("<hp:p id=\"0\" paraPrIDRef=\"13\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"5\">");
s.append("<hp:tbl id=\"1572281559\" zOrder=\"2\" numberingType=\"TABLE\" textWrap=\"SQUARE\" textFlow=\"BOTH_SIDES\" ")
.append("lock=\"0\" dropcapstyle=\"None\" pageBreak=\"CELL\" repeatHeader=\"1\" rowCnt=\"2\" colCnt=\"1\" ")
.append("cellSpacing=\"0\" borderFillIDRef=\"6\" noAdjust=\"0\">");
s.append("<hp:sz width=\"10497\" widthRelTo=\"ABSOLUTE\" height=\"3696\" heightRelTo=\"ABSOLUTE\" protect=\"0\"/>");
s.append("<hp:pos treatAsChar=\"0\" affectLSpacing=\"0\" flowWithText=\"1\" allowOverlap=\"0\" holdAnchorAndSO=\"0\" ")
.append("vertRelTo=\"PARA\" horzRelTo=\"PARA\" vertAlign=\"TOP\" horzAlign=\"LEFT\" vertOffset=\"0\" horzOffset=\"0\"/>");
s.append("<hp:outMargin left=\"141\" right=\"141\" top=\"141\" bottom=\"141\"/>");
s.append("<hp:inMargin left=\"141\" right=\"141\" top=\"141\" bottom=\"141\"/>");
s.append("<hp:tr>");
appendHeaderCell(s, hdrDate, 0, 8, 11, 1400, 1190, 840);
s.append("</hp:tr>");
s.append("<hp:tr>");
appendHeaderCell(s, "부서명", 1, 7, 12, 1100, 935, 660);
s.append("</hp:tr>");
s.append("</hp:tbl><hp:t/></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"1200\" textheight=\"1200\" ")
.append("baseline=\"1020\" spacing=\"720\" horzpos=\"10779\" horzsize=\"37409\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p></hp:subList></hp:header></hp:ctrl>");
}
private void appendHeaderCell(StringBuilder s, String text, int row, int bf, int charPr,
int vSize, int baseline, int spacing) {
s.append("<hp:tc name=\"\" header=\"0\" hasMargin=\"0\" protect=\"0\" editable=\"0\" dirty=\"0\" borderFillIDRef=\"").append(bf).append("\">");
s.append("<hp:subList id=\"\" textDirection=\"HORIZONTAL\" lineWrap=\"BREAK\" vertAlign=\"CENTER\" ")
.append("linkListIDRef=\"0\" linkListNextIDRef=\"0\" textWidth=\"0\" textHeight=\"0\" hasTextRef=\"0\" hasNumRef=\"0\">");
s.append("<hp:p id=\"2147483648\" paraPrIDRef=\"1\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"").append(charPr).append("\"><hp:t>").append(text).append("</hp:t></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"").append(vSize)
.append("\" textheight=\"").append(vSize).append("\" baseline=\"").append(baseline)
.append("\" spacing=\"").append(spacing).append("\" horzpos=\"0\" horzsize=\"10212\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p></hp:subList>");
s.append("<hp:cellAddr colAddr=\"0\" rowAddr=\"").append(row).append("\"/>");
s.append("<hp:cellSpan colSpan=\"1\" rowSpan=\"1\"/>");
s.append("<hp:cellSz width=\"10497\" height=\"1848\"/>");
s.append("<hp:cellMargin left=\"141\" right=\"141\" top=\"141\" bottom=\"141\"/>");
s.append("</hp:tc>");
}
private void appendTitleTable(StringBuilder s) {
s.append("<hp:tbl id=\"1689732038\" zOrder=\"0\" numberingType=\"TABLE\" textWrap=\"TOP_AND_BOTTOM\" ")
.append("textFlow=\"BOTH_SIDES\" lock=\"0\" dropcapstyle=\"None\" pageBreak=\"CELL\" repeatHeader=\"1\" ")
.append("rowCnt=\"1\" colCnt=\"1\" cellSpacing=\"0\" borderFillIDRef=\"3\" noAdjust=\"0\">");
s.append("<hp:sz width=\"47913\" widthRelTo=\"ABSOLUTE\" height=\"4181\" heightRelTo=\"ABSOLUTE\" protect=\"0\"/>");
s.append("<hp:pos treatAsChar=\"1\" affectLSpacing=\"0\" flowWithText=\"1\" allowOverlap=\"0\" holdAnchorAndSO=\"0\" ")
.append("vertRelTo=\"PARA\" horzRelTo=\"PARA\" vertAlign=\"TOP\" horzAlign=\"LEFT\" vertOffset=\"0\" horzOffset=\"0\"/>");
s.append("<hp:outMargin left=\"0\" right=\"0\" top=\"0\" bottom=\"0\"/>");
s.append("<hp:inMargin left=\"0\" right=\"0\" top=\"0\" bottom=\"0\"/>");
s.append("<hp:tr><hp:tc name=\"\" header=\"0\" hasMargin=\"0\" protect=\"0\" editable=\"0\" dirty=\"0\" borderFillIDRef=\"4\">");
s.append("<hp:subList id=\"\" textDirection=\"HORIZONTAL\" lineWrap=\"BREAK\" vertAlign=\"CENTER\" ")
.append("linkListIDRef=\"0\" linkListNextIDRef=\"0\" textWidth=\"0\" textHeight=\"0\" hasTextRef=\"0\" hasNumRef=\"0\">");
s.append("<hp:p id=\"2147483648\" paraPrIDRef=\"14\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"10\"><hp:t>접수 민원 보고</hp:t></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"2200\" textheight=\"2200\" ")
.append("baseline=\"1870\" spacing=\"1100\" horzpos=\"0\" horzsize=\"47912\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p></hp:subList>");
s.append("<hp:cellAddr colAddr=\"0\" rowAddr=\"0\"/><hp:cellSpan colSpan=\"1\" rowSpan=\"1\"/>");
s.append("<hp:cellSz width=\"47913\" height=\"4181\"/>");
s.append("<hp:cellMargin left=\"510\" right=\"510\" top=\"141\" bottom=\"141\"/>");
s.append("</hp:tc></hp:tr></hp:tbl>");
}
private void appendSectionIcon(StringBuilder s, String label, long cId, int zOrder,
long instId, long rId1, long rId2) {
s.append("<hp:p id=\"0\" paraPrIDRef=\"16\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"6\">");
appendContainer(s, cId, zOrder, instId, rId1, rId2);
s.append("<hp:t>").append(label).append("</hp:t></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"1500\" textheight=\"1500\" ")
.append("baseline=\"1275\" spacing=\"900\" horzpos=\"0\" horzsize=\"48188\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p>");
}
private void appendContainer(StringBuilder s, long cId, int zOrder, long instId, long rId1, long rId2) {
s.append("<hp:container id=\"").append(cId).append("\" zOrder=\"").append(zOrder)
.append("\" numberingType=\"PICTURE\" textWrap=\"TOP_AND_BOTTOM\" textFlow=\"BOTH_SIDES\" ")
.append("lock=\"0\" dropcapstyle=\"None\" href=\"\" groupLevel=\"0\" instid=\"").append(instId).append("\">");
s.append("<hp:offset x=\"0\" y=\"0\"/><hp:orgSz width=\"1416\" height=\"1420\"/>");
s.append("<hp:curSz width=\"0\" height=\"1416\"/><hp:flip horizontal=\"0\" vertical=\"0\"/>");
s.append("<hp:rotationInfo angle=\"0\" centerX=\"708\" centerY=\"708\" rotateimage=\"0\"/>");
s.append("<hp:renderingInfo><hc:transMatrix e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"/>");
s.append("<hc:scaMatrix e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"0.997242\" e6=\"0\"/>");
s.append("<hc:rotMatrix e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"/></hp:renderingInfo>");
appendRect(s, rId1, 140, 164, "#303030",
"e1=\"1\" e2=\"0\" e3=\"140\" e4=\"0\" e5=\"1\" e6=\"164\"",
"e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"0.996907\" e6=\"-0.507181\"",
"e1=\"1\" e2=\"-0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"");
appendRect(s, rId2, 0, 0, "#FFFFFF",
"e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"",
"e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"0.996907\" e6=\"0\"",
"e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"");
s.append("<hp:sz width=\"1416\" widthRelTo=\"ABSOLUTE\" height=\"1416\" heightRelTo=\"ABSOLUTE\" protect=\"0\"/>");
s.append("<hp:pos treatAsChar=\"1\" affectLSpacing=\"0\" flowWithText=\"1\" allowOverlap=\"0\" ")
.append("holdAnchorAndSO=\"0\" vertRelTo=\"PARA\" horzRelTo=\"PARA\" vertAlign=\"TOP\" horzAlign=\"LEFT\" ")
.append("vertOffset=\"0\" horzOffset=\"0\"/>");
s.append("<hp:outMargin left=\"0\" right=\"0\" top=\"0\" bottom=\"0\"/>");
s.append("</hp:container>");
}
private void appendRect(StringBuilder s, long instId, int ox, int oy, String color,
String trans, String sca, String sca2) {
s.append("<hp:rect id=\"0\" zOrder=\"0\" numberingType=\"NONE\" textWrap=\"TOP_AND_BOTTOM\" ")
.append("textFlow=\"BOTH_SIDES\" lock=\"0\" dropcapstyle=\"None\" href=\"\" groupLevel=\"1\" instid=\"")
.append(instId).append("\" ratio=\"0\">");
s.append("<hp:offset x=\"").append(ox).append("\" y=\"").append(oy).append("\"/>");
s.append("<hp:orgSz width=\"1276\" height=\"1256\"/><hp:curSz width=\"0\" height=\"1252\"/>");
s.append("<hp:flip horizontal=\"0\" vertical=\"0\"/>");
s.append("<hp:rotationInfo angle=\"0\" centerX=\"638\" centerY=\"626\" rotateimage=\"0\"/>");
s.append("<hp:renderingInfo>");
s.append("<hc:transMatrix ").append(trans).append("/>");
s.append("<hc:scaMatrix ").append(sca).append("/>");
s.append("<hc:rotMatrix e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"/>");
s.append("<hc:scaMatrix ").append(sca2).append("/>");
s.append("<hc:rotMatrix e1=\"1\" e2=\"0\" e3=\"0\" e4=\"0\" e5=\"1\" e6=\"0\"/>");
s.append("</hp:renderingInfo>");
s.append("<hp:lineShape color=\"#000000\" width=\"32\" style=\"SOLID\" endCap=\"FLAT\" ")
.append("headStyle=\"NORMAL\" tailStyle=\"NORMAL\" headfill=\"1\" tailfill=\"1\" ")
.append("headSz=\"SMALL_SMALL\" tailSz=\"SMALL_SMALL\" outlineStyle=\"NORMAL\" alpha=\"0\"/>");
s.append("<hc:fillBrush><hc:winBrush faceColor=\"").append(color).append("\" hatchColor=\"#000000\" alpha=\"0\"/></hc:fillBrush>");
s.append("<hp:shadow type=\"NONE\" color=\"#000000\" offsetX=\"0\" offsetY=\"0\" alpha=\"0\"/>");
s.append("<hc:pt0 x=\"0\" y=\"0\"/><hc:pt1 x=\"1276\" y=\"0\"/>");
s.append("<hc:pt2 x=\"1276\" y=\"1256\"/><hc:pt3 x=\"0\" y=\"1256\"/>");
s.append("</hp:rect>");
}
private void appendDataTable(StringBuilder s, SinmungoPost post, String docNum,
String created, String due) {
s.append("<hp:p id=\"0\" paraPrIDRef=\"18\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"8\">");
s.append("<hp:tbl id=\"2083004207\" zOrder=\"3\" numberingType=\"TABLE\" textWrap=\"TOP_AND_BOTTOM\" ")
.append("textFlow=\"BOTH_SIDES\" lock=\"0\" dropcapstyle=\"None\" pageBreak=\"CELL\" repeatHeader=\"1\" ")
.append("rowCnt=\"5\" colCnt=\"4\" cellSpacing=\"0\" borderFillIDRef=\"3\" noAdjust=\"0\">");
s.append("<hp:sz width=\"47622\" widthRelTo=\"ABSOLUTE\" height=\"15900\" heightRelTo=\"ABSOLUTE\" protect=\"0\"/>");
s.append("<hp:pos treatAsChar=\"1\" affectLSpacing=\"0\" flowWithText=\"1\" allowOverlap=\"0\" holdAnchorAndSO=\"0\" ")
.append("vertRelTo=\"PARA\" horzRelTo=\"PARA\" vertAlign=\"TOP\" horzAlign=\"LEFT\" vertOffset=\"0\" horzOffset=\"0\"/>");
s.append("<hp:outMargin left=\"0\" right=\"0\" top=\"0\" bottom=\"0\"/>");
s.append("<hp:inMargin left=\"510\" right=\"510\" top=\"141\" bottom=\"141\"/>");
s.append("<hp:tr>");
cell(s, "제목", 0, 0, 1, 1, 7937, 3180, 10, 14, 0);
cell(s, post.getTitle(), 1, 0, 3, 1, 39685, 3180, 9, 13, 0);
s.append("</hp:tr>");
s.append("<hp:tr>");
cell(s, "분류", 0, 1, 1, 1, 7937, 3180, 11, 14, 0);
cell(s, post.getCategory().getLabel(), 1, 1, 1, 1, 15874, 3180, 14, 13, 0);
cell(s, "민원인 정보", 2, 1, 2, 1, 23811, 3180, 21, 14, 1);
s.append("</hp:tr>");
s.append("<hp:tr>");
cell(s, "문서번호", 0, 2, 1, 1, 7937, 3180, 11, 14, 0);
cell(s, docNum, 1, 2, 1, 1, 15874, 3180, 14, 13, 0);
cell(s, "성명", 2, 2, 1, 1, 6522, 3180, 19, 14, 0);
cell(s, ns(post.getAuthorName()), 3, 2, 1, 1, 17289, 3180, 20, 13, 0);
s.append("</hp:tr>");
s.append("<hp:tr>");
cell(s, "접수일시", 0, 3, 1, 1, 7937, 3180, 11, 14, 0);
cell(s, created, 1, 3, 1, 1, 15874, 3180, 14, 13, 0);
cell(s, "소속부서", 2, 3, 1, 1, 6522, 3180, 15, 14, 0);
cell(s, ns(post.getAuthorDepartment()), 3, 3, 1, 1, 17289, 3180, 13, 13, 0);
s.append("</hp:tr>");
s.append("<hp:tr>");
cell(s, "처리기한", 0, 4, 1, 1, 7937, 3180, 12, 14, 0);
cell(s, due, 1, 4, 1, 1, 15874, 3180, 16, 13, 0);
cell(s, "연락처", 2, 4, 1, 1, 6522, 3180, 17, 14, 0);
cell(s, ns(post.getAuthorPhone()), 3, 4, 1, 1, 17289, 3180, 18, 13, 0);
s.append("</hp:tr>");
s.append("</hp:tbl><hp:t/></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"11181\" vertsize=\"15900\" textheight=\"15900\" ")
.append("baseline=\"13515\" spacing=\"780\" horzpos=\"0\" horzsize=\"48188\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p>");
}
private void cell(StringBuilder s, String text, int col, int row,
int cs, int rs, int w, int h, int bf, int cp, int pp) {
s.append("<hp:tc name=\"\" header=\"0\" hasMargin=\"0\" protect=\"0\" editable=\"0\" dirty=\"0\" borderFillIDRef=\"").append(bf).append("\">");
s.append("<hp:subList id=\"\" textDirection=\"HORIZONTAL\" lineWrap=\"BREAK\" vertAlign=\"CENTER\" ")
.append("linkListIDRef=\"0\" linkListNextIDRef=\"0\" textWidth=\"0\" textHeight=\"0\" hasTextRef=\"0\" hasNumRef=\"0\">");
s.append("<hp:p id=\"0\" paraPrIDRef=\"").append(pp).append("\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"").append(cp).append("\"><hp:t>").append(esc(text)).append("</hp:t></hp:run>");
int hz = w - 1021;
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"1200\" textheight=\"1200\" ")
.append("baseline=\"1020\" spacing=\"720\" horzpos=\"0\" horzsize=\"").append(hz).append("\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p></hp:subList>");
s.append("<hp:cellAddr colAddr=\"").append(col).append("\" rowAddr=\"").append(row).append("\"/>");
s.append("<hp:cellSpan colSpan=\"").append(cs).append("\" rowSpan=\"").append(rs).append("\"/>");
s.append("<hp:cellSz width=\"").append(w).append("\" height=\"").append(h).append("\"/>");
s.append("<hp:cellMargin left=\"510\" right=\"510\" top=\"141\" bottom=\"141\"/>");
s.append("</hp:tc>");
}
private void appendContent(StringBuilder s, String content) {
String text = content != null ? content.replace("\r", "") : "";
s.append("<hp:p id=\"0\" paraPrIDRef=\"19\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"7\"><hp:t> ").append(esc(text)).append("</hp:t></hp:run>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"1500\" textheight=\"1500\" ")
.append("baseline=\"1275\" spacing=\"900\" horzpos=\"0\" horzsize=\"48188\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p>");
}
private void appendTrailing(StringBuilder s) {
s.append("<hp:p id=\"0\" paraPrIDRef=\"17\" styleIDRef=\"0\" pageBreak=\"0\" columnBreak=\"0\" merged=\"0\">");
s.append("<hp:run charPrIDRef=\"7\"/>");
s.append("<hp:linesegarray><hp:lineseg textpos=\"0\" vertpos=\"0\" vertsize=\"1500\" textheight=\"1500\" ")
.append("baseline=\"1275\" spacing=\"900\" horzpos=\"0\" horzsize=\"48188\" flags=\"393216\"/></hp:linesegarray>");
s.append("</hp:p>");
}
// =====================================================================
// 날짜 포맷
// =====================================================================
/** 머리말 날짜: '26. 2. 12.(金) — fwSpace 포함 XML 문자열 */
private String fmtHeaderDate(LocalDateTime dt) {
int y = dt.getYear() % 100;
int m = dt.getMonthValue();
int d = dt.getDayOfMonth();
String dow = DOW_HANJA[dt.getDayOfWeek().getValue() - 1];
return "'" + y + ".<hp:fwSpace/>" + m + ".<hp:fwSpace/>" + d + ".(" + dow + ")";
}
/** 접수일시: 2026. 2. 9. 11:50 */
private String fmtDateTime(LocalDateTime dt) {
if (dt == null) return "-";
return dt.getYear() + ". " + dt.getMonthValue() + ". " + dt.getDayOfMonth() + ". "
+ String.format("%02d:%02d", dt.getHour(), dt.getMinute());
}
/** 처리기한: 2026. 2. 23 */
private String fmtDate(LocalDateTime dt) {
if (dt == null) return "-";
return dt.getYear() + ". " + dt.getMonthValue() + ". " + dt.getDayOfMonth();
}
// =====================================================================
// ZIP 유틸리티
// =====================================================================
private void addEntry(ZipOutputStream zos, String name, String content, boolean store) throws IOException {
byte[] data = content.getBytes(StandardCharsets.UTF_8);
ZipEntry entry = new ZipEntry(name);
if (store) {
entry.setMethod(ZipEntry.STORED);
entry.setSize(data.length);
entry.setCompressedSize(data.length);
CRC32 crc = new CRC32();
crc.update(data);
entry.setCrc(crc.getValue());
} else {
entry.setMethod(ZipEntry.DEFLATED);
}
zos.putNextEntry(entry);
zos.write(data);
zos.closeEntry();
}
private void addBinaryEntry(ZipOutputStream zos, String name, byte[] data) throws IOException {
ZipEntry entry = new ZipEntry(name);
entry.setMethod(ZipEntry.DEFLATED);
zos.putNextEntry(entry);
zos.write(data);
zos.closeEntry();
}
// =====================================================================
// 문자열 유틸리티
// =====================================================================
private String esc(String text) {
if (text == null) return "";
return text.replace("&", "&").replace("<", "<").replace(">", ">")
.replace("\"", """).replace("'", "'");
}
private String ns(String value) {
return value != null ? value : "-";
}
}