From 538fcacfabe8a4f9d190151a054a875d75d64cab Mon Sep 17 00:00:00 2001
From: Minjun Kim
Date: Fri, 2 Oct 2026 22:12:46 +0900
Subject: [PATCH 1/2] =?UTF-8?q?fix(core):=20=EC=97=AC=EB=8A=94=20=EC=AA=BD?=
=?UTF-8?q?=EC=9D=B4=20=EB=A7=89=ED=9E=8C=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20?=
=?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=A4=84=EC=9D=98=20=EA=B1=B0=EC=9A=B8=20?=
=?UTF-8?q?=EB=A7=88=EC=BB=A4=EC=99=80=20=EC=A7=9D=EC=A7=93=EB=8A=94?=
=?UTF-8?q?=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`값**(합계)**를` · `이름이**"홍길동"**이다` 처럼 여는 `**` 앞이 글자이고 뒤가 구두점이면 CommonMark 는 열지
않는다. 지금까지는 첫 `**` 를 추측으로 열었다가 블록 끝에서 버리고, 둘째 `**` 는 "추측은 닫지 않는다" 규칙에
걸려 글자로 남겼다 — 하나는 사라지고 하나만 보이는 가장 나쁜 결과였다. 이 프로젝트를 시작하게 한 모양 가운데
하나다.
- 막힌 여는 마커는 같은 줄에서 같은 길이 · 앞이 구두점인 마커(거울 모양)와 짝짓는다. 추측을 닫지 않는 규칙을
만든 글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다
- 짝 없는 막힌 마커는 버리지 않고 글자로 되돌린다 — `2**(n-1)` 이 `2(n-1)` 이 되던 것
- Rust · Go · 하네스 참조 모델 같이. 코퍼스 `hemmed-opener-before-punct`
- 출력은 닫는 쪽 장치를 그대로 탄다(GitHub ``, 슬랙 U+2060). 슬랙 · 노션에서 굵게로 실측(2026-10-02)
Co-Authored-By: Claude Opus 5.5 (1M context)
---
SPEC.md | 6 ++++
.../github-markdown.txt | 6 ++++
.../cases/hemmed-opener-before-punct/html.txt | 6 ++++
.../cases/hemmed-opener-before-punct/input.md | 6 ++++
.../notion-markdown.txt | 6 ++++
.../hemmed-opener-before-punct/plain.txt | 6 ++++
.../slack-markdown.txt | 6 ++++
.../telegram-html.txt | 6 ++++
.../cases/hemmed-opener-before-punct/why.md | 13 ++++++++
crates/mdwire-core/src/inline.rs | 33 +++++++++++++++++--
crates/mdwire-core/tests/render.rs | 15 +++++++++
crates/mdwire-harness/src/emphasis.rs | 32 ++++++++++++++----
go/inline.go | 24 ++++++++++++--
go/inline_test.go | 17 ++++++++++
14 files changed, 170 insertions(+), 12 deletions(-)
create mode 100644 corpus/cases/hemmed-opener-before-punct/github-markdown.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/html.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/input.md
create mode 100644 corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/plain.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/telegram-html.txt
create mode 100644 corpus/cases/hemmed-opener-before-punct/why.md
diff --git a/SPEC.md b/SPEC.md
index 768d8d2..375ded3 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -79,6 +79,12 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지
여럿이면 짝이 엇갈려 범위가 뒤집힌다(`DESIGN.md` 1절의 고장이 출력 쪽에서 난다). 그런 짝만
`` `` `` 로 낸다 — GFM 이 읽는 자리는 마커 그대로다.
+**여는 쪽이 막힌 강조**(`값**(합계)**를`, `이름이**"홍길동"**이다` — 여는 마커 앞이 글자, 뒤가 구두점)는 같은 줄에서
+같은 길이의 거울 모양(앞이 구두점) 마커와 짝짓는다(2026-10-02). 추측을 닫지 않는 규칙의 예외다 — 그 규칙을 만든
+글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다. 짝이 없으면 버리지 않고 글자로
+되돌린다(`2**(n-1)`). 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub ``, 슬랙은 여는 마커 뒤 · 닫는 마커
+앞에 U+2060. 슬랙 · 노션에서 굵게로 그리는 것을 실측했다.
+
**슬랙 `markdown_text` 도 같은 자리에서 깨진다**(실측 2026-10-01, `chat.postMessage`). 태그를 못
쓰니 그 짝만 못 읽히는 쪽 마커 안쪽에 U+2060(워드 조이너)을 끼운다.
닫는 마커 앞 글자가 공백도 구두점도 아니게 되어 닫힌다. 보이지 않고, U+200B 와 달리 그 자리에서
diff --git a/corpus/cases/hemmed-opener-before-punct/github-markdown.txt b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt
new file mode 100644
index 0000000..1fc5c0e
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt
@@ -0,0 +1,6 @@
+값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.
+
+짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
+**다음** 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/html.txt b/corpus/cases/hemmed-opener-before-punct/html.txt
new file mode 100644
index 0000000..17571a1
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/html.txt
@@ -0,0 +1,6 @@
+값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.
+
+짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값**(합계)
+다음 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/input.md b/corpus/cases/hemmed-opener-before-punct/input.md
new file mode 100644
index 0000000..58cacf6
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/input.md
@@ -0,0 +1,6 @@
+값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다.
+
+짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값**(합계)
+**다음** 줄이다.
diff --git a/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
new file mode 100644
index 0000000..b3672a3
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
@@ -0,0 +1,6 @@
+값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다.
+
+짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
+**다음** 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/plain.txt b/corpus/cases/hemmed-opener-before-punct/plain.txt
new file mode 100644
index 0000000..3bbe664
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/plain.txt
@@ -0,0 +1,6 @@
+값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.
+
+짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값**(합계)
+다음 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
new file mode 100644
index 0000000..dc62c20
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
@@ -0,0 +1,6 @@
+값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다.
+
+짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값**(합계)
+**다음** 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/telegram-html.txt b/corpus/cases/hemmed-opener-before-punct/telegram-html.txt
new file mode 100644
index 0000000..0884a38
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/telegram-html.txt
@@ -0,0 +1,6 @@
+값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.
+
+짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
+
+줄을 넘으면 짝짓지 않는다: 값**(합계)
+다음 줄이다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/why.md b/corpus/cases/hemmed-opener-before-punct/why.md
new file mode 100644
index 0000000..0b246a2
--- /dev/null
+++ b/corpus/cases/hemmed-opener-before-punct/why.md
@@ -0,0 +1,13 @@
+강조를 여는 `**` 앞에 낱말이 붙고 뒤가 구두점이면 CommonMark 는 그 마커를 열지 않는다 — `값**(합계)**를`,
+`이름이**"홍길동"**이다`. 닫는 쪽이 "구두점 + 조사"라 못 닫히는 모양(`punct-before-closing-emphasis`)의 거울이고,
+이 프로젝트를 시작하게 한 모양 가운데 하나다. 전에는 첫 `**` 를 추측으로 열었다가 블록 끝에서 버리고, 둘째
+`**` 는 추측을 닫지 않는 규칙 때문에 글자로 남겼다 — 하나는 사라지고 하나만 보이는 가장 나쁜 결과였다.
+
+**막힌 여는 마커는 같은 줄의 거울 모양 마커하고만 짝짓는다.** 같은 길이이고 앞이 구두점인 닫는 마커다. 추측을
+닫지 않는 규칙은 글롭(`underfront.* (4개)`)·주석(`/* */`)에서 나왔는데 그 둘은 여는 쪽 뒤가 공백이라 여기 걸리지
+않고, 마스킹 번호(`4***-****`)는 길이가 달라 걸리지 않는다. 줄을 넘으면 짝짓지 않는다.
+
+**짝 없는 막힌 마커는 버리지 않고 글자로 되돌린다.** `2**(n-1)` 의 `**` 는 거듭제곱이지 짝 잃은 마커가 아니다 —
+전에는 버려서 `2(n-1)` 이 됐다.
+
+출력: GitHub 은 ``, 슬랙은 여는 마커 뒤 · 닫는 마커 앞에 U+2060, 텔레그램은 태그.
diff --git a/crates/mdwire-core/src/inline.rs b/crates/mdwire-core/src/inline.rs
index 8f59ded..23eabc1 100644
--- a/crates/mdwire-core/src/inline.rs
+++ b/crates/mdwire-core/src/inline.rs
@@ -42,11 +42,16 @@ struct Open {
/// 더 긴 런(`***`)을 쪼갠 조각인가. 안 닫히면 버리지 않고 글자로 되돌린다 — 원래 한
/// 덩어리의 글자였다(마스킹 번호 `4***-…`).
split: bool,
+ /// **여는 쪽이 막힌 마커**인가 — 앞이 글자이고 뒤가 구두점이라 CommonMark 가 열지 않는 자리
+ /// (`값**(합계)**를`). 열린 줄 번호를 든다. 같은 줄에서 거울 모양으로 닫는 마커가 오면 짝을 맺는다.
+ hemmed: Option,
}
#[derive(Clone)]
pub(crate) struct Inline {
open: Vec,
+ /// 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다.
+ line: u32,
/// 줄을 넘어온 직전 글자. 블록 안에서 줄이 바뀌면 `'\n'` 이다.
/// 이 값이 있어야 **줄 첫머리의 마커가 닫기가 아니라는 판정**이 선다.
prev: Option,
@@ -81,6 +86,7 @@ impl Inline {
pub fn new() -> Self {
Self {
open: Vec::new(),
+ line: 0,
prev: None,
scratch: String::new(),
code_src: Vec::new(),
@@ -122,6 +128,7 @@ impl Inline {
/// 줄 하나가 끝났다. 다음 줄의 첫 글자에게 앞 글자는 줄바꿈이다.
pub fn end_line(&mut self) {
+ self.line += 1;
self.prev = Some('\n');
}
@@ -229,6 +236,7 @@ impl Inline {
after_space: prev.is_none_or(char::is_whitespace),
before: prev,
split: false,
+ hemmed: None,
});
i += run;
continue;
@@ -319,6 +327,20 @@ impl Inline {
// 끝에서 되돌린다.
// 앞이 글자인 마커(`조합**이`)도 추측을 닫지 않고, 열지도 않는다 — 열면
// 블록 끝까지 삼킨다. 글자다.
+ // **막힌 여는 마커의 거울 짝.** `값**(합계)**를` 의 첫 `**` 는 앞이 글자, 뒤가 구두점이라
+ // 열지 못하고 추측으로 열렸다. 같은 줄에서 같은 길이의 마커가 거울 모양(앞이 구두점)으로
+ // 오면 그 짝이다 — 닫는 쪽이 "구두점 + 조사" 인 모양을 닫아 주는 규칙의 거울이다. 글롭
+ // (`.* (4개)`)·주석(`/* */`)은 여는 쪽 뒤가 공백이라, 마스킹 번호(`4***-****`)는 길이가
+ // 달라 여기 걸리지 않는다.
+ Some(at)
+ if self.open[at].hemmed == Some(self.line)
+ && self.open[at].run == take
+ && prev.is_some_and(|p| !is_word_char(p) && !p.is_whitespace()) =>
+ {
+ self.open[at].guess = false;
+ self.after_close = next;
+ self.close_at(at, out, v)
+ }
Some(at) if self.open[at].guess && (!left || !after_space) => {
for _ in 0..take {
self.text_char(c, out, v);
@@ -355,6 +377,7 @@ impl Inline {
after_space,
before: prev,
split,
+ hemmed: None,
}),
// 열 수도 닫을 수도 없다. 일단 열어 두고 안 닫히면 글자로 되돌린다. 규칙 3.
None => self.open.push(Open {
@@ -366,6 +389,8 @@ impl Inline {
after_space,
before: prev,
split,
+ hemmed: (prev.is_some_and(is_word_char) && next.is_some_and(|n| !n.is_whitespace() && is_punct(n)))
+ .then_some(self.line),
}),
}
i += take;
@@ -437,12 +462,12 @@ impl Inline {
self.finalize(out, v, false);
}
let old = self.open.pop().expect("at 은 유효한 인덱스다");
- if old.run == 1 || (old.guess && (old.after_space || old.split)) {
+ if old.run == 1 || (old.guess && (old.after_space || old.split || old.hemmed.is_some())) {
insert_marker(out, old.at, old.ch, old.run, v, &mut self.repairs);
} else {
self.repairs.dropped_marker += 1;
}
- self.open.push(Open { emph, at: out.len(), run: take, ch: c, guess: false, after_space, before, split });
+ self.open.push(Open { emph, at: out.len(), run: take, ch: c, guess: false, after_space, before, split, hemmed: None });
}
fn prev_char(&self, line: &[char], i: usize) -> Option {
@@ -487,7 +512,8 @@ impl Inline {
// (`underfront.*`), 곱셈(`2 * 3`)으로 쓰이는 글자라 버리면 내용 손실이다.
// `**` 는 앞이 공백이었을 때만 되돌린다(`2 ** 3`). 앞이 글자인 `**` 가 홀로
// 남을 이유는 없다 — 짝 잃은 닫는 마커고, 되돌리면 출력에 마커가 남는다.
- if open.after_space || open.run == 1 || open.split {
+ // 막힌 여는 마커도 되돌린다 — 짝 없는 `2**(n-1)` 의 `**` 는 거듭제곱이지 짝 잃은 마커가 아니다.
+ if open.after_space || open.run == 1 || open.split || open.hemmed.is_some() {
insert_marker(out, open.at, open.ch, open.run, v, &mut self.repairs);
} else {
self.repairs.dropped_marker += 1;
@@ -734,6 +760,7 @@ impl Inline {
after_space: false,
before: self.prev_char(line, i),
split: false,
+ hemmed: None,
}),
}
self.prev = Some('>');
diff --git a/crates/mdwire-core/tests/render.rs b/crates/mdwire-core/tests/render.rs
index d4d2fc1..0bd321b 100644
--- a/crates/mdwire-core/tests/render.rs
+++ b/crates/mdwire-core/tests/render.rs
@@ -849,6 +849,21 @@ fn report_counts_what_the_channel_rewrote() {
assert_eq!(r("1) a\n2) b", Channel::SlackMarkdown).rewritten_bullet, 2);
}
+/// **여는 쪽이 막힌 강조는 같은 줄의 거울 모양 마커와 짝짓는다**(`값**(합계)**를`). 짝이 없으면 버리지 않고
+/// 글자로 되돌린다 — `2**(n-1)` 의 `**` 는 거듭제곱이다. 글롭 · 마스킹 번호 · 줄을 넘는 것은 그대로다.
+#[test]
+fn hemmed_opener_pairs_with_its_mirror_on_the_same_line() {
+ let t = |s| one(s, Channel::TelegramHtml);
+ assert_eq!(t("값**(합계)**를 본다"), "값(합계)를 본다");
+ assert_eq!(t("이름이**\"홍길동\"**이다"), "이름이\"홍길동\"이다");
+ assert_eq!(one("값**(합계)**를", Channel::GithubMarkdown), "값(합계)를");
+ assert_eq!(one("값**(합계)**를", Channel::SlackMarkdown), "값**\u{2060}(합계)\u{2060}**를");
+ assert_eq!(t("2**(n-1) 은 거듭제곱"), "2**(n-1) 은 거듭제곱");
+ assert_eq!(t("underfront.* (4개), minjunkim.* (3개)"), "underfront.* (4개), minjunkim.* (3개)");
+ assert_eq!(t("마스킹 4***-****-****-003* 번호"), "마스킹 4***-****-****-003* 번호");
+ assert_eq!(t("값**(합계)\n**다음** 줄"), "값**(합계)\n다음 줄");
+}
+
/// **GFM 이 마커를 못 읽는 자리의 강조는 태그로 낸다**(실측 2026-09-30). 닫는 `**` 앞이
/// 구두점이고 뒤에 조사가 붙으면 GFM 은 닫지 않아 별표가 글자로 남고, 여럿이면 범위가 뒤집힌다.
#[test]
diff --git a/crates/mdwire-harness/src/emphasis.rs b/crates/mdwire-harness/src/emphasis.rs
index 27b94b3..a833f11 100644
--- a/crates/mdwire-harness/src/emphasis.rs
+++ b/crates/mdwire-harness/src/emphasis.rs
@@ -267,6 +267,8 @@ struct Open {
/// 추측으로 연 것인가. 양쪽 다 공백이라 원래는 그냥 글자인 `**` 를
/// "줄바꿈에 걸린 강조일 것"으로 보고 연 경우다. 안 닫히면 되돌린다.
guess: bool,
+ /// 여는 쪽이 막힌 마커(`값**(합계)**를`)면 열린 줄 번호. 같은 줄의 거울 모양 마커와 짝짓는다. 규칙은 코어와 같다.
+ hemmed: Option,
buf: String,
}
@@ -276,8 +278,12 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
let mut root = String::new();
let mut i = 0;
+ let mut line = 0;
while i < ch.len() {
let c = ch[i];
+ if c == '\n' {
+ line += 1;
+ }
// **역슬래시 이스케이프가 먼저다.** `\_` 는 밑줄 한 글자지 강조 마커가 아니다.
// 코어가 그렇게 읽으므로 참조 구현도 같이 읽어야 한다.
@@ -407,7 +413,21 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
let right = !after_space && (!prev.is_some_and(is_punct) || next.is_none_or(|n| n.is_whitespace() || is_punct(n)));
let closes = mode != Mode::CommonMark || right;
+ // 여는 쪽이 막혔다 — 앞이 글자이고 뒤가 구두점. 규칙은 코어와 같다.
+ let hemmed = prev.is_some_and(|p| p.is_alphabetic() || p.is_ascii_digit())
+ && next.is_some_and(|n| !n.is_whitespace() && is_punct(n));
+
match open_same {
+ // 막힌 여는 마커의 거울 짝 — 같은 줄, 같은 길이, 앞이 구두점. 규칙은 코어와 같다.
+ Some(at)
+ if mode != Mode::CommonMark
+ && stack[at].hemmed == Some(line)
+ && stack[at].marker.chars().count() == take
+ && prev.is_some_and(|p| !(p.is_alphabetic() || p.is_ascii_digit()) && !p.is_whitespace()) =>
+ {
+ stack[at].guess = false;
+ close_to(&mut stack, &mut root, at, scan)
+ }
// 추측으로 연 것은 닫지 않는다 — 닫는 자리의 마커는 글자다. 규칙은 코어와 같다.
Some(at) if stack[at].guess && (!left || !after_space) => {
*scan.literal.entry(c).or_default() += take;
@@ -429,7 +449,7 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
*scan.literal.entry(c).or_default() += old.marker.chars().count();
let restored = format!("{}{}", old.marker, old.buf);
push_text(&mut stack, &mut root, &restored);
- stack.push(Open { kind, marker, guess: false, buf: String::new() });
+ stack.push(Open { kind, marker, guess: false, hemmed: None, buf: String::new() });
}
// 같은 종류가 열려 있고 앞이 공백이 아니면 닫는 자리다.
Some(at) if !after_space && closes => close_to(&mut stack, &mut root, at, scan),
@@ -447,7 +467,7 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
let buf = old.buf;
push_text(&mut stack, &mut root, &buf);
}
- stack.push(Open { kind, marker, guess: false, buf: String::new() });
+ stack.push(Open { kind, marker, guess: false, hemmed: None, buf: String::new() });
}
// 안쪽에 다른 종류가 열려 있으면 갈아 끼우지 못한다. 버린다.
Some(_) if left => {
@@ -461,12 +481,12 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
*scan.literal.entry(c).or_default() += take;
push_text(&mut stack, &mut root, &marker)
}
- Some(_) => stack.push(Open { kind, marker, guess: false, buf: String::new() }),
- None if left => stack.push(Open { kind, marker, guess: false, buf: String::new() }),
+ Some(_) => stack.push(Open { kind, marker, guess: false, hemmed: None, buf: String::new() }),
+ None if left => stack.push(Open { kind, marker, guess: false, hemmed: None, buf: String::new() }),
// 열 수도 닫을 수도 없다. 그래도 80열 wrap 이 `... **\n강조**` 를 만들어 낸다.
// 일단 열어 두고, 안 닫히면 글자로 되돌린다.
- None if mode == Mode::Repair => {
- stack.push(Open { kind, marker, guess: true, buf: String::new() })
+ None if mode == Mode::Repair || (mode == Mode::Strict && hemmed) => {
+ stack.push(Open { kind, marker, guess: true, hemmed: hemmed.then_some(line), buf: String::new() })
}
None => {
*scan.literal.entry(c).or_default() += take;
diff --git a/go/inline.go b/go/inline.go
index d947784..cfff3bc 100644
--- a/go/inline.go
+++ b/go/inline.go
@@ -37,10 +37,15 @@ type openMark struct {
// 더 긴 런(`***`)의 첫 조각인가. 안 닫히면 버리지 않고 글자로 되돌린다 — 원래 한 덩어리의
// 글자였다(마스킹 번호 `4***-…`).
split bool
+ // hemmed 는 여는 쪽이 막힌 마커인가다 — 앞이 글자이고 뒤가 구두점(`값**(합계)**를`). 열린 줄 번호에
+ // 1 을 더해 든다(0 은 아님). 같은 줄에서 거울 모양으로 닫는 마커가 오면 짝을 맺는다. 러스트 쪽과 같다.
+ hemmed uint32
}
type inline struct {
open []openMark
+ // 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다.
+ line uint32
// 줄을 넘어온 직전 글자. 블록 안에서 줄이 바뀌면 '\n' 이다.
prev rune
// 링크 텍스트를 렌더할 때만 쓰는 버퍼. 재사용해서 할당을 아낀다.
@@ -90,7 +95,10 @@ func (in *inline) shift(n int) {
}
// endLine: 줄 하나가 끝났다. 다음 줄의 첫 글자에게 앞 글자는 줄바꿈이다.
-func (in *inline) endLine() { in.prev = '\n' }
+func (in *inline) endLine() {
+ in.line++
+ in.prev = '\n'
+}
// noteRaw 는 블록 층이 out 에 바로 쓴 글자를 코드 스팬 내용에도 남긴다 — 줄 사이의 구분자는
// render 를 거치지 않는데, 되돌려 다시 읽을 때 두 줄이 한 줄로 붙으면 안 된다.
@@ -249,6 +257,12 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) {
// 추측으로 연 것은 닫지 않는다. 추측은 확정되지 않는다 — 닫아 주면 여는 쪽은 사라지고
// 닫는 쪽만 없어져 `underfront.*`·`/* 주석 */` 의 별표가 없어졌다. 앞이 글자인 마커도
// 추측을 닫지 않고 열지도 않는다 — 열면 블록 끝까지 삼킨다. 글자다.
+ // 막힌 여는 마커의 거울 짝 — 러스트 쪽과 같다(`값**(합계)**를`).
+ case same >= 0 && in.open[same].hemmed == in.line+1 && in.open[same].run == take &&
+ prev != noChar && !isWordChar(prev) && !unicode.IsSpace(prev):
+ in.open[same].guess = false
+ in.afterClose = next
+ in.closeAt(same, out, v)
case same >= 0 && in.open[same].guess && (!left || !afterSpace):
for k := 0; k < take; k++ {
in.textChar(c, out, v)
@@ -275,6 +289,9 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) {
default:
fresh.at = len(*out)
fresh.guess = true
+ if prev != noChar && isWordChar(prev) && next != noChar && !unicode.IsSpace(next) && isPunct(next) {
+ fresh.hemmed = in.line + 1
+ }
in.open = append(in.open, fresh)
}
i += take
@@ -332,7 +349,7 @@ func (in *inline) reopenAt(at int, out *[]byte, v vocab, fresh openMark) {
}
old := in.open[len(in.open)-1]
in.open = in.open[:len(in.open)-1]
- if old.run == 1 || (old.guess && (old.afterSpace || old.split)) {
+ if old.run == 1 || (old.guess && (old.afterSpace || old.split || old.hemmed != 0)) {
insertMarker(out, old.at, old.ch, old.run, v, &in.repairs)
} else {
in.repairs.DroppedMarker++
@@ -402,7 +419,8 @@ func (in *inline) finalize(out *[]byte, v vocab, matched bool) {
if o.guess || empty {
// 추측이 빗나갔다. 홑마커는 글자로 되돌린다 — 각주·글롭·곱셈. `**` 는 앞이 공백이었을
// 때만 되돌린다(`2 ** 3`). 앞이 글자인 `**` 가 홀로 남을 이유는 없다.
- if o.afterSpace || o.run == 1 || o.split {
+ // 막힌 여는 마커도 되돌린다 — 짝 없는 `2**(n-1)` 의 `**` 는 거듭제곱이다.
+ if o.afterSpace || o.run == 1 || o.split || o.hemmed != 0 {
insertMarker(out, o.at, o.ch, o.run, v, &in.repairs)
} else {
in.repairs.DroppedMarker++
diff --git a/go/inline_test.go b/go/inline_test.go
index 4c5001d..3da147e 100644
--- a/go/inline_test.go
+++ b/go/inline_test.go
@@ -125,3 +125,20 @@ func TestSlackJoinerForNestedEmphasis(t *testing.T) {
}
}
}
+
+// 여는 쪽이 막힌 강조 — 러스트 쪽 hemmed_opener_pairs_with_its_mirror_on_the_same_line.
+func TestHemmedOpenerPairsWithItsMirror(t *testing.T) {
+ cases := map[string]string{
+ "값**(합계)**를 본다": "값(합계)를 본다",
+ "이름이**\"홍길동\"**이다": "이름이\"홍길동\"이다",
+ "2**(n-1) 은 거듭제곱": "2**(n-1) 은 거듭제곱",
+ "마스킹 4***-****-****-003* 번호": "마스킹 4***-****-****-003* 번호",
+ "값**(합계)\n**다음** 줄": "값**(합계)\n다음 줄",
+ "underfront.* (4개), minjunkim.* (3개)": "underfront.* (4개), minjunkim.* (3개)",
+ }
+ for in, want := range cases {
+ if got := strings.Join(Render(in, TelegramHTML), "\x00"); got != want {
+ t.Errorf("%q:\n got %q\n want %q", in, got, want)
+ }
+ }
+}
From 256ede73fa4ad62d2ff15f5beb55d768a37bbd71 Mon Sep 17 00:00:00 2001
From: Minjun Kim
Date: Sat, 3 Oct 2026 08:13:00 +0900
Subject: [PATCH 2/2] =?UTF-8?q?fix(review):=20=EC=97=AC=EB=8A=94=20?=
=?UTF-8?q?=EA=B4=84=ED=98=B8=20=EB=92=A4=20=EB=A7=88=EC=BB=A4=EB=8A=94=20?=
=?UTF-8?q?=EA=B1=B0=EC=9A=B8=20=EC=A7=9D=EC=9D=B4=20=EC=95=84=EB=8B=88?=
=?UTF-8?q?=EA=B3=A0=20=EB=90=98=EB=8F=8C=EB=A6=B0=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=8A=A4=ED=8C=AC=EB=8F=84=20=EC=A4=84=EC=9D=84=20=EC=84=BC?=
=?UTF-8?q?=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #100 의 Codex 리뷰 두 건을 반영한다.
- 막힌 여는 마커의 거울 짝은 앞이 여는 괄호·따옴표(`([{「『(【《“‘`)가 아니어야 한다.
`2**(n-1) (**주의**)` 의 둘째 `**` 를 거울로 삼키면 `(` 까지 굵어지고 셋째가 글자로
남았다. 이제 막힌 마커는 글자로 돌아가고 `**주의**` 가 온전히 열린다. ASCII `"`·`'` 는
여닫는 모양이 같아 가르지 않는다.
- 안 닫힌 코드 스팬을 되돌려 다시 읽을 때 줄 번호를 코드 스팬이 열린 줄로 되감고 줄바꿈마다
센다. 전에는 `self.line` 이 그대로라 "같은 줄" 검사가 줄을 넘어 통과했다
(`` `값**(합계)⏎)**를 `` 가 굵어졌다).
Rust·Go·하네스 참조 모델에 같이 넣고, 코퍼스 `hemmed-opener-before-punct` 에 두 모양을 더했다.
Co-Authored-By: Claude
---
SPEC.md | 3 +-
.../github-markdown.txt | 7 +++-
.../cases/hemmed-opener-before-punct/html.txt | 7 +++-
.../cases/hemmed-opener-before-punct/input.md | 5 +++
.../notion-markdown.txt | 7 +++-
.../hemmed-opener-before-punct/plain.txt | 7 +++-
.../slack-markdown.txt | 7 +++-
.../telegram-html.txt | 7 +++-
.../cases/hemmed-opener-before-punct/why.md | 7 ++++
crates/mdwire-core/src/inline.rs | 39 ++++++++++++++++---
crates/mdwire-core/tests/render.rs | 7 ++++
crates/mdwire-harness/src/emphasis.rs | 25 +++++++++++-
go/inline.go | 30 ++++++++++++--
go/inline_test.go | 3 ++
14 files changed, 144 insertions(+), 17 deletions(-)
diff --git a/SPEC.md b/SPEC.md
index 375ded3..325304d 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -81,7 +81,8 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지
**여는 쪽이 막힌 강조**(`값**(합계)**를`, `이름이**"홍길동"**이다` — 여는 마커 앞이 글자, 뒤가 구두점)는 같은 줄에서
같은 길이의 거울 모양(앞이 구두점) 마커와 짝짓는다(2026-10-02). 추측을 닫지 않는 규칙의 예외다 — 그 규칙을 만든
-글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다. 짝이 없으면 버리지 않고 글자로
+글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다. 앞이 여는 괄호 · 따옴표인 마커는
+거울이 아니라 다음 강조의 여는 마커다(`2**(n-1) (**주의**)`). 짝이 없으면 버리지 않고 글자로
되돌린다(`2**(n-1)`). 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub ``, 슬랙은 여는 마커 뒤 · 닫는 마커
앞에 U+2060. 슬랙 · 노션에서 굵게로 그리는 것을 실측했다.
diff --git a/corpus/cases/hemmed-opener-before-punct/github-markdown.txt b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt
index 1fc5c0e..9565a79 100644
--- a/corpus/cases/hemmed-opener-before-punct/github-markdown.txt
+++ b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
-**다음** 줄이다.
\ No newline at end of file
+**다음** 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2\*\*(n-1) (**주의**) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값\*\*(합계)
+)\*\*를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/html.txt b/corpus/cases/hemmed-opener-before-punct/html.txt
index 17571a1..10f8459 100644
--- a/corpus/cases/hemmed-opener-before-punct/html.txt
+++ b/corpus/cases/hemmed-opener-before-punct/html.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값**(합계)
-다음 줄이다.
\ No newline at end of file
+다음 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2**(n-1) (주의) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
+)**를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/input.md b/corpus/cases/hemmed-opener-before-punct/input.md
index 58cacf6..0f809c6 100644
--- a/corpus/cases/hemmed-opener-before-punct/input.md
+++ b/corpus/cases/hemmed-opener-before-punct/input.md
@@ -4,3 +4,8 @@
줄을 넘으면 짝짓지 않는다: 값**(합계)
**다음** 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2**(n-1) (**주의**) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
+)**를 본다.
diff --git a/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
index b3672a3..6e9f474 100644
--- a/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
+++ b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
-**다음** 줄이다.
\ No newline at end of file
+**다음** 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2\*\*(n-1) (**주의**) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값\*\*(합계)
+)\*\*를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/plain.txt b/corpus/cases/hemmed-opener-before-punct/plain.txt
index 3bbe664..bf91518 100644
--- a/corpus/cases/hemmed-opener-before-punct/plain.txt
+++ b/corpus/cases/hemmed-opener-before-punct/plain.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값**(합계)
-다음 줄이다.
\ No newline at end of file
+다음 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2**(n-1) (주의) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
+)**를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
index dc62c20..b337d20 100644
--- a/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
+++ b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값**(합계)
-**다음** 줄이다.
\ No newline at end of file
+**다음** 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2**(n-1) (**주의**) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
+)**를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/telegram-html.txt b/corpus/cases/hemmed-opener-before-punct/telegram-html.txt
index 0884a38..718e1ac 100644
--- a/corpus/cases/hemmed-opener-before-punct/telegram-html.txt
+++ b/corpus/cases/hemmed-opener-before-punct/telegram-html.txt
@@ -3,4 +3,9 @@
짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.
줄을 넘으면 짝짓지 않는다: 값**(합계)
-다음 줄이다.
\ No newline at end of file
+다음 줄이다.
+
+괄호 안의 강조는 거울이 아니다: 2**(n-1) (주의) 를 본다.
+
+안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
+)**를 본다.
\ No newline at end of file
diff --git a/corpus/cases/hemmed-opener-before-punct/why.md b/corpus/cases/hemmed-opener-before-punct/why.md
index 0b246a2..767f807 100644
--- a/corpus/cases/hemmed-opener-before-punct/why.md
+++ b/corpus/cases/hemmed-opener-before-punct/why.md
@@ -11,3 +11,10 @@
전에는 버려서 `2(n-1)` 이 됐다.
출력: GitHub 은 ``, 슬랙은 여는 마커 뒤 · 닫는 마커 앞에 U+2060, 텔레그램은 태그.
+
+**앞이 여는 괄호·따옴표인 마커는 거울이 아니라 다음 강조의 여는 마커다.** `2**(n-1) (**주의**)` 의 둘째 `**` 를
+거울로 삼키면 `(` 까지 굵어지고 셋째가 글자로 남는다(리뷰가 찾았다). 막힌 마커는 글자로 돌아가고 `**주의**` 가
+온전히 열린다. ASCII `"`·`'` 는 여닫는 모양이 같아 가르지 않는다.
+
+**안 닫힌 코드 스팬을 되돌려 다시 읽을 때도 줄은 줄이다.** 되돌린 내용에 줄바꿈이 들어 있으면 그 줄들을 다시
+지나므로 줄 번호를 코드 스팬이 열린 줄로 되감고 센다 — 안 그러면 "같은 줄" 검사가 줄을 넘어 통과한다.
diff --git a/crates/mdwire-core/src/inline.rs b/crates/mdwire-core/src/inline.rs
index 23eabc1..5a459d2 100644
--- a/crates/mdwire-core/src/inline.rs
+++ b/crates/mdwire-core/src/inline.rs
@@ -45,6 +45,9 @@ struct Open {
/// **여는 쪽이 막힌 마커**인가 — 앞이 글자이고 뒤가 구두점이라 CommonMark 가 열지 않는 자리
/// (`값**(합계)**를`). 열린 줄 번호를 든다. 같은 줄에서 거울 모양으로 닫는 마커가 오면 짝을 맺는다.
hemmed: Option,
+ /// 열린 줄 번호. 안 닫힌 코드 스팬을 되돌려 다시 읽을 때 줄 번호를 여기로 되감는다 —
+ /// 블록 층은 줄마다 `end_line` 을 이미 불렀고, 되돌린 내용은 그 줄들을 다시 지난다.
+ line: u32,
}
#[derive(Clone)]
@@ -184,6 +187,10 @@ impl Inline {
}
let c = line[i];
+ // 되돌린 코드 스팬을 다시 읽을 때만 줄바꿈이 여기 온다 — 되감은 줄을 도로 센다.
+ if c == '\n' {
+ self.line += 1;
+ }
// 이미지 ``. 대체 글은 링크 텍스트처럼 인라인으로 읽는다.
if c == '!' && line.get(i + 1) == Some(&'[') {
@@ -237,6 +244,7 @@ impl Inline {
before: prev,
split: false,
hemmed: None,
+ line: self.line,
});
i += run;
continue;
@@ -332,22 +340,29 @@ impl Inline {
// 오면 그 짝이다 — 닫는 쪽이 "구두점 + 조사" 인 모양을 닫아 주는 규칙의 거울이다. 글롭
// (`.* (4개)`)·주석(`/* */`)은 여는 쪽 뒤가 공백이라, 마스킹 번호(`4***-****`)는 길이가
// 달라 여기 걸리지 않는다.
+ // 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 여는 마커다 — `2**(n-1) (**주의**)`
+ // 의 둘째 `**` 를 닫기로 삼키면 `(` 까지 굵어지고 셋째가 글자로 남는다.
Some(at)
if self.open[at].hemmed == Some(self.line)
&& self.open[at].run == take
- && prev.is_some_and(|p| !is_word_char(p) && !p.is_whitespace()) =>
+ && prev.is_some_and(|p| !is_word_char(p) && !p.is_whitespace() && !is_opening_bracket(p)) =>
{
self.open[at].guess = false;
self.after_close = next;
self.close_at(at, out, v)
}
- Some(at) if self.open[at].guess && (!left || !after_space) => {
+ Some(at)
+ if self.open[at].guess
+ && (!left || !after_space)
+ && !(left && self.open[at].hemmed.is_some() && prev.is_some_and(is_opening_bracket)) =>
+ {
for _ in 0..take {
self.text_char(c, out, v);
}
}
// 여는 자리의 마커가 왔는데 추측이 열려 있다 — 추측이 틀렸다. 되돌리고
- // 이쪽을 연다. `/* a */ 다음 *z*` 의 `*z` 가 여기다.
+ // 이쪽을 연다. `/* a */ 다음 *z*` 의 `*z` 가 여기다. 막힌 추측 뒤의 `(**주의`
+ // 도 여기다 — 막힌 마커는 글자로 돌아가고 `**주의**` 가 온전히 열린다.
Some(at) if self.open[at].guess => self.reopen_at(at, out, v, (emph, take, c, after_space, prev, split)),
// 같은 종류가 열려 있고 앞이 공백이 아니면 여기가 닫는 자리다. 규칙 1.
Some(at) if !after_space => {
@@ -378,6 +393,7 @@ impl Inline {
before: prev,
split,
hemmed: None,
+ line: self.line,
}),
// 열 수도 닫을 수도 없다. 일단 열어 두고 안 닫히면 글자로 되돌린다. 규칙 3.
None => self.open.push(Open {
@@ -391,6 +407,7 @@ impl Inline {
split,
hemmed: (prev.is_some_and(is_word_char) && next.is_some_and(|n| !n.is_whitespace() && is_punct(n)))
.then_some(self.line),
+ line: self.line,
}),
}
i += take;
@@ -431,9 +448,14 @@ impl Inline {
}
// 버퍼를 통째로 빌려 와서 다시 읽고 돌려준다. 새로 만들지 않는다.
let src = std::mem::take(&mut self.code_src);
- // 다시 읽는 내용은 백틱 바로 뒤에서 시작한다.
+ // 다시 읽는 내용은 백틱 바로 뒤에서 시작한다. 줄 번호는 코드 스팬이 열린 줄로 되감고
+ // 다시 읽으며 센다 — 안 그러면 막힌 마커의 "같은 줄" 검사가 줄을 넘어 통과한다
+ // (`` `값**(합계)⏎)**를 ``). 다 읽으면 블록 층이 세어 둔 값으로 돌려놓는다.
+ let line = self.line;
+ self.line = open.line;
self.prev = Some(open.ch);
self.render(&src, out, v);
+ self.line = line;
// 다시 읽는 동안 새 코드 스팬이 열렸으면 그쪽 버퍼를 지키고, 아니면 돌려준다.
if self.code_src.is_empty() {
self.code_src = src;
@@ -467,7 +489,7 @@ impl Inline {
} else {
self.repairs.dropped_marker += 1;
}
- self.open.push(Open { emph, at: out.len(), run: take, ch: c, guess: false, after_space, before, split, hemmed: None });
+ self.open.push(Open { emph, at: out.len(), run: take, ch: c, guess: false, after_space, before, split, hemmed: None, line: self.line });
}
fn prev_char(&self, line: &[char], i: usize) -> Option {
@@ -761,6 +783,7 @@ impl Inline {
before: self.prev_char(line, i),
split: false,
hemmed: None,
+ line: self.line,
}),
}
self.prev = Some('>');
@@ -1048,6 +1071,12 @@ fn can_open(prev: Option, next: Option) -> bool {
})
}
+/// 여는 괄호·따옴표 — 바로 뒤의 마커는 닫는 자리가 아니라 여는 자리다. ASCII `"`·`'` 는
+/// 여닫는 모양이 같아 가르지 못하므로 넣지 않는다.
+fn is_opening_bracket(c: char) -> bool {
+ matches!(c, '(' | '[' | '{' | '「' | '『' | '(' | '【' | '《' | '“' | '‘')
+}
+
/// 강조 마커 앞뒤의 "글자". 알파벳(한글 포함)과 ASCII 숫자다 — `①` 같은 기호는 아니다.
fn is_word_char(c: char) -> bool {
c.is_alphabetic() || c.is_ascii_digit()
diff --git a/crates/mdwire-core/tests/render.rs b/crates/mdwire-core/tests/render.rs
index 0bd321b..f404ede 100644
--- a/crates/mdwire-core/tests/render.rs
+++ b/crates/mdwire-core/tests/render.rs
@@ -862,6 +862,13 @@ fn hemmed_opener_pairs_with_its_mirror_on_the_same_line() {
assert_eq!(t("underfront.* (4개), minjunkim.* (3개)"), "underfront.* (4개), minjunkim.* (3개)");
assert_eq!(t("마스킹 4***-****-****-003* 번호"), "마스킹 4***-****-****-003* 번호");
assert_eq!(t("값**(합계)\n**다음** 줄"), "값**(합계)\n다음 줄");
+ // 앞이 여는 괄호인 마커는 거울이 아니라 다음 강조의 여는 마커다 — 막힌 마커는 글자로 돌아간다.
+ assert_eq!(t("2**(n-1) (**주의**)"), "2**(n-1) (주의)");
+ assert_eq!(t("이름이**\"홍길동\"** (\"**주의**\")"), "이름이\"홍길동\" (\"주의\")");
+ // 안 닫힌 코드 스팬을 되돌려 다시 읽을 때도 줄은 줄이다 — 줄을 넘은 거울과 짝짓지 않고,
+ // 같은 줄에 있던 거울과는 짝짓는다.
+ assert_eq!(t("`값**(합계)\n)**를 끝"), "`값**(합계)\n)**를 끝");
+ assert_eq!(t("값**(합계) `x )**를\ny"), "값(합계) `x )를\ny");
}
/// **GFM 이 마커를 못 읽는 자리의 강조는 태그로 낸다**(실측 2026-09-30). 닫는 `**` 앞이
diff --git a/crates/mdwire-harness/src/emphasis.rs b/crates/mdwire-harness/src/emphasis.rs
index a833f11..1eddffb 100644
--- a/crates/mdwire-harness/src/emphasis.rs
+++ b/crates/mdwire-harness/src/emphasis.rs
@@ -423,13 +423,20 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) {
if mode != Mode::CommonMark
&& stack[at].hemmed == Some(line)
&& stack[at].marker.chars().count() == take
- && prev.is_some_and(|p| !(p.is_alphabetic() || p.is_ascii_digit()) && !p.is_whitespace()) =>
+ && prev.is_some_and(|p| {
+ !(p.is_alphabetic() || p.is_ascii_digit()) && !p.is_whitespace() && !is_opening_bracket(p)
+ }) =>
{
stack[at].guess = false;
close_to(&mut stack, &mut root, at, scan)
}
// 추측으로 연 것은 닫지 않는다 — 닫는 자리의 마커는 글자다. 규칙은 코어와 같다.
- Some(at) if stack[at].guess && (!left || !after_space) => {
+ // 막힌 추측 뒤의 `(**주의` 는 여는 마커라 아래로 보낸다(`2**(n-1) (**주의**)`).
+ Some(at)
+ if stack[at].guess
+ && (!left || !after_space)
+ && !(left && stack[at].hemmed.is_some() && prev.is_some_and(is_opening_bracket)) =>
+ {
*scan.literal.entry(c).or_default() += take;
push_text(&mut stack, &mut root, &marker)
}
@@ -632,6 +639,11 @@ pub(crate) fn can_open(prev: Option, next: Option) -> bool {
})
}
+/// 여는 괄호·따옴표 — 코어의 `is_opening_bracket` 과 같다.
+fn is_opening_bracket(c: char) -> bool {
+ matches!(c, '(' | '[' | '{' | '「' | '『' | '(' | '【' | '《' | '“' | '‘')
+}
+
pub(crate) fn is_punct(c: char) -> bool {
c.is_ascii_punctuation()
|| matches!(c,
@@ -908,4 +920,13 @@ mod angle_tests {
assert!(scan_markdown(fixed, Mode::CommonMark).unpaired.is_empty());
assert!(!scan_markdown("**설정(config)**을", Mode::CommonMark).unpaired.is_empty());
}
+
+ /// 막힌 여는 마커 뒤에 괄호 안의 강조가 오면 거울이 아니다 — 규칙은 코어와 같다.
+ #[test]
+ fn opening_bracket_marker_is_not_a_mirror() {
+ let scan = scan_markdown("2**(n-1) (**주의**)", Mode::Repair);
+ assert_eq!(scan.spans, vec![Span { kind: Kind::Bold, text: "주의".into() }]);
+ let scan = scan_markdown("값**(합계)**를", Mode::Repair);
+ assert_eq!(scan.spans, vec![Span { kind: Kind::Bold, text: "(합계)".into() }]);
+ }
}
diff --git a/go/inline.go b/go/inline.go
index cfff3bc..f9f3bc9 100644
--- a/go/inline.go
+++ b/go/inline.go
@@ -40,6 +40,8 @@ type openMark struct {
// hemmed 는 여는 쪽이 막힌 마커인가다 — 앞이 글자이고 뒤가 구두점(`값**(합계)**를`). 열린 줄 번호에
// 1 을 더해 든다(0 은 아님). 같은 줄에서 거울 모양으로 닫는 마커가 오면 짝을 맺는다. 러스트 쪽과 같다.
hemmed uint32
+ // line 은 열린 줄 번호다. 안 닫힌 코드 스팬을 되돌려 다시 읽을 때 줄 번호를 여기로 되감는다.
+ line uint32
}
type inline struct {
@@ -146,6 +148,10 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) {
}
c := line[i]
+ // 되돌린 코드 스팬을 다시 읽을 때만 줄바꿈이 여기 온다 — 되감은 줄을 도로 센다.
+ if c == '\n' {
+ in.line++
+ }
// 이미지 ``. 대체 글은 링크 텍스트처럼 인라인으로 읽는다.
if c == '!' && i+1 < len(line) && line[i+1] == '[' {
@@ -183,7 +189,7 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) {
run := runLen(line, i, '`')
prev := in.prevChar(line, i)
in.codeSrc = in.codeSrc[:0]
- in.open = append(in.open, openMark{emph: emphCode, at: len(*out), run: run, ch: '`', afterSpace: prev == noChar || unicode.IsSpace(prev), before: prev})
+ in.open = append(in.open, openMark{emph: emphCode, at: len(*out), run: run, ch: '`', afterSpace: prev == noChar || unicode.IsSpace(prev), before: prev, line: in.line})
i += run
continue
}
@@ -258,16 +264,19 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) {
// 닫는 쪽만 없어져 `underfront.*`·`/* 주석 */` 의 별표가 없어졌다. 앞이 글자인 마커도
// 추측을 닫지 않고 열지도 않는다 — 열면 블록 끝까지 삼킨다. 글자다.
// 막힌 여는 마커의 거울 짝 — 러스트 쪽과 같다(`값**(합계)**를`).
+ // 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 여는 마커다(`2**(n-1) (**주의**)`).
case same >= 0 && in.open[same].hemmed == in.line+1 && in.open[same].run == take &&
- prev != noChar && !isWordChar(prev) && !unicode.IsSpace(prev):
+ prev != noChar && !isWordChar(prev) && !unicode.IsSpace(prev) && !isOpeningBracket(prev):
in.open[same].guess = false
in.afterClose = next
in.closeAt(same, out, v)
- case same >= 0 && in.open[same].guess && (!left || !afterSpace):
+ case same >= 0 && in.open[same].guess && (!left || !afterSpace) &&
+ !(left && in.open[same].hemmed != 0 && isOpeningBracket(prev)):
for k := 0; k < take; k++ {
in.textChar(c, out, v)
}
// 여는 자리의 마커가 왔는데 추측이 열려 있다 — 추측이 틀렸다. 되돌리고 이쪽을 연다.
+ // 막힌 추측 뒤의 `(**주의` 도 여기다 — 막힌 마커는 글자로 돌아간다.
case same >= 0 && in.open[same].guess:
in.reopenAt(same, out, v, fresh)
// 같은 종류가 열려 있고 앞이 공백이 아니면 여기가 닫는 자리다. 규칙 1.
@@ -331,8 +340,13 @@ func (in *inline) revertCodeSpan(out *[]byte, v vocab) {
// 버퍼를 통째로 빌려 와서 다시 읽고 돌려준다. 새로 만들지 않는다.
src := in.codeSrc
in.codeSrc = nil
+ // 줄 번호는 코드 스팬이 열린 줄로 되감고 다시 읽으며 센다 — 안 그러면 막힌 마커의 "같은 줄"
+ // 검사가 줄을 넘어 통과한다. 다 읽으면 블록 층이 세어 둔 값으로 돌려놓는다. 러스트 쪽과 같다.
+ line := in.line
+ in.line = o.line
in.prev = o.ch
in.render(src, out, v)
+ in.line = line
// 다시 읽는 동안 새 코드 스팬이 열렸으면 그쪽 버퍼를 지키고, 아니면 돌려준다.
if len(in.codeSrc) == 0 {
in.codeSrc = src[:0]
@@ -909,6 +923,16 @@ func isAlphabetic(c rune) bool {
return unicode.IsLetter(c) || unicode.Is(unicode.Nl, c) || unicode.Is(unicode.Other_Alphabetic, c)
}
+// isOpeningBracket 은 여는 괄호·따옴표다 — 바로 뒤의 마커는 닫는 자리가 아니라 여는 자리다.
+// ASCII `"`·`'` 는 여닫는 모양이 같아 가르지 못하므로 넣지 않는다.
+func isOpeningBracket(c rune) bool {
+ switch c {
+ case '(', '[', '{', '「', '『', '(', '【', '《', '“', '‘':
+ return true
+ }
+ return false
+}
+
// isWordChar 는 강조 마커 앞뒤의 "글자"다. 알파벳(한글 포함)과 ASCII 숫자 — `①` 은 아니다.
func isWordChar(c rune) bool {
return isAlphabetic(c) || (c >= '0' && c <= '9')
diff --git a/go/inline_test.go b/go/inline_test.go
index 3da147e..862fa5e 100644
--- a/go/inline_test.go
+++ b/go/inline_test.go
@@ -135,6 +135,9 @@ func TestHemmedOpenerPairsWithItsMirror(t *testing.T) {
"마스킹 4***-****-****-003* 번호": "마스킹 4***-****-****-003* 번호",
"값**(합계)\n**다음** 줄": "값**(합계)\n다음 줄",
"underfront.* (4개), minjunkim.* (3개)": "underfront.* (4개), minjunkim.* (3개)",
+ "2**(n-1) (**주의**)": "2**(n-1) (주의)",
+ "`값**(합계)\n)**를 끝": "`값**(합계)\n)**를 끝",
+ "값**(합계) `x )**를\ny": "값(합계) `x )를\ny",
}
for in, want := range cases {
if got := strings.Join(Render(in, TelegramHTML), "\x00"); got != want {