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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Decoder } from "../../contract/decoder";
|
|
2
1
|
import type {
|
|
3
2
|
DocRoot,
|
|
4
3
|
ContentNode,
|
|
@@ -6,6 +5,7 @@ import type {
|
|
|
6
5
|
SpanNode,
|
|
7
6
|
GridNode,
|
|
8
7
|
ImgNode,
|
|
8
|
+
LinkNode,
|
|
9
9
|
PageNumNode,
|
|
10
10
|
CellNode,
|
|
11
11
|
} from "../../model/doc-tree";
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
CellProps,
|
|
19
19
|
GridProps,
|
|
20
20
|
TableLook,
|
|
21
|
+
Stroke,
|
|
21
22
|
ImgLayout,
|
|
22
23
|
ImgHorzAlign,
|
|
23
24
|
ImgVertAlign,
|
|
@@ -46,13 +47,16 @@ import {
|
|
|
46
47
|
safeHex,
|
|
47
48
|
safeStrokeDocx,
|
|
48
49
|
} from "../../safety/StyleBridge";
|
|
50
|
+
import { BaseDecoder } from "../../core/BaseDecoder";
|
|
49
51
|
import { ArchiveKit } from "../../toolkit/ArchiveKit";
|
|
50
52
|
import { XmlKit } from "../../toolkit/XmlKit";
|
|
51
53
|
import { TextKit } from "../../toolkit/TextKit";
|
|
52
54
|
import { registry } from "../../pipeline/registry";
|
|
53
55
|
|
|
54
|
-
export class DocxDecoder
|
|
55
|
-
|
|
56
|
+
export class DocxDecoder extends BaseDecoder {
|
|
57
|
+
protected getFormat(): string {
|
|
58
|
+
return "docx";
|
|
59
|
+
}
|
|
56
60
|
|
|
57
61
|
async decode(data: Uint8Array): Promise<Outcome<DocRoot>> {
|
|
58
62
|
const shield = new ShieldedParser();
|
|
@@ -61,15 +65,23 @@ export class DocxDecoder implements Decoder {
|
|
|
61
65
|
try {
|
|
62
66
|
const files = await ArchiveKit.unzip(data);
|
|
63
67
|
|
|
64
|
-
const
|
|
68
|
+
const getFile = (path: string) => {
|
|
69
|
+
const lower = path.toLowerCase();
|
|
70
|
+
for (const [name, data] of files.entries()) {
|
|
71
|
+
if (name.toLowerCase() === lower) return data;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const docXml = getFile("word/document.xml");
|
|
65
77
|
if (!docXml) return fail("DOCX: word/document.xml not found");
|
|
66
78
|
|
|
67
|
-
const relsXml =
|
|
79
|
+
const relsXml = getFile("word/_rels/document.xml.rels");
|
|
68
80
|
const relsMap = relsXml
|
|
69
81
|
? await parseRels(TextKit.decode(relsXml))
|
|
70
82
|
: new Map<string, string>();
|
|
71
83
|
|
|
72
|
-
const coreXml =
|
|
84
|
+
const coreXml = getFile("docProps/core.xml");
|
|
73
85
|
let meta: DocMeta = {};
|
|
74
86
|
if (coreXml) {
|
|
75
87
|
try {
|
|
@@ -80,7 +92,7 @@ export class DocxDecoder implements Decoder {
|
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
// Parse numbering.xml for list support
|
|
83
|
-
const numXml =
|
|
95
|
+
const numXml = getFile("word/numbering.xml");
|
|
84
96
|
let numMap: NumMap = new Map();
|
|
85
97
|
if (numXml) {
|
|
86
98
|
try {
|
|
@@ -90,47 +102,85 @@ export class DocxDecoder implements Decoder {
|
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
|
|
105
|
+
// Parse styles.xml for table and paragraph/character style defaults
|
|
106
|
+
let stylesMap: StylesMap = new Map();
|
|
107
|
+
let paraStyleMap: ParaStyleMap = new Map();
|
|
108
|
+
const stylesXml = getFile("word/styles.xml");
|
|
109
|
+
if (stylesXml) {
|
|
110
|
+
try {
|
|
111
|
+
const stylesStr = TextKit.decode(stylesXml);
|
|
112
|
+
stylesMap = await parseStylesMap(stylesStr);
|
|
113
|
+
paraStyleMap = await parseParaStyleMap(stylesStr);
|
|
114
|
+
} catch {
|
|
115
|
+
/* non-fatal */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let docStr = TextKit.decode(docXml).trim();
|
|
120
|
+
if (!docStr) {
|
|
121
|
+
warns.push(
|
|
122
|
+
"DOCX: word/document.xml is empty, using fallback empty document",
|
|
123
|
+
);
|
|
124
|
+
docStr =
|
|
125
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>';
|
|
126
|
+
}
|
|
94
127
|
const docObj: any = await XmlKit.parseStrict(docStr);
|
|
95
128
|
|
|
96
129
|
const body = getBody(docObj);
|
|
97
130
|
const dims = extractDims(body) ?? { ...A4 };
|
|
98
131
|
const elements = getBodyElements(body);
|
|
132
|
+
console.log(
|
|
133
|
+
`[DocxDecoder] 파싱된 전체 본문 요소 개수: ${elements.length}`,
|
|
134
|
+
);
|
|
99
135
|
|
|
100
|
-
const decCtx: DecCtx = {
|
|
136
|
+
const decCtx: DecCtx = {
|
|
137
|
+
relsMap,
|
|
138
|
+
files,
|
|
139
|
+
shield,
|
|
140
|
+
numMap,
|
|
141
|
+
warns,
|
|
142
|
+
stylesMap,
|
|
143
|
+
paraStyleMap,
|
|
144
|
+
};
|
|
101
145
|
|
|
102
146
|
const kids: ContentNode[] = [];
|
|
103
147
|
for (const el of elements) {
|
|
104
|
-
const
|
|
148
|
+
const nodes = shield.guard(
|
|
105
149
|
() => decodeElement(el, decCtx),
|
|
106
|
-
buildPara([buildSpan("[요소 파싱 실패]")]),
|
|
150
|
+
[buildPara([buildSpan("[요소 파싱 실패]")])],
|
|
107
151
|
"docx:bodyElement",
|
|
108
152
|
);
|
|
109
|
-
|
|
153
|
+
if (Array.isArray(nodes)) {
|
|
154
|
+
kids.push(...nodes);
|
|
155
|
+
} else {
|
|
156
|
+
kids.push(nodes);
|
|
157
|
+
}
|
|
110
158
|
|
|
111
159
|
// Inline sectPr in pPr = section break → insert page-break paragraph after
|
|
112
|
-
if (el.type ===
|
|
160
|
+
if (el.type === "para") {
|
|
113
161
|
const pPr = el.node?.["w:pPr"]?.[0] ?? el.node?.pPr?.[0] ?? {};
|
|
114
162
|
const inlineSectPr = pPr?.["w:sectPr"]?.[0] ?? pPr?.sectPr?.[0];
|
|
115
163
|
if (inlineSectPr) {
|
|
116
164
|
const typeAttr = inlineSectPr?.["w:type"]?.[0]?._attr;
|
|
117
|
-
const sectType = typeAttr?.["w:val"] ?? typeAttr?.val ??
|
|
118
|
-
if (sectType !==
|
|
119
|
-
kids.push(
|
|
165
|
+
const sectType = typeAttr?.["w:val"] ?? typeAttr?.val ?? "nextPage";
|
|
166
|
+
if (sectType !== "continuous") {
|
|
167
|
+
kids.push(
|
|
168
|
+
buildPara([{ tag: "span", props: {}, kids: [buildPb()] }]),
|
|
169
|
+
);
|
|
120
170
|
}
|
|
121
171
|
}
|
|
122
172
|
}
|
|
123
173
|
}
|
|
124
174
|
|
|
125
175
|
// Decode header/footer
|
|
126
|
-
const
|
|
176
|
+
const headersMap = await decodeHeaderFooter(
|
|
127
177
|
"header",
|
|
128
178
|
body,
|
|
129
179
|
relsMap,
|
|
130
180
|
files,
|
|
131
181
|
decCtx,
|
|
132
182
|
);
|
|
133
|
-
const
|
|
183
|
+
const footersMap = await decodeHeaderFooter(
|
|
134
184
|
"footer",
|
|
135
185
|
body,
|
|
136
186
|
relsMap,
|
|
@@ -140,8 +190,8 @@ export class DocxDecoder implements Decoder {
|
|
|
140
190
|
|
|
141
191
|
warns.push(...shield.flush());
|
|
142
192
|
const sheet = buildSheet(kids.filter(Boolean) as ContentNode[], dims, {
|
|
143
|
-
|
|
144
|
-
|
|
193
|
+
headers: headersMap,
|
|
194
|
+
footers: footersMap,
|
|
145
195
|
});
|
|
146
196
|
return succeed(buildRoot(meta, [sheet]), warns);
|
|
147
197
|
} catch (e: any) {
|
|
@@ -153,12 +203,55 @@ export class DocxDecoder implements Decoder {
|
|
|
153
203
|
|
|
154
204
|
// ─── types ─────────────────────────────────────────────────
|
|
155
205
|
|
|
206
|
+
interface TblBorderDef {
|
|
207
|
+
top?: Stroke;
|
|
208
|
+
bottom?: Stroke;
|
|
209
|
+
left?: Stroke;
|
|
210
|
+
right?: Stroke;
|
|
211
|
+
insideH?: Stroke;
|
|
212
|
+
insideV?: Stroke;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Parsed tblStyle defaults from styles.xml */
|
|
216
|
+
interface TblStyleDef {
|
|
217
|
+
tblBorders?: TblBorderDef;
|
|
218
|
+
cellBg?: string; // default cell background
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Parsed paragraph/character style defaults */
|
|
222
|
+
interface ParaStyleDef {
|
|
223
|
+
rPr?: {
|
|
224
|
+
b?: boolean;
|
|
225
|
+
i?: boolean;
|
|
226
|
+
u?: boolean;
|
|
227
|
+
s?: boolean;
|
|
228
|
+
pt?: number;
|
|
229
|
+
color?: string;
|
|
230
|
+
font?: string;
|
|
231
|
+
};
|
|
232
|
+
pPr?: {
|
|
233
|
+
align?: string;
|
|
234
|
+
spaceBefore?: number;
|
|
235
|
+
spaceAfter?: number;
|
|
236
|
+
lineHeight?: number;
|
|
237
|
+
indentPt?: number;
|
|
238
|
+
indentRightPt?: number;
|
|
239
|
+
firstLineIndentPt?: number;
|
|
240
|
+
};
|
|
241
|
+
basedOn?: string; // parent style id
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type StylesMap = Map<string, TblStyleDef>; // styleId → table style defaults
|
|
245
|
+
type ParaStyleMap = Map<string, ParaStyleDef>; // styleId → para/char style defaults
|
|
246
|
+
|
|
156
247
|
interface DecCtx {
|
|
157
248
|
relsMap: Map<string, string>;
|
|
158
249
|
files: Map<string, Uint8Array>;
|
|
159
250
|
shield: ShieldedParser;
|
|
160
251
|
numMap: NumMap;
|
|
161
252
|
warns: string[];
|
|
253
|
+
stylesMap: StylesMap;
|
|
254
|
+
paraStyleMap: ParaStyleMap;
|
|
162
255
|
}
|
|
163
256
|
|
|
164
257
|
// numId → { abstractNumId, levels: Map<ilvl, { fmt, isOrdered }> }
|
|
@@ -190,8 +283,10 @@ function resolveDocxPath(baseDir: string, target: string): string {
|
|
|
190
283
|
|
|
191
284
|
async function parseRels(xml: string): Promise<Map<string, string>> {
|
|
192
285
|
const map = new Map<string, string>();
|
|
286
|
+
const trimmed = xml.trim();
|
|
287
|
+
if (!trimmed) return map;
|
|
193
288
|
try {
|
|
194
|
-
const obj: any = await XmlKit.parseStrict(
|
|
289
|
+
const obj: any = await XmlKit.parseStrict(trimmed);
|
|
195
290
|
for (const rel of toArr(obj?.Relationships?.[0]?.Relationship)) {
|
|
196
291
|
const a = rel?._attr ?? {};
|
|
197
292
|
if (a.Id && a.Target) map.set(a.Id, a.Target);
|
|
@@ -203,8 +298,10 @@ async function parseRels(xml: string): Promise<Map<string, string>> {
|
|
|
203
298
|
}
|
|
204
299
|
|
|
205
300
|
async function parseCoreProps(xml: string): Promise<DocMeta> {
|
|
301
|
+
const trimmed = xml.trim();
|
|
302
|
+
if (!trimmed) return {};
|
|
206
303
|
try {
|
|
207
|
-
const obj: any = await XmlKit.parseStrict(
|
|
304
|
+
const obj: any = await XmlKit.parseStrict(trimmed);
|
|
208
305
|
const c = obj?.["cp:coreProperties"]?.[0] ?? obj?.coreProperties?.[0] ?? {};
|
|
209
306
|
return {
|
|
210
307
|
title: c?.["dc:title"]?.[0]?._text ?? undefined,
|
|
@@ -220,8 +317,10 @@ async function parseCoreProps(xml: string): Promise<DocMeta> {
|
|
|
220
317
|
|
|
221
318
|
async function parseNumbering(xml: string): Promise<NumMap> {
|
|
222
319
|
const map: NumMap = new Map();
|
|
320
|
+
const trimmed = xml.trim();
|
|
321
|
+
if (!trimmed) return map;
|
|
223
322
|
try {
|
|
224
|
-
const obj: any = await XmlKit.parseStrict(
|
|
323
|
+
const obj: any = await XmlKit.parseStrict(trimmed);
|
|
225
324
|
const root = obj?.["w:numbering"]?.[0] ?? obj?.numbering?.[0] ?? obj;
|
|
226
325
|
|
|
227
326
|
// Parse abstractNums
|
|
@@ -262,11 +361,14 @@ async function parseNumbering(xml: string): Promise<NumMap> {
|
|
|
262
361
|
}
|
|
263
362
|
|
|
264
363
|
function getBody(obj: any): any {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
364
|
+
// XML 파서에 따라 w:document 또는 document 형태일 수 있음
|
|
365
|
+
const doc = obj?.["w:document"]?.[0] ?? obj?.document?.[0] ?? obj;
|
|
366
|
+
const body = doc?.["w:body"]?.[0] ?? doc?.body?.[0] ?? doc;
|
|
367
|
+
|
|
368
|
+
if (!body) {
|
|
369
|
+
console.error("[DocxDecoder] 본문(body)을 찾을 수 없습니다.");
|
|
370
|
+
}
|
|
371
|
+
return body;
|
|
270
372
|
}
|
|
271
373
|
|
|
272
374
|
function extractDims(body: any): PageDims | null {
|
|
@@ -276,6 +378,8 @@ function extractDims(body: any): PageDims | null {
|
|
|
276
378
|
const sz = sp?.["w:pgSz"]?.[0]?._attr ?? sp?.pgSz?.[0]?._attr;
|
|
277
379
|
const mar = sp?.["w:pgMar"]?.[0]?._attr ?? sp?.pgMar?.[0]?._attr;
|
|
278
380
|
if (!sz) return null;
|
|
381
|
+
const headerDxa = Number(mar?.["w:header"] ?? mar?.header ?? 0);
|
|
382
|
+
const footerDxa = Number(mar?.["w:footer"] ?? mar?.footer ?? 0);
|
|
279
383
|
return {
|
|
280
384
|
wPt: Metric.dxaToPt(Number(sz["w:w"] ?? sz.w ?? 11906)),
|
|
281
385
|
hPt: Metric.dxaToPt(Number(sz["w:h"] ?? sz.h ?? 16838)),
|
|
@@ -287,6 +391,8 @@ function extractDims(body: any): PageDims | null {
|
|
|
287
391
|
(sz["w:orient"] ?? sz.orient) === "landscape"
|
|
288
392
|
? "landscape"
|
|
289
393
|
: "portrait",
|
|
394
|
+
headerPt: headerDxa > 0 ? Metric.dxaToPt(headerDxa) : undefined,
|
|
395
|
+
footerPt: footerDxa > 0 ? Metric.dxaToPt(footerDxa) : undefined,
|
|
290
396
|
};
|
|
291
397
|
} catch {
|
|
292
398
|
return null;
|
|
@@ -296,35 +402,36 @@ function extractDims(body: any): PageDims | null {
|
|
|
296
402
|
function getBodyElements(body: any): { type: string; node: any }[] {
|
|
297
403
|
const paras = toArr(body?.["w:p"] ?? body?.p);
|
|
298
404
|
const tables = toArr(body?.["w:tbl"] ?? body?.tbl);
|
|
405
|
+
const sdts = toArr(body?.["w:sdt"] ?? body?.sdt);
|
|
299
406
|
|
|
300
|
-
if (tables.length === 0)
|
|
301
|
-
return paras.map((n: any) => ({ type: "para", node: n }));
|
|
302
|
-
if (paras.length === 0)
|
|
303
|
-
return tables.map((n: any) => ({ type: "table", node: n }));
|
|
304
|
-
|
|
305
|
-
// Use _childOrder from XmlKit to preserve document order
|
|
306
407
|
const childOrder = body?.["_childOrder"] as string[] | undefined;
|
|
307
408
|
if (Array.isArray(childOrder)) {
|
|
308
409
|
const items: { type: string; node: any }[] = [];
|
|
309
410
|
let pi = 0,
|
|
310
|
-
ti = 0
|
|
411
|
+
ti = 0,
|
|
412
|
+
si = 0;
|
|
311
413
|
for (const tag of childOrder) {
|
|
312
414
|
if ((tag === "w:p" || tag === "p") && pi < paras.length) {
|
|
313
415
|
items.push({ type: "para", node: paras[pi++] });
|
|
314
416
|
} else if ((tag === "w:tbl" || tag === "tbl") && ti < tables.length) {
|
|
315
417
|
items.push({ type: "table", node: tables[ti++] });
|
|
418
|
+
} else if ((tag === "w:sdt" || tag === "sdt") && si < sdts.length) {
|
|
419
|
+
items.push({ type: "sdt", node: sdts[si++] });
|
|
316
420
|
}
|
|
317
421
|
}
|
|
422
|
+
// Append any remainders
|
|
318
423
|
while (pi < paras.length) items.push({ type: "para", node: paras[pi++] });
|
|
319
424
|
while (ti < tables.length)
|
|
320
425
|
items.push({ type: "table", node: tables[ti++] });
|
|
426
|
+
while (si < sdts.length) items.push({ type: "sdt", node: sdts[si++] });
|
|
321
427
|
return items;
|
|
322
428
|
}
|
|
323
429
|
|
|
324
|
-
// Fallback: paragraphs
|
|
430
|
+
// Fallback: paragraphs, then tables, then sdts
|
|
325
431
|
return [
|
|
326
432
|
...paras.map((n: any) => ({ type: "para", node: n })),
|
|
327
433
|
...tables.map((n: any) => ({ type: "table", node: n })),
|
|
434
|
+
...sdts.map((n: any) => ({ type: "sdt", node: n })),
|
|
328
435
|
];
|
|
329
436
|
}
|
|
330
437
|
|
|
@@ -333,10 +440,10 @@ function getBodyElements(body: any): { type: string; node: any }[] {
|
|
|
333
440
|
async function decodeHeaderFooter(
|
|
334
441
|
kind: "header" | "footer",
|
|
335
442
|
body: any,
|
|
336
|
-
relsMap: Map<string, string>,
|
|
443
|
+
relsMap: Map<string, string>, // document.xml.rels (기존)
|
|
337
444
|
files: Map<string, Uint8Array>,
|
|
338
445
|
ctx: DecCtx,
|
|
339
|
-
): Promise<ParaNode[] | undefined> {
|
|
446
|
+
): Promise<Record<string, ParaNode[]> | undefined> {
|
|
340
447
|
try {
|
|
341
448
|
const sp = body?.["w:sectPr"]?.[0] ?? body?.sectPr?.[0];
|
|
342
449
|
if (!sp) return undefined;
|
|
@@ -346,35 +453,79 @@ async function decodeHeaderFooter(
|
|
|
346
453
|
const refs = toArr(sp?.[refTag] ?? sp?.[refTag.replace("w:", "")]);
|
|
347
454
|
if (refs.length === 0) return undefined;
|
|
348
455
|
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
456
|
+
const result: Record<string, ParaNode[]> = {};
|
|
457
|
+
|
|
458
|
+
for (const ref of refs) {
|
|
459
|
+
const type = ref._attr?.["w:type"] ?? ref._attr?.type ?? "default";
|
|
460
|
+
const rId = ref._attr?.["r:id"] ?? ref._attr?.["r:Id"] ?? ref._attr?.id;
|
|
461
|
+
if (!rId) continue;
|
|
462
|
+
|
|
463
|
+
const target = relsMap.get(rId);
|
|
464
|
+
if (!target) continue;
|
|
465
|
+
|
|
466
|
+
const filePath = resolveDocxPath("word", target);
|
|
467
|
+
const fileData = files.get(filePath);
|
|
468
|
+
if (!fileData) continue;
|
|
469
|
+
|
|
470
|
+
// ★ 핵심 수정: 헤더/풋터 전용 rels 파일 로드
|
|
471
|
+
const hfFileName = filePath.split("/").pop() ?? "";
|
|
472
|
+
const hfRelsPath = `word/_rels/${hfFileName}.rels`;
|
|
473
|
+
const hfRelsData = files.get(hfRelsPath);
|
|
474
|
+
// 헤더/풋터 rels를 document rels와 병합
|
|
475
|
+
let hfRelsMap = relsMap;
|
|
476
|
+
if (hfRelsData) {
|
|
477
|
+
const hfRelsStr = TextKit.decode(hfRelsData).trim();
|
|
478
|
+
const parsed = hfRelsStr
|
|
479
|
+
? await parseRels(hfRelsStr)
|
|
480
|
+
: new Map<string, string>();
|
|
481
|
+
// 병합 (헤더/풋터 rels 우선)
|
|
482
|
+
hfRelsMap = new Map([...relsMap, ...parsed]);
|
|
483
|
+
}
|
|
361
484
|
|
|
362
|
-
|
|
363
|
-
|
|
485
|
+
const xmlStr = TextKit.decode(fileData).trim();
|
|
486
|
+
if (!xmlStr) continue;
|
|
364
487
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
488
|
+
const watermark = extractWatermark(xmlStr);
|
|
489
|
+
if (watermark) {
|
|
490
|
+
result[type] = [
|
|
491
|
+
buildPara([
|
|
492
|
+
buildSpan(watermark, { pt: 80, color: "CCCCCC", b: true }),
|
|
493
|
+
]),
|
|
494
|
+
];
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
368
497
|
|
|
369
|
-
|
|
370
|
-
|
|
498
|
+
try {
|
|
499
|
+
const obj: any = await XmlKit.parseStrict(xmlStr);
|
|
500
|
+
const rootTag = kind === "header" ? "w:hdr" : "w:ftr";
|
|
501
|
+
const root =
|
|
502
|
+
obj?.[rootTag]?.[0] ?? obj?.[rootTag.replace("w:", "")]?.[0] ?? obj;
|
|
503
|
+
|
|
504
|
+
// ctx에 hfRelsMap 임시 적용
|
|
505
|
+
const origRelsMap = ctx.relsMap;
|
|
506
|
+
(ctx as any).relsMap = hfRelsMap;
|
|
507
|
+
const paras = toArr(root?.["w:p"] ?? root?.p);
|
|
508
|
+
result[type] = paras.map((p: any) => decodePara(p, ctx));
|
|
509
|
+
(ctx as any).relsMap = origRelsMap;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
console.warn(`[DocxDecoder] ${kind} (${type}) XML 파싱 실패:`, err);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
371
515
|
|
|
372
|
-
return
|
|
516
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
373
517
|
} catch {
|
|
374
518
|
return undefined;
|
|
375
519
|
}
|
|
376
520
|
}
|
|
377
521
|
|
|
522
|
+
/** 워터마크 텍스트 추출 (VML v:textpath 기반) */
|
|
523
|
+
function extractWatermark(xml: string): string | null {
|
|
524
|
+
if (!xml.includes("v:textpath")) return null;
|
|
525
|
+
const m = xml.match(/string="([^"]+)"/);
|
|
526
|
+
return m ? m[1] : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
378
529
|
// ─── Element decoding ──────────────────────────────────────
|
|
379
530
|
|
|
380
531
|
//만약에 drawing 태그가 안에 있으면 true 반환
|
|
@@ -392,7 +543,7 @@ function hasDrawingDeep(node: any): boolean {
|
|
|
392
543
|
function decodeElement(
|
|
393
544
|
el: { type: string; node: any },
|
|
394
545
|
ctx: DecCtx,
|
|
395
|
-
): ContentNode {
|
|
546
|
+
): ContentNode | ContentNode[] {
|
|
396
547
|
if (el.type === "table") {
|
|
397
548
|
const { value } = ctx.shield.guardGrid(
|
|
398
549
|
el.node,
|
|
@@ -403,10 +554,25 @@ function decodeElement(
|
|
|
403
554
|
"docx:table",
|
|
404
555
|
);
|
|
405
556
|
return value;
|
|
557
|
+
} else if (el.type === "sdt") {
|
|
558
|
+
return decodeSdt(el.node, ctx);
|
|
406
559
|
}
|
|
407
560
|
return decodePara(el.node, ctx);
|
|
408
561
|
}
|
|
409
562
|
|
|
563
|
+
function decodeSdt(sdt: any, ctx: DecCtx): ContentNode[] {
|
|
564
|
+
const content = sdt?.["w:sdtContent"]?.[0] ?? sdt?.sdtContent?.[0];
|
|
565
|
+
if (!content) return [];
|
|
566
|
+
const elements = getBodyElements(content);
|
|
567
|
+
const kids: ContentNode[] = [];
|
|
568
|
+
for (const el of elements) {
|
|
569
|
+
const res = decodeElement(el, ctx);
|
|
570
|
+
if (Array.isArray(res)) kids.push(...res);
|
|
571
|
+
else kids.push(res);
|
|
572
|
+
}
|
|
573
|
+
return kids;
|
|
574
|
+
}
|
|
575
|
+
|
|
410
576
|
function decodePara(p: any, ctx: DecCtx): ParaNode {
|
|
411
577
|
const pPr = p?.["w:pPr"]?.[0] ?? {};
|
|
412
578
|
const alignVal =
|
|
@@ -416,12 +582,19 @@ function decodePara(p: any, ctx: DecCtx): ParaNode {
|
|
|
416
582
|
pPr?.["w:pStyle"]?.[0]?._attr?.val ??
|
|
417
583
|
"";
|
|
418
584
|
|
|
585
|
+
// Resolve paragraph style inheritance chain
|
|
586
|
+
const styleInherited = resolveParaStyle(
|
|
587
|
+
headStyle || undefined,
|
|
588
|
+
ctx.paraStyleMap,
|
|
589
|
+
);
|
|
590
|
+
|
|
419
591
|
const props: ParaProps = {
|
|
420
592
|
align: safeAlign(alignVal),
|
|
421
593
|
heading: parseHeading(headStyle),
|
|
594
|
+
styleId: headStyle || undefined,
|
|
422
595
|
};
|
|
423
596
|
|
|
424
|
-
// Spacing (before/after/line height)
|
|
597
|
+
// Spacing (before/after/line height) — inline pPr wins over style
|
|
425
598
|
const spacingAttr =
|
|
426
599
|
pPr?.["w:spacing"]?.[0]?._attr ?? pPr?.spacing?.[0]?._attr ?? {};
|
|
427
600
|
const beforeVal = Number(
|
|
@@ -432,13 +605,38 @@ function decodePara(p: any, ctx: DecCtx): ParaNode {
|
|
|
432
605
|
const lineRule =
|
|
433
606
|
spacingAttr?.["w:lineRule"] ?? spacingAttr?.lineRule ?? "auto";
|
|
434
607
|
if (beforeVal > 0) props.spaceBefore = Metric.dxaToPt(beforeVal);
|
|
608
|
+
else if (styleInherited.pPr?.spaceBefore)
|
|
609
|
+
props.spaceBefore = styleInherited.pPr.spaceBefore;
|
|
435
610
|
if (afterVal > 0) props.spaceAfter = Metric.dxaToPt(afterVal);
|
|
611
|
+
else if (styleInherited.pPr?.spaceAfter)
|
|
612
|
+
props.spaceAfter = styleInherited.pPr.spaceAfter;
|
|
436
613
|
if (lineVal > 0 && lineRule === "auto") props.lineHeight = lineVal / 240;
|
|
614
|
+
else if (styleInherited.pPr?.lineHeight)
|
|
615
|
+
props.lineHeight = styleInherited.pPr.lineHeight;
|
|
437
616
|
|
|
438
617
|
// Indentation
|
|
439
618
|
const indAttr = pPr?.["w:ind"]?.[0]?._attr ?? pPr?.ind?.[0]?._attr ?? {};
|
|
440
619
|
const leftVal = Number(indAttr?.["w:left"] ?? indAttr?.left ?? 0);
|
|
620
|
+
const rightVal = Number(indAttr?.["w:right"] ?? indAttr?.right ?? 0);
|
|
621
|
+
const firstLineVal = Number(
|
|
622
|
+
indAttr?.["w:firstLine"] ?? indAttr?.firstLine ?? 0,
|
|
623
|
+
);
|
|
624
|
+
const hangingVal = Number(indAttr?.["w:hanging"] ?? indAttr?.hanging ?? 0);
|
|
441
625
|
if (leftVal > 0) props.indentPt = Metric.dxaToPt(leftVal);
|
|
626
|
+
else if (styleInherited.pPr?.indentPt)
|
|
627
|
+
props.indentPt = styleInherited.pPr.indentPt;
|
|
628
|
+
if (rightVal > 0) props.indentRightPt = Metric.dxaToPt(rightVal);
|
|
629
|
+
else if (styleInherited.pPr?.indentRightPt)
|
|
630
|
+
props.indentRightPt = styleInherited.pPr.indentRightPt;
|
|
631
|
+
if (firstLineVal > 0) props.firstLineIndentPt = Metric.dxaToPt(firstLineVal);
|
|
632
|
+
else if (hangingVal > 0)
|
|
633
|
+
props.firstLineIndentPt = -Metric.dxaToPt(hangingVal);
|
|
634
|
+
else if (styleInherited.pPr?.firstLineIndentPt)
|
|
635
|
+
props.firstLineIndentPt = styleInherited.pPr.firstLineIndentPt;
|
|
636
|
+
|
|
637
|
+
// Alignment from style if not set inline
|
|
638
|
+
if (!alignVal && styleInherited.pPr?.align)
|
|
639
|
+
props.align = safeAlign(styleInherited.pPr.align);
|
|
442
640
|
|
|
443
641
|
// List/numbering
|
|
444
642
|
const numPr = pPr?.["w:numPr"]?.[0] ?? pPr?.numPr?.[0];
|
|
@@ -462,26 +660,100 @@ function decodePara(p: any, ctx: DecCtx): ParaNode {
|
|
|
462
660
|
}
|
|
463
661
|
|
|
464
662
|
// pageBreakBefore: paragraph always starts on a new page
|
|
465
|
-
const pbBeforeNode =
|
|
466
|
-
|
|
663
|
+
const pbBeforeNode =
|
|
664
|
+
pPr?.["w:pageBreakBefore"]?.[0] ?? pPr?.pageBreakBefore?.[0];
|
|
665
|
+
const hasPageBreakBefore =
|
|
666
|
+
pbBeforeNode != null &&
|
|
467
667
|
(pbBeforeNode?._attr?.["w:val"] ?? pbBeforeNode?._attr?.val ?? "1") !== "0";
|
|
468
668
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
669
|
+
// Resolve all children (runs AND hyperlinks) in document order
|
|
670
|
+
const children = p?.["_childOrder"] as string[] | undefined;
|
|
671
|
+
const kids: (SpanNode | ImgNode | LinkNode)[] = [];
|
|
672
|
+
|
|
673
|
+
if (Array.isArray(children)) {
|
|
674
|
+
const runsArr = toArr(p?.["w:r"] ?? p?.r);
|
|
675
|
+
const hlArr = toArr(p?.["w:hyperlink"] ?? p?.hyperlink);
|
|
676
|
+
const sdtArr = toArr(p?.["w:sdt"] ?? p?.sdt);
|
|
677
|
+
let ri = 0;
|
|
678
|
+
let hi = 0;
|
|
679
|
+
let si = 0;
|
|
680
|
+
|
|
681
|
+
for (const tag of children) {
|
|
682
|
+
if (tag === "w:r" || tag === "r") {
|
|
683
|
+
const run = runsArr[ri++];
|
|
684
|
+
if (run) {
|
|
685
|
+
kids.push(
|
|
686
|
+
ctx.shield.guard(
|
|
687
|
+
() =>
|
|
688
|
+
hasDrawingDeep(run)
|
|
689
|
+
? decodeRunOrImage(run, ctx)
|
|
690
|
+
: decodeRun(run, ctx, styleInherited.rPr),
|
|
691
|
+
buildSpan(""),
|
|
692
|
+
"docx:run",
|
|
693
|
+
),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
} else if (tag === "w:hyperlink" || tag === "hyperlink") {
|
|
697
|
+
const hl = hlArr[hi++];
|
|
698
|
+
if (hl) {
|
|
699
|
+
const rId = hl?._attr?.["r:id"] ?? hl?._attr?.id;
|
|
700
|
+
const url = rId ? ctx.relsMap.get(rId) : "";
|
|
701
|
+
const hlRuns = toArr(hl?.["w:r"] ?? hl?.r);
|
|
702
|
+
const hlKids = hlRuns.map((r: any) =>
|
|
703
|
+
decodeRun(r, ctx, {
|
|
704
|
+
...styleInherited.rPr,
|
|
705
|
+
u: true,
|
|
706
|
+
color: "0000FF",
|
|
707
|
+
}),
|
|
708
|
+
);
|
|
709
|
+
kids.push({
|
|
710
|
+
tag: "link",
|
|
711
|
+
href: url || "",
|
|
712
|
+
kids: hlKids,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
} else if (tag === "w:sdt" || tag === "sdt") {
|
|
716
|
+
const sdt = sdtArr[si++];
|
|
717
|
+
if (sdt) {
|
|
718
|
+
const sdtContent = sdt?.["w:sdtContent"]?.[0] ?? sdt?.sdtContent?.[0];
|
|
719
|
+
if (sdtContent) {
|
|
720
|
+
const innerRuns = toArr(sdtContent?.["w:r"] ?? sdtContent?.r);
|
|
721
|
+
for (const ir of innerRuns) {
|
|
722
|
+
kids.push(
|
|
723
|
+
ctx.shield.guard(
|
|
724
|
+
() =>
|
|
725
|
+
hasDrawingDeep(ir)
|
|
726
|
+
? decodeRunOrImage(ir, ctx)
|
|
727
|
+
: decodeRun(ir, ctx, styleInherited.rPr),
|
|
728
|
+
buildSpan(""),
|
|
729
|
+
"docx:run",
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
// Fallback if _childOrder is missing
|
|
739
|
+
const runs = toArr(p?.["w:r"] ?? p?.r);
|
|
740
|
+
const legacyKids: (SpanNode | ImgNode)[] = ctx.shield.guardAll(
|
|
741
|
+
runs,
|
|
742
|
+
(run: any) =>
|
|
743
|
+
hasDrawingDeep(run)
|
|
744
|
+
? decodeRunOrImage(run, ctx)
|
|
745
|
+
: decodeRun(run, ctx, styleInherited.rPr),
|
|
746
|
+
() => buildSpan(""),
|
|
747
|
+
"docx:run",
|
|
748
|
+
);
|
|
749
|
+
kids.push(...legacyKids);
|
|
750
|
+
}
|
|
479
751
|
|
|
480
752
|
const filteredKids = kids.filter(Boolean) as ParaNode["kids"];
|
|
481
753
|
|
|
482
754
|
// Prepend pb span when pageBreakBefore is set
|
|
483
755
|
if (hasPageBreakBefore) {
|
|
484
|
-
filteredKids.unshift({ tag:
|
|
756
|
+
filteredKids.unshift({ tag: "span", props: {}, kids: [buildPb()] });
|
|
485
757
|
}
|
|
486
758
|
|
|
487
759
|
return buildPara(filteredKids, props);
|
|
@@ -519,6 +791,33 @@ function decodeRunOrImage(run: any, ctx: DecCtx): SpanNode | ImgNode {
|
|
|
519
791
|
|
|
520
792
|
return decodeRun(run, ctx);
|
|
521
793
|
}
|
|
794
|
+
/** Decode image layout from anchor element */
|
|
795
|
+
function decodeImageLayout(anchor: any): ImgLayout {
|
|
796
|
+
const wrap = anchor?.["wp:wrapTop"]?.[0] ?? anchor?.wrapTop?.[0];
|
|
797
|
+
const anchorPos =
|
|
798
|
+
anchor?.["wp:anchorPos"]?.[0]?._attr ?? anchor?.anchorPos?.[0]?._attr ?? {};
|
|
799
|
+
|
|
800
|
+
const layout: ImgLayout = {
|
|
801
|
+
wrap: "square",
|
|
802
|
+
horzAlign: "left",
|
|
803
|
+
vertAlign: "top",
|
|
804
|
+
horzRelTo: "page",
|
|
805
|
+
vertRelTo: "page",
|
|
806
|
+
xPt: Number(anchorPos?.x ?? 0) / 12700, // emu to pt
|
|
807
|
+
yPt: Number(anchorPos?.y ?? 0) / 12700, // emu to pt
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Parse wrap type
|
|
811
|
+
if (wrap?.["wp:none"]) layout.wrap = "none";
|
|
812
|
+
else if (wrap?.["wp:square"]) layout.wrap = "square";
|
|
813
|
+
else if (wrap?.["wp:tight"]) layout.wrap = "tight";
|
|
814
|
+
else if (wrap?.["wp:through"]) layout.wrap = "through";
|
|
815
|
+
else if (wrap?.["wp:behind"]) layout.wrap = "behind";
|
|
816
|
+
else if (wrap?.["wp:inFront"]) layout.wrap = "front";
|
|
817
|
+
|
|
818
|
+
return layout;
|
|
819
|
+
}
|
|
820
|
+
|
|
522
821
|
function decodeDrawing(drawing: any, ctx: DecCtx): ImgNode | null {
|
|
523
822
|
try {
|
|
524
823
|
const inline = drawing?.["wp:inline"]?.[0] ?? drawing?.inline?.[0];
|
|
@@ -545,6 +844,20 @@ function decodeDrawing(drawing: any, ctx: DecCtx): ImgNode | null {
|
|
|
545
844
|
const graphic = container?.["a:graphic"]?.[0] ?? container?.graphic?.[0];
|
|
546
845
|
const graphicData =
|
|
547
846
|
graphic?.["a:graphicData"]?.[0] ?? graphic?.graphicData?.[0];
|
|
847
|
+
|
|
848
|
+
// 1. 차트 감지
|
|
849
|
+
if (graphicData?.["c:chart"] || graphicData?.chart) {
|
|
850
|
+
return {
|
|
851
|
+
tag: "img",
|
|
852
|
+
b64: "", // 플레이스홀더
|
|
853
|
+
mime: "image/png",
|
|
854
|
+
w: wPt,
|
|
855
|
+
h: hPt,
|
|
856
|
+
alt: `[차트: ${alt || "차트"}]`,
|
|
857
|
+
layout: decodeImageLayout(anchor),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
548
861
|
const pic = graphicData?.["pic:pic"]?.[0] ?? graphicData?.pic?.[0];
|
|
549
862
|
const blipFill = pic?.["pic:blipFill"]?.[0] ?? pic?.blipFill?.[0];
|
|
550
863
|
const blip =
|
|
@@ -556,12 +869,27 @@ function decodeDrawing(drawing: any, ctx: DecCtx): ImgNode | null {
|
|
|
556
869
|
const target = ctx.relsMap.get(rId);
|
|
557
870
|
if (!target) return null;
|
|
558
871
|
|
|
559
|
-
|
|
560
|
-
|
|
872
|
+
let filePath = resolveDocxPath("word", target);
|
|
873
|
+
let fileData = ctx.files.get(filePath);
|
|
874
|
+
|
|
561
875
|
if (!fileData) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
876
|
+
filePath = resolveDocxPath("word/_rels", target);
|
|
877
|
+
fileData = ctx.files.get(filePath);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!fileData) {
|
|
881
|
+
const fileName = target.split("/").pop() ?? "";
|
|
882
|
+
for (const [k, v] of ctx.files) {
|
|
883
|
+
if (fileName && (k.endsWith("/" + fileName) || k === fileName)) {
|
|
884
|
+
fileData = v;
|
|
885
|
+
filePath = k;
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!fileData) {
|
|
892
|
+
console.warn(`[DocxDecoder] image not found: "${target}"`);
|
|
565
893
|
return null;
|
|
566
894
|
}
|
|
567
895
|
|
|
@@ -580,20 +908,63 @@ function decodeDrawing(drawing: any, ctx: DecCtx): ImgNode | null {
|
|
|
580
908
|
|
|
581
909
|
// ── layout 추출 ──────────────────────────────────────────
|
|
582
910
|
const layout: ImgLayout = inline
|
|
583
|
-
? { wrap:
|
|
911
|
+
? { wrap: "inline" }
|
|
584
912
|
: extractAnchorLayout(anchor);
|
|
585
913
|
|
|
586
|
-
return buildImg(
|
|
914
|
+
return buildImg(
|
|
915
|
+
TextKit.base64Encode(fileData),
|
|
916
|
+
mime,
|
|
917
|
+
wPt,
|
|
918
|
+
hPt,
|
|
919
|
+
alt || undefined,
|
|
920
|
+
layout,
|
|
921
|
+
);
|
|
587
922
|
} catch {
|
|
588
923
|
return null;
|
|
589
924
|
}
|
|
590
925
|
}
|
|
591
926
|
|
|
592
|
-
|
|
927
|
+
/** w:highlight val → hex 색상 매핑 (OOXML 명세) */
|
|
928
|
+
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
|
|
929
|
+
yellow: "FFFF00",
|
|
930
|
+
green: "00FF00",
|
|
931
|
+
cyan: "00FFFF",
|
|
932
|
+
magenta: "FF00FF",
|
|
933
|
+
blue: "0000FF",
|
|
934
|
+
red: "FF0000",
|
|
935
|
+
darkBlue: "00008B",
|
|
936
|
+
darkCyan: "008B8B",
|
|
937
|
+
darkGreen: "006400",
|
|
938
|
+
darkMagenta: "8B008B",
|
|
939
|
+
darkRed: "8B0000",
|
|
940
|
+
darkYellow: "808000",
|
|
941
|
+
darkGray: "A9A9A9",
|
|
942
|
+
lightGray: "D3D3D3",
|
|
943
|
+
black: "000000",
|
|
944
|
+
white: "FFFFFF",
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
function decodeRun(
|
|
948
|
+
run: any,
|
|
949
|
+
ctx: DecCtx,
|
|
950
|
+
styleRpr?: ParaStyleDef["rPr"],
|
|
951
|
+
): SpanNode {
|
|
593
952
|
const rPr = run?.["w:rPr"]?.[0] ?? run?.rPr?.[0] ?? {};
|
|
594
953
|
|
|
954
|
+
// w:vanish — 숨긴 텍스트: run 전체 건너뜀 (빈 span 반환)
|
|
955
|
+
const vanishNode = rPr?.["w:vanish"]?.[0] ?? rPr?.vanish?.[0];
|
|
956
|
+
if (vanishNode != null) {
|
|
957
|
+
const vanishVal =
|
|
958
|
+
vanishNode?._attr?.["w:val"] ?? vanishNode?._attr?.val ?? "1";
|
|
959
|
+
if (vanishVal !== "0") return buildSpan("");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// w:sz → 없으면 w:szCs 로 fallback (한글 글꼴 크기)
|
|
595
963
|
const szAttr = rPr?.["w:sz"]?.[0]?._attr ?? rPr?.sz?.[0]?._attr ?? {};
|
|
596
964
|
const szVal = szAttr?.["w:val"] ?? szAttr?.val;
|
|
965
|
+
const szCsAttr = rPr?.["w:szCs"]?.[0]?._attr ?? rPr?.szCs?.[0]?._attr ?? {};
|
|
966
|
+
const szCsVal = szCsAttr?.["w:val"] ?? szCsAttr?.val;
|
|
967
|
+
const effectiveSzVal = szVal ?? szCsVal;
|
|
597
968
|
|
|
598
969
|
const colorAttr =
|
|
599
970
|
rPr?.["w:color"]?.[0]?._attr ?? rPr?.color?.[0]?._attr ?? {};
|
|
@@ -612,15 +983,33 @@ function decodeRun(run: any, ctx: DecCtx): SpanNode {
|
|
|
612
983
|
const underVal =
|
|
613
984
|
rPr?.["w:u"]?.[0]?._attr?.["w:val"] ?? rPr?.["w:u"]?.[0]?._attr?.val;
|
|
614
985
|
|
|
615
|
-
//
|
|
986
|
+
// w:shd — 배경색 (낮은 우선순위)
|
|
616
987
|
const shdAttr = rPr?.["w:shd"]?.[0]?._attr ?? rPr?.shd?.[0]?._attr ?? {};
|
|
617
|
-
const
|
|
988
|
+
const shdBg = safeHex(shdAttr?.["w:fill"] ?? shdAttr?.fill);
|
|
618
989
|
|
|
619
|
-
//
|
|
990
|
+
// w:highlight — 형광펜 색상 (w:shd보다 우선)
|
|
991
|
+
const hlAttr =
|
|
992
|
+
rPr?.["w:highlight"]?.[0]?._attr ?? rPr?.highlight?.[0]?._attr ?? {};
|
|
993
|
+
const hlVal = hlAttr?.["w:val"] ?? hlAttr?.val;
|
|
994
|
+
const bgVal = (hlVal ? HIGHLIGHT_COLOR_MAP[hlVal] : undefined) ?? shdBg;
|
|
995
|
+
|
|
996
|
+
// w:vertAlign — superscript / subscript
|
|
620
997
|
const vertAlignVal =
|
|
621
998
|
rPr?.["w:vertAlign"]?.[0]?._attr?.["w:val"] ??
|
|
622
999
|
rPr?.["w:vertAlign"]?.[0]?._attr?.val;
|
|
623
1000
|
|
|
1001
|
+
// w:position — 글자 상하 이동 (half-point, 양수=위, 음수=아래)
|
|
1002
|
+
// vertAlign이 없을 때 보조 판단: ±4 half-pt(≈2pt) 이상이면 sup/sub
|
|
1003
|
+
const posAttr =
|
|
1004
|
+
rPr?.["w:position"]?.[0]?._attr ?? rPr?.position?.[0]?._attr ?? {};
|
|
1005
|
+
const posVal = Number(posAttr?.["w:val"] ?? posAttr?.val ?? 0);
|
|
1006
|
+
let isSup = vertAlignVal === "superscript";
|
|
1007
|
+
let isSub = vertAlignVal === "subscript";
|
|
1008
|
+
if (!isSup && !isSub && posVal !== 0) {
|
|
1009
|
+
if (posVal >= 4) isSup = true;
|
|
1010
|
+
else if (posVal <= -4) isSub = true;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
624
1013
|
// Check bold/italic/strike — val="0" means explicitly OFF
|
|
625
1014
|
const bNode = rPr?.["w:b"]?.[0] ?? rPr?.b?.[0];
|
|
626
1015
|
const isBold =
|
|
@@ -635,16 +1024,19 @@ function decodeRun(run: any, ctx: DecCtx): SpanNode {
|
|
|
635
1024
|
sNode != null &&
|
|
636
1025
|
(sNode?._attr?.["w:val"] ?? sNode?._attr?.val ?? "1") !== "0";
|
|
637
1026
|
|
|
1027
|
+
// Run-level properties: run wins, then fall back to paragraph style inheritance
|
|
638
1028
|
const props: TextProps = {
|
|
639
|
-
b: isBold || undefined,
|
|
640
|
-
i: isItalic || undefined,
|
|
641
|
-
u: underVal
|
|
642
|
-
s: isStrike || undefined,
|
|
643
|
-
sup:
|
|
644
|
-
sub:
|
|
645
|
-
pt:
|
|
646
|
-
|
|
647
|
-
|
|
1029
|
+
b: (bNode != null ? isBold : styleRpr?.b) || undefined,
|
|
1030
|
+
i: (iNode != null ? isItalic : styleRpr?.i) || undefined,
|
|
1031
|
+
u: (underVal ? underVal !== "none" : styleRpr?.u) || undefined,
|
|
1032
|
+
s: (sNode != null ? isStrike : styleRpr?.s) || undefined,
|
|
1033
|
+
sup: isSup || undefined,
|
|
1034
|
+
sub: isSub || undefined,
|
|
1035
|
+
pt: effectiveSzVal
|
|
1036
|
+
? Metric.halfPtToPt(Number(effectiveSzVal))
|
|
1037
|
+
: styleRpr?.pt,
|
|
1038
|
+
color: safeHex(colorVal) ?? styleRpr?.color,
|
|
1039
|
+
font: fontName ? safeFont(fontName) : styleRpr?.font,
|
|
648
1040
|
bg: bgVal,
|
|
649
1041
|
};
|
|
650
1042
|
|
|
@@ -679,6 +1071,235 @@ function decodeRun(run: any, ctx: DecCtx): SpanNode {
|
|
|
679
1071
|
return buildSpan(content, props);
|
|
680
1072
|
}
|
|
681
1073
|
|
|
1074
|
+
/** Parse all 6 border sides from a w:tblBorders or w:tcBorders node */
|
|
1075
|
+
function parseBorderDef(bdrNode: any): TblBorderDef {
|
|
1076
|
+
const sides: [string, keyof TblBorderDef][] = [
|
|
1077
|
+
["top", "top"],
|
|
1078
|
+
["bottom", "bottom"],
|
|
1079
|
+
["left", "left"],
|
|
1080
|
+
["right", "right"],
|
|
1081
|
+
["insideH", "insideH"],
|
|
1082
|
+
["insideV", "insideV"],
|
|
1083
|
+
];
|
|
1084
|
+
const result: TblBorderDef = {};
|
|
1085
|
+
for (const [xml, prop] of sides) {
|
|
1086
|
+
const bdr = bdrNode?.["w:" + xml]?.[0]?._attr ?? bdrNode?.[xml]?.[0]?._attr;
|
|
1087
|
+
if (!bdr) continue;
|
|
1088
|
+
const val = bdr?.["w:val"] ?? bdr?.val;
|
|
1089
|
+
if (val === "none" || val === "nil") continue; // explicit none → skip (no border)
|
|
1090
|
+
result[prop] = safeStrokeDocx(
|
|
1091
|
+
val,
|
|
1092
|
+
Number(bdr?.["w:sz"] ?? bdr?.sz ?? 4),
|
|
1093
|
+
bdr?.["w:color"] ?? bdr?.color,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Parse styles.xml and build a map of tblStyle defaults */
|
|
1100
|
+
async function parseStylesMap(xml: string): Promise<StylesMap> {
|
|
1101
|
+
const map: StylesMap = new Map();
|
|
1102
|
+
const trimmed = xml.trim();
|
|
1103
|
+
if (!trimmed) return map;
|
|
1104
|
+
try {
|
|
1105
|
+
const obj: any = await XmlKit.parseStrict(trimmed);
|
|
1106
|
+
const stylesRoot = obj?.["w:styles"]?.[0] ?? obj?.styles?.[0] ?? obj;
|
|
1107
|
+
const styleArr = toArr(stylesRoot?.["w:style"] ?? stylesRoot?.style);
|
|
1108
|
+
for (const style of styleArr) {
|
|
1109
|
+
const attr = style?._attr ?? {};
|
|
1110
|
+
const type = attr?.["w:type"] ?? attr?.type;
|
|
1111
|
+
if (type !== "table") continue;
|
|
1112
|
+
const id = attr?.["w:styleId"] ?? attr?.styleId;
|
|
1113
|
+
if (!id) continue;
|
|
1114
|
+
const tblPr = style?.["w:tblPr"]?.[0] ?? style?.tblPr?.[0];
|
|
1115
|
+
const tblBdrNode = tblPr?.["w:tblBorders"]?.[0] ?? tblPr?.tblBorders?.[0];
|
|
1116
|
+
const tblBorders = tblBdrNode ? parseBorderDef(tblBdrNode) : undefined;
|
|
1117
|
+
// tcStyle > tcBdr for default cell borders
|
|
1118
|
+
const tcStyle = style?.["w:tcStyle"]?.[0] ?? style?.tcStyle?.[0];
|
|
1119
|
+
const tcBdrNode = tcStyle?.["w:tcBdr"]?.[0] ?? tcStyle?.tcBdr?.[0];
|
|
1120
|
+
if (tcBdrNode) {
|
|
1121
|
+
const cellDef = parseBorderDef(tcBdrNode);
|
|
1122
|
+
// merge into tblBorders as inner/outer defaults
|
|
1123
|
+
if (!tblBorders) {
|
|
1124
|
+
map.set(id, { tblBorders: cellDef });
|
|
1125
|
+
} else {
|
|
1126
|
+
map.set(id, { tblBorders: { ...cellDef, ...tblBorders } });
|
|
1127
|
+
}
|
|
1128
|
+
} else if (tblBorders) {
|
|
1129
|
+
map.set(id, { tblBorders });
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
} catch {
|
|
1133
|
+
/* non-fatal */
|
|
1134
|
+
}
|
|
1135
|
+
return map;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Parse styles.xml and build a map of paragraph/character style defaults */
|
|
1139
|
+
async function parseParaStyleMap(xml: string): Promise<ParaStyleMap> {
|
|
1140
|
+
const map: ParaStyleMap = new Map();
|
|
1141
|
+
const trimmed = xml.trim();
|
|
1142
|
+
if (!trimmed) return map;
|
|
1143
|
+
try {
|
|
1144
|
+
const obj: any = await XmlKit.parseStrict(trimmed);
|
|
1145
|
+
const stylesRoot = obj?.["w:styles"]?.[0] ?? obj?.styles?.[0] ?? obj;
|
|
1146
|
+
const styleArr = toArr(stylesRoot?.["w:style"] ?? stylesRoot?.style);
|
|
1147
|
+
for (const style of styleArr) {
|
|
1148
|
+
const attr = style?._attr ?? {};
|
|
1149
|
+
const type = attr?.["w:type"] ?? attr?.type;
|
|
1150
|
+
if (type !== "paragraph" && type !== "character") continue;
|
|
1151
|
+
const id = attr?.["w:styleId"] ?? attr?.styleId;
|
|
1152
|
+
if (!id) continue;
|
|
1153
|
+
const basedOn = (style?.["w:basedOn"]?.[0]?._attr ??
|
|
1154
|
+
style?.basedOn?.[0]?._attr)?.["w:val"];
|
|
1155
|
+
|
|
1156
|
+
const def: ParaStyleDef = { basedOn };
|
|
1157
|
+
|
|
1158
|
+
// rPr from run properties
|
|
1159
|
+
const rPr = style?.["w:rPr"]?.[0] ?? style?.rPr?.[0];
|
|
1160
|
+
if (rPr) {
|
|
1161
|
+
const szAttr = rPr?.["w:sz"]?.[0]?._attr ?? rPr?.sz?.[0]?._attr ?? {};
|
|
1162
|
+
const szVal = szAttr?.["w:val"] ?? szAttr?.val;
|
|
1163
|
+
const colorAttr =
|
|
1164
|
+
rPr?.["w:color"]?.[0]?._attr ?? rPr?.color?.[0]?._attr ?? {};
|
|
1165
|
+
const colorVal = colorAttr?.["w:val"] ?? colorAttr?.val;
|
|
1166
|
+
const fontAttr =
|
|
1167
|
+
rPr?.["w:rFonts"]?.[0]?._attr ?? rPr?.rFonts?.[0]?._attr ?? {};
|
|
1168
|
+
const fontName =
|
|
1169
|
+
fontAttr?.["w:ascii"] ??
|
|
1170
|
+
fontAttr?.ascii ??
|
|
1171
|
+
fontAttr?.["w:eastAsia"] ??
|
|
1172
|
+
fontAttr?.eastAsia;
|
|
1173
|
+
const bNode = rPr?.["w:b"]?.[0] ?? rPr?.b?.[0];
|
|
1174
|
+
const isBold =
|
|
1175
|
+
bNode != null &&
|
|
1176
|
+
(bNode?._attr?.["w:val"] ?? bNode?._attr?.val ?? "1") !== "0";
|
|
1177
|
+
const iNode = rPr?.["w:i"]?.[0] ?? rPr?.i?.[0];
|
|
1178
|
+
const isItalic =
|
|
1179
|
+
iNode != null &&
|
|
1180
|
+
(iNode?._attr?.["w:val"] ?? iNode?._attr?.val ?? "1") !== "0";
|
|
1181
|
+
const underVal =
|
|
1182
|
+
rPr?.["w:u"]?.[0]?._attr?.["w:val"] ?? rPr?.["w:u"]?.[0]?._attr?.val;
|
|
1183
|
+
const sNode = rPr?.["w:strike"]?.[0] ?? rPr?.strike?.[0];
|
|
1184
|
+
const isStrike =
|
|
1185
|
+
sNode != null &&
|
|
1186
|
+
(sNode?._attr?.["w:val"] ?? sNode?._attr?.val ?? "1") !== "0";
|
|
1187
|
+
def.rPr = {
|
|
1188
|
+
b: isBold || undefined,
|
|
1189
|
+
i: isItalic || undefined,
|
|
1190
|
+
u: underVal && underVal !== "none" ? true : undefined,
|
|
1191
|
+
s: isStrike || undefined,
|
|
1192
|
+
pt: szVal ? Metric.halfPtToPt(Number(szVal)) : undefined,
|
|
1193
|
+
color: safeHex(colorVal),
|
|
1194
|
+
font: fontName ? safeFont(fontName) : undefined,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// pPr from paragraph properties
|
|
1199
|
+
const pPr = style?.["w:pPr"]?.[0] ?? style?.pPr?.[0];
|
|
1200
|
+
if (pPr) {
|
|
1201
|
+
const spacingAttr =
|
|
1202
|
+
pPr?.["w:spacing"]?.[0]?._attr ?? pPr?.spacing?.[0]?._attr ?? {};
|
|
1203
|
+
const beforeVal = Number(
|
|
1204
|
+
spacingAttr?.["w:before"] ?? spacingAttr?.before ?? 0,
|
|
1205
|
+
);
|
|
1206
|
+
const afterVal = Number(
|
|
1207
|
+
spacingAttr?.["w:after"] ?? spacingAttr?.after ?? 0,
|
|
1208
|
+
);
|
|
1209
|
+
const lineVal = Number(
|
|
1210
|
+
spacingAttr?.["w:line"] ?? spacingAttr?.line ?? 0,
|
|
1211
|
+
);
|
|
1212
|
+
const lineRule =
|
|
1213
|
+
spacingAttr?.["w:lineRule"] ?? spacingAttr?.lineRule ?? "auto";
|
|
1214
|
+
const indAttr =
|
|
1215
|
+
pPr?.["w:ind"]?.[0]?._attr ?? pPr?.ind?.[0]?._attr ?? {};
|
|
1216
|
+
const leftVal = Number(indAttr?.["w:left"] ?? indAttr?.left ?? 0);
|
|
1217
|
+
const rightVal = Number(indAttr?.["w:right"] ?? indAttr?.right ?? 0);
|
|
1218
|
+
const firstLineVal = Number(
|
|
1219
|
+
indAttr?.["w:firstLine"] ?? indAttr?.firstLine ?? 0,
|
|
1220
|
+
);
|
|
1221
|
+
const hangingVal = Number(
|
|
1222
|
+
indAttr?.["w:hanging"] ?? indAttr?.hanging ?? 0,
|
|
1223
|
+
);
|
|
1224
|
+
const alignVal =
|
|
1225
|
+
pPr?.["w:jc"]?.[0]?._attr?.["w:val"] ??
|
|
1226
|
+
pPr?.["w:jc"]?.[0]?._attr?.val;
|
|
1227
|
+
def.pPr = {
|
|
1228
|
+
align: alignVal,
|
|
1229
|
+
spaceBefore: beforeVal > 0 ? Metric.dxaToPt(beforeVal) : undefined,
|
|
1230
|
+
spaceAfter: afterVal > 0 ? Metric.dxaToPt(afterVal) : undefined,
|
|
1231
|
+
lineHeight:
|
|
1232
|
+
lineVal > 0 && lineRule === "auto" ? lineVal / 240 : undefined,
|
|
1233
|
+
indentPt: leftVal > 0 ? Metric.dxaToPt(leftVal) : undefined,
|
|
1234
|
+
indentRightPt: rightVal > 0 ? Metric.dxaToPt(rightVal) : undefined,
|
|
1235
|
+
firstLineIndentPt:
|
|
1236
|
+
firstLineVal > 0
|
|
1237
|
+
? Metric.dxaToPt(firstLineVal)
|
|
1238
|
+
: hangingVal > 0
|
|
1239
|
+
? -Metric.dxaToPt(hangingVal)
|
|
1240
|
+
: undefined,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
map.set(id, def);
|
|
1245
|
+
}
|
|
1246
|
+
} catch {
|
|
1247
|
+
/* non-fatal */
|
|
1248
|
+
}
|
|
1249
|
+
return map;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Resolve paragraph style inheritance chain (max depth 8) */
|
|
1253
|
+
function resolveParaStyle(
|
|
1254
|
+
styleId: string | undefined,
|
|
1255
|
+
map: ParaStyleMap,
|
|
1256
|
+
): ParaStyleDef {
|
|
1257
|
+
let merged: ParaStyleDef = {};
|
|
1258
|
+
const visited = new Set<string>();
|
|
1259
|
+
let cur = styleId;
|
|
1260
|
+
while (cur && !visited.has(cur)) {
|
|
1261
|
+
visited.add(cur);
|
|
1262
|
+
const def = map.get(cur);
|
|
1263
|
+
if (!def) break;
|
|
1264
|
+
// Merge: child values win over parent
|
|
1265
|
+
if (def.rPr) {
|
|
1266
|
+
merged.rPr = { ...def.rPr, ...merged.rPr };
|
|
1267
|
+
}
|
|
1268
|
+
if (def.pPr) {
|
|
1269
|
+
merged.pPr = { ...def.pPr, ...merged.pPr };
|
|
1270
|
+
}
|
|
1271
|
+
cur = def.basedOn;
|
|
1272
|
+
}
|
|
1273
|
+
return merged;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Resolve final CellProps borders using 3-level priority chain */
|
|
1277
|
+
function resolveCellBorders(
|
|
1278
|
+
cp: CellProps,
|
|
1279
|
+
ri: number,
|
|
1280
|
+
ci: number,
|
|
1281
|
+
rs: number,
|
|
1282
|
+
cs: number,
|
|
1283
|
+
rowCount: number,
|
|
1284
|
+
colCount: number,
|
|
1285
|
+
tblBdr: TblBorderDef,
|
|
1286
|
+
): CellProps {
|
|
1287
|
+
const isTopEdge = ri === 0;
|
|
1288
|
+
const isBottomEdge = ri + rs >= rowCount;
|
|
1289
|
+
const isLeftEdge = ci === 0;
|
|
1290
|
+
const isRightEdge = ci + cs >= colCount;
|
|
1291
|
+
|
|
1292
|
+
// Apply tblBorders only where no explicit tcBorder was set
|
|
1293
|
+
const resolved: CellProps = { ...cp };
|
|
1294
|
+
if (!resolved.top) resolved.top = isTopEdge ? tblBdr.top : tblBdr.insideH;
|
|
1295
|
+
if (!resolved.bot)
|
|
1296
|
+
resolved.bot = isBottomEdge ? tblBdr.bottom : tblBdr.insideH;
|
|
1297
|
+
if (!resolved.left) resolved.left = isLeftEdge ? tblBdr.left : tblBdr.insideV;
|
|
1298
|
+
if (!resolved.right)
|
|
1299
|
+
resolved.right = isRightEdge ? tblBdr.right : tblBdr.insideV;
|
|
1300
|
+
return resolved;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
682
1303
|
function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
683
1304
|
// Parse tblPr for table styles
|
|
684
1305
|
const tblPr = tbl?.["w:tblPr"]?.[0] ?? tbl?.tblPr?.[0] ?? {};
|
|
@@ -700,21 +1321,21 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
700
1321
|
bandedCols: tblLookAttr?.["w:noVBand"] === "0" || undefined,
|
|
701
1322
|
};
|
|
702
1323
|
|
|
703
|
-
//
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
);
|
|
715
|
-
}
|
|
1324
|
+
// ① tblStyle 기본값 로드
|
|
1325
|
+
const tblStyleId = (tblPr?.["w:tblStyle"]?.[0]?._attr ??
|
|
1326
|
+
tblPr?.tblStyle?.[0]?._attr)?.["w:val"];
|
|
1327
|
+
const styleDef = tblStyleId ? ctx.stylesMap.get(tblStyleId) : undefined;
|
|
1328
|
+
let tblBdr: TblBorderDef = styleDef?.tblBorders ?? {};
|
|
1329
|
+
|
|
1330
|
+
// ② tblBorders 재정의 (tblStyle보다 우선)
|
|
1331
|
+
const tblBordersNode = tblPr?.["w:tblBorders"]?.[0] ?? tblPr?.tblBorders?.[0];
|
|
1332
|
+
if (tblBordersNode) {
|
|
1333
|
+
const parsed = parseBorderDef(tblBordersNode);
|
|
1334
|
+
tblBdr = { ...tblBdr, ...parsed };
|
|
716
1335
|
}
|
|
717
1336
|
|
|
1337
|
+
// defaultStroke for HWPX/HWP encoders: use insideH (inner horizontal border)
|
|
1338
|
+
const defaultStroke = tblBdr.insideH ?? tblBdr.top;
|
|
718
1339
|
const gridProps: GridProps = { look, defaultStroke };
|
|
719
1340
|
|
|
720
1341
|
// Read column widths from w:tblGrid
|
|
@@ -790,6 +1411,15 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
790
1411
|
trPr?.["w:tblHeader"]?.[0] != null || trPr?.tblHeader?.[0] != null;
|
|
791
1412
|
if (ri === 0 && isHeaderRow) gridProps.headerRow = true;
|
|
792
1413
|
|
|
1414
|
+
// Row height from w:trHeight
|
|
1415
|
+
let rowHeightPt: number | undefined;
|
|
1416
|
+
const trHAttr =
|
|
1417
|
+
trPr?.["w:trHeight"]?.[0]?._attr ?? trPr?.trHeight?.[0]?._attr;
|
|
1418
|
+
if (trHAttr) {
|
|
1419
|
+
const hDxa = Number(trHAttr?.["w:val"] ?? trHAttr?.val ?? 0);
|
|
1420
|
+
if (hDxa > 0) rowHeightPt = Metric.dxaToPt(hDxa);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
793
1423
|
const cellNodes: CellNode[] = [];
|
|
794
1424
|
for (let ci = 0; ci < rawRow.length; ci++) {
|
|
795
1425
|
const rc = rawRow[ci];
|
|
@@ -803,11 +1433,11 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
803
1433
|
const bgAttr = tcPr?.["w:shd"]?.[0]?._attr ?? {};
|
|
804
1434
|
const bg = safeHex(bgAttr?.["w:fill"] ?? bgAttr?.fill);
|
|
805
1435
|
|
|
806
|
-
//
|
|
807
|
-
const
|
|
1436
|
+
// ③ tcBorders 셀 수준 재정의 (우선순위 가장 높음)
|
|
1437
|
+
const tcBordersNode = tcPr?.["w:tcBorders"]?.[0] ?? tcPr?.tcBorders?.[0];
|
|
808
1438
|
const cp: CellProps = { bg, isHeader: isHeaderRow || undefined };
|
|
809
1439
|
|
|
810
|
-
if (
|
|
1440
|
+
if (tcBordersNode) {
|
|
811
1441
|
const dirs: Array<[string, "top" | "bot" | "left" | "right"]> = [
|
|
812
1442
|
["top", "top"],
|
|
813
1443
|
["bottom", "bot"],
|
|
@@ -816,11 +1446,15 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
816
1446
|
];
|
|
817
1447
|
for (const [xmlTag, propKey] of dirs) {
|
|
818
1448
|
const bdr =
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (bdr)
|
|
1449
|
+
tcBordersNode?.["w:" + xmlTag]?.[0]?._attr ??
|
|
1450
|
+
tcBordersNode?.[xmlTag]?.[0]?._attr;
|
|
1451
|
+
if (!bdr) continue;
|
|
1452
|
+
const val = bdr?.["w:val"] ?? bdr?.val;
|
|
1453
|
+
if (val === "none" || val === "nil") {
|
|
1454
|
+
// explicit none: keep as undefined (no border)
|
|
1455
|
+
} else {
|
|
822
1456
|
cp[propKey] = safeStrokeDocx(
|
|
823
|
-
|
|
1457
|
+
val,
|
|
824
1458
|
Number(bdr?.["w:sz"] ?? bdr?.sz ?? 4),
|
|
825
1459
|
bdr?.["w:color"] ?? bdr?.color,
|
|
826
1460
|
);
|
|
@@ -841,8 +1475,49 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
841
1475
|
cp.va = vaMap[vaVal];
|
|
842
1476
|
}
|
|
843
1477
|
|
|
1478
|
+
// Cell margins (padding)
|
|
1479
|
+
const tcMar = tcPr?.["w:tcMar"]?.[0] ?? tcPr?.tcMar?.[0];
|
|
1480
|
+
if (tcMar) {
|
|
1481
|
+
const top = tcMar?.["w:top"]?.[0]?._attr ?? tcMar?.top?.[0]?._attr;
|
|
1482
|
+
const bot =
|
|
1483
|
+
tcMar?.["w:bottom"]?.[0]?._attr ?? tcMar?.bottom?.[0]?._attr;
|
|
1484
|
+
const left = tcMar?.["w:left"]?.[0]?._attr ?? tcMar?.left?.[0]?._attr;
|
|
1485
|
+
const right =
|
|
1486
|
+
tcMar?.["w:right"]?.[0]?._attr ?? tcMar?.right?.[0]?._attr;
|
|
1487
|
+
|
|
1488
|
+
if (top) cp.padT = Metric.dxaToPt(Number(top?.["w:w"] ?? top?.w ?? 0));
|
|
1489
|
+
if (bot) cp.padB = Metric.dxaToPt(Number(bot?.["w:w"] ?? bot?.w ?? 0));
|
|
1490
|
+
if (left)
|
|
1491
|
+
cp.padL = Metric.dxaToPt(Number(left?.["w:w"] ?? left?.w ?? 0));
|
|
1492
|
+
if (right)
|
|
1493
|
+
cp.padR = Metric.dxaToPt(Number(right?.["w:w"] ?? right?.w ?? 0));
|
|
1494
|
+
}
|
|
1495
|
+
|
|
844
1496
|
const rs = rsMap.get(`${ri},${ci}`) ?? 1;
|
|
845
1497
|
|
|
1498
|
+
// Compute logical column index for this cell
|
|
1499
|
+
let gridColIdx = 0;
|
|
1500
|
+
for (let prevCi = 0; prevCi < ci; prevCi++) {
|
|
1501
|
+
if (!rawRow[prevCi].vMergeContinue)
|
|
1502
|
+
gridColIdx += rawRow[prevCi].gridSpan;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Apply 3-level border resolution (tblStyle → tblBorders → tcBorders already in cp)
|
|
1506
|
+
const colCount =
|
|
1507
|
+
gridProps.colWidths?.length ??
|
|
1508
|
+
rawGrid[0]?.reduce((s, c) => s + c.gridSpan, 0) ??
|
|
1509
|
+
1;
|
|
1510
|
+
const resolvedCp = resolveCellBorders(
|
|
1511
|
+
cp,
|
|
1512
|
+
ri,
|
|
1513
|
+
gridColIdx,
|
|
1514
|
+
rs,
|
|
1515
|
+
rc.gridSpan,
|
|
1516
|
+
rawGrid.length,
|
|
1517
|
+
colCount,
|
|
1518
|
+
tblBdr,
|
|
1519
|
+
);
|
|
1520
|
+
|
|
846
1521
|
const paras = toArr(cell?.["w:p"] ?? cell?.p).map((p: any) =>
|
|
847
1522
|
decodePara(p, ctx),
|
|
848
1523
|
);
|
|
@@ -850,11 +1525,11 @@ function decodeGrid(tbl: any, ctx: DecCtx): GridNode {
|
|
|
850
1525
|
buildCell(paras.length > 0 ? paras : [buildPara([buildSpan("")])], {
|
|
851
1526
|
cs: rc.gridSpan,
|
|
852
1527
|
rs,
|
|
853
|
-
props:
|
|
1528
|
+
props: resolvedCp,
|
|
854
1529
|
}),
|
|
855
1530
|
);
|
|
856
1531
|
}
|
|
857
|
-
return buildRow(cellNodes);
|
|
1532
|
+
return buildRow(cellNodes, rowHeightPt);
|
|
858
1533
|
});
|
|
859
1534
|
return buildGrid(rowNodes, gridProps);
|
|
860
1535
|
}
|
|
@@ -920,36 +1595,39 @@ registry.registerDecoder(new DocxDecoder());
|
|
|
920
1595
|
|
|
921
1596
|
function extractAnchorLayout(anchor: any): ImgLayout {
|
|
922
1597
|
const attr = anchor?._attr ?? {};
|
|
923
|
-
const behindDoc = attr.behindDoc ===
|
|
1598
|
+
const behindDoc = attr.behindDoc === "1";
|
|
924
1599
|
|
|
925
1600
|
// 텍스트 감싸기 타입
|
|
926
|
-
let wrap: ImgWrap =
|
|
927
|
-
if (anchor?.[
|
|
928
|
-
|
|
929
|
-
else if (anchor?.[
|
|
930
|
-
else if (anchor?.[
|
|
931
|
-
else if (anchor?.[
|
|
932
|
-
else if (anchor?.[
|
|
1601
|
+
let wrap: ImgWrap = "square";
|
|
1602
|
+
if (anchor?.["wp:wrapNone"]?.[0] != null)
|
|
1603
|
+
wrap = behindDoc ? "behind" : "none";
|
|
1604
|
+
else if (anchor?.["wp:wrapTight"]?.[0] != null) wrap = "tight";
|
|
1605
|
+
else if (anchor?.["wp:wrapThrough"]?.[0] != null) wrap = "through";
|
|
1606
|
+
else if (anchor?.["wp:wrapSquare"]?.[0] != null) wrap = "square";
|
|
1607
|
+
else if (anchor?.["wp:wrapTopAndBottom"]?.[0] != null) wrap = "square";
|
|
1608
|
+
else if (anchor?.["wp:wrapBehind"]?.[0] != null || behindDoc) wrap = "behind";
|
|
933
1609
|
|
|
934
1610
|
// 가로 위치
|
|
935
|
-
const posH = anchor?.[
|
|
1611
|
+
const posH = anchor?.["wp:positionH"]?.[0];
|
|
936
1612
|
const horzRelTo = parseHorzRelTo(posH?._attr?.relativeFrom);
|
|
937
|
-
const horzAlignTxt = posH?.[
|
|
938
|
-
const horzOffsetTxt = posH?.[
|
|
1613
|
+
const horzAlignTxt = posH?.["wp:align"]?.[0]?._text;
|
|
1614
|
+
const horzOffsetTxt = posH?.["wp:posOffset"]?.[0]?._text;
|
|
939
1615
|
const horzAlign = horzAlignTxt ? parseHorzAlign(horzAlignTxt) : undefined;
|
|
940
|
-
const xPt =
|
|
941
|
-
|
|
942
|
-
|
|
1616
|
+
const xPt =
|
|
1617
|
+
horzOffsetTxt && !horzAlignTxt
|
|
1618
|
+
? Metric.emuToPt(Number(horzOffsetTxt))
|
|
1619
|
+
: undefined;
|
|
943
1620
|
|
|
944
1621
|
// 세로 위치
|
|
945
|
-
const posV = anchor?.[
|
|
1622
|
+
const posV = anchor?.["wp:positionV"]?.[0];
|
|
946
1623
|
const vertRelTo = parseVertRelTo(posV?._attr?.relativeFrom);
|
|
947
|
-
const vertAlignTxt = posV?.[
|
|
948
|
-
const vertOffsetTxt = posV?.[
|
|
1624
|
+
const vertAlignTxt = posV?.["wp:align"]?.[0]?._text;
|
|
1625
|
+
const vertOffsetTxt = posV?.["wp:posOffset"]?.[0]?._text;
|
|
949
1626
|
const vertAlign = vertAlignTxt ? parseVertAlign(vertAlignTxt) : undefined;
|
|
950
|
-
const yPt =
|
|
951
|
-
|
|
952
|
-
|
|
1627
|
+
const yPt =
|
|
1628
|
+
vertOffsetTxt && !vertAlignTxt
|
|
1629
|
+
? Metric.emuToPt(Number(vertOffsetTxt))
|
|
1630
|
+
: undefined;
|
|
953
1631
|
|
|
954
1632
|
// 텍스트와의 거리
|
|
955
1633
|
const distT = attr.distT ? Metric.emuToPt(Number(attr.distT)) : undefined;
|
|
@@ -958,29 +1636,68 @@ function extractAnchorLayout(anchor: any): ImgLayout {
|
|
|
958
1636
|
const distR = attr.distR ? Metric.emuToPt(Number(attr.distR)) : undefined;
|
|
959
1637
|
const zOrder = attr.relativeHeight ? Number(attr.relativeHeight) : undefined;
|
|
960
1638
|
|
|
961
|
-
return {
|
|
1639
|
+
return {
|
|
1640
|
+
wrap,
|
|
1641
|
+
horzAlign,
|
|
1642
|
+
vertAlign,
|
|
1643
|
+
horzRelTo,
|
|
1644
|
+
vertRelTo,
|
|
1645
|
+
xPt,
|
|
1646
|
+
yPt,
|
|
1647
|
+
distT,
|
|
1648
|
+
distB,
|
|
1649
|
+
distL,
|
|
1650
|
+
distR,
|
|
1651
|
+
behindDoc,
|
|
1652
|
+
zOrder,
|
|
1653
|
+
};
|
|
962
1654
|
}
|
|
963
1655
|
|
|
964
1656
|
const HORZ_RELTO_MAP: Record<string, ImgHorzRelTo> = {
|
|
965
|
-
margin:
|
|
966
|
-
|
|
967
|
-
|
|
1657
|
+
margin: "margin",
|
|
1658
|
+
leftMargin: "margin",
|
|
1659
|
+
rightMargin: "margin",
|
|
1660
|
+
insideMargin: "margin",
|
|
1661
|
+
outsideMargin: "margin",
|
|
1662
|
+
column: "column",
|
|
1663
|
+
page: "page",
|
|
1664
|
+
character: "para",
|
|
1665
|
+
paragraph: "para",
|
|
968
1666
|
};
|
|
969
1667
|
const VERT_RELTO_MAP: Record<string, ImgVertRelTo> = {
|
|
970
|
-
margin:
|
|
971
|
-
|
|
972
|
-
|
|
1668
|
+
margin: "margin",
|
|
1669
|
+
topMargin: "margin",
|
|
1670
|
+
bottomMargin: "margin",
|
|
1671
|
+
insideMargin: "margin",
|
|
1672
|
+
outsideMargin: "margin",
|
|
1673
|
+
line: "line",
|
|
1674
|
+
page: "page",
|
|
1675
|
+
paragraph: "para",
|
|
973
1676
|
};
|
|
974
1677
|
const HORZ_ALIGN_MAP: Record<string, ImgHorzAlign> = {
|
|
975
|
-
left:
|
|
976
|
-
|
|
1678
|
+
left: "left",
|
|
1679
|
+
center: "center",
|
|
1680
|
+
right: "right",
|
|
1681
|
+
inside: "left",
|
|
1682
|
+
outside: "right",
|
|
977
1683
|
};
|
|
978
1684
|
const VERT_ALIGN_MAP: Record<string, ImgVertAlign> = {
|
|
979
|
-
top:
|
|
980
|
-
|
|
1685
|
+
top: "top",
|
|
1686
|
+
center: "center",
|
|
1687
|
+
bottom: "bottom",
|
|
1688
|
+
inside: "top",
|
|
1689
|
+
outside: "bottom",
|
|
981
1690
|
};
|
|
982
1691
|
|
|
983
|
-
function parseHorzRelTo(v?: string): ImgHorzRelTo {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
function
|
|
1692
|
+
function parseHorzRelTo(v?: string): ImgHorzRelTo {
|
|
1693
|
+
return HORZ_RELTO_MAP[v ?? ""] ?? "column";
|
|
1694
|
+
}
|
|
1695
|
+
function parseVertRelTo(v?: string): ImgVertRelTo {
|
|
1696
|
+
return VERT_RELTO_MAP[v ?? ""] ?? "para";
|
|
1697
|
+
}
|
|
1698
|
+
function parseHorzAlign(v?: string): ImgHorzAlign | undefined {
|
|
1699
|
+
return HORZ_ALIGN_MAP[v ?? ""];
|
|
1700
|
+
}
|
|
1701
|
+
function parseVertAlign(v?: string): ImgVertAlign | undefined {
|
|
1702
|
+
return VERT_ALIGN_MAP[v ?? ""];
|
|
1703
|
+
}
|