Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지
여럿이면 짝이 엇갈려 범위가 뒤집힌다(`DESIGN.md` 1절의 고장이 출력 쪽에서 난다). 그런 짝만
`<strong>` `<em>` `<del>` 로 낸다 — GFM 이 읽는 자리는 마커 그대로다.

**여는 쪽이 막힌 강조**(`값**(합계)**를`, `이름이**"홍길동"**이다` — 여는 마커 앞이 글자, 뒤가 구두점)는 같은 줄에서
같은 길이의 거울 모양(앞이 구두점) 마커와 짝짓는다(2026-10-02). 추측을 닫지 않는 규칙의 예외다 — 그 규칙을 만든
글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다. 앞이 여는 괄호 · 따옴표인 마커는
거울이 아니라 다음 강조의 여는 마커다(`2**(n-1) (**주의**)`). 짝이 없으면 버리지 않고 글자로
되돌린다(`2**(n-1)`). 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub `<strong>`, 슬랙은 여는 마커 뒤 · 닫는 마커
앞에 U+2060. 슬랙 · 노션에서 굵게로 그리는 것을 실측했다.

**슬랙 `markdown_text` 도 같은 자리에서 깨진다**(실측 2026-10-01, `chat.postMessage`). 태그를 못
쓰니 그 짝만 못 읽히는 쪽 마커 안쪽에 U+2060(워드 조이너)을 끼운다.
닫는 마커 앞 글자가 공백도 구두점도 아니게 되어 닫힌다. 보이지 않고, U+200B 와 달리 그 자리에서
Expand Down
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/github-markdown.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값<strong>(합계)</strong>를 본다. 이름이<strong>"홍길동"</strong>이다. 설정<strong>「캐시」</strong>는 끈다.

짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
**다음** 줄이다.

괄호 안의 강조는 거울이 아니다: 2\*\*(n-1) (**주의**) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값\*\*(합계)
)\*\*를 본다.
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/html.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<p>값<strong>(합계)</strong>를 본다. 이름이<strong>"홍길동"</strong>이다. 설정<strong>「캐시」</strong>는 끈다.</p>

<p>짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.</p>

<p>줄을 넘으면 짝짓지 않는다: 값**(합계)<br>
<strong>다음</strong> 줄이다.</p>

<p>괄호 안의 강조는 거울이 아니다: 2**(n-1) (<strong>주의</strong>) 를 본다.</p>

<p>안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)<br>
)**를 본다.</p>
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다.

짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값**(합계)
**다음** 줄이다.

괄호 안의 강조는 거울이 아니다: 2**(n-1) (**주의**) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
)**를 본다.
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/notion-markdown.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다.

짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값\*\*(합계)
**다음** 줄이다.

괄호 안의 강조는 거울이 아니다: 2\*\*(n-1) (**주의**) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값\*\*(합계)
)\*\*를 본다.
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/plain.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.

짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값**(합계)
다음 줄이다.

괄호 안의 강조는 거울이 아니다: 2**(n-1) (주의) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
)**를 본다.
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/slack-markdown.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값**⁠(합계)⁠**를 본다. 이름이**⁠"홍길동"⁠**이다. 설정**⁠「캐시」⁠**는 끈다.

짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값**(합계)
**다음** 줄이다.

괄호 안의 강조는 거울이 아니다: 2**(n-1) (**주의**) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
)**를 본다.
11 changes: 11 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/telegram-html.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
값<b>(합계)</b>를 본다. 이름이<b>"홍길동"</b>이다. 설정<b>「캐시」</b>는 끈다.

짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다.

줄을 넘으면 짝짓지 않는다: 값**(합계)
<b>다음</b> 줄이다.

괄호 안의 강조는 거울이 아니다: 2**(n-1) (<b>주의</b>) 를 본다.

안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계)
)**를 본다.
20 changes: 20 additions & 0 deletions corpus/cases/hemmed-opener-before-punct/why.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
강조를 여는 `**` 앞에 낱말이 붙고 뒤가 구두점이면 CommonMark 는 그 마커를 열지 않는다 — `값**(합계)**를`,
`이름이**"홍길동"**이다`. 닫는 쪽이 "구두점 + 조사"라 못 닫히는 모양(`punct-before-closing-emphasis`)의 거울이고,
이 프로젝트를 시작하게 한 모양 가운데 하나다. 전에는 첫 `**` 를 추측으로 열었다가 블록 끝에서 버리고, 둘째
`**` 는 추측을 닫지 않는 규칙 때문에 글자로 남겼다 — 하나는 사라지고 하나만 보이는 가장 나쁜 결과였다.

**막힌 여는 마커는 같은 줄의 거울 모양 마커하고만 짝짓는다.** 같은 길이이고 앞이 구두점인 닫는 마커다. 추측을
닫지 않는 규칙은 글롭(`underfront.* (4개)`)·주석(`/* */`)에서 나왔는데 그 둘은 여는 쪽 뒤가 공백이라 여기 걸리지
않고, 마스킹 번호(`4***-****`)는 길이가 달라 걸리지 않는다. 줄을 넘으면 짝짓지 않는다.

**짝 없는 막힌 마커는 버리지 않고 글자로 되돌린다.** `2**(n-1)` 의 `**` 는 거듭제곱이지 짝 잃은 마커가 아니다 —
전에는 버려서 `2(n-1)` 이 됐다.

출력: GitHub 은 `<strong>`, 슬랙은 여는 마커 뒤 · 닫는 마커 앞에 U+2060, 텔레그램은 태그.

**앞이 여는 괄호·따옴표인 마커는 거울이 아니라 다음 강조의 여는 마커다.** `2**(n-1) (**주의**)` 의 둘째 `**` 를
거울로 삼키면 `(` 까지 굵어지고 셋째가 글자로 남는다(리뷰가 찾았다). 막힌 마커는 글자로 돌아가고 `**주의**` 가
온전히 열린다. ASCII `"`·`'` 는 여닫는 모양이 같아 가르지 않는다.

**안 닫힌 코드 스팬을 되돌려 다시 읽을 때도 줄은 줄이다.** 되돌린 내용에 줄바꿈이 들어 있으면 그 줄들을 다시
지나므로 줄 번호를 코드 스팬이 열린 줄로 되감고 센다 — 안 그러면 "같은 줄" 검사가 줄을 넘어 통과한다.
68 changes: 62 additions & 6 deletions crates/mdwire-core/src/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,19 @@ struct Open {
/// 더 긴 런(`***`)을 쪼갠 조각인가. 안 닫히면 버리지 않고 글자로 되돌린다 — 원래 한
/// 덩어리의 글자였다(마스킹 번호 `4***-…`).
split: bool,
/// **여는 쪽이 막힌 마커**인가 — 앞이 글자이고 뒤가 구두점이라 CommonMark 가 열지 않는 자리
/// (`값**(합계)**를`). 열린 줄 번호를 든다. 같은 줄에서 거울 모양으로 닫는 마커가 오면 짝을 맺는다.
hemmed: Option<u32>,
/// 열린 줄 번호. 안 닫힌 코드 스팬을 되돌려 다시 읽을 때 줄 번호를 여기로 되감는다 —
/// 블록 층은 줄마다 `end_line` 을 이미 불렀고, 되돌린 내용은 그 줄들을 다시 지난다.
line: u32,
}

#[derive(Clone)]
pub(crate) struct Inline {
open: Vec<Open>,
/// 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다.
line: u32,
/// 줄을 넘어온 직전 글자. 블록 안에서 줄이 바뀌면 `'\n'` 이다.
/// 이 값이 있어야 **줄 첫머리의 마커가 닫기가 아니라는 판정**이 선다.
prev: Option<char>,
Expand Down Expand Up @@ -81,6 +89,7 @@ impl Inline {
pub fn new() -> Self {
Self {
open: Vec::new(),
line: 0,
prev: None,
scratch: String::new(),
code_src: Vec::new(),
Expand Down Expand Up @@ -122,6 +131,7 @@ impl Inline {

/// 줄 하나가 끝났다. 다음 줄의 첫 글자에게 앞 글자는 줄바꿈이다.
pub fn end_line(&mut self) {
self.line += 1;
self.prev = Some('\n');
}

Expand Down Expand Up @@ -177,6 +187,10 @@ impl Inline {
}

let c = line[i];
// 되돌린 코드 스팬을 다시 읽을 때만 줄바꿈이 여기 온다 — 되감은 줄을 도로 센다.
if c == '\n' {
self.line += 1;
}

// 이미지 `![alt](url)`. 대체 글은 링크 텍스트처럼 인라인으로 읽는다.
if c == '!' && line.get(i + 1) == Some(&'[') {
Expand Down Expand Up @@ -229,6 +243,8 @@ impl Inline {
after_space: prev.is_none_or(char::is_whitespace),
before: prev,
split: false,
hemmed: None,
line: self.line,
});
i += run;
continue;
Expand Down Expand Up @@ -319,13 +335,34 @@ impl Inline {
// 끝에서 되돌린다.
// 앞이 글자인 마커(`조합**이`)도 추측을 닫지 않고, 열지도 않는다 — 열면
// 블록 끝까지 삼킨다. 글자다.
Some(at) if self.open[at].guess && (!left || !after_space) => {
// **막힌 여는 마커의 거울 짝.** `값**(합계)**를` 의 첫 `**` 는 앞이 글자, 뒤가 구두점이라
// 열지 못하고 추측으로 열렸다. 같은 줄에서 같은 길이의 마커가 거울 모양(앞이 구두점)으로
// 오면 그 짝이다 — 닫는 쪽이 "구두점 + 조사" 인 모양을 닫아 주는 규칙의 거울이다. 글롭
// (`.* (4개)`)·주석(`/* */`)은 여는 쪽 뒤가 공백이라, 마스킹 번호(`4***-****`)는 길이가
// 달라 여기 걸리지 않는다.
// 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 여는 마커다 — `2**(n-1) (**주의**)`
// 의 둘째 `**` 를 닫기로 삼키면 `(` 까지 굵어지고 셋째가 글자로 남는다.
Some(at)
if self.open[at].hemmed == Some(self.line)
Comment thread
minjun0219 marked this conversation as resolved.
&& self.open[at].run == take
&& 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)
&& !(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 => {
Expand Down Expand Up @@ -355,6 +392,8 @@ impl Inline {
after_space,
before: prev,
split,
hemmed: None,
line: self.line,
}),
// 열 수도 닫을 수도 없다. 일단 열어 두고 안 닫히면 글자로 되돌린다. 규칙 3.
None => self.open.push(Open {
Expand All @@ -366,6 +405,9 @@ 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),
line: self.line,
}),
}
i += take;
Expand Down Expand Up @@ -406,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;
Expand Down Expand Up @@ -437,12 +484,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, line: self.line });
}

fn prev_char(&self, line: &[char], i: usize) -> Option<char> {
Expand Down Expand Up @@ -487,7 +534,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;
Expand Down Expand Up @@ -734,6 +782,8 @@ impl Inline {
after_space: false,
before: self.prev_char(line, i),
split: false,
hemmed: None,
line: self.line,
}),
}
self.prev = Some('>');
Expand Down Expand Up @@ -1021,6 +1071,12 @@ fn can_open(prev: Option<char>, next: Option<char>) -> 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()
Expand Down
22 changes: 22 additions & 0 deletions crates/mdwire-core/tests/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,28 @@ 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("값**(합계)**를 본다"), "값<b>(합계)</b>를 본다");
assert_eq!(t("이름이**\"홍길동\"**이다"), "이름이<b>\"홍길동\"</b>이다");
assert_eq!(one("값**(합계)**를", Channel::GithubMarkdown), "값<strong>(합계)</strong>를");
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<b>다음</b> 줄");
// 앞이 여는 괄호인 마커는 거울이 아니라 다음 강조의 여는 마커다 — 막힌 마커는 글자로 돌아간다.
assert_eq!(t("2**(n-1) (**주의**)"), "2**(n-1) (<b>주의</b>)");
assert_eq!(t("이름이**\"홍길동\"** (\"**주의**\")"), "이름이<b>\"홍길동\"</b> (\"<b>주의</b>\")");
// 안 닫힌 코드 스팬을 되돌려 다시 읽을 때도 줄은 줄이다 — 줄을 넘은 거울과 짝짓지 않고,
// 같은 줄에 있던 거울과는 짝짓는다.
assert_eq!(t("`값**(합계)\n)**를 끝"), "`값**(합계)\n)**를 끝");
assert_eq!(t("값**(합계) `x )**를\ny"), "값<b>(합계) `x )</b>를\ny");
}

/// **GFM 이 마커를 못 읽는 자리의 강조는 태그로 낸다**(실측 2026-09-30). 닫는 `**` 앞이
/// 구두점이고 뒤에 조사가 붙으면 GFM 은 닫지 않아 별표가 글자로 남고, 여럿이면 범위가 뒤집힌다.
#[test]
Expand Down
Loading
Loading