From bdd893603410fc6cc8e719909a18784e7f26e597 Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Fri, 2 Oct 2026 23:09:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20=EC=B6=94=EC=B8=A1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=9D=EC=A7=80=EC=9D=80=20=EA=B0=95=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=84=B8=EA=B3=A0,=20=EC=88=98=EC=8B=9D=EC=9D=80?= =?UTF-8?q?=20=EC=A7=9D=EC=A7=93=EC=A7=80=20=EC=95=8A=EA=B3=A0,=20?= =?UTF-8?q?=EC=8A=AC=EB=9E=99=EC=9D=80=20=EA=B8=80=EC=9E=90=20=EB=B3=84?= =?UTF-8?q?=ED=91=9C=EB=A5=BC=20=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여는 쪽이 막힌 강조(#100)는 규칙이 아니라 추측으로 짝을 맺는다. 그 판단을 보고에 드러내고, 애매한 자리를 좁히고, 슬랙에서 글자 별표가 다시 읽히는 것을 막는다. 모델에게 "수식은 백틱으로" 라고 부탁하지 않아도 되게 하는 것이 목표다. - `Repairs.guessed_pair`(npm `guessedPair`, Go `GuessedPair`, CLI JSON) — 고친 것 다섯 번째. `any()`에 든다. 0 이 아니면 한 번 볼 만한 답변이다 - 거울 짝은 **닫는 마커 뒤에 글자(조사)가 와야** 맺는다 — 마스킹 번호 `4***-…-003*` 의 홑 `*` 가 같은 줄 멀리의 글롭 `underfront.*` 와 묶이던 것. **양 끝이 다 ASCII 영숫자면 수식**이라 짝짓지 않는다(`x**(y)**z`, `2**(n-1)**2`). 막힌 여는 마커가 또 오면 앞의 것은 글자로 되돌리고 새것을 연다 — 앞의 것이 뒤의 거울 짝을 가로채지 않게 - **슬랙은 글자 `*` 를 `\*` 로 낸다**(실측 2026-10-02, `chat.postMessage`): 글자로 둔 별표를 슬랙이 CommonMark 로 다시 읽어 `x**(y)**z 와 2**(n-1)**2` 의 `**z 와 2**` 와 마스킹 번호의 `-****-` 를 굵게로 그렸다(후자는 지금 main 도 그렇다). `\*` `\~` `\<` `\_` 는 글자로 그렸다. 하네스의 슬랙 CommonMark 판정(#99)이 이걸 잡아냈다. 코퍼스 슬랙 기대값 4건이 바뀐다 - Rust · Go · 하네스 참조 모델 같이. 코퍼스 `hemmed-opener-before-punct` 에 수식 줄 · 사이트 문서에 `` Co-Authored-By: Claude Fable 5.1 --- README.ko.md | 5 +-- README.md | 5 +-- SPEC.md | 16 +++++---- .../asterisk-is-a-letter/slack-markdown.txt | 6 ++-- corpus/cases/asterisk-is-a-letter/why.md | 4 +++ .../github-markdown.txt | 2 +- .../cases/hemmed-opener-before-punct/html.txt | 2 +- .../cases/hemmed-opener-before-punct/input.md | 2 +- .../notion-markdown.txt | 2 +- .../hemmed-opener-before-punct/plain.txt | 2 +- .../slack-markdown.txt | 10 +++--- .../telegram-html.txt | 2 +- .../cases/hemmed-opener-before-punct/why.md | 3 ++ .../slack-markdown.txt | 4 +-- corpus/cases/masked-number-asterisks/why.md | 4 +++ .../spaced-bold-marker/slack-markdown.txt | 2 +- corpus/cases/spaced-bold-marker/why.md | 3 ++ crates/mdwire-cli/src/main.rs | 7 ++-- crates/mdwire-core/src/inline.rs | 28 +++++++++++---- crates/mdwire-core/src/lib.rs | 17 +++++++--- crates/mdwire-core/src/vocab.rs | 6 ++++ crates/mdwire-core/tests/render.rs | 30 ++++++++++++++-- crates/mdwire-harness/src/emphasis.rs | 34 +++++++++++++++---- crates/mdwire-wasm/src/lib.rs | 6 +++- go/cmd/mdwire/main.go | 4 +-- go/cmd/mdwire/main_test.go | 2 +- go/fuzz_test.go | 4 +-- go/inline.go | 16 +++++++-- go/inline_test.go | 26 ++++++++++++++ go/mdwire.go | 11 ++++-- go/testdata/block.slack-markdown.txt | 2 +- go/testdata/inline.slack-markdown.txt | 16 ++++----- go/vocab.go | 4 +++ scripts/smoke.mjs | 2 ++ scripts/smoke.ts | 2 +- site/src/components/ChannelCompare.tsx | 1 + site/src/components/RepairCompare.tsx | 1 + site/src/pages/docs/cli.mdx | 2 +- site/src/pages/docs/go.mdx | 2 +- site/src/pages/docs/index.mdx | 7 ++-- site/src/pages/docs/npm.mdx | 3 +- site/src/pages/docs/rust.mdx | 1 + site/src/pages/ko/docs/cli.mdx | 2 +- site/src/pages/ko/docs/go.mdx | 2 +- site/src/pages/ko/docs/index.mdx | 7 ++-- site/src/pages/ko/docs/npm.mdx | 3 +- site/src/pages/ko/docs/rust.mdx | 1 + site/src/webmcp.ts | 1 + skills/mdwire/SKILL.md | 3 +- 49 files changed, 244 insertions(+), 83 deletions(-) diff --git a/README.ko.md b/README.ko.md index 01ae23a..6ebc832 100644 --- a/README.ko.md +++ b/README.ko.md @@ -22,7 +22,8 @@ LLM 마크다운 → 정규화 → 채널별 렌더링 → 안전한 분 1. **정규화.** 에이전트가 쓴 마크다운은 표준 마크다운 변환으로는 제대로 표현되지 않을 수 있습니다. 짝 없는 `**`, 줄을 접어 쓴 문단에서 줄을 넘는 강조, 닫히지 않은 코드펜스가 흔합니다. 렌더링하기 전에 고칩니다. 2. **렌더링.** 채널이 실제로 받는 문법으로 출력합니다. Telegram HTML 은 태그 아홉 개만 받고, - Slack `markdown_text` 는 표준 마크다운을 그대로 받습니다. GitHub 도 표준 마크다운을 받지만 홑 `~` 를 + Slack `markdown_text` 는 표준 마크다운을 그대로 받되, 문자로 쓴 `*` 는 슬랙이 강조로 다시 읽지 않게 + `\*` 로 출력합니다. GitHub 도 표준 마크다운을 받지만 홑 `~` 를 취소선으로, `` 를 HTML 태그로 읽습니다. 그래서 문자로 쓴 `~`·`<` 는 이스케이프해서(`\~`, `\<`) 출력합니다. GFM 이 닫지 않는 강조(`**(a)**` 바로 뒤에 조사가 붙은 경우)는 `` 으로 출력합니다. 노션은 그 강조를 제대로 렌더링하지만 인라인 HTML 은 텍스트로 보여 줍니다. 그래서 `notion-markdown` 은 태그를 제거하고 @@ -34,7 +35,7 @@ LLM 마크다운 → 정규화 → 채널별 렌더링 → 안전한 분 청크 경계가 `**굵게**` 안에 걸리면 안 됩니다. 이 흐름에 옵션이 하나 있습니다. **정규화 보고:** 정규화가 대신 고친 횟수(닫히지 않은 강조, -닫히지 않은 펜스, 짝 없는 백틱, 버린 마커)와 채널에 맞춰 바꿔 쓴 횟수(이스케이프한 글자, 태그로 +닫히지 않은 펜스, 짝 없는 백틱, 버린 마커, 추측으로 짝지은 강조)와 채널에 맞춰 바꿔 쓴 횟수(이스케이프한 글자, 태그로 출력한 강조, 제거한 HTML, 불릿, 표, 바꾼 마커)를 셉니다. 모델이 자기 서식을 얼마나 자주 깨는지 기록하고, 채널을 도입하기 전에 그 채널에 맞춰 무엇이 바뀌는지 확인할 수 있습니다. diff --git a/README.md b/README.md index edbb05c..5964e1a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ LLM markdown → normalize → render for channel → split safely → s Markdown converter. Typical cases: unpaired `**`, emphasis that spans a line break in wrapped prose, unclosed code fences. Repair before rendering. 2. **Render.** Emit the syntax the channel actually accepts. Telegram HTML allows nine - tags; Slack `markdown_text` takes standard Markdown directly. GitHub takes it too, but + tags; Slack `markdown_text` takes standard Markdown directly, with a `*` meant as a character + escaped (`\*`) so Slack does not re-read it as emphasis. GitHub takes it too, but reads a lone `~` as strikethrough and `` as an HTML tag — so a `~` or `<` meant as a character goes out escaped (`\~`, `\<`), and emphasis that GFM would not close (`**(a)**` followed directly by a Korean particle) goes out as ``. Notion renders that bold as is @@ -40,7 +41,7 @@ LLM markdown → normalize → render for channel → split safely → s covers streaming: a chunk boundary must not land inside `**bold**`. The pipeline has one option. **Repair report:** how many times the normalizer stepped -in — unclosed emphasis, unclosed fence, unpaired backticks, dropped markers — and what it +in — unclosed emphasis, unclosed fence, unpaired backticks, dropped markers, guessed pairs — and what it rewrote for the channel — escaped characters, tags for emphasis, stripped HTML, bullets, tables, converted markers — so you can log how often the model breaks its own formatting and see what a channel changes before you adopt it. diff --git a/SPEC.md b/SPEC.md index 325304d..f37d633 100644 --- a/SPEC.md +++ b/SPEC.md @@ -42,7 +42,7 @@ mdwire/ | 채널 | 받는 것 | 한도 | |---|---|---| | Telegram HTML | `b i u s code pre a blockquote tg-spoiler` | 4096 | -| Slack `markdown_text` | 표준 마크다운 그대로 | 12,000 | +| Slack `markdown_text` | 표준 마크다운. 글자 `*` 는 이스케이프 | 12,000 | | GitHub 코멘트 (GFM) | 표준 마크다운. 글자 `~` `<` 는 이스케이프 | 65,536 | | 노션 페이지 (`notion-markdown`) | Notion-flavored Markdown. 글자 `*` `\` 는 이스케이프, 인라인 HTML 없음 | 65,536 | | Plain | 마크업 제거. 폴백 | 12,000 | @@ -83,7 +83,8 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지 같은 길이의 거울 모양(앞이 구두점) 마커와 짝짓는다(2026-10-02). 추측을 닫지 않는 규칙의 예외다 — 그 규칙을 만든 글롭 · 주석은 여는 쪽 뒤가 공백이고, 마스킹 번호는 길이가 달라 걸리지 않는다. 앞이 여는 괄호 · 따옴표인 마커는 거울이 아니라 다음 강조의 여는 마커다(`2**(n-1) (**주의**)`). 짝이 없으면 버리지 않고 글자로 -되돌린다(`2**(n-1)`). 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub ``, 슬랙은 여는 마커 뒤 · 닫는 마커 +되돌린다(`2**(n-1)`). **양 끝이 다 ASCII 영숫자면 수식이라 짝짓지 않는다**(`x**(y)**z`) — 한국어 강조는 여는 쪽 앞이나 +닫는 쪽 뒤에 한글이 온다. 모델에게 수식을 백틱으로 감싸 달라고 부탁하지 않아도 되게 여기서 가른다. 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub ``, 슬랙은 여는 마커 뒤 · 닫는 마커 앞에 U+2060. 슬랙 · 노션에서 굵게로 그리는 것을 실측했다. **슬랙 `markdown_text` 도 같은 자리에서 깨진다**(실측 2026-10-01, `chat.postMessage`). 태그를 못 @@ -92,7 +93,9 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지 줄을 가르지 않는다. 보고에서는 GitHub 의 태그와 같이 `tag_emphasis` 로 센다. 강조가 겹치면(`***중요(필수)***를`) 맞붙은 안쪽 마커 너머가 구두점일 때 안쪽 마커 앞에도 넣는다 — 바깥 조이너 때문에 안쪽 `*` 가 못 닫혔다(실측 2026-10-02). 노션은 이 모양을 그대로 그려서 건드리지 않는다. **슬랙은 홑 `~` 를 긋지 않는다**(같은 날 실측 — `약 ~40km … ~22km`, -`5~6월` 이 글자 그대로) — 그래서 슬랙에서는 `~` 를 이스케이프하지 않는다. +`5~6월` 이 글자 그대로) — 그래서 슬랙에서는 `~` 를 이스케이프하지 않는다. **글자 `*` 는 슬랙에서도 `\*` 로 +낸다**(실측 2026-10-02): 글자로 둔 별표를 슬랙이 CommonMark 로 다시 읽어 `x**(y)**z 와 2**(n-1)**2` 의 `**z 와 2**` +와 마스킹 번호 `4***-****-****-003*` 의 `-****-` 를 굵게로 그렸고, `\*` 는 글자로 그렸다(`\~` `\<` `\_` 도). - **인라인 태그(`` `` `` `
` …)는 GitHub 에서 살린다** — 그리는 채널에서 벗기면 LLM 이 적은 뜻이 사라진다. **태그뿐인 줄만 벗긴다**(짝인 닫는 태그도). 줄 첫머리 여는 태그 뒤가 줄 @@ -230,9 +233,9 @@ CLI는 `mdwire --channel telegram-html [--limit N] [--stream] [--report]`, **고친 것과 바꾼 것**(`Repairs`)은 정규화가 저자 대신 한 일과 채널에 맞춰 바꿔 쓴 것의 개수다 — 모델이 얼마나 자주 서식을 깨는지 로그로 재고, 채널을 들이기 전에 그 채널이 무엇을 바꾸는지 보려는 쪽이 -쓴다. 세기만 하고 출력은 바꾸지 않는다. 앞 넷이 **고친 것**, 뒤 여섯이 **바꾼 것**이다 — 바꾼 것을 +쓴다. 세기만 하고 출력은 바꾸지 않는다. 앞 다섯이 **고친 것**, 뒤 여섯이 **바꾼 것**이다 — 바꾼 것을 안 세던 때는 출력이 달라진 문서의 보고가 대부분 0 이라 붙일지 판단하는 근거가 되지 못했다. -"모델이 서식을 깼는가"는 `any()`(Go `Repaired()`, 앞 넷), "출력이 달라질 수 있는가"는 `changed()` +"모델이 서식을 깼는가"는 `any()`(Go `Repaired()`, 앞 다섯), "출력이 달라질 수 있는가"는 `changed()` (Go `Changed()`, 전부)로 따로 묻는다 — 이스케이프 하나로 서식이 깨졌다고 울리지 않게. | 필드 | 뜻 | @@ -241,7 +244,8 @@ CLI는 `mdwire --channel telegram-html [--limit N] [--stream] [--report]`, | `closed_fence` | 문서 끝까지 안 닫혀서 닫아 준 코드펜스 | | `reverted_code_span` | 짝이 없어 글자로 되돌린 백틱 런 | | `dropped_marker` | 짝 잃은 채 버린 `**` | -| `escaped_char` | 채널이 구문으로 읽을 글자를 이스케이프한 수(GitHub 의 `\~` `\<` `\*`) | +| `guessed_pair` | 규칙이 아니라 추측으로 짝지은 강조 — 여는 쪽이 막힌 마커와 같은 줄의 거울 모양 마커(`값**(합계)**를`). 양 끝이 ASCII 영숫자인 수식(`x**(y)**z`)은 짝짓지 않으니, 남는 것은 문맥으로만 가려지는 드문 모양이다. 0 이 아니면 한 번 볼 만한 답변이다 | +| `escaped_char` | 채널이 구문으로 읽을 글자를 이스케이프한 수(GitHub 의 `\~` `\<` `\*`, 슬랙·노션의 `\*`) | | `tag_emphasis` | 채널이 마커로 못 읽는 자리라 다르게 낸 강조(GitHub 은 ``, 슬랙은 마커 안쪽의 U+2060) | | `stripped_html` | 벗긴 원문 HTML — 그 채널이 못 그리는 태그, 주석, 줄바꿈으로 바꾼 `
` | | `rewritten_bullet` | 다른 기호로 바꿔 쓴 목록 기호 — 불릿(`* ` `• ` → `- `, 텔레그램은 `- ` → `• `)과 번호(`1)` → `1.`) | diff --git a/corpus/cases/asterisk-is-a-letter/slack-markdown.txt b/corpus/cases/asterisk-is-a-letter/slack-markdown.txt index 9d8afe9..ddaffc4 100644 --- a/corpus/cases/asterisk-is-a-letter/slack-markdown.txt +++ b/corpus/cases/asterisk-is-a-letter/slack-markdown.txt @@ -1,4 +1,4 @@ -- 남은 도메인: underfront.* (4개), minjunkim.* (3개) -- 주석은 /* 이렇게 */ 쓰고, 다음 *기울임*은 산다 +- 남은 도메인: underfront.\* (4개), minjunkim.\* (3개) +- 주석은 /\* 이렇게 \*/ 쓰고, 다음 *기울임*은 산다 - ①**⁠"주간 졸림"(2026-02 패턴 메모) — OSA 전형 증상** -- 각주는 참고* 처럼, 곱셈은 2 ** 3 처럼 온다* 뒤에 더 있다 \ No newline at end of file +- 각주는 참고\* 처럼, 곱셈은 2 \*\* 3 처럼 온다\* 뒤에 더 있다 \ No newline at end of file diff --git a/corpus/cases/asterisk-is-a-letter/why.md b/corpus/cases/asterisk-is-a-letter/why.md index 0b895c3..253a069 100644 --- a/corpus/cases/asterisk-is-a-letter/why.md +++ b/corpus/cases/asterisk-is-a-letter/why.md @@ -9,3 +9,7 @@ `①**"…"**` 은 다른 뿌리다. `①` 이 유니코드로는 숫자(No)라 "글자 뒤의 `**` 는 못 연다"에 걸려 닫는 쪽만 글자로 남았다. 마커 앞뒤의 "글자"는 알파벳과 ASCII 숫자만이다. + +슬랙 출력의 글자 별표도 `\*` 로 낸다(실측 2026-10-02 — 슬랙이 글자로 둔 별표를 CommonMark 로 다시 읽어 수식 +`x**(y)**z 와 2**(n-1)**2` 의 `**z 와 2**` 를 굵게로 그렸다). 여기 글롭 · 주석 · 곱셈의 별표는 슬랙이 어차피 +못 여는 자리지만, 판정이 같은 글자라 같은 모양으로 낸다. diff --git a/corpus/cases/hemmed-opener-before-punct/github-markdown.txt b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt index 9565a79..d0abc5d 100644 --- a/corpus/cases/hemmed-opener-before-punct/github-markdown.txt +++ b/corpus/cases/hemmed-opener-before-punct/github-markdown.txt @@ -1,6 +1,6 @@ 값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다. -짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다. 산문 속 수식 x\*\*(y)\*\*z 와 2\*\*(n-1)\*\*2 도 강조가 아니다. 수식 뒤의 값(합계)는 강조다. 줄을 넘으면 짝짓지 않는다: 값\*\*(합계) **다음** 줄이다. diff --git a/corpus/cases/hemmed-opener-before-punct/html.txt b/corpus/cases/hemmed-opener-before-punct/html.txt index 10f8459..6a4150f 100644 --- a/corpus/cases/hemmed-opener-before-punct/html.txt +++ b/corpus/cases/hemmed-opener-before-punct/html.txt @@ -1,6 +1,6 @@

값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다.

-

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

+

짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. 산문 속 수식 x**(y)**z 와 2**(n-1)**2 도 강조가 아니다. 수식 뒤의 값(합계)는 강조다.

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

diff --git a/corpus/cases/hemmed-opener-before-punct/input.md b/corpus/cases/hemmed-opener-before-punct/input.md index 0f809c6..3658904 100644 --- a/corpus/cases/hemmed-opener-before-punct/input.md +++ b/corpus/cases/hemmed-opener-before-punct/input.md @@ -1,6 +1,6 @@ 값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다. -짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. 산문 속 수식 x**(y)**z 와 2**(n-1)**2 도 강조가 아니다. 수식 뒤의 값**(합계)**는 강조다. 줄을 넘으면 짝짓지 않는다: 값**(합계) **다음** 줄이다. diff --git a/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt index 6e9f474..f178690 100644 --- a/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt +++ b/corpus/cases/hemmed-opener-before-punct/notion-markdown.txt @@ -1,6 +1,6 @@ 값**(합계)**를 본다. 이름이**"홍길동"**이다. 설정**「캐시」**는 끈다. -짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다. 산문 속 수식 x\*\*(y)\*\*z 와 2\*\*(n-1)\*\*2 도 강조가 아니다. 수식 뒤의 값**(합계)**는 강조다. 줄을 넘으면 짝짓지 않는다: 값\*\*(합계) **다음** 줄이다. diff --git a/corpus/cases/hemmed-opener-before-punct/plain.txt b/corpus/cases/hemmed-opener-before-punct/plain.txt index bf91518..f68c29e 100644 --- a/corpus/cases/hemmed-opener-before-punct/plain.txt +++ b/corpus/cases/hemmed-opener-before-punct/plain.txt @@ -1,6 +1,6 @@ 값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다. -짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. 산문 속 수식 x**(y)**z 와 2**(n-1)**2 도 강조가 아니다. 수식 뒤의 값(합계)는 강조다. 줄을 넘으면 짝짓지 않는다: 값**(합계) 다음 줄이다. diff --git a/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt index b337d20..c94a6f1 100644 --- a/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt +++ b/corpus/cases/hemmed-opener-before-punct/slack-markdown.txt @@ -1,11 +1,11 @@ 값**⁠(합계)⁠**를 본다. 이름이**⁠"홍길동"⁠**이다. 설정**⁠「캐시」⁠**는 끈다. -짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2\*\*(n-1) 은 그대로 둔다. 산문 속 수식 x\*\*(y)\*\*z 와 2\*\*(n-1)\*\*2 도 강조가 아니다. 수식 뒤의 값**⁠(합계)⁠**는 강조다. -줄을 넘으면 짝짓지 않는다: 값**(합계) +줄을 넘으면 짝짓지 않는다: 값\*\*(합계) **다음** 줄이다. -괄호 안의 강조는 거울이 아니다: 2**(n-1) (**주의**) 를 본다. +괄호 안의 강조는 거울이 아니다: 2\*\*(n-1) (**주의**) 를 본다. -안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값**(합계) -)**를 본다. \ No newline at end of file +안 닫힌 코드 스팬 뒤에서도 줄은 줄이다: `값\*\*(합계) +)\*\*를 본다. \ 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 718e1ac..06cae17 100644 --- a/corpus/cases/hemmed-opener-before-punct/telegram-html.txt +++ b/corpus/cases/hemmed-opener-before-punct/telegram-html.txt @@ -1,6 +1,6 @@ 값(합계)를 본다. 이름이"홍길동"이다. 설정「캐시」는 끈다. -짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. +짝 없는 거듭제곱 2**(n-1) 은 그대로 둔다. 산문 속 수식 x**(y)**z 와 2**(n-1)**2 도 강조가 아니다. 수식 뒤의 값(합계)는 강조다. 줄을 넘으면 짝짓지 않는다: 값**(합계) 다음 줄이다. diff --git a/corpus/cases/hemmed-opener-before-punct/why.md b/corpus/cases/hemmed-opener-before-punct/why.md index 767f807..7df884e 100644 --- a/corpus/cases/hemmed-opener-before-punct/why.md +++ b/corpus/cases/hemmed-opener-before-punct/why.md @@ -7,6 +7,9 @@ 닫지 않는 규칙은 글롭(`underfront.* (4개)`)·주석(`/* */`)에서 나왔는데 그 둘은 여는 쪽 뒤가 공백이라 여기 걸리지 않고, 마스킹 번호(`4***-****`)는 길이가 달라 걸리지 않는다. 줄을 넘으면 짝짓지 않는다. +**양 끝이 다 ASCII 영숫자면 수식이라 짝짓지 않는다**(`x**(y)**z`, `2**(n-1)**2`). 한국어 강조는 여는 쪽 앞이나 닫는 쪽 +뒤에 한글이 온다. 모델에게 수식을 백틱으로 감싸 달라고 부탁하지 않아도 되게 여기서 가른다. + **짝 없는 막힌 마커는 버리지 않고 글자로 되돌린다.** `2**(n-1)` 의 `**` 는 거듭제곱이지 짝 잃은 마커가 아니다 — 전에는 버려서 `2(n-1)` 이 됐다. diff --git a/corpus/cases/masked-number-asterisks/slack-markdown.txt b/corpus/cases/masked-number-asterisks/slack-markdown.txt index 2da619e..c36c153 100644 --- a/corpus/cases/masked-number-asterisks/slack-markdown.txt +++ b/corpus/cases/masked-number-asterisks/slack-markdown.txt @@ -1,2 +1,2 @@ -결제 수단: 신한카드 1***-****-****-001* (본인 명의) -계좌는 농협 302-****-****-** 로 옮겼다. +결제 수단: 신한카드 1\*\*\*-\*\*\*\*-\*\*\*\*-001\* (본인 명의) +계좌는 농협 302-\*\*\*\*-\*\*\*\*-\*\* 로 옮겼다. diff --git a/corpus/cases/masked-number-asterisks/why.md b/corpus/cases/masked-number-asterisks/why.md index d384b95..86c76cb 100644 --- a/corpus/cases/masked-number-asterisks/why.md +++ b/corpus/cases/masked-number-asterisks/why.md @@ -6,3 +6,7 @@ CommonMark 는 flanking 을 런 전체로 정한다 — 남은 조각도 런 앞의 글자(`4`)를 본다. 그리고 어디에도 못 열린 런의 첫 조각 `**` 는 짝 잃은 닫기가 아니라 한 덩어리 글자라 버리지 않고 되돌린다. 버리면 별표 둘이 사라진다. + +**슬랙은 글자로 둔 별표를 CommonMark 로 다시 읽는다**(실측 2026-10-02, `chat.postMessage`). 그대로 보낸 +`4***-****-****-003*` 의 `-****-` 가 굵게로 그려졌다. 슬랙 `markdown_text` 는 `\*` 를 글자로 그려서, 글자로 +판정된 별표는 GitHub · 노션처럼 `\*` 로 낸다. diff --git a/corpus/cases/spaced-bold-marker/slack-markdown.txt b/corpus/cases/spaced-bold-marker/slack-markdown.txt index 475bee5..64f219e 100644 --- a/corpus/cases/spaced-bold-marker/slack-markdown.txt +++ b/corpus/cases/spaced-bold-marker/slack-markdown.txt @@ -1 +1 @@ -** 배포 ** 는 금지다. 2 ** 3 은 곱셈이다. \ No newline at end of file +\*\* 배포 \*\* 는 금지다. 2 \*\* 3 은 곱셈이다. \ No newline at end of file diff --git a/corpus/cases/spaced-bold-marker/why.md b/corpus/cases/spaced-bold-marker/why.md index 90ca692..e7294a7 100644 --- a/corpus/cases/spaced-bold-marker/why.md +++ b/corpus/cases/spaced-bold-marker/why.md @@ -3,3 +3,6 @@ 되돌아가고 닫는 쪽만 사라졌다 — `** 배포 는` 이 됐다. 마커가 사라지는 것은 내용 손실이다. 추측으로 연 것은 공백 뒤의 마커로 닫지 않는다. `2 ** 3` 과 같은 부류다. + +슬랙 출력에서는 이 `**` 도 `\*\*` 로 낸다 — 글자로 판정된 별표는 슬랙에서 이스케이프한다(실측 2026-10-02, +`masked-number-asterisks`). 슬랙은 둘 다 글자로 그린다. diff --git a/crates/mdwire-cli/src/main.rs b/crates/mdwire-cli/src/main.rs index 42386ab..edda435 100644 --- a/crates/mdwire-cli/src/main.rs +++ b/crates/mdwire-cli/src/main.rs @@ -175,13 +175,14 @@ fn repairs_json(r: &Repairs) -> String { fn repairs_fields(r: &Repairs) -> String { format!( - "\"closedEmphasis\":{},\"closedFence\":{},\"revertedCodeSpan\":{},\"droppedMarker\":{},\ + "\"closedEmphasis\":{},\"closedFence\":{},\"revertedCodeSpan\":{},\"droppedMarker\":{},\"guessedPair\":{},\ \"escapedChar\":{},\"tagEmphasis\":{},\"strippedHtml\":{},\"rewrittenBullet\":{},\ \"rewrittenTable\":{},\"convertedMarker\":{}", r.closed_emphasis, r.closed_fence, r.reverted_code_span, r.dropped_marker, + r.guessed_pair, r.escaped_char, r.tag_emphasis, r.stripped_html, @@ -341,10 +342,10 @@ mod tests { assert_eq!(failed, 0); assert_eq!( out, - "{\"line\":1,\"id\":\"a\\\"1\",\"closedEmphasis\":0,\"closedFence\":0,\"revertedCodeSpan\":0,\"droppedMarker\":0,\ + "{\"line\":1,\"id\":\"a\\\"1\",\"closedEmphasis\":0,\"closedFence\":0,\"revertedCodeSpan\":0,\"droppedMarker\":0,\"guessedPair\":0,\ \"escapedChar\":0,\"tagEmphasis\":0,\"strippedHtml\":0,\"rewrittenBullet\":0,\"rewrittenTable\":0,\ \"convertedMarker\":1}\n\ - {\"line\":3,\"id\":7,\"closedEmphasis\":1,\"closedFence\":0,\"revertedCodeSpan\":0,\"droppedMarker\":0,\ + {\"line\":3,\"id\":7,\"closedEmphasis\":1,\"closedFence\":0,\"revertedCodeSpan\":0,\"droppedMarker\":0,\"guessedPair\":0,\ \"escapedChar\":0,\"tagEmphasis\":0,\"strippedHtml\":0,\"rewrittenBullet\":0,\"rewrittenTable\":0,\ \"convertedMarker\":0}\n" ); diff --git a/crates/mdwire-core/src/inline.rs b/crates/mdwire-core/src/inline.rs index 5a459d2..00326e7 100644 --- a/crates/mdwire-core/src/inline.rs +++ b/crates/mdwire-core/src/inline.rs @@ -325,6 +325,8 @@ impl Inline { } let left = can_open(prev, next) && !intraword; + // 여는 쪽이 막힌 자리인가 — 앞이 글자, 뒤가 구두점(`값**(합계)`). + let hemmed_here = prev.is_some_and(is_word_char) && next.is_some_and(|n| !n.is_whitespace() && is_punct(n)); match same { // **추측으로 연 것은 닫지 않는다.** 추측은 확정되지 않는다 — 닫아 주면 @@ -337,20 +339,35 @@ impl Inline { // 블록 끝까지 삼킨다. 글자다. // **막힌 여는 마커의 거울 짝.** `값**(합계)**를` 의 첫 `**` 는 앞이 글자, 뒤가 구두점이라 // 열지 못하고 추측으로 열렸다. 같은 줄에서 같은 길이의 마커가 거울 모양(앞이 구두점)으로 - // 오면 그 짝이다 — 닫는 쪽이 "구두점 + 조사" 인 모양을 닫아 주는 규칙의 거울이다. 글롭 - // (`.* (4개)`)·주석(`/* */`)은 여는 쪽 뒤가 공백이라, 마스킹 번호(`4***-****`)는 길이가 - // 달라 여기 걸리지 않는다. + // 오면 그 짝이다 — 닫는 쪽이 "구두점 + 조사" 인 모양을 닫아 주는 규칙의 거울이라, 닫는 + // 마커 **뒤에도 글자(조사)가 와야** 한다. 뒤가 공백이면 글롭(`underfront.*`)이고, 그걸 짝으로 + // 받으면 마스킹 번호 `4***-…-003*` 의 홑 `*` 가 같은 줄 멀리의 글롭과 묶인다(실측). 글롭·주석 + // (`/* */`)은 여는 쪽 뒤가 공백이라, 마스킹 번호는 길이가 달라 여기 걸리지 않는다. **양 끝이 다 ASCII 영숫자면 수식이다**(`x**(y)**z`, + // `2**(n-1)**2`) — 한국어 강조는 여는 쪽 앞이나 닫는 쪽 뒤에 한글이 온다. 백틱으로 + // 감싸 달라고 모델에게 부탁하지 않아도 되게 여기서 가른다. // 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 여는 마커다 — `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() && !is_opening_bracket(p)) => + && prev.is_some_and(|p| !is_word_char(p) && !p.is_whitespace() && !is_opening_bracket(p)) + && next.is_some_and(is_word_char) + && !(self.open[at].before.is_some_and(|c| c.is_ascii_alphanumeric()) + && next.is_some_and(|c| c.is_ascii_alphanumeric())) => { self.open[at].guess = false; + self.repairs.guessed_pair += 1; self.after_close = next; self.close_at(at, out, v) } + // 막힌 여는 마커가 또 왔다 — 앞의 것은 짝이 없었다(`x**(y)**z 와 값**(합계)**를` 의 `x**`). + // 글자로 되돌리고 이쪽을 새 막힌 마커로 연다. 두면 앞의 것이 뒤의 거울 짝을 가로챈다. + Some(at) if self.open[at].hemmed.is_some() && hemmed_here => { + self.reopen_at(at, out, v, (emph, take, c, after_space, prev, split)); + let top = self.open.last_mut().expect("방금 넣었다"); + top.guess = true; + top.hemmed = Some(self.line); + } Some(at) if self.open[at].guess && (!left || !after_space) @@ -405,8 +422,7 @@ 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), + hemmed: hemmed_here.then_some(self.line), line: self.line, }), } diff --git a/crates/mdwire-core/src/lib.rs b/crates/mdwire-core/src/lib.rs index 8782ad3..44aab73 100644 --- a/crates/mdwire-core/src/lib.rs +++ b/crates/mdwire-core/src/lib.rs @@ -173,7 +173,7 @@ pub enum Images { Load, } -/// Counts of what normalization repaired and what was changed to fit the channel. The first four +/// Counts of what normalization repaired and what was changed to fit the channel. The first five /// (repairs) measure **how often the model breaks formatting**; the last six (changes) measure /// **what a channel changes, before adopting it** — to ask about each separately, use /// [`Repairs::any`] and [`Repairs::changed`]. @@ -187,8 +187,14 @@ pub struct Repairs { pub reverted_code_span: usize, /// Orphaned `**` dropped (preceded by text, like `꼬리**`). pub dropped_marker: usize, + /// Emphasis paired by a guess rather than by the rules — an opener CommonMark would not open + /// (`값**(합계)**를`, a letter before it and punctuation after) matched with a mirror-shaped closer + /// on the same line. Math is left out — ASCII letters or digits on both ends (`x**(y)**z`) never + /// pair — so what remains is the rare shape that is bold or math only by context. A non-zero + /// count marks an answer worth a look. + pub guessed_pair: usize, /// Characters escaped because the channel would read them as syntax (GitHub's `\~`, `\<`, - /// `\*`). + /// `\*`; Slack's and Notion's `\*`). pub escaped_char: usize, /// Emphasis emitted differently because the channel cannot read the markers in that position /// (`**「설정」**가`) — `` on GitHub, U+2060 inserted inside the markers on Slack. @@ -214,6 +220,7 @@ impl Repairs { self.closed_fence += other.closed_fence; self.reverted_code_span += other.reverted_code_span; self.dropped_marker += other.dropped_marker; + self.guessed_pair += other.guessed_pair; self.escaped_char += other.escaped_char; self.tag_emphasis += other.tag_emphasis; self.stripped_html += other.stripped_html; @@ -222,12 +229,12 @@ impl Repairs { self.converted_marker += other.converted_marker; } - /// Whether normalization **repaired** anything — the first four (closed emphasis and fences, - /// reverted backticks, dropped markers). It asks whether the model broke formatting. Changes + /// Whether normalization **repaired** anything — the first five (closed emphasis and fences, + /// reverted backticks, dropped markers, guessed pairs). It asks whether the model broke formatting. Changes /// made to fit the channel (escapes, bullets, tables …) are not counted — that is /// [`Repairs::changed`]. pub fn any(&self) -> bool { - self.closed_emphasis + self.closed_fence + self.reverted_code_span + self.dropped_marker > 0 + self.closed_emphasis + self.closed_fence + self.reverted_code_span + self.dropped_marker + self.guessed_pair > 0 } /// Whether **anything at all** was done, repair or channel change. It asks whether the output diff --git a/crates/mdwire-core/src/vocab.rs b/crates/mdwire-core/src/vocab.rs index 61e83a1..a5e2c02 100644 --- a/crates/mdwire-core/src/vocab.rs +++ b/crates/mdwire-core/src/vocab.rs @@ -225,10 +225,16 @@ impl Vocab { /// **노션은 `*` 와 `\` 를 이스케이프한다**(실측 2026-10-01, 커넥터). 마스킹 번호가 GitHub 처럼 /// 뭉개지고(`1***-****-001*` → `1***-**--001*`), 홀로 쓴 `\` 는 사라진다. `~` 와 `<` 는 /// 노션이 글자로 그려 손대지 않는다. + /// + /// **슬랙 `markdown_text` 는 `*` 를 이스케이프한다**(실측 2026-10-02, `chat.postMessage`). 글자로 둔 + /// 별표를 슬랙이 CommonMark 로 다시 읽어 `x**(y)**z 와 2**(n-1)**2` 의 `**z 와 2**` 를, 마스킹 번호 + /// `4***-****-****-003*` 의 `-****-` 를 굵게로 그렸다. `\*` 는 글자로 그린다(`\~` `\<` `\_` 도). + /// `~` 는 홑으로는 긋지 않아 두고, `<` 는 슬랙이 태그로 읽지 않는다. pub fn escapes(&self, c: char) -> bool { match self.channel { Channel::GithubMarkdown => matches!(c, '~' | '<' | '*'), Channel::NotionMarkdown => matches!(c, '*' | '\\'), + Channel::SlackMarkdown => c == '*', _ => false, } } diff --git a/crates/mdwire-core/tests/render.rs b/crates/mdwire-core/tests/render.rs index f404ede..19aa0f5 100644 --- a/crates/mdwire-core/tests/render.rs +++ b/crates/mdwire-core/tests/render.rs @@ -849,6 +849,20 @@ fn report_counts_what_the_channel_rewrote() { assert_eq!(r("1) a\n2) b", Channel::SlackMarkdown).rewritten_bullet, 2); } +/// **슬랙은 글자 `*` 를 `\*` 로 낸다**(실측 2026-10-02). 글자로 둔 별표를 슬랙이 CommonMark 로 다시 읽어 +/// `**z 와 2**` 와 마스킹 번호의 `-****-` 를 굵게로 그렸다. `\*` 는 글자로 그린다. +#[test] +fn slack_escapes_literal_asterisks() { + let s = |x| one(x, Channel::SlackMarkdown); + assert_eq!(s("x**(y)**z 와 2**(n-1)**2"), "x\\*\\*(y)\\*\\*z 와 2\\*\\*(n-1)\\*\\*2"); + assert_eq!(s("카드 4***-****-003* 번호"), "카드 4\\*\\*\\*-\\*\\*\\*\\*-003\\* 번호"); + assert_eq!(s("underfront.* (4개), 2 ** 3"), "underfront.\\* (4개), 2 \\*\\* 3"); + // 마커는 그대로다 — 이스케이프는 글자로 판정된 별표에만. + assert_eq!(s("**굵게** 와 *기울임*"), "**굵게** 와 *기울임*"); + let r = mdwire::render_with("2 ** 3", Channel::SlackMarkdown, mdwire::Options::default()).repairs; + assert_eq!(r.escaped_char, 2, "{r:?}"); +} + /// **여는 쪽이 막힌 강조는 같은 줄의 거울 모양 마커와 짝짓는다**(`값**(합계)**를`). 짝이 없으면 버리지 않고 /// 글자로 되돌린다 — `2**(n-1)` 의 `**` 는 거듭제곱이다. 글롭 · 마스킹 번호 · 줄을 넘는 것은 그대로다. #[test] @@ -859,16 +873,28 @@ fn hemmed_opener_pairs_with_its_mirror_on_the_same_line() { assert_eq!(one("값**(합계)**를", Channel::GithubMarkdown), "값(합계)를"); assert_eq!(one("값**(합계)**를", Channel::SlackMarkdown), "값**\u{2060}(합계)\u{2060}**를"); assert_eq!(t("2**(n-1) 은 거듭제곱"), "2**(n-1) 은 거듭제곱"); + // 양 끝이 다 ASCII 영숫자면 수식이다 — 짝짓지 않고 글자로 둔다. + assert_eq!(t("x**(y)**z 와 2**(n-1)**2"), "x**(y)**z 와 2**(n-1)**2"); + assert_eq!(t("a**(b)**를 본다"), "a(b)를 본다"); + // 막힌 여는 마커가 또 오면 앞의 것은 글자로 — 앞의 것이 뒤의 거울 짝을 가로채면 안 된다. + assert_eq!(t("x**(y)**z 와 값**(합계)**를"), "x**(y)**z 와 값(합계)를"); + // 거울 짝은 뒤에 글자(조사)가 와야 한다 — 마스킹 번호의 홑 `*` 가 같은 줄 멀리의 글롭과 묶이면 안 된다. + assert_eq!(t("카드 4***-****-****-003* 번호, underfront.* (4개)"), "카드 4***-****-****-003* 번호, underfront.* (4개)"); + assert_eq!(t("값**(합계)**."), "값**(합계)**."); 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("2**(n-1) 【**주의**】"), "2**(n-1) 【주의】"); // 안 닫힌 코드 스팬을 되돌려 다시 읽을 때도 줄은 줄이다 — 줄을 넘은 거울과 짝짓지 않고, // 같은 줄에 있던 거울과는 짝짓는다. assert_eq!(t("`값**(합계)\n)**를 끝"), "`값**(합계)\n)**를 끝"); assert_eq!(t("값**(합계) `x )**를\ny"), "값(합계) `x )를\ny"); + // 추측으로 짝지은 것은 보고에서 센다 — 고친 것(`any`)에 든다. + let r = mdwire::render_with("값**(합계)**를 · 2**(n-1)", Channel::TelegramHtml, mdwire::Options::default()).repairs; + assert_eq!((r.guessed_pair, r.dropped_marker), (1, 0), "{r:?}"); + assert!(r.any()); } /// **GFM 이 마커를 못 읽는 자리의 강조는 태그로 낸다**(실측 2026-09-30). 닫는 `**` 앞이 @@ -934,7 +960,7 @@ fn br_inside_a_table_cell_keeps_the_row() { /// 슬랙도 글자로 둔다. 전에는 닫는 쪽만 삼켜 `** 띄운 굵게 는` 이 됐다 — 내용 손실이다. #[test] fn spaced_bold_markers_stay_as_text() { - assert_eq!(one("** 배포 ** 는 금지", Channel::SlackMarkdown), "** 배포 ** 는 금지"); + assert_eq!(one("** 배포 ** 는 금지", Channel::SlackMarkdown), "\\*\\* 배포 \\*\\* 는 금지"); assert_eq!(one("** 배포 ** 는 금지", Channel::TelegramHtml), "** 배포 ** 는 금지"); // 앞이 글자인 닫기는 여전히 닫는다 — `**굵게 **` 는 관대하게 굵게다. assert_eq!(one("**굵게 ** 끝", Channel::TelegramHtml), "굵게 끝"); diff --git a/crates/mdwire-harness/src/emphasis.rs b/crates/mdwire-harness/src/emphasis.rs index 1eddffb..1c040da 100644 --- a/crates/mdwire-harness/src/emphasis.rs +++ b/crates/mdwire-harness/src/emphasis.rs @@ -269,6 +269,8 @@ struct Open { guess: bool, /// 여는 쪽이 막힌 마커(`값**(합계)**를`)면 열린 줄 번호. 같은 줄의 거울 모양 마커와 짝짓는다. 규칙은 코어와 같다. hemmed: Option, + /// 여는 마커 앞 글자. 막힌 마커의 짝을 가를 때만 본다 — 양 끝이 ASCII 영숫자면 수식이다. + before: Option, buf: String, } @@ -425,11 +427,31 @@ fn scan_block(block: &str, mode: Mode, scan: &mut Scan) { && stack[at].marker.chars().count() == take && prev.is_some_and(|p| { !(p.is_alphabetic() || p.is_ascii_digit()) && !p.is_whitespace() && !is_opening_bracket(p) - }) => + }) + && next.is_some_and(|n| n.is_alphabetic() || n.is_ascii_digit()) + && !(stack[at].before.is_some_and(|c| c.is_ascii_alphanumeric()) + && next.is_some_and(|c| c.is_ascii_alphanumeric())) => { stack[at].guess = false; close_to(&mut stack, &mut root, at, scan) } + // 막힌 여는 마커가 또 왔다 — 앞의 것은 글자로 되돌리고 이쪽을 새 막힌 마커로 연다. 규칙은 코어와 같다. + Some(at) if stack[at].hemmed.is_some() && hemmed => { + while stack.len() > at + 1 { + let inner = stack.pop().expect("at 보다 위에 있다"); + if !inner.guess && !inner.buf.trim().is_empty() { + scan.raw.push(inner.buf.to_string()); + scan.spans.push(Span { kind: inner.kind, text: normalize_ws(&inner.buf) }); + } + let text = if inner.guess { format!("{}{}", inner.marker, inner.buf) } else { inner.buf }; + push_text(&mut stack, &mut root, &text); + } + let old = stack.pop().expect("at 은 유효한 인덱스다"); + *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: true, hemmed: Some(line), before: prev, buf: String::new() }); + } // 추측으로 연 것은 닫지 않는다 — 닫는 자리의 마커는 글자다. 규칙은 코어와 같다. // 막힌 추측 뒤의 `(**주의` 는 여는 마커라 아래로 보낸다(`2**(n-1) (**주의**)`). Some(at) @@ -456,7 +478,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, hemmed: None, buf: String::new() }); + stack.push(Open { kind, marker, guess: false, hemmed: None, before: None, buf: String::new() }); } // 같은 종류가 열려 있고 앞이 공백이 아니면 닫는 자리다. Some(at) if !after_space && closes => close_to(&mut stack, &mut root, at, scan), @@ -474,7 +496,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, hemmed: None, buf: String::new() }); + stack.push(Open { kind, marker, guess: false, hemmed: None, before: None, buf: String::new() }); } // 안쪽에 다른 종류가 열려 있으면 갈아 끼우지 못한다. 버린다. Some(_) if left => { @@ -488,12 +510,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, hemmed: None, buf: String::new() }), - None if left => stack.push(Open { kind, marker, guess: false, hemmed: None, buf: String::new() }), + Some(_) => stack.push(Open { kind, marker, guess: false, hemmed: None, before: None, buf: String::new() }), + None if left => stack.push(Open { kind, marker, guess: false, hemmed: None, before: None, buf: String::new() }), // 열 수도 닫을 수도 없다. 그래도 80열 wrap 이 `... **\n강조**` 를 만들어 낸다. // 일단 열어 두고, 안 닫히면 글자로 되돌린다. None if mode == Mode::Repair || (mode == Mode::Strict && hemmed) => { - stack.push(Open { kind, marker, guess: true, hemmed: hemmed.then_some(line), buf: String::new() }) + stack.push(Open { kind, marker, guess: true, hemmed: hemmed.then_some(line), before: prev, buf: String::new() }) } None => { *scan.literal.entry(c).or_default() += take; diff --git a/crates/mdwire-wasm/src/lib.rs b/crates/mdwire-wasm/src/lib.rs index c02b988..dd75458 100644 --- a/crates/mdwire-wasm/src/lib.rs +++ b/crates/mdwire-wasm/src/lib.rs @@ -130,7 +130,10 @@ pub struct Repairs { /// Unmatched `**` markers that were dropped. #[wasm_bindgen(js_name = droppedMarker)] pub dropped_marker: usize, - /// Characters escaped because the channel would read them as syntax (GitHub's `\~`, `\<`, `\*`). + /// Emphasis paired by a guess — an opener CommonMark would not open (`값**(합계)**를`) matched with a mirror-shaped closer on the same line. Math (`x**(y)**z`) is left out. + #[wasm_bindgen(js_name = guessedPair)] + pub guessed_pair: usize, + /// Characters escaped because the channel would read them as syntax (GitHub's `\~`, `\<`, `\*`; Slack's and Notion's `\*`). #[wasm_bindgen(js_name = escapedChar)] pub escaped_char: usize, /// Emphasis emitted differently because the channel would not read the marker in that position (GitHub's ``, Slack's U+2060). @@ -157,6 +160,7 @@ impl From for Repairs { closed_fence: r.closed_fence, reverted_code_span: r.reverted_code_span, dropped_marker: r.dropped_marker, + guessed_pair: r.guessed_pair, escaped_char: r.escaped_char, tag_emphasis: r.tag_emphasis, stripped_html: r.stripped_html, diff --git a/go/cmd/mdwire/main.go b/go/cmd/mdwire/main.go index bf2d839..d052196 100644 --- a/go/cmd/mdwire/main.go +++ b/go/cmd/mdwire/main.go @@ -213,10 +213,10 @@ func writeReport(w io.Writer, r mdwire.Repairs) error { } func repairsFields(r mdwire.Repairs) string { - return fmt.Sprintf("\"closedEmphasis\":%d,\"closedFence\":%d,\"revertedCodeSpan\":%d,\"droppedMarker\":%d,"+ + return fmt.Sprintf("\"closedEmphasis\":%d,\"closedFence\":%d,\"revertedCodeSpan\":%d,\"droppedMarker\":%d,\"guessedPair\":%d,"+ "\"escapedChar\":%d,\"tagEmphasis\":%d,\"strippedHtml\":%d,\"rewrittenBullet\":%d,"+ "\"rewrittenTable\":%d,\"convertedMarker\":%d", - r.ClosedEmphasis, r.ClosedFence, r.RevertedCodeSpan, r.DroppedMarker, + r.ClosedEmphasis, r.ClosedFence, r.RevertedCodeSpan, r.DroppedMarker, r.GuessedPair, r.EscapedChar, r.TagEmphasis, r.StrippedHTML, r.RewrittenBullet, r.RewrittenTable, r.ConvertedMarker) } diff --git a/go/cmd/mdwire/main_test.go b/go/cmd/mdwire/main_test.go index dd4c128..0dc8bda 100644 --- a/go/cmd/mdwire/main_test.go +++ b/go/cmd/mdwire/main_test.go @@ -52,7 +52,7 @@ func TestBatchKeepsGoingAndCountsFailures(t *testing.T) { t.Fatalf("에러가 %v — 못 읽은 11줄을 세야 한다\n%s", err, out.String()) } lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") - if got := lines[0]; got != `{"line":1,"id":"a\"1<&>","closedEmphasis":0,"closedFence":0,"revertedCodeSpan":0,"droppedMarker":0,`+ + if got := lines[0]; got != `{"line":1,"id":"a\"1<&>","closedEmphasis":0,"closedFence":0,"revertedCodeSpan":0,"droppedMarker":0,"guessedPair":0,`+ `"escapedChar":0,"tagEmphasis":0,"strippedHtml":0,"rewrittenBullet":0,"rewrittenTable":0,"convertedMarker":1}` { t.Errorf("1줄: %s", got) } diff --git a/go/fuzz_test.go b/go/fuzz_test.go index 426e468..ad9d781 100644 --- a/go/fuzz_test.go +++ b/go/fuzz_test.go @@ -175,9 +175,9 @@ func TestParityWithRustCore(t *testing.T) { failures++ } r := rendered.Repairs - report := fmt.Sprintf(`{"closedEmphasis":%d,"closedFence":%d,"revertedCodeSpan":%d,"droppedMarker":%d,`+ + report := fmt.Sprintf(`{"closedEmphasis":%d,"closedFence":%d,"revertedCodeSpan":%d,"droppedMarker":%d,"guessedPair":%d,`+ `"escapedChar":%d,"tagEmphasis":%d,"strippedHtml":%d,"rewrittenBullet":%d,"rewrittenTable":%d,"convertedMarker":%d}`, - r.ClosedEmphasis, r.ClosedFence, r.RevertedCodeSpan, r.DroppedMarker, + r.ClosedEmphasis, r.ClosedFence, r.RevertedCodeSpan, r.DroppedMarker, r.GuessedPair, r.EscapedChar, r.TagEmphasis, r.StrippedHTML, r.RewrittenBullet, r.RewrittenTable, r.ConvertedMarker) if report != strings.TrimSpace(stderr.String()) { t.Errorf("#%d %s: 고친 것이 러스트와 다르다\n 입력: %q\n go %s\n rust %s", round, ch.Name(), input, report, stderr.String()) diff --git a/go/inline.go b/go/inline.go index f9f3bc9..5fe248e 100644 --- a/go/inline.go +++ b/go/inline.go @@ -257,6 +257,8 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { } left := canOpen(prev, next) && !intraword + // 여는 쪽이 막힌 자리인가 — 앞이 글자, 뒤가 구두점. 러스트 쪽 hemmed_here. + hemmedHere := prev != noChar && isWordChar(prev) && next != noChar && !unicode.IsSpace(next) && isPunct(next) fresh := openMark{emph: e, run: take, ch: c, afterSpace: afterSpace, before: prev, split: split} switch { @@ -265,11 +267,21 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { // 추측을 닫지 않고 열지도 않는다 — 열면 블록 끝까지 삼킨다. 글자다. // 막힌 여는 마커의 거울 짝 — 러스트 쪽과 같다(`값**(합계)**를`). // 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 여는 마커다(`2**(n-1) (**주의**)`). + // 양 끝이 다 ASCII 영숫자면 수식이라 짝짓지 않는다(`x**(y)**z`). case same >= 0 && in.open[same].hemmed == in.line+1 && in.open[same].run == take && - prev != noChar && !isWordChar(prev) && !unicode.IsSpace(prev) && !isOpeningBracket(prev): + prev != noChar && !isWordChar(prev) && !unicode.IsSpace(prev) && !isOpeningBracket(prev) && + next != noChar && isWordChar(next) && + !(isASCIIAlnum(in.open[same].before) && next != noChar && isASCIIAlnum(next)): in.open[same].guess = false + in.repairs.GuessedPair++ in.afterClose = next in.closeAt(same, out, v) + // 막힌 여는 마커가 또 왔다 — 앞의 것은 글자로 되돌리고 이쪽을 새 막힌 마커로 연다. 러스트 쪽과 같다. + case same >= 0 && in.open[same].hemmed != 0 && hemmedHere: + in.reopenAt(same, out, v, fresh) + top := &in.open[len(in.open)-1] + top.guess = true + top.hemmed = in.line + 1 case same >= 0 && in.open[same].guess && (!left || !afterSpace) && !(left && in.open[same].hemmed != 0 && isOpeningBracket(prev)): for k := 0; k < take; k++ { @@ -298,7 +310,7 @@ 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) { + if hemmedHere { fresh.hemmed = in.line + 1 } in.open = append(in.open, fresh) diff --git a/go/inline_test.go b/go/inline_test.go index 862fa5e..b86a59a 100644 --- a/go/inline_test.go +++ b/go/inline_test.go @@ -132,6 +132,9 @@ func TestHemmedOpenerPairsWithItsMirror(t *testing.T) { "값**(합계)**를 본다": "값(합계)를 본다", "이름이**\"홍길동\"**이다": "이름이\"홍길동\"이다", "2**(n-1) 은 거듭제곱": "2**(n-1) 은 거듭제곱", + "x**(y)**z 와 2**(n-1)**2": "x**(y)**z 와 2**(n-1)**2", + "a**(b)**를 본다": "a(b)를 본다", + "x**(y)**z 와 값**(합계)**를": "x**(y)**z 와 값(합계)를", "마스킹 4***-****-****-003* 번호": "마스킹 4***-****-****-003* 번호", "값**(합계)\n**다음** 줄": "값**(합계)\n다음 줄", "underfront.* (4개), minjunkim.* (3개)": "underfront.* (4개), minjunkim.* (3개)", @@ -145,3 +148,26 @@ func TestHemmedOpenerPairsWithItsMirror(t *testing.T) { } } } + +// 추측으로 짝지은 강조는 보고에서 센다 — 러스트 쪽과 같다. +func TestGuessedPairIsReported(t *testing.T) { + r := RenderWith("값**(합계)**를 · 2**(n-1) · x**(y)**z", TelegramHTML, Options{}).Repairs + if r.GuessedPair != 1 || r.DroppedMarker != 0 || !r.Repaired() { + t.Errorf("%+v", r) + } +} + +// 슬랙은 글자 별표를 이스케이프한다 — 러스트 쪽 slack_escapes_literal_asterisks. +func TestSlackEscapesLiteralAsterisks(t *testing.T) { + cases := map[string]string{ + "x**(y)**z 와 2**(n-1)**2": `x\*\*(y)\*\*z 와 2\*\*(n-1)\*\*2`, + "underfront.* (4개), 2 ** 3": `underfront.\* (4개), 2 \*\* 3`, + "**굵게** 와 *기울임*": "**굵게** 와 *기울임*", + "카드 4***-****-003* 번호, underfront.* (4개)": `카드 4\*\*\*-\*\*\*\*-003\* 번호, underfront.\* (4개)`, + } + for in, want := range cases { + if got := strings.Join(Render(in, SlackMarkdown), "\x00"); got != want { + t.Errorf("%q:\n got %q\n want %q", in, got, want) + } + } +} diff --git a/go/mdwire.go b/go/mdwire.go index ae3d5e4..065e77a 100644 --- a/go/mdwire.go +++ b/go/mdwire.go @@ -78,7 +78,7 @@ const ( ) // Repairs counts what normalization fixed and what was changed to fit the -// channel. The first four fields (fixes) measure how often a model breaks +// channel. The first five fields (fixes) measure how often a model breaks // formatting. The last six (changes) measure what a channel changes, before // you adopt it. To ask about each group separately, use Repaired and Changed. type Repairs struct { @@ -90,6 +90,10 @@ type Repairs struct { RevertedCodeSpan int // DroppedMarker counts unmatched `**` markers that were dropped. DroppedMarker int + // GuessedPair counts emphasis paired by a guess rather than by the rules — an opener CommonMark + // would not open (`값**(합계)**를`) matched with a mirror-shaped closer on the same line. Math + // (`x**(y)**z`, ASCII on both ends) is left out. A non-zero count marks an answer worth a look. + GuessedPair int // EscapedChar counts characters escaped because the channel would read them // as syntax (GitHub's \~, \< and \*). EscapedChar int @@ -109,9 +113,9 @@ type Repairs struct { } // Repaired reports whether normalization fixed anything. It looks only at the -// first four fields. Repairs::any in the Rust core. +// first five fields. Repairs::any in the Rust core. func (r Repairs) Repaired() bool { - return r.ClosedEmphasis+r.ClosedFence+r.RevertedCodeSpan+r.DroppedMarker > 0 + return r.ClosedEmphasis+r.ClosedFence+r.RevertedCodeSpan+r.DroppedMarker+r.GuessedPair > 0 } // Changed reports whether anything was fixed or changed to fit the channel. @@ -123,6 +127,7 @@ func (r *Repairs) add(o Repairs) { r.ClosedFence += o.ClosedFence r.RevertedCodeSpan += o.RevertedCodeSpan r.DroppedMarker += o.DroppedMarker + r.GuessedPair += o.GuessedPair r.EscapedChar += o.EscapedChar r.TagEmphasis += o.TagEmphasis r.StrippedHTML += o.StrippedHTML diff --git a/go/testdata/block.slack-markdown.txt b/go/testdata/block.slack-markdown.txt index 00f75e3..f7cd9c8 100644 --- a/go/testdata/block.slack-markdown.txt +++ b/go/testdata/block.slack-markdown.txt @@ -58,7 +58,7 @@ tail -f app.log | grep ERROR 다음 줄 ---- 문단에서 *기울임이 열리고* -> 인용 줄이 온다* 뒤 +> 인용 줄이 온다\* 뒤 ---- `코드 안의 |` 는 | 셀이 아니다 ---- diff --git a/go/testdata/inline.slack-markdown.txt b/go/testdata/inline.slack-markdown.txt index bb2d0eb..a0ee71a 100644 --- a/go/testdata/inline.slack-markdown.txt +++ b/go/testdata/inline.slack-markdown.txt @@ -10,19 +10,19 @@ ---- **코드 `a`⁠**였다 ---- -2 ** 3 은 곱셈이 아니다 +2 \*\* 3 은 곱셈이 아니다 ---- -참고* +참고\* ---- -온다* 뒤에 더 있다 +온다\* 뒤에 더 있다 ---- 꼬리 ---- -underfront.* (4개), minjunkim.* (3개) +underfront.\* (4개), minjunkim.\* (3개) ---- -/* a */ 다음 *z* 끝 +/\* a \*/ 다음 *z* 끝 ---- -a*** **x** +a\*\*\* **x** ---- ***세 개 별표*** ---- @@ -42,7 +42,7 @@ snake_case 와 plain_text_name 은 그대로다 ---- *진료*가 먼저고 *처방*은 나중이다. ---- -** 배포 ** 는 금지다. +\*\* 배포 \*\* 는 금지다. ---- **굵게 ⁠** 끝 ---- @@ -87,7 +87,7 @@ b** ---- [[위키링크]] 와 [텍스트] ---- -빈 강조 **** 와 ` ` +빈 강조 \*\*\*\* 와 ` ` ---- ` ` 빈 이중 백틱 ---- diff --git a/go/vocab.go b/go/vocab.go index ee651ac..3c2f0b4 100644 --- a/go/vocab.go +++ b/go/vocab.go @@ -274,6 +274,10 @@ func (v vocab) escapes(c rune) bool { case NotionMarkdown: // 노션은 마스킹 번호의 별표를 먹고 홀로 쓴 역슬래시를 지운다(실측 2026-10-01). return c == '*' || c == '\\' + case SlackMarkdown: + // 슬랙은 글자로 둔 별표를 CommonMark 로 다시 읽어 `x**(y)**z` 의 `**z 와 2**` 와 마스킹 번호의 + // `-****-` 를 굵게로 그렸다. `\*` 는 글자로 그린다(실측 2026-10-02). + return c == '*' } return false } diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 1768433..3f5944c 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -55,6 +55,8 @@ assert.equal(report.repairs.closedEmphasis, 1); assert.equal(report.repairs.closedFence, 1); // 채널에 맞춰 바꾼 것도 센다 — GitHub 의 `\~` 이스케이프. assert.equal(renderWithReport("약 ~40km", "github-markdown").repairs.escapedChar, 1); +// 추측으로 짝지은 강조도 센다 — 여는 쪽이 막힌 `값**(합계)**를`. +assert.equal(renderWithReport("값**(합계)**를", "telegram-html").repairs.guessedPair, 1); const rs = new Streamer("slack-markdown"); rs.push("**열고 안 닫힘"); rs.finish(); diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 3640ae2..d0ad2a1 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -12,7 +12,7 @@ const revised: boolean = s.revised(); const opts: RenderOptions = { limit: 4096 }; const withOpts: string[] = render("*굵게*", "slack-markdown", opts); const report = renderWithReport("**열림", "telegram-html", opts); -const closed: number = report.repairs.closedEmphasis + s.repairs().closedFence; +const closed: number = report.repairs.closedEmphasis + s.repairs().closedFence + report.repairs.guessedPair; const reportParts: string[] = report.parts; void [parts, n, out, withOpts, closed, reportParts]; diff --git a/site/src/components/ChannelCompare.tsx b/site/src/components/ChannelCompare.tsx index 3cdd5f0..d9c6c46 100644 --- a/site/src/components/ChannelCompare.tsx +++ b/site/src/components/ChannelCompare.tsx @@ -12,6 +12,7 @@ const channels = [ ] as const; // 정규화 보고의 열 가지 수 — 앞 넷은 고친 것, 뒤 여섯은 채널에 맞춰 바꾼 것. +// npm 에 올라간 판의 필드다 — 다음 판의 `guessedPair` 는 사이트가 그 판으로 올라갈 때 더한다. const keys = [ "closedEmphasis", "closedFence", diff --git a/site/src/components/RepairCompare.tsx b/site/src/components/RepairCompare.tsx index 29faa41..206b422 100644 --- a/site/src/components/RepairCompare.tsx +++ b/site/src/components/RepairCompare.tsx @@ -26,6 +26,7 @@ const channels = [ type ChannelId = (typeof channels)[number]["id"]; // 정규화 보고 — 앞 넷은 고친 것, 뒤 여섯은 채널에 맞춰 바꾼 것. +// npm 에 올라간 판의 필드다 — 다음 판의 `guessedPair` 는 사이트가 그 판으로 올라갈 때 더한다. const fixed = ["closedEmphasis", "closedFence", "revertedCodeSpan", "droppedMarker"] as const; const rewritten = [ "escapedChar", diff --git a/site/src/pages/docs/cli.mdx b/site/src/pages/docs/cli.mdx index eec9d96..a9c6cf7 100644 --- a/site/src/pages/docs/cli.mdx +++ b/site/src/pages/docs/cli.mdx @@ -59,7 +59,7 @@ One JSON line to stderr: {"closedEmphasis":1,"closedFence":0,"revertedCodeSpan":0,"droppedMarker":0,"escapedChar":0,"tagEmphasis":0,"strippedHtml":0,"rewrittenBullet":0,"rewrittenTable":0,"convertedMarker":0} ``` -The first four keys are repairs; the other six are channel rewrites. What each counts: +The first four keys are repairs; the other six are channel rewrites. A fifth repair key, `guessedPair`, follows `droppedMarker` . What each counts: [the repair report](/docs/#the-repair-report). ## `--batch jsonl` diff --git a/site/src/pages/docs/go.mdx b/site/src/pages/docs/go.mdx index 8340555..0203e05 100644 --- a/site/src/pages/docs/go.mdx +++ b/site/src/pages/docs/go.mdx @@ -95,7 +95,7 @@ store or send a channel. ```go type Repairs struct { - ClosedEmphasis, ClosedFence, RevertedCodeSpan, DroppedMarker int + ClosedEmphasis, ClosedFence, RevertedCodeSpan, DroppedMarker, GuessedPair int // channel rewrites EscapedChar, TagEmphasis, StrippedHTML, RewrittenBullet, RewrittenTable, ConvertedMarker int } diff --git a/site/src/pages/docs/index.mdx b/site/src/pages/docs/index.mdx index b609018..4a58496 100644 --- a/site/src/pages/docs/index.mdx +++ b/site/src/pages/docs/index.mdx @@ -23,7 +23,7 @@ A channel is where the output goes. Its name is the same string everywhere. | Name | Goes to | Limit | |---|---|---| | `telegram-html` | Telegram `parse_mode: "HTML"`. Nine tags (`b i u s code pre a blockquote tg-spoiler`), no headings or tables — a table goes out as a monospace block. | 4,096 | -| `slack-markdown` | Slack `markdown_text`. Standard Markdown, which Slack converts itself; mdwire only repairs and splits. | 12,000 | +| `slack-markdown` | Slack `markdown_text`. Standard Markdown, which Slack converts itself; mdwire repairs, escapes a `*` meant as a character (`\*`) and splits. | 12,000 | | `github-markdown` | GitHub comments and PR bodies (GFM). A `~` or `<` meant as a character is escaped, and bold that GFM would not close (before a Korean particle) is written as ``. | 65,536 | | `notion-markdown` | Notion page body (Notion-flavored Markdown). Four heading levels; inline HTML Notion cannot draw is stripped, and an autolink becomes `[url](url)`. | 65,536 (not measured) | | `plain` | Plain text — every marker removed. The fallback. | 12,000 | @@ -45,7 +45,7 @@ The three `html` options apply only to the `html` channel. ## The repair report -Rendering also counts what it had to do. The first four are **repairs** — how often the model broke +Rendering also counts what it had to do. The first five are **repairs** — how often the model broke its own formatting: | Field | Counts | @@ -54,12 +54,13 @@ its own formatting: | closed fence | A code fence still open at the end of the document, closed. | | reverted code span | A backtick run with no partner, kept as text. | | dropped marker | A stray `**` dropped (`tail**`, where text comes before it). | +| guessed pair | Emphasis paired by a guess rather than by the rules — an opener CommonMark would not open (`값**(합계)**를`) matched with a mirror-shaped closer on the same line. Math with ASCII on both ends (`x**(y)**z`) is left out. A non-zero count marks an answer worth a look. | The other six are **channel rewrites** — what the channel needed changed before posting: | Field | Counts | |---|---| -| escaped char | Characters the channel would read as syntax, escaped (GitHub's `\~` `\<` `\*`). | +| escaped char | Characters the channel would read as syntax, escaped (GitHub's `\~` `\<` `\*`; Slack's and Notion's `\*`). | | tag emphasis | Emphasis the channel cannot read as markers, written another way — GitHub's ``, or an invisible U+2060 inside Slack's markers . | | stripped HTML | Source HTML removed: tags the channel cannot draw, comments, `
` turned into a line break. | | rewritten bullet | List markers rewritten (`* ` `• ` → `- `, Telegram's `- ` → `• `, `1)` → `1.`). | diff --git a/site/src/pages/docs/npm.mdx b/site/src/pages/docs/npm.mdx index 93e2a94..e5fa084 100644 --- a/site/src/pages/docs/npm.mdx +++ b/site/src/pages/docs/npm.mdx @@ -3,6 +3,7 @@ layout: ../../layouts/Doc.astro title: npm (JS · React) description: The @minjun0219/mdwire package — render, renderWithReport, Streamer, and the React and events entry points. --- +import Next from "../../components/Next.astro"; import Badges from "../../components/Badges.astro"; @@ -102,7 +103,7 @@ integer throws an `Error`. | Field | | |---|---| -| `closedEmphasis` · `closedFence` · `revertedCodeSpan` · `droppedMarker` | Repairs | +| `closedEmphasis` · `closedFence` · `revertedCodeSpan` · `droppedMarker` · `guessedPair` | Repairs | | `escapedChar` · `tagEmphasis` · `strippedHtml` · `rewrittenBullet` · `rewrittenTable` · `convertedMarker` | Channel rewrites | What each counts: [Concepts](/docs/#the-repair-report). diff --git a/site/src/pages/docs/rust.mdx b/site/src/pages/docs/rust.mdx index 1cc3fe0..aeb6396 100644 --- a/site/src/pages/docs/rust.mdx +++ b/site/src/pages/docs/rust.mdx @@ -112,6 +112,7 @@ pub struct Repairs { pub closed_fence: usize, pub reverted_code_span: usize, pub dropped_marker: usize, + pub guessed_pair: usize, // channel rewrites pub escaped_char: usize, pub tag_emphasis: usize, diff --git a/site/src/pages/ko/docs/cli.mdx b/site/src/pages/ko/docs/cli.mdx index d99f17d..1137d2b 100644 --- a/site/src/pages/ko/docs/cli.mdx +++ b/site/src/pages/ko/docs/cli.mdx @@ -58,7 +58,7 @@ stderr 에 JSON 한 줄을 출력합니다. {"closedEmphasis":1,"closedFence":0,"revertedCodeSpan":0,"droppedMarker":0,"escapedChar":0,"tagEmphasis":0,"strippedHtml":0,"rewrittenBullet":0,"rewrittenTable":0,"convertedMarker":0} ``` -앞의 네 키는 고친 것이고, 뒤의 여섯 키는 채널에 맞춰 바꾼 것입니다. 각 키가 세는 것은 +앞의 네 키는 고친 것이고, 뒤의 여섯 키는 채널에 맞춰 바꾼 것입니다. 고친 것에 다섯 번째 키 `guessedPair` 가 `droppedMarker` 뒤에 더해집니다 . 각 키가 세는 것은 [정규화 보고](/ko/docs/#정규화-보고)에 있습니다. ## `--batch jsonl` diff --git a/site/src/pages/ko/docs/go.mdx b/site/src/pages/ko/docs/go.mdx index eac4219..1d9ed1b 100644 --- a/site/src/pages/ko/docs/go.mdx +++ b/site/src/pages/ko/docs/go.mdx @@ -94,7 +94,7 @@ const ( TelegramHTML Channel = iota; SlackMarkdown; Plain; GithubMarkdown; HTML; ```go type Repairs struct { - ClosedEmphasis, ClosedFence, RevertedCodeSpan, DroppedMarker int + ClosedEmphasis, ClosedFence, RevertedCodeSpan, DroppedMarker, GuessedPair int // 채널에 맞춰 바꾼 것 EscapedChar, TagEmphasis, StrippedHTML, RewrittenBullet, RewrittenTable, ConvertedMarker int } diff --git a/site/src/pages/ko/docs/index.mdx b/site/src/pages/ko/docs/index.mdx index 0d298bf..1823c9e 100644 --- a/site/src/pages/ko/docs/index.mdx +++ b/site/src/pages/ko/docs/index.mdx @@ -22,7 +22,7 @@ Rust 코어, npm 패키지, Go 구현, CLI 는 같은 입력을 받아 같은 | 이름 | 보내는 곳 | 길이 제한 | |---|---|---| | `telegram-html` | 텔레그램 `parse_mode: "HTML"` 입니다. 태그 아홉 개(`b i u s code pre a blockquote tg-spoiler`)만 받습니다. 헤딩과 표가 없어서 표는 고정폭 블록으로 출력합니다. | 4,096 | -| `slack-markdown` | 슬랙 `markdown_text` 입니다. 표준 마크다운을 슬랙이 직접 변환하므로 mdwire 는 정규화와 분할만 합니다. | 12,000 | +| `slack-markdown` | 슬랙 `markdown_text` 입니다. 표준 마크다운을 슬랙이 직접 변환하므로 mdwire 는 정규화와 분할을 하고, 문자로 쓴 `*` 는 `\*` 로 출력합니다. | 12,000 | | `github-markdown` | GitHub 코멘트와 PR 본문(GFM)입니다. 문자로 쓴 `~` `<` 는 이스케이프합니다. GFM 이 닫지 않는 굵게(한국어 조사 앞)는 `` 으로 출력합니다. | 65,536 | | `notion-markdown` | 노션 페이지 본문(Notion-flavored Markdown)입니다. 헤딩은 네 단계까지 씁니다. 노션이 렌더링하지 못하는 인라인 HTML 은 제거하고 오토링크는 `[url](url)` 로 씁니다. | 65,536 (측정 안 함) | | `plain` | 평문입니다. 마커를 모두 빼므로 폴백용으로 씁니다. | 12,000 | @@ -44,7 +44,7 @@ Rust 코어, npm 패키지, Go 구현, CLI 는 같은 입력을 받아 같은 ## 정규화 보고 -mdwire 는 렌더링하면서 한 일도 셉니다. 앞의 네 가지는 **고친 것**으로, 모델이 자기 서식을 얼마나 자주 +mdwire 는 렌더링하면서 한 일도 셉니다. 앞의 다섯 가지는 **고친 것**으로, 모델이 자기 서식을 얼마나 자주 깨는지 보여 줍니다. | 필드 | 세는 것 | @@ -53,13 +53,14 @@ mdwire 는 렌더링하면서 한 일도 셉니다. 앞의 네 가지는 **고 | 닫아 준 펜스 | 문서 끝까지 닫히지 않아서 닫아 준 코드펜스를 셉니다. | | 되돌린 코드 스팬 | 짝이 없어서 문자로 되돌린 연속된 백틱을 셉니다. | | 버린 마커 | 짝이 없어서 버린 `**` 를 셉니다. `꼬리**` 처럼 앞에 글자가 붙은 마커가 해당합니다. | +| 추측으로 짝지은 강조 | 규칙이 아니라 추측으로 짝지은 강조를 셉니다. CommonMark 가 열지 않는 여는 마커(`값**(합계)**를`)를 같은 줄의 거울 모양 닫는 마커와 짝지은 횟수입니다. 양 끝이 ASCII 영숫자인 수식(`x**(y)**z`)은 세지 않습니다. 0 이 아니면 한 번 볼 만한 답변입니다. | 뒤의 여섯 가지는 **채널에 맞춰 바꾼 것**입니다. 원문에는 문제가 없어도 채널이 그대로 받지 못해서 보내기 전에 바꿔 쓴 것을 셉니다. | 필드 | 세는 것 | |---|---| -| 이스케이프한 글자 | 채널이 구문으로 읽을 수 있어서 이스케이프한 글자를 셉니다(GitHub 의 `\~` `\<` `\*`). | +| 이스케이프한 글자 | 채널이 구문으로 읽을 수 있어서 이스케이프한 글자를 셉니다(GitHub 의 `\~` `\<` `\*`, 슬랙·노션의 `\*`). | | 태그로 출력한 강조 | 채널이 마커로 읽지 못하는 자리라서 다르게 출력한 강조를 셉니다. GitHub 은 `` 으로 출력하고 슬랙은 마커 안쪽에 보이지 않는 U+2060 을 넣습니다 . | | 제거한 HTML | 제거한 원문 HTML 을 셉니다. 채널이 렌더링하지 못하는 태그, 주석, 줄바꿈으로 바꾼 `
` 이 해당합니다. | | 바꿔 쓴 불릿 | 채널 표기로 바꾼 목록 기호를 셉니다(`* ` `• ` → `- `, 텔레그램은 `- ` → `• `, `1)` → `1.`). | diff --git a/site/src/pages/ko/docs/npm.mdx b/site/src/pages/ko/docs/npm.mdx index f105857..136d6a2 100644 --- a/site/src/pages/ko/docs/npm.mdx +++ b/site/src/pages/ko/docs/npm.mdx @@ -3,6 +3,7 @@ layout: ../../../layouts/Doc.astro title: npm (JS · React) description: "@minjun0219/mdwire 패키지의 render, renderWithReport, Streamer, 그리고 React · events 진입점을 설명합니다." --- +import Next from "../../../components/Next.astro"; import Badges from "../../../components/Badges.astro"; @@ -101,7 +102,7 @@ interface RenderOptions { | 필드 | | |---|---| -| `closedEmphasis` · `closedFence` · `revertedCodeSpan` · `droppedMarker` | 고친 횟수입니다. | +| `closedEmphasis` · `closedFence` · `revertedCodeSpan` · `droppedMarker` · `guessedPair` | 고친 횟수입니다. | | `escapedChar` · `tagEmphasis` · `strippedHtml` · `rewrittenBullet` · `rewrittenTable` · `convertedMarker` | 채널에 맞춰 바꾼 횟수입니다. | 각 필드가 세는 것은 [Concepts](/ko/docs/#정규화-보고)에 있습니다. diff --git a/site/src/pages/ko/docs/rust.mdx b/site/src/pages/ko/docs/rust.mdx index fbf8e7a..b8daea0 100644 --- a/site/src/pages/ko/docs/rust.mdx +++ b/site/src/pages/ko/docs/rust.mdx @@ -114,6 +114,7 @@ pub struct Repairs { pub closed_fence: usize, pub reverted_code_span: usize, pub dropped_marker: usize, + pub guessed_pair: usize, // 채널에 맞춰 바꾼 것 pub escaped_char: usize, pub tag_emphasis: usize, diff --git a/site/src/webmcp.ts b/site/src/webmcp.ts index 3fad1fd..8171947 100644 --- a/site/src/webmcp.ts +++ b/site/src/webmcp.ts @@ -5,6 +5,7 @@ const channels = ["telegram-html", "slack-markdown", "github-markdown", "notion-markdown", "plain", "html"] as const; // 정규화 보고의 열 가지 수 — ChannelCompare 와 같다. +// npm 에 올라간 판의 필드다 — 다음 판의 `guessedPair` 는 사이트가 그 판으로 올라갈 때 더한다. const repairKeys = [ "closedEmphasis", "closedFence", diff --git a/skills/mdwire/SKILL.md b/skills/mdwire/SKILL.md index 2887e23..14b14d7 100644 --- a/skills/mdwire/SKILL.md +++ b/skills/mdwire/SKILL.md @@ -114,4 +114,5 @@ const { elements, push, finish } = useMarkdownStream(); // streaming: push - `render` is enough when you do not need the repair report. - `limit` below 256 is raised to 256; every part needs room to close and reopen markup. - The repair report (`renderWithReport`, `mdwire --report`) counts how often the model broke its own - formatting — useful to log per answer. + formatting — useful to log per answer. `guessedPair` counts emphasis mdwire paired by a guess + (`값**(합계)**를`); a non-zero count marks an answer worth a look. From 15f5fc0895d9c15ebd15789a6fd5cc89256d998f Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Fri, 2 Oct 2026 23:23:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(fuzz):=20=ED=95=9C=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=20=EC=A1=B0=EC=82=AC=20=EC=95=9E=20=EA=B0=95=EC=A1=B0=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=EC=9D=84=20=ED=8D=BC=EC=A6=88=20=EC=95=8C?= =?UTF-8?q?=ED=8C=8C=EB=B2=B3=EC=97=90=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닫는 쪽·여는 쪽이 막힌 모양, 겹친 강조, 수식, 낫표를 부품으로 넣어 새 규칙이 스트리밍 일치와 Rust↔Go 대조에 실제로 걸리게 한다. Rust 20,000회 · Go 4,000회(Rust CLI 대조) 통과. Co-Authored-By: Claude Fable 5.1 --- crates/mdwire-harness/tests/fuzz.rs | 2 ++ go/fuzz_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/mdwire-harness/tests/fuzz.rs b/crates/mdwire-harness/tests/fuzz.rs index 7a0d760..be4898e 100644 --- a/crates/mdwire-harness/tests/fuzz.rs +++ b/crates/mdwire-harness/tests/fuzz.rs @@ -37,6 +37,8 @@ const PIECES: &[&str] = &[ "가", "나다", "한글 조사가", "이다.", "word", "x", "2", "का_x", "&", "😀", "①", "•", ".md", "@id", "#40", // 브라우저 채널이 막아야 하는 것들 — 스킴, 이벤트 속성, 속성값에 드는 info, 마스킹 번호. "[x](javascript:alert(1))", "[m](MAILTO:a@b.c)", "", "", "", "```x\" y=\"z\n", "4***-****-003*", + // 한국어 조사 앞 강조 — 닫는 쪽 · 여는 쪽이 막힌 모양, 겹친 강조, 수식, 낫표. + "**설정(config)**을", "값**(합계)**를", "***중요(필수)***를", "x**(y)**z", "2**(n-1)", "「", "」", "**「캐시」**가", ]; fn doc(rng: &mut Rng) -> String { diff --git a/go/fuzz_test.go b/go/fuzz_test.go index ad9d781..742b2a4 100644 --- a/go/fuzz_test.go +++ b/go/fuzz_test.go @@ -37,6 +37,8 @@ var fuzzPieces = []string{ "가", "나다", "한글 조사가", "이다.", "word", "x", "2", "का_x", "&", "😀", "①", "•", ".md", "@id", "#40", // 브라우저 채널이 막아야 하는 것들 — 스킴, 이벤트 속성, 속성값에 드는 info, 마스킹 번호. "[x](javascript:alert(1))", "[m](MAILTO:a@b.c)", ``, "", "", "```x\" y=\"z\n", "4***-****-003*", + // 한국어 조사 앞 강조 — 러스트 쪽 PIECES 와 같다. + "**설정(config)**을", "값**(합계)**를", "***중요(필수)***를", "x**(y)**z", "2**(n-1)", "「", "」", "**「캐시」**가", } func fuzzDoc(r *xorshift) string {