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/ .npmignore +1 -0
- package/README.md +8 -8
- package/dist/index.d.mts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +573 -230
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +573 -230
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/decoders/hwp/HwpScanner.ts +174 -57
- package/src/decoders/hwpx/HwpxDecoder.ts +23 -12
- package/src/encoders/docx/DocxEncoder.ts +49 -4
- package/src/encoders/hwp/HwpEncoder.ts +309 -163
- package/src/encoders/hwpx/HwpxEncoder.ts +249 -103
- package/src/model/doc-props.ts +5 -5
- package/src/model/doc-tree.ts +2 -2
- package/test-styling.ts +0 -210
package/package.json
CHANGED
|
@@ -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; // '
|
|
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 =
|
|
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: '
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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 {
|
|
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
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1127
|
-
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
const
|
|
1131
|
-
const
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
if (
|
|
1135
|
-
if (
|
|
1136
|
-
if (
|
|
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
|
-
|
|
451
|
-
|
|
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
|
|
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>`);
|