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.
- package/ .npmignore +1 -0
- package/dist/index.d.mts +34 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.js +2138 -245
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2134 -245
- package/dist/index.mjs.map +1 -1
- package/hwp-analyze.ts +90 -0
- package/inspect-doc.ts +57 -0
- package/output_test.hwp +0 -0
- package/package.json +3 -1
- package/src/decoders/docx/DocxDecoder.ts +155 -30
- package/src/decoders/hwp/HwpScanner.ts +258 -37
- package/src/decoders/hwpx/HwpxDecoder.ts +9 -1
- package/src/encoders/docx/DocxEncoder.ts +199 -158
- package/src/encoders/html/HtmlEncoder.ts +205 -0
- package/src/encoders/hwp/HwpEncoder.ts +864 -222
- package/src/encoders/hwpx/HwpxEncoder.ts +119 -59
- package/src/encoders/md/MdEncoder.ts +98 -16
- package/src/index.ts +1 -0
- package/src/model/builders.ts +4 -2
- package/src/model/doc-tree.ts +1 -1
- package/src/pipeline/Pipeline.ts +14 -1
- package/src/safety/StyleBridge.ts +1 -1
- package/test-docx-to-hwp.ts +45 -0
|
@@ -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
|
|
21
|
-
const 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',
|
|
47
|
-
{ name: '_rels/.rels',
|
|
48
|
-
{ name: 'word/document.xml',
|
|
49
|
-
{ name: 'word/styles.xml',
|
|
50
|
-
{ name: 'word/settings.xml',
|
|
51
|
-
{ name: 'word/_rels/document.xml.rels',
|
|
52
|
-
{ name: 'docProps/app.xml',
|
|
53
|
-
{ name: 'docProps/core.xml',
|
|
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="
|
|
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
|
|
370
|
-
if (lineHeight
|
|
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:
|
|
495
|
-
tight:
|
|
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:
|
|
498
|
-
behind:
|
|
499
|
-
front:
|
|
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
|
|
508
|
-
const lastRow
|
|
509
|
-
const firstCol
|
|
510
|
-
const lastCol
|
|
511
|
-
const noHBand
|
|
512
|
-
const noVBand
|
|
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
|
-
//
|
|
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 (
|
|
521
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
537
|
-
|
|
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)
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
583
|
-
const
|
|
610
|
+
for (let c = 0; c < colCount; c++) {
|
|
611
|
+
const mapEntry = tableMap[ri][c];
|
|
584
612
|
|
|
585
|
-
//
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
621
|
+
if (isContinue || isReal || isVoid) {
|
|
622
|
+
let cw = 0;
|
|
623
|
+
const cellWidth = mapEntry.width || 1;
|
|
597
624
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
|
|
631
|
+
const tcPrParts: string[] = [];
|
|
632
|
+
tcPrParts.push(`<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`);
|
|
604
633
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
srcCellIdx++;
|
|
615
|
-
}
|
|
638
|
+
if (isContinue) {
|
|
639
|
+
tcPrParts.push(`<w:vMerge/>`);
|
|
640
|
+
}
|
|
616
641
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
let trPr = '';
|
|
666
|
+
const trPrParts: string[] = [];
|
|
670
667
|
if (ri === 0 && (gp.headerRow || look?.firstRow)) {
|
|
671
|
-
|
|
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${
|
|
676
|
+
return ` <w:tr>${trPr}\n${cellXmls.join('\n')}\n </w:tr>`;
|
|
675
677
|
}).join('\n');
|
|
676
678
|
|
|
677
|
-
//
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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> = {
|
|
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
|
-
|
|
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 {
|
|
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());
|