hwpkit-dev 0.0.1 → 0.0.3
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 +4 -1
- package/README.md +39 -2
- package/dist/index.d.mts +74 -16
- package/dist/index.d.ts +70 -16
- package/dist/index.js +4985 -698
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4981 -698
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -1
- package/playground/index.html +346 -0
- package/playground/main.ts +302 -0
- package/playground/vite.config.ts +16 -0
- package/src/contract/decoder.ts +1 -0
- package/src/contract/encoder.ts +6 -1
- package/src/core/BaseDecoder.ts +118 -0
- package/src/core/BaseEncoder.ts +146 -0
- package/src/decoders/docx/DocxDecoder.ts +867 -150
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +477 -88
- package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +600 -295
- package/src/encoders/html/HtmlEncoder.ts +203 -0
- package/src/encoders/hwp/HwpEncoder.ts +1647 -398
- package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +117 -30
- package/src/index.ts +1 -0
- package/src/model/builders.ts +8 -6
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +13 -5
- package/src/pipeline/Pipeline.ts +21 -4
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +52 -7
- package/src/toolkit/ArchiveKit.ts +56 -0
- package/src/toolkit/StyleMapper.ts +221 -0
- package/src/toolkit/UnitConverter.ts +138 -0
- package/src/toolkit/XmlKit.ts +0 -5
- package/test-styling.ts +210 -0
package/src/model/doc-tree.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface LinkNode {
|
|
|
44
44
|
export interface ParaNode {
|
|
45
45
|
tag: 'para';
|
|
46
46
|
props: ParaProps;
|
|
47
|
-
kids: (SpanNode | ImgNode | LinkNode)[];
|
|
47
|
+
kids: (SpanNode | ImgNode | LinkNode | GridNode)[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// ─── 표(Grid) 노드 ─────────────────────────────────────────
|
|
@@ -53,10 +53,10 @@ export interface CellNode {
|
|
|
53
53
|
cs: number;
|
|
54
54
|
rs: number;
|
|
55
55
|
props: CellProps;
|
|
56
|
-
kids: ParaNode[];
|
|
56
|
+
kids: (ParaNode | GridNode)[];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export interface RowNode { tag: 'row'; kids: CellNode[] }
|
|
59
|
+
export interface RowNode { tag: 'row'; kids: CellNode[]; heightPt?: number }
|
|
60
60
|
|
|
61
61
|
export interface GridNode {
|
|
62
62
|
tag: 'grid';
|
|
@@ -71,8 +71,16 @@ export interface SheetNode {
|
|
|
71
71
|
tag: 'sheet';
|
|
72
72
|
dims: PageDims;
|
|
73
73
|
kids: ContentNode[];
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
headers?: {
|
|
75
|
+
default?: ParaNode[];
|
|
76
|
+
first?: ParaNode[];
|
|
77
|
+
even?: ParaNode[];
|
|
78
|
+
};
|
|
79
|
+
footers?: {
|
|
80
|
+
default?: ParaNode[];
|
|
81
|
+
first?: ParaNode[];
|
|
82
|
+
even?: ParaNode[];
|
|
83
|
+
};
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// ─── 루트 노드 ─────────────────────────────────────────────
|
package/src/pipeline/Pipeline.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DocRoot } from '../model/doc-tree';
|
|
2
2
|
import type { Outcome } from '../contract/result';
|
|
3
|
-
import {
|
|
3
|
+
import type { EncoderOptions } from '../contract/encoder';
|
|
4
|
+
import { succeed, fail } from '../contract/result';
|
|
4
5
|
import { registry } from './registry';
|
|
5
6
|
|
|
6
7
|
// Side-effect imports: auto-register all decoders and encoders
|
|
@@ -8,9 +9,12 @@ import '../decoders/hwpx/HwpxDecoder';
|
|
|
8
9
|
import '../decoders/hwp/HwpScanner';
|
|
9
10
|
import '../decoders/docx/DocxDecoder';
|
|
10
11
|
import '../decoders/md/MdDecoder';
|
|
12
|
+
import '../decoders/html/HtmlDecoder';
|
|
11
13
|
import '../encoders/hwpx/HwpxEncoder';
|
|
12
14
|
import '../encoders/docx/DocxEncoder';
|
|
13
15
|
import '../encoders/md/MdEncoder';
|
|
16
|
+
import '../encoders/html/HtmlEncoder';
|
|
17
|
+
import '../encoders/hwp/HwpEncoder';
|
|
14
18
|
|
|
15
19
|
export class Pipeline {
|
|
16
20
|
private constructor(private raw: Uint8Array, private srcFmt: string) {}
|
|
@@ -35,7 +39,7 @@ export class Pipeline {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** 목표 포맷으로 변환 */
|
|
38
|
-
async to(targetFmt: string): Promise<Outcome<Uint8Array>> {
|
|
42
|
+
async to(targetFmt: string, options?: EncoderOptions): Promise<Outcome<Uint8Array>> {
|
|
39
43
|
const decoder = registry.getDecoder(this.srcFmt);
|
|
40
44
|
const encoder = registry.getEncoder(targetFmt);
|
|
41
45
|
|
|
@@ -45,7 +49,7 @@ export class Pipeline {
|
|
|
45
49
|
const docResult = await decoder.decode(this.raw);
|
|
46
50
|
if (!docResult.ok) return docResult;
|
|
47
51
|
|
|
48
|
-
const encResult = await encoder.encode(docResult.data);
|
|
52
|
+
const encResult = await encoder.encode(docResult.data, options);
|
|
49
53
|
if (!encResult.ok) return { ...encResult, warns: [...docResult.warns, ...encResult.warns] };
|
|
50
54
|
|
|
51
55
|
return { ...encResult, warns: [...docResult.warns, ...encResult.warns] };
|
|
@@ -60,8 +64,21 @@ export class Pipeline {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
function detectFormat(data: Uint8Array): string {
|
|
63
|
-
|
|
67
|
+
// HWP 파일 (OLE Compound Document)
|
|
64
68
|
if (data[0] === 0xD0 && data[1] === 0xCF && data[2] === 0x11 && data[3] === 0xE0) return 'hwp';
|
|
69
|
+
|
|
70
|
+
// ZIP 기반 파일 (DOCX, HWPX)
|
|
71
|
+
if (data[0] === 0x50 && data[1] === 0x4B) {
|
|
72
|
+
// DOCX 는 [Content_Types].xml 에 application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml 이 있음
|
|
73
|
+
// HWPX 는 application/ha-xml-core-document
|
|
74
|
+
const str = new TextDecoder('utf-8', { fatal: false }).decode(data.slice(0, 4096));
|
|
75
|
+
if (str.includes('wordprocessingml')) return 'docx';
|
|
76
|
+
if (str.includes('ha-xml')) return 'hwpx';
|
|
77
|
+
if (str.includes('hwpml/')) return 'hwpx';
|
|
78
|
+
if (str.includes('word/')) return 'docx';
|
|
79
|
+
return 'hwpx'; // 기본값
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
return 'md';
|
|
66
83
|
}
|
|
67
84
|
|
package/src/pipeline/registry.ts
CHANGED
|
@@ -5,8 +5,19 @@ class FormatRegistry {
|
|
|
5
5
|
private decoders = new Map<string, Decoder>();
|
|
6
6
|
private encoders = new Map<string, Encoder>();
|
|
7
7
|
|
|
8
|
-
registerDecoder(d: Decoder): void {
|
|
9
|
-
|
|
8
|
+
registerDecoder(d: Decoder): void {
|
|
9
|
+
this.decoders.set(d.format, d);
|
|
10
|
+
if (d.aliases) {
|
|
11
|
+
for (const alias of d.aliases) this.decoders.set(alias, d);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
registerEncoder(e: Encoder): void {
|
|
16
|
+
this.encoders.set(e.format, e);
|
|
17
|
+
if (e.aliases) {
|
|
18
|
+
for (const alias of e.aliases) this.encoders.set(alias, e);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
getDecoder(fmt: string): Decoder | undefined { return this.decoders.get(fmt); }
|
|
12
23
|
getEncoder(fmt: string): Encoder | undefined { return this.encoders.get(fmt); }
|
|
@@ -50,17 +50,39 @@ const ALIGN_MAP: Record<string, Align> = {
|
|
|
50
50
|
start: 'left', end: 'right',
|
|
51
51
|
};
|
|
52
52
|
export function safeAlign(raw?: string): Align {
|
|
53
|
-
return ALIGN_MAP[raw ?? ''] ?? '
|
|
53
|
+
return ALIGN_MAP[raw ?? ''] ?? 'left';
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// ─── 테두리 정규화 ─────────────────────────────────────────
|
|
57
57
|
const HWPX_STROKE: Record<string, StrokeKind> = {
|
|
58
|
-
SOLID:
|
|
59
|
-
|
|
58
|
+
SOLID: "solid",
|
|
59
|
+
NONE: "none",
|
|
60
|
+
DASH: "dash",
|
|
61
|
+
DOT: "dot",
|
|
62
|
+
DOUBLE: "double",
|
|
63
|
+
LONG_DASH: "dash",
|
|
64
|
+
DASH_DOT: "dashDot",
|
|
65
|
+
DASH_DOT_DOT: "dashDotDot",
|
|
66
|
+
THICK_THIN: "double",
|
|
67
|
+
THIN_THICK: "double",
|
|
68
|
+
TRIPLE: "double",
|
|
60
69
|
};
|
|
61
70
|
const DOCX_STROKE: Record<string, StrokeKind> = {
|
|
62
|
-
single:
|
|
63
|
-
|
|
71
|
+
single: "solid",
|
|
72
|
+
none: "none",
|
|
73
|
+
nil: "none",
|
|
74
|
+
dashed: "dash",
|
|
75
|
+
dotted: "dot",
|
|
76
|
+
double: "double",
|
|
77
|
+
dotDash: "dashDot",
|
|
78
|
+
dotDotDash: "dashDotDot",
|
|
79
|
+
thickThin: "double",
|
|
80
|
+
thinThick: "double",
|
|
81
|
+
triple: "double",
|
|
82
|
+
wave: "wave",
|
|
83
|
+
dashDotStroked: "dashDot",
|
|
84
|
+
threeDEmboss: "solid",
|
|
85
|
+
threeDEngrave: "solid",
|
|
64
86
|
};
|
|
65
87
|
|
|
66
88
|
export function safeStrokeHwpx(type?: string, w?: number, c?: string): Stroke {
|
|
@@ -81,14 +103,37 @@ export function safeStrokeDocx(val?: string, sz?: number, c?: string): Stroke {
|
|
|
81
103
|
|
|
82
104
|
// ─── 폰트 정규화 ───────────────────────────────────────────
|
|
83
105
|
const FONT_MAP: Record<string, string> = {
|
|
106
|
+
// 맑은 고딕 계열
|
|
84
107
|
'맑은 고딕': 'Malgun Gothic',
|
|
108
|
+
'맑은고딕': 'Malgun Gothic',
|
|
109
|
+
// 바탕 계열 (serif)
|
|
85
110
|
'바탕': 'Batang',
|
|
111
|
+
'바탕체': 'BatangChe',
|
|
112
|
+
'한컴바탕': 'Batang',
|
|
113
|
+
'함초롬바탕': 'Batang',
|
|
114
|
+
'HY신명조': 'Batang',
|
|
115
|
+
'HY견명조': 'Batang',
|
|
116
|
+
'HY그래픽': 'Batang',
|
|
117
|
+
'궁서': 'Gungsuh',
|
|
118
|
+
'궁서체': 'GungsuhChe',
|
|
119
|
+
// 고딕 계열 (sans-serif)
|
|
86
120
|
'돋움': 'Dotum',
|
|
121
|
+
'돋움체': 'DotumChe',
|
|
87
122
|
'굴림': 'Gulim',
|
|
88
|
-
'
|
|
123
|
+
'굴림체': 'GulimChe',
|
|
89
124
|
'한컴돋움': 'Malgun Gothic',
|
|
90
|
-
'함초롬바탕': 'Batang',
|
|
91
125
|
'함초롬돋움': 'Malgun Gothic',
|
|
126
|
+
'HY견고딕': 'Malgun Gothic',
|
|
127
|
+
'HY중고딕': 'Malgun Gothic',
|
|
128
|
+
'HY헤드라인M': 'Malgun Gothic',
|
|
129
|
+
'HY강B': 'Malgun Gothic',
|
|
130
|
+
'HY나무M': 'Malgun Gothic',
|
|
131
|
+
'HY목각파임B': 'Malgun Gothic',
|
|
132
|
+
'HY엽서M': 'Malgun Gothic',
|
|
133
|
+
'HY엽서L': 'Malgun Gothic',
|
|
134
|
+
// 나눔 계열
|
|
135
|
+
'나눔고딕': 'Malgun Gothic',
|
|
136
|
+
'나눔명조': 'Batang',
|
|
92
137
|
};
|
|
93
138
|
export function safeFont(raw?: string): string {
|
|
94
139
|
return FONT_MAP[raw ?? ''] ?? raw ?? 'Malgun Gothic';
|
|
@@ -18,6 +18,62 @@ export const ArchiveKit = {
|
|
|
18
18
|
const files = new Map<string, Uint8Array>();
|
|
19
19
|
const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
|
|
20
20
|
|
|
21
|
+
// Find EOCD by scanning backward from end (supports ZIP comment up to 64KB)
|
|
22
|
+
let eocdOffset = -1;
|
|
23
|
+
const searchStart = Math.max(0, zipData.length - 65558);
|
|
24
|
+
for (let i = zipData.length - 22; i >= searchStart; i--) {
|
|
25
|
+
if (view.getUint32(i, true) === 0x06054b50) {
|
|
26
|
+
eocdOffset = i;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (eocdOffset !== -1) {
|
|
32
|
+
// Parse central directory — always has correct sizes even with data descriptors
|
|
33
|
+
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
34
|
+
const centralDirOffset = view.getUint32(eocdOffset + 16, true);
|
|
35
|
+
|
|
36
|
+
let cdOffset = centralDirOffset;
|
|
37
|
+
for (let i = 0; i < entryCount; i++) {
|
|
38
|
+
if (cdOffset + 46 > zipData.length) break;
|
|
39
|
+
if (view.getUint32(cdOffset, true) !== 0x02014b50) break;
|
|
40
|
+
|
|
41
|
+
const compressionMethod = view.getUint16(cdOffset + 10, true);
|
|
42
|
+
const compressedSize = view.getUint32(cdOffset + 20, true);
|
|
43
|
+
const uncompressedSize = view.getUint32(cdOffset + 24, true);
|
|
44
|
+
const fileNameLength = view.getUint16(cdOffset + 28, true);
|
|
45
|
+
const extraLength = view.getUint16(cdOffset + 30, true);
|
|
46
|
+
const commentLength = view.getUint16(cdOffset + 32, true);
|
|
47
|
+
const localHeaderOffset = view.getUint32(cdOffset + 42, true);
|
|
48
|
+
|
|
49
|
+
const nameBytes = zipData.subarray(cdOffset + 46, cdOffset + 46 + fileNameLength);
|
|
50
|
+
const name = new TextDecoder('utf-8').decode(nameBytes);
|
|
51
|
+
cdOffset += 46 + fileNameLength + extraLength + commentLength;
|
|
52
|
+
|
|
53
|
+
if (name.endsWith('/')) continue; // directory entry
|
|
54
|
+
|
|
55
|
+
// Read actual data using the local header offset from central directory
|
|
56
|
+
const localFnLen = view.getUint16(localHeaderOffset + 26, true);
|
|
57
|
+
const localExtraLen = view.getUint16(localHeaderOffset + 28, true);
|
|
58
|
+
const dataOffset = localHeaderOffset + 30 + localFnLen + localExtraLen;
|
|
59
|
+
|
|
60
|
+
let fileData: Uint8Array;
|
|
61
|
+
if (compressionMethod === 0) {
|
|
62
|
+
fileData = zipData.subarray(dataOffset, dataOffset + uncompressedSize);
|
|
63
|
+
} else if (compressionMethod === 8) {
|
|
64
|
+
const compressed = zipData.subarray(dataOffset, dataOffset + compressedSize);
|
|
65
|
+
fileData = pako.inflateRaw(compressed);
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error(`Unsupported ZIP compression method: ${compressionMethod}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
files.set(name, new Uint8Array(fileData));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fallback: scan local headers sequentially (no EOCD found — truncated ZIP)
|
|
21
77
|
let offset = 0;
|
|
22
78
|
while (offset < zipData.length - 4) {
|
|
23
79
|
const sig = view.getUint32(offset, true);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스타일 매핑 유틸리티
|
|
3
|
+
*
|
|
4
|
+
* 다양한 문서 포맷 간의 스타일 (폰트, 색상, 정렬, 테두리 등) 을 매핑합니다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Align, StrokeKind, Stroke, TextProps, ParaProps } from '../model/doc-props';
|
|
8
|
+
|
|
9
|
+
// ─── 폰트 매핑 ───────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** 한글 폰트 ↔ 영문 폰트 매핑 */
|
|
12
|
+
export const FONT_MAP: Record<string, string> = {
|
|
13
|
+
// 한글 → 영문
|
|
14
|
+
'맑은 고딕': 'Malgun Gothic',
|
|
15
|
+
'바탕': 'Batang',
|
|
16
|
+
'돋움': 'Dotum',
|
|
17
|
+
'굴림': 'Gulim',
|
|
18
|
+
'한컴바탕': 'Batang',
|
|
19
|
+
'한컴돋움': 'Malgun Gothic',
|
|
20
|
+
'함초롬바탕': 'Batang',
|
|
21
|
+
'함초롬돋움': 'Malgun Gothic',
|
|
22
|
+
|
|
23
|
+
// 영문 → 한글 (역매핑)
|
|
24
|
+
'Malgun Gothic': '맑은 고딕',
|
|
25
|
+
'Batang': '바탕',
|
|
26
|
+
'Dotum': '돋움',
|
|
27
|
+
'Gulim': '굴림',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** 폰트를 한글로 변환 (한컴 호환성) */
|
|
31
|
+
export function fontToKorean(font?: string): string {
|
|
32
|
+
if (!font) return '맑은 고딕';
|
|
33
|
+
return FONT_MAP[font] ?? font;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 폰트를 영문으로 변환 (DOCX 호환성) */
|
|
37
|
+
export function fontToEnglish(font?: string): string {
|
|
38
|
+
if (!font) return 'Malgun Gothic';
|
|
39
|
+
|
|
40
|
+
// 이미 영문 폰트라면 그대로 반환
|
|
41
|
+
if (/^[a-zA-Z\s]+$/.test(font)) {
|
|
42
|
+
return FONT_MAP[font] ?? font;
|
|
43
|
+
}
|
|
44
|
+
// 한글 폰트라면 영문으로 변환
|
|
45
|
+
return FONT_MAP[font] ?? 'Malgun Gothic';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── 색상 매핑 ───────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** 색상을 안전한 6 자리 hex 문자열로 변환 */
|
|
51
|
+
export function safeHex(raw: string | number | null | undefined): string | undefined {
|
|
52
|
+
if (raw == null) return undefined;
|
|
53
|
+
|
|
54
|
+
if (typeof raw === 'number') {
|
|
55
|
+
if (raw <= 0) return '000000';
|
|
56
|
+
if (raw >= 0xFFFFFF) return undefined;
|
|
57
|
+
return raw.toString(16).padStart(6, '0').toUpperCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let s = String(raw).replace(/^#/, '').toUpperCase();
|
|
61
|
+
|
|
62
|
+
// 3 자리 hex → 6 자리 확장
|
|
63
|
+
if (/^[0-9A-F]{3}$/.test(s)) {
|
|
64
|
+
s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 6 자리 hex 검증
|
|
68
|
+
if (/^[0-9A-F]{6}$/.test(s)) {
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 특수값 처리
|
|
73
|
+
if (s === 'AUTO' || s === 'NONE' || s === 'TRANSPARENT') {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── 정렬 매핑 ───────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** 정렬 값 정규화 */
|
|
83
|
+
const ALIGN_MAP: Record<string, Align> = {
|
|
84
|
+
// 대문자
|
|
85
|
+
LEFT: 'left',
|
|
86
|
+
CENTER: 'center',
|
|
87
|
+
RIGHT: 'right',
|
|
88
|
+
JUSTIFY: 'justify',
|
|
89
|
+
BOTH: 'justify',
|
|
90
|
+
DISTRIBUTE: 'justify',
|
|
91
|
+
|
|
92
|
+
// 소문자
|
|
93
|
+
left: 'left',
|
|
94
|
+
center: 'center',
|
|
95
|
+
right: 'right',
|
|
96
|
+
both: 'justify',
|
|
97
|
+
|
|
98
|
+
// CSS 스타일
|
|
99
|
+
start: 'left',
|
|
100
|
+
end: 'right',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function safeAlign(raw?: string): Align {
|
|
104
|
+
return ALIGN_MAP[raw ?? ''] ?? 'left';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── 테두리 매핑 ────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** HWPX stroke kind 매핑 */
|
|
110
|
+
const HWPX_STROKE: Record<string, StrokeKind> = {
|
|
111
|
+
SOLID: 'solid',
|
|
112
|
+
NONE: 'none',
|
|
113
|
+
DASH: 'dash',
|
|
114
|
+
DOT: 'dot',
|
|
115
|
+
DOUBLE: 'double',
|
|
116
|
+
LONG_DASH: 'dash',
|
|
117
|
+
DASH_DOT: 'dashDot',
|
|
118
|
+
DASH_DOT_DOT: 'dashDotDot',
|
|
119
|
+
THICK_THIN: 'double',
|
|
120
|
+
THIN_THICK: 'double',
|
|
121
|
+
TRIPLE: 'double',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** DOCX stroke kind 매핑 */
|
|
125
|
+
const DOCX_STROKE: Record<string, StrokeKind> = {
|
|
126
|
+
single: 'solid',
|
|
127
|
+
none: 'none',
|
|
128
|
+
nil: 'none',
|
|
129
|
+
dashed: 'dash',
|
|
130
|
+
dotted: 'dot',
|
|
131
|
+
double: 'double',
|
|
132
|
+
dotDash: 'dashDot',
|
|
133
|
+
dotDotDash: 'dashDotDot',
|
|
134
|
+
thickThin: 'double',
|
|
135
|
+
thinThick: 'double',
|
|
136
|
+
triple: 'double',
|
|
137
|
+
wave: 'wave',
|
|
138
|
+
dashDotStroked: 'dashDot',
|
|
139
|
+
threeDEmboss: 'solid',
|
|
140
|
+
threeDEngrave: 'solid',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** HWPX stroke 값을 Stroke 객체로 변환 */
|
|
144
|
+
export function strokeFromHwpx(type?: string, width?: number, color?: string): Stroke {
|
|
145
|
+
return {
|
|
146
|
+
kind: HWPX_STROKE[type ?? ''] ?? 'solid',
|
|
147
|
+
pt: width != null ? width / 100 : 0.5, // hwpunit → pt
|
|
148
|
+
color: safeHex(color) ?? '000000',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** DOCX stroke 값을 Stroke 객체로 변환 */
|
|
153
|
+
export function strokeFromDocx(type?: string, size?: number, color?: string): Stroke {
|
|
154
|
+
return {
|
|
155
|
+
kind: DOCX_STROKE[type ?? ''] ?? 'solid',
|
|
156
|
+
pt: size != null ? size / 8 : 0.5, // 1/8 pt 단위로 변환
|
|
157
|
+
color: safeHex(color) ?? '000000',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── 스타일 객체 매핑 ───────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* DOCX 스타일 → HWPX 스타일 매핑
|
|
165
|
+
*/
|
|
166
|
+
export function styleDocxToHwpx(docxStyle: Partial<TextProps>): Partial<TextProps> {
|
|
167
|
+
return {
|
|
168
|
+
font: fontToKorean(docxStyle.font),
|
|
169
|
+
pt: docxStyle.pt,
|
|
170
|
+
b: docxStyle.b,
|
|
171
|
+
i: docxStyle.i,
|
|
172
|
+
u: docxStyle.u,
|
|
173
|
+
s: docxStyle.s,
|
|
174
|
+
color: docxStyle.color,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* HWPX 스타일 → DOCX 스타일 매핑
|
|
180
|
+
*/
|
|
181
|
+
export function styleHwpxToDocx(hwpxStyle: Partial<TextProps>): Partial<TextProps> {
|
|
182
|
+
return {
|
|
183
|
+
font: fontToEnglish(hwpxStyle.font),
|
|
184
|
+
pt: hwpxStyle.pt,
|
|
185
|
+
b: hwpxStyle.b,
|
|
186
|
+
i: hwpxStyle.i,
|
|
187
|
+
u: hwpxStyle.u,
|
|
188
|
+
s: hwpxStyle.s,
|
|
189
|
+
color: hwpxStyle.color,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* DOCX 스타일 → HWP 스타일 매핑
|
|
195
|
+
*/
|
|
196
|
+
export function styleDocxToHwp(docxStyle: Partial<TextProps>): Partial<TextProps> {
|
|
197
|
+
return {
|
|
198
|
+
font: fontToKorean(docxStyle.font),
|
|
199
|
+
pt: docxStyle.pt,
|
|
200
|
+
b: docxStyle.b,
|
|
201
|
+
i: docxStyle.i,
|
|
202
|
+
u: docxStyle.u,
|
|
203
|
+
s: docxStyle.s,
|
|
204
|
+
color: docxStyle.color,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* HWP 스타일 → DOCX 스타일 매핑
|
|
210
|
+
*/
|
|
211
|
+
export function styleHwpToDocx(hwpStyle: Partial<TextProps>): Partial<TextProps> {
|
|
212
|
+
return {
|
|
213
|
+
font: fontToEnglish(hwpStyle.font),
|
|
214
|
+
pt: hwpStyle.pt,
|
|
215
|
+
b: hwpStyle.b,
|
|
216
|
+
i: hwpStyle.i,
|
|
217
|
+
u: hwpStyle.u,
|
|
218
|
+
s: hwpStyle.s,
|
|
219
|
+
color: hwpStyle.color,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 단위 변환 유틸리티
|
|
3
|
+
*
|
|
4
|
+
* 다양한 문서 포맷 간의 단위 변환을 제공합니다.
|
|
5
|
+
*
|
|
6
|
+
* 단위 시스템:
|
|
7
|
+
* - HWP: 1 inch = 7200 hwpunit (1 pt = 100 hwpunit)
|
|
8
|
+
* - DOCX: 1 inch = 1440 dxa = 914400 emu (1 pt = 20 dxa = 12700 emu)
|
|
9
|
+
* - HWPX: charPr height 는 1000 = 10pt
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── 기본 단위 변환 (pt 기준) ─────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** pt 를 다양한 단위로 변환 */
|
|
15
|
+
export const ptTo = {
|
|
16
|
+
/** pt → HWP unit (1 pt = 100 hwpunit) */
|
|
17
|
+
hwpunit: (pt: number): number => Math.round(pt * 100),
|
|
18
|
+
|
|
19
|
+
/** pt → EMU (English Metric Units, 1 pt = 12700 emu) */
|
|
20
|
+
emu: (pt: number): number => Math.round(pt * 12700),
|
|
21
|
+
|
|
22
|
+
/** pt → DXA (Twips, 1 pt = 20 dxa) */
|
|
23
|
+
dxa: (pt: number): number => Math.round(pt * 20),
|
|
24
|
+
|
|
25
|
+
/** pt → HWPX charPr height (1 pt = 100 hHeight) */
|
|
26
|
+
hHeight: (pt: number): number => Math.round(pt * 100),
|
|
27
|
+
|
|
28
|
+
/** pt → DOCX half-point (1 pt = 2 half-pt) */
|
|
29
|
+
halfPt: (pt: number): number => Math.round(pt * 2),
|
|
30
|
+
|
|
31
|
+
/** pt → pixel (96 DPI 기준) */
|
|
32
|
+
pixel: (pt: number): number => Math.round(pt * 1.333),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const mmTo = {
|
|
36
|
+
/** mm → HWP unit (오버플로 방지 및 한컴 엔진 내림 처리 반영) */
|
|
37
|
+
hwpunit: (mm: number): number => Math.floor((mm * 283465) / 1000),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** 다양한 단위를 pt 로 변환 */
|
|
41
|
+
export const toPt = {
|
|
42
|
+
/** HWP unit → pt */
|
|
43
|
+
hwpunit: (val: number): number => val / 100,
|
|
44
|
+
|
|
45
|
+
/** EMU → pt */
|
|
46
|
+
emu: (val: number): number => val / 12700,
|
|
47
|
+
|
|
48
|
+
/** DXA → pt */
|
|
49
|
+
dxa: (val: number): number => val / 20,
|
|
50
|
+
|
|
51
|
+
/** HWPX charPr height → pt */
|
|
52
|
+
hHeight: (val: number): number => val / 100,
|
|
53
|
+
|
|
54
|
+
/** DOCX half-point → pt */
|
|
55
|
+
halfPt: (val: number): number => val / 2,
|
|
56
|
+
|
|
57
|
+
/** pixel → pt (96 DPI 기준) */
|
|
58
|
+
pixel: (val: number): number => val * 0.75,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─── 직접 변환 (pt 를 거치지 않음) ───────────────────────
|
|
62
|
+
|
|
63
|
+
/** HWP ↔ DOCX 단위 직접 변환 */
|
|
64
|
+
export const directConvert = {
|
|
65
|
+
/** hwpunit → dxa (1 hwpunit = 0.2 dxa) */
|
|
66
|
+
hwpunitToDxa: (val: number): number => Math.round(val / 5),
|
|
67
|
+
|
|
68
|
+
/** dxa → hwpunit (1 dxa = 5 hwpunit) */
|
|
69
|
+
dxaToHwpunit: (val: number): number => Math.round(val * 5),
|
|
70
|
+
|
|
71
|
+
/** hwpunit → emu (1 hwpunit = 127 emu) */
|
|
72
|
+
hwpunitToEmu: (val: number): number => Math.round(val * 127),
|
|
73
|
+
|
|
74
|
+
/** emu → hwpunit (1 emu = 1/127 hwpunit) */
|
|
75
|
+
emuToHwpunit: (val: number): number => Math.round(val / 127),
|
|
76
|
+
|
|
77
|
+
/** dxa → emu (1 dxa = 635 emu) */
|
|
78
|
+
dxaToEmu: (val: number): number => Math.round(val * 635),
|
|
79
|
+
|
|
80
|
+
/** emu → dxa (1 emu = 1/635 dxa) */
|
|
81
|
+
emuToDxa: (val: number): number => Math.round(val / 635),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ─── 페이지 차원 변환 ────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** A4 크기 (pt) */
|
|
87
|
+
export const A4_DIMENSIONS = {
|
|
88
|
+
widthPt: 595.28,
|
|
89
|
+
heightPt: 841.89,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A4 크기 (hwpunit) */
|
|
93
|
+
export const A4_HWPUNIT = {
|
|
94
|
+
width: 5952,
|
|
95
|
+
height: 8418,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A4 크기 (emu) */
|
|
99
|
+
export const A4_EMU = {
|
|
100
|
+
width: 7512960,
|
|
101
|
+
height: 10689180,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A4 크기 (dxa) */
|
|
105
|
+
export const A4_DXA = {
|
|
106
|
+
width: 11905,
|
|
107
|
+
height: 16837,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ─── 편의 함수 ───────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** pt 를 지정된 포맷의 단위로 변환 */
|
|
113
|
+
export function ptToFormat(pt: number, format: 'hwp' | 'docx' | 'hwpx'): number {
|
|
114
|
+
switch (format) {
|
|
115
|
+
case 'hwp':
|
|
116
|
+
return ptTo.hwpunit(pt);
|
|
117
|
+
case 'docx':
|
|
118
|
+
return ptTo.emu(pt);
|
|
119
|
+
case 'hwpx':
|
|
120
|
+
return ptTo.hHeight(pt);
|
|
121
|
+
default:
|
|
122
|
+
return pt;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 지정된 포맷의 단위를 pt 로 변환 */
|
|
127
|
+
export function formatToPt(value: number, format: 'hwp' | 'docx' | 'hwpx'): number {
|
|
128
|
+
switch (format) {
|
|
129
|
+
case 'hwp':
|
|
130
|
+
return toPt.hwpunit(value);
|
|
131
|
+
case 'docx':
|
|
132
|
+
return toPt.emu(value);
|
|
133
|
+
case 'hwpx':
|
|
134
|
+
return toPt.hHeight(value);
|
|
135
|
+
default:
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/toolkit/XmlKit.ts
CHANGED
|
@@ -37,11 +37,6 @@ function parseXmlStrict(xml: string): Promise<unknown> {
|
|
|
37
37
|
if (!frame) return;
|
|
38
38
|
const { tag, obj } = frame;
|
|
39
39
|
|
|
40
|
-
// Drop whitespace-only _text
|
|
41
|
-
if (typeof obj['_text'] === 'string' && !(obj['_text'] as string).trim()) {
|
|
42
|
-
delete obj['_text'];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
40
|
if (stack.length === 0) {
|
|
46
41
|
result = { [tag]: [obj] };
|
|
47
42
|
} else {
|