hwp-convert 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +25 -0
  3. package/NOTICE +23 -0
  4. package/README.md +338 -0
  5. package/dist/browser/hwp-convert.browser.mjs +20677 -0
  6. package/dist/browser/hwp-convert.browser.mjs.map +7 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +267 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.js +5 -0
  11. package/dist/lib/errors.d.ts +9 -0
  12. package/dist/lib/errors.js +18 -0
  13. package/dist/lib/hwp/binData.d.ts +15 -0
  14. package/dist/lib/hwp/binData.js +64 -0
  15. package/dist/lib/hwp/bodyText.d.ts +31 -0
  16. package/dist/lib/hwp/bodyText.js +208 -0
  17. package/dist/lib/hwp/byteReader.d.ts +40 -0
  18. package/dist/lib/hwp/byteReader.js +116 -0
  19. package/dist/lib/hwp/cfbReader.d.ts +44 -0
  20. package/dist/lib/hwp/cfbReader.js +134 -0
  21. package/dist/lib/hwp/control.d.ts +17 -0
  22. package/dist/lib/hwp/control.js +290 -0
  23. package/dist/lib/hwp/converter.d.ts +22 -0
  24. package/dist/lib/hwp/converter.js +41 -0
  25. package/dist/lib/hwp/docInfo.d.ts +26 -0
  26. package/dist/lib/hwp/docInfo.js +396 -0
  27. package/dist/lib/hwp/fileHeader.d.ts +42 -0
  28. package/dist/lib/hwp/fileHeader.js +66 -0
  29. package/dist/lib/hwp/htmlReader.d.ts +17 -0
  30. package/dist/lib/hwp/htmlReader.js +602 -0
  31. package/dist/lib/hwp/hwpxBuilder.d.ts +19 -0
  32. package/dist/lib/hwp/hwpxBuilder.js +633 -0
  33. package/dist/lib/hwp/index.d.ts +68 -0
  34. package/dist/lib/hwp/index.js +149 -0
  35. package/dist/lib/hwp/mdReader.d.ts +16 -0
  36. package/dist/lib/hwp/mdReader.js +485 -0
  37. package/dist/lib/hwp/mdWriter.d.ts +23 -0
  38. package/dist/lib/hwp/mdWriter.js +182 -0
  39. package/dist/lib/hwp/owpml.d.ts +33 -0
  40. package/dist/lib/hwp/owpml.js +86 -0
  41. package/dist/lib/hwp/record.d.ts +24 -0
  42. package/dist/lib/hwp/record.js +59 -0
  43. package/dist/lib/hwp/tags.d.ts +115 -0
  44. package/dist/lib/hwp/tags.js +217 -0
  45. package/dist/lib/hwp/types.d.ts +214 -0
  46. package/dist/lib/hwp/types.js +5 -0
  47. package/dist/lib/hwpxReader.d.ts +60 -0
  48. package/dist/lib/hwpxReader.js +1104 -0
  49. package/dist/lib/types.d.ts +47 -0
  50. package/dist/lib/types.js +1 -0
  51. package/dist/lib/writer.d.ts +19 -0
  52. package/dist/lib/writer.js +149 -0
  53. package/package.json +94 -0
