hwpkit-dev 0.0.2 → 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 -2
- package/README.md +39 -2
- package/dist/index.d.mts +41 -14
- package/dist/index.d.ts +41 -14
- package/dist/index.js +3553 -1159
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3553 -1159
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -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 +743 -151
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +325 -157
- package/src/decoders/hwpx/HwpxDecoder.ts +785 -297
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +504 -240
- package/src/encoders/html/HtmlEncoder.ts +17 -19
- package/src/encoders/hwp/HwpEncoder.ts +1466 -859
- package/src/encoders/hwpx/HwpxEncoder.ts +1477 -469
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +20 -15
- package/src/model/builders.ts +4 -4
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +12 -4
- package/src/pipeline/Pipeline.ts +7 -3
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +51 -6
- 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/hwp-analyze.ts +0 -90
- package/inspect-doc.ts +0 -57
- package/output_test.hwp +0 -0
- package/test-docx-to-hwp.ts +0 -45
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HWPX 인코더 관련 상수
|
|
3
|
+
*
|
|
4
|
+
* HWPX 파일 생성에 필요한 모든 설정값을 한곳에 모아 관리합니다.
|
|
5
|
+
* 초중급 개발자도 쉽게 수정할 수 있도록 명확한 주석을 포함했습니다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ==================== 파일 형식 관련 ====================
|
|
9
|
+
|
|
10
|
+
/** HWPX 파일의 MIME 타입 (ZIP 파일 내 mimetype 파일에 저장됨) */
|
|
11
|
+
export const HWPX_MIME_TYPE = "application/hwp+zip";
|
|
12
|
+
|
|
13
|
+
/** HWPX 파일의 버전 */
|
|
14
|
+
export const HWPX_VERSION = "1.2";
|
|
15
|
+
|
|
16
|
+
// ==================== XML 네임스페이스 ====================
|
|
17
|
+
|
|
18
|
+
/** HWPX XML 문서에서 사용되는 네임스페이스 */
|
|
19
|
+
export const NAMESPACES = {
|
|
20
|
+
/** Hancom 문서 네임스페이스 */
|
|
21
|
+
HANCOM: "http://www.hancom.co.kr/hwp/xml",
|
|
22
|
+
/** Hancom 공통 네임스페이스 */
|
|
23
|
+
HANCOM_COMMON: "http://www.hancom.co.kr/hwp/xml/common",
|
|
24
|
+
/** Hancom 버전 네임스페이스 */
|
|
25
|
+
HANCOM_VERSION: "http://www.hancom.co.kr/hwp/xml/version",
|
|
26
|
+
/** Hancom 속성 네임스페이스 */
|
|
27
|
+
HANCOM_PROP: "http://www.hancom.co.kr/hwp/xml/property",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** XML 헤더에 포함될 네임스페이스 선언 문자열 */
|
|
31
|
+
export const NAMESPACE_DECLARATIONS = {
|
|
32
|
+
HEAD: `xmlns:hh="${NAMESPACES.HANCOM}" xmlns:hc="${NAMESPACES.HANCOM_COMMON}" xmlns:hv="${NAMESPACES.HANCOM_VERSION}" xmlns:hp="${NAMESPACES.HANCOM_PROP}"`,
|
|
33
|
+
SECTION: `xmlns:hs="${NAMESPACES.HANCOM}" xmlns:hp="${NAMESPACES.HANCOM_PROP}"`,
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
// ==================== 단위 변환 ====================
|
|
37
|
+
|
|
38
|
+
/** 1 포인트 (pt) 를 HWPUNIT 으로 변환한 값 (HWPX 내부 단위) */
|
|
39
|
+
export const HWPUNIT_PER_PT = 1000;
|
|
40
|
+
|
|
41
|
+
/** 1 인치 (inch) 를 포인트 (pt) 로 변환한 값 */
|
|
42
|
+
export const PT_PER_INCH = 72;
|
|
43
|
+
|
|
44
|
+
/** 1 인치 (inch) 를 픽셀로 변환한 값 (표준 96 DPI 기준) */
|
|
45
|
+
export const PIXELS_PER_INCH = 96;
|
|
46
|
+
|
|
47
|
+
/** 픽셀을 포인트로 변환하는 계수 */
|
|
48
|
+
export const PT_PER_PIXEL = PT_PER_INCH / PIXELS_PER_INCH; // 0.75
|
|
49
|
+
|
|
50
|
+
// ==================== 페이지 크기 (A4 기준) ====================
|
|
51
|
+
|
|
52
|
+
/** A4 용지 너비 (포인트) */
|
|
53
|
+
export const A4_WIDTH_PT = 794;
|
|
54
|
+
|
|
55
|
+
/** A4 용지 높이 (포인트) */
|
|
56
|
+
export const A4_HEIGHT_PT = 1123;
|
|
57
|
+
|
|
58
|
+
/** A4 용지 상단 여백 (포인트) */
|
|
59
|
+
export const A4_TOP_MARGIN_PT = 72;
|
|
60
|
+
|
|
61
|
+
/** A4 용지 하단 여백 (포인트) */
|
|
62
|
+
export const A4_BOTTOM_MARGIN_PT = 72;
|
|
63
|
+
|
|
64
|
+
/** A4 용지 왼쪽 여백 (포인트) */
|
|
65
|
+
export const A4_LEFT_MARGIN_PT = 83;
|
|
66
|
+
|
|
67
|
+
/** A4 용지 오른쪽 여백 (포인트) */
|
|
68
|
+
export const A4_RIGHT_MARGIN_PT = 83;
|
|
69
|
+
|
|
70
|
+
/** A4 용지 상단 머리글 영역 (포인트) */
|
|
71
|
+
export const A4_HEADER_MARGIN_PT = 40;
|
|
72
|
+
|
|
73
|
+
/** A4 용지 하단 바닥글 영역 (포인트) */
|
|
74
|
+
export const A4_FOOTER_MARGIN_PT = 40;
|
|
75
|
+
|
|
76
|
+
// ==================== 폰트 관련 ====================
|
|
77
|
+
|
|
78
|
+
/** 기본 한글 폰트 */
|
|
79
|
+
export const DEFAULT_KOREAN_FONT = "Batang";
|
|
80
|
+
|
|
81
|
+
/** 기본 영문 폰트 */
|
|
82
|
+
export const DEFAULT_LATIN_FONT = "Arial";
|
|
83
|
+
|
|
84
|
+
/** 기본 폰트 크기 (포인트) */
|
|
85
|
+
export const DEFAULT_FONT_SIZE_PT = 10;
|
|
86
|
+
|
|
87
|
+
// ==================== 줄 간격 ====================
|
|
88
|
+
|
|
89
|
+
/** 기본 줄 간격 (폰트 크기의 %) */
|
|
90
|
+
export const DEFAULT_LINE_SPACING_PERCENT = 160;
|
|
91
|
+
|
|
92
|
+
/** 최소 줄 간격 (HWPUNIT) */
|
|
93
|
+
export const MIN_LINE_SPACING_HWPUNIT = 1000;
|
|
94
|
+
|
|
95
|
+
// ==================== 스타일 ID ====================
|
|
96
|
+
|
|
97
|
+
/** 바탕글 스타일 ID */
|
|
98
|
+
export const STYLE_ID_BATANGGUL = "0";
|
|
99
|
+
|
|
100
|
+
/** 개요 1 스타일 ID */
|
|
101
|
+
export const STYLE_ID_GYEYOE1 = "1";
|
|
102
|
+
|
|
103
|
+
/** 개요 2 스타일 ID */
|
|
104
|
+
export const STYLE_ID_GYEYOE2 = "2";
|
|
105
|
+
|
|
106
|
+
// ==================== 이미지 관련 ====================
|
|
107
|
+
|
|
108
|
+
/** 이미지 파일이 저장될 디렉토리 경로 (ZIP 내) */
|
|
109
|
+
export const IMAGE_DIR_PATH = "image/";
|
|
110
|
+
|
|
111
|
+
/** 이미지 파일명 접미사 */
|
|
112
|
+
export const IMAGE_FILE_EXTENSION = ".png";
|
|
113
|
+
|
|
114
|
+
// ==================== 테이블 관련 ====================
|
|
115
|
+
|
|
116
|
+
/** 테이블 테두리 기본 두께 (HWPUNIT) */
|
|
117
|
+
export const TABLE_BORDER_WIDTH_HWPUNIT = 500; // 0.5pt
|
|
118
|
+
|
|
119
|
+
/** 테이블 셀 기본 패딩 (HWPUNIT) */
|
|
120
|
+
export const TABLE_CELL_PADDING_HWPUNIT = 200; // 0.2pt
|
|
121
|
+
|
|
122
|
+
// ==================== 색상 코드 ====================
|
|
123
|
+
|
|
124
|
+
/** 검정색 (hex) */
|
|
125
|
+
export const COLOR_BLACK = "#000000";
|
|
126
|
+
|
|
127
|
+
/** 흰색 (hex) */
|
|
128
|
+
export const COLOR_WHITE = "#FFFFFF";
|
|
129
|
+
|
|
130
|
+
/** 회색 (워터마크용) */
|
|
131
|
+
export const COLOR_GRAY = "#CCCCCC";
|
|
132
|
+
|
|
133
|
+
/** 코드 블록 배경색 */
|
|
134
|
+
export const COLOR_CODE_BLOCK_BG = "#f4f4f4";
|
|
135
|
+
|
|
136
|
+
// ==================== ZIP 파일 구조 ====================
|
|
137
|
+
|
|
138
|
+
/** HWPX 파일 내 파일 목록 */
|
|
139
|
+
export const HWPX_FILE_STRUCTURE = [
|
|
140
|
+
{ name: "mimetype", mimeType: "" },
|
|
141
|
+
{ name: ".hwpversions/version.xml", mimeType: "application/xml" },
|
|
142
|
+
{ name: "header.xml", mimeType: "application/xml" },
|
|
143
|
+
{ name: "section0.xml", mimeType: "application/xml" },
|
|
144
|
+
{ name: "content.hpf", mimeType: "application/octet-stream" },
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
/** ZIP 파일 생성 시 사용할 압축 레벨 (0=압축안함, 9=최대압축) */
|
|
148
|
+
export const ZIP_COMPRESSION_LEVEL = 9;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HWPX 인코더 유틸리티 함수
|
|
3
|
+
*
|
|
4
|
+
* HWPX 인코딩에 필요한 재사용 가능한 함수들을 모아둔 모듈입니다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
HWPUNIT_PER_PT,
|
|
9
|
+
PT_PER_PIXEL,
|
|
10
|
+
PT_PER_INCH,
|
|
11
|
+
} from './constants';
|
|
12
|
+
|
|
13
|
+
// ==================== 단위 변환 함수 ====================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 포인트 (pt) 를 HWPUNIT 으로 변환합니다.
|
|
17
|
+
* @param pt - 포인트 값
|
|
18
|
+
* @returns HWPUNIT 값 (정수)
|
|
19
|
+
* @example
|
|
20
|
+
* ptToHwp(10) // 10000 (10pt × 1000)
|
|
21
|
+
*/
|
|
22
|
+
export function ptToHwp(pt: number): number {
|
|
23
|
+
return Math.round(pt * HWPUNIT_PER_PT);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* HWPUNIT 을 포인트 (pt) 로 변환합니다.
|
|
28
|
+
* @param hwpunit - HWPUNIT 값
|
|
29
|
+
* @returns 포인트 값
|
|
30
|
+
* @example
|
|
31
|
+
* hwpToPt(10000) // 10 (10000 ÷ 1000)
|
|
32
|
+
*/
|
|
33
|
+
export function hwpToPt(hwpunit: number): number {
|
|
34
|
+
return hwpunit / HWPUNIT_PER_PT;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 포인트 (pt) 를 HWP 높이 단위로 변환합니다.
|
|
39
|
+
* 폰트 높이로 사용되며 ptToHwp 와 동일합니다.
|
|
40
|
+
* @param pt - 포인트 값
|
|
41
|
+
* @returns HWP 높이 단위
|
|
42
|
+
*/
|
|
43
|
+
export function ptToHHeight(pt: number): number {
|
|
44
|
+
return ptToHwp(pt);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 픽셀을 포인트 (pt) 로 변환합니다.
|
|
49
|
+
* @param pixels - 픽셀 값
|
|
50
|
+
* @returns 포인트 값
|
|
51
|
+
* @example
|
|
52
|
+
* pixelsToPt(96) // 72 (96px × 72/96)
|
|
53
|
+
*/
|
|
54
|
+
export function pixelsToPt(pixels: number): number {
|
|
55
|
+
return pixels * PT_PER_PIXEL;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 픽셀을 HWPUNIT 으로 변환합니다.
|
|
60
|
+
* @param pixels - 픽셀 값
|
|
61
|
+
* @returns HWPUNIT 값
|
|
62
|
+
*/
|
|
63
|
+
export function pixelsToHwp(pixels: number): number {
|
|
64
|
+
return ptToHwp(pixelsToPt(pixels));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 인치 (inch) 를 포인트 (pt) 로 변환합니다.
|
|
69
|
+
* @param inches - 인치 값
|
|
70
|
+
* @returns 포인트 값
|
|
71
|
+
* @example
|
|
72
|
+
* inchesToPt(1) // 72 (1 인치 × 72)
|
|
73
|
+
*/
|
|
74
|
+
export function inchesToPt(inches: number): number {
|
|
75
|
+
return inches * PT_PER_INCH;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 인치 (inch) 를 HWPUNIT 으로 변환합니다.
|
|
80
|
+
* @param inches - 인치 값
|
|
81
|
+
* @returns HWPUNIT 값
|
|
82
|
+
*/
|
|
83
|
+
export function inchesToHwp(inches: number): number {
|
|
84
|
+
return ptToHwp(inchesToPt(inches));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ==================== XML 도우미 함수 ====================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* XML 문자열을 이스케이프 처리합니다.
|
|
91
|
+
* @param str - 이스케이프할 문자열
|
|
92
|
+
* @returns 이스케이프된 문자열
|
|
93
|
+
* @example
|
|
94
|
+
* escapeXml("<hello>&world") // "<hello>&world"
|
|
95
|
+
*/
|
|
96
|
+
export function escapeXml(str: string): string {
|
|
97
|
+
return str
|
|
98
|
+
.replace(/&/g, "&")
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">")
|
|
101
|
+
.replace(/"/g, """)
|
|
102
|
+
.replace(/'/g, "'");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 속성 문자열을 생성합니다.
|
|
107
|
+
* @param attributes - 속성 객체 (null/undefined 값은 제외됨)
|
|
108
|
+
* @returns 속성 문자열
|
|
109
|
+
* @example
|
|
110
|
+
* buildAttributes({ id: "1", name: "test", disabled: null })
|
|
111
|
+
* // 'id="1" name="test"'
|
|
112
|
+
*/
|
|
113
|
+
export function buildAttributes(attributes: Record<string, string | number | null | undefined>): string {
|
|
114
|
+
const parts: string[] = [];
|
|
115
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
116
|
+
if (value != null && value !== "") {
|
|
117
|
+
const escapedValue = typeof value === "string" ? escapeXml(value) : String(value);
|
|
118
|
+
parts.push(`${key}="${escapedValue}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return parts.join(" ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* XML 요소를 생성합니다.
|
|
126
|
+
* @param tagName - 태그 이름
|
|
127
|
+
* @param attributes - 속성 객체
|
|
128
|
+
* @param content - 내용 (문자열 또는 자식 요소 배열)
|
|
129
|
+
* @returns XML 요소 문자열
|
|
130
|
+
* @example
|
|
131
|
+
* createElement("span", { id: "1" }, "내용")
|
|
132
|
+
* // '<span id="1">내용</span>'
|
|
133
|
+
*/
|
|
134
|
+
export function createElement(
|
|
135
|
+
tagName: string,
|
|
136
|
+
attributes: Record<string, string | number | null | undefined> = {},
|
|
137
|
+
content: string | string[] = "",
|
|
138
|
+
): string {
|
|
139
|
+
const attrs = buildAttributes(attributes);
|
|
140
|
+
const attrsStr = attrs ? ` ${attrs}` : "";
|
|
141
|
+
const contentStr = Array.isArray(content) ? content.join("") : content;
|
|
142
|
+
|
|
143
|
+
if (contentStr === "") {
|
|
144
|
+
return `<${tagName}${attrsStr}/>`;
|
|
145
|
+
}
|
|
146
|
+
return `<${tagName}${attrsStr}>${contentStr}</${tagName}>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ==================== 문자열 처리 함수 ====================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 문자열이 한글을 포함하는지 확인합니다.
|
|
153
|
+
* @param text - 확인할 문자열
|
|
154
|
+
* @returns 한글이 포함되어 있으면 true
|
|
155
|
+
*/
|
|
156
|
+
export function containsKorean(text: string): boolean {
|
|
157
|
+
return /[\uAC00-\uD7A3\u3131-\u318E]/.test(text);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 문자열이 영문을 포함하는지 확인합니다.
|
|
162
|
+
* @param text - 확인할 문자열
|
|
163
|
+
* @returns 영문이 포함되어 있으면 true
|
|
164
|
+
*/
|
|
165
|
+
export function containsLatin(text: string): boolean {
|
|
166
|
+
return /[a-zA-Z]/.test(text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ==================== 계산 함수 ====================
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 두 숫자 중 큰 값을 반환합니다.
|
|
173
|
+
* @param a - 첫 번째 숫자
|
|
174
|
+
* @param b - 두 번째 숫자
|
|
175
|
+
* @returns 큰 값
|
|
176
|
+
*/
|
|
177
|
+
export function max(a: number, b: number): number {
|
|
178
|
+
return a > b ? a : b;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 두 숫자 중 작은 값을 반환합니다.
|
|
183
|
+
* @param a - 첫 번째 숫자
|
|
184
|
+
* @param b - 두 번째 숫자
|
|
185
|
+
* @returns 작은 값
|
|
186
|
+
*/
|
|
187
|
+
export function min(a: number, b: number): number {
|
|
188
|
+
return a < b ? a : b;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 숫자를 정수로 반올림합니다.
|
|
193
|
+
* @param value - 변환할 숫자
|
|
194
|
+
* @returns 정수
|
|
195
|
+
*/
|
|
196
|
+
export function round(value: number): number {
|
|
197
|
+
return Math.round(value);
|
|
198
|
+
}
|
|
@@ -1,40 +1,42 @@
|
|
|
1
|
-
import type { Encoder } from '../../contract/encoder';
|
|
2
1
|
import type { DocRoot, ParaNode, SpanNode, GridNode, ContentNode, ImgNode } from '../../model/doc-tree';
|
|
3
2
|
import type { Outcome } from '../../contract/result';
|
|
4
3
|
import type { Stroke } from '../../model/doc-props';
|
|
4
|
+
import type { EncoderOptions } from '../../contract/encoder';
|
|
5
5
|
import { succeed, fail } from '../../contract/result';
|
|
6
6
|
import { TextKit } from '../../toolkit/TextKit';
|
|
7
7
|
import { registry } from '../../pipeline/registry';
|
|
8
|
+
import { BaseEncoder } from '../../core/BaseEncoder';
|
|
8
9
|
|
|
9
|
-
export class MdEncoder
|
|
10
|
-
|
|
10
|
+
export class MdEncoder extends BaseEncoder {
|
|
11
|
+
protected getFormat(): string { return 'md'; }
|
|
11
12
|
|
|
12
|
-
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
13
|
+
async encode(doc: DocRoot, options?: EncoderOptions): Promise<Outcome<Uint8Array>> {
|
|
14
|
+
const includeImages = options?.includeImages !== false; // default: true
|
|
13
15
|
try {
|
|
14
16
|
const warns: string[] = [];
|
|
15
17
|
const parts: string[] = [];
|
|
16
18
|
for (const sheet of doc.kids) {
|
|
17
19
|
// Warn about header/footer loss
|
|
18
|
-
if (sheet.
|
|
19
|
-
if (sheet.
|
|
20
|
+
if (sheet.headers && sheet.headers.default && sheet.headers.default.length > 0) warns.push('[SHIELD] MD: 머리글(header) 표현 불가 — 손실됨');
|
|
21
|
+
if (sheet.footers && sheet.footers.default && sheet.footers.default.length > 0) warns.push('[SHIELD] MD: 바닥글(footer) 표현 불가 — 손실됨');
|
|
20
22
|
|
|
21
|
-
for (const kid of sheet.kids) parts.push(encodeContent(kid, warns));
|
|
23
|
+
for (const kid of sheet.kids) parts.push(encodeContent(kid, warns, includeImages));
|
|
22
24
|
}
|
|
23
|
-
return succeed(
|
|
25
|
+
return succeed(this.stringToBytes(parts.join('\n\n')), warns);
|
|
24
26
|
} catch (e: any) {
|
|
25
27
|
return fail(`MD encode error: ${e?.message ?? String(e)}`);
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function encodeContent(node: ContentNode, warns: string[]): string {
|
|
31
|
-
return node.tag === 'grid' ? encodeGrid(node, warns) : encodePara(node, warns);
|
|
32
|
+
function encodeContent(node: ContentNode, warns: string[], includeImages: boolean): string {
|
|
33
|
+
return node.tag === 'grid' ? encodeGrid(node, warns, includeImages) : encodePara(node, warns, includeImages);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
function encodePara(para: ParaNode, warns: string[]): string {
|
|
36
|
+
function encodePara(para: ParaNode, warns: string[], includeImages: boolean): string {
|
|
35
37
|
const text = para.kids.map(k => {
|
|
36
38
|
if (k.tag === 'span') return encodeSpan(k, warns);
|
|
37
|
-
if (k.tag === 'img') return encodeImage(k);
|
|
39
|
+
if (k.tag === 'img') return encodeImage(k, includeImages);
|
|
38
40
|
return '';
|
|
39
41
|
}).join('');
|
|
40
42
|
|
|
@@ -112,7 +114,10 @@ function encodeSpan(span: SpanNode, warns: string[]): string {
|
|
|
112
114
|
return r;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function encodeImage(img: ImgNode): string {
|
|
117
|
+
function encodeImage(img: ImgNode, includeImages: boolean): string {
|
|
118
|
+
if (!includeImages) {
|
|
119
|
+
return `![${img.alt ?? ''}]`; // alt text only, no data URI
|
|
120
|
+
}
|
|
116
121
|
return ``;
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -126,7 +131,7 @@ function strokeToCss(s?: Stroke): string | undefined {
|
|
|
126
131
|
return `${px}px ${style} ${color}`;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
function encodeGrid(grid: GridNode, warns: string[]): string {
|
|
134
|
+
function encodeGrid(grid: GridNode, warns: string[], includeImages: boolean): string {
|
|
130
135
|
if (grid.kids.length === 0) return '';
|
|
131
136
|
|
|
132
137
|
// HTML 테이블로 출력 — 테두리/배경색을 인라인 스타일로 유지
|
|
@@ -177,7 +182,7 @@ function encodeGrid(grid: GridNode, warns: string[]): string {
|
|
|
177
182
|
else if (cell.props.va === 'bot') styles[1] = 'vertical-align:bottom';
|
|
178
183
|
|
|
179
184
|
const tag = (grid.props.headerRow && ri === 0) || cell.props.isHeader ? 'th' : 'td';
|
|
180
|
-
const content = cell.kids.map(p => encodePara(p, warns)).join('\n');
|
|
185
|
+
const content = cell.kids.map(p => p.tag === 'para' ? encodePara(p, warns, includeImages) : encodeGrid(p, warns, includeImages)).join('\n');
|
|
181
186
|
cells += `<${tag}${cs}${rs} style="${styles.join(';')}">${content}</${tag}>`;
|
|
182
187
|
colIdx += cell.cs;
|
|
183
188
|
}
|
package/src/model/builders.ts
CHANGED
|
@@ -12,11 +12,11 @@ export function buildRoot(meta: DocMeta = {}, kids: SheetNode[] = []): DocRoot {
|
|
|
12
12
|
export function buildSheet(
|
|
13
13
|
kids: ContentNode[] = [],
|
|
14
14
|
dims: PageDims = A4,
|
|
15
|
-
opts?: {
|
|
15
|
+
opts?: { headers?: SheetNode["headers"]; footers?: SheetNode["footers"] },
|
|
16
16
|
): SheetNode {
|
|
17
17
|
const node: SheetNode = { tag: 'sheet', dims, kids };
|
|
18
|
-
if (opts?.
|
|
19
|
-
if (opts?.
|
|
18
|
+
if (opts?.headers) node.headers = opts.headers;
|
|
19
|
+
if (opts?.footers) node.footers = opts.footers;
|
|
20
20
|
return node;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -61,7 +61,7 @@ export function buildRow(kids: CellNode[], heightPt?: number): RowNode {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function buildCell(
|
|
64
|
-
kids: ParaNode[],
|
|
64
|
+
kids: (ParaNode | GridNode)[],
|
|
65
65
|
opts: { cs?: number; rs?: number; props?: CellProps } = {},
|
|
66
66
|
): CellNode {
|
|
67
67
|
return { tag: 'cell', cs: opts.cs ?? 1, rs: opts.rs ?? 1, props: opts.props ?? {}, kids };
|
package/src/model/doc-props.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type Align = 'left' | 'center' | 'right' | 'justify';
|
|
2
2
|
|
|
3
3
|
// ─── 이미지 배치 ────────────────────────────────────────────
|
|
4
|
-
export type ImgWrap = 'inline' | 'square' | 'tight' | 'through' | 'none' | 'behind' | 'front';
|
|
4
|
+
export type ImgWrap = 'inline' | 'square' | 'tight' | 'through' | 'none' | 'behind' | 'front' | 'topAndBottom';
|
|
5
5
|
export type ImgHorzAlign = 'left' | 'center' | 'right';
|
|
6
6
|
export type ImgVertAlign = 'top' | 'center' | 'bottom';
|
|
7
7
|
export type ImgHorzRelTo = 'margin' | 'column' | 'page' | 'para';
|
|
@@ -24,7 +24,7 @@ export interface ImgLayout {
|
|
|
24
24
|
}
|
|
25
25
|
export type VAlign = 'top' | 'mid' | 'bot';
|
|
26
26
|
export type Heading = 1 | 2 | 3 | 4 | 5 | 6;
|
|
27
|
-
export type StrokeKind = 'solid' | 'dash' | 'dot' | 'double' | 'none';
|
|
27
|
+
export type StrokeKind = 'solid' | 'dash' | 'dot' | 'double' | 'none' | 'dashDot' | 'dashDotDot' | 'wave';
|
|
28
28
|
|
|
29
29
|
export interface TextProps {
|
|
30
30
|
b?: boolean;
|
|
@@ -42,10 +42,15 @@ export interface TextProps {
|
|
|
42
42
|
export interface ParaProps {
|
|
43
43
|
align?: Align;
|
|
44
44
|
heading?: Heading;
|
|
45
|
-
|
|
45
|
+
styleId?: string; // DOCX pStyle styleId (e.g. "Heading1", "TOC1")
|
|
46
|
+
indentPt?: number; // 문단 왼쪽 전체 들여쓰기 (pt) — OWPML hc:left
|
|
47
|
+
indentRightPt?: number; // 문단 오른쪽 전체 들여쓰기 (pt) — OWPML hc:right
|
|
48
|
+
firstLineIndentPt?: number; // 첫 줄 들여쓰기 (pt, 음수=내어쓰기) — OWPML hc:indent
|
|
49
|
+
leftMargin?: number; // 문단 왼쪽 여백 (HWP leftMargin, pt)
|
|
46
50
|
spaceBefore?: number;
|
|
47
51
|
spaceAfter?: number;
|
|
48
|
-
lineHeight?: number;
|
|
52
|
+
lineHeight?: number; // 줄 간격 배율 (예: 1.5 = 150%)
|
|
53
|
+
lineHeightFixed?: number; // 고정 줄 높이 (pt) — OWPML lineSpacing type="FIXED"
|
|
49
54
|
listLv?: number;
|
|
50
55
|
listOrd?: boolean;
|
|
51
56
|
listMark?: string;
|
|
@@ -63,7 +68,11 @@ export interface CellProps {
|
|
|
63
68
|
left?: Stroke;
|
|
64
69
|
right?: Stroke;
|
|
65
70
|
bg?: string;
|
|
66
|
-
padPt?: number;
|
|
71
|
+
padPt?: number; // 모든 방향 균일 패딩 (하위 호환)
|
|
72
|
+
padT?: number; // 상단 패딩 (pt)
|
|
73
|
+
padB?: number; // 하단 패딩
|
|
74
|
+
padL?: number; // 좌측 패딩
|
|
75
|
+
padR?: number; // 우측 패딩
|
|
67
76
|
align?: Align;
|
|
68
77
|
va?: VAlign;
|
|
69
78
|
isHeader?: boolean;
|
|
@@ -84,6 +93,7 @@ export interface GridProps {
|
|
|
84
93
|
defaultStroke?: Stroke;
|
|
85
94
|
look?: TableLook;
|
|
86
95
|
headerRow?: boolean;
|
|
96
|
+
align?: Align; // 표 정렬: 'left' | 'center' | 'right' | 'justify'
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
export interface PageDims {
|
|
@@ -94,6 +104,8 @@ export interface PageDims {
|
|
|
94
104
|
ml: number;
|
|
95
105
|
mr: number;
|
|
96
106
|
orient?: 'portrait' | 'landscape';
|
|
107
|
+
headerPt?: number; // distance from paper top to header top (DOCX w:header)
|
|
108
|
+
footerPt?: number; // distance from paper bottom to footer bottom (DOCX w:footer)
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
export interface DocMeta {
|
|
@@ -104,6 +116,8 @@ export interface DocMeta {
|
|
|
104
116
|
keywords?: string;
|
|
105
117
|
created?: string;
|
|
106
118
|
modified?: string;
|
|
119
|
+
zoom?: number;
|
|
120
|
+
viewMode?: string;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
export const A4: PageDims = {
|
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,7 +53,7 @@ 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
59
|
export interface RowNode { tag: 'row'; kids: CellNode[]; heightPt?: number }
|
|
@@ -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] };
|
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); }
|