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.
Files changed (40) hide show
  1. package/ .npmignore +4 -1
  2. package/README.md +39 -2
  3. package/dist/index.d.mts +74 -16
  4. package/dist/index.d.ts +70 -16
  5. package/dist/index.js +4985 -698
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +4981 -698
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +4 -1
  10. package/playground/index.html +346 -0
  11. package/playground/main.ts +302 -0
  12. package/playground/vite.config.ts +16 -0
  13. package/src/contract/decoder.ts +1 -0
  14. package/src/contract/encoder.ts +6 -1
  15. package/src/core/BaseDecoder.ts +118 -0
  16. package/src/core/BaseEncoder.ts +146 -0
  17. package/src/decoders/docx/DocxDecoder.ts +867 -150
  18. package/src/decoders/html/HtmlDecoder.ts +366 -0
  19. package/src/decoders/hwp/HwpScanner.ts +477 -88
  20. package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
  21. package/src/decoders/md/MdDecoder.ts +4 -4
  22. package/src/encoders/docx/DocxEncoder.ts +600 -295
  23. package/src/encoders/html/HtmlEncoder.ts +203 -0
  24. package/src/encoders/hwp/HwpEncoder.ts +1647 -398
  25. package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
  26. package/src/encoders/hwpx/constants.ts +148 -0
  27. package/src/encoders/hwpx/utils.ts +198 -0
  28. package/src/encoders/md/MdEncoder.ts +117 -30
  29. package/src/index.ts +1 -0
  30. package/src/model/builders.ts +8 -6
  31. package/src/model/doc-props.ts +19 -5
  32. package/src/model/doc-tree.ts +13 -5
  33. package/src/pipeline/Pipeline.ts +21 -4
  34. package/src/pipeline/registry.ts +13 -2
  35. package/src/safety/StyleBridge.ts +52 -7
  36. package/src/toolkit/ArchiveKit.ts +56 -0
  37. package/src/toolkit/StyleMapper.ts +221 -0
  38. package/src/toolkit/UnitConverter.ts +138 -0
  39. package/src/toolkit/XmlKit.ts +0 -5
  40. package/test-styling.ts +210 -0
@@ -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") // "&lt;hello&gt;&amp;world"
95
+ */
96
+ export function escapeXml(str: string): string {
97
+ return str
98
+ .replace(/&/g, "&amp;")
99
+ .replace(/</g, "&lt;")
100
+ .replace(/>/g, "&gt;")
101
+ .replace(/"/g, "&quot;")
102
+ .replace(/'/g, "&apos;");
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,39 +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';
3
+ import type { Stroke } from '../../model/doc-props';
4
+ import type { EncoderOptions } from '../../contract/encoder';
4
5
  import { succeed, fail } from '../../contract/result';
5
6
  import { TextKit } from '../../toolkit/TextKit';
6
7
  import { registry } from '../../pipeline/registry';
8
+ import { BaseEncoder } from '../../core/BaseEncoder';
7
9
 
8
- export class MdEncoder implements Encoder {
9
- readonly format = 'md';
10
+ export class MdEncoder extends BaseEncoder {
11
+ protected getFormat(): string { return 'md'; }
10
12
 
11
- 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
12
15
  try {
13
16
  const warns: string[] = [];
14
17
  const parts: string[] = [];
15
18
  for (const sheet of doc.kids) {
16
19
  // Warn about header/footer loss
17
- if (sheet.header && sheet.header.length > 0) warns.push('[SHIELD] MD: 머리글(header) 표현 불가 — 손실됨');
18
- if (sheet.footer && sheet.footer.length > 0) warns.push('[SHIELD] MD: 바닥글(footer) 표현 불가 — 손실됨');
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) 표현 불가 — 손실됨');
19
22
 
20
- for (const kid of sheet.kids) parts.push(encodeContent(kid, warns));
23
+ for (const kid of sheet.kids) parts.push(encodeContent(kid, warns, includeImages));
21
24
  }
22
- return succeed(TextKit.encode(parts.join('\n\n')), warns);
25
+ return succeed(this.stringToBytes(parts.join('\n\n')), warns);
23
26
  } catch (e: any) {
24
27
  return fail(`MD encode error: ${e?.message ?? String(e)}`);
25
28
  }
26
29
  }
27
30
  }
28
31
 
29
- function encodeContent(node: ContentNode, warns: string[]): string {
30
- 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);
31
34
  }
32
35
 
33
- function encodePara(para: ParaNode, warns: string[]): string {
36
+ function encodePara(para: ParaNode, warns: string[], includeImages: boolean): string {
34
37
  const text = para.kids.map(k => {
35
38
  if (k.tag === 'span') return encodeSpan(k, warns);
36
- if (k.tag === 'img') return encodeImage(k);
39
+ if (k.tag === 'img') return encodeImage(k, includeImages);
37
40
  return '';
38
41
  }).join('');
39
42
 
@@ -53,12 +56,6 @@ function encodePara(para: ParaNode, warns: string[]): string {
53
56
  }
54
57
 
55
58
  function encodeSpan(span: SpanNode, warns: string[]): string {
56
- // Warn about properties that can't be represented in MD
57
- if (span.props.font) warns.push(`[SHIELD] MD: 글꼴(${span.props.font}) 표현 불가 — 손실됨`);
58
- if (span.props.pt) warns.push(`[SHIELD] MD: 글자 크기(${span.props.pt}pt) 표현 불가 — 손실됨`);
59
- if (span.props.color) warns.push(`[SHIELD] MD: 글자 색상(#${span.props.color}) 표현 불가 — 손실됨`);
60
- if (span.props.bg) warns.push(`[SHIELD] MD: 배경 색상(#${span.props.bg}) 표현 불가 — 손실됨`);
61
-
62
59
  let hasPageNum = false;
63
60
  const textParts: string[] = [];
64
61
  for (const kid of span.kids) {
@@ -72,6 +69,40 @@ function encodeSpan(span: SpanNode, warns: string[]): string {
72
69
  let r = textParts.join('');
73
70
  if (hasPageNum && r === '') r = '[페이지 번호]';
74
71
 
72
+ // Collect CSS styles for font/color/size/bg — use HTML span so fonts can be
73
+ // loaded externally via the page's stylesheet or @font-face rules.
74
+ const cssStyles: string[] = [];
75
+ if (span.props.font) cssStyles.push(`font-family: ${span.props.font}`);
76
+ if (span.props.pt) cssStyles.push(`font-size: ${span.props.pt}pt`);
77
+ if (span.props.color) cssStyles.push(`color: #${span.props.color}`);
78
+ if (span.props.bg) cssStyles.push(`background-color: #${span.props.bg}`);
79
+
80
+ const hasHtmlStyle = cssStyles.length > 0;
81
+
82
+ if (hasHtmlStyle) {
83
+ // When style properties are present, use HTML for all formatting so that
84
+ // markdown markers inside an HTML element don't break parsers.
85
+ if (span.props.b) cssStyles.push('font-weight: bold');
86
+ if (span.props.i) cssStyles.push('font-style: italic');
87
+ if (span.props.s) cssStyles.push('text-decoration: line-through');
88
+ if (span.props.u) {
89
+ // combine underline with possible line-through
90
+ const existing = cssStyles.find(s => s.startsWith('text-decoration:'));
91
+ if (existing) {
92
+ const idx = cssStyles.indexOf(existing);
93
+ cssStyles[idx] = existing.replace('line-through', 'underline line-through');
94
+ if (!existing.includes('line-through')) cssStyles[idx] = existing + ' underline';
95
+ } else {
96
+ cssStyles.push('text-decoration: underline');
97
+ }
98
+ }
99
+ const styleAttr = cssStyles.join('; ');
100
+ if (span.props.sup) return `<sup style="${styleAttr}">${r}</sup>`;
101
+ if (span.props.sub) return `<sub style="${styleAttr}">${r}</sub>`;
102
+ return `<span style="${styleAttr}">${r}</span>`;
103
+ }
104
+
105
+ // No CSS styles needed — use plain Markdown formatting
75
106
  if (span.props.b && span.props.i) r = `***${r}***`;
76
107
  else if (span.props.b) r = `**${r}**`;
77
108
  else if (span.props.i) r = `*${r}*`;
@@ -83,26 +114,82 @@ function encodeSpan(span: SpanNode, warns: string[]): string {
83
114
  return r;
84
115
  }
85
116
 
86
- 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
+ }
87
121
  return `![${img.alt ?? ''}](data:${img.mime};base64,${img.b64})`;
88
122
  }
89
123
 
90
- function encodeGrid(grid: GridNode, warns: string[]): string {
91
- if (grid.kids.length === 0) return '';
124
+ /** pt → CSS border shorthand (only if stroke is visible) */
125
+ function strokeToCss(s?: Stroke): string | undefined {
126
+ if (!s || s.kind === 'none' || s.pt <= 0) return undefined;
127
+ const kindMap: Record<string, string> = { solid: 'solid', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
128
+ const style = kindMap[s.kind] ?? 'solid';
129
+ const px = Math.max(1, Math.round(s.pt * 96 / 72));
130
+ const color = s.color.startsWith('#') ? s.color : `#${s.color}`;
131
+ return `${px}px ${style} ${color}`;
132
+ }
92
133
 
93
- // Warn about table style loss
94
- if (grid.props.look) warns.push('[SHIELD] MD: 표 스타일(색상, 테두리, 머리행 강조) 표현 불가 — 손실됨');
134
+ function encodeGrid(grid: GridNode, warns: string[], includeImages: boolean): string {
135
+ if (grid.kids.length === 0) return '';
95
136
 
96
- const rows = grid.kids.map(row =>
97
- `| ${row.kids.map(cell => cell.kids.map(p => encodePara(p, warns)).join(' ')).join(' | ')} |`,
98
- );
137
+ // HTML 테이블로 출력 — 테두리/배경색을 인라인 스타일로 유지
138
+ const rowCount = grid.kids.length;
139
+
140
+ // Build occupancy map for rowspan
141
+ const occupancy: Set<number>[] = Array.from({ length: rowCount }, () => new Set());
142
+ let colCount = 0;
143
+ for (let ri = 0; ri < rowCount; ri++) {
144
+ const row = grid.kids[ri];
145
+ let ci = 0;
146
+ for (const cell of row.kids) {
147
+ while (occupancy[ri].has(ci)) ci++;
148
+ if (cell.rs > 1) {
149
+ for (let r = ri + 1; r < ri + cell.rs && r < rowCount; r++) {
150
+ for (let c = ci; c < ci + cell.cs; c++) occupancy[r].add(c);
151
+ }
152
+ }
153
+ ci += cell.cs;
154
+ }
155
+ while (occupancy[ri].has(ci)) ci++;
156
+ if (ci > colCount) colCount = ci;
157
+ }
99
158
 
100
- if (rows.length > 0) {
101
- const cols = grid.kids[0].kids.length;
102
- rows.splice(1, 0, `| ${Array(cols).fill('---').join(' | ')} |`);
159
+ let rows = '';
160
+ for (let ri = 0; ri < rowCount; ri++) {
161
+ const row = grid.kids[ri];
162
+ let cells = '';
163
+ let colIdx = 0;
164
+
165
+ for (const cell of row.kids) {
166
+ while (occupancy[ri].has(colIdx)) colIdx++;
167
+
168
+ const cs = cell.cs > 1 ? ` colspan="${cell.cs}"` : '';
169
+ const rs = cell.rs > 1 ? ` rowspan="${cell.rs}"` : '';
170
+
171
+ const styles: string[] = ['padding:4px 6px', 'vertical-align:top'];
172
+ const top = strokeToCss(cell.props.top);
173
+ const bot = strokeToCss(cell.props.bot);
174
+ const left = strokeToCss(cell.props.left);
175
+ const right = strokeToCss(cell.props.right);
176
+ if (top) styles.push(`border-top:${top}`);
177
+ if (bot) styles.push(`border-bottom:${bot}`);
178
+ if (left) styles.push(`border-left:${left}`);
179
+ if (right) styles.push(`border-right:${right}`);
180
+ if (cell.props.bg) styles.push(`background-color:#${cell.props.bg}`);
181
+ if (cell.props.va === 'mid') styles[1] = 'vertical-align:middle';
182
+ else if (cell.props.va === 'bot') styles[1] = 'vertical-align:bottom';
183
+
184
+ const tag = (grid.props.headerRow && ri === 0) || cell.props.isHeader ? 'th' : 'td';
185
+ const content = cell.kids.map(p => p.tag === 'para' ? encodePara(p, warns, includeImages) : encodeGrid(p, warns, includeImages)).join('\n');
186
+ cells += `<${tag}${cs}${rs} style="${styles.join(';')}">${content}</${tag}>`;
187
+ colIdx += cell.cs;
188
+ }
189
+ rows += `<tr>${cells}</tr>\n`;
103
190
  }
104
191
 
105
- return rows.join('\n');
192
+ return `<table style="border-collapse:collapse;width:100%">\n<tbody>\n${rows}</tbody>\n</table>\n`;
106
193
  }
107
194
 
108
195
  registry.registerEncoder(new MdEncoder());
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import './decoders/hwpx/HwpxDecoder';
10
10
  import './decoders/docx/DocxDecoder';
11
11
  import './decoders/hwp/HwpScanner';
12
12
  import './encoders/md/MdEncoder';
13
+ import './encoders/html/HtmlEncoder';
13
14
  import './encoders/hwpx/HwpxEncoder';
14
15
  import './encoders/docx/DocxEncoder';
15
16
  import './encoders/hwp/HwpEncoder';
@@ -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?: { header?: ParaNode[]; footer?: ParaNode[] },
15
+ opts?: { headers?: SheetNode["headers"]; footers?: SheetNode["footers"] },
16
16
  ): SheetNode {
17
17
  const node: SheetNode = { tag: 'sheet', dims, kids };
18
- if (opts?.header) node.header = opts.header;
19
- if (opts?.footer) node.footer = opts.footer;
18
+ if (opts?.headers) node.headers = opts.headers;
19
+ if (opts?.footers) node.footers = opts.footers;
20
20
  return node;
21
21
  }
22
22
 
@@ -54,12 +54,14 @@ export function buildGrid(kids: RowNode[], props: GridProps = {}): GridNode {
54
54
  return { tag: 'grid', props, kids };
55
55
  }
56
56
 
57
- export function buildRow(kids: CellNode[]): RowNode {
58
- return { tag: 'row', kids };
57
+ export function buildRow(kids: CellNode[], heightPt?: number): RowNode {
58
+ const node: RowNode = { tag: 'row', kids };
59
+ if (heightPt != null) node.heightPt = heightPt;
60
+ return node;
59
61
  }
60
62
 
61
63
  export function buildCell(
62
- kids: ParaNode[],
64
+ kids: (ParaNode | GridNode)[],
63
65
  opts: { cs?: number; rs?: number; props?: CellProps } = {},
64
66
  ): CellNode {
65
67
  return { tag: 'cell', cs: opts.cs ?? 1, rs: opts.rs ?? 1, props: opts.props ?? {}, kids };
@@ -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
- indentPt?: number;
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 = {