@@ -0,0 +1,633 @@
1
+ /**
2
+ * HwpDocument IR → HWPX(OWPML) 패키지 빌더 (스타일 보존 포함).
3
+ *
4
+ * - DocInfo 의 fontFaces / charShapes / paraShapes / styles 를 header.xml refList 로 매핑
5
+ * - paragraph: paraPrIDRef = paraShapeId, styleIDRef = styleId
6
+ * - run: charPrIDRef = charShapeId
7
+ * - 표/이미지 + BinData 패키징 + manifest 등록
8
+ *
9
+ * 1차 포팅 한계: BorderFill/Numbering/TabDef 는 paraShape 의 참조 ID 만 보존하고
10
+ * 실제 정의는 default(0) 로 둠. 추후 단계에서 정의 자체도 옮길 예정.
11
+ */
12
+ import JSZip from "jszip";
13
+ import { detectImageMime } from "./binData.js";
14
+ import { MIMETYPE, OWPML_NS, DEFAULT_LINESEG, SEC_PR_XML, makeParaId, escapeXml, } from "./owpml.js";
15
+ const NS_OPF = "http://www.idpf.org/2007/opf/";
16
+ const NS_DC = "http://purl.org/dc/elements/1.1/";
17
+ const NS_OASIS_CONTAINER = "urn:oasis:names:tc:opendocument:xmlns:container";
18
+ const NS_OASIS_MANIFEST = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
19
+ const LANG_NAMES = ["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
20
+ export async function buildHwpxFromDocument(doc, options) {
21
+ const zip = new JSZip();
22
+ zip.file("mimetype", MIMETYPE, { compression: "STORE" });
23
+ // BinData 매니페스트 항목 사전 구성
24
+ const binEntries = [];
25
+ for (const [storageId, { data, extension }] of doc.binData) {
26
+ const ext = extension.toLowerCase();
27
+ binEntries.push({
28
+ id: `image${storageId}`,
29
+ href: `BinData/image${storageId}.${ext}`,
30
+ mediaType: detectImageMime(ext),
31
+ data,
32
+ });
33
+ }
34
+ // META-INF/container.xml
35
+ zip.file("META-INF/container.xml", `<?xml version="1.0" encoding="UTF-8"?>\n` +
36
+ `<container xmlns="${NS_OASIS_CONTAINER}">` +
37
+ `<rootfiles>` +
38
+ `<rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/>` +
39
+ `</rootfiles>` +
40
+ `</container>`);
41
+ // META-INF/manifest.xml
42
+ const manifestEntries = [
43
+ `<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwpml-package+xml"/>`,
44
+ `<manifest:file-entry manifest:full-path="version.xml" manifest:media-type="application/xml"/>`,
45
+ `<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="application/xml"/>`,
46
+ `<manifest:file-entry manifest:full-path="Contents/content.hpf" manifest:media-type="application/hwpml-package+xml"/>`,
47
+ `<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>`,
48
+ ];
49
+ for (let i = 0; i < doc.sections.length; i++) {
50
+ manifestEntries.push(`<manifest:file-entry manifest:full-path="Contents/section${i}.xml" manifest:media-type="application/xml"/>`);
51
+ }
52
+ for (const e of binEntries) {
53
+ manifestEntries.push(`<manifest:file-entry manifest:full-path="${e.href}" manifest:media-type="${e.mediaType}"/>`);
54
+ }
55
+ zip.file("META-INF/manifest.xml", `<?xml version="1.0" encoding="UTF-8"?>\n` +
56
+ `<manifest:manifest xmlns:manifest="${NS_OASIS_MANIFEST}">` +
57
+ manifestEntries.join("") +
58
+ `</manifest:manifest>`);
59
+ // version.xml
60
+ zip.file("version.xml", `<?xml version="1.0" encoding="UTF-8"?>\n` +
61
+ `<ha:HCFVersion xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" ha:targetApplication="WORDPROCESSOR" ha:major="${doc.header.version.major}" ha:minor="${doc.header.version.minor}" ha:micro="${doc.header.version.build}" ha:buildNumber="${doc.header.version.revision}"/>`);
62
+ // settings.xml
63
+ zip.file("settings.xml", `<?xml version="1.0" encoding="UTF-8"?>\n` +
64
+ `<ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app">` +
65
+ `<ha:CaretPosition ha:listIDRef="0" ha:paraIDRef="0" ha:pos="0"/>` +
66
+ `</ha:HWPApplicationSetting>`);
67
+ // OPF 매니페스트 + spine
68
+ const opfManifest = [
69
+ `<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>`,
70
+ ];
71
+ for (let i = 0; i < doc.sections.length; i++) {
72
+ opfManifest.push(`<opf:item id="section${i}" href="Contents/section${i}.xml" media-type="application/xml"/>`);
73
+ }
74
+ for (const e of binEntries) {
75
+ opfManifest.push(`<opf:item id="${e.id}" href="${e.href}" media-type="${e.mediaType}" isEmbeded="1"/>`);
76
+ }
77
+ const spineRefs = `<opf:itemref idref="header" linear="yes"/>` +
78
+ doc.sections.map((_, i) => `<opf:itemref idref="section${i}" linear="yes"/>`).join("");
79
+ zip.file("Contents/content.hpf", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
80
+ `<opf:package ${OWPML_NS} version="" unique-identifier="" id="">` +
81
+ `<opf:metadata>` +
82
+ `<dc:title>${escapeXml(options?.title ?? "")}</dc:title>` +
83
+ `<dc:creator>${escapeXml(options?.creator ?? "")}</dc:creator>` +
84
+ `<dc:format>application/hwpml-package+xml</dc:format>` +
85
+ `</opf:metadata>` +
86
+ `<opf:manifest>` +
87
+ opfManifest.join("") +
88
+ `</opf:manifest>` +
89
+ `<opf:spine>` +
90
+ spineRefs +
91
+ `</opf:spine>` +
92
+ `</opf:package>`);
93
+ // header.xml — DocInfo 기반 풀 빌드
94
+ zip.file("Contents/header.xml", buildHeaderXmlFromDocInfo(doc.docInfo, doc.sections.length));
95
+ // 섹션
96
+ for (let i = 0; i < doc.sections.length; i++) {
97
+ zip.file(`Contents/section${i}.xml`, buildSectionXml(doc.sections[i].paragraphs, binEntries));
98
+ }
99
+ // BinData
100
+ for (const e of binEntries) {
101
+ zip.file(e.href, e.data);
102
+ }
103
+ // Preview/PrvText.txt — 다른 HWP 뷰어 호환을 위한 평문 미리보기
104
+ zip.file("Preview/PrvText.txt", buildPrvText(doc));
105
+ return await zip.generateAsync({ type: "uint8array" });
106
+ }
107
+ /**
108
+ * 한컴 HWP/HWPX 의 Preview/PrvText.txt 형식을 따른 미리보기 평문 생성.
109
+ * - 셀은 "<셀텍스트 >" 로 감싸 행 단위로 나열
110
+ * - 행 사이는 \r\n
111
+ * - 일반 문단은 그대로
112
+ */
113
+ function buildPrvText(doc) {
114
+ const lines = [];
115
+ for (const section of doc.sections) {
116
+ for (const para of section.paragraphs) {
117
+ collectPrvLines(para, lines);
118
+ }
119
+ }
120
+ // 약 2KB 까지만 보존 (Hancom 제한)
121
+ return lines.join("\r\n").slice(0, 2000);
122
+ }
123
+ function collectPrvLines(para, lines) {
124
+ if (para.text.length > 0) {
125
+ lines.push(para.text);
126
+ }
127
+ for (const ctrl of para.controls) {
128
+ if (ctrl.kind === "table") {
129
+ // 행별로 셀을 < ... > 로 감싸 join
130
+ const rows = Array.from({ length: ctrl.rowCount }, () => []);
131
+ for (const cell of ctrl.cells) {
132
+ if (cell.row >= 0 && cell.row < ctrl.rowCount)
133
+ rows[cell.row].push(cell);
134
+ }
135
+ for (const row of rows) {
136
+ row.sort((a, b) => a.col - b.col);
137
+ const cellTexts = row.map((cell) => {
138
+ const inner = cell.paragraphs
139
+ .map((q) => {
140
+ const buf = [];
141
+ collectPrvLines(q, buf);
142
+ return buf.join(" ");
143
+ })
144
+ .join(" ");
145
+ return `<${inner} >`;
146
+ });
147
+ if (cellTexts.length > 0)
148
+ lines.push(cellTexts.join(""));
149
+ }
150
+ }
151
+ else if (ctrl.kind === "header" ||
152
+ ctrl.kind === "footer" ||
153
+ ctrl.kind === "footnote") {
154
+ for (const q of ctrl.paragraphs)
155
+ collectPrvLines(q, lines);
156
+ }
157
+ else if (ctrl.kind === "equation" && ctrl.script.length > 0) {
158
+ lines.push(ctrl.script);
159
+ }
160
+ }
161
+ }
162
+ // ============================================================
163
+ // header.xml 빌드 (DocInfo → refList)
164
+ // ============================================================
165
+ function buildHeaderXmlFromDocInfo(docInfo, secCnt) {
166
+ const fontfacesXml = buildFontfacesXml(docInfo.fontFaces);
167
+ const borderFillsXml = buildBorderFillsXml(docInfo.borderFills);
168
+ const charPropsXml = buildCharPropertiesXml(docInfo.charShapes);
169
+ const tabDefsXml = buildTabDefsXml(docInfo.tabDefs);
170
+ const numberingsXml = buildNumberingsXml(docInfo.numberings);
171
+ const bulletsXml = buildBulletsXml(docInfo.bullets);
172
+ const paraPropsXml = buildParaPropertiesXml(docInfo.paraShapes);
173
+ const stylesXml = buildStylesXml(docInfo.styles);
174
+ return (`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
175
+ `<hh:head ${OWPML_NS} version="1.5" secCnt="${Math.max(1, secCnt)}">` +
176
+ `<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>` +
177
+ `<hh:refList>` +
178
+ fontfacesXml +
179
+ borderFillsXml +
180
+ charPropsXml +
181
+ tabDefsXml +
182
+ numberingsXml +
183
+ bulletsXml +
184
+ paraPropsXml +
185
+ stylesXml +
186
+ `</hh:refList>` +
187
+ `</hh:head>`);
188
+ }
189
+ function buildBorderFillsXml(borderFills) {
190
+ const cnt = Math.max(1, borderFills.length);
191
+ const items = [];
192
+ for (let i = 0; i < cnt; i++) {
193
+ items.push(buildSingleBorderFillXml(i, borderFills[i]));
194
+ }
195
+ return `<hh:borderFills itemCnt="${cnt}">${items.join("")}</hh:borderFills>`;
196
+ }
197
+ /** HWP 너비 인덱스 → mm 매핑 (HWP 5.0 스펙) */
198
+ const BORDER_WIDTH_MM = [
199
+ "0.1", "0.12", "0.15", "0.2", "0.25", "0.3", "0.4", "0.5",
200
+ "0.6", "0.7", "1.0", "1.5", "2.0", "3.0", "4.0", "5.0",
201
+ ];
202
+ const BORDER_LINE_TYPE_NAMES = [
203
+ "NONE", "SOLID", "DASH", "DOT", "DASH_DOT", "DASH_DOT_DOT", "LONG_DASH", "CIRCLE",
204
+ "DOUBLE", "THIN_THICK_DOUBLE", "THICK_THIN_DOUBLE", "THIN_THICK_THIN_TRIPLE",
205
+ "WAVE", "DOUBLE_WAVE", "THICK_3D", "THICK_3D_REVERSE", "THIN_3D", "THIN_3D_REVERSE",
206
+ ];
207
+ function lineTypeName(idx) {
208
+ return BORDER_LINE_TYPE_NAMES[idx] ?? "SOLID";
209
+ }
210
+ function widthMm(idx) {
211
+ return (BORDER_WIDTH_MM[idx] ?? "0.1") + " mm";
212
+ }
213
+ function buildBorderXml(tagName, line) {
214
+ if (!line) {
215
+ return `<hh:${tagName} type="SOLID" width="0.1 mm" color="#000000"/>`;
216
+ }
217
+ return `<hh:${tagName} type="${lineTypeName(line.lineType)}" width="${widthMm(line.widthIndex)}" color="${colorBgrToHex(line.color)}"/>`;
218
+ }
219
+ function buildSingleBorderFillXml(id, bf) {
220
+ const left = buildBorderXml("leftBorder", bf?.borders?.[0]);
221
+ const right = buildBorderXml("rightBorder", bf?.borders?.[1]);
222
+ const top = buildBorderXml("topBorder", bf?.borders?.[2]);
223
+ const bottom = buildBorderXml("bottomBorder", bf?.borders?.[3]);
224
+ // BorderFill attr u16 비트필드:
225
+ // bit 0: 3D, bit 1: 그림자
226
+ // bit 2..4 (0x1C): slash 대각선 모양 — 0=NONE, 그 외=present
227
+ // bit 5..7 (0xE0): backSlash 대각선 모양
228
+ const attr = bf?.attr ?? 0;
229
+ const slashKind = (attr >>> 2) & 0x07;
230
+ const backSlashKind = (attr >>> 5) & 0x07;
231
+ const diagWidth = widthMm(bf?.diagonal?.widthIndex ?? 0);
232
+ const diagColor = colorBgrToHex(bf?.diagonal?.color ?? 0);
233
+ const slashType = slashKind !== 0 ? "SOLID" : "NONE";
234
+ const backSlashType = backSlashKind !== 0 ? "SOLID" : "NONE";
235
+ // <hh:diagonal> 의 type 은 둘 중 하나라도 있으면 SOLID
236
+ const hasDiag = slashKind !== 0 || backSlashKind !== 0;
237
+ const diagonalEl = `<hh:diagonal type="${hasDiag ? "SOLID" : "NONE"}" width="${diagWidth}" color="${diagColor}"/>`;
238
+ const fillEl = bf?.fill
239
+ ? `<hh:fillBrush>` +
240
+ `<hh:winBrush faceColor="${colorBgrToHex(bf.fill.backgroundColor)}" hatchColor="${colorBgrToHex(bf.fill.patternColor)}" hatchStyle="${bf.fill.patternType < 0 ? "NONE" : "HORIZONTAL"}" alpha="0"/>` +
241
+ `</hh:fillBrush>`
242
+ : "";
243
+ return (`<hh:borderFill id="${id}" threeD="${(attr & 0x01) !== 0 ? 1 : 0}" shadow="${(attr & 0x02) !== 0 ? 1 : 0}" centerLine="NONE" breakCellSeparateLine="0">` +
244
+ `<hh:slash type="${slashType}" Crooked="0" isCounter="0"/>` +
245
+ `<hh:backSlash type="${backSlashType}" Crooked="0" isCounter="0"/>` +
246
+ left +
247
+ right +
248
+ top +
249
+ bottom +
250
+ diagonalEl +
251
+ fillEl +
252
+ `</hh:borderFill>`);
253
+ }
254
+ function buildTabDefsXml(tabDefs) {
255
+ const cnt = Math.max(1, tabDefs.length);
256
+ const items = [];
257
+ for (let i = 0; i < cnt; i++) {
258
+ const td = tabDefs[i];
259
+ const al = td?.autoTabLeft ?? true ? 1 : 0;
260
+ const ar = td?.autoTabRight ?? true ? 1 : 0;
261
+ items.push(`<hh:tabPr id="${i}" autoTabLeft="${al}" autoTabRight="${ar}">` +
262
+ `<hh:items itemCnt="0"/>` +
263
+ `</hh:tabPr>`);
264
+ }
265
+ return `<hh:tabProperties itemCnt="${cnt}">${items.join("")}</hh:tabProperties>`;
266
+ }
267
+ function buildNumberingsXml(numberings) {
268
+ if (numberings.length === 0) {
269
+ return (`<hh:numberings itemCnt="1">` +
270
+ `<hh:numbering id="0" start="1">` +
271
+ Array.from({ length: 7 })
272
+ .map((_, level) => `<hh:paraHead level="${level + 1}" start="1" numFormat="^${level + 1}." textOffsetType="PERCENT" textOffset="50" numberingChar="false" charPrIDRef="0">` +
273
+ `<hh:autoNumberFormat type="DIGIT" userChar="" prefixChar="" suffixChar="."/>` +
274
+ `</hh:paraHead>`)
275
+ .join("") +
276
+ `</hh:numbering>` +
277
+ `</hh:numberings>`);
278
+ }
279
+ const items = numberings
280
+ .map((n, idx) => `<hh:numbering id="${idx}" start="${n.startNumber}">` +
281
+ n.levelFormats
282
+ .map((fmt, level) => `<hh:paraHead level="${level + 1}" start="1" numFormat="${escapeXml(fmt || "^" + (level + 1) + ".")}" textOffsetType="PERCENT" textOffset="50" numberingChar="false" charPrIDRef="0">` +
283
+ `<hh:autoNumberFormat type="DIGIT" userChar="" prefixChar="" suffixChar="."/>` +
284
+ `</hh:paraHead>`)
285
+ .join("") +
286
+ `</hh:numbering>`)
287
+ .join("");
288
+ return `<hh:numberings itemCnt="${numberings.length}">${items}</hh:numberings>`;
289
+ }
290
+ function buildBulletsXml(bullets) {
291
+ if (bullets.length === 0) {
292
+ return (`<hh:bullets itemCnt="1">` +
293
+ `<hh:bullet id="0" char="●" imageBullet="0" checkedChar="0">` +
294
+ `<hh:img bright="0" contrast="0" effect="REAL_PIC" binaryItemIDRef="0"/>` +
295
+ `</hh:bullet>` +
296
+ `</hh:bullets>`);
297
+ }
298
+ const items = bullets
299
+ .map((b, idx) => `<hh:bullet id="${idx}" char="${escapeXml(b.bulletChar)}" imageBullet="0" checkedChar="0">` +
300
+ `<hh:img bright="0" contrast="0" effect="REAL_PIC" binaryItemIDRef="0"/>` +
301
+ `</hh:bullet>`)
302
+ .join("");
303
+ return `<hh:bullets itemCnt="${bullets.length}">${items}</hh:bullets>`;
304
+ }
305
+ function buildFontfacesXml(fontFaces) {
306
+ // 7개 언어 그룹 — 비어있어도 lang 속성은 넣어둔다.
307
+ // 그룹 안 폰트가 0개면 단일 fallback "바탕"
308
+ const groups = [];
309
+ for (let li = 0; li < 7; li++) {
310
+ const fonts = fontFaces[li] ?? [];
311
+ const lang = LANG_NAMES[li];
312
+ const list = fonts.length > 0
313
+ ? fonts.map((f, idx) => buildFontXml(idx, f)).join("")
314
+ : `<hh:font id="0" face="바탕" type="TTF" isEmbedded="0"/>`;
315
+ const cnt = fonts.length > 0 ? fonts.length : 1;
316
+ groups.push(`<hh:fontface lang="${lang}" fontCnt="${cnt}">${list}</hh:fontface>`);
317
+ }
318
+ return `<hh:fontfaces itemCnt="${groups.length}">${groups.join("")}</hh:fontfaces>`;
319
+ }
320
+ function buildFontXml(id, f) {
321
+ const subAttrs = f.substituteName ? ` type="UNKNOWN" face="${escapeXml(f.substituteName)}"` : "";
322
+ const sub = f.substituteName ? `<hh:substFont${subAttrs}/>` : "";
323
+ return `<hh:font id="${id}" face="${escapeXml(f.name)}" type="TTF" isEmbedded="0">${sub}</hh:font>`;
324
+ }
325
+ function buildCharPropertiesXml(charShapes) {
326
+ if (charShapes.length === 0) {
327
+ // 최소 1개 fallback
328
+ return (`<hh:charProperties itemCnt="1">` +
329
+ `<hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="0">` +
330
+ defaultFontGroupXml() +
331
+ `</hh:charPr>` +
332
+ `</hh:charProperties>`);
333
+ }
334
+ const items = charShapes.map((cs, idx) => buildCharPrXml(idx, cs)).join("");
335
+ return `<hh:charProperties itemCnt="${charShapes.length}">${items}</hh:charProperties>`;
336
+ }
337
+ function buildCharPrXml(id, cs) {
338
+ const ids = cs.faceNameIds;
339
+ const fontRef = `<hh:fontRef hangul="${ids.hangul}" latin="${ids.latin}" hanja="${ids.hanja}" japanese="${ids.japanese}" other="${ids.other}" symbol="${ids.symbol}" user="${ids.user}"/>`;
340
+ const ratio = `<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>`;
341
+ const spacing = `<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>`;
342
+ const relSz = `<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>`;
343
+ const offset = `<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>`;
344
+ const italic = cs.italic ? `<hh:italic/>` : "";
345
+ const bold = cs.bold ? `<hh:bold/>` : "";
346
+ const underline = cs.underline
347
+ ? `<hh:underline type="BOTTOM" shape="SOLID" color="${colorBgrToHex(cs.underlineColor)}"/>`
348
+ : "";
349
+ const strikeout = cs.strikeout
350
+ ? `<hh:strikeout shape="SOLID" color="${colorBgrToHex(cs.textColor)}"/>`
351
+ : "";
352
+ const textColor = colorBgrToHex(cs.textColor);
353
+ const shadeColor = cs.shadeColor === 0xffffff || cs.shadeColor === 0 ? "none" : colorBgrToHex(cs.shadeColor);
354
+ return (`<hh:charPr id="${id}" height="${cs.baseSize}" textColor="${textColor}" shadeColor="${shadeColor}" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="0">` +
355
+ fontRef +
356
+ ratio +
357
+ spacing +
358
+ relSz +
359
+ offset +
360
+ italic +
361
+ bold +
362
+ underline +
363
+ strikeout +
364
+ `</hh:charPr>`);
365
+ }
366
+ function defaultFontGroupXml() {
367
+ return (`<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
368
+ `<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
369
+ `<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
370
+ `<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
371
+ `<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>`);
372
+ }
373
+ function buildParaPropertiesXml(paraShapes) {
374
+ if (paraShapes.length === 0) {
375
+ return (`<hh:paraProperties itemCnt="1">` +
376
+ `<hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">` +
377
+ `<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>` +
378
+ `<hh:heading type="NONE" idRef="0" level="0"/>` +
379
+ `<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>` +
380
+ `<hh:margin><hh:intent value="0"/><hh:left value="0"/><hh:right value="0"/><hh:prev value="0"/><hh:next value="0"/></hh:margin>` +
381
+ `<hh:lineSpacing type="PERCENT" value="160"/>` +
382
+ `</hh:paraPr>` +
383
+ `</hh:paraProperties>`);
384
+ }
385
+ const items = paraShapes.map((ps, idx) => buildParaPrXml(idx, ps)).join("");
386
+ return `<hh:paraProperties itemCnt="${paraShapes.length}">${items}</hh:paraProperties>`;
387
+ }
388
+ function buildParaPrXml(id, ps) {
389
+ const align = alignToOwpml(ps.alignment);
390
+ return (`<hh:paraPr id="${id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">` +
391
+ `<hh:align horizontal="${align}" vertical="BASELINE"/>` +
392
+ `<hh:heading type="NONE" idRef="0" level="0"/>` +
393
+ `<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>` +
394
+ `<hh:margin>` +
395
+ `<hh:intent value="${ps.indent}"/>` +
396
+ `<hh:left value="${ps.leftMargin}"/>` +
397
+ `<hh:right value="${ps.rightMargin}"/>` +
398
+ `<hh:prev value="${ps.prevSpacing}"/>` +
399
+ `<hh:next value="${ps.nextSpacing}"/>` +
400
+ `</hh:margin>` +
401
+ `<hh:lineSpacing type="PERCENT" value="${Math.max(0, ps.lineSpacing)}"/>` +
402
+ `</hh:paraPr>`);
403
+ }
404
+ function buildStylesXml(styles) {
405
+ if (styles.length === 0) {
406
+ return (`<hh:styles itemCnt="1">` +
407
+ `<hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>` +
408
+ `</hh:styles>`);
409
+ }
410
+ const items = styles
411
+ .map((s, idx) => `<hh:style id="${idx}" type="PARA" name="${escapeXml(s.name || "Style" + idx)}" engName="${escapeXml(s.engName ?? "")}" paraPrIDRef="${s.paraShapeId}" charPrIDRef="${s.charShapeId}" nextStyleIDRef="${idx}" langID="1042" lockForm="0"/>`)
412
+ .join("");
413
+ return `<hh:styles itemCnt="${styles.length}">${items}</hh:styles>`;
414
+ }
415
+ // ============================================================
416
+ // 색상 / 정렬 변환
417
+ // ============================================================
418
+ /** HWP ColorRef (u32 LE 의 0xAABBGGRR 형식) → "#RRGGBB" */
419
+ export function colorBgrToHex(color) {
420
+ const r = color & 0xff;
421
+ const g = (color >>> 8) & 0xff;
422
+ const b = (color >>> 16) & 0xff;
423
+ return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0").toUpperCase()).join("");
424
+ }
425
+ function alignToOwpml(a) {
426
+ switch (a) {
427
+ case "left":
428
+ return "LEFT";
429
+ case "right":
430
+ return "RIGHT";
431
+ case "center":
432
+ return "CENTER";
433
+ case "justify":
434
+ return "JUSTIFY";
435
+ case "distribute":
436
+ return "DISTRIBUTE";
437
+ case "distributeSpace":
438
+ return "DISTRIBUTE_SPACE";
439
+ default:
440
+ return "JUSTIFY";
441
+ }
442
+ }
443
+ // ============================================================
444
+ // section.xml 빌드
445
+ // ============================================================
446
+ function buildSectionXml(paragraphs, binEntries) {
447
+ // 본 문단 + 머리말/꼬리말/각주 인라인 보강
448
+ const parts = [];
449
+ // 섹션 첫 문단에 secPr(페이지 설정) — 한컴이 섹션을 구성하는 데 필수
450
+ parts.push(`<hp:p id="${makeParaId()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
451
+ `<hp:run charPrIDRef="0">${SEC_PR_XML}</hp:run>` +
452
+ `<hp:run charPrIDRef="0"><hp:t/></hp:run>` +
453
+ DEFAULT_LINESEG +
454
+ `</hp:p>`);
455
+ for (const p of paragraphs) {
456
+ parts.push(buildParagraphXml(p, binEntries));
457
+ // 같은 paragraph 안의 header/footer/footnote 컨트롤이 가진 paragraphs 도 본문 흐름에 평탄 출력
458
+ for (const ctrl of p.controls) {
459
+ if (ctrl.kind === "header" ||
460
+ ctrl.kind === "footer" ||
461
+ ctrl.kind === "footnote") {
462
+ for (const subPara of ctrl.paragraphs) {
463
+ parts.push(buildParagraphXml(subPara, binEntries));
464
+ }
465
+ }
466
+ }
467
+ }
468
+ return (`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
469
+ `<hs:sec ${OWPML_NS}>` +
470
+ parts.join("") +
471
+ `</hs:sec>`);
472
+ }
473
+ function buildParagraphXml(p, binEntries) {
474
+ const parts = [];
475
+ // 텍스트 run 들 (charShape 별로 분리됨)
476
+ if (p.runs.length > 0) {
477
+ for (const run of p.runs) {
478
+ parts.push(buildRunXml(run));
479
+ }
480
+ }
481
+ else if (p.text.length > 0) {
482
+ parts.push(buildRunXml({ charShapeId: 0, text: p.text }));
483
+ }
484
+ // 컨트롤 (표/그림/...)
485
+ for (const ctrl of p.controls) {
486
+ const xml = buildControlXml(ctrl, binEntries);
487
+ if (xml)
488
+ parts.push(xml);
489
+ }
490
+ if (parts.length === 0) {
491
+ parts.push(`<hp:run charPrIDRef="0"/>`);
492
+ }
493
+ return (`<hp:p id="${makeParaId()}" paraPrIDRef="${p.paraShapeId}" styleIDRef="${p.styleId}" pageBreak="0" columnBreak="0" merged="0">` +
494
+ parts.join("") +
495
+ DEFAULT_LINESEG +
496
+ `</hp:p>`);
497
+ }
498
+ function buildRunXml(run) {
499
+ return `<hp:run charPrIDRef="${run.charShapeId}"><hp:t>${escapeXml(run.text)}</hp:t></hp:run>`;
500
+ }
501
+ // 이미지 표시 기본 크기(HWPUNIT). 원본 픽셀을 모르므로 고정값 — 한컴이 비율 보정.
502
+ const PIC_WIDTH = 40000;
503
+ const PIC_HEIGHT = 30000;
504
+ /**
505
+ * 한컴 정상 hp:pic 구조(etc/hwpjs_image_test 기준).
506
+ * orgSz=curSz 1:1, 단위행렬 — 한글이 실제 크기를 재계산한다.
507
+ */
508
+ function buildPicXml(entry) {
509
+ const w = PIC_WIDTH;
510
+ const h = PIC_HEIGHT;
511
+ return (`<hp:pic id="${makeParaId()}" zOrder="0" numberingType="PICTURE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" ` +
512
+ `lock="0" dropcapstyle="None" href="" groupLevel="0" instid="${makeParaId()}" reverse="0">` +
513
+ `<hp:offset x="0" y="0"/>` +
514
+ `<hp:orgSz width="${w}" height="${h}"/>` +
515
+ `<hp:curSz width="${w}" height="${h}"/>` +
516
+ `<hp:flip horizontal="0" vertical="0"/>` +
517
+ `<hp:rotationInfo angle="0" centerX="${(w / 2) | 0}" centerY="${(h / 2) | 0}" rotateimage="1"/>` +
518
+ `<hp:renderingInfo>` +
519
+ `<hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
520
+ `<hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
521
+ `<hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
522
+ `</hp:renderingInfo>` +
523
+ `<hc:img binaryItemIDRef="${entry.id}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>` +
524
+ `<hp:imgRect><hc:pt0 x="0" y="0"/><hc:pt1 x="${w}" y="0"/><hc:pt2 x="${w}" y="${h}"/><hc:pt3 x="0" y="${h}"/></hp:imgRect>` +
525
+ `<hp:imgClip left="0" right="${w}" top="0" bottom="${h}"/>` +
526
+ `<hp:inMargin left="0" right="0" top="0" bottom="0"/>` +
527
+ `<hp:imgDim dimwidth="${w}" dimheight="${h}"/>` +
528
+ `<hp:effects/>` +
529
+ `<hp:sz width="${w}" widthRelTo="ABSOLUTE" height="${h}" heightRelTo="ABSOLUTE" protect="0"/>` +
530
+ `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" ` +
531
+ `vertRelTo="PARA" horzRelTo="COLUMN" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
532
+ `<hp:outMargin left="0" right="0" top="0" bottom="0"/>` +
533
+ `</hp:pic>`);
534
+ }
535
+ function buildControlXml(ctrl, binEntries) {
536
+ switch (ctrl.kind) {
537
+ case "table":
538
+ return `<hp:run charPrIDRef="0">${buildTableXml(ctrl, binEntries)}</hp:run>`;
539
+ case "picture": {
540
+ const entry = binEntries.find((b) => b.id === `image${ctrl.binDataId}`);
541
+ if (!entry)
542
+ return "";
543
+ return `<hp:run charPrIDRef="0">${buildPicXml(entry)}</hp:run>`;
544
+ }
545
+ case "shape": {
546
+ // 도형: 1차 포팅에서는 placeholder. line 은 좌표만 보존.
547
+ const tag = ctrl.shapeType === "line"
548
+ ? "line"
549
+ : ctrl.shapeType === "rectangle"
550
+ ? "rect"
551
+ : ctrl.shapeType === "ellipse"
552
+ ? "ellipse"
553
+ : ctrl.shapeType === "arc"
554
+ ? "arc"
555
+ : ctrl.shapeType === "polygon"
556
+ ? "polygon"
557
+ : "curve";
558
+ const coords = ctrl.shapeType === "line" && ctrl.x1 !== undefined
559
+ ? `<hc:startPt x="${ctrl.x1}" y="${ctrl.y1 ?? 0}"/><hc:endPt x="${ctrl.x2 ?? 0}" y="${ctrl.y2 ?? 0}"/>`
560
+ : "";
561
+ return `<hp:run charPrIDRef="0"><hp:${tag}>${coords}</hp:${tag}></hp:run>`;
562
+ }
563
+ case "equation": {
564
+ if (ctrl.script.length === 0)
565
+ return "";
566
+ return (`<hp:run charPrIDRef="0">` +
567
+ `<hp:equation>` +
568
+ `<hp:script>${escapeXml(ctrl.script)}</hp:script>` +
569
+ `</hp:equation>` +
570
+ `</hp:run>`);
571
+ }
572
+ case "header":
573
+ case "footer":
574
+ case "footnote":
575
+ case "field":
576
+ case "unknown":
577
+ return "";
578
+ }
579
+ }
580
+ // 본문 가용 폭(HWPUNIT) — SEC_PR_XML 의 pagePr(width 59528, 좌우 margin 8504) 기준.
581
+ const TABLE_BODY_WIDTH = 42520;
582
+ const DEFAULT_ROW_HEIGHT = 2000; // 한글이 실제 높이를 재계산하므로 추정값으로 충분
583
+ function buildTableXml(t, binEntries) {
584
+ const colCount = Math.max(1, t.colCount);
585
+ const rowCount = Math.max(1, t.rowCount);
586
+ const cellW = Math.max(1, Math.floor(TABLE_BODY_WIDTH / colCount));
587
+ const tableW = cellW * colCount;
588
+ const tableH = DEFAULT_ROW_HEIGHT * rowCount;
589
+ const rows = Array.from({ length: t.rowCount }, () => []);
590
+ for (const cell of t.cells) {
591
+ if (cell.row >= 0 && cell.row < t.rowCount)
592
+ rows[cell.row].push(cell);
593
+ }
594
+ for (const row of rows)
595
+ row.sort((a, b) => a.col - b.col);
596
+ const subListAttrs = `id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" ` +
597
+ `linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0"`;
598
+ const trXml = rows
599
+ .map((row) => {
600
+ const tcXml = row
601
+ .map((cell) => {
602
+ const cellInner = cell.paragraphs
603
+ .map((q) => buildParagraphXml(q, binEntries))
604
+ .join("");
605
+ const colSpan = Math.max(1, cell.colSpan);
606
+ const rowSpan = Math.max(1, cell.rowSpan);
607
+ const cw = cellW * colSpan;
608
+ const ch = DEFAULT_ROW_HEIGHT * rowSpan;
609
+ const inner = cellInner ||
610
+ `<hp:p id="${makeParaId()}" paraPrIDRef="0" styleIDRef="0"><hp:run charPrIDRef="0"/>${DEFAULT_LINESEG}</hp:p>`;
611
+ return (`<hp:tc name="" header="0" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="0">` +
612
+ `<hp:subList ${subListAttrs}>${inner}</hp:subList>` +
613
+ `<hp:cellAddr colAddr="${cell.col}" rowAddr="${cell.row}"/>` +
614
+ `<hp:cellSpan colSpan="${colSpan}" rowSpan="${rowSpan}"/>` +
615
+ `<hp:cellSz width="${cw}" height="${ch}"/>` +
616
+ `<hp:cellMargin left="510" right="510" top="141" bottom="141"/>` +
617
+ `</hp:tc>`);
618
+ })
619
+ .join("");
620
+ return `<hp:tr>${tcXml}</hp:tr>`;
621
+ })
622
+ .join("");
623
+ return (`<hp:tbl id="${makeParaId()}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" ` +
624
+ `lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="1" rowCnt="${rowCount}" colCnt="${colCount}" ` +
625
+ `cellSpacing="0" borderFillIDRef="0" noAdjust="0">` +
626
+ `<hp:sz width="${tableW}" widthRelTo="ABSOLUTE" height="${tableH}" heightRelTo="ABSOLUTE" protect="0"/>` +
627
+ `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" ` +
628
+ `vertRelTo="PARA" horzRelTo="COLUMN" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
629
+ `<hp:outMargin left="283" right="283" top="283" bottom="283"/>` +
630
+ `<hp:inMargin left="510" right="510" top="141" bottom="141"/>` +
631
+ trXml +
632
+ `</hp:tbl>`);
633
+ }