diff --git a/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py b/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py
index 1bf3e38e9..f52a7caf7 100644
--- a/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py
+++ b/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py
@@ -16,6 +16,59 @@ def _normalize_trailing_ws(text: str) -> str:
return re.sub(r'[ \t]+$', '', text, flags=re.MULTILINE)
+def _normalize_consecutive_spaces_in_text(text: str) -> str:
+ """코드 블록 외 텍스트에서 인라인 연속 공백을 단일 공백으로 정규화한다.
+
+ Forward converter가 XHTML에서 생성한 MDX는 단일 공백만 사용하므로,
+ improved.mdx의 이중 공백(예: **bold** :, * `item`)과의 차이를 무시한다.
+ 줄 앞 들여쓰기(leading whitespace)는 보존한다.
+
+ fenced code block(```) 내부는 변경하지 않는다.
+ 인라인 code span(` `` `) 내부의 연속 공백도 정규화 대상에 포함된다.
+ 이는 HTML 렌더링 시 a b와 a b가 동일하게
+ 표시되는 것과 일치한다.
+ """
+ lines = text.split('\n')
+ result = []
+ in_code_block = False
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith('```'):
+ in_code_block = not in_code_block
+ result.append(line)
+ continue
+ if in_code_block:
+ result.append(line)
+ continue
+ leading = len(line) - len(line.lstrip(' \t'))
+ rest = re.sub(r' {2,}', ' ', line[leading:])
+ result.append(line[:leading] + rest)
+ return '\n'.join(result)
+
+
+def _normalize_br_space(text: str) -> str:
+ """
앞의 공백을 제거한다.
+
+ Forward converter가 list item 구성 시 ' '.join(li_itself)로
+
앞에 공백을 추가하므로, 비교 시 이를 제거한다.
+ """
+ return re.sub(r' +(
)', r'\1', text)
+
+
+def _apply_minimal_normalizations(text: str) -> str:
+ """항상 적용하는 최소 정규화 (strict/lenient 모드 공통).
+
+ forward converter의 체계적 출력 특성에 의한 차이만 처리한다:
+ - 인라인 이중 공백 → 단일 공백 (_normalize_consecutive_spaces_in_text)
+ -
앞 공백 제거 (_normalize_br_space)
+
+ lenient 모드에서는 이 정규화 이후 _apply_normalizations가 추가로 적용된다.
+ """
+ text = _normalize_consecutive_spaces_in_text(text)
+ text = _normalize_br_space(text)
+ return text
+
+
_MONTH_KO_TO_EN = {
'01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr',
'05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug',
@@ -194,11 +247,11 @@ def verify_roundtrip(
) -> VerifyResult:
"""두 MDX 문자열의 일치를 검증한다.
- 기본 동작(엄격 모드): 정규화 없이 문자 그대로 비교한다.
- 모든 공백, 줄바꿈, 문자가 동일해야 PASS.
+ 기본 동작(엄격 모드): forward converter의 체계적 차이(이중 공백,
앞 공백)를
+ 정규화한 후 문자 그대로 비교한다. 그 외 공백, 줄바꿈, 문자가 동일해야 PASS.
- lenient=True(관대 모드): trailing whitespace, 날짜 형식 등 XHTML↔MDX 변환기
- 한계에 의한 차이를 정규화한 후 전체 행에서 exact match를 검증한다.
+ lenient=True(관대 모드): trailing whitespace, 날짜 형식, 테이블 패딩 등
+ XHTML↔MDX 변환기 한계에 의한 추가 차이를 정규화한 후 exact match를 검증한다.
Args:
expected_mdx: 개선 MDX (의도한 결과)
@@ -208,6 +261,10 @@ def verify_roundtrip(
Returns:
VerifyResult: passed=True면 통과, 아니면 diff_report 포함
"""
+ # 항상 최소 정규화 적용 (forward converter 특성에 의한 체계적 차이 처리)
+ expected_mdx = _apply_minimal_normalizations(expected_mdx)
+ actual_mdx = _apply_minimal_normalizations(actual_mdx)
+
if lenient:
expected_mdx = _apply_normalizations(expected_mdx)
actual_mdx = _apply_normalizations(actual_mdx)
diff --git a/confluence-mdx/tests/reverse-sync/pages.yaml b/confluence-mdx/tests/reverse-sync/pages.yaml
index ae6566efd..63d53796d 100644
--- a/confluence-mdx/tests/reverse-sync/pages.yaml
+++ b/confluence-mdx/tests/reverse-sync/pages.yaml
@@ -99,7 +99,7 @@
description: '**예시:** (이중 공백) 형태가 라운드트립 후 **예시:** (단일 공백)으로 변환됨.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: 'Bold 뒤 이중 공백 소실 (예시: → 예시:)'
mdx_path: administrator-manual/general/company-management/general.mdx
@@ -183,7 +183,7 @@
description: '**Request Header (JSON)** : 형태가 **Request Header (JSON)** : 로 변환됨.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: Bold 뒤 이중 공백 소실 (Request Header (JSON) :)
mdx_path: administrator-manual/general/company-management/channels.mdx
@@ -380,7 +380,7 @@
description: '**Enable Attribute Synchronization :** (이중 공백) 및 **Dry Run :** 형태가 단일 공백으로 변환됨. 2개 항목 영향.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: 'Bold 뒤 이중 공백 소실 (Enable Attribute Synchronization : , Dry Run : )'
mdx_path: administrator-manual/general/user-management/authentication/integrating-with-ldap.mdx
@@ -743,7 +743,7 @@
description: '* `+Add` 버튼 형태에서 목록 마커 뒤 이중 공백이 단일 공백으로 변환됨. 또한 **Access Token Timeout (Minutes)** : 형태의 이중 공백도 소실됨.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: 목록 항목 앞 이중 공백 소실 (* `+Add` → * `+Add`)
mdx_path: administrator-manual/general/system/integrations/oauth-client-application.mdx
@@ -775,7 +775,7 @@
동일 패턴)
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: 'Bold 뒤 이중 공백 소실 (Enable Attribute Synchronization : )'
mdx_path: administrator-manual/general/system/integrations/identity-providers.mdx
@@ -1113,7 +1113,7 @@
description: '팝업이 나타나면 *DELETE* 문구 형태에서 이탤릭 앞뒤 이중 공백이 단일 공백으로 변환됨.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: Italic 앞뒤 공백 소실 ( *DELETE* → *DELETE*)
mdx_path: administrator-manual/kubernetes/connection-management/cloud-providers.mdx
@@ -1201,7 +1201,7 @@
description: '**(Title)** : 형태가 **(Title)** : 로 변환됨.
'
- expected_status: fail
+ expected_status: pass
failure_type: 13
label: Bold 뒤 이중 공백 소실 ((Title) :)
mdx_path: administrator-manual/kubernetes/k8s-access-control/policies.mdx
diff --git a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py
index fa199d368..30a327b7d 100644
--- a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py
+++ b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py
@@ -1,5 +1,10 @@
import pytest
-from reverse_sync.roundtrip_verifier import verify_roundtrip, VerifyResult
+from reverse_sync.roundtrip_verifier import (
+ verify_roundtrip,
+ VerifyResult,
+ _normalize_consecutive_spaces_in_text,
+ _normalize_br_space,
+)
def test_identical_mdx_passes():
@@ -87,3 +92,94 @@ def test_strict_mode_fails_on_date_format():
actual_mdx="Jan 15, 2024\n",
)
assert result.passed is False
+
+
+# --- _normalize_consecutive_spaces_in_text 단위 테스트 ---
+
+
+class TestNormalizeConsecutiveSpaces:
+ def test_double_space_collapsed(self):
+ """이중 공백이 단일 공백으로 정규화된다."""
+ assert _normalize_consecutive_spaces_in_text("**bold** :") == "**bold** :"
+
+ def test_leading_indent_preserved(self):
+ """줄 앞 들여쓰기는 보존된다."""
+ assert _normalize_consecutive_spaces_in_text(" * item") == " * item"
+
+ def test_code_block_preserved(self):
+ """코드 블록 내부 연속 공백은 보존된다."""
+ text = "```\ncode with spaces\n```"
+ assert _normalize_consecutive_spaces_in_text(text) == text
+
+ def test_code_block_with_surrounding_text(self):
+ """코드 블록 전후 일반 텍스트의 이중 공백은 정규화되고, 블록 내부는 보존된다."""
+ text = "**bold** before\n```\ncode inside\n```\n**bold** after"
+ expected = "**bold** before\n```\ncode inside\n```\n**bold** after"
+ assert _normalize_consecutive_spaces_in_text(text) == expected
+
+ def test_inline_code_span_spaces_collapsed(self):
+ """인라인 code span 내부 연속 공백도 정규화 대상이다 (HTML 렌더링과 일치)."""
+ assert _normalize_consecutive_spaces_in_text("`code here`") == "`code here`"
+
+ def test_single_space_unchanged(self):
+ """단일 공백은 변경되지 않는다."""
+ assert _normalize_consecutive_spaces_in_text("a b c") == "a b c"
+
+ def test_three_spaces_collapsed_to_one(self):
+ """3개 이상의 공백도 단일 공백으로 정규화된다."""
+ assert _normalize_consecutive_spaces_in_text("a b") == "a b"
+
+
+# --- _normalize_br_space 단위 테스트 ---
+
+
+class TestNormalizeBrSpace:
+ def test_space_before_br_removed(self):
+ """
앞의 공백이 제거된다."""
+ assert _normalize_br_space("text
") == "text
"
+
+ def test_multiple_spaces_before_br_removed(self):
+ """
앞의 여러 공백도 제거된다."""
+ assert _normalize_br_space("text
") == "text
"
+
+ def test_br_with_space_inside_tag(self):
+ """
형식도 처리한다."""
+ assert _normalize_br_space("text
") == "text
"
+
+ def test_no_space_before_br_unchanged(self):
+ """
앞에 공백 없으면 변경하지 않는다."""
+ assert _normalize_br_space("text
") == "text
"
+
+ def test_text_after_br_unchanged(self):
+ """
뒤의 텍스트는 영향받지 않는다."""
+ assert _normalize_br_space("a
b") == "a
b"
+
+
+# --- verify_roundtrip에서의 최소 정규화 동작 ---
+
+
+def test_minimal_norm_double_space_passes():
+ """이중 공백 차이는 strict 모드에서도 정규화된다 (forward converter 특성)."""
+ result = verify_roundtrip(
+ expected_mdx="**bold** : value\n",
+ actual_mdx="**bold** : value\n",
+ )
+ assert result.passed is True
+
+
+def test_minimal_norm_br_space_passes():
+ """
앞 공백 차이는 strict 모드에서도 정규화된다."""
+ result = verify_roundtrip(
+ expected_mdx="item
next\n",
+ actual_mdx="item
next\n",
+ )
+ assert result.passed is True
+
+
+def test_strict_mode_still_fails_on_trailing_ws():
+ """strict 모드: trailing whitespace 차이는 여전히 실패한다 (최소 정규화 대상 아님)."""
+ result = verify_roundtrip(
+ expected_mdx="Paragraph. \n",
+ actual_mdx="Paragraph.\n",
+ )
+ assert result.passed is False