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
@@ -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 implements Decoder {
55
- readonly format = "docx";
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 docXml = files.get("word/document.xml");
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 = files.get("word/_rels/document.xml.rels");
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 = files.get("docProps/core.xml");
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 = files.get("word/numbering.xml");
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
- const docStr = TextKit.decode(docXml);
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 = { relsMap, files, shield, numMap, warns };
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 node = shield.guard(
148
+ const nodes = shield.guard(
105
149
  () => decodeElement(el, decCtx),
106
- buildPara([buildSpan("[요소 파싱 실패]")]),
150
+ [buildPara([buildSpan("[요소 파싱 실패]")])],
107
151
  "docx:bodyElement",
108
152
  );
109
- kids.push(node);
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 === 'para') {
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 ?? 'nextPage';
118
- if (sectType !== 'continuous') {
119
- kids.push(buildPara([{ tag: 'span', props: {}, kids: [buildPb()] }]));
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 headerParas = await decodeHeaderFooter(
176
+ const headersMap = await decodeHeaderFooter(
127
177
  "header",
128
178
  body,
129
179
  relsMap,
130
180
  files,
131
181
  decCtx,
132
182
  );
133
- const footerParas = await decodeHeaderFooter(
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
- header: headerParas,
144
- footer: footerParas,
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(xml);
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(xml);
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(xml);
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
- return (
266
- obj?.["w:document"]?.[0]?.["w:body"]?.[0] ??
267
- obj?.document?.[0]?.body?.[0] ??
268
- obj
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 first, then tables
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 rId =
350
- refs[0]?._attr?.["r:id"] ??
351
- refs[0]?._attr?.["r:Id"] ??
352
- refs[0]?._attr?.id;
353
- if (!rId) return undefined;
354
-
355
- const target = relsMap.get(rId);
356
- if (!target) return undefined;
357
-
358
- const filePath = resolveDocxPath("word", target);
359
- const fileData = files.get(filePath);
360
- if (!fileData) return undefined;
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
- const xmlStr = TextKit.decode(fileData);
363
- const obj: any = await XmlKit.parseStrict(xmlStr);
485
+ const xmlStr = TextKit.decode(fileData).trim();
486
+ if (!xmlStr) continue;
364
487
 
365
- const rootTag = kind === "header" ? "w:hdr" : "w:ftr";
366
- const root =
367
- obj?.[rootTag]?.[0] ?? obj?.[rootTag.replace("w:", "")]?.[0] ?? obj;
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
- const paras = toArr(root?.["w:p"] ?? root?.p);
370
- if (paras.length === 0) return undefined;
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 paras.map((p: any) => decodePara(p, ctx));
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 = pPr?.["w:pageBreakBefore"]?.[0] ?? pPr?.pageBreakBefore?.[0];
466
- const hasPageBreakBefore = pbBeforeNode != null &&
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
- const runs = toArr(p?.["w:r"] ?? p?.r);
470
-
471
- // 3/28 이미지 태크를 찾을수 있기 때문에 별도 함수 구현
472
- const kids: (SpanNode | ImgNode)[] = ctx.shield.guardAll(
473
- runs,
474
- (run: any) =>
475
- hasDrawingDeep(run) ? decodeRunOrImage(run, ctx) : decodeRun(run, ctx),
476
- () => buildSpan(""),
477
- "docx:run",
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: 'span', props: {}, kids: [buildPb()] });
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
- const filePath = resolveDocxPath("word", target);
560
- const fileData = ctx.files.get(filePath);
872
+ let filePath = resolveDocxPath("word", target);
873
+ let fileData = ctx.files.get(filePath);
874
+
561
875
  if (!fileData) {
562
- console.warn(
563
- `[DocxDecoder] image not found in ZIP: "${filePath}" (rId=${rId}, target=${target})`,
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: 'inline' }
911
+ ? { wrap: "inline" }
584
912
  : extractAnchorLayout(anchor);
585
913
 
586
- return buildImg(TextKit.base64Encode(fileData), mime, wPt, hPt, alt || undefined, layout);
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
- function decodeRun(run: any, ctx: DecCtx): SpanNode {
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
- // Background/highlight
986
+ // w:shd — 배경색 (낮은 우선순위)
616
987
  const shdAttr = rPr?.["w:shd"]?.[0]?._attr ?? rPr?.shd?.[0]?._attr ?? {};
617
- const bgVal = safeHex(shdAttr?.["w:fill"] ?? shdAttr?.fill);
988
+ const shdBg = safeHex(shdAttr?.["w:fill"] ?? shdAttr?.fill);
618
989
 
619
- // Superscript/subscript
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 && underVal !== "none" ? true : undefined,
642
- s: isStrike || undefined,
643
- sup: vertAlignVal === "superscript" || undefined,
644
- sub: vertAlignVal === "subscript" || undefined,
645
- pt: szVal ? Metric.halfPtToPt(Number(szVal)) : undefined,
646
- color: safeHex(colorVal),
647
- font: fontName ? safeFont(fontName) : undefined,
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
- // Parse table borders for defaultStroke
704
- const tblBorders = tblPr?.["w:tblBorders"]?.[0] ?? tblPr?.tblBorders?.[0];
705
- let defaultStroke = undefined;
706
- if (tblBorders) {
707
- const top =
708
- tblBorders?.["w:top"]?.[0]?._attr ?? tblBorders?.top?.[0]?._attr;
709
- if (top) {
710
- defaultStroke = safeStrokeDocx(
711
- top?.["w:val"] ?? top?.val,
712
- Number(top?.["w:sz"] ?? top?.sz ?? 4),
713
- top?.["w:color"] ?? top?.color,
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
- // Cell borders
807
- const tcBorders = tcPr?.["w:tcBorders"]?.[0] ?? tcPr?.tcBorders?.[0];
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 (tcBorders) {
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
- tcBorders?.["w:" + xmlTag]?.[0]?._attr ??
820
- tcBorders?.[xmlTag]?.[0]?._attr;
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
- bdr?.["w:val"] ?? bdr?.val,
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: cp,
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 === '1';
1598
+ const behindDoc = attr.behindDoc === "1";
924
1599
 
925
1600
  // 텍스트 감싸기 타입
926
- let wrap: ImgWrap = 'square';
927
- if (anchor?.['wp:wrapNone']?.[0] != null) wrap = behindDoc ? 'behind' : 'none';
928
- else if (anchor?.['wp:wrapTight']?.[0] != null) wrap = 'tight';
929
- else if (anchor?.['wp:wrapThrough']?.[0] != null) wrap = 'through';
930
- else if (anchor?.['wp:wrapSquare']?.[0] != null) wrap = 'square';
931
- else if (anchor?.['wp:wrapTopAndBottom']?.[0] != null) wrap = 'square';
932
- else if (anchor?.['wp:wrapBehind']?.[0] != null || behindDoc) wrap = 'behind';
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?.['wp:positionH']?.[0];
1611
+ const posH = anchor?.["wp:positionH"]?.[0];
936
1612
  const horzRelTo = parseHorzRelTo(posH?._attr?.relativeFrom);
937
- const horzAlignTxt = posH?.['wp:align']?.[0]?._text;
938
- const horzOffsetTxt = posH?.['wp:posOffset']?.[0]?._text;
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 = horzOffsetTxt && !horzAlignTxt
941
- ? Metric.emuToPt(Number(horzOffsetTxt))
942
- : undefined;
1616
+ const xPt =
1617
+ horzOffsetTxt && !horzAlignTxt
1618
+ ? Metric.emuToPt(Number(horzOffsetTxt))
1619
+ : undefined;
943
1620
 
944
1621
  // 세로 위치
945
- const posV = anchor?.['wp:positionV']?.[0];
1622
+ const posV = anchor?.["wp:positionV"]?.[0];
946
1623
  const vertRelTo = parseVertRelTo(posV?._attr?.relativeFrom);
947
- const vertAlignTxt = posV?.['wp:align']?.[0]?._text;
948
- const vertOffsetTxt = posV?.['wp:posOffset']?.[0]?._text;
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 = vertOffsetTxt && !vertAlignTxt
951
- ? Metric.emuToPt(Number(vertOffsetTxt))
952
- : undefined;
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 { wrap, horzAlign, vertAlign, horzRelTo, vertRelTo, xPt, yPt, distT, distB, distL, distR, behindDoc, zOrder };
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: 'margin', leftMargin: 'margin', rightMargin: 'margin',
966
- insideMargin: 'margin', outsideMargin: 'margin',
967
- column: 'column', page: 'page', character: 'para', paragraph: 'para',
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: 'margin', topMargin: 'margin', bottomMargin: 'margin',
971
- insideMargin: 'margin', outsideMargin: 'margin',
972
- line: 'line', page: 'page', paragraph: 'para',
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: 'left', center: 'center', right: 'right',
976
- inside: 'left', outside: 'right',
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: 'top', center: 'center', bottom: 'bottom',
980
- inside: 'top', outside: 'bottom',
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 { return HORZ_RELTO_MAP[v ?? ''] ?? 'column'; }
984
- function parseVertRelTo(v?: string): ImgVertRelTo { return VERT_RELTO_MAP[v ?? ''] ?? 'para'; }
985
- function parseHorzAlign(v?: string): ImgHorzAlign | undefined { return HORZ_ALIGN_MAP[v ?? '']; }
986
- function parseVertAlign(v?: string): ImgVertAlign | undefined { return VERT_ALIGN_MAP[v ?? '']; }
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
+ }