hwpkit-dev 0.0.1 → 0.0.2

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.
@@ -17,8 +17,8 @@ export class DocxEncoder implements Encoder {
17
17
  async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
18
18
  try {
19
19
  const sheet = doc.kids[0];
20
- const dims = normalizeDims(sheet?.dims ?? A4);
21
- const kids = sheet?.kids ?? [];
20
+ const dims = normalizeDims(sheet?.dims ?? A4);
21
+ const kids = sheet?.kids ?? [];
22
22
 
23
23
  const images: ImageEntry[] = [];
24
24
  const ctx: EncCtx = { images, nextId: 10, nextImgNum: 1, warns: [], imgMap: new WeakMap() };
@@ -43,14 +43,14 @@ export class DocxEncoder implements Encoder {
43
43
  const numInfo = collectNumbering(kids);
44
44
 
45
45
  const entries: { name: string; data: Uint8Array }[] = [
46
- { name: '[Content_Types].xml', data: TextKit.encode(contentTypes(images, hasHeader, hasFooter)) },
47
- { name: '_rels/.rels', data: TextKit.encode(pkgRels()) },
48
- { name: 'word/document.xml', data: TextKit.encode(documentXml(kids, dims, ctx, headerRId, footerRId)) },
49
- { name: 'word/styles.xml', data: TextKit.encode(stylesXml()) },
50
- { name: 'word/settings.xml', data: TextKit.encode(settingsXml()) },
51
- { name: 'word/_rels/document.xml.rels', data: TextKit.encode(docRels(images, headerRId, footerRId, numInfo.hasLists)) },
52
- { name: 'docProps/app.xml', data: TextKit.encode(appXml()) },
53
- { name: 'docProps/core.xml', data: TextKit.encode(coreXml(doc.meta)) },
46
+ { name: '[Content_Types].xml', data: TextKit.encode(contentTypes(images, hasHeader, hasFooter)) },
47
+ { name: '_rels/.rels', data: TextKit.encode(pkgRels()) },
48
+ { name: 'word/document.xml', data: TextKit.encode(documentXml(kids, dims, ctx, headerRId, footerRId)) },
49
+ { name: 'word/styles.xml', data: TextKit.encode(stylesXml()) },
50
+ { name: 'word/settings.xml', data: TextKit.encode(settingsXml()) },
51
+ { name: 'word/_rels/document.xml.rels', data: TextKit.encode(docRels(images, headerRId, footerRId, numInfo.hasLists)) },
52
+ { name: 'docProps/app.xml', data: TextKit.encode(appXml()) },
53
+ { name: 'docProps/core.xml', data: TextKit.encode(coreXml(doc.meta)) },
54
54
  ];
55
55
 
56
56
  // Add numbering.xml if needed
@@ -252,7 +252,7 @@ function stylesXml(): string {
252
252
  <w:style w:type="paragraph" w:styleId="Header"><w:name w:val="header"/><w:basedOn w:val="Normal"/></w:style>
253
253
  <w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/></w:style>
254
254
  <w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/></w:pPr></w:style>
255
- <w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders></w:tblPr></w:style>
255
+ <w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:left w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:bottom w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:right w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:insideH w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:insideV w:val="none" w:sz="0" w:space="0" w:color="auto"/></w:tblBorders></w:tblPr></w:style>
256
256
  </w:styles>`;
257
257
  }
258
258
 
@@ -366,8 +366,8 @@ function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
366
366
  if (spaceBefore !== undefined || spaceAfter !== undefined || lineHeight !== undefined) {
367
367
  const parts: string[] = [];
368
368
  if (spaceBefore !== undefined) parts.push(`w:before="${Metric.ptToDxa(spaceBefore)}"`);
369
- if (spaceAfter !== undefined) parts.push(`w:after="${Metric.ptToDxa(spaceAfter)}"`);
370
- if (lineHeight !== undefined) parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
369
+ if (spaceAfter !== undefined) parts.push(`w:after="${Metric.ptToDxa(spaceAfter)}"`);
370
+ if (lineHeight !== undefined) parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
371
371
  spacingXml = `<w:spacing ${parts.join(' ')}/>`;
372
372
  }
373
373
 
@@ -491,12 +491,12 @@ const VERT_ALIGN_DOCX: Record<string, string> = {
491
491
  top: 'top', center: 'center', bottom: 'bottom',
492
492
  };
493
493
  const WRAP_DOCX: Record<string, string> = {
494
- square: '<wp:wrapSquare wrapText="bothSides"/>',
495
- tight: '<wp:wrapTight><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapTight>',
494
+ square: '<wp:wrapSquare wrapText="bothSides"/>',
495
+ tight: '<wp:wrapTight><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapTight>',
496
496
  through: '<wp:wrapThrough wrapText="bothSides"><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapThrough>',
497
- none: '<wp:wrapNone/>',
498
- behind: '<wp:wrapNone/>',
499
- front: '<wp:wrapNone/>',
497
+ none: '<wp:wrapNone/>',
498
+ behind: '<wp:wrapNone/>',
499
+ front: '<wp:wrapNone/>',
500
500
  };
501
501
 
502
502
  function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
@@ -504,185 +504,200 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
504
504
  const look = gp.look;
505
505
 
506
506
  // tblLook attributes
507
- const firstRow = look?.firstRow ? '1' : '0';
508
- const lastRow = look?.lastRow ? '1' : '0';
509
- const firstCol = look?.firstCol ? '1' : '0';
510
- const lastCol = look?.lastCol ? '1' : '0';
511
- const noHBand = look?.bandedRows ? '0' : '1';
512
- const noVBand = look?.bandedCols ? '0' : '1';
513
-
514
- // Determine actual grid column count from colWidths or by scanning all rows
507
+ const firstRow = look?.firstRow ? '1' : '0';
508
+ const lastRow = look?.lastRow ? '1' : '0';
509
+ const firstCol = look?.firstCol ? '1' : '0';
510
+ const lastCol = look?.lastCol ? '1' : '0';
511
+ const noHBand = look?.bandedRows ? '0' : '1';
512
+ const noVBand = look?.bandedCols ? '0' : '1';
513
+
515
514
  const d = dims ?? A4;
516
515
  const availDxa = Metric.ptToDxa(d.wPt - d.ml - d.mr);
517
516
 
518
- // Compute true column count: max total colSpan across all rows
517
+ // 1단계: 표의 가상 2D 맵핑 (Virtual Table Map) 생성
518
+ // 'real': 데이터 셀, 'continue': 세로 병합 지속 셀, 'absorbed': 가로/세로 병합으로 흡수된 자리, 'void': 빈 공간
519
+ interface CellMap {
520
+ type: 'real' | 'continue' | 'absorbed' | 'void';
521
+ cell?: any;
522
+ width?: number;
523
+ }
524
+ const tableMap: CellMap[][] = Array.from({ length: grid.kids.length }, () => []);
525
+
526
+ for (let ri = 0; ri < grid.kids.length; ri++) {
527
+ let c = 0;
528
+ for (const cell of grid.kids[ri].kids) {
529
+ // 이미 이전 행의 rowspan이나 현재 행의 colspan으로 차지된 자리 건너뜀
530
+ while (tableMap[ri][c]) c++;
531
+
532
+ // 실제 데이터 셀 배치
533
+ tableMap[ri][c] = { type: 'real', cell, width: cell.cs };
534
+
535
+ // 병합 영역(colspan, rowspan) 예약 처리
536
+ for (let rr = 0; rr < cell.rs; rr++) {
537
+ const targetRi = ri + rr;
538
+ if (targetRi >= grid.kids.length) break;
539
+ if (!tableMap[targetRi]) tableMap[targetRi] = [];
540
+
541
+ for (let cc = 0; cc < cell.cs; cc++) {
542
+ if (rr === 0 && cc === 0) continue; // 시작 셀은 이미 'real'로 처리됨
543
+
544
+ if (rr > 0 && cc === 0) {
545
+ // 세로 병합이 시작된 이후 행의 첫 번째 칸
546
+ tableMap[targetRi][c + cc] = { type: 'continue', width: cell.cs };
547
+ } else {
548
+ // 가로 병합으로 흡수된 칸 또는 세로 병합 중 가로 병합된 칸
549
+ tableMap[targetRi][c + cc] = { type: 'absorbed' };
550
+ }
551
+ }
552
+ }
553
+ c += cell.cs;
554
+ }
555
+ }
556
+
557
+ // 정확한 전체 열 개수(colCount) 계산 (모든 행 중 최대 길이)
519
558
  let colCount = 0;
520
- for (const row of grid.kids) {
521
- let rowCols = 0;
522
- for (const cell of row.kids) rowCols += cell.cs;
523
- if (rowCols > colCount) colCount = rowCols;
559
+ for (let ri = 0; ri < grid.kids.length; ri++) {
560
+ colCount = Math.max(colCount, tableMap[ri].length);
524
561
  }
525
- if (colCount === 0) colCount = grid.kids[0]?.kids.length ?? 1;
562
+ if (colCount === 0) colCount = 1;
526
563
 
564
+ // 빈 공간(void) 채우기 및 colCount에 맞춰 배열 길이 정규화
565
+ for (let ri = 0; ri < grid.kids.length; ri++) {
566
+ for (let c = 0; c < colCount; c++) {
567
+ if (!tableMap[ri][c]) tableMap[ri][c] = { type: 'void' };
568
+ }
569
+ }
570
+
571
+ // 2단계: 컬럼 너비(dxa) 계산
527
572
  const defaultColDxa = Math.round(availDxa / colCount);
573
+ let colWidthsDxa: number[] = [];
528
574
 
529
- // Use actual column widths if available from source format
530
- const colWidthsDxa: number[] = [];
531
- if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
532
- // Fill zero-width columns by distributing remaining space
575
+ if (grid.props.colWidths && grid.props.colWidths.length > 0) {
533
576
  const srcPt = [...grid.props.colWidths];
534
- const knownTotal = srcPt.filter(w => w > 0).reduce((s, w) => s + w, 0);
577
+ while (srcPt.length < colCount) srcPt.push(0);
578
+ srcPt.length = colCount;
579
+
580
+ const knownTotalPt = srcPt.filter(w => w > 0).reduce((s, w) => s + w, 0);
535
581
  const zeroCount = srcPt.filter(w => w <= 0).length;
536
- const remaining = Math.max(0, Metric.dxaToPt(availDxa) - knownTotal);
537
- const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
582
+ const availPt = Metric.dxaToPt(availDxa);
583
+
584
+ const remainingPt = Math.max(0, availPt - knownTotalPt);
585
+ const zeroFillPt = zeroCount > 0 ? remainingPt / zeroCount : 0;
586
+
538
587
  for (let i = 0; i < srcPt.length; i++) {
539
- if (srcPt[i] <= 0) srcPt[i] = zeroFill > 0 ? zeroFill : Metric.dxaToPt(defaultColDxa);
588
+ if (srcPt[i] <= 0) {
589
+ srcPt[i] = zeroFillPt > 0 ? zeroFillPt : (availPt / colCount);
590
+ }
591
+ }
592
+
593
+ colWidthsDxa = srcPt.map(w => Math.round(Metric.ptToDxa(w)));
594
+ const computedTotalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
595
+ if (computedTotalDxa > availDxa) {
596
+ const scale = availDxa / computedTotalDxa;
597
+ colWidthsDxa = colWidthsDxa.map(w => Math.round(w * scale));
540
598
  }
541
- const srcWidths = srcPt.map(w => Metric.ptToDxa(w));
542
- const srcTotal = srcWidths.reduce((s, w) => s + w, 0);
543
- // Normalize to fit available page width if source widths exceed it
544
- const scale = srcTotal > availDxa ? availDxa / srcTotal : 1;
545
- for (const w of srcWidths) colWidthsDxa.push(Math.round(w * scale));
546
599
  } else {
547
600
  for (let c = 0; c < colCount; c++) colWidthsDxa.push(defaultColDxa);
548
601
  }
549
- const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
550
-
551
- // Grid columns
552
- const gridCols = colWidthsDxa.map(w => `<w:gridCol w:w="${Math.round(w)}"/>`).join('');
553
602
 
554
- // Pre-compute vMerge map: for each (ri, colIdx), track if a cell with rs>1 spans into this row
555
- // Key: "ri,colIdx", Value: 'restart' | 'continue'
556
- const vMergeMap = new Map<string, 'restart' | 'continue'>();
557
- for (let ri = 0; ri < grid.kids.length; ri++) {
558
- let colIdx = 0;
559
- for (const cell of grid.kids[ri].kids) {
560
- if (cell.rs > 1) {
561
- vMergeMap.set(`${ri},${colIdx}`, 'restart');
562
- for (let sr = 1; sr < cell.rs; sr++) {
563
- vMergeMap.set(`${ri + sr},${colIdx}`, 'continue');
564
- }
565
- }
566
- colIdx += cell.cs;
567
- }
568
- }
603
+ const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
604
+ const gridCols = colWidthsDxa.map(w => `<w:gridCol w:w="${w}"/>`).join('');
569
605
 
606
+ // 3단계: XML 렌더링
570
607
  const rows = grid.kids.map((row, ri) => {
571
- let colIdx = 0;
572
-
573
- // Build actual cells including continuation cells for vMerge
574
608
  const cellXmls: string[] = [];
575
- let srcCellIdx = 0;
576
-
577
- // Walk through grid columns, emitting either real cells or vMerge continue cells
578
- while (srcCellIdx < row.kids.length) {
579
- const cell = row.kids[srcCellIdx];
580
- const mergeType = vMergeMap.get(`${ri},${colIdx}`);
581
609
 
582
- const cp = cell.props;
583
- const tcPrParts: string[] = [];
610
+ for (let c = 0; c < colCount; c++) {
611
+ const mapEntry = tableMap[ri][c];
584
612
 
585
- // Cell width in DXA (sum of spanned columns)
586
- let cellW = 0;
587
- for (let sc = colIdx; sc < colIdx + cell.cs && sc < colWidthsDxa.length; sc++) cellW += colWidthsDxa[sc];
588
- if (cellW === 0) cellW = defaultColDxa * cell.cs;
589
- tcPrParts.push(`<w:tcW w:w="${Math.round(cellW)}" w:type="dxa"/>`);
613
+ // 가로 병합으로 흡수된 칸은 렌더링하지 않음 (앞선 칸의 gridSpan이 차지)
614
+ if (mapEntry.type === 'absorbed') continue;
590
615
 
591
- if (cell.cs > 1) tcPrParts.push(`<w:gridSpan w:val="${cell.cs}"/>`);
616
+ // 세로 병합 지속(continue), 실제 셀(real), 또는 빈 공간(void) 처리
617
+ const isContinue = mapEntry.type === 'continue';
618
+ const isReal = mapEntry.type === 'real';
619
+ const isVoid = mapEntry.type === 'void';
592
620
 
593
- // vMerge
594
- if (cell.rs > 1) {
595
- tcPrParts.push(`<w:vMerge w:val="restart"/>`);
596
- }
621
+ if (isContinue || isReal || isVoid) {
622
+ let cw = 0;
623
+ const cellWidth = mapEntry.width || 1;
597
624
 
598
- // Cell borders
599
- const borders = encodeCellBorders(cp);
600
- if (borders) tcPrParts.push(borders);
625
+ // 너비 계산: 현재 칸부터 colspan(width) 만큼의 컬럼 너비 합산
626
+ for (let sc = c; sc < c + cellWidth && sc < colWidthsDxa.length; sc++) {
627
+ cw += colWidthsDxa[sc];
628
+ }
629
+ if (cw === 0) cw = defaultColDxa * cellWidth;
601
630
 
602
- // Cell background
603
- if (cp.bg) tcPrParts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`);
631
+ const tcPrParts: string[] = [];
632
+ tcPrParts.push(`<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`);
604
633
 
605
- // Vertical alignment
606
- if (cp.va) {
607
- const vaMap: Record<string, string> = { top: 'top', mid: 'center', bot: 'bottom' };
608
- tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? 'top'}"/>`);
609
- }
634
+ if (cellWidth > 1) {
635
+ tcPrParts.push(`<w:gridSpan w:val="${cellWidth}"/>`);
636
+ }
610
637
 
611
- const tcPr = `<w:tcPr>${tcPrParts.join('')}</w:tcPr>`;
612
- cellXmls.push(` <w:tc>${tcPr}${cell.kids.map(p => encodeParaInner(p, ctx)).join('')}</w:tc>`);
613
- colIdx += cell.cs;
614
- srcCellIdx++;
615
- }
638
+ if (isContinue) {
639
+ tcPrParts.push(`<w:vMerge/>`);
640
+ }
616
641
 
617
- // Now emit vMerge continue cells for rows that are spanned into
618
- // We need to check if any cells from rows above span into this row
619
- // Re-walk grid columns looking for continue cells not covered by this row's cells
620
- const finalCells: string[] = [];
621
- let finalColIdx = 0;
622
- let cellIter = 0;
623
-
624
- // Use the computed colCount (max across all rows)
625
- const totalGridCols = colCount;
626
-
627
- // Re-check: we need to interleave vMerge continue cells
628
- // The current row may have fewer cells because the model already skipped continuation cells
629
- // So we need to insert continuation cells where vMergeMap says 'continue' for this row
630
- finalColIdx = 0;
631
- cellIter = 0;
632
- for (let gc = 0; gc < totalGridCols; ) {
633
- const mergeType = vMergeMap.get(`${ri},${gc}`);
634
- if (mergeType === 'continue') {
635
- // Find the original cell's cs from the restart row
636
- let origCs = 1;
637
- for (let sr = ri - 1; sr >= 0; sr--) {
638
- const mt = vMergeMap.get(`${sr},${gc}`);
639
- if (mt === 'restart') {
640
- // Find the cell at this column in that row
641
- let col = 0;
642
- for (const c of grid.kids[sr].kids) {
643
- if (col === gc) { origCs = c.cs; break; }
644
- col += c.cs;
645
- }
646
- break;
642
+ let cellContent = '';
643
+ if (isReal) {
644
+ const cell = mapEntry.cell!;
645
+ const cp = cell.props;
646
+ if (cell.rs > 1) tcPrParts.push(`<w:vMerge w:val="restart"/>`);
647
+
648
+ const borders = encodeCellBorders(cp);
649
+ if (borders) tcPrParts.push(borders);
650
+ if (cp.bg) tcPrParts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`);
651
+ if (cp.va) {
652
+ const vaMap: Record<string, string> = { top: 'top', mid: 'center', bot: 'bottom' };
653
+ tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? 'top'}"/>`);
647
654
  }
648
- }
649
- let cw = 0;
650
- for (let sc = gc; sc < gc + origCs && sc < colWidthsDxa.length; sc++) cw += colWidthsDxa[sc];
651
- if (cw === 0) cw = defaultColDxa * origCs;
652
- let contParts = `<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`;
653
- if (origCs > 1) contParts += `<w:gridSpan w:val="${origCs}"/>`;
654
- contParts += `<w:vMerge/>`;
655
- finalCells.push(` <w:tc><w:tcPr>${contParts}</w:tcPr><w:p><w:pPr/></w:p></w:tc>`);
656
- gc += origCs;
657
- } else {
658
- if (cellIter < cellXmls.length) {
659
- finalCells.push(cellXmls[cellIter]);
660
- gc += row.kids[cellIter]?.cs ?? 1;
661
- cellIter++;
655
+ cellContent = cell.kids.map((p: any) => encodeParaInner(p, ctx)).join('');
662
656
  } else {
663
- gc++;
657
+ // continue 거나 void 인 경우 빈 단락 추가
658
+ cellContent = `<w:p><w:pPr/></w:p>`;
664
659
  }
660
+
661
+ const tcPr = `<w:tcPr>${tcPrParts.join('')}</w:tcPr>`;
662
+ cellXmls.push(` <w:tc>${tcPr}${cellContent}</w:tc>`);
665
663
  }
666
664
  }
667
665
 
668
- // Header row
669
- let trPr = '';
666
+ const trPrParts: string[] = [];
670
667
  if (ri === 0 && (gp.headerRow || look?.firstRow)) {
671
- trPr = '<w:trPr><w:tblHeader/></w:trPr>';
668
+ trPrParts.push('<w:tblHeader/>');
669
+ }
670
+ if (row.heightPt != null && row.heightPt > 0) {
671
+ const hDxa = Math.round(Metric.ptToDxa(row.heightPt));
672
+ trPrParts.push(`<w:trHeight w:val="${hDxa}" w:hRule="exact"/>`);
672
673
  }
674
+ const trPr = trPrParts.length > 0 ? `<w:trPr>${trPrParts.join('')}</w:trPr>` : '';
673
675
 
674
- return ` <w:tr>${trPr}\n${finalCells.join('\n')}\n </w:tr>`;
676
+ return ` <w:tr>${trPr}\n${cellXmls.join('\n')}\n </w:tr>`;
675
677
  }).join('\n');
676
678
 
677
- // Table borders from defaultStroke
679
+ // 4단계: 테두리 최종 테이블 XML 조립
678
680
  let tblBorders = '';
681
+ const strokeKindMap: Record<string, string> = {
682
+ solid: 'single', dash: 'dash', dot: 'dot', double: 'double', none: 'none',
683
+ dotDash: 'dotDash', dotDotDash: 'dotDotDash', triple: 'triple', thinThickSmallGap: 'thinThickSmallGap',
684
+ thickThinSmallGap: 'thickThinSmallGap', thinThickThinSmallGap: 'thinThickThinSmallGap',
685
+ };
686
+
679
687
  if (gp.defaultStroke) {
680
688
  const s = gp.defaultStroke;
681
- const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
682
689
  const val = strokeKindMap[s.kind] ?? 'single';
683
- const sz = Math.round(s.pt * 8);
684
- const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${s.color}"`;
685
- tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
690
+
691
+ if (val === 'none' || s.pt <= 0) {
692
+ tblBorders = '<w:tblBorders><w:top w:val="none"/><w:left w:val="none"/><w:bottom w:val="none"/><w:right w:val="none"/><w:insideH w:val="none"/><w:insideV w:val="none"/></w:tblBorders>';
693
+ } else {
694
+ // DOCX sz는 1/8pt 단위. 최소 굵기 2(0.25pt) 보장
695
+ const sz = Math.max(2, Math.round(s.pt * 8));
696
+ // 색상 '#' 제거 및 빈 값일 경우 auto 처리
697
+ const clr = s.color ? s.color.replace('#', '') : 'auto';
698
+ const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"`;
699
+ tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
700
+ }
686
701
  }
687
702
 
688
703
  return ` <w:tbl>
@@ -691,20 +706,46 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
691
706
  ${rows}
692
707
  </w:tbl>`;
693
708
  }
694
-
695
709
  function encodeCellBorders(cp: CellProps): string {
696
710
  if (!cp.top && !cp.bot && !cp.left && !cp.right) return '';
697
- const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
711
+ const strokeKindMap: Record<string, string> = {
712
+ solid: 'single', dash: 'dash', dot: 'dot', double: 'double', none: 'none',
713
+ dotDash: 'dotDash', dotDotDash: 'dotDotDash', triple: 'triple',
714
+ };
698
715
 
699
716
  const encode = (s?: { kind: string; pt: number; color: string }, tag?: string) => {
700
717
  if (!s || !tag) return '';
701
718
  const val = strokeKindMap[s.kind] ?? 'single';
702
- return `<w:${tag} w:val="${val}" w:sz="${Math.round(s.pt * 8)}" w:space="0" w:color="${s.color}"/>`;
719
+
720
+ // 선이 없거나 굵기가 0 이하인 경우 확실하게 제거 처리
721
+ if (val === 'none' || s.pt <= 0) {
722
+ return `<w:${tag} w:val="none" w:sz="0" w:space="0" w:color="auto"/>`;
723
+ }
724
+
725
+ // 최소 굵기 sz=2 (0.25pt) 보장
726
+ const sz = Math.max(2, Math.round(s.pt * 8));
727
+ // 색상 '#' 제거 및 빈 값일 경우 auto 처리
728
+ const clr = s.color ? s.color.replace('#', '') : 'auto';
729
+
730
+ return `<w:${tag} w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"/>`;
703
731
  };
704
732
 
705
733
  return `<w:tcBorders>${encode(cp.top, 'top')}${encode(cp.bot, 'bottom')}${encode(cp.left, 'left')}${encode(cp.right, 'right')}</w:tcBorders>`;
706
734
  }
707
735
 
708
- function esc(s: string): string { return TextKit.escapeXml(s); }
736
+ function esc(s: string): string {
737
+ if (!s) return '';
738
+ // 1. 내부 처리용 플레이스홀더(__EXT_0__ 등) 제거
739
+ s = s.replace(/__EXT_\d+__/g, '');
740
+
741
+ // 2. 글자 깨짐을 유발하는 쓰레기값 및 BOM 기호 명시적 제거 (이 부분 추가!)
742
+ s = s.replace(/湰灧/g, '');
743
+ s = s.replace(/\uFEFF/g, '');
744
+
745
+ // 3. DOCX(XML 1.0)에서 허용하지 않는 보이지 않는 제어문자 모두 제거
746
+ s = s.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]/g, '');
747
+
748
+ return TextKit.escapeXml(s);
749
+ }
709
750
 
710
751
  registry.registerEncoder(new DocxEncoder());