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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 빌드할 때만 장 제목, 수식 표기, 접힌 코드, 옮긴이 주 상자를 책 형식으로 바꿉니다. 그래서 노트북은 원서와 같은 구조로 두면 됩니다.

<br>

Expand Down
11 changes: 2 additions & 9 deletions book/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +119,7 @@ format:
include-after-body:
- sidebar-resize.html
- chapter-toc.html
- site-search.html
include-in-header:
- text: |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
Expand Down
2 changes: 1 addition & 1 deletion book/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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)에 정리해 두었습니다.

## 함께 만들어 주세요

Expand Down
125 changes: 109 additions & 16 deletions book/postrender.py
Original file line number Diff line number Diff line change
@@ -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"))
Expand All @@ -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("\\", "")
Expand All @@ -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"<title>(.*?)</title>", 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__":
Expand Down
79 changes: 66 additions & 13 deletions book/sidebar-resize.html
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
<script>
// 왼쪽 목차: 너비 조절(끌기, 두 번 누르면 원래대로)과 접기. 이 장의 내용: 접기. 기본은 모두 펼침이고 선택은 브라우저에 기억한다.
(function () {
var KEY = "cil-sidebar-width", MIN = 220, MAX = 480;
var root = document.documentElement;
try {
var saved = parseInt(localStorage.getItem(KEY), 10);
if (saved >= MIN && saved <= MAX) root.style.setProperty("--sidebar-w", saved + "px");
} catch (e) {}
var KEY_W = "cil-sidebar-width", KEY_L = "cil-sidebar-collapsed", KEY_R = "cil-toc-collapsed";
var MIN = 220, MAX = 480, root = document.documentElement;
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function set(k, v) { try { v === null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} }
try { localStorage.removeItem("quarto-reader-mode"); } catch (e) {}
var saved = parseInt(get(KEY_W), 10);
if (saved >= MIN && saved <= MAX) root.style.setProperty("--sidebar-w", saved + "px");
if (get(KEY_L) === "1") root.classList.add("sidebar-collapsed");
if (get(KEY_R) === "1") root.classList.add("toc-collapsed");

document.addEventListener("DOMContentLoaded", function () {
if (!document.body.classList.contains("docked") || !document.getElementById("quarto-sidebar")) return;
var sidebar = document.getElementById("quarto-sidebar");
if (!document.body.classList.contains("docked") || !sidebar) return;

// 너비 조절 손잡이
var bar = document.createElement("div");
bar.className = "sidebar-resizer";
bar.setAttribute("role", "separator");
bar.setAttribute("aria-orientation", "vertical");
bar.title = "끌어서 목차 너비 조절 (두 번 누르면 원래대로)";
document.body.appendChild(bar);
var dragging = false;
var dragging = false, frame = 0, lastX = 0;
function setW(w) {
w = Math.max(MIN, Math.min(MAX, Math.round(w)));
root.style.setProperty("--sidebar-w", w + "px");
Expand All @@ -27,18 +35,63 @@
e.preventDefault();
});
bar.addEventListener("pointermove", function (e) {
if (dragging) setW(e.clientX);
if (!dragging) return;
lastX = e.clientX;
if (!frame) frame = requestAnimationFrame(function () { frame = 0; setW(lastX); });
});
bar.addEventListener("pointerup", function (e) {
function stop(e) {
if (!dragging) return;
dragging = false;
document.body.classList.remove("sidebar-resizing");
try { localStorage.setItem(KEY, setW(e.clientX)); } catch (err) {}
});
set(KEY_W, String(setW(e.clientX)));
}
bar.addEventListener("pointerup", stop);
bar.addEventListener("pointercancel", stop);
bar.addEventListener("dblclick", function () {
root.style.removeProperty("--sidebar-w");
try { localStorage.removeItem(KEY); } catch (err) {}
set(KEY_W, null);
});

// 왼쪽 목차 접기
var left = document.createElement("button");
left.type = "button";
left.className = "panel-toggle left";
function paintLeft() {
var c = root.classList.contains("sidebar-collapsed");
left.textContent = c ? "목차 ›" : "‹";
left.title = c ? "목차 펼치기" : "목차 접기";
left.setAttribute("aria-label", left.title);
left.setAttribute("aria-expanded", String(!c));
}
left.addEventListener("click", function () {
var c = root.classList.toggle("sidebar-collapsed");
set(KEY_L, c ? "1" : null);
paintLeft();
});
paintLeft();
document.body.appendChild(left);

// 이 장의 내용 접기
var toc = document.getElementById("quarto-margin-sidebar");
var tocTitle = document.getElementById("toc-title");
if (toc && tocTitle) {
var right = document.createElement("button");
right.type = "button";
right.className = "toc-toggle";
function paintRight() {
var c = root.classList.contains("toc-collapsed");
right.textContent = c ? "펼치기" : "접기";
right.setAttribute("aria-expanded", String(!c));
right.setAttribute("aria-label", c ? "이 장의 내용 펼치기" : "이 장의 내용 접기");
}
right.addEventListener("click", function () {
var c = root.classList.toggle("toc-collapsed");
set(KEY_R, c ? "1" : null);
paintRight();
});
paintRight();
tocTitle.appendChild(right);
}
});
})();
</script>
Loading
Loading