diff --git a/README.md b/README.md index 7220015..e84eb02 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ python book/prepare.py cd book && quarto preview ``` -`book/prepare.py`는 빌드할 때 [번역 가이드](TRANSLATION_GUIDE.md)의 용어표로 웹북의 '용어집' 페이지를 만들고, `book/postrender.py`는 검색 결과 미리보기의 수식 기호를 읽기 쉽게 정리합니다. `book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다. +`book/prepare.py`는 빌드할 때 [번역 가이드](TRANSLATION_GUIDE.md)의 용어표로 웹북의 '용어집' 페이지를 만들고, `book/postrender.py`는 렌더링이 끝난 뒤 웹북 검색 색인(`search-index.json`)을 만듭니다. `book/prepare.py`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.
diff --git a/book/_quarto.yml b/book/_quarto.yml index 3b2ae6b..f174d94 100644 --- a/book/_quarto.yml +++ b/book/_quarto.yml @@ -28,15 +28,7 @@ book: repo-subdir: causal-inference-for-the-brave-and-true repo-actions: [issue] favicon: assets/causal-lab-mark.png - search: - location: sidebar - type: textbox - limit: 50 - collapse-after: 3 - copy-button: true - show-item-context: true - keyboard-shortcut: ["/", "k"] - reader-mode: true + search: false open-graph: image: assets/og-image.png locale: ko_KR @@ -127,6 +119,7 @@ format: include-after-body: - sidebar-resize.html - chapter-toc.html + - site-search.html include-in-header: - text: | diff --git a/book/index.qmd b/book/index.qmd index cbfd51a..4bd4c2f 100644 --- a/book/index.qmd +++ b/book/index.qmd @@ -91,7 +91,7 @@ aliases: 각 장은 원서의 주피터 노트북을 그대로 옮긴 것이라 코드와 출력이 원서와 같습니다. 노트북 파일은 [GitHub 저장소](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/tree/main/causal-inference-for-the-brave-and-true)에서 내려받아 직접 실행해 볼 수 있습니다. 그래프를 그리는 코드처럼 길어서 접어 둔 셀은 **코드**를 눌러 펼치면 됩니다. -용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 용어의 원어와 그 용어를 다루는 장은 [용어집](glossary.html)에서 찾을 수 있습니다. 검색창(단축키 `/`)에 한국어나 영어 용어를 넣어도 됩니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다. +용어는 같은 저자의 책을 옮긴 『실무로 통하는 인과추론 with 파이썬』(한빛미디어)과 맞췄습니다. 두 책을 함께 보셔도 용어가 헷갈리지 않습니다. 용어의 원어와 그 용어를 다루는 장은 [용어집](glossary.html)에서 찾을 수 있습니다. 왼쪽 위 검색(단축키 `/` 또는 `Ctrl+K`)에 한국어나 영어 용어를 넣어도 됩니다. 띄어쓰기는 달라도 찾아 줍니다. 번역 원칙과 용어표는 [번역 가이드](https://github.com/CausalInferenceLab/Causal-Inference-with-Python/blob/main/TRANSLATION_GUIDE.md)에 정리해 두었습니다. ## 함께 만들어 주세요 diff --git a/book/postrender.py b/book/postrender.py index 38c92d5..e8637df 100644 --- a/book/postrender.py +++ b/book/postrender.py @@ -1,11 +1,14 @@ -"""렌더링 뒤처리: 검색 색인(search.json)의 수식 기호를 읽기 쉽게 바꾼다. +"""렌더링 뒤처리: 웹북 검색 색인(search-index.json)을 만든다. -검색 결과 미리보기는 수식을 그리지 않으므로 `\\(Y_1 \\perp T\\)` 같은 TeX 원문이 그대로 보인다. -구분 기호를 지우고 자주 쓰는 기호만 유니코드로 바꾼다. 검색 대상 문자열에만 적용되고 본문은 그대로다. +렌더링된 각 장의 본문을 절(h2/h3) 단위로 나눠 평문으로 모은다. 코드 셀과 출력은 빼고, +수식은 `\\(Y_1 \\perp T\\)` 대신 `Y_1 ⊥ T`처럼 읽히는 평문으로 바꾼다. +검색 화면(book/site-search.html)이 이 파일을 읽어 띄어쓰기와 상관없이 찾는다. """ +import html import json import os import re +from html.parser import HTMLParser from pathlib import Path OUT = Path(os.environ.get("QUARTO_PROJECT_OUTPUT_DIR", Path(__file__).parent / "_book")) @@ -19,7 +22,7 @@ def tex_to_plain(tex): tex = re.sub(r"\\[!,;: ]", "", tex) - tex = re.sub(r"\\(?:hat|bar|tilde|mathbb|mathcal|mathrm|text|operatorname|underbrace|overbrace|left|right|big|Big|frac|displaystyle)\b", "", tex) + tex = re.sub(r"\\(?:hat|bar|tilde|mathbb|mathcal|mathrm|text|operatorname|underbrace|overbrace|left|right|big|Big|frac|dfrac|tfrac|displaystyle|mathbf|boldsymbol)\b", "", tex) tex = re.sub(r"\\([A-Za-z]+)", lambda m: SYMBOLS.get(m.group(1), m.group(1)), tex) tex = re.sub(r"⊥(\s*⊥)+", "⊥", tex) tex = tex.replace("{", "").replace("}", "").replace("\\", "") @@ -31,19 +34,109 @@ def clean(text): return re.sub(r"\\\((.*?)\\\)|\\\[(.*?)\\\]", lambda m: tex_to_plain(m.group(1) or m.group(2) or ""), text, flags=re.S) +SKIP_TAGS = {"script", "style", "svg", "pre", "nav", "button", "noscript", "header"} +SKIP_CLASSES = ("cell", "code-copy-outer-scaffold", "sourceCode", "inline-toc", "hero-actions", "hero-links", "term-link", "screen-reader-only") +TEXT_BREAK = {"p", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "h4", "h5", "div", "tr"} +VOID = {"br", "img", "hr", "meta", "link", "input", "source", "wbr"} + + +class PageParser(HTMLParser): + """main#quarto-document-content 안의 글을 절 단위로 모은다.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.in_main = False + self.skip = 0 + self.stack = [] + self.heading = None + self.sections = [{"id": "", "title": "", "text": []}] + + def handle_starttag(self, tag, attrs): + if tag in VOID: + if tag == "br" and self.in_main and not self.skip: + self.sections[-1]["text"].append(" ") + return + a = dict(attrs) + cls = (a.get("class") or "").split() + mark = None + if tag == "main" and a.get("id") == "quarto-document-content": + self.in_main = True + mark = "main" + elif self.in_main: + if self.skip or tag in SKIP_TAGS or any(c in cls for c in SKIP_CLASSES): + self.skip += 1 + mark = "skip" + elif tag == "section" and a.get("id") and ("level2" in cls or "level3" in cls): + self.sections.append({"id": a["id"], "title": "", "text": []}) + elif tag in ("h2", "h3"): + self.heading = [] + mark = "heading" + self.stack.append((tag, mark)) + + def handle_endtag(self, tag): + if tag in VOID: + return + while self.stack: + t, mark = self.stack.pop() + if mark == "skip": + self.skip -= 1 + elif mark == "heading" and self.heading is not None: + title = " ".join("".join(self.heading).split()) + if not self.sections[-1]["title"]: + self.sections[-1]["title"] = title + self.heading = None + elif mark == "main": + self.in_main = False + if t == tag: + break + if self.in_main and not self.skip and tag in TEXT_BREAK: + self.sections[-1]["text"].append(" ") + + def handle_data(self, data): + if not self.in_main or self.skip: + return + (self.heading if self.heading is not None else self.sections[-1]["text"]).append(data) + + +def page_title(raw): + m = re.search(r"(.*?)", raw, re.S) + title = html.unescape(m.group(1)) if m else "" + title = re.sub(r"\s+[–|]\s+Python으로 하는 인과추론\s*$", "", title) + return " ".join(title.replace("\u00a0", " ").split()) + + +def build_index(): + entries = [] + for path in sorted(OUT.glob("*.html")): + if path.name in ("404.html", "search.html", "landing-page.html"): + continue + raw = path.read_text(encoding="utf-8") + if "quarto-document-content" not in raw: + continue + parser = PageParser() + parser.feed(raw) + title = page_title(raw) + for sec in parser.sections: + text = clean(" ".join("".join(sec["text"]).split())) + text = re.sub(r"\s+([.,)])", r"\1", text) + if not text and not sec["title"]: + continue + if sec["title"] == "기여하기": + continue # 모든 장에 같은 문단이라 검색 결과만 어지럽힌다 + entries.append({ + "u": path.name + (f"#{sec['id']}" if sec["id"] else ""), + "p": title, + "s": clean(sec["title"]), + "t": text, + }) + (OUT / "search-index.json").write_text(json.dumps(entries, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + return len(entries) + + def main(): - path = OUT / "search.json" - if not path.exists(): - return - data = json.loads(path.read_text(encoding="utf-8")) - for item in data: - for key in ("text", "section", "title"): - if isinstance(item.get(key), str): - item[key] = clean(item[key]) - # 검색 결과의 경로 표시(crumbs)에 장 번호 HTML이 그대로 찍히는 문제 - if isinstance(item.get("crumbs"), list): - item["crumbs"] = [re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", c)).strip() for c in item["crumbs"]] - path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + print(f"search index: {build_index()} sections") + # Quarto 기본 검색은 쓰지 않으므로 그 색인은 지운다. + (OUT / "search.json").unlink(missing_ok=True) if __name__ == "__main__": diff --git a/book/sidebar-resize.html b/book/sidebar-resize.html index d3ddf3d..e3ba2d8 100644 --- a/book/sidebar-resize.html +++ b/book/sidebar-resize.html @@ -1,20 +1,28 @@ diff --git a/book/site-search.html b/book/site-search.html new file mode 100644 index 0000000..0ae3567 --- /dev/null +++ b/book/site-search.html @@ -0,0 +1,196 @@ + + diff --git a/book/styles-dark.scss b/book/styles-dark.scss index 0e10ecb..83478ed 100644 --- a/book/styles-dark.scss +++ b/book/styles-dark.scss @@ -108,3 +108,23 @@ body { .quarto-secondary-nav .quarto-btn-toggle::after { color: #eceaff; } + +.cil-search-panel, +.cil-search-trigger { + background: #1b1d2a; +} +.cil-search-item:hover, +.cil-search-item.active, +.cil-search-chips button { + background: rgba(169, 150, 214, 0.14); +} +.cil-search-where, +.cil-search-bar, +.cil-search-chips button, +.cil-search-mobile { + color: #c9bcf5; +} +.panel-toggle { + background: #1b1d2a; + color: #eceaff; +} diff --git a/book/styles.scss b/book/styles.scss index 08d6a5f..7035ad0 100644 --- a/book/styles.scss +++ b/book/styles.scss @@ -619,29 +619,37 @@ main.content .quarto-figure img, margin-right: auto; } -// 목차 너비 조절: 넓은 화면에서만, 손잡이를 끌어 바꾸고 브라우저에 기억 +// 넓은 화면 레이아웃: 왼쪽 목차 | 본문 | 이 장의 내용 +// 본문은 왼쪽 목차 바로 옆에서 시작하므로, 목차 너비를 바꾸면 본문이 손잡이를 따라 움직인다. @media (min-width: 992px) { body.docked #quarto-content.page-columns { grid-template-columns: [screen-start] var(--sidebar-w, 290px) - [screen-start-inset page-start page-start-inset body-start-outset] 1.5em - [body-start] 1.5em - [body-content-start] minmax(500px, calc(1000px - 3em)) - [body-content-end] 1.5em - [body-end] 50px - [body-end-outset] minmax(50px, 100px) - [page-end-inset] 50px - [page-end] 5fr - [screen-end-inset] 1.5em + [screen-start-inset page-start page-start-inset body-start-outset] clamp(1.5rem, 3vw, 3rem) + [body-start body-content-start] minmax(0, 52rem) + [body-content-end] clamp(1.5rem, 3vw, 3rem) + [body-end body-end-outset] minmax(150px, 240px) + [page-end-inset] 1.5rem + [page-end] 1fr + [screen-end-inset] 0 [screen-end]; } body.docked .sidebar.sidebar-navigation { grid-column: screen-start / screen-start-inset; } + #quarto-sidebar { + transition: none; + } #quarto-sidebar .sidebar-menu-container { min-width: 0; white-space: normal; } + html.sidebar-collapsed { + --sidebar-w: 72px !important; + } + html.sidebar-collapsed #quarto-sidebar { + display: none !important; + } .sidebar-resizer { position: fixed; top: 0; @@ -650,6 +658,7 @@ main.content .quarto-figure img, width: 8px; cursor: col-resize; z-index: 1040; + touch-action: none; } .sidebar-resizer::after { content: ""; @@ -665,17 +674,61 @@ main.content .quarto-figure img, body.sidebar-resizing .sidebar-resizer::after { background: $lavender; } + html.sidebar-collapsed .sidebar-resizer { + display: none; + } body.sidebar-resizing { cursor: col-resize; user-select: none; } } @media (max-width: 991.98px) { - .sidebar-resizer { - display: none; + .sidebar-resizer, + .panel-toggle { + display: none !important; } } +// 목차 접기·펼치기 버튼 (기본은 펼침) +.panel-toggle { + position: fixed; + top: 0.75rem; + z-index: 1045; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.55rem; + border: 1px solid rgba($indigo, 0.2); + border-radius: 999px; + background: $body-bg; + color: $navy; + font-size: 0.78rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: 0 2px 8px rgba($navy, 0.08); + opacity: 0.55; + transition: opacity 0.15s ease; +} +.panel-toggle:hover, +.panel-toggle:focus-visible { + opacity: 1; +} +.panel-toggle.left { + left: calc(var(--sidebar-w, 290px) - 1.1rem); +} +html.sidebar-collapsed .panel-toggle.left { + left: 0.6rem; + opacity: 1; +} +html.toc-collapsed #quarto-margin-sidebar nav#TOC > ul, +html.toc-collapsed #quarto-margin-sidebar .toc-actions { + display: none; +} +html.toc-collapsed #quarto-margin-sidebar #toc-title { + opacity: 0.6; +} + // 폭이 넓은 수식은 본문 밖으로 넘치지 않고 가로로 스크롤 mjx-container[display="true"] { overflow-x: auto; @@ -768,8 +821,10 @@ h1.title .chapter-number { position: sticky !important; top: 0; width: 250px !important; - height: 100vh !important; - max-height: 100vh; + height: calc(100vh - 37px) !important; + min-height: 0 !important; + max-height: calc(100vh - 37px) !important; + align-self: start; transform: none !important; flex-direction: column !important; overflow-y: auto !important; @@ -829,3 +884,203 @@ span[id^="term-"]:target { border-radius: 4px; padding: 0 0.2rem; } + +// '이 장의 내용' 제목 옆 접기 버튼 +.toc-toggle { + float: right; + margin-top: 0.1rem; + border: 0; + background: none; + padding: 0 0.1rem; + color: $lavender; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; +} +.toc-toggle:hover { + color: $indigo; +} + +// 웹북 검색 +.cil-search-trigger { + display: flex; + align-items: center; + gap: 0.5rem; + width: calc(100% - 1rem); + margin: 0.6rem 0.5rem 0.8rem; + padding: 0.45rem 0.7rem; + border: 1px solid rgba($indigo, 0.18); + border-radius: 10px; + background: $body-bg; + color: rgba($sidebar-fg, 0.9); + font-size: 0.88rem; + text-align: left; + cursor: pointer; +} +.cil-search-trigger:hover { + border-color: $lavender; +} +.cil-search-trigger span { + flex: 1; +} +.cil-search-trigger kbd, +.cil-search-close { + padding: 0.05rem 0.4rem; + border: 1px solid rgba($indigo, 0.18); + border-radius: 6px; + background: transparent; + color: rgba($sidebar-fg, 0.8); + font-family: inherit; + font-size: 0.72rem; + box-shadow: none; +} +.cil-search-mobile { + margin-left: auto; + border: 0; + background: none; + color: $navy; + padding: 0.2rem 0.4rem; +} +@media (min-width: 992px) { + .cil-search-mobile { + display: none; + } +} +html.cil-search-open { + overflow: hidden; +} +.cil-search { + position: fixed; + inset: 0; + z-index: 2000; +} +.cil-search[hidden] { + display: none; +} +.cil-search-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 21, 40, 0.45); + backdrop-filter: blur(2px); +} +.cil-search-panel { + position: relative; + display: flex; + flex-direction: column; + width: min(720px, calc(100vw - 2rem)); + max-height: min(80vh, 760px); + margin: 8vh auto 0; + border-radius: 16px; + background: $body-bg; + box-shadow: 0 24px 60px rgba(20, 21, 40, 0.35); + overflow: hidden; +} +.cil-search-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid rgba($indigo, 0.12); + color: $indigo; +} +.cil-search-bar input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: $body-color; + font-size: 1.05rem; +} +.cil-search-close { + cursor: pointer; +} +.cil-search-results { + overflow-y: auto; + padding: 0.4rem; +} +.cil-search-count { + padding: 0.3rem 0.7rem 0.4rem; + color: rgba($body-color, 0.55); + font-size: 0.78rem; +} +.cil-search-item { + display: block; + padding: 0.6rem 0.75rem; + border-radius: 10px; + color: $body-color !important; + text-decoration: none !important; +} +.cil-search-item:hover, +.cil-search-item.active { + background: $lavender-soft; +} +.cil-search-where { + display: block; + color: $indigo; + font-size: 0.82rem; + font-weight: 700; +} +.cil-search-where span { + color: $lavender; + font-weight: 400; +} +.cil-search-snippet { + display: block; + margin-top: 0.15rem; + font-size: 0.9rem; + line-height: 1.6; + color: rgba($body-color, 0.85); +} +.cil-search-snippet mark { + padding: 0 0.1rem; + border-radius: 3px; + background: rgba($lavender, 0.45); + color: inherit; +} +.cil-search-empty { + padding: 1rem 0.8rem; + color: rgba($body-color, 0.75); + font-size: 0.92rem; +} +.cil-search-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.cil-search-chips button { + padding: 0.3rem 0.7rem; + border: 1px solid rgba($indigo, 0.18); + border-radius: 999px; + background: $lavender-soft; + color: $navy; + font-size: 0.85rem; + cursor: pointer; +} +.cil-search-foot { + display: flex; + gap: 1rem; + padding: 0.55rem 1rem; + border-top: 1px solid rgba($indigo, 0.12); + color: rgba($body-color, 0.55); + font-size: 0.75rem; +} +.cil-search-foot a { + margin-left: auto; +} +@media (max-width: 575.98px) { + .cil-search-panel { + width: 100vw; + max-height: 100vh; + height: 100vh; + margin: 0; + border-radius: 0; + } + .cil-search-foot span { + display: none; + } +} + +// 검색 미리보기의 수식은 평문이므로 줄바꿈이 자연스럽게 +.cil-search-snippet { word-break: keep-all; overflow-wrap: anywhere; } +.cil-search-bar input::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }