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; + } // 이미지 `![alt](url)`. 대체 글은 링크 텍스트처럼 인라인으로 읽는다. 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++ + } // 이미지 `![alt](url)`. 대체 글은 링크 텍스트처럼 인라인으로 읽는다. 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 {