diff --git a/_plugins/search_index.rb b/_plugins/search_index.rb index f5e2260..731f525 100644 --- a/_plugins/search_index.rb +++ b/_plugins/search_index.rb @@ -22,13 +22,26 @@ module SearchIndex |tbody|tr|td|th|section|article|header|footer|figure|figcaption |br|hr)\b[^>]*>}xi + # kramdown's mathjax engine leaves the delimiters in the HTML as literal text, + # so a flattened post reads "메커니즘의 \(O(L^2)\) 계산 복잡도" and an excerpt landing + # there showed the delimiters to the reader. Only the delimiters go; the TeX + # inside stays, both because it is what the sentence is about and so that a + # symbol inside a formula is still findable. + INLINE_MATH = /\\\((.+?)\\\)/m + DISPLAY_MATH = /\\\[(.+?)\\\]/m + def self.plain_text(html) text = html.to_s.gsub(NON_PROSE, " ").gsub(COMMENT, " ") # Strip before decoding, never after. A post that quotes markup contains # `<script>` as text; decoding first would make it a real tag and the # strip would then delete the words the author actually wrote. text = text.gsub(BOUNDARY, " ").gsub(/<[^>]*>/, "") - CGI.unescapeHTML(text).gsub(/\s+/, " ").strip + text = CGI.unescapeHTML(text) + # Display math is its own block, so it may take spaces. Inline math must not: + # Korean attaches particles directly, and `\(\theta\)로` has to stay one word. + text = text.gsub(DISPLAY_MATH) { " #{Regexp.last_match(1)} " } + text = text.gsub(INLINE_MATH) { Regexp.last_match(1) } + text.gsub(/\s+/, " ").strip end end diff --git a/docs/tech-doc.md b/docs/tech-doc.md index d5bd35a..3b36954 100644 --- a/docs/tech-doc.md +++ b/docs/tech-doc.md @@ -242,8 +242,14 @@ MathJax 설정은 `head.html`에 있고 `{% if page.use_math %}`로 감싸 **프 순서가 중요하다 — **태그를 먼저 지우고 그다음에 엔티티를 디코딩한다.** 반대로 하면 마크업을 인용한 글의 `<script>`가 진짜 태그가 되고, 이어지는 태그 제거가 저자가 쓴 글자를 - 지운다. 인라인 태그(``, ``)는 공백 없이 벗겨 `강조된`이 한 단어로 - 남는다. `test/test_search_index.rb`가 이 경계들을 고정한다. + 지운다. 인라인 태그(``, ``)는 공백 없이 벗겨 `강조된`이 한 단어로 남는다. + + 같은 필터가 **수식 구분자도 벗긴다.** kramdown의 mathjax 엔진은 `\(...\)` · `\[...\]`를 + HTML에 글자 그대로 남기므로, 평문화한 본문이 `메커니즘의 \(O(L^2)\) 계산 복잡도`가 되고 + 발췌가 그 구간에 걸리면 구분자가 독자에게 보였다. 구분자만 지우고 **안의 TeX는 남긴다** — + 문장이 말하는 대상이고, 수식 속 기호도 계속 검색돼야 한다. 인라인 수식에는 공백을 붙이지 + 않는다(한국어는 조사가 기호에 바로 붙어 `\(\theta\)로`가 한 단어여야 한다). 디스플레이 + 수식은 원래 블록이라 붙여도 된다. `test/test_search_index.rb`가 이 경계들을 고정한다. - **왜 발췌가 아니라 전체 본문을 색인하나** — `simple-jekyll-search`는 형태소 분석 없이 단순 부분문자열 매칭을 한다. **색인에 없는 글자는 못 찾는다.** 발췌만 색인하면 본문 중·후반에만 나오는 단어("어텐션", "트랜스포머" 같은)는 검색 결과가 0건이 된다. `content`로 @@ -399,6 +405,7 @@ Bourbon → base/ → Neat → _layout → _post → _tags → _syntax(Rouge 코 이미 잘려 나간 뒤에 정렬하기 때문이다. 제대로 하려면 매칭·정렬·자르기를 직접 들고 있어야 하고, 그 시점에는 라이브러리를 걷어내는 게 맞다(현재 쓰는 기능은 페치·부분문자열 매칭·템플릿 치환뿐이다). -- **수식이 발췌에 날것으로 보인다** — 색인은 렌더된 HTML을 평문화한 것이라 MathJax 구분자가 - `\(O(L^2)\)` 형태로 남는다. 발췌가 그 구간에 걸리면 그대로 찍힌다. 색인에서 수식 구간을 - 지우면 깔끔해지지만 수식 안의 문자는 검색되지 않게 된다. +- **발췌에 TeX 명령이 남는다** — 수식 구분자는 벗기지만 안의 TeX는 남기므로(§5), 발췌가 + 수식에 걸리면 `\theta`처럼 명령어가 그대로 보인다. `O(L^2)`처럼 읽히는 경우가 대부분이라 + 그대로 두었다. 유니코드로 바꾸려면 TeX→기호 매핑이 필요하고, 그건 색인이 감당할 범위를 + 넘는다. diff --git a/test/test_search_index.rb b/test/test_search_index.rb index 5270381..70e1e85 100644 --- a/test/test_search_index.rb +++ b/test/test_search_index.rb @@ -75,3 +75,44 @@ def test_is_idempotent_on_already_plain_text assert_equal plain, SearchIndex.plain_text(plain) end end + +class TestMathDelimiters < Minitest::Test + # kramdown leaves these in the HTML as text, so an excerpt landing on a formula + # used to show the reader `\(O(L^2)\)`. + def test_strips_inline_delimiters_and_keeps_the_tex + assert_equal "메커니즘의 O(L^2) 계산 복잡도", + SearchIndex.plain_text("

메커니즘의 \\(O(L^2)\\) 계산 복잡도

") + end + + def test_strips_display_delimiters + assert_equal "앞 E = mc^2 뒤", SearchIndex.plain_text("앞 \\[E = mc^2\\] 뒤") + end + + # Inline math gets no padding: Korean attaches a particle straight onto the + # symbol, and a space here would break the word in two. + def test_inline_math_does_not_gain_a_space_before_a_korean_particle + assert_equal "비율 \\theta로 나눕니다", + SearchIndex.plain_text("비율 \\(\\theta\\)로 나눕니다") + end + + def test_handles_several_formulas_in_one_paragraph + assert_equal "a x b y c", + SearchIndex.plain_text("

a \\(x\\) b \\(y\\) c

") + end + + # The symbol stays searchable — that is why the TeX is kept rather than dropped. + def test_the_contents_remain_findable + assert_includes SearchIndex.plain_text("

복잡도는 \\(O(L^2)\\)입니다

"), "O(L^2)" + end + + # An opening delimiter with no partner is left alone rather than eating the rest + # of the post. + def test_an_unclosed_delimiter_is_left_as_is + assert_equal "열린 \\( 그리고 나머지 본문", + SearchIndex.plain_text("

열린 \\( 그리고 나머지 본문

") + end + + def test_a_lone_backslash_paren_in_prose_is_untouched + assert_equal "함수 f(x) 는 그대로", SearchIndex.plain_text("

함수 f(x) 는 그대로

") + end +end