hwpkit-dev 0.0.3 → 0.0.5

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.
@@ -172,6 +172,31 @@ const KIND_MAP: Record<string, string> = {
172
172
  dash_dot_dot: "DASH_DOT_DOT",
173
173
  };
174
174
 
175
+ /**
176
+ * 테두리 선 굵기 mm 값을 한글 표준 규격 리스트 중 가장 가까운 값으로 매핑(양자화)합니다.
177
+ */
178
+ function quantizeBorderWidth(pt: number): string {
179
+ const mm = pt * 0.3528;
180
+ const standardWidths = [0.1, 0.12, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0];
181
+ let closest = standardWidths[0];
182
+ let minDiff = Math.abs(mm - closest);
183
+ for (let i = 1; i < standardWidths.length; i++) {
184
+ const diff = Math.abs(mm - standardWidths[i]);
185
+ if (diff < minDiff) {
186
+ minDiff = diff;
187
+ closest = standardWidths[i];
188
+ }
189
+ }
190
+ let str = closest.toFixed(2);
191
+ if (str.endsWith("0")) {
192
+ str = str.slice(0, -1);
193
+ }
194
+ if (str.endsWith(".0")) {
195
+ str = str.slice(0, -2);
196
+ }
197
+ return `${str} mm`;
198
+ }
199
+
175
200
  class BorderFillBank {
176
201
  private fills: { id: number; xml: string }[] = [];
177
202
  private keyMap = new Map<string, number>();
@@ -190,7 +215,7 @@ class BorderFillBank {
190
215
  const type =
191
216
  s && s.kind !== "none" ? (KIND_MAP[s.kind] ?? "SOLID") : "NONE";
192
217
  const w =
193
- s && s.kind !== "none" ? `${(s.pt * 0.3528).toFixed(2)} mm` : "0.12 mm";
218
+ s && s.kind !== "none" ? quantizeBorderWidth(s.pt) : "0.12 mm";
194
219
  const c = s
195
220
  ? s.color.startsWith("#")
196
221
  ? s.color
@@ -346,7 +371,6 @@ interface CharPrDef {
346
371
  latinId: number; // LATIN 그룹 폰트 ID
347
372
  bg?: string;
348
373
  }
349
-
350
374
  interface ParaPrDef {
351
375
  id: number;
352
376
  align: string;
@@ -356,10 +380,12 @@ interface ParaPrDef {
356
380
  prevHwp: number;
357
381
  nextHwp: number;
358
382
  lineSpacing: number;
383
+ lineSpacingFixed?: number; // HWPUNIT, type=FIXED
359
384
  listType?: string;
360
385
  listLevel?: number;
386
+ verAlign?: string;
387
+ lineWrap?: string;
361
388
  }
362
-
363
389
  interface StyleEntry {
364
390
  id: number;
365
391
  name: string;
@@ -383,7 +409,7 @@ function charPrKey(p: TextProps): string {
383
409
  * null/undefined는 0 으로 처리하여 일관성 유지
384
410
  */
385
411
  function paraPrKey(p: ParaProps): string {
386
- return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.firstLineIndentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}|${p.styleId ?? ""}`;
412
+ return `${p.align ?? "left"}|${p.verAlign ?? "baseline"}|${p.lineWrap ?? "break"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.leftMargin ?? 0}|${p.indentRightPt ?? 0}|${p.firstLineIndentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}|${p.lineHeightFixed ?? 0}|${p.styleId ?? ""}`;
387
413
  }
388
414
 
389
415
  // ─── 인코딩 컨텍스트 ─────────────────────────────────────────
@@ -430,21 +456,51 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
430
456
  return id;
431
457
  }
432
458
 
459
+ const ALIGN_MAP: Record<string, string> = {
460
+ left: "LEFT",
461
+ center: "CENTER",
462
+ right: "RIGHT",
463
+ justify: "JUSTIFY",
464
+ distribute: "DISTRIBUTE",
465
+ distribute_space: "DISTRIBUTE_SPACE",
466
+ };
467
+
468
+ const V_ALIGN_MAP: Record<string, string> = {
469
+ baseline: "BASELINE",
470
+ top: "TOP",
471
+ center: "CENTER",
472
+ bottom: "BOTTOM",
473
+ };
474
+
475
+ const LINE_WRAP_MAP: Record<string, string> = {
476
+ break: "BREAK",
477
+ squeeze: "SQUEEZE",
478
+ keep: "KEEP",
479
+ };
480
+
433
481
  function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
434
482
  const key = paraPrKey(props);
435
483
  const existing = ctx.paraPrMap.get(key);
436
484
  if (existing !== undefined) return existing;
437
485
 
438
486
  const id = ctx.paraPrs.length;
487
+
488
+ const alignStr = props.align ? (ALIGN_MAP[props.align] ?? "LEFT") : "LEFT";
489
+ const verAlignStr = props.verAlign ? (V_ALIGN_MAP[props.verAlign] ?? "BASELINE") : "BASELINE";
490
+ const lineWrapStr = props.lineWrap ? (LINE_WRAP_MAP[props.lineWrap] ?? "BREAK") : "BREAK";
491
+
439
492
  const def: ParaPrDef = {
440
493
  id,
441
- align: (props.align ?? "left").toUpperCase(),
442
- leftHwp: Metric.ptToHwp(props.indentPt ?? 0),
494
+ align: alignStr,
495
+ verAlign: verAlignStr,
496
+ lineWrap: lineWrapStr,
497
+ leftHwp: Metric.ptToHwp(props.leftMargin ?? 0),
443
498
  rightHwp: Metric.ptToHwp(props.indentRightPt ?? 0),
444
499
  intentHwp: Metric.ptToHwp(props.firstLineIndentPt ?? 0),
445
500
  prevHwp: Metric.ptToHwp(props.spaceBefore ?? 0),
446
501
  nextHwp: Metric.ptToHwp(props.spaceAfter ?? 0),
447
- lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
502
+ lineSpacing: props.lineHeightFixed ? 0 : (props.lineHeight ? Math.round(props.lineHeight * 100) : 160),
503
+ lineSpacingFixed: props.lineHeightFixed ? Metric.ptToHwp(props.lineHeightFixed) : undefined,
448
504
  };
449
505
  if (props.listOrd !== undefined) {
450
506
  def.listType = props.listOrd ? "DIGIT" : "BULLET";
@@ -570,8 +626,8 @@ export class HwpxEncoder extends BaseEncoder {
570
626
  const sheet = doc.kids[0];
571
627
  const dims = normalizeDims(sheet?.dims ?? A4);
572
628
 
573
- const safeML = dims.ml > 0 ? dims.ml : 70.87;
574
- const safeMR = dims.mr > 0 ? dims.mr : 70.87;
629
+ const safeML = (dims.ml !== undefined && dims.ml >= 0) ? dims.ml : 70.87;
630
+ const safeMR = (dims.mr !== undefined && dims.mr >= 0) ? dims.mr : 70.87;
575
631
  const availableWidth = Math.round(
576
632
  Metric.ptToHwp(dims.wPt) -
577
633
  Metric.ptToHwp(safeML) -
@@ -638,9 +694,9 @@ export class HwpxEncoder extends BaseEncoder {
638
694
  mime: "application/xml",
639
695
  },
640
696
  {
641
- name: "META-INF/container.rdf",
642
- data: this.stringToBytes(CONTAINER_RDF),
643
- mime: "application/rdf+xml",
697
+ name: "META-INF/manifest.xml",
698
+ data: this.stringToBytes(MANIFEST_XML),
699
+ mime: "application/xml",
644
700
  },
645
701
  {
646
702
  name: "Contents/content.hpf",
@@ -691,12 +747,14 @@ export class HwpxEncoder extends BaseEncoder {
691
747
 
692
748
  // ─── 상수 XML ────────────────────────────────────────────────
693
749
 
750
+ // namespace: 실제 HWP가 기대하는 2011 버전 네임스페이스 사용 (owpml.org/2024는 열리지 않음)
694
751
  const VERSION_XML =
695
752
  `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
696
- `<hv:HCFVersion xmlns:hv="http://www.owpml.org/owpml/2024/version" ` +
697
- `targetApplication="WORDPROCESSING" major="5" minor="1" micro="0" buildNumber="1" ` +
698
- `os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="11, 0, 0, 0"/>`;
753
+ `<hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" ` +
754
+ `tagetApplication="WORDPROCESSOR" major="5" minor="0" micro="5" buildNumber="0" ` +
755
+ `os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="9, 6, 1, 10097"/>`;
699
756
 
757
+ // container.rdf rootfile 항목 제거 — 실제 HWPX 파일 구조와 일치
700
758
  const CONTAINER_XML =
701
759
  `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
702
760
  `<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" ` +
@@ -704,19 +762,9 @@ const CONTAINER_XML =
704
762
  `<ocf:rootfiles>` +
705
763
  `<ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/>` +
706
764
  `<ocf:rootfile full-path="Preview/PrvText.txt" media-type="text/plain"/>` +
707
- `<ocf:rootfile full-path="META-INF/container.rdf" media-type="application/rdf+xml"/>` +
708
765
  `</ocf:rootfiles></ocf:container>`;
709
766
 
710
- const CONTAINER_RDF =
711
- `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
712
- `<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">` +
713
- `<rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/header.xml"/></rdf:Description>` +
714
- `<rdf:Description rdf:about="Contents/header.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#HeaderFile"/></rdf:Description>` +
715
- `<rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/section0.xml"/></rdf:Description>` +
716
- `<rdf:Description rdf:about="Contents/section0.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#SectionFile"/></rdf:Description>` +
717
- `<rdf:Description rdf:about=""><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#Document"/></rdf:Description>` +
718
- `</rdf:RDF>`;
719
-
767
+ // HWPX 파일은 container.rdf 대신 manifest.xml(빈 ODF 매니페스트) 사용
720
768
  const MANIFEST_XML =
721
769
  `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
722
770
  `<odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>`;
@@ -817,6 +865,73 @@ function buildBulletsXml(): string {
817
865
 
818
866
  // ─── header.xml ──────────────────────────────────────────────
819
867
 
868
+ /**
869
+ * Contents/header.xml 용 전역 구역 설정 리스트(secPrList)를 생성합니다.
870
+ */
871
+ /**
872
+ * 페이지 여백 (margin) 과 헤더/푸터 영역 (zone) 을 계산합니다.
873
+ * HWPX spec 에서는 pagePr > margin 의 header/footer 가 헤더/푸터 영역의 높이 (zone height) 입니다.
874
+ * - headerZone: 용지 상단에서 헤더 영역 상단까지의 거리 (headerPt 가 없으면 0)
875
+ * - footerZone: 용지 하단에서 푸터 영역 하단까지의 거리 (footerPt 가 없으면 0)
876
+ * - mt/mb: 용지 상단/하단에서 본문 영역 상단/하단까지의 거리
877
+ */
878
+ function buildHeaderSecPrListXml(dims: PageDims): string {
879
+ const wHwp = Metric.ptToHwp(dims.wPt);
880
+ const hHwp = Metric.ptToHwp(dims.hPt);
881
+ const ml = Metric.ptToHwp(dims.ml);
882
+ const mr = Metric.ptToHwp(dims.mr);
883
+ const mt = Metric.ptToHwp(dims.mt);
884
+ const mb = Metric.ptToHwp(dims.mb);
885
+
886
+ // 헤더/푸터 영역 높이 계산 (HWPX 는 zone height 를 직접 지정)
887
+ // headerPt 가 설정되어 있으면 그 값을 zone height 로 사용, 없으면 0 으로 설정
888
+ const headerZone = dims.headerPt !== undefined && dims.headerPt > 0
889
+ ? Metric.ptToHwp(dims.headerPt)
890
+ : 0;
891
+ const footerZone = dims.footerPt !== undefined && dims.footerPt > 0
892
+ ? Metric.ptToHwp(dims.footerPt)
893
+ : 0;
894
+
895
+ const pageBorderFill =
896
+ `<hh:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
897
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
898
+ `</hh:pageBorderFill>` +
899
+ `<hh:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
900
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
901
+ `</hh:pageBorderFill>` +
902
+ `<hh:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
903
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
904
+ `</hh:pageBorderFill>`;
905
+
906
+ return (
907
+ `<hh:secPrList itemCnt="1">` +
908
+ `<hh:secPr id="0" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">` +
909
+ `<hh:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/>` +
910
+ `<hh:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
911
+ `<hh:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
912
+ `<hh:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>` +
913
+ `<hh:pagePr landscape="WIDELY" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
914
+ `<hh:margin header="${headerZone}" footer="${footerZone}" gutter="0" left="${ml}" right="${mr}" top="${mt}" bottom="${mb}"/>` +
915
+ `</hh:pagePr>` +
916
+ `<hh:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>` +
917
+ `<hh:footNotePr><hh:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
918
+ `<hh:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
919
+ `<hh:noteSpacing betweenNotes="283" belowLine="0" aboveLine="1000"/>` +
920
+ `<hh:numbering type="CONTINUOUS" newNum="1"/>` +
921
+ `<hh:placement place="EACH_COLUMN" beneathText="0"/>` +
922
+ `</hh:footNotePr>` +
923
+ `<hh:endNotePr><hh:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
924
+ `<hh:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
925
+ `<hh:noteSpacing betweenNotes="0" belowLine="0" aboveLine="1000"/>` +
926
+ `<hh:numbering type="CONTINUOUS" newNum="1"/>` +
927
+ `<hh:placement place="END_OF_DOCUMENT" beneathText="0"/>` +
928
+ `</hh:endNotePr>` +
929
+ pageBorderFill +
930
+ `</hh:secPr>` +
931
+ `</hh:secPrList>`
932
+ );
933
+ }
934
+
820
935
  function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
821
936
  // 언어별 폰트 (LangFontBank → XML)
822
937
  const fontFacesXml = ctx.fontBank.toXml();
@@ -846,14 +961,18 @@ function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
846
961
  `</hh:charPr>`;
847
962
  }
848
963
 
849
- // paraPr 목록
964
+ // paraPr 목록 (동적 정렬 및 줄바꿈 적용)
850
965
  let paraPrXml = "";
851
966
  for (const pp of ctx.paraPrs) {
967
+ const ver = pp.verAlign ?? "BASELINE";
968
+ const wrap = pp.lineWrap ?? "BREAK";
969
+ const lsType = pp.lineSpacingFixed !== undefined ? "FIXED" : "PERCENT";
970
+ const lsValue = pp.lineSpacingFixed !== undefined ? pp.lineSpacingFixed : pp.lineSpacing;
852
971
  paraPrXml +=
853
972
  `<hh:paraPr id="${pp.id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">` +
854
- `<hh:align horizontal="${pp.align}" vertical="BASELINE"/>` +
973
+ `<hh:align horizontal="${pp.align}" vertical="${ver}"/>` +
855
974
  `<hh:heading type="NONE" idRef="0" level="0"/>` +
856
- `<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>` +
975
+ `<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="${wrap}"/>` +
857
976
  `<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>` +
858
977
  `<hh:margin>` +
859
978
  `<hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/>` +
@@ -862,7 +981,7 @@ function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
862
981
  `<hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/>` +
863
982
  `<hc:next value="${pp.nextHwp}" unit="HWPUNIT"/>` +
864
983
  `</hh:margin>` +
865
- `<hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/>` +
984
+ `<hh:lineSpacing type="${lsType}" value="${lsValue}" unit="HWPUNIT"/>` +
866
985
  `<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>` +
867
986
  `</hh:paraPr>`;
868
987
  }
@@ -896,45 +1015,8 @@ function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
896
1015
  `<hh:paraProperties itemCnt="${ctx.paraPrs.length}">${paraPrXml}</hh:paraProperties>` +
897
1016
  stylesXml +
898
1017
  `</hh:refList>` +
899
- `<hh:compatibleDocument targetProgram="MS_WORD">
900
- <hh:layoutCompatibility>
901
- <hh:applyFontWeightToBold />
902
- <hh:useInnerUnderline />
903
- <hh:useLowercaseStrikeout />
904
- <hh:extendLineheightToOffset />
905
- <hh:treatQuotationAsLatin />
906
- <hh:doNotAlignWhitespaceOnRight />
907
- <hh:doNotAdjustWordInJustify />
908
- <hh:baseCharUnitOnEAsian />
909
- <hh:baseCharUnitOfIndentOnFirstChar />
910
- <hh:adjustLineheightToFont />
911
- <hh:adjustBaselineInFixedLinespacing />
912
- <hh:applyPrevspacingBeneathObject />
913
- <hh:applyNextspacingOfLastPara />
914
- <hh:adjustParaBorderfillToSpacing />
915
- <hh:connectParaBorderfillOfEqualBorder />
916
- <hh:adjustParaBorderOffsetWithBorder />
917
- <hh:extendLineheightToParaBorderOffset />
918
- <hh:applyParaBorderToOutside />
919
- <hh:applyMinColumnWidthTo1mm />
920
- <hh:applyTabPosBasedOnSegment />
921
- <hh:breakTabOverLine />
922
- <hh:adjustVertPosOfLine />
923
- <hh:doNotAlignLastForbidden />
924
- <hh:adjustMarginFromAdjustLineheight />
925
- <hh:baseLineSpacingOnLineGrid />
926
- <hh:applyCharSpacingToCharGrid />
927
- <hh:doNotApplyGridInHeaderFooter />
928
- <hh:applyExtendHeaderFooterEachSection />
929
- <hh:doNotApplyLinegridAtNoLinespacing />
930
- <hh:doNotAdjustEmptyAnchorLine />
931
- <hh:overlapBothAllowOverlap />
932
- <hh:extendVertLimitToPageMargins />
933
- <hh:doNotHoldAnchorOfTable />
934
- <hh:doNotFormattingAtBeneathAnchor />
935
- <hh:adjustBaselineOfObjectToBottom />
936
- </hh:layoutCompatibility>
937
- </hh:compatibleDocument>` +
1018
+ buildHeaderSecPrListXml(dims) +
1019
+ `<hh:compatibleDocument targetProgram="HWP201X"><hh:layoutCompatibility/></hh:compatibleDocument>` +
938
1020
  `<hh:docOption><hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/></hh:docOption>` +
939
1021
  `<hh:trackchageConfig flags="56"/>` +
940
1022
  `</hh:head>`
@@ -956,8 +1038,8 @@ function buildHeaderFooterRunXml(
956
1038
  const availW = ctx.availableWidth;
957
1039
  const mtHwp = Metric.ptToHwp(dims.mt);
958
1040
  const mbHwp = Metric.ptToHwp(dims.mb);
959
- const headerZoneH = dims.headerPt ? Math.max(100, mtHwp - Metric.ptToHwp(dims.headerPt)) : 3600;
960
- const footerZoneH = dims.footerPt ? Math.max(100, mbHwp - Metric.ptToHwp(dims.footerPt)) : 3600;
1041
+ const headerZoneH = dims.headerPt ? Metric.ptToHwp(dims.headerPt) : 4252; // 기본값 15mm
1042
+ const footerZoneH = dims.footerPt ? Metric.ptToHwp(dims.footerPt) : 4252; // 기본값 15mm
961
1043
 
962
1044
  let inner = "";
963
1045
 
@@ -967,6 +1049,7 @@ function buildHeaderFooterRunXml(
967
1049
 
968
1050
  // 2. 헤더들 생성
969
1051
  for (const [type, paras] of Object.entries(headers)) {
1052
+ if (!Array.isArray(paras) || paras.length === 0) continue;
970
1053
  const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
971
1054
  const savedId = ctx.nextElementId;
972
1055
  ctx.nextElementId = 0;
@@ -986,6 +1069,7 @@ function buildHeaderFooterRunXml(
986
1069
 
987
1070
  // 3. 푸터들 생성
988
1071
  for (const [type, paras] of Object.entries(footers)) {
1072
+ if (!Array.isArray(paras) || paras.length === 0) continue;
989
1073
  const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
990
1074
  const savedId = ctx.nextElementId;
991
1075
  ctx.nextElementId = 0;
@@ -1028,11 +1112,12 @@ function buildSectionXml(
1028
1112
  for (let i = 0; i < kids.length; i++) {
1029
1113
  const kid = kids[i];
1030
1114
  const isFirst = i === 0;
1031
- const curSecPr = isFirst ? secPrXml : "";
1115
+ // secPr은 hs:sec 하위에 직접 기입하므로 문단 내에는 기입하지 않습니다.
1116
+ const curSecPr = "";
1032
1117
  const curHfRun = isFirst ? hfRunXml : "";
1033
1118
 
1034
1119
  if (kid.tag === "para") {
1035
- const { xml, nextVertPos } = encodeParaPositioned(
1120
+ const { xml, nextVertPos, hasPageBreak } = encodeParaPositioned(
1036
1121
  kid,
1037
1122
  ctx,
1038
1123
  vertPos,
@@ -1041,9 +1126,9 @@ function buildSectionXml(
1041
1126
  curHfRun,
1042
1127
  );
1043
1128
  contentXml += xml;
1044
- vertPos = nextVertPos;
1129
+ vertPos = nextVertPos; // 페이지 브레이크 발생 시에도 누적 절대 좌표 유지
1045
1130
  } else if (kid.tag === "grid") {
1046
- const { xml, nextVertPos } = encodeGridPositioned(
1131
+ const { xml, nextVertPos, hasPageBreak } = encodeGridPositioned(
1047
1132
  kid,
1048
1133
  ctx,
1049
1134
  vertPos,
@@ -1051,7 +1136,7 @@ function buildSectionXml(
1051
1136
  curHfRun,
1052
1137
  );
1053
1138
  contentXml += xml;
1054
- vertPos = nextVertPos;
1139
+ vertPos = nextVertPos; // 페이지 브레이크 발생 시에도 누적 절대 좌표 유지
1055
1140
  }
1056
1141
  }
1057
1142
 
@@ -1062,14 +1147,14 @@ function buildSectionXml(
1062
1147
  const { xml: linesegXml } = buildLinesegarray(" ", 0, fs, vs / (fs / 100), availWidth);
1063
1148
  contentXml =
1064
1149
  `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
1065
- secPrXml +
1066
1150
  hfRunXml +
1067
1151
  `<hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run>` +
1068
1152
  linesegXml +
1069
1153
  `</hp:p>`;
1070
1154
  }
1071
1155
 
1072
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS} xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">${contentXml}</hs:sec>`;
1156
+ // hs:sec 바로 아래 직계 자식으로 secPrXml 기입!
1157
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS} xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">${secPrXml}${contentXml}</hs:sec>`;
1073
1158
  }
1074
1159
 
1075
1160
  function buildSecPrXml(dims: PageDims): string {
@@ -1081,12 +1166,8 @@ function buildSecPrXml(dims: PageDims): string {
1081
1166
  const mb = Metric.ptToHwp(dims.mb);
1082
1167
  // HWPX margin header/footer = header/footer ZONE HEIGHT (not distance from paper edge)
1083
1168
  // = top_hwp - header_from_top_hwp (and bottom_hwp - footer_from_bottom_hwp)
1084
- const headerZone = dims.headerPt
1085
- ? Math.max(0, mt - Metric.ptToHwp(dims.headerPt))
1086
- : 0;
1087
- const footerZone = dims.footerPt
1088
- ? Math.max(0, mb - Metric.ptToHwp(dims.footerPt))
1089
- : 0;
1169
+ const headerZone = dims.headerPt ? Metric.ptToHwp(dims.headerPt) : 0;
1170
+ const footerZone = dims.footerPt ? Metric.ptToHwp(dims.footerPt) : 0;
1090
1171
 
1091
1172
  const pageBorderFill =
1092
1173
  `<hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
@@ -1105,7 +1186,7 @@ function buildSecPrXml(dims: PageDims): string {
1105
1186
  `<hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
1106
1187
  `<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
1107
1188
  `<hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>` +
1108
- `<hp:pagePr landscape="${dims.wPt >= dims.hPt ? 'WIDELY' : 'NARROWLY'}" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
1189
+ `<hp:pagePr landscape="WIDELY" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
1109
1190
  `<hp:margin header="${headerZone}" footer="${footerZone}" gutter="0" left="${ml}" right="${mr}" top="${mt}" bottom="${mb}"/>` +
1110
1191
  `</hp:pagePr>` +
1111
1192
  `<hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>` +
@@ -1140,18 +1221,67 @@ function buildLinesegarray(
1140
1221
  const spacing = vertsizeLine - fontSize;
1141
1222
  const baseline = Math.round(fontSize * 0.83);
1142
1223
 
1143
- // 글자 너비 추정 (영문 0.47, 한글 1.0)
1144
- const isKorean = /[\uAC00-\uD7A3\u3131-\u318E]/.test(text);
1145
- const charW = isKorean ? fontSize : Math.round(fontSize * 0.47);
1146
- const charsPerLine = Math.max(1, Math.floor(horzSize / charW));
1147
- const lineCount = text.length === 0 ? 1 : Math.ceil(text.length / charsPerLine);
1224
+ if (text.length === 0) {
1225
+ const xml = `<hp:linesegarray>` +
1226
+ `<hp:lineseg textpos="0" vertpos="${vertPosStart}" vertsize="${vertsizeLine}" ` +
1227
+ `textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
1228
+ `horzpos="0" horzsize="${horzSize}" flags="${LINESEG_FLAGS_FIRST}"/>` +
1229
+ `</hp:linesegarray>`;
1230
+ return { xml, totalHeight: vertsizeLine };
1231
+ }
1232
+
1233
+ // 문자 단위 정밀 가로 폭 계산 및 자동 줄바꿈 알고리즘 (개행 문자 지원)
1234
+ const lines: { startPos: number; width: number }[] = [];
1235
+ let currentLineWidth = 0;
1236
+ let lineStartIdx = 0;
1237
+
1238
+ for (let i = 0; i < text.length; i++) {
1239
+ const charCode = text.charCodeAt(i);
1240
+
1241
+ // 개행 문자 (\n 또는 \r) 감지 시, 현재 줄 세그먼트를 무조건 마감하고 새로운 줄 시작
1242
+ if (charCode === 10 || charCode === 13) {
1243
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1244
+ lineStartIdx = i + 1;
1245
+ currentLineWidth = 0;
1246
+ continue;
1247
+ }
1248
+
1249
+ let charW = fontSize * 0.55; // 기본값 (영문 소문자, 숫자 등)
1250
+
1251
+ if (charCode >= 0xac00 && charCode <= 0xd7a3) {
1252
+ charW = fontSize; // 한글
1253
+ } else if (charCode >= 0x3130 && charCode <= 0x318f) {
1254
+ charW = fontSize; // 한글 자모
1255
+ } else if (charCode >= 0x4e00 && charCode <= 0x9fff) {
1256
+ charW = fontSize; // 한자
1257
+ } else if (charCode >= 65 && charCode <= 90) {
1258
+ charW = fontSize * 0.65; // 영문 대문자
1259
+ } else if (charCode === 32) {
1260
+ charW = fontSize * 0.32; // 공백
1261
+ } else if (charCode > 255) {
1262
+ charW = fontSize; // 기타 전각 문자
1263
+ } else {
1264
+ charW = fontSize * 0.42; // 기타 특수기호
1265
+ }
1266
+
1267
+ if (currentLineWidth + charW > horzSize && i > lineStartIdx) {
1268
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1269
+ lineStartIdx = i;
1270
+ currentLineWidth = charW;
1271
+ } else {
1272
+ currentLineWidth += charW;
1273
+ }
1274
+ }
1275
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1148
1276
 
1149
- // 성능 최적화: 문자열 병합 대신 배열 수집 후 join 사용 (O(n²) → O(n))
1277
+ const lineCount = lines.length;
1150
1278
  const linesegParts: string[] = [];
1279
+
1151
1280
  for (let i = 0; i < lineCount; i++) {
1152
1281
  const flags = i === 0 ? LINESEG_FLAGS_FIRST : LINESEG_FLAGS_OTHER;
1282
+ const textpos = lines[i].startPos;
1153
1283
  linesegParts.push(
1154
- `<hp:lineseg textpos="${i * charsPerLine}" ` +
1284
+ `<hp:lineseg textpos="${textpos}" ` +
1155
1285
  `vertpos="${vertPosStart + i * vertsizeLine}" ` +
1156
1286
  `vertsize="${vertsizeLine}" textheight="${fontSize}" ` +
1157
1287
  `baseline="${baseline}" spacing="${spacing}" ` +
@@ -1172,7 +1302,13 @@ function extractParaText(para: ParaNode): string {
1172
1302
  const walk = (kids: any[]) => {
1173
1303
  for (const k of kids) {
1174
1304
  if (k.tag === "span") {
1175
- for (const c of k.kids) if (c.tag === "txt") text += c.content;
1305
+ for (const c of k.kids) {
1306
+ if (c.tag === "txt") {
1307
+ text += c.content;
1308
+ } else if (c.tag === "br") {
1309
+ text += "\n";
1310
+ }
1311
+ }
1176
1312
  } else if (k.tag === "link") {
1177
1313
  walk(k.kids);
1178
1314
  }
@@ -1201,7 +1337,7 @@ function encodeParaPositioned(
1201
1337
  secPr = "",
1202
1338
  availWidth?: number,
1203
1339
  hfRun = "",
1204
- ): { xml: string; nextVertPos: number } {
1340
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1205
1341
  // ✅ 표(Grid)를 포함하는 단락인지 확인
1206
1342
  const gridKid = para.kids.find((k): k is GridNode => k.tag === "grid");
1207
1343
  if (gridKid) {
@@ -1268,7 +1404,7 @@ function encodeParaPositioned(
1268
1404
  linesegXml +
1269
1405
  `</hp:p>`;
1270
1406
 
1271
- return { xml, nextVertPos: vertPos + totalHeight };
1407
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak };
1272
1408
  }
1273
1409
 
1274
1410
  /** ✅ 가이드 준수: 표를 포함하는 단락 인코딩 */
@@ -1279,7 +1415,7 @@ function encodeTablePara(
1279
1415
  vertPos: number,
1280
1416
  secPr: string,
1281
1417
  hfRun: string,
1282
- ): { xml: string; nextVertPos: number } {
1418
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1283
1419
  const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props)) ?? 0;
1284
1420
 
1285
1421
  // 표 알맹이 생성 (기존 로직 재사용)
@@ -1298,16 +1434,23 @@ function encodeTablePara(
1298
1434
  `horzpos="0" horzsize="${ctx.availableWidth}" flags="1441792"/>` +
1299
1435
  `</hp:linesegarray>`;
1300
1436
 
1437
+ const hasPageBreak = para.kids.some(
1438
+ (k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
1439
+ );
1440
+
1441
+ const runId = ctx.nextElementId++;
1301
1442
  const xml =
1302
1443
  `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="${paraPrId}" styleIDRef="0" ` +
1303
- `pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
1444
+ `pageBreak="${hasPageBreak ? 1 : 0}" columnBreak="0" merged="0" paraTcId="0">` +
1304
1445
  secPr +
1446
+ `<hp:run id="${runId}" charPrIDRef="0" charTcId="0">` +
1305
1447
  gridXml +
1448
+ `</hp:run>` +
1306
1449
  hfRun +
1307
1450
  linesegXml +
1308
1451
  `</hp:p>`;
1309
1452
 
1310
- return { xml, nextVertPos: vertPos + totalHeight };
1453
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak };
1311
1454
  }
1312
1455
 
1313
1456
  function encodeCodeBlockPositioned(
@@ -1318,7 +1461,7 @@ function encodeCodeBlockPositioned(
1318
1461
  fontSize: number,
1319
1462
  spacing: number,
1320
1463
  vertSize: number,
1321
- ): { xml: string; nextVertPos: number } {
1464
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1322
1465
  const codeBfId = ctx.borderFillBank.addUniform(
1323
1466
  { kind: "solid", pt: 0.5, color: "aaaaaa" },
1324
1467
  "f4f4f4",
@@ -1358,7 +1501,7 @@ function encodeCodeBlockPositioned(
1358
1501
  linesegXml +
1359
1502
  `</hp:p>`;
1360
1503
 
1361
- return { xml, nextVertPos: vertPos + totalHeight };
1504
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak: false };
1362
1505
  }
1363
1506
 
1364
1507
  function encodeParaKids(kids: ParaNode["kids"], ctx: HwpxCtx): string {
@@ -1421,7 +1564,7 @@ function encodeRunInner(span: SpanNode): string {
1421
1564
  const content = esc(kid.content);
1422
1565
  if (content) xml += `<hp:t xml:space="preserve">${content}</hp:t>`;
1423
1566
  } else if (kid.tag === "br") {
1424
- xml += `<hp:t xml:space="preserve">\n</hp:t>`;
1567
+ xml += `<hp:br/>`;
1425
1568
  } else if (kid.tag === "pagenum") {
1426
1569
  const fmt = (kid as any).format === "roman" ? "ROMAN_LOWER"
1427
1570
  : (kid as any).format === "romanCaps" ? "ROMAN_UPPER" : "DIGIT";
@@ -1569,7 +1712,7 @@ function encodeGridPositioned(
1569
1712
  vertPos: number,
1570
1713
  secPr = "",
1571
1714
  hfRun = "",
1572
- ): { xml: string; nextVertPos: number } {
1715
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1573
1716
  const { xml: gridXml, height: tblHeight } = buildGridXml(grid, ctx);
1574
1717
  const totalHeight = Math.max(1600, tblHeight);
1575
1718
  const fontSize = 1000;
@@ -1583,15 +1726,18 @@ function encodeGridPositioned(
1583
1726
  `horzpos="0" horzsize="${ctx.availableWidth}" flags="${LINESEG_FLAGS_FIRST}"/>` +
1584
1727
  `</hp:linesegarray>`;
1585
1728
 
1729
+ const runId = ctx.nextElementId++;
1586
1730
  const xml =
1587
1731
  `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
1588
1732
  secPr +
1589
1733
  hfRun +
1734
+ `<hp:run id="${runId}" charPrIDRef="0" charTcId="0">` +
1590
1735
  gridXml +
1736
+ `</hp:run>` +
1591
1737
  linesegXml +
1592
1738
  `</hp:p>`;
1593
1739
 
1594
- return { xml, nextVertPos: vertPos + totalHeight };
1740
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak: false };
1595
1741
  }
1596
1742
  function buildGridXml(
1597
1743
  grid: GridNode,