feat(reports): HWP 저장을 실제 HWPX 포맷으로 변경
기존 HTML Blob → .doc 저장 방식을 OWPML 표준 HWPX(ZIP+XML) 포맷으로 교체. JSZip으로 HWPX 파일을 순수 브라우저에서 생성하여 한글에서 직접 열 수 있도록 구현. - hwpxExport.ts 신규: HWPX ZIP 패키징 (mimetype, header.xml, section0.xml 등) - reportUtils.ts: exportAsHWP → dynamic import로 HWPX 위임 - ReportsView.tsx, TemplateFormEditor.tsx: 구조화 데이터 직접 전달 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
a9294e848c
커밋
374a487878
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@ -23,6 +23,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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const tpl = templateTypes.find(t => t.id === previewReport.reportType)
|
||||
const tpl = templateTypes.find(t => t.id === previewReport.reportType) as TemplateType | undefined
|
||||
if (tpl) {
|
||||
const getVal = (key: string) => {
|
||||
if (key === 'author') return previewReport.author
|
||||
@ -308,14 +309,15 @@ export function ReportsView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (previewReport as any)[key] || ''
|
||||
}
|
||||
const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal)
|
||||
exportAsHWP(html, previewReport.title || tpl.label)
|
||||
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
||||
const filename = previewReport.title || tpl.label
|
||||
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename)
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
|
||||
style={{ border: '1px solid rgba(59,130,246,0.4)', background: 'rgba(59,130,246,0.1)', color: 'var(--blue)' }}
|
||||
>
|
||||
<span>📝</span> HWP 저장
|
||||
<span>📝</span> HWPX 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => doExport('pdf')} className="px-3 py-2 text-[11px] font-semibold rounded bg-status-red text-white hover:opacity-90 transition-all">PDF</button>
|
||||
<button onClick={() => doExport('hwp')} className="px-3 py-2 text-[11px] font-semibold rounded bg-[#2563eb] text-white hover:opacity-90 transition-all">HWP</button>
|
||||
<button onClick={() => doExport('hwp')} className="px-3 py-2 text-[11px] font-semibold rounded bg-[#2563eb] text-white hover:opacity-90 transition-all">HWPX</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
||||
703
frontend/src/tabs/reports/components/hwpxExport.ts
Normal file
703
frontend/src/tabs/reports/components/hwpxExport.ts
Normal file
@ -0,0 +1,703 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
// ─── XML 이스케이프 ──────────────────────────────────────────
|
||||
function esc(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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 =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" ' +
|
||||
'tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="1" buildNumber="0" ' +
|
||||
'os="1" xmlVersion="1.5" application="Hancom Office Hangul" ' +
|
||||
'appVersion="13, 0, 0, 1408 WIN32LEWindows_10"/>';
|
||||
|
||||
/**
|
||||
* settings.xml: Skeleton.hwpx 그대로
|
||||
*/
|
||||
const SETTINGS_XML =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<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="16"/>' +
|
||||
'</ha:HWPApplicationSetting>';
|
||||
|
||||
/**
|
||||
* META-INF/container.xml: Skeleton.hwpx 그대로
|
||||
*/
|
||||
const CONTAINER_XML =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<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>';
|
||||
|
||||
/**
|
||||
* META-INF/container.rdf: Skeleton.hwpx 그대로
|
||||
*/
|
||||
const CONTAINER_RDF =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<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>';
|
||||
|
||||
/**
|
||||
* META-INF/manifest.xml: Skeleton.hwpx 그대로
|
||||
*/
|
||||
const MANIFEST_XML =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>';
|
||||
|
||||
/**
|
||||
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
||||
*/
|
||||
function buildContentHpf(): string {
|
||||
const now = new Date().toISOString();
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<opf:package 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" ' +
|
||||
'version="" unique-identifier="" id="">' +
|
||||
'<opf:metadata>' +
|
||||
'<opf:title/>' +
|
||||
'<opf:language>ko</opf:language>' +
|
||||
'<opf:meta name="creator" content="text">WING-OPS</opf:meta>' +
|
||||
'<opf:meta name="subject" content="text"/>' +
|
||||
'<opf:meta name="description" content="text"/>' +
|
||||
'<opf:meta name="lastsaveby" content="text">WING-OPS</opf:meta>' +
|
||||
`<opf:meta name="CreatedDate" content="text">${now}</opf:meta>` +
|
||||
`<opf:meta name="ModifiedDate" content="text">${now}</opf:meta>` +
|
||||
'</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="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:spine>' +
|
||||
'</opf:package>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
'<hh:head 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" ' +
|
||||
'version="1.5" secCnt="1">' +
|
||||
'<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>' +
|
||||
'<hh:refList>' +
|
||||
// ── 폰트 (함초롬돋움/함초롬바탕, 7개 언어 그룹)
|
||||
'<hh:fontfaces itemCnt="7">' +
|
||||
'<hh:fontface lang="HANGUL" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="LATIN" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="HANJA" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="JAPANESE" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="OTHER" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="SYMBOL" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'<hh:fontface lang="USER" fontCnt="2">' +
|
||||
'<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>' +
|
||||
'</hh:fontface>' +
|
||||
'</hh:fontfaces>' +
|
||||
// ── borderFill
|
||||
'<hh:borderFills itemCnt="2">' +
|
||||
'<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">' +
|
||||
'<hh:slash type="NONE" Crooked="0" isCounter="0"/>' +
|
||||
'<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>' +
|
||||
'<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>' +
|
||||
'</hh:borderFill>' +
|
||||
'<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">' +
|
||||
'<hh:slash type="NONE" Crooked="0" isCounter="0"/>' +
|
||||
'<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>' +
|
||||
'<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>' +
|
||||
'<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>' +
|
||||
'<hc:fillBrush><hc:winBrush faceColor="none" hatchColor="#999999" alpha="0"/></hc:fillBrush>' +
|
||||
'</hh:borderFill>' +
|
||||
'</hh:borderFills>' +
|
||||
// ── charProperties (Skeleton 7개 그대로)
|
||||
'<hh:charProperties itemCnt="7">' +
|
||||
'<hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="1" latin="1" hanja="1" japanese="1" other="1" symbol="1" user="1"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
'<hh:charPr id="1" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
'<hh:charPr id="2" height="900" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
'<hh:charPr id="3" height="900" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="1" latin="1" hanja="1" japanese="1" other="1" symbol="1" user="1"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
'<hh:charPr id="4" height="900" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="-5" latin="-5" hanja="-5" japanese="-5" other="-5" symbol="-5" user="-5"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
// charPr id=5: 16pt 파란색 (제목용으로 활용)
|
||||
'<hh:charPr id="5" height="1600" textColor="#2E74B5" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
// charPr id=6: 11pt 검정
|
||||
'<hh:charPr id="6" height="1100" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">' +
|
||||
'<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>' +
|
||||
'<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>' +
|
||||
'<hh:underline type="NONE" shape="SOLID" color="#000000"/>' +
|
||||
'<hh:strikeout shape="NONE" color="#000000"/>' +
|
||||
'<hh:outline type="NONE"/>' +
|
||||
'<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>' +
|
||||
'</hh:charPr>' +
|
||||
'</hh:charProperties>' +
|
||||
// ── tabProperties
|
||||
'<hh:tabProperties itemCnt="3">' +
|
||||
'<hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/>' +
|
||||
'<hh:tabPr id="1" autoTabLeft="1" autoTabRight="0"/>' +
|
||||
'<hh:tabPr id="2" autoTabLeft="0" autoTabRight="1"/>' +
|
||||
'</hh:tabProperties>' +
|
||||
// ── numberings (Skeleton 그대로)
|
||||
'<hh:numberings itemCnt="1">' +
|
||||
'<hh:numbering id="1" start="0">' +
|
||||
'<hh:paraHead start="1" level="1" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295" checkable="0">^1.</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="2" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="HANGUL_SYLLABLE" charPrIDRef="4294967295" checkable="0">^2.</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="3" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295" checkable="0">^3)</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="4" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="HANGUL_SYLLABLE" charPrIDRef="4294967295" checkable="0">^4)</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="5" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295" checkable="0">(^5)</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="6" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="HANGUL_SYLLABLE" charPrIDRef="4294967295" checkable="0">(^6)</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="7" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="CIRCLED_DIGIT" charPrIDRef="4294967295" checkable="1">^7</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="8" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="CIRCLED_HANGUL_SYLLABLE" charPrIDRef="4294967295" checkable="1">^8</hh:paraHead>' +
|
||||
'<hh:paraHead start="1" level="9" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="HANGUL_JAMO" charPrIDRef="4294967295" checkable="0"/>' +
|
||||
'<hh:paraHead start="1" level="10" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="ROMAN_SMALL" charPrIDRef="4294967295" checkable="1"/>' +
|
||||
'</hh:numbering>' +
|
||||
'</hh:numberings>' +
|
||||
// ── paraProperties (Skeleton id=0만 사용)
|
||||
'<hh:paraProperties itemCnt="1">' +
|
||||
'<hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="1" suppressLineNumbers="0" checked="0" textDir="LTR">' +
|
||||
'<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>' +
|
||||
'<hh:heading type="NONE" idRef="0" level="0"/>' +
|
||||
'<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>' +
|
||||
'<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>' +
|
||||
'<hp:switch>' +
|
||||
'<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">' +
|
||||
'<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>' +
|
||||
'<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>' +
|
||||
'</hp:case>' +
|
||||
'<hp:default>' +
|
||||
'<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>' +
|
||||
'<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>' +
|
||||
'</hp:default>' +
|
||||
'</hp:switch>' +
|
||||
'<hh:border borderFillIDRef="2" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>' +
|
||||
'</hh:paraPr>' +
|
||||
'</hh:paraProperties>' +
|
||||
// ── styles (Skeleton 바탕글 스타일만)
|
||||
'<hh:styles itemCnt="1">' +
|
||||
'<hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>' +
|
||||
'</hh:styles>' +
|
||||
'</hh:refList>' +
|
||||
'<hh:compatibleDocument targetProgram="HWP201X"><hh:layoutCompatibility/></hh:compatibleDocument>' +
|
||||
'<hh:docOption><hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/></hh:docOption>' +
|
||||
'<hh:metaTag>{"name":""}</hh:metaTag>' +
|
||||
'<hh:trackchageConfig flags="56"/>' +
|
||||
'</hh:head>';
|
||||
|
||||
// ─── 공통 네임스페이스 선언 (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 (
|
||||
`<hp:p id="${id}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
'<hp:run charPrIDRef="0">' +
|
||||
'<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" ' +
|
||||
'tabStopVal="4000" tabStopUnit="HWPUNIT" outlineShapeIDRef="1" memoShapeIDRef="0" ' +
|
||||
'textVerticalWidthHead="0" masterPageCnt="0">' +
|
||||
'<hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/>' +
|
||||
'<hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>' +
|
||||
'<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" ' +
|
||||
'border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>' +
|
||||
'<hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>' +
|
||||
'<hp:pagePr landscape="WIDELY" width="59528" height="84186" gutterType="LEFT_ONLY">' +
|
||||
'<hp:margin header="4252" footer="4252" gutter="0" left="8504" right="8504" top="5668" bottom="4252"/>' +
|
||||
'</hp:pagePr>' +
|
||||
'<hp:footNotePr>' +
|
||||
'<hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/>' +
|
||||
'<hp:noteLine length="-1" type="SOLID" width="0.12 mm" color="#000000"/>' +
|
||||
'<hp:noteSpacing betweenNotes="283" belowLine="567" aboveLine="850"/>' +
|
||||
'<hp:numbering type="CONTINUOUS" newNum="1"/>' +
|
||||
'<hp:placement place="EACH_COLUMN" beneathText="0"/>' +
|
||||
'</hp:footNotePr>' +
|
||||
'<hp:endNotePr>' +
|
||||
'<hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/>' +
|
||||
'<hp:noteLine length="14692344" type="SOLID" width="0.12 mm" color="#000000"/>' +
|
||||
'<hp:noteSpacing betweenNotes="0" belowLine="567" aboveLine="850"/>' +
|
||||
'<hp:numbering type="CONTINUOUS" newNum="1"/>' +
|
||||
'<hp:placement place="END_OF_DOCUMENT" beneathText="0"/>' +
|
||||
'</hp:endNotePr>' +
|
||||
'<hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">' +
|
||||
'<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>' +
|
||||
'</hp:pageBorderFill>' +
|
||||
'<hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">' +
|
||||
'<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>' +
|
||||
'</hp:pageBorderFill>' +
|
||||
'<hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">' +
|
||||
'<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>' +
|
||||
'</hp:pageBorderFill>' +
|
||||
'</hp:secPr>' +
|
||||
'<hp:ctrl>' +
|
||||
'<hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>' +
|
||||
'</hp:ctrl>' +
|
||||
'</hp:run>' +
|
||||
'<hp:run charPrIDRef="0"><hp:t/></hp:run>' +
|
||||
'<hp:linesegarray>' +
|
||||
'<hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" ' +
|
||||
'spacing="600" horzpos="0" horzsize="42520" flags="393216"/>' +
|
||||
'</hp:linesegarray>' +
|
||||
'</hp:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 단락 생성 (charPrIDRef=0 기본 10pt)
|
||||
*/
|
||||
function buildPara(text: string, charPrId = 0): string {
|
||||
const id = nextId();
|
||||
return (
|
||||
`<hp:p id="${id}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
`<hp:run charPrIDRef="${charPrId}">` +
|
||||
`<hp:t>${esc(text)}</hp:t>` +
|
||||
'</hp:run>' +
|
||||
'</hp:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 단락 (줄바꿈용)
|
||||
*/
|
||||
function buildEmptyPara(): string {
|
||||
const id = nextId();
|
||||
return (
|
||||
`<hp:p id="${id}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
'<hp:run charPrIDRef="0"><hp:t/></hp:run>' +
|
||||
'</hp:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 셀 내 단락 (subList 내부용)
|
||||
*/
|
||||
function buildCellPara(text: string): string {
|
||||
const id = nextId();
|
||||
return (
|
||||
`<hp:p id="${id}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
'<hp:run charPrIDRef="0">' +
|
||||
`<hp:t>${esc(text)}</hp:t>` +
|
||||
'</hp:run>' +
|
||||
'</hp:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 셀 생성
|
||||
* OWPML 스펙: <hp:tc> 자식 순서: subList → cellAddr → cellSpan → cellSz → cellMargin
|
||||
*/
|
||||
function buildCell(
|
||||
text: string,
|
||||
colAddr: number,
|
||||
rowAddr: number,
|
||||
colSpan: number,
|
||||
rowSpan: number,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): string {
|
||||
return (
|
||||
`<hp:tc name="" header="${isHeader ? '1' : '0'}" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="2">` +
|
||||
`<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" ` +
|
||||
`linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">` +
|
||||
buildCellPara(text) +
|
||||
'</hp:subList>' +
|
||||
`<hp:cellAddr colAddr="${colAddr}" rowAddr="${rowAddr}"/>` +
|
||||
`<hp:cellSpan colSpan="${colSpan}" rowSpan="${rowSpan}"/>` +
|
||||
`<hp:cellSz width="${width}" height="564"/>` +
|
||||
'<hp:cellMargin left="510" right="510" top="141" bottom="141"/>' +
|
||||
'</hp:tc>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +=
|
||||
'<hp:tr>' +
|
||||
buildCell(field.label, 0, rowIdx, 1, 1, LABEL_WIDTH) +
|
||||
buildCell(value, 1, rowIdx, 1, 1, VALUE_WIDTH) +
|
||||
'</hp:tr>';
|
||||
} else {
|
||||
// 1열 전체 너비 (textarea 류)
|
||||
rows +=
|
||||
'<hp:tr>' +
|
||||
buildCell(value, 0, rowIdx, 2, 1, CONTENT_WIDTH) +
|
||||
'</hp:tr>';
|
||||
}
|
||||
});
|
||||
|
||||
const colCnt = 2;
|
||||
const pId = nextId();
|
||||
const tblId = nextId();
|
||||
// 테이블 높이 추정: 각 행 564 HWPUNIT
|
||||
const tblHeight = rowCnt * 564;
|
||||
|
||||
// 테이블은 <hp:p> > <hp:run> 안에 감싸야 함
|
||||
// OWPML 스펙: hp:tbl 속성 + 자식(sz, pos, outMargin, inMargin) 후 행
|
||||
return (
|
||||
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
'<hp:run charPrIDRef="0">' +
|
||||
`<hp:tbl id="${tblId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" ` +
|
||||
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" ` +
|
||||
`repeatHeader="0" rowCnt="${rowCnt}" colCnt="${colCnt}" cellSpacing="0" ` +
|
||||
`borderFillIDRef="2" noAdjust="0">` +
|
||||
`<hp:sz width="${CONTENT_WIDTH}" height="${tblHeight}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
|
||||
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
|
||||
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
|
||||
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
|
||||
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||
'<hp:inMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||
rows +
|
||||
'</hp:tbl>' +
|
||||
'</hp:run>' +
|
||||
'</hp:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||
`<hs:sec ${SEC_NS}>` +
|
||||
body +
|
||||
'</hs:sec>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user