hwpkit-dev 0.0.3 → 0.0.5

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hwpkit-dev",
3
3
  "description": "HWP/HWPX/DOCX/MD 양방향 문서 변환 라이브러리",
4
- "version": "0.0.3",
4
+ "version": "0.0.5",
5
5
  "author": {
6
6
  "name": "INMD1",
7
7
  "email": "lyw514549@gmail.com",
@@ -1,9 +1,9 @@
1
1
  import type { Decoder } from '../../contract/decoder';
2
- import type { DocRoot, ContentNode, ParaNode, SpanNode, ImgNode, GridNode } from '../../model/doc-tree';
2
+ import type { DocRoot, ContentNode, ParaNode, SpanNode, ImgNode, GridNode, PageNumNode } from '../../model/doc-tree';
3
3
  import type { Outcome } from '../../contract/result';
4
4
  import type { Align, Stroke, StrokeKind, PageDims, TextProps, ParaProps, CellProps, GridProps } from '../../model/doc-props';
5
5
  import { succeed, fail } from '../../contract/result';
6
- import { buildRoot, buildSheet, buildPara, buildSpan, buildGrid, buildRow, buildCell, buildImg } from '../../model/builders';
6
+ import { buildRoot, buildSheet, buildPara, buildSpan, buildGrid, buildRow, buildCell, buildImg, buildPb, buildPageNum } from '../../model/builders';
7
7
  import { ShieldedParser } from '../../safety/ShieldedParser';
8
8
  import { BinaryKit } from '../../toolkit/BinaryKit';
9
9
  import { TextKit } from '../../toolkit/TextKit';
@@ -39,11 +39,14 @@ function isTableTag(t: number) { return t === TAG_TABLE_A || t === TAG_TABLE_B;
39
39
  function isCellTag(t: number) { return t === TAG_CELL_A || t === TAG_CELL_B || t === TAG_LIST_HEADER; }
40
40
 
41
41
  // CTRL_HEADER ctrlId values (UINT32-LE as ASCII)
42
- const CTRL_TABLE = 0x74626C20; // ' lbt' = 표(table)
42
+ const CTRL_TABLE = 0x74626C20; // 'tbl ' = 표(table)
43
43
  const CTRL_IMAGE = 0x696D6720; // 'img '
44
44
  const CTRL_OBJ = 0x6F626A20; // 'obj '
45
45
  const CTRL_FIG = 0x66696720; // 'fig '
46
46
  const CTRL_GSO = 0x67736F20; // 'gso ' = 그리기 객체 (drawing object, contains embedded images)
47
+ const CTRL_HEAD = 0x68656164; // 'head' = 머리말
48
+ const CTRL_FOOT = 0x666F6F74; // 'foot' = 꼬리말
49
+ const CTRL_ATNO = 0x61746E6F; // 'atno' = 자동 번호 (쪽번호 등)
47
50
 
48
51
  /* ═══════════════════════════════════════════════════════════════
49
52
  Types
@@ -66,16 +69,18 @@ interface HwpCharShape {
66
69
  subscript: boolean;
67
70
  textColor: string;
68
71
  }
69
-
70
72
  interface HwpParaShape {
71
73
  align: Align;
72
74
  spaceBefore: number;
73
75
  spaceAfter: number;
74
76
  lineSpacing: number;
77
+ lineSpacingType: 0 | 1 | 2 | 3; // 0=PERCENT, 1=FIXED, 2=BETWEEN_LINES, 3=AT_LEAST
75
78
  leftMargin: number;
79
+ rightMargin: number;
76
80
  indent: number;
81
+ verAlign?: 'baseline' | 'top' | 'center' | 'bottom';
82
+ lineWrap?: 'break' | 'squeeze' | 'keep';
77
83
  }
78
-
79
84
  interface HwpBorderFill {
80
85
  borders: { type: number; widthPt: number; color: string }[];
81
86
  bgColor?: string;
@@ -213,7 +218,7 @@ function parseCharShape(d: Uint8Array): HwpCharShape {
213
218
 
214
219
  /* ── PARA_SHAPE ─────────────────────────────────────────────── */
215
220
  /* offset size field
216
- 0 4 attr1 (bits 0-1 = alignment: 0=justify,1=left,2=right,3=center)
221
+ 0 4 attr1 (bits 0-1 = line spacing type, bits 2-4 = alignment)
217
222
  4 4 leftMargin (HWPUNIT)
218
223
  8 4 rightMargin
219
224
  12 4 indent
@@ -221,18 +226,36 @@ function parseCharShape(d: Uint8Array): HwpCharShape {
221
226
  20 4 spaceAfter
222
227
  24 4 lineSpacing */
223
228
 
224
- const ALIGN_TBL: Record<number, Align> = { 0: 'justify', 1: 'left', 2: 'right', 3: 'center', 4: 'justify' };
229
+ const ALIGN_TBL: Record<number, Align> = { 0: 'justify', 1: 'left', 2: 'right', 3: 'center', 4: 'distribute', 5: 'distribute_space' };
225
230
 
226
231
  function parseParaShape(d: Uint8Array): HwpParaShape {
227
- if (d.length < 4) return { align: 'left', spaceBefore: 0, spaceAfter: 0, lineSpacing: 160, leftMargin: 0, indent: 0 };
232
+ if (d.length < 4) return { align: 'left', spaceBefore: 0, spaceAfter: 0, lineSpacing: 160, lineSpacingType: 0, leftMargin: 0, rightMargin: 0, indent: 0 };
228
233
  const attr = BinaryKit.readU32LE(d, 0);
234
+
235
+ // bits 0-1: 줄 간격 종류 (0=PERCENT, 1=FIXED, 2=BETWEEN_LINES, 3=AT_LEAST)
236
+ const lineSpacingType = (attr & 0x3) as 0 | 1 | 2 | 3;
237
+
238
+ // bits 2-4: 정렬 방식 (0=justify,1=left,2=right,3=center,4=distribute,5=split)
239
+ const align = ALIGN_TBL[(attr >> 2) & 0x7] ?? 'left';
240
+
241
+ // 세로 정렬 (Bit 18 ~ Bit 19)
242
+ const vVal = (attr >> 18) & 0x3;
243
+ const verAlign = vVal === 1 ? 'top' : vVal === 2 ? 'center' : vVal === 3 ? 'bottom' : 'baseline';
244
+
245
+ // 줄 바꿈 기준: attr1 에는 별도 비트 없음, 기본값 'break'
246
+ const lineWrap: 'break' = 'break';
247
+
229
248
  return {
230
- align: ALIGN_TBL[(attr >> 2) & 0x7] ?? 'left',
231
- leftMargin: d.length >= 8 ? i32(d, 4) : 0, // offset 4: leftMargin (들여쓰기)
232
- indent: d.length >= 16 ? i32(d, 12) : 0, // offset 12: first-line indent
249
+ align,
250
+ lineSpacingType,
251
+ leftMargin: d.length >= 8 ? i32(d, 4) : 0, // offset 4: 문단 몸체 왼쪽 여백 (HWPUNIT)
252
+ rightMargin: d.length >= 12 ? i32(d, 8) : 0, // offset 8: 문단 몸체 오른쪽 여백 (HWPUNIT)
253
+ indent: d.length >= 16 ? i32(d, 12) : 0, // offset 12: 첫 줄 들여쓰기 (HWPUNIT)
233
254
  spaceBefore: d.length >= 20 ? i32(d, 16) : 0,
234
255
  spaceAfter: d.length >= 24 ? i32(d, 20) : 0,
235
256
  lineSpacing: d.length >= 28 ? i32(d, 24) : 160,
257
+ verAlign,
258
+ lineWrap,
236
259
  };
237
260
  }
238
261
 
@@ -282,7 +305,11 @@ function parseBorderFill(d: Uint8Array): HwpBorderFill {
282
305
  // gsoCtx: shared mutable counter for 'gso ' drawing objects.
283
306
  // Each 'gso ' CTRL_HEADER encountered increments this counter.
284
307
  // objectMap is keyed by 0-based gso order = sequential BinData insertion order.
285
- interface GsoCtx { count: number }
308
+ interface GsoCtx {
309
+ count: number;
310
+ headers?: ParaNode[];
311
+ footers?: ParaNode[];
312
+ }
286
313
 
287
314
  function parseBody(
288
315
  raw: Uint8Array, compressed: boolean, di: DocInfo, shield: ShieldedParser, gsoCtx: GsoCtx,
@@ -326,15 +353,20 @@ function parseParagraphGroup(
326
353
  const hdr = recs[start];
327
354
  const lv = hdr.level;
328
355
 
329
- // paraShapeId at offset 8 (UINT16)
330
- const psId = hdr.data.length >= 10 ? BinaryKit.readU16LE(hdr.data, 8) : 0;
331
- const ps = di.paraShapes[psId];
356
+ // P1: PARA_HEADER 레이아웃
357
+ // offset 8-9: paraShapeId (UINT16)
358
+ // offset 10: styleId (UINT8)
359
+ // offset 11: divideSort (UINT8) — 0x04=쪽나누기
360
+ const psId = hdr.data.length >= 10 ? BinaryKit.readU16LE(hdr.data, 8) : 0;
361
+ const hwpStyleId = hdr.data.length >= 11 ? hdr.data[10] : 0;
362
+ const divideSort = hdr.data.length >= 12 ? hdr.data[11] : 0;
363
+ const ps = di.paraShapes[psId];
332
364
 
333
365
  let text: ParaTextResult | null = null;
334
366
  let csPairs: [number, number][] = [];
335
367
  const grids: ContentNode[] = [];
336
368
  // imgId: for 'gso' uses sequential gsoCtx.count; for others uses flags-based objId
337
- const ctrlHeaders: { ctrlId: number; imgId: number; wPt: number; hPt: number }[] = [];
369
+ const ctrlHeaders: { ctrlId: number; imgId: number; wPt: number; hPt: number; atnoType?: number }[] = [];
338
370
  let i = start + 1;
339
371
 
340
372
  while (i < recs.length && recs[i].level > lv) {
@@ -350,29 +382,59 @@ function parseParagraphGroup(
350
382
  if (r.data.length >= 4) {
351
383
  const ctrlId = BinaryKit.readU32LE(r.data, 0);
352
384
 
353
- // HWP 5.0 general-object layout:
354
- // [0:4] ctrlId [4:4] flags [8:4] xOff [12:4] yOff
355
- // [16:4] width(HWPUNIT) [20:4] height(HWPUNIT)
356
- const MAX_HWP = 1_000_000;
357
- const rawW = r.data.length >= 24 ? BinaryKit.readU32LE(r.data, 16) : 0;
358
- const rawH = r.data.length >= 28 ? BinaryKit.readU32LE(r.data, 20) : 0;
359
- const wPt = rawW > 0 && rawW < MAX_HWP ? Metric.hwpToPt(rawW) : 0;
360
- const hPt = rawH > 0 && rawH < MAX_HWP ? Metric.hwpToPt(rawH) : 0;
361
-
362
- // 'gso ' (그리기 객체) uses sequential counter; others use flags-based id
363
- const imgId = ctrlId === CTRL_GSO ? gsoCtx.count++ : (r.data.length >= 6 ? BinaryKit.readU16LE(r.data, 4) : 0);
364
- ctrlHeaders.push({ ctrlId, imgId, wPt, hPt });
365
-
366
- if (ctrlId === CTRL_TABLE) {
367
- const tr = shield.guard(
368
- () => parseTableCtrl(recs, i, di, shield, gsoCtx),
369
- { grid: null, next: skipKids(recs, i) },
370
- `hwp:tbl@${i}`,
371
- );
372
- if (tr.grid) grids.push(tr.grid);
373
- i = tr.next;
385
+ if (ctrlId === CTRL_HEAD || ctrlId === CTRL_FOOT) {
386
+ // P8: 머리말/꼬리말 컨트롤 — 자식 문단을 파싱해 gsoCtx에 저장
387
+ const ctrlLv = r.level;
388
+ const hfParas: ParaNode[] = [];
389
+ let j = i + 1;
390
+ while (j < recs.length && recs[j].level > ctrlLv) {
391
+ if (recs[j].tag === TAG_PARA_HEADER) {
392
+ const pr = shield.guard(
393
+ () => parseParagraphGroup(recs, j, di, shield, gsoCtx),
394
+ { nodes: [] as ContentNode[], next: j + 1 },
395
+ `hwp:hf@${j}`,
396
+ );
397
+ hfParas.push(...pr.nodes.filter((n): n is ParaNode => n.tag === 'para'));
398
+ j = pr.next;
399
+ } else {
400
+ j++;
401
+ }
402
+ }
403
+ if (hfParas.length > 0) {
404
+ const key = ctrlId === CTRL_HEAD ? 'headers' : 'footers';
405
+ if (!gsoCtx[key]) gsoCtx[key] = hfParas;
406
+ }
407
+ i = j;
374
408
  } else {
375
- i = skipKids(recs, i);
409
+ // HWP 5.0 general-object layout:
410
+ // [0:4] ctrlId [4:4] flags [8:4] xOff [12:4] yOff
411
+ // [16:4] width(HWPUNIT) [20:4] height(HWPUNIT)
412
+ const MAX_HWP = 1_000_000;
413
+ const rawW = r.data.length >= 24 ? BinaryKit.readU32LE(r.data, 16) : 0;
414
+ const rawH = r.data.length >= 28 ? BinaryKit.readU32LE(r.data, 20) : 0;
415
+ const wPt = rawW > 0 && rawW < MAX_HWP ? Metric.hwpToPt(rawW) : 0;
416
+ const hPt = rawH > 0 && rawH < MAX_HWP ? Metric.hwpToPt(rawH) : 0;
417
+
418
+ // P9: atno — offset 4 u32 하위 4bit = 번호 종류 (0=쪽번호, 6=전체쪽수)
419
+ const atnoType = ctrlId === CTRL_ATNO && r.data.length >= 8
420
+ ? BinaryKit.readU32LE(r.data, 4) & 15
421
+ : undefined;
422
+
423
+ // 'gso ' (그리기 객체) uses sequential counter; others use flags-based id
424
+ const imgId = ctrlId === CTRL_GSO ? gsoCtx.count++ : (r.data.length >= 6 ? BinaryKit.readU16LE(r.data, 4) : 0);
425
+ ctrlHeaders.push({ ctrlId, imgId, wPt, hPt, atnoType });
426
+
427
+ if (ctrlId === CTRL_TABLE) {
428
+ const tr = shield.guard(
429
+ () => parseTableCtrl(recs, i, di, shield, gsoCtx),
430
+ { grid: null, next: skipKids(recs, i) },
431
+ `hwp:tbl@${i}`,
432
+ );
433
+ if (tr.grid) grids.push(tr.grid);
434
+ i = tr.next;
435
+ } else {
436
+ i = skipKids(recs, i);
437
+ }
376
438
  }
377
439
  } else {
378
440
  i = skipKids(recs, i);
@@ -384,18 +446,42 @@ function parseParagraphGroup(
384
446
 
385
447
  const nodes: ContentNode[] = [];
386
448
 
387
- // Build paragraph from text and inline controls (images)
388
- if (text && (text.chars.length > 0 || text.controls.length > 0)) {
389
- const paraContent: (SpanNode | ContentNode)[] = [];
449
+ {
450
+ const paraContent: Array<SpanNode | GridNode | PageNumNode> = [];
451
+
452
+ // P9: atno 컨트롤 위치 수집 (pos 기준 정렬)
453
+ const atnoCtrls: { pos: number; type: number }[] = [];
454
+ if (text && text.controls.length > 0) {
455
+ for (let ci = 0; ci < text.controls.length; ci++) {
456
+ const ch = ctrlHeaders[ci];
457
+ if (ch && ch.ctrlId === CTRL_ATNO)
458
+ atnoCtrls.push({ pos: text.controls[ci].pos, type: ch.atnoType ?? 0 });
459
+ }
460
+ atnoCtrls.sort((a, b) => a.pos - b.pos);
461
+ }
390
462
 
391
- if (text.chars.length > 0) {
392
- const spans = resolveCharShapes(text.chars, csPairs, di);
393
- paraContent.push(...spans);
463
+ // P9: 텍스트 chars atno 위치 기준으로 분할하여 PageNumNode 삽입
464
+ if (text && text.chars.length > 0) {
465
+ if (atnoCtrls.length > 0) {
466
+ let k = 0;
467
+ for (const ac of atnoCtrls) {
468
+ const seg: ParsedChar[] = [];
469
+ while (k < text.chars.length && text.chars[k].pos < ac.pos) seg.push(text.chars[k++]);
470
+ if (seg.length > 0) paraContent.push(...resolveCharShapes(seg, csPairs, di));
471
+ paraContent.push(buildPageNum(ac.type === 0 ? 'decimal' : 'total'));
472
+ }
473
+ const rest = text.chars.slice(k);
474
+ if (rest.length > 0) paraContent.push(...resolveCharShapes(rest, csPairs, di));
475
+ } else {
476
+ paraContent.push(...resolveCharShapes(text.chars, csPairs, di));
477
+ }
478
+ } else if (atnoCtrls.length > 0) {
479
+ for (const ac of atnoCtrls) paraContent.push(buildPageNum(ac.type === 0 ? 'decimal' : 'total'));
394
480
  }
395
481
 
396
482
  // Image placeholder spans: only for actual image controls.
397
483
  // Non-image controls (footnotes, TOC entries, etc.) are silently skipped.
398
- if (text.controls.length > 0) {
484
+ if (text && text.controls.length > 0) {
399
485
  for (let ci = 0; ci < text.controls.length; ci++) {
400
486
  const ch = ctrlHeaders[ci];
401
487
  if (!ch) continue; // anchor-only ctrl (gso is sibling, not inline)
@@ -408,12 +494,18 @@ function parseParagraphGroup(
408
494
  }
409
495
  }
410
496
 
411
- if (paraContent.length > 0) {
412
- nodes.push(buildPara(paraContent as any, buildParaProps(ps)));
497
+ // P5: 쪽나누기(divideSort & 4) → page-break 문단 먼저 출력
498
+ if (divideSort & 4) {
499
+ nodes.push(buildPara([{ tag: 'span', props: {}, kids: [buildPb()] } as SpanNode]));
413
500
  }
501
+ // P5: 표 → 앵커 문단 순서 (앵커 문단 드롭 금지)
502
+ nodes.push(...grids);
503
+ nodes.push(buildPara(
504
+ paraContent.length > 0 ? paraContent as any : [buildSpan('')],
505
+ buildParaProps(ps, hwpStyleId),
506
+ ));
414
507
  }
415
508
 
416
- nodes.push(...grids);
417
509
  return { nodes, next: i };
418
510
  }
419
511
 
@@ -426,8 +518,8 @@ function skipKids(recs: HwpRecord[], idx: number): number {
426
518
 
427
519
  /* ── PARA_TEXT ───────────────────────────────────────────────── */
428
520
 
429
- // Extended controls: 8 WORDs, associated CTRL_HEADER
430
- const EXT_CTRL = new Set([2, 3, 11, 12, 14, 15]);
521
+ // Extended controls: 8 WORDs, associated CTRL_HEADER (16-25 also skip 16 bytes)
522
+ const EXT_CTRL = new Set([2, 3, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]);
431
523
  // Inline controls: 8 WORDs, no CTRL_HEADER
432
524
  const INL_CTRL = new Set([4, 5, 6, 7, 8]);
433
525
 
@@ -766,6 +858,9 @@ function parseCellRec(
766
858
  const hdr = recs[k];
767
859
  const lv = hdr.level;
768
860
  const psId = hdr.data.length >= 10 ? BinaryKit.readU16LE(hdr.data, 8) : 0;
861
+ // P6: 셀 내부 문단의 styleId / divideSort 읽기
862
+ const cellStyleId = hdr.data.length >= 11 ? hdr.data[10] : 0;
863
+ const cellDivide = hdr.data.length >= 12 ? hdr.data[11] : 0;
769
864
  const ps = di.paraShapes[psId];
770
865
  let txt: ParaTextResult | null = null;
771
866
  let csp: [number, number][] = [];
@@ -815,7 +910,9 @@ function parseCellRec(
815
910
  }
816
911
  }
817
912
  const kids = paraContent.length > 0 ? paraContent as any : [buildSpan('')];
818
- const items: (ParaNode | GridNode)[] = [buildPara(kids, buildParaProps(ps)), ...innerGrids];
913
+ // P6: innerGrids 먼저, 앵커 문단 나중 (P5와 동일한 순서)
914
+ const items: (ParaNode | GridNode)[] = [...innerGrids, buildPara(kids, buildParaProps(ps, cellStyleId))];
915
+ if (cellDivide & 4) items.unshift(buildPara([{ tag: 'span', props: {}, kids: [buildPb()] } as SpanNode]));
819
916
  return { items, next: j };
820
917
  },
821
918
  { items: [buildPara([buildSpan('')])] as (ParaNode | GridNode)[], next: k + 1 },
@@ -858,7 +955,7 @@ function parseCellRec(
858
955
 
859
956
  /* ── PAGE_DEF ───────────────────────────────────────────────── */
860
957
  /* [0:4] width [4:4] height [8:4] ml [12:4] mr
861
- [16:4] mt [20:4] mb [36:4] attr (bit0=landscape) */
958
+ [16:4] mt [20:4] mb [24:4] header [28:4] footer [36:4] attr (bit0=landscape) */
862
959
 
863
960
  function parsePageDef(d: Uint8Array): PageDims {
864
961
  if (d.length < 24) return A4;
@@ -868,11 +965,15 @@ function parsePageDef(d: Uint8Array): PageDims {
868
965
  const mr = BinaryKit.readU32LE(d, 12);
869
966
  const mt = BinaryKit.readU32LE(d, 16);
870
967
  const mb = BinaryKit.readU32LE(d, 20);
968
+ const header = d.length >= 28 ? BinaryKit.readU32LE(d, 24) : 0;
969
+ const footer = d.length >= 32 ? BinaryKit.readU32LE(d, 28) : 0;
871
970
  const at = d.length >= 40 ? BinaryKit.readU32LE(d, 36) : 0;
872
971
  return {
873
972
  wPt: Metric.hwpToPt(w), hPt: Metric.hwpToPt(h),
874
973
  ml: Metric.hwpToPt(ml), mr: Metric.hwpToPt(mr),
875
974
  mt: Metric.hwpToPt(mt), mb: Metric.hwpToPt(mb),
975
+ headerPt: header > 0 ? Metric.hwpToPt(header) : undefined,
976
+ footerPt: footer > 0 ? Metric.hwpToPt(footer) : undefined,
876
977
  orient: (at & 1) ? 'landscape' : 'portrait',
877
978
  };
878
979
  }
@@ -915,18 +1016,30 @@ function strokeFromBF(bfId: number, di: DocInfo): Stroke | undefined {
915
1016
  return { kind: BORDER_KIND[b.type] ?? 'solid', pt: b.widthPt, color: b.color };
916
1017
  }
917
1018
 
918
- function buildParaProps(ps?: HwpParaShape): ParaProps {
919
- if (!ps) return {};
920
- const p: ParaProps = {};
1019
+ function buildParaProps(ps?: HwpParaShape, hwpStyleId?: number): ParaProps {
1020
+ // P2: hwpStyleId를 초기값으로 포함 (undefined이면 객체)
1021
+ const p: ParaProps = hwpStyleId !== undefined ? { hwpStyleId } : {};
1022
+ if (!ps) return p;
921
1023
  if (ps.align && ps.align !== 'left') p.align = ps.align;
922
1024
  if (ps.spaceBefore > 0) p.spaceBefore = Metric.hwpToPt(ps.spaceBefore);
923
1025
  if (ps.spaceAfter > 0) p.spaceAfter = Metric.hwpToPt(ps.spaceAfter);
924
- if (ps.lineSpacing > 0 && ps.lineSpacing !== 160) p.lineHeight = ps.lineSpacing / 100;
1026
+ // 간격: type=0(PERCENT) lineHeight, type=1(FIXED) lineHeightFixed
1027
+ if (ps.lineSpacingType === 1) {
1028
+ if (ps.lineSpacing > 0) p.lineHeightFixed = Metric.hwpToPt(ps.lineSpacing);
1029
+ } else {
1030
+ // P10: 160%(HWP 기본값) 생략 버그 수정 — 항상 lineHeight 설정
1031
+ if (ps.lineSpacing > 0) p.lineHeight = ps.lineSpacing / 100;
1032
+ }
925
1033
  // leftMargin (offset 4) = 문단 몸체 왼쪽 여백 → leftMargin (pt), ensure non-negative
926
1034
  const leftMarginPt = Math.max(0, Metric.hwpToPt(ps.leftMargin));
927
1035
  if (leftMarginPt > 0) p.leftMargin = leftMarginPt;
1036
+ // rightMargin (offset 8) = 문단 몸체 오른쪽 여백 → indentRightPt (pt)
1037
+ const rightMarginPt = Math.max(0, Metric.hwpToPt(ps.rightMargin));
1038
+ if (rightMarginPt > 0) p.indentRightPt = rightMarginPt;
928
1039
  // indent (offset 12) = 첫 줄 들여쓰기(양수) / 내어쓰기(음수) → firstLineIndentPt
929
1040
  if (ps.indent !== 0) p.firstLineIndentPt = Metric.hwpToPt(ps.indent);
1041
+ if (ps.verAlign && ps.verAlign !== 'baseline') p.verAlign = ps.verAlign;
1042
+ if (ps.lineWrap && ps.lineWrap !== 'break') p.lineWrap = ps.lineWrap;
930
1043
  return p;
931
1044
  }
932
1045
 
@@ -1021,7 +1134,11 @@ export class HwpScanner implements Decoder {
1021
1134
 
1022
1135
  warns.push(...shield.flush());
1023
1136
  const content = allContent.length > 0 ? allContent : [buildPara([buildSpan('')])];
1024
- return succeed(buildRoot({}, [buildSheet(content, pageDims)]), warns);
1137
+ // P8: 머리말/꼬리말을 gsoCtx에서 가져와 buildSheet 전달
1138
+ return succeed(buildRoot({}, [buildSheet(content, pageDims, {
1139
+ headers: gsoCtx.headers ? { default: gsoCtx.headers } : undefined,
1140
+ footers: gsoCtx.footers ? { default: gsoCtx.footers } : undefined,
1141
+ })]), warns);
1025
1142
  } catch (e: any) {
1026
1143
  warns.push(...shield.flush());
1027
1144
  return fail(`HWP decode error: ${e?.message ?? String(e)}`, warns);
@@ -1028,6 +1028,16 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
1028
1028
  const gridProps: GridProps = { headerRow: headerRow || undefined };
1029
1029
  if (borderFill?.stroke) gridProps.defaultStroke = borderFill.stroke;
1030
1030
 
1031
+ // 표 정렬 — <hp:pos horzAlign="..."> 에서 읽음 (OWPML CAbstractShapeObjectType)
1032
+ const posAttr = tbl?.["hp:pos"]?.[0]?._attr ?? {};
1033
+ if (posAttr.horzAlign) {
1034
+ const alignMap: Record<string, "left" | "right" | "center" | "justify"> = {
1035
+ LEFT: "left", RIGHT: "right", CENTER: "center", JUSTIFY: "justify",
1036
+ };
1037
+ const a = alignMap[posAttr.horzAlign];
1038
+ if (a) gridProps.align = a;
1039
+ }
1040
+
1031
1041
  const rowArr = getTag(tbl, "hp:tr", "hp:ROW");
1032
1042
 
1033
1043
  // Read column widths: first try a row where ALL cells have cs=1
@@ -1112,7 +1122,7 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
1112
1122
  cellProps.right = cellBf.right ?? cellBf.stroke;
1113
1123
  }
1114
1124
 
1115
- // Vertical alignment and cell padding from subList
1125
+ // 수직 정렬 <hp:subList vertAlign="..."> (OWPML CParaListType::GetVertAlign)
1116
1126
  const subList = cell?.["hp:subList"]?.[0] ?? cell?.subList?.[0];
1117
1127
  const subAttr = subList?._attr ?? {};
1118
1128
  if (subAttr.vertAlign) {
@@ -1123,17 +1133,18 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
1123
1133
  };
1124
1134
  cellProps.va = vaMap[subAttr.vertAlign];
1125
1135
  }
1126
- // Cell margins (stored in HWPUNIT on subList attributes)
1127
- const HWPX_DEFAULT_MARGIN_LR = 360; // typical default: 3.6pt
1128
- const HWPX_DEFAULT_MARGIN_TB = 141; // typical default: ~1.4pt
1129
- const mL = Number(subAttr.marginLeft ?? HWPX_DEFAULT_MARGIN_LR);
1130
- const mR = Number(subAttr.marginRight ?? HWPX_DEFAULT_MARGIN_LR);
1131
- const mT = Number(subAttr.marginTop ?? HWPX_DEFAULT_MARGIN_TB);
1132
- const mB = Number(subAttr.marginBottom ?? HWPX_DEFAULT_MARGIN_TB);
1133
- if (mL !== HWPX_DEFAULT_MARGIN_LR) cellProps.padL = Metric.hwpToPt(mL);
1134
- if (mR !== HWPX_DEFAULT_MARGIN_LR) cellProps.padR = Metric.hwpToPt(mR);
1135
- if (mT !== HWPX_DEFAULT_MARGIN_TB) cellProps.padT = Metric.hwpToPt(mT);
1136
- if (mB !== HWPX_DEFAULT_MARGIN_TB) cellProps.padB = Metric.hwpToPt(mB);
1136
+ // 여백 <hp:cellMargin left/right/top/bottom> (OWPML CTc::SetcellMargin)
1137
+ // subList 속성이 아닌 <hp:cellMargin> 자식 요소에서 읽어야 함
1138
+ const cellMarginAttr =
1139
+ cell?.["hp:cellMargin"]?.[0]?._attr ?? {};
1140
+ const mL = cellMarginAttr.left !== undefined ? Number(cellMarginAttr.left) : -1;
1141
+ const mR = cellMarginAttr.right !== undefined ? Number(cellMarginAttr.right) : -1;
1142
+ const mT = cellMarginAttr.top !== undefined ? Number(cellMarginAttr.top) : -1;
1143
+ const mB = cellMarginAttr.bottom !== undefined ? Number(cellMarginAttr.bottom) : -1;
1144
+ if (mL >= 0) cellProps.padL = Metric.hwpToPt(mL);
1145
+ if (mR >= 0) cellProps.padR = Metric.hwpToPt(mR);
1146
+ if (mT >= 0) cellProps.padT = Metric.hwpToPt(mT);
1147
+ if (mB >= 0) cellProps.padB = Metric.hwpToPt(mB);
1137
1148
 
1138
1149
  // Colspan/rowspan from cellSpan element or attributes
1139
1150
  const cellSpan = cell?.["hp:cellSpan"]?.[0]?._attr ?? {};
@@ -320,6 +320,40 @@ function stylesXml(): string {
320
320
  </w:pPr></w:pPrDefault>
321
321
  </w:docDefaults>
322
322
  <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
323
+ <w:style w:type="paragraph" w:styleId="0"><w:name w:val="바탕글"/><w:basedOn w:val="Normal"/></w:style>
324
+ <w:style w:type="paragraph" w:styleId="1"><w:name w:val="본문"/><w:basedOn w:val="Normal"/></w:style>
325
+ <w:style w:type="paragraph" w:styleId="2"><w:name w:val="개요 1"/><w:basedOn w:val="Normal"/></w:style>
326
+ <w:style w:type="paragraph" w:styleId="3"><w:name w:val="개요 2"/><w:basedOn w:val="Normal"/></w:style>
327
+ <w:style w:type="paragraph" w:styleId="4"><w:name w:val="개요 3"/><w:basedOn w:val="Normal"/></w:style>
328
+ <w:style w:type="paragraph" w:styleId="5"><w:name w:val="개요 4"/><w:basedOn w:val="Normal"/></w:style>
329
+ <w:style w:type="paragraph" w:styleId="6"><w:name w:val="개요 5"/><w:basedOn w:val="Normal"/></w:style>
330
+ <w:style w:type="paragraph" w:styleId="7"><w:name w:val="개요 6"/><w:basedOn w:val="Normal"/></w:style>
331
+ <w:style w:type="paragraph" w:styleId="8"><w:name w:val="개요 7"/><w:basedOn w:val="Normal"/></w:style>
332
+ <w:style w:type="paragraph" w:styleId="9"><w:name w:val="개요 8"/><w:basedOn w:val="Normal"/></w:style>
333
+ <w:style w:type="paragraph" w:styleId="10"><w:name w:val="개요 9"/><w:basedOn w:val="Normal"/></w:style>
334
+ <w:style w:type="paragraph" w:styleId="11"><w:name w:val="개요 10"/><w:basedOn w:val="Normal"/></w:style>
335
+ <w:style w:type="paragraph" w:styleId="12"><w:name w:val="쪽 번호"/><w:basedOn w:val="Normal"/></w:style>
336
+ <w:style w:type="paragraph" w:styleId="13"><w:name w:val="머리말"/><w:basedOn w:val="Normal"/></w:style>
337
+ <w:style w:type="paragraph" w:styleId="14"><w:name w:val="각주"/><w:basedOn w:val="Normal"/></w:style>
338
+ <w:style w:type="paragraph" w:styleId="15"><w:name w:val="미주"/><w:basedOn w:val="Normal"/></w:style>
339
+ <w:style w:type="paragraph" w:styleId="16"><w:name w:val="메모"/><w:basedOn w:val="Normal"/></w:style>
340
+ <w:style w:type="paragraph" w:styleId="17"><w:name w:val="차례 제목"/><w:basedOn w:val="Normal"/></w:style>
341
+ <w:style w:type="paragraph" w:styleId="18"><w:name w:val="차례 1"/><w:basedOn w:val="Normal"/></w:style>
342
+ <w:style w:type="paragraph" w:styleId="19"><w:name w:val="차례 2"/><w:basedOn w:val="Normal"/></w:style>
343
+ <w:style w:type="paragraph" w:styleId="20"><w:name w:val="차례 3"/><w:basedOn w:val="Normal"/></w:style>
344
+ <w:style w:type="paragraph" w:styleId="21"><w:name w:val="본문 제목"/><w:basedOn w:val="Normal"/></w:style>
345
+ <w:style w:type="paragraph" w:styleId="22"><w:name w:val="그림"/><w:basedOn w:val="Normal"/></w:style>
346
+ <w:style w:type="paragraph" w:styleId="23"><w:name w:val="표"/><w:basedOn w:val="Normal"/></w:style>
347
+ <w:style w:type="paragraph" w:styleId="24"><w:name w:val="수식"/><w:basedOn w:val="Normal"/></w:style>
348
+ <w:style w:type="paragraph" w:styleId="25"><w:name w:val="인용문"/><w:basedOn w:val="Normal"/></w:style>
349
+ <w:style w:type="paragraph" w:styleId="26"><w:name w:val="날짜"/><w:basedOn w:val="Normal"/></w:style>
350
+ <w:style w:type="paragraph" w:styleId="27"><w:name w:val="발신명의"/><w:basedOn w:val="Normal"/></w:style>
351
+ <w:style w:type="paragraph" w:styleId="28"><w:name w:val="제목"/><w:basedOn w:val="Normal"/></w:style>
352
+ <w:style w:type="paragraph" w:styleId="29"><w:name w:val="부제목"/><w:basedOn w:val="Normal"/></w:style>
353
+ <w:style w:type="paragraph" w:styleId="30"><w:name w:val="문단 제목"/><w:basedOn w:val="Normal"/></w:style>
354
+ <w:style w:type="paragraph" w:styleId="31"><w:name w:val="MEMO"/><w:basedOn w:val="Normal"/></w:style>
355
+ <w:style w:type="paragraph" w:styleId="32"><w:name w:val="개요"/><w:basedOn w:val="Normal"/></w:style>
356
+ <w:style w:type="paragraph" w:styleId="33"><w:name w:val="표 제목"/><w:basedOn w:val="Normal"/></w:style>
323
357
  <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/></w:rPr></w:style>
324
358
  <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="36"/><w:szCs w:val="36"/></w:rPr></w:style>
325
359
  <w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="2"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr></w:style>
@@ -447,9 +481,13 @@ function encodeContent(
447
481
 
448
482
  function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
449
483
  const align = para.props.align ?? "left";
450
- const headStyle = para.props.heading
451
- ? `<w:pStyle w:val="Heading${para.props.heading}"/>`
452
- : "";
484
+ // P3: hwpStyleId(숫자 ID) 우선, 없으면 heading 스타일, 둘 다 없으면 빈 문자열
485
+ let headStyle = "";
486
+ if (para.props.hwpStyleId !== undefined) {
487
+ headStyle = `<w:pStyle w:val="${para.props.hwpStyleId}"/>`;
488
+ } else if (para.props.heading) {
489
+ headStyle = `<w:pStyle w:val="Heading${para.props.heading}"/>`;
490
+ }
453
491
 
454
492
  // List numbering
455
493
  let numPr = "";
@@ -506,6 +544,11 @@ function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
506
544
  .map((k) => {
507
545
  if (k.tag === "span") return encodeRun(k, ctx);
508
546
  if (k.tag === "img") return encodeImage(k, ctx);
547
+ // P9: PageNumNode가 para.kids에 직접 있는 경우 (머리말/꼬리말 등)
548
+ if (k.tag === "pagenum") {
549
+ const instr = k.format === "total" ? " NUMPAGES " : " PAGE ";
550
+ return `<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>${instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>`;
551
+ }
509
552
  return "";
510
553
  })
511
554
  .join("");
@@ -548,8 +591,10 @@ function encodeRun(span: SpanNode, _ctx: EncCtx): string {
548
591
  );
549
592
  }
550
593
  } else if (kid.tag === "pagenum") {
594
+ // P9: format === 'total' → NUMPAGES 필드, 나머지 → PAGE 필드
595
+ const instr = kid.format === "total" ? " NUMPAGES " : " PAGE ";
551
596
  parts.push(
552
- `<w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:instrText> PAGE </w:instrText></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:t>1</w:t></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>`,
597
+ `<w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:instrText>${instr}</w:instrText></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:t>1</w:t></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>`,
553
598
  );
554
599
  } else if (kid.tag === "br") {
555
600
  parts.push(`<w:r><w:br/></w:r>`);