feat: add common utilities and feedback handling scripts

- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
View File
+631
View File
@@ -0,0 +1,631 @@
"""PDF → data/atlas.json 빌드.
.venv/bin/python -m pipeline.build
자동 추출 결과는 모두 review_status='draft' 이며 method 필드로 출처(색인/목차/본문/시안)를 남긴다.
"""
import hashlib
import json
import re
import sys
import time
import unicodedata
from collections import defaultdict
from functools import lru_cache
import pymupdf
from . import curated
from .config import (BOOKS, DATA_DIR, GALAXIES, SECONDARY_CHAPTER_MAP, SECONDARY_KEYWORD_MAP,
SECONDARY_SECTION_OVERRIDES)
from .index import extract_index
from .pdfutil import body_text, label_to_index
from .toc import extract_toc
HANGUL = re.compile(r"[가-힣]")
def log(*a):
print(*a, file=sys.stderr, flush=True)
def norm_key(s: str) -> str:
s = unicodedata.normalize("NFC", s).lower()
s = re.sub(r"[\s·\-–_‧∙/'’\.]", "", s)
return s
def tid(key: str) -> str:
return "t_" + hashlib.sha1(key.encode()).hexdigest()[:10]
def rid(a: str, b: str, typ: str) -> str:
return "r_" + hashlib.sha1(f"{a}|{b}|{typ}".encode()).hexdigest()[:10]
def clean_alias(a: str):
"""동의어 정리: 하이픈 줄바꿈 잇기, 연도·숫자만 있는 값 제거."""
a = re.sub(r"(?<=[A-Za-z])- (?=[a-z])", "", a).strip(" ,;")
if not a or re.fullmatch(r"[\d\s.,~\-]+", a) or len(a) < 2:
return None
return a
def split_name(raw: str):
"""'건강 신념 모형(Health Belief Model; HBM)' → ('건강 신념 모형', ['Health Belief Model', 'HBM'])"""
raw = raw.strip()
m = re.match(r"^(.+?)\s*[\((]([^()()]+)[\))]?\s*$", raw)
if m and m.group(1).strip():
name = m.group(1).strip()
inner = m.group(2)
aliases = [c for c in (clean_alias(a) for a in re.split(r"[;,]", inner)) if c]
return name, aliases
return raw, []
# ---------------------------------------------------------------- 단원 매핑
class ChapterIndex:
def __init__(self, toc_by_book):
self.toc = toc_by_book
self.sorted = {b: sorted(n, key=lambda x: (x["page"], x["level"])) for b, n in toc_by_book.items()}
self.by_id = {n["id"]: n for nodes in toc_by_book.values() for n in nodes}
def locate(self, book_id: str, page: int):
"""페이지가 속한 가장 깊은 목차 노드."""
best = None
for n in self.sorted.get(book_id, []):
if n["page"] <= page:
if best is None or n["page"] > best["page"] or (n["page"] == best["page"] and n["level"] >= best["level"]):
best = n
else:
break
return best
def ancestors(self, node):
out = []
while node:
out.append(node)
node = self.by_id.get(node["parent_id"])
return out[::-1]
def chapter_section(self, node):
chain = self.ancestors(node) if node else []
chapter = next((n for n in chain if n["kind"] == "chapter"), None)
section = next((n for n in chain if n["kind"] == "section"), None)
return chapter, section, chain
def galaxy_for(book, chapter, chain):
if book["primary"]:
if chapter is None:
return None
no = int(chapter["number"])
return next((g["id"] for g in GALAXIES if g["chapter_no"] == no), None)
titles = " ".join(n["title"] for n in chain[1:]) if chain else ""
for kw, g in SECONDARY_KEYWORD_MAP:
if kw in titles:
return g
if chapter is not None:
g = SECONDARY_CHAPTER_MAP.get((book["book_id"], int(chapter["number"])))
if g:
return g
return SECONDARY_CHAPTER_MAP.get((book["book_id"], None))
# ---------------------------------------------------------------- 본문 문장
SENT_SPLIT = re.compile(r"(?<=[다요음함됨임])\.\s+|(?<=\.)\s+(?=[가-힣A-Z①-⑳•\-])|\n")
DEF_PATTERNS = [
(re.compile(r"(이)?란\s"), 4),
(re.compile(r"(이)?라\s?(함은|한다|하며|하고|부른다)"), 4),
(re.compile(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이르는 말)"), 4),
(re.compile(r"정의"), 2),
]
class Corpus:
def __init__(self):
self.docs = {}
self.label_maps = {}
def open(self, book):
bid = book["book_id"]
if bid not in self.docs:
self.docs[bid] = pymupdf.open(book["file"]())
self.label_maps[bid] = label_to_index(self.docs[bid])
return self.docs[bid]
def pdf_index(self, book, page: int):
self.open(book)
return self.label_maps[book["book_id"]].get(str(page))
@lru_cache(maxsize=4096)
def sentences(self, book_id: str, pdf_page: int) -> tuple[str, ...]:
doc = self.docs[book_id]
text = body_text(doc, pdf_page)
# 쪽 끝에서 끊긴 문장은 다음 쪽 첫 문장과 잇는다
if text and not re.search(r"[.?!)」』]\s*$", text) and pdf_page + 1 < len(doc):
nxt = body_text(doc, pdf_page + 1)
m = re.match(r"^(.{1,300}?(?:다|요|음)\.)", nxt)
if m:
joiner = "" if re.search(r"[가-힣]$", text) and re.match(r"^[가-힣]", nxt) else " "
text = text + joiner + m.group(1)
text = rejoin_lines(text)
out = []
for s in SENT_SPLIT.split(text):
s = s.strip(" •-·")
if 8 <= len(s) <= 400:
out.append(s if s.endswith(".") or not s.endswith("다") else s + ".")
return tuple(out)
HEADING = re.compile(r"^(\d+[\).]|[①-⑳]|[가-하][\).]|\(\d+\)|[•▪■□○●]|표\s?\d|그림\s?\d)")
WORD_END = set("의은는이가을를에로고며서과와도한된인적및등면데때중게")
def rejoin_lines(text: str) -> str:
"""줄 단위로 끊긴 본문을 문장으로 다시 잇는다. 제목·목록 줄은 그대로 둔다."""
out = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if out:
prev = out[-1]
ends = re.search(r"[.?!:)」』]$", prev) or len(prev) < 22 or HEADING.match(line) or "=" in prev
if not ends:
if re.search(r"[가-힣]$", prev) and re.match(r"^[가-힣]", line) and prev[-1] not in WORD_END:
out[-1] = prev + line # 단어 중간에서 줄바꿈
else:
out[-1] = prev + " " + line
continue
out.append(line)
return "\n".join(out)
PARTICLE = r"(?=\s|\(|(|은|는|이|가|란|을|를|의|과|와|에|도|로|으로|,|·|라|$)"
def score_sentence(sent: str, name: str, key: str):
"""정의 문장일수록 높은 점수. 용어가 없으면 None."""
flat = norm_key(sent)
if key not in flat or len(flat) - len(key) < 8: # 제목줄 등 너무 짧은 문장 제외
return None
pat = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(pat + PARTICLE, sent)
if not m:
return 0
sc = 1
paren = r"\s?(?:\([^)]*\)|([^)]*))?\s?"
if re.match(r"^\s*" + pat + paren + r"(?:이란|란|은|는|이라 함은|라 함은|이라고 함은)\s", sent):
sc += 4
if re.search(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이다|것이다)\.?$", sent):
sc += 3
elif re.search(r"(정의|의미|개념|나타내는|지표|방법|과정|상태|활동|체계|이론|모형)", sent):
sc += 2
elif re.search(pat + paren + r"(?:이?라고?\s?(?:한다|부른다|일컫는다|정의한다|명명한다))", sent):
sc += 6
if re.match(r"^(에|을|를|의|은|는|이|가|와|과|로|으로|하여|하고|되어|및|또는|등)\s", sent):
sc -= 6
if 20 <= len(sent) <= 220:
sc += 1
if len(sent) > 300:
sc -= 3
return sc
EN_PAREN = re.compile(r"\(\s*([A-Za-z][A-Za-z0-9 ,;:'&\-/\.]{1,90}?)\s*\)")
def english_aliases(sent: str, name: str):
"""문장에서 '용어(English, ABC)' 꼴을 찾아 영문 표기를 돌려줌."""
flat_name = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(flat_name + r"\s?" + EN_PAREN.pattern, sent)
if not m:
return []
return [c for c in (clean_alias(a) for a in re.split(r"[;,]|:", m.group(1))) if c]
# ---------------------------------------------------------------- 메인
def build():
t0 = time.time()
corpus = Corpus()
toc = {}
entries = []
for b in BOOKS:
doc = corpus.open(b)
toc[b["book_id"]] = extract_toc(doc, b)
es = extract_index(doc, b)
for e in es:
e["book_id"] = b["book_id"]
entries.extend(es)
log(f"[{b['book_id']}] 목차 {len(toc[b['book_id']])}개, 색인 {len(es)}개")
chidx = ChapterIndex(toc)
books_by_id = {b["book_id"]: b for b in BOOKS}
# ---- 1) 색인 항목 → 용어 통합
terms: dict[str, dict] = {}
alias_index: dict[str, str] = {} # 별칭 키 → 용어 키
xrefs = []
def get_term(name, kind):
key = norm_key(name)
t = terms.get(key)
if t is None:
t = terms[key] = {
"key": key, "id": tid(key), "name": name, "kind": kind, "aliases": [],
"sources": [], "index_entries": [],
}
return t
# 한글/인물 먼저, 영문은 뒤에서 별칭 연결을 시도
for e in sorted(entries, key=lambda e: e["section"] == "en"):
if e.get("see"):
xrefs.append(e)
continue
name, aliases = split_name(e["term"])
if e["section"] == "en" and not HANGUL.search(name):
e["_name"], e["_aliases"] = name, aliases
continue
kind = "person" if e["section"] == "person" else "concept"
t = get_term(name, kind)
for a in aliases:
if a not in t["aliases"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
# ---- 2) 본문에서 영문 표기 찾기 (색인 페이지의 문장)
log("본문 문장 분석 중…")
for t in terms.values():
for s in t["sources"]:
b = books_by_id[s["book_id"]]
pi = corpus.pdf_index(b, s["page"])
if pi is None:
continue
for sent in corpus.sentences(b["book_id"], pi):
for a in english_aliases(sent, t["name"]):
if a not in t["aliases"] and norm_key(a) != t["key"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
# ---- 3) 영문 색인 항목: 별칭과 일치하면 합치고, 아니면 독립 용어
en_new = 0
for e in entries:
if "_name" not in e:
continue
k = norm_key(e["_name"])
target = alias_index.get(k) or next((alias_index[norm_key(a)] for a in e["_aliases"] if norm_key(a) in alias_index), None)
if target is None and k in terms:
target = k
if target:
t = terms[target]
if e["_name"] not in t["aliases"] and norm_key(e["_name"]) != t["key"]:
t["aliases"].append(e["_name"])
else:
t = get_term(e["_name"], "concept")
t["lang"] = "en"
en_new += 1
for a in e["_aliases"]:
if a not in t["aliases"]:
t["aliases"].append(a)
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
if {"book_id": e["book_id"], "page": p} not in t["sources"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
log(f"영문 독립 용어 {en_new}개")
for x in xrefs:
src = terms.get(norm_key(split_name(x["see"])[0]))
if src and x["term"] not in src["aliases"]:
src["aliases"].append(x["term"])
# ---- 4) 출처마다 단원·은하·문장 연결, 정의 후보 선택
log("출처·정의 연결 중…")
for t in terms.values():
best_def = None
galaxy_votes = defaultdict(float)
memberships = {}
for s in t["sources"]:
b = books_by_id[s["book_id"]]
node = chidx.locate(b["book_id"], s["page"])
chapter, section, chain = chidx.chapter_section(node)
s["toc_id"] = node["id"] if node else None
s["chapter_id"] = chapter["id"] if chapter else None
s["section_id"] = section["id"] if section else None
s["path"] = " › ".join(n["title"] for n in chain[1:]) if chain else ""
g = galaxy_for(b, chapter, chain)
s["galaxy_id"] = g
if g:
galaxy_votes[g] += 3 if b["primary"] else 1
if b["primary"] and section:
memberships[section["id"]] = g
item = next((n for n in chain if n["kind"] == "item"), None)
if item and section["id"] not in t.setdefault("items", {}):
t["items"][section["id"]] = item["id"]
pi = corpus.pdf_index(b, s["page"])
s["pdf_page"] = pi + 1 if pi is not None else None
s["snippet"] = None
if pi is None:
continue
cand = []
for sent in corpus.sentences(b["book_id"], pi):
sc = score_sentence(sent, t["name"], t["key"])
if sc is not None:
cand.append((sc, sent))
if cand:
cand.sort(key=lambda x: (-x[0], len(x[1])))
sc, sent = cand[0]
s["snippet"] = sent
weight = sc + (1 if b["primary"] else 0)
if sc >= 7 and (best_def is None or weight > best_def[0]):
best_def = (weight, sent, b["book_id"], s["page"])
t["galaxy_id"] = max(galaxy_votes, key=galaxy_votes.get) if galaxy_votes else None
# 주 소속 절: 주 은하에 속한 절 가운데 처음 나온 것
t["section_ids"] = list(dict.fromkeys(memberships))
prim_secs = [sid for sid, g in memberships.items() if g == t["galaxy_id"]]
t["section_id"] = prim_secs[0] if prim_secs else None
if best_def:
t["definition"] = {"text": best_def[1], "book_id": best_def[2], "page": best_def[3], "method": "auto"}
else:
t["definition"] = None
# 보조 교재에만 있는 용어: 2026 절(하위 항목 제목 포함)과 출처 경로의 글자 bigram 유사도로 배정
def bigrams(text):
k = norm_key(text)
return {k[i:i + 2] for i in range(len(k) - 1)}
primary_nodes = [n for b in BOOKS if b["primary"] for n in toc[b["book_id"]]]
sec_nodes = [n for n in primary_nodes if n["kind"] == "section"]
sec_galaxy, sec_bag, item_bag, sec_items = {}, {}, {}, defaultdict(list)
for n in sec_nodes:
ch, _, chain = chidx.chapter_section(n)
sec_galaxy[n["id"]] = galaxy_for(books_by_id[n["book_id"]], ch, chain)
sec_bag[n["id"]] = bigrams(n["title"]) | bigrams(n["title"])
for n in primary_nodes:
if n["kind"] in ("item", "subitem"):
chain = chidx.ancestors(n)
sec = next((c for c in chain if c["kind"] == "section"), None)
if sec:
sec_bag[sec["id"]] |= bigrams(n["title"])
if n["kind"] == "item":
sec_items[sec["id"]].append(n)
item_bag[n["id"]] = bigrams(n["title"])
else:
it = next((c for c in chain if c["kind"] == "item"), None)
if it:
item_bag.setdefault(it["id"], set()).update(bigrams(n["title"]))
def sim(a, b):
return len(a & b) / ((len(a) * len(b)) ** 0.5 or 1)
# 보조 교재의 절 → 2026 절 매핑 (제목·상위·하위 항목 제목 전체의 bigram 유사도)
sec_map = {}
for b in BOOKS:
if b["primary"]:
continue
nodes = toc[b["book_id"]]
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
def desc_bag(n):
out = bigrams(n["title"])
for k in kids[n["id"]]:
out |= desc_bag(k)
return out
for n in nodes:
if n["kind"] not in ("section", "chapter"):
continue
chain = chidx.ancestors(n)
ch = next((c for c in chain if c["kind"] == "chapter"), None)
mapped = galaxy_for(b, ch, chain)
titles = " ".join(c["title"] for c in chain[1:])
forced = next((p for kw, title in SECONDARY_SECTION_OVERRIDES if kw in titles
for p in sec_nodes if p["title"].strip() == title), None)
if forced:
sec_map[n["id"]] = forced["id"]
continue
bag = desc_bag(n) | {x for c in chain[1:-1] for x in bigrams(c["title"])}
sec_map[n["id"]] = max(sec_nodes, key=lambda p: sim(bag, sec_bag[p["id"]]) + (0.08 if sec_galaxy[p["id"]] == mapped else 0))["id"]
for t in terms.values():
if t.get("section_id"):
continue
votes = defaultdict(int)
for s in t["sources"]:
if books_by_id[s["book_id"]]["primary"]:
continue
key = s.get("section_id") or s.get("chapter_id")
if key in sec_map:
votes[sec_map[key]] += 1
if votes:
best = max(votes, key=votes.get)
else:
best = max(sec_nodes, key=lambda n: sim(bigrams(t["name"]), sec_bag[n["id"]]))["id"]
t["section_id"] = best
t["galaxy_id"] = sec_galaxy[best]
t["section_guess"] = True
for t in terms.values():
if not t["galaxy_id"]:
t["galaxy_id"] = sec_galaxy.get(t["section_id"], "g1")
# 절 안의 항목(지도에서 작은 묶음): 원문 위치가 없으면 이름 유사도로 추정
item = t.get("items", {}).get(t["section_id"])
if not item and sec_items.get(t["section_id"]):
bag = bigrams(t["name"]) | {b for s in t["sources"] for b in bigrams(s.get("path", "").split(" › ")[-1])}
item = max(sec_items[t["section_id"]], key=lambda n: sim(bag, item_bag.get(n["id"], set())))["id"]
t["item_id"] = item
# ---- 5) 시안 예시 설명 붙이기
def find(name):
k = norm_key(name)
if k in terms:
return terms[k]
if k in alias_index:
return terms[alias_index[k]]
return None
for name, desc, example, aliases in curated.WIREFRAME_TERMS:
t = find(name)
if not t:
log(f" (시안 용어 없음) {name}")
continue
t["easy"] = desc
t["example"] = example
t["search_terms"] = aliases.split()
# 쉬운 뜻·사례 초안 (data/drafts/out_*.json, 교재 문장을 바탕으로 작성한 검토 전 초안)
drafts = {}
for f in sorted((DATA_DIR / "drafts").glob("out_*.json")):
try:
drafts.update({k: v for k, v in json.loads(f.read_text()).items()
if v and v.get("easy") and v.get("confidence") != "low"}) # 신뢰도 낮은 초안은 제외
except json.JSONDecodeError as e:
log(f" 초안 파일 오류 {f.name}: {e}")
used = 0
for t in terms.values():
dr = drafts.get(t["id"])
if dr and not t.get("easy"):
t["easy"] = dr["easy"].strip()
t["example"] = (dr.get("example") or "").strip() or None
t["easy_method"] = "draft"
used += 1
log(f"쉬운 뜻 초안 {used}개 반영")
# ---- 6) 관계 초안
relations = {}
def add_rel(a, b, typ, label, method, evidence=None, directed=True):
if a is None or b is None or a["id"] == b["id"]:
return
r = rid(a["id"], b["id"], typ)
if r in relations or rid(b["id"], a["id"], typ) in relations and not directed:
return
relations[r] = {
"relation_id": r, "source": a["id"], "target": b["id"], "type": typ, "label": label,
"directed": directed, "method": method, "evidence": evidence or [], "review_status": "draft",
}
for a, b, typ, label in curated.WIREFRAME_RELATIONS:
add_rel(find(a), find(b), typ, label, "wireframe")
for a, b, label in curated.CONTRAST_PAIRS:
add_rel(find(a), find(b), "contrasts_with", label, "curated", directed=False)
# 목차 계층: 제목이 용어인 항목 → 상위 항목 (part_of), '과정' 아래 형제는 순서(precedes)
for b in BOOKS:
nodes = toc[b["book_id"]]
by_id = {n["id"]: n for n in nodes}
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
for n in nodes:
parent = by_id.get(n["parent_id"])
tn = find(n["title"])
if parent and tn:
tp = find(parent["title"])
if tp:
add_rel(tn, tp, "part_of", f"목차에서 ‘{parent['title']}’의 하위 항목", "toc",
[{"book_id": b["book_id"], "page": n["page"]}])
for pid, ks in kids.items():
parent = by_id.get(pid)
if not parent or "과정" not in parent["title"] and parent["kind"] != "chapter":
continue
if "과정" not in parent["title"]:
continue
seq = [(k, find(k["title"])) for k in ks]
seq = [(k, t) for k, t in seq if t]
for (k1, t1), (k2, t2) in zip(seq, seq[1:]):
add_rel(t1, t2, "precedes", "다음 단계", "toc",
[{"book_id": b["book_id"], "page": k2["page"]}])
# 정의 문장 속에 등장하는 다른 용어 (가장 긴 것 최대 3개)
concept_terms = [t for t in terms.values() if t["kind"] == "concept" and len(t["key"]) >= 2 and HANGUL.search(t["name"])]
by_len = sorted(concept_terms, key=lambda t: -len(t["key"]))
for t in terms.values():
d = t.get("definition")
if not d:
continue
flat = norm_key(d["text"])
found = []
for o in by_len:
if o is t or len(o["key"]) < 3 or o["key"] in t["key"]:
continue
if o["key"] in flat and not any(o["key"] in f["key"] for f in found):
found.append(o)
if len(found) >= 3:
break
for o in found:
add_rel(t, o, "related_to", f"정의 문장에 ‘{o['name']}’이(가) 등장", "definition",
[{"book_id": d["book_id"], "page": d["page"]}])
# 이름 포함: '건강증진사업' → '건강증진' (같은 은하, 가장 긴 포함어 1개)
by_galaxy = defaultdict(list)
for t in concept_terms:
by_galaxy[t["galaxy_id"]].append(t)
for t in concept_terms:
best = None
for o in by_galaxy[t["galaxy_id"]]:
if o is t or len(o["key"]) < 3 or len(o["key"]) >= len(t["key"]):
continue
if o["key"] in t["key"] and (best is None or len(o["key"]) > len(best["key"])):
best = o
if best:
add_rel(t, best, "related_to", f"‘{best['name']}’에서 갈라져 나온 개념", "name")
# ---- 7) 출력
toc_out = []
for b in BOOKS:
for n in toc[b["book_id"]]:
ch, _, chain = chidx.chapter_section(n)
n = dict(n)
n["galaxy_id"] = galaxy_for(b, ch, chain)
toc_out.append(n)
term_out = []
for t in sorted(terms.values(), key=lambda t: t["name"]):
term_out.append({
"term_id": t["id"],
"name": t["name"],
"kind": t["kind"],
"lang": t.get("lang", "ko"),
"aliases": t["aliases"],
"search_terms": t.get("search_terms", []),
"galaxy_id": t["galaxy_id"],
"section_id": t["section_id"],
"section_guess": t.get("section_guess", False),
"item_id": t.get("item_id"),
"section_ids": t["section_ids"],
"definition": t["definition"],
"easy": t.get("easy"),
"example": t.get("example"),
"easy_method": t.get("easy_method") or ("wireframe" if t.get("easy") else None),
"sources": t["sources"],
"index_entries": t["index_entries"],
"review_status": "draft",
})
out = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"books": [{k: v for k, v in b.items() if k not in ("file", "toc_pages", "index_pages")} | {"filename": b["file"]().split("/")[-1]} for b in BOOKS],
"galaxies": GALAXIES,
"toc": toc_out,
"terms": term_out,
"relations": list(relations.values()),
}
DATA_DIR.mkdir(exist_ok=True)
path = DATA_DIR / "atlas.json"
path.write_text(json.dumps(out, ensure_ascii=False, indent=1))
by_method = defaultdict(int)
for r in relations.values():
by_method[r["method"]] += 1
log(f"용어 {len(term_out)}개 (정의 후보 {sum(1 for t in term_out if t['definition'])}개), "
f"관계 {len(relations)}개 {dict(by_method)}, {time.time()-t0:.1f}s → {path}")
if __name__ == "__main__":
build()
+142
View File
@@ -0,0 +1,142 @@
"""교재 PDF 목록과 은하(대단원) 구성.
은하는 2026 지역사회간호학 목차의 9개 장을 기준으로 삼고,
나머지 교재의 장은 아래 매핑표로 가장 가까운 은하에 연결한다.
"""
from pathlib import Path
import fnmatch
import unicodedata
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
def _find(pattern: str) -> str:
nfc = lambda x: unicodedata.normalize("NFC", x)
hits = sorted(str(p) for p in ROOT.iterdir() if fnmatch.fnmatch(nfc(p.name), nfc(pattern)))
if not hits:
raise FileNotFoundError(pattern)
return hits[0]
# toc_pages / index_pages: 0부터 시작하는 PDF 파일상 페이지 번호
BOOKS = [
{
"book_id": "cn26a",
"title": "지역사회간호학 1권",
"short": "지간 2026-1",
"edition": "2026",
"year": 2026,
"primary": True,
"file": lambda: _find("2026 지역사회간호학 1권.pdf"),
"toc_style": "cn2026",
"toc_pages": range(6, 17),
"index_pages": range(402, 408),
},
{
"book_id": "cn26b",
"title": "지역사회간호학 2권",
"short": "지간 2026-2",
"edition": "2026",
"year": 2026,
"primary": True,
"file": lambda: _find("2026 지역사회간호학 2권.pdf"),
"toc_style": "cn2026",
"toc_pages": range(6, 14),
"index_pages": range(312, 317),
},
{
"book_id": "pac23a",
"title": "지역사회간호학 1 (5판, 퍼시픽북스)",
"short": "퍼시픽 5판-1",
"edition": "5판",
"year": 2023,
"primary": False,
"file": lambda: _find("*pacific*-1_*.pdf"),
"toc_style": "pacific",
"toc_pages": range(9, 17),
"index_pages": None, # 자동 탐지
},
{
"book_id": "pac23b",
"title": "지역사회간호학 2 (5판, 퍼시픽북스)",
"short": "퍼시픽 5판-2",
"edition": "5판",
"year": 2023,
"primary": False,
"file": lambda: _find("*pacific*-2_*.pdf"),
"toc_style": "pacific",
"toc_pages": range(9, 17),
"index_pages": None,
},
{
"book_id": "he23",
"title": "보건교육학",
"short": "보건교육학 2023",
"edition": "2023",
"year": 2023,
"primary": False,
"file": lambda: _find("2023 보건교육학*.pdf"),
"toc_style": "healthedu",
"toc_pages": range(5, 12),
"index_pages": range(305, 309),
},
]
# 은하 = 2026 교재의 장. sub(쉬운 보조 설명)는 교수자 검수 대상 초안.
GALAXIES = [
{"id": "g1", "chapter_no": 1, "name": "지역사회간호개론", "sub": "지역사회와 간호의 기본 생각", "color": "#9de0cc"},
{"id": "g2", "chapter_no": 2, "name": "지역사회보건행정", "sub": "제도·정책·조직으로 건강 지키기", "color": "#e5a7c5"},
{"id": "g3", "chapter_no": 3, "name": "지역사회 간호과정", "sub": "문제를 찾고, 해결하고, 돌아보기", "color": "#c4a5ff"},
{"id": "g4", "chapter_no": 4, "name": "건강증진", "sub": "건강하게 살아갈 힘 기르기", "color": "#8fd694"},
{"id": "g5", "chapter_no": 5, "name": "보건교육", "sub": "건강을 배우고 실천하도록 돕기", "color": "#f2d17c"},
{"id": "g6", "chapter_no": 6, "name": "역학", "sub": "질병은 얼마나, 누구에게, 왜 생길까", "color": "#93c8f3"},
{"id": "g7", "chapter_no": 7, "name": "생애주기별 인구집단 간호", "sub": "아기부터 노인까지 나이에 맞는 돌봄", "color": "#f0a98a"},
{"id": "g8", "chapter_no": 8, "name": "건강문제별 인구집단 간호", "sub": "감염병·만성질환 등 문제별 관리", "color": "#ef8f9f"},
{"id": "g9", "chapter_no": 9, "name": "생활터별 인구집단 간호", "sub": "가정·학교·일터·환경에서의 돌봄", "color": "#e5bd8a"},
]
# 보조 교재 장 번호 → 은하. (book_id, 장 번호) 기준, 절 제목 키워드로 덮어쓸 수 있음.
SECONDARY_CHAPTER_MAP = {
("pac23a", 1): "g1",
("pac23a", 2): "g2",
("pac23a", 3): "g3",
("pac23a", 4): "g3",
("pac23a", 5): "g4",
("pac23b", 6): "g9",
("pac23b", 7): "g7",
("pac23b", 8): "g6",
("pac23b", 9): "g8",
("pac23b", 10): "g9",
("pac23b", 11): "g8",
("he23", None): "g5",
}
# 절/항 제목에 이 키워드가 있으면 해당 은하로 보냄 (보조 교재에만 적용)
SECONDARY_KEYWORD_MAP = [
("보건교육", "g5"),
("건강증진", "g4"),
("역학", "g6"),
("가족", "g9"),
("학교", "g9"),
("산업", "g9"),
("환경", "g9"),
("재난", "g8"),
("감염", "g8"),
("만성질환", "g8"),
]
# 보조 교재 목차 경로에 키워드가 있으면 2026 교재의 해당 제목 절로 우선 배정
SECONDARY_SECTION_OVERRIDES = [
("산업", "직업건강간호"),
("작업환경", "직업건강간호"),
("근로자", "직업건강간호"),
("재난", "재난관리"),
("학교", "학교보건"),
("학생", "학교보건"),
("가족", "가족간호"),
("보건통계", "역학적 측정지표"),
("모자보건", "모자보건사업"),
("영유아", "모자보건사업"),
("노인", "노인보건사업"),
("재활", "재활간호사업"),
]
+101
View File
@@ -0,0 +1,101 @@
"""사람이 직접 작성한 초안 데이터. 모두 review_status='draft'로 들어가며 교수자 검수가 필요하다.
- WIREFRAME_*: 기존 HTML 시안에 있던 예시 설명·관계 (교재 확인 전 예시)
- CONTRAST_PAIRS: 비교해서 배워야 하는 대표적 개념 쌍 (일반 지역사회간호학 지식 기반 초안)
"""
# 이름, 쉬운 뜻, 사례, 검색 표현
WIREFRAME_TERMS = [
("건강증진", "사람들이 자신의 건강을 더 잘 관리하고 개선하도록 돕는 과정입니다.", "걷기 좋은 환경을 만들고, 주민이 함께 건강 활동을 계획합니다.", "건강 향상"),
("보건교육", "건강에 관한 지식과 실천 방법을 배우도록 돕는 활동입니다.", "주민들과 식품의 영양표시를 읽고 활용하는 방법을 연습합니다.", "건강교육"),
("건강행태", "건강과 관련된 일상적인 행동을 뜻합니다.", "걷기, 식사, 수면 등 일상에서 반복하는 행동을 살펴봅니다.", "건강행동 생활습관"),
("주민참여", "주민이 지역의 문제를 파악하고 해결하는 과정에 함께하는 것입니다.", "주민이 마을 건강사업의 주제와 운영 방법을 함께 결정합니다.", "지역주민 참여"),
("역량강화", "자신과 공동체의 건강에 영향을 주는 문제를 다룰 힘을 키우는 과정입니다.", "주민 모임이 필요한 정보를 찾고 마을의 개선안을 제안합니다.", "임파워먼트 empowerment"),
("지역사회 간호사정", "지역의 건강 상태와 생활환경, 이용할 수 있는 자원을 살펴보는 과정입니다.", "지역 통계와 주민의 이야기를 모아 어떤 건강 문제가 있는지 알아봅니다.", "지역사회 사정 지역사회 건강사정 자료수집"),
("지역사회 간호진단", "사정한 자료를 바탕으로 간호가 필요한 지역사회의 문제를 정리하는 것입니다.", "수집한 자료를 비교하고, 지역에서 해결해야 할 건강 문제를 구체화합니다.", "지역사회 진단 건강문제"),
("우선순위 설정", "여러 문제 가운데 무엇을 먼저 다룰지 정하는 과정입니다.", "문제의 크기와 심각성, 해결 가능성 등을 살펴 사업의 순서를 정합니다.", "우선순위 무엇부터 먼저"),
("지역사회 간호계획", "목표를 정하고, 이를 달성하기 위한 활동과 자원 등을 구체화하는 단계입니다.", "사업의 대상, 활동, 일정, 담당자와 평가 방법을 정합니다.", "간호계획 사업계획 목표"),
("지역사회 간호수행", "계획한 간호 활동이나 사업을 실제로 실행하는 단계입니다.", "계획에 따라 주민 교육을 운영하고 참여 상황을 기록합니다.", "간호수행 실행 사업운영"),
("지역사회 간호평가", "사업이 어떻게 진행되었고 어떤 변화가 있었는지 살펴 판단하는 과정입니다.", "계획한 활동이 진행되었는지와 목표한 변화가 나타났는지 확인합니다.", "간호평가 사업평가"),
("역학", "인구집단에서 건강과 질병이 어떻게 분포하고 무엇과 관련되는지 연구하는 분야입니다.", "지역이나 연령에 따라 건강 문제의 분포가 다른지 살펴봅니다.", "epidemiology"),
("발생률", "일정 기간에 질병이 새롭게 발생하는 빈도를 나타내는 지표입니다.", "관찰 기간 중 새롭게 질병이 발생한 사람을 살펴봅니다.", "새 환자 신규 발생 incidence"),
("유병률", "특정 시점이나 기간에 질병을 가진 사람의 비율을 나타내는 지표입니다.", "현재 지역주민 가운데 해당 질병을 가진 사람이 얼마나 되는지 살펴봅니다.", "기존 환자 prevalence"),
("위험요인", "질병이나 건강 문제의 발생 가능성과 관련된 특성이나 노출입니다.", "어떤 생활환경이나 행동이 건강 문제와 관련되는지 살펴봅니다.", "위험인자 risk factor"),
("건강지표", "인구집단의 건강 상태 등을 수치로 나타내는 지표입니다.", "지역별 건강 수준을 비교하거나 시간에 따른 변화를 살펴봅니다.", "건강수준 통계"),
("가족간호", "가족의 건강 문제와 강점을 살펴 건강을 유지하고 증진하도록 돕는 간호입니다.", "가족 구성원의 역할과 관계, 건강관리 방법을 함께 살펴봅니다.", "가족 건강"),
("방문간호", "대상자가 생활하는 곳을 방문하여 제공하는 간호입니다.", "가정을 방문하여 건강 상태와 실제 생활환경을 함께 살펴봅니다.", "가정방문 방문건강관리"),
("사례관리", "복합적인 요구가 있는 대상자에게 필요한 서비스가 연결되도록 조정하는 과정입니다.", "건강관리와 생활지원이 함께 필요한 대상자의 서비스 이용을 조정합니다.", "케이스매니지먼트"),
("돌봄의 연속성", "시간이나 서비스 제공 장소가 바뀌어도 돌봄이 이어지는 것을 뜻합니다.", "퇴원 후에도 지역에서 필요한 건강관리를 받을 수 있도록 연결합니다.", "계속 돌봄 지속적 관리"),
("보건의료체계", "사람들에게 보건의료를 제공하기 위한 조직, 인력, 자원과 제도의 체계입니다.", "지역의 기관들이 어떤 역할로 건강관리를 제공하는지 살펴봅니다.", "보건의료시스템"),
("일차보건의료", "주민이 가까운 생활권에서 필요한 기본적인 건강관리를 받을 수 있도록 하는 접근입니다.", "지역의 건강 요구에 대응하는 접근성 높은 서비스를 생각해 봅니다.", "primary health care PHC"),
("보건소", "지역주민의 건강증진과 질병예방 등 공공보건 기능을 수행하는 기관입니다.", "지역의 건강사업을 운영하고 주민에게 필요한 보건서비스를 제공합니다.", "지역보건기관"),
("의뢰", "대상자의 요구에 맞는 전문 인력이나 기관으로 연결하는 것입니다.", "추가적인 서비스가 필요한 대상자를 적절한 기관으로 연결합니다.", "기관 의뢰 referral"),
("자원 연계", "대상자나 지역에 필요한 자원과 서비스를 찾아 연결하는 것입니다.", "건강관리 외에 필요한 생활지원 자원도 찾아 연결합니다.", "서비스 연결 지역자원"),
]
# (시작, 도착, 유형, 라벨)
WIREFRAME_RELATIONS = [
("지역사회 간호사정", "지역사회 간호진단", "precedes", "사정 자료를 바탕으로 문제를 정리"),
("지역사회 간호진단", "우선순위 설정", "precedes", "정리한 문제 중 먼저 다룰 문제를 선택"),
("우선순위 설정", "지역사회 간호계획", "precedes", "선택한 문제에 대한 활동을 계획"),
("지역사회 간호계획", "지역사회 간호수행", "precedes", "계획한 활동을 실행"),
("지역사회 간호수행", "지역사회 간호평가", "precedes", "수행 과정과 결과를 살펴봄"),
("지역사회 간호평가", "지역사회 간호사정", "informs", "평가 결과를 다음 사정에 반영"),
("건강지표", "지역사회 간호사정", "informs", "건강지표를 사정 자료로 활용"),
("보건교육", "건강증진", "part_of", "보건교육은 건강증진에 활용되는 방법"),
("보건교육", "건강행태", "informs", "건강행태에 관한 학습과 실천을 지원"),
("주민참여", "건강증진", "informs", "주민참여를 통해 건강증진을 추진"),
("주민참여", "역량강화", "informs", "참여를 통해 공동체의 역량을 키움"),
("건강지표", "역학", "informs", "역학에서 건강 상태를 나타내는 지표를 활용"),
("발생률", "건강지표", "part_of", "발생률은 건강지표의 한 예"),
("유병률", "건강지표", "part_of", "유병률은 건강지표의 한 예"),
("역학", "위험요인", "related_to", "역학에서 건강 문제와 위험요인의 관련성을 탐구"),
("가족간호", "방문간호", "related_to", "가족이 생활하는 곳에서 간호 제공"),
("방문간호", "사례관리", "informs", "방문 중 파악한 복합적인 요구의 관리를 연결"),
("사례관리", "돌봄의 연속성", "informs", "서비스 조정을 통해 돌봄이 이어지도록 지원"),
("사례관리", "자원 연계", "related_to", "필요한 자원과 서비스를 찾아 연결"),
("일차보건의료", "보건의료체계", "part_of", "보건의료체계 안에서 일차보건의료를 구현"),
("보건소", "보건의료체계", "part_of", "보건소는 지역 보건의료체계의 구성 기관"),
("보건소", "의뢰", "related_to", "필요에 따라 다른 전문 기관으로 의뢰"),
("의뢰", "자원 연계", "related_to", "요구에 맞는 서비스와 자원 연결"),
]
# (A, B, 구분 포인트)
CONTRAST_PAIRS = [
("발생률", "유병률", "새로 생긴 환자 vs 현재 가진 사람 전체"),
("민감도", "특이도", "환자를 양성으로 vs 비환자를 음성으로 판정하는 능력"),
("타당도", "신뢰도", "정확하게 재는가 vs 일관되게 재는가"),
("상대위험도", "기여위험도", "위험의 배수 vs 위험의 차이"),
("코호트 연구", "환자-대조군 연구", "원인→결과로 추적 vs 결과→원인을 거슬러 조사"),
("1차 자료", "2차 자료", "직접 수집한 자료 vs 이미 있는 자료"),
("형성평가", "총괄평가", "진행 중 개선을 위한 평가 vs 끝난 뒤 결과 판단"),
("양성예측도", "음성예측도", "양성 판정자 중 실제 환자 비율 vs 음성 판정자 중 실제 비환자 비율"),
("직접전파", "간접전파", "사람 간 직접 접촉 vs 매개물을 통한 전파"),
("능동면역", "수동면역", "스스로 항체를 만듦 vs 만들어진 항체를 받음"),
("행위별수가제", "포괄수가제", "진료 항목마다 vs 질병군별로 정해진 금액"),
("사회보험", "공공부조", "보험료 기반 vs 세금으로 저소득층 지원"),
("국민건강보험", "의료급여", "보험료 기반 건강보장 vs 저소득층 대상 공공부조"),
("구조적 지역사회", "기능적 지역사회", "지리·행정 경계로 묶임 vs 공동의 목표·기능으로 묶임"),
("공중보건", "지역사회간호", "인구집단 건강 전반 vs 간호사가 수행하는 지역사회 중심 간호"),
("일차예방", "이차예방", "발생 전 예방 vs 조기발견·조기치료"),
("이차예방", "삼차예방", "조기발견 vs 재활·악화 방지"),
("상대평가", "절대평가", "집단 내 순위 기준 vs 정해진 목표 기준"),
("고전적 조건반사이론", "조작적 조건형성이론", "자극-반응 연합 vs 행동 결과(강화)에 따른 학습"),
("기술역학", "분석역학", "누가·언제·어디서 분포 기술 vs 원인 가설 검정"),
("전향적 조사", "후향적 조사", "앞으로 추적 vs 과거 기록 조사"),
("조출생률", "일반출산율", "전체 인구 기준 vs 가임기 여성 기준"),
("영아사망률", "신생아사망률", "생후 1년 미만 vs 생후 28일 미만"),
("감수성", "면역", "감염되기 쉬운 상태 vs 감염에 저항하는 상태"),
("현성감염", "불현성감염", "증상이 나타남 vs 증상 없이 감염"),
("보균자", "환자", "증상 없이 병원체 보유 vs 증상이 있는 감염자"),
("병원체", "병원소", "질병을 일으키는 미생물 vs 병원체가 살며 증식하는 장소"),
("핵가족", "확대가족", "부부와 미혼 자녀 vs 여러 세대·친족 포함"),
("가계도", "가족밀착도", "가족 구조·세대 vs 구성원 간 친밀도"),
("외부체계도", "가족밀착도", "가족과 외부 체계의 관계 vs 가족 내부 관계"),
("허용농도", "노출기준", "작업장 유해물질 허용 한계 관련 개념"),
("산업재해", "직업병", "업무상 사고·질병 전반 vs 작업환경 노출로 생긴 질병"),
("건강증진", "질병예방", "건강 잠재력 향상 vs 특정 질병 발생 방지"),
("효과성", "효율성", "목표를 얼마나 달성했나 vs 투입 대비 산출"),
("투입", "산출", "체계로 들어가는 자원 vs 체계가 내보내는 결과"),
("개방체계", "폐쇄체계", "환경과 교류 vs 교류 없음"),
]
+150
View File
@@ -0,0 +1,150 @@
"""찾아보기(색인) 페이지에서 용어와 인쇄 페이지 번호를 추출."""
import re
import pymupdf
from .pdfutil import Line, page_lines
PAGES_ONLY = re.compile(r"^\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*$")
ENTRY = re.compile(r"^(.+?)\s*[\s_]\s*(\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*)$")
XREF = re.compile(r"^(.+?)\s*(?:→|☞|⇒|=>|참조|see)\s*(.+)$")
HEADINGS = {"찾아보기", "한글 찾아보기", "영문 찾아보기", "인물 찾아보기", "기타", "INDEX", "Index"}
JAMO = set("ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎㄲㄸㅃㅆㅉ가나다라마바사아자차카타파하")
INDEX_PAT = re.compile(r"^(.+?)\s+(\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*)$")
def find_index_pages(doc: pymupdf.Document) -> list[int]:
"""책 끝 60쪽 가운데 대부분의 줄이 '용어 페이지' 꼴인 쪽."""
pages = []
for i in range(max(0, len(doc) - 60), len(doc)):
lines = page_lines(doc, i)
merged = _merge_numbers(lines)
if len(merged) < 15:
continue
hits = sum(bool(INDEX_PAT.match(l.text)) for l in merged)
if hits / len(merged) > 0.6:
pages.append(i)
# 연속 구간만 사용 (본문 중간의 표 등 배제)
if not pages:
return []
run = [pages[-1]]
for p in reversed(pages[:-1]):
if run[0] - p <= 2:
run.insert(0, p)
else:
break
return run
def _merge_numbers(lines: list[Line]) -> list[Line]:
"""'용어'와 '페이지 번호'가 별개 줄로 나뉜 경우(같은 높이) 하나로 합침."""
out: list[Line] = []
used = set()
nums = [k for k, l in enumerate(lines) if PAGES_ONLY.match(l.text)]
for k in nums:
L = lines[k]
best = None
for j, T in enumerate(lines):
if j == k or j in nums or abs(T.y - L.y) > 3.5:
continue
gap = L.x - T.x1
if -2 <= gap < 160 and (best is None or gap < best[0]):
best = (gap, j)
if best is not None:
used.add(k)
j = best[1]
T = lines[j]
lines[j] = Line(T.page, T.x, T.y, L.x1, T.size, T.font, f"{T.text} {L.text}")
for k, l in enumerate(lines):
if k not in used:
out.append(l)
return out
def _pages(s: str) -> list[int]:
out = []
for part in re.split(r"\s*,\s*", s):
m = re.match(r"(\d+)(?:\s*[~\-–]\s*(\d+))?", part)
if m:
out.append(int(m.group(1)))
return out
def _columns(lines: list[Line]) -> list[list[Line]]:
"""x 시작점으로 단을 나눈 뒤 각 단을 위→아래로 정렬."""
xs = sorted({round(l.x) for l in lines})
cols: list[list[Line]] = []
starts: list[float] = []
for l in sorted(lines, key=lambda l: l.x):
for ci, s in enumerate(starts):
if abs(l.x - s) < 40:
cols[ci].append(l)
break
else:
starts.append(l.x)
cols.append([l])
order = sorted(range(len(cols)), key=lambda c: starts[c])
return [sorted(cols[c], key=lambda l: l.y) for c in order]
def extract_index(doc: pymupdf.Document, book: dict) -> list[dict]:
pages = list(book["index_pages"]) if book.get("index_pages") else find_index_pages(doc)
entries: list[dict] = []
section = "ko"
for pi in pages:
h = doc[pi].rect.height
lines = [l for l in page_lines(doc, pi) if 40 < l.y < h - 40 and l.size < 20]
lines = _merge_numbers(lines)
for col in _columns(lines):
pending: Line | None = None
for l in col:
t = l.text.strip()
if t in HEADINGS or t.endswith("찾아보기"):
if "인물" in t:
section = "person"
elif "영문" in t:
section = "en"
elif "한글" in t:
section = "ko"
pending = None
continue
if len(t) == 1 and (t in JAMO or t.isascii() and t.isalpha()):
if section != "person":
section = "en" if t.isascii() else "ko"
pending = None
continue
if t == "●" or t.isdigit() and int(t) > 400:
continue
m = ENTRY.match(t)
if not m:
x = XREF.match(t)
if x:
entries.append({"term": x.group(1).strip(), "see": x.group(2).strip(),
"pages": [], "section": section, "index_page": pi})
pending = None
continue
# 다음 줄로 이어지는 긴 용어
if pending is not None and abs(l.y - pending.y) < 20:
pending = Line(l.page, pending.x, l.y, l.x1, l.size, l.font, pending.text + " " + t)
else:
pending = l
continue
term = m.group(1).strip()
if pending is not None and abs(l.y - pending.y) < 20:
term = pending.text + " " + term
pending = None
term = re.sub(r"\s*●\s*", "", term).strip(" ,")
if not term or term.isdigit():
continue
# 영문 색인에서 두 항목이 한 줄로 붙은 경우: "a 20, 101 b" → a / b
split = re.match(r"^(.+?) (\d{1,3}(?:, ?\d{1,3})*) ([a-z].+)$", term) if section == "en" else None
if split:
entries.append({"term": split.group(1), "pages": _pages(split.group(2)),
"section": section, "index_page": pi})
term = split.group(3)
sec = section
if sec == "person" and not re.search(r"[가-힣]", term):
sec = "en" # 인물 찾아보기 뒤에 이어진 영문 색인
entries.append({"term": term, "pages": _pages(m.group(2)), "section": sec,
"index_page": pi})
return entries
+82
View File
@@ -0,0 +1,82 @@
"""PDF 텍스트 줄 단위 추출 도우미."""
import re
import unicodedata
from dataclasses import dataclass
import pymupdf
@dataclass
class Line:
page: int
x: float
y: float
x1: float
size: float
font: str
text: str
def clean(s: str) -> str:
s = unicodedata.normalize("NFC", s)
s = "".join(c for c in s if c in "\n\t" or unicodedata.category(c)[0] != "C")
s = s.replace(" ", " ").replace(" ", " ").replace(" ", " ")
s = s.replace("ᝍ", "-").replace("․", "·")
return re.sub(r"\s+", " ", s).strip()
def page_lines(doc: pymupdf.Document, i: int) -> list[Line]:
out = []
for b in doc[i].get_text("dict")["blocks"]:
for l in b.get("lines", []):
spans = l["spans"]
if not spans:
continue
text = clean("".join(s["text"] for s in spans))
if not text:
continue
x0, y0, x1, _ = l["bbox"]
out.append(Line(i, x0, y0, x1, spans[0]["size"], spans[0]["font"], text))
return out
def print_label(doc: pymupdf.Document, i: int) -> str:
return doc[i].get_label() or str(i + 1)
def label_to_index(doc: pymupdf.Document) -> dict[str, int]:
"""인쇄 페이지 번호(라벨) → PDF 파일상 0-based 페이지. 아라비아 숫자 라벨만."""
m = {}
for i in range(len(doc)):
lab = doc[i].get_label() or str(i + 1)
if lab.isdigit() and lab not in m:
m[lab] = i
return m
def body_text(doc: pymupdf.Document, i: int) -> str:
"""머리말·꼬리말을 뺀 본문 텍스트. 줄바꿈으로 끊긴 단어를 이어 붙임."""
h = doc[i].rect.height
parts = []
for b in doc[i].get_text("dict")["blocks"]:
buf = ""
for l in b.get("lines", []):
y = l["bbox"][1]
if y < 50 or y > h - 45:
continue
raw = unicodedata.normalize("NFC", "".join(s["text"] for s in l["spans"]))
if not raw.strip():
continue
if buf and not buf.endswith(" ") and not raw.startswith(" "):
# 한글끼리 줄이 바뀌면 단어 중간에서 끊긴 것으로 보고 붙인다
if not (_is_hangul(buf[-1]) and _is_hangul(raw[0])):
buf += " "
buf += raw
t = clean(buf)
if t:
parts.append(t)
return "\n".join(parts)
def _is_hangul(c: str) -> bool:
return "가" <= c <= "힣"
+139
View File
@@ -0,0 +1,139 @@
"""목차 페이지에서 장·절·항 트리를 추출."""
import hashlib
import re
import pymupdf
from .pdfutil import page_lines
LEADER = re.compile(r"(?:j{3,}|[·․\.…]{3,}|·\s?·)[\sj·․\.…]*")
TRAIL_PAGE = re.compile(r"(?:_|\s|^)(\d{1,3})$")
ROMAN = "ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ"
LEVELS = {"chapter": 1, "part": 2, "section": 3, "item": 4, "subitem": 5}
def _split_page(text: str):
t = LEADER.sub(" ", text).strip()
m = TRAIL_PAGE.search(t)
if m and (m.start() > 0 or t.startswith("_")):
return t[: m.start()].strip(" _"), int(m.group(1))
return t, None
def _classify(style: str, text: str, size: float):
"""(kind, number, title) 또는 None."""
if style == "cn2026":
for kind, pat in (
("chapter", r"^(\d+)장\s*(.+)$"),
("section", r"^(\d+)절\s*(.+)$"),
("item", r"^(\d+)\.\s*(.+)$"),
("subitem", r"^(\d+)\)\s*(.+)$"),
):
m = re.match(pat, text)
if m:
return kind, m.group(1), m.group(2).strip()
return None
if style == "pacific":
m = re.match(r"^제(\d+)장\s*(.*)$", text)
if m:
return "chapter", m.group(1), m.group(2).strip()
m = re.match(rf"^([{ROMAN}])\.?\s*(.*)$", text)
if m:
return "part", str(ROMAN.index(m.group(1)) + 1), m.group(2).strip()
for kind, pat in (
("section", r"^제(\d+)절\s*(.+)$"),
("item", r"^(\d+)\.\s*(.+)$"),
("subitem", r"^(\d+)\)\s*(.+)$"),
):
m = re.match(pat, text)
if m:
return kind, m.group(1), m.group(2).strip()
return None
if style == "healthedu":
m = re.match(r"^(\d+)\.\s*(.+)$", text)
if m and size >= 8.7:
return "chapter", m.group(1), re.sub(r"(?<=[가-힣]) (?=[가-힣]$)", "", m.group(2).strip())
m = re.match(r"^\((\d+)\)\s*(.+)$", text)
if m:
return "subitem", m.group(1), m.group(2).strip()
m = re.match(r"^(\d+)\s+(.+)$", text)
if m and size < 8.2:
return "section", m.group(1), m.group(2).strip()
return None
raise ValueError(style)
def extract_toc(doc: pymupdf.Document, book: dict) -> list[dict]:
style = book["toc_style"]
nodes: list[dict] = []
stack: list[dict] = []
pending_title_for = None # 제목이 다음 줄에 오는 장/부
seq = 0
for pi in book["toc_pages"]:
for ln in page_lines(doc, pi):
if ln.size >= 16 or ln.y < 60: # 쪽 제목·머리말
continue
text, page = _split_page(ln.text)
if not text:
continue
if text in ("Contents", "목차", "w"):
continue
c = _classify(style, text, ln.size)
if c is None:
if pending_title_for is not None and not pending_title_for["title"]:
pending_title_for["title"] = text
if page is not None and pending_title_for["page"] is None:
pending_title_for["page"] = page
pending_title_for = None
elif nodes and nodes[-1]["page"] is None:
nodes[-1]["title"] = (nodes[-1]["title"] + " " + text).strip()
if page is not None:
nodes[-1]["page"] = page
continue
kind, num, title = c
level = LEVELS[kind]
while stack and stack[-1]["level"] >= level:
stack.pop()
seq += 1
node = {
# 공개 화면 주소에 교재가 드러나지 않도록 해시 ID
"id": "s_" + hashlib.sha1(f"{book['book_id']}|{seq}".encode()).hexdigest()[:10],
"book_id": book["book_id"],
"kind": kind,
"level": level,
"number": num,
"title": title,
"page": page,
"parent_id": stack[-1]["id"] if stack else None,
}
# 같은 장이 목차 여러 쪽에 걸쳐 반복 표기된 경우 건너뜀
same = [n for n in nodes if n["kind"] == "chapter" and n["number"] == num
and (not title or not n["title"] or n["title"] == title)]
if kind == "chapter" and same:
seq -= 1
stack = [same[0]]
continue
nodes.append(node)
stack.append(node)
pending_title_for = node if not title else None
_fill_pages(nodes)
return nodes
def _fill_pages(nodes: list[dict]) -> None:
"""페이지가 없는 노드는 첫 자식의 페이지를 쓰고, 끝 페이지를 계산."""
by_parent: dict = {}
for n in nodes:
by_parent.setdefault(n["parent_id"], []).append(n)
for n in reversed(nodes):
if n["page"] is None:
kids = [k for k in by_parent.get(n["id"], []) if k["page"] is not None]
if kids:
n["page"] = kids[0]["page"]
# 페이지 역행(목차 파싱 오류)은 직전 값으로 보정
last = 0
for n in nodes:
if n["page"] is None or n["page"] < last - 50:
n["page"] = last
last = max(last, n["page"])