@tienne/gestalt 0.107.0 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/dist/package.json +1 -1
- package/dist/plugin/role-agents/lens-context/AGENT.md +94 -0
- package/dist/plugin/role-agents/lens-link/AGENT.md +83 -0
- package/dist/plugin/role-agents/lens-reader/AGENT.md +230 -0
- package/dist/plugin/skills/_shared/proactive-routing.md +1 -0
- package/dist/plugin/skills/lens/SKILL.md +375 -0
- package/dist/schemas/gestalt.schema.json +13 -0
- package/dist/src/cli/commands/digest.d.ts +2 -0
- package/dist/src/cli/commands/digest.d.ts.map +1 -1
- package/dist/src/cli/commands/digest.js +6 -0
- package/dist/src/cli/commands/digest.js.map +1 -1
- package/dist/src/cli/commands/lens.d.ts +27 -0
- package/dist/src/cli/commands/lens.d.ts.map +1 -0
- package/dist/src/cli/commands/lens.js +200 -0
- package/dist/src/cli/commands/lens.js.map +1 -0
- package/dist/src/cli/commands/peer.d.ts +19 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -0
- package/dist/src/cli/commands/peer.js +147 -0
- package/dist/src/cli/commands/peer.js.map +1 -0
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +72 -2
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/digest/format.d.ts.map +1 -1
- package/dist/src/digest/format.js +1 -0
- package/dist/src/digest/format.js.map +1 -1
- package/dist/src/digest/frames.d.ts.map +1 -1
- package/dist/src/digest/frames.js +1 -1
- package/dist/src/digest/frames.js.map +1 -1
- package/dist/src/digest/ingest.d.ts +8 -1
- package/dist/src/digest/ingest.d.ts.map +1 -1
- package/dist/src/digest/ingest.js +2 -1
- package/dist/src/digest/ingest.js.map +1 -1
- package/dist/src/digest/lens-schema.d.ts +542 -0
- package/dist/src/digest/lens-schema.d.ts.map +1 -0
- package/dist/src/digest/lens-schema.js +108 -0
- package/dist/src/digest/lens-schema.js.map +1 -0
- package/dist/src/digest/lens-source.d.ts +260 -0
- package/dist/src/digest/lens-source.d.ts.map +1 -0
- package/dist/src/digest/lens-source.js +92 -0
- package/dist/src/digest/lens-source.js.map +1 -0
- package/dist/src/digest/lens-widget-schema.d.ts +1225 -0
- package/dist/src/digest/lens-widget-schema.d.ts.map +1 -0
- package/dist/src/digest/lens-widget-schema.js +80 -0
- package/dist/src/digest/lens-widget-schema.js.map +1 -0
- package/dist/src/digest/lens-widgets.d.ts +25 -0
- package/dist/src/digest/lens-widgets.d.ts.map +1 -0
- package/dist/src/digest/lens-widgets.js +225 -0
- package/dist/src/digest/lens-widgets.js.map +1 -0
- package/dist/src/digest/lint-lens-context.d.ts +102 -0
- package/dist/src/digest/lint-lens-context.d.ts.map +1 -0
- package/dist/src/digest/lint-lens-context.js +192 -0
- package/dist/src/digest/lint-lens-context.js.map +1 -0
- package/dist/src/digest/lint-lens.d.ts +17 -0
- package/dist/src/digest/lint-lens.d.ts.map +1 -0
- package/dist/src/digest/lint-lens.js +237 -0
- package/dist/src/digest/lint-lens.js.map +1 -0
- package/dist/src/digest/lint.d.ts +14 -0
- package/dist/src/digest/lint.d.ts.map +1 -1
- package/dist/src/digest/lint.js +156 -2
- package/dist/src/digest/lint.js.map +1 -1
- package/dist/src/digest/merge.d.ts.map +1 -1
- package/dist/src/digest/merge.js +11 -2
- package/dist/src/digest/merge.js.map +1 -1
- package/dist/src/digest/prose.d.ts.map +1 -1
- package/dist/src/digest/prose.js +8 -0
- package/dist/src/digest/prose.js.map +1 -1
- package/dist/src/digest/render-html.d.ts +5 -0
- package/dist/src/digest/render-html.d.ts.map +1 -1
- package/dist/src/digest/render-html.js +93 -18
- package/dist/src/digest/render-html.js.map +1 -1
- package/dist/src/digest/render-lens.d.ts +61 -0
- package/dist/src/digest/render-lens.d.ts.map +1 -0
- package/dist/src/digest/render-lens.js +273 -0
- package/dist/src/digest/render-lens.js.map +1 -0
- package/dist/src/digest/render-md.d.ts +5 -0
- package/dist/src/digest/render-md.d.ts.map +1 -1
- package/dist/src/digest/render-md.js +116 -7
- package/dist/src/digest/render-md.js.map +1 -1
- package/dist/src/digest/render-widgets.d.ts +13 -0
- package/dist/src/digest/render-widgets.d.ts.map +1 -0
- package/dist/src/digest/render-widgets.js +197 -0
- package/dist/src/digest/render-widgets.js.map +1 -0
- package/dist/src/digest/render.d.ts +2 -0
- package/dist/src/digest/render.d.ts.map +1 -1
- package/dist/src/digest/render.js +17 -3
- package/dist/src/digest/render.js.map +1 -1
- package/dist/src/digest/schema.d.ts +14683 -2022
- package/dist/src/digest/schema.d.ts.map +1 -1
- package/dist/src/digest/schema.js +67 -6
- package/dist/src/digest/schema.js.map +1 -1
- package/dist/src/digest/server.d.ts +2 -1
- package/dist/src/digest/server.d.ts.map +1 -1
- package/dist/src/digest/server.js +26 -11
- package/dist/src/digest/server.js.map +1 -1
- package/dist/src/digest/viewer-js.d.ts.map +1 -1
- package/dist/src/digest/viewer-js.js +120 -4
- package/dist/src/digest/viewer-js.js.map +1 -1
- package/dist/src/lens/attach.d.ts +436 -0
- package/dist/src/lens/attach.d.ts.map +1 -0
- package/dist/src/lens/attach.js +46 -0
- package/dist/src/lens/attach.js.map +1 -0
- package/dist/src/lens/ingest.d.ts +75 -0
- package/dist/src/lens/ingest.d.ts.map +1 -0
- package/dist/src/lens/ingest.js +558 -0
- package/dist/src/lens/ingest.js.map +1 -0
- package/dist/src/lens/kind.d.ts +11 -0
- package/dist/src/lens/kind.d.ts.map +1 -0
- package/dist/src/lens/kind.js +59 -0
- package/dist/src/lens/kind.js.map +1 -0
- package/dist/src/lens/knowledge.d.ts +53 -0
- package/dist/src/lens/knowledge.d.ts.map +1 -0
- package/dist/src/lens/knowledge.js +202 -0
- package/dist/src/lens/knowledge.js.map +1 -0
- package/dist/src/lens/notes.d.ts +73 -0
- package/dist/src/lens/notes.d.ts.map +1 -0
- package/dist/src/lens/notes.js +276 -0
- package/dist/src/lens/notes.js.map +1 -0
- package/dist/src/lens/panels.d.ts +10 -0
- package/dist/src/lens/panels.d.ts.map +1 -0
- package/dist/src/lens/panels.js +25 -0
- package/dist/src/lens/panels.js.map +1 -0
- package/dist/src/lens/pdf-extract-py.d.ts +15 -0
- package/dist/src/lens/pdf-extract-py.d.ts.map +1 -0
- package/dist/src/lens/pdf-extract-py.js +414 -0
- package/dist/src/lens/pdf-extract-py.js.map +1 -0
- package/dist/src/lens/podcast.d.ts +36 -0
- package/dist/src/lens/podcast.d.ts.map +1 -0
- package/dist/src/lens/podcast.js +104 -0
- package/dist/src/lens/podcast.js.map +1 -0
- package/dist/src/lens/python.d.ts +33 -0
- package/dist/src/lens/python.d.ts.map +1 -0
- package/dist/src/lens/python.js +61 -0
- package/dist/src/lens/python.js.map +1 -0
- package/dist/src/lens/web-extract-py.d.ts +9 -0
- package/dist/src/lens/web-extract-py.d.ts.map +1 -0
- package/dist/src/lens/web-extract-py.js +114 -0
- package/dist/src/lens/web-extract-py.js.map +1 -0
- package/dist/src/mcp/schemas.d.ts +3 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +4 -0
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/digest-passthrough.d.ts.map +1 -1
- package/dist/src/mcp/tools/digest-passthrough.js +5 -0
- package/dist/src/mcp/tools/digest-passthrough.js.map +1 -1
- package/dist/src/peer/client.d.ts +9 -0
- package/dist/src/peer/client.d.ts.map +1 -0
- package/dist/src/peer/client.js +51 -0
- package/dist/src/peer/client.js.map +1 -0
- package/dist/src/peer/daemon.d.ts +71 -0
- package/dist/src/peer/daemon.d.ts.map +1 -0
- package/dist/src/peer/daemon.js +407 -0
- package/dist/src/peer/daemon.js.map +1 -0
- package/dist/src/peer/errors.d.ts +2 -0
- package/dist/src/peer/errors.d.ts.map +1 -0
- package/dist/src/peer/errors.js +4 -0
- package/dist/src/peer/errors.js.map +1 -0
- package/dist/src/peer/index.d.ts +14 -0
- package/dist/src/peer/index.d.ts.map +1 -0
- package/dist/src/peer/index.js +9 -0
- package/dist/src/peer/index.js.map +1 -0
- package/dist/src/peer/locate.d.ts +44 -0
- package/dist/src/peer/locate.d.ts.map +1 -0
- package/dist/src/peer/locate.js +116 -0
- package/dist/src/peer/locate.js.map +1 -0
- package/dist/src/peer/paths.d.ts +23 -0
- package/dist/src/peer/paths.d.ts.map +1 -0
- package/dist/src/peer/paths.js +50 -0
- package/dist/src/peer/paths.js.map +1 -0
- package/dist/src/peer/protocol.d.ts +61 -0
- package/dist/src/peer/protocol.d.ts.map +1 -0
- package/dist/src/peer/protocol.js +7 -0
- package/dist/src/peer/protocol.js.map +1 -0
- package/dist/src/peer/redact.d.ts +30 -0
- package/dist/src/peer/redact.d.ts.map +1 -0
- package/dist/src/peer/redact.js +214 -0
- package/dist/src/peer/redact.js.map +1 -0
- package/dist/src/peer/share.d.ts +123 -0
- package/dist/src/peer/share.d.ts.map +1 -0
- package/dist/src/peer/share.js +204 -0
- package/dist/src/peer/share.js.map +1 -0
- package/dist/src/peer/state.d.ts +37 -0
- package/dist/src/peer/state.d.ts.map +1 -0
- package/dist/src/peer/state.js +99 -0
- package/dist/src/peer/state.js.map +1 -0
- package/dist/src/peer/tail.d.ts +55 -0
- package/dist/src/peer/tail.d.ts.map +1 -0
- package/dist/src/peer/tail.js +148 -0
- package/dist/src/peer/tail.js.map +1 -0
- package/dist/src/peer/transform.d.ts +79 -0
- package/dist/src/peer/transform.d.ts.map +1 -0
- package/dist/src/peer/transform.js +237 -0
- package/dist/src/peer/transform.js.map +1 -0
- package/dist/src/peer/viewer-html.d.ts +10 -0
- package/dist/src/peer/viewer-html.d.ts.map +1 -0
- package/dist/src/peer/viewer-html.js +157 -0
- package/dist/src/peer/viewer-html.js.map +1 -0
- package/dist/src/peer/viewer.d.ts +58 -0
- package/dist/src/peer/viewer.d.ts.map +1 -0
- package/dist/src/peer/viewer.js +249 -0
- package/dist/src/peer/viewer.js.map +1 -0
- package/dist/src/research/source-create.d.ts +6 -1
- package/dist/src/research/source-create.d.ts.map +1 -1
- package/dist/src/research/source-create.js +41 -10
- package/dist/src/research/source-create.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/role-agents/lens-context/AGENT.md +94 -0
- package/plugin/role-agents/lens-link/AGENT.md +83 -0
- package/plugin/role-agents/lens-reader/AGENT.md +230 -0
- package/plugin/skills/_shared/proactive-routing.md +1 -0
- package/plugin/skills/lens/SKILL.md +375 -0
- package/schemas/gestalt.schema.json +13 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF에서 문단과 표, 그림을 좌표와 함께 뽑는 파이썬 스크립트. pdfplumber를 쓴다(pypdfium2로 쪽 그림을 그린다).
|
|
3
|
+
*
|
|
4
|
+
* - 문단: 단을 나눠 읽고 줄 간격과 들여쓰기로 끊는다. 단이나 쪽을 넘는 문단은 사각형을 여럿 단다.
|
|
5
|
+
* 아래첨자 줄은 버리고 수식 조각은 앞 문단에 붙인다.
|
|
6
|
+
* - 표: "Table N:" 캡션과 같은 단에서 가장 가까운 가로줄 묶음(booktabs)을 표 자리로 잡는다.
|
|
7
|
+
* 모든 줄에서 비어 있는 세로 띠를 칸 경계로 본다. 칸이 하나뿐이거나 빈칸이 30%를 넘으면 grid를 loose로 적는다.
|
|
8
|
+
* - 그림: "Figure N:" 캡션 위나 아래의 비트맵을 묶어 자르고 벡터 그림은 캡션 위 빈자리를 자른다.
|
|
9
|
+
*
|
|
10
|
+
* 결과 JSON은 stdout 마지막 줄 하나로 낸다. 쪽 그림과 크롭은 두 번째 인자 폴더의 pages/, figures/, tables/에 쓴다.
|
|
11
|
+
* 세 번째 인자는 쪽 수 상한이다. 넘으면 stderr에 too-many-pages를 남기고 멈춘다.
|
|
12
|
+
* 백틱과 달러 중괄호가 없어야 String.raw 안에 그대로 실린다
|
|
13
|
+
*/
|
|
14
|
+
export const PDF_EXTRACT_PY = String.raw `import json, os, re, statistics, sys
|
|
15
|
+
|
|
16
|
+
# 신뢰할 수 없는 PDF가 메모리를 다 먹지 않게 주소 공간을 4GB로 묶는다. macOS는 RLIMIT_AS를 거부해서 Linux에서만 건다
|
|
17
|
+
if sys.platform.startswith("linux"):
|
|
18
|
+
try:
|
|
19
|
+
import resource
|
|
20
|
+
_, hard = resource.getrlimit(resource.RLIMIT_AS)
|
|
21
|
+
if hard == resource.RLIM_INFINITY or hard > (4 << 30):
|
|
22
|
+
resource.setrlimit(resource.RLIMIT_AS, (4 << 30, hard))
|
|
23
|
+
except (ImportError, OSError, ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
import pdfplumber
|
|
27
|
+
|
|
28
|
+
pdf_path, out_dir = sys.argv[1], sys.argv[2]
|
|
29
|
+
MAX_PAGES = int(sys.argv[3]) if len(sys.argv) > 3 else 300
|
|
30
|
+
PAGE_SCALE = 2.0
|
|
31
|
+
CROP_SCALE = 3.0
|
|
32
|
+
CAPTION = re.compile(r"^(Table|Figure|Fig\.)\s*(\d+)\s*[:.]", re.I)
|
|
33
|
+
NUM = re.compile(r"^[-+]?\d")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cluster_lines(words, tol=2.0, gap=15.0):
|
|
37
|
+
words = sorted(words, key=lambda w: (round(w["top"] / tol), w["x0"]))
|
|
38
|
+
rows = []
|
|
39
|
+
for w in sorted(words, key=lambda w: w["top"]):
|
|
40
|
+
for r in rows:
|
|
41
|
+
if abs(r["top"] - w["top"]) <= tol:
|
|
42
|
+
r["words"].append(w)
|
|
43
|
+
break
|
|
44
|
+
else:
|
|
45
|
+
rows.append({"top": w["top"], "words": [w]})
|
|
46
|
+
segs = []
|
|
47
|
+
for r in rows:
|
|
48
|
+
ws = sorted(r["words"], key=lambda w: w["x0"])
|
|
49
|
+
cur = [ws[0]]
|
|
50
|
+
for w in ws[1:]:
|
|
51
|
+
if w["x0"] - cur[-1]["x1"] > gap:
|
|
52
|
+
segs.append(cur)
|
|
53
|
+
cur = [w]
|
|
54
|
+
else:
|
|
55
|
+
cur.append(w)
|
|
56
|
+
segs.append(cur)
|
|
57
|
+
out = []
|
|
58
|
+
for ws in segs:
|
|
59
|
+
out.append(
|
|
60
|
+
{
|
|
61
|
+
"x0": min(w["x0"] for w in ws),
|
|
62
|
+
"x1": max(w["x1"] for w in ws),
|
|
63
|
+
"top": min(w["top"] for w in ws),
|
|
64
|
+
"bottom": max(w["bottom"] for w in ws),
|
|
65
|
+
"size": statistics.median(w["size"] for w in ws),
|
|
66
|
+
"bold": sum(1 for w in ws if re.search(r"Bold|Medi|Semibold", w["fontname"])) > len(ws) / 2,
|
|
67
|
+
"text": " ".join(w["text"] for w in ws),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def inside(line, box, pad=2):
|
|
74
|
+
x0, top, x1, bottom = box
|
|
75
|
+
return line["x0"] >= x0 - pad and line["x1"] <= x1 + pad and line["top"] >= top - pad and line["bottom"] <= bottom + pad
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def overlap_x(a0, a1, b0, b1):
|
|
79
|
+
return max(0.0, min(a1, b1) - max(a0, b0))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def table_regions(page, captions):
|
|
83
|
+
"""Booktabs 표는 가로줄 묶음으로 자리를 잡는다. 캡션과 같은 단에서 가장 가까운 묶음이다"""
|
|
84
|
+
rules = sorted(
|
|
85
|
+
[l for l in page.lines if abs(l["top"] - l["bottom"]) < 1.5 and l["x1"] - l["x0"] > 40],
|
|
86
|
+
key=lambda l: l["top"],
|
|
87
|
+
)
|
|
88
|
+
groups = []
|
|
89
|
+
for l in rules:
|
|
90
|
+
for g in groups:
|
|
91
|
+
if overlap_x(g["x0"], g["x1"], l["x0"], l["x1"]) > 0.8 * (l["x1"] - l["x0"]) and l["top"] - g["bottom"] < 90:
|
|
92
|
+
g["bottom"] = max(g["bottom"], l["top"])
|
|
93
|
+
g["n"] += 1
|
|
94
|
+
g["ys"].append((l["top"], l["x1"] - l["x0"]))
|
|
95
|
+
break
|
|
96
|
+
else:
|
|
97
|
+
groups.append({"x0": l["x0"], "x1": l["x1"], "top": l["top"], "bottom": l["top"], "n": 1, "ys": [(l["top"], l["x1"] - l["x0"])]})
|
|
98
|
+
groups = [g for g in groups if g["n"] >= 2]
|
|
99
|
+
found = []
|
|
100
|
+
used = set()
|
|
101
|
+
for cap in captions:
|
|
102
|
+
best, best_d = None, 1e9
|
|
103
|
+
for gi, g in enumerate(groups):
|
|
104
|
+
if gi in used or overlap_x(g["x0"], g["x1"], cap["x0"], cap["x1"]) <= 0:
|
|
105
|
+
continue
|
|
106
|
+
d = g["top"] - cap["bottom"] if g["top"] >= cap["top"] else cap["top"] - g["bottom"]
|
|
107
|
+
if 0 <= d < best_d and d < 120:
|
|
108
|
+
best, best_d = gi, d
|
|
109
|
+
if best is not None:
|
|
110
|
+
used.add(best)
|
|
111
|
+
g = groups[best]
|
|
112
|
+
# 머리줄 경계는 표 폭 전체를 긋는 줄만 본다. 여러 열을 묶는 짧은 줄(cmidrule)은 머리줄 안쪽이다
|
|
113
|
+
full = sorted(y for y, w in g["ys"] if w >= 0.9 * (g["x1"] - g["x0"]))
|
|
114
|
+
found.append((cap, (g["x0"] - 2, g["top"] - 1, g["x1"] + 2, g["bottom"] + 1), full))
|
|
115
|
+
return found
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_table(page, box, ys):
|
|
119
|
+
words = page.crop(box).extract_words(x_tolerance=1.5, extra_attrs=["size", "fontname"])
|
|
120
|
+
if not words:
|
|
121
|
+
return None
|
|
122
|
+
rows = []
|
|
123
|
+
for w in sorted(words, key=lambda w: (w["top"] + w["bottom"]) / 2):
|
|
124
|
+
mid = (w["top"] + w["bottom"]) / 2
|
|
125
|
+
for r in rows:
|
|
126
|
+
if abs(r["mid"] - mid) <= 3.5:
|
|
127
|
+
r["words"].append(w)
|
|
128
|
+
break
|
|
129
|
+
else:
|
|
130
|
+
rows.append({"mid": mid, "words": [w]})
|
|
131
|
+
rows.sort(key=lambda r: r["mid"])
|
|
132
|
+
# booktabs는 윗줄과 가운데 줄 사이가 머리줄이다. 머리줄이 여러 줄이면 칸끼리 이어 한 줄로 합친다
|
|
133
|
+
head_n = sum(1 for r in rows if r["mid"] < ys[1]) if len(ys) >= 3 else 0
|
|
134
|
+
body_words = [w for r in rows[head_n:] for w in r["words"]] or words
|
|
135
|
+
# 본문 줄에서 비어 있는 세로 띠가 칸 경계다. 여러 칸에 걸친 머리줄 글자가 경계를 지우지 않게 머리줄은 빼고 본다
|
|
136
|
+
x0 = min(w["x0"] for w in body_words)
|
|
137
|
+
x1 = max(w["x1"] for w in body_words)
|
|
138
|
+
step = 0.5
|
|
139
|
+
n = int((x1 - x0) / step) + 1
|
|
140
|
+
covered = [False] * n
|
|
141
|
+
for w in body_words:
|
|
142
|
+
for k in range(int((w["x0"] - x0) / step), min(n, int((w["x1"] - x0) / step) + 1)):
|
|
143
|
+
covered[k] = True
|
|
144
|
+
bounds = []
|
|
145
|
+
k = 0
|
|
146
|
+
while k < n:
|
|
147
|
+
if not covered[k]:
|
|
148
|
+
j = k
|
|
149
|
+
while j < n and not covered[j]:
|
|
150
|
+
j += 1
|
|
151
|
+
if (j - k) * step >= 4.0:
|
|
152
|
+
bounds.append(x0 + (k + j) / 2 * step)
|
|
153
|
+
k = j
|
|
154
|
+
else:
|
|
155
|
+
k += 1
|
|
156
|
+
# 머리줄 글자가 본문 폭 밖으로 나가도 양 끝 칸에 들어가게 바깥 경계는 열어 둔다
|
|
157
|
+
edges = [float("-inf")] + bounds + [float("inf")]
|
|
158
|
+
# 본문 칸마다 글자가 실제로 차지한 폭. 머리줄 낱말이 어느 칸 글자 위에 있는지 볼 때 쓴다
|
|
159
|
+
extents = {}
|
|
160
|
+
for w in body_words:
|
|
161
|
+
cx = (w["x0"] + w["x1"]) / 2
|
|
162
|
+
c = next(c for c in range(len(edges) - 1) if edges[c] <= cx < edges[c + 1])
|
|
163
|
+
lo, hi = extents.get(c, (w["x0"], w["x1"]))
|
|
164
|
+
extents[c] = (min(lo, w["x0"]), max(hi, w["x1"]))
|
|
165
|
+
grid = []
|
|
166
|
+
for ri, r in enumerate(rows):
|
|
167
|
+
cells = [[] for _ in range(len(edges) - 1)]
|
|
168
|
+
ws = sorted(r["words"], key=lambda w: w["x0"])
|
|
169
|
+
# 머리줄 낱말이 칸 경계에 걸치면 본문 칸의 실제 폭과 겹치는 칸으로 보낸다. 두 칸 사이 빈 띠 안이면
|
|
170
|
+
# 더 가까운 옆 낱말의 칸을 따른다("Train Size"의 Size). 머리 글자가 본문 칸보다 넓어도 제 칸에 남는다
|
|
171
|
+
def anchor(k):
|
|
172
|
+
w = ws[k]
|
|
173
|
+
mid = (w["x0"] + w["x1"]) / 2
|
|
174
|
+
if ri >= head_n or not any(w["x0"] < b < w["x1"] for b in bounds):
|
|
175
|
+
return mid
|
|
176
|
+
hits = sorted(c for c, (a, b) in extents.items() if min(w["x1"], b) - max(w["x0"], a) > 0)
|
|
177
|
+
# 여러 칸에 걸친 머리 글자("BLEU")는 묶는 칸들 가운데 첫 칸에 둔다
|
|
178
|
+
if hits:
|
|
179
|
+
lo, hi = extents[hits[0]]
|
|
180
|
+
return (max(lo, w["x0"]) + min(hi, w["x1"])) / 2
|
|
181
|
+
left = ws[k - 1] if k > 0 else None
|
|
182
|
+
right = ws[k + 1] if k + 1 < len(ws) else None
|
|
183
|
+
gl = w["x0"] - left["x1"] if left else float("inf")
|
|
184
|
+
gr = right["x0"] - w["x1"] if right else float("inf")
|
|
185
|
+
near = left if gl <= gr else right
|
|
186
|
+
return (near["x0"] + near["x1"]) / 2 if near else mid
|
|
187
|
+
placed = [(anchor(k), w["text"]) for k, w in enumerate(ws)]
|
|
188
|
+
for cx, text in placed:
|
|
189
|
+
for c in range(len(edges) - 1):
|
|
190
|
+
if edges[c] <= cx < edges[c + 1]:
|
|
191
|
+
cells[c].append(text)
|
|
192
|
+
break
|
|
193
|
+
grid.append([re.sub(r"(?<=\d) ?\. ?(?=\d)", ".", " ".join(c)) for c in cells])
|
|
194
|
+
if head_n > 1:
|
|
195
|
+
head = [" ".join(grid[r][c] for r in range(head_n) if grid[r][c]) for c in range(len(edges) - 1)]
|
|
196
|
+
grid = [head] + grid[head_n:]
|
|
197
|
+
return grid
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def figure_region(page, cap, text_lines):
|
|
201
|
+
imgs = [im for im in page.images if overlap_x(im["x0"], im["x1"], cap["x0"], cap["x1"]) > 0 or cap["x1"] - cap["x0"] > page.width * 0.6]
|
|
202
|
+
above = [im for im in imgs if im["bottom"] <= cap["top"] + 4 and cap["top"] - im["bottom"] < 80]
|
|
203
|
+
if above:
|
|
204
|
+
near = max(im["bottom"] for im in above)
|
|
205
|
+
group = [im for im in above if near - im["bottom"] < 20]
|
|
206
|
+
return (min(i["x0"] for i in group), min(i["top"] for i in group), max(i["x1"] for i in group), max(i["bottom"] for i in group))
|
|
207
|
+
below = [im for im in imgs if im["top"] >= cap["bottom"] - 4 and im["top"] - cap["bottom"] < 80]
|
|
208
|
+
if below:
|
|
209
|
+
near = min(im["top"] for im in below)
|
|
210
|
+
group = [im for im in below if im["top"] - near < 20]
|
|
211
|
+
return (min(i["x0"] for i in group), min(i["top"] for i in group), max(i["x1"] for i in group), max(i["bottom"] for i in group))
|
|
212
|
+
# 벡터 그림은 캡션 위로 같은 단의 본문이 끝나는 자리까지를 잡는다
|
|
213
|
+
col = [l for l in text_lines if overlap_x(l["x0"], l["x1"], cap["x0"], cap["x1"]) > 0 and l["bottom"] < cap["top"] - 2]
|
|
214
|
+
top = max([l["bottom"] for l in col if cap["top"] - l["bottom"] > 30] or [page.bbox[1] + 50])
|
|
215
|
+
if cap["top"] - top < 30:
|
|
216
|
+
return None
|
|
217
|
+
return (cap["x0"], top + 2, cap["x1"], cap["top"] - 2)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
ENDS = re.compile(r"[.?!:]['\")\]]?$")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def join_text(lines):
|
|
224
|
+
text = ""
|
|
225
|
+
for l in lines:
|
|
226
|
+
t = l["text"].strip()
|
|
227
|
+
if not text:
|
|
228
|
+
text = t
|
|
229
|
+
elif re.search(r"[A-Za-z]-$", text) and re.match(r"^[a-z]", t):
|
|
230
|
+
text = text[:-1] + t
|
|
231
|
+
else:
|
|
232
|
+
text += " " + t
|
|
233
|
+
return text
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
os.makedirs(os.path.join(out_dir, "pages"), exist_ok=True)
|
|
238
|
+
os.makedirs(os.path.join(out_dir, "figures"), exist_ok=True)
|
|
239
|
+
os.makedirs(os.path.join(out_dir, "tables"), exist_ok=True)
|
|
240
|
+
pdf = pdfplumber.open(pdf_path)
|
|
241
|
+
# 쪽을 돌기 전에 멈춘다. ingest가 이 꼴을 stderr에서 읽어 사람이 읽을 오류로 바꾼다
|
|
242
|
+
if len(pdf.pages) > MAX_PAGES:
|
|
243
|
+
sys.stderr.write("too-many-pages %d %d\n" % (len(pdf.pages), MAX_PAGES))
|
|
244
|
+
sys.exit(3)
|
|
245
|
+
pages, units, tables, figures = [], [], [], []
|
|
246
|
+
body_sizes = []
|
|
247
|
+
page_lines = []
|
|
248
|
+
for page in pdf.pages:
|
|
249
|
+
words = page.extract_words(x_tolerance=1.5, extra_attrs=["size", "fontname"])
|
|
250
|
+
lines = cluster_lines(words)
|
|
251
|
+
page_lines.append(lines)
|
|
252
|
+
body_sizes += [round(l["size"], 1) for l in lines]
|
|
253
|
+
body = statistics.mode(body_sizes) if body_sizes else 10.0
|
|
254
|
+
|
|
255
|
+
pending = None # 단이나 쪽을 넘어 이어지는 문단
|
|
256
|
+
for pi, page in enumerate(pdf.pages):
|
|
257
|
+
pno = pi + 1
|
|
258
|
+
W, H = page.width, page.height
|
|
259
|
+
mid = W / 2
|
|
260
|
+
lines = [l for l in page_lines[pi] if not (l["top"] > H - 70 and re.fullmatch(r"\d{1,4}", l["text"].strip()))]
|
|
261
|
+
captions = [l for l in lines if CAPTION.match(l["text"])]
|
|
262
|
+
# 캡션은 이어지는 줄까지 붙인다
|
|
263
|
+
cap_blocks = []
|
|
264
|
+
for c in captions:
|
|
265
|
+
block = [c]
|
|
266
|
+
for l in sorted(lines, key=lambda l: l["top"]):
|
|
267
|
+
if l is c or l["top"] <= block[-1]["top"]:
|
|
268
|
+
continue
|
|
269
|
+
if overlap_x(l["x0"], l["x1"], c["x0"], c["x1"]) > 0 and l["top"] - block[-1]["bottom"] < 4 and abs(l["size"] - c["size"]) < 0.3 and not CAPTION.match(l["text"]):
|
|
270
|
+
if abs(l["size"] - body) < 0.3:
|
|
271
|
+
break
|
|
272
|
+
block.append(l)
|
|
273
|
+
cap_blocks.append(block)
|
|
274
|
+
regions = []
|
|
275
|
+
# 캡션 단위는 본문 뒤에 붙인다. 앞 쪽에서 넘어온 문단이 먼저 이어져야 읽는 순서가 맞다
|
|
276
|
+
page_units = []
|
|
277
|
+
tcaps = [b for b in cap_blocks if b[0]["text"].lower().startswith("table")]
|
|
278
|
+
for cap, box, ys in table_regions(page, [{**b[0], "bottom": b[-1]["bottom"]} for b in tcaps]):
|
|
279
|
+
m = CAPTION.match(cap["text"])
|
|
280
|
+
block = next(b for b in tcaps if b[0]["top"] == cap["top"] and b[0]["x0"] == cap["x0"])
|
|
281
|
+
grid = build_table(page, box, ys)
|
|
282
|
+
if not grid:
|
|
283
|
+
continue
|
|
284
|
+
tid = f"table-{m.group(2)}"
|
|
285
|
+
if any(t["id"] == tid for t in tables):
|
|
286
|
+
continue
|
|
287
|
+
cells = [c for r in grid for c in r]
|
|
288
|
+
# 칸이 하나뿐이거나 빈칸이 많으면 칸 경계를 잘못 읽은 표다. 뷰어는 원문 크롭을 앞에 둔다
|
|
289
|
+
loose = len(grid[0]) < 2 or sum(1 for c in cells if not c) > len(cells) * 0.3
|
|
290
|
+
name = f"tables/{tid}.png"
|
|
291
|
+
page.crop(box).to_image(resolution=72 * CROP_SCALE).original.save(os.path.join(out_dir, name))
|
|
292
|
+
tables.append({"id": tid, "number": m.group(2), "caption": join_text(block), "page": pno, "bbox": [round(v, 1) for v in box], "rows": grid, "grid": "loose" if loose else "ok", "image": name})
|
|
293
|
+
page_units.append({"page": pno, "role": "caption", "ref": tid, "text": join_text(block), "rects": [{"page": pno, "bbox": [round(min(l["x0"] for l in block), 1), round(block[0]["top"], 1), round(max(l["x1"] for l in block), 1), round(block[-1]["bottom"], 1)]}, {"page": pno, "bbox": [round(v, 1) for v in box]}]})
|
|
294
|
+
regions.append(box)
|
|
295
|
+
regions.append((min(l["x0"] for l in block), block[0]["top"], max(l["x1"] for l in block), block[-1]["bottom"]))
|
|
296
|
+
for block in cap_blocks:
|
|
297
|
+
m = CAPTION.match(block[0]["text"])
|
|
298
|
+
if not m.group(1).lower().startswith("fig"):
|
|
299
|
+
continue
|
|
300
|
+
fid = f"figure-{m.group(2)}"
|
|
301
|
+
if any(f["id"] == fid for f in figures):
|
|
302
|
+
continue
|
|
303
|
+
cap = {**block[0], "x1": max(l["x1"] for l in block), "bottom": block[-1]["bottom"]}
|
|
304
|
+
box = figure_region(page, cap, [l for l in lines if l not in block])
|
|
305
|
+
capbox = (min(l["x0"] for l in block), block[0]["top"], max(l["x1"] for l in block), block[-1]["bottom"])
|
|
306
|
+
regions.append(capbox)
|
|
307
|
+
fig = {"id": fid, "number": m.group(2), "caption": join_text(block), "page": pno}
|
|
308
|
+
rects = [{"page": pno, "bbox": [round(v, 1) for v in capbox]}]
|
|
309
|
+
if box:
|
|
310
|
+
regions.append(box)
|
|
311
|
+
fig["bbox"] = [round(v, 1) for v in box]
|
|
312
|
+
rects.append({"page": pno, "bbox": fig["bbox"]})
|
|
313
|
+
img = page.crop(box).to_image(resolution=72 * CROP_SCALE).original
|
|
314
|
+
name = f"figures/{fid}.png"
|
|
315
|
+
img.save(os.path.join(out_dir, name))
|
|
316
|
+
fig["image"] = name
|
|
317
|
+
figures.append(fig)
|
|
318
|
+
page_units.append({"page": pno, "role": "caption", "ref": fid, "text": join_text(block), "rects": rects})
|
|
319
|
+
# 쪽 그림
|
|
320
|
+
pimg = page.to_image(resolution=72 * PAGE_SCALE).original.convert("RGB")
|
|
321
|
+
pname = f"pages/p{pno}.jpg"
|
|
322
|
+
pimg.save(os.path.join(out_dir, pname), quality=80)
|
|
323
|
+
pages.append({"n": pno, "width": round(W, 1), "height": round(H, 1), "image": pname})
|
|
324
|
+
|
|
325
|
+
body_lines = [l for l in lines if not any(inside(l, r) for r in regions)]
|
|
326
|
+
crossing = sum(1 for l in body_lines if l["x0"] < mid - 8 and l["x1"] > mid + 8)
|
|
327
|
+
two_col = crossing < len(body_lines) * 0.35
|
|
328
|
+
def col(l):
|
|
329
|
+
if not two_col:
|
|
330
|
+
return 0
|
|
331
|
+
if l["x1"] <= mid + 8:
|
|
332
|
+
return 1
|
|
333
|
+
if l["x0"] >= mid - 8:
|
|
334
|
+
return 2
|
|
335
|
+
return 0
|
|
336
|
+
first_col_top = min([l["top"] for l in body_lines if col(l) > 0] or [H])
|
|
337
|
+
head = sorted([l for l in body_lines if col(l) == 0 and l["top"] < first_col_top], key=lambda l: l["top"])
|
|
338
|
+
tail = sorted([l for l in body_lines if col(l) == 0 and l["top"] >= first_col_top], key=lambda l: l["top"])
|
|
339
|
+
streams = [head] + [sorted([l for l in body_lines if col(l) == c], key=lambda l: l["top"]) for c in (1, 2)] + [tail]
|
|
340
|
+
for stream in streams:
|
|
341
|
+
# 아래첨자나 위첨자 줄은 글자만 흩어 놓아서 문단을 끊지 않게 미리 버린다
|
|
342
|
+
stream = [l for l in stream if not (l["size"] < body - 1.2 and len(l["text"]) < 15)]
|
|
343
|
+
if not stream:
|
|
344
|
+
continue
|
|
345
|
+
gaps = [b["top"] - a["top"] for a, b in zip(stream, stream[1:]) if 8 < b["top"] - a["top"] < 30]
|
|
346
|
+
step = statistics.median(gaps) if gaps else 13.0
|
|
347
|
+
left = statistics.median(l["x0"] for l in stream)
|
|
348
|
+
cur = []
|
|
349
|
+
for l in stream:
|
|
350
|
+
if not cur:
|
|
351
|
+
# 앞 단이나 앞 쪽에서 끝나지 않은 문단을 잇는다
|
|
352
|
+
at = next((k for k in range(len(units) - 1, -1, -1) if units[k] is pending), None)
|
|
353
|
+
if at is not None and abs(l["x0"] - left) < 3 and abs(l["size"] - body) < 0.6:
|
|
354
|
+
cur = [l]
|
|
355
|
+
# 이어 붙이기는 문단을 닫을 때 한다. 사이에 캡션이 끼어 있어도 원래 자리에 붙인다
|
|
356
|
+
l["_cont"] = at
|
|
357
|
+
pending = None
|
|
358
|
+
continue
|
|
359
|
+
cur = [l]
|
|
360
|
+
continue
|
|
361
|
+
prev = cur[-1]
|
|
362
|
+
new_para = (
|
|
363
|
+
l["top"] - prev["top"] > step * 1.4
|
|
364
|
+
or (l["x0"] - left > 4 and ENDS.search(prev["text"].strip()) is not None)
|
|
365
|
+
or (abs(l["size"] - prev["size"]) > 0.6 and min(len(l["text"]), len(prev["text"])) >= 15)
|
|
366
|
+
or (prev["size"] > body + 0.5)
|
|
367
|
+
)
|
|
368
|
+
if new_para:
|
|
369
|
+
close(cur, pno, units, body)
|
|
370
|
+
cur = [l]
|
|
371
|
+
else:
|
|
372
|
+
cur.append(l)
|
|
373
|
+
if cur:
|
|
374
|
+
last = close(cur, pno, units, body)
|
|
375
|
+
pending = last if last["role"] == "para" and not ENDS.search(last["text"]) else None
|
|
376
|
+
units.extend(page_units)
|
|
377
|
+
# 수식 조각은 앞 문단에 붙인다. 출처 단위가 문단이라 따로 두면 번호만 늘어난다
|
|
378
|
+
merged = []
|
|
379
|
+
for u in units:
|
|
380
|
+
letters = sum(1 for ch in u["text"] if ch.isalpha())
|
|
381
|
+
frag = u["role"] == "para" and (len(u["text"]) < 30 or letters < len(u["text"]) * 0.55)
|
|
382
|
+
prev = merged[-1] if merged else None
|
|
383
|
+
if frag and prev is not None and prev["role"] == "para" and prev["page"] == u["page"]:
|
|
384
|
+
prev["text"] = prev["text"] + " " + u["text"]
|
|
385
|
+
prev["rects"] = prev["rects"] + u["rects"]
|
|
386
|
+
continue
|
|
387
|
+
merged.append(u)
|
|
388
|
+
units[:] = merged
|
|
389
|
+
for i, u in enumerate(units):
|
|
390
|
+
u["i"] = i
|
|
391
|
+
for t in tables:
|
|
392
|
+
t["unit"] = next(u["i"] for u in units if u.get("ref") == t["id"])
|
|
393
|
+
for f in figures:
|
|
394
|
+
f["unit"] = next(u["i"] for u in units if u.get("ref") == f["id"])
|
|
395
|
+
meta = pdf.metadata or {}
|
|
396
|
+
print(json.dumps({"title": meta.get("Title") or "", "pageCount": len(pdf.pages), "pages": pages, "units": units, "tables": tables, "figures": figures}))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def close(cur, pno, units, body):
|
|
400
|
+
at = cur[0].pop("_cont", None)
|
|
401
|
+
text = join_text(cur)
|
|
402
|
+
rect = {"page": pno, "bbox": [round(min(l["x0"] for l in cur), 1), round(cur[0]["top"], 1), round(max(l["x1"] for l in cur), 1), round(cur[-1]["bottom"], 1)]}
|
|
403
|
+
if at is not None:
|
|
404
|
+
cont = units[at]
|
|
405
|
+
units[at] = {**cont, "text": join_text([{"text": cont["text"]}, {"text": text}]), "rects": cont["rects"] + [rect]}
|
|
406
|
+
return units[at]
|
|
407
|
+
heading = len(cur) == 1 and (cur[0]["bold"] or cur[0]["size"] > body + 0.5) and len(text) < 90 and not ENDS.search(text)
|
|
408
|
+
units.append({"page": pno, "role": "heading" if heading else "para", "text": text, "rects": [rect]})
|
|
409
|
+
return units[-1]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
main()
|
|
413
|
+
`;
|
|
414
|
+
//# sourceMappingURL=pdf-extract-py.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pdf-extract-py.js","sourceRoot":"","sources":["../../../src/lens/pdf-extract-py.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+YvC,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** 피드에서 고른 에피소드 */
|
|
2
|
+
export interface FeedEpisode {
|
|
3
|
+
show: string;
|
|
4
|
+
title: string;
|
|
5
|
+
audioUrl: string;
|
|
6
|
+
pubDate?: string;
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* RSS 피드에서 n번째(0부터, 최신순) 에피소드의 오디오 주소를 꺼낸다. XML 파서를 들이지 않으려고
|
|
11
|
+
* item과 enclosure 태그만 정규식으로 읽는다. 주소는 http(s)만 받는다
|
|
12
|
+
*/
|
|
13
|
+
export declare function pickEpisode(xml: string, index?: number): FeedEpisode;
|
|
14
|
+
/** 오디오 확장자. 주소에 없으면 mp3로 둔다 */
|
|
15
|
+
export declare function audioExt(url: string): string;
|
|
16
|
+
/** 받아쓰기 스크립트. 진행 로그가 섞일 수 있어 결과는 마지막 줄 한 줄로 낸다 */
|
|
17
|
+
export declare const PODCAST_WHISPER_PY = "\nimport json, sys\nfrom faster_whisper import WhisperModel\naudio, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3] or None\nmodel = WhisperModel(model_name, device=\"auto\", compute_type=\"auto\")\nsegments, info = model.transcribe(audio, language=lang, vad_filter=True)\nout = [{\"start\": s.start, \"end\": s.end, \"text\": s.text.strip()} for s in segments if s.text.strip()]\nsys.stdout.write(\"\\n\" + json.dumps({\"language\": info.language, \"duration\": info.duration, \"segments\": out}) + \"\\n\")\n";
|
|
18
|
+
/**
|
|
19
|
+
* 화자 구분 스크립트. sherpa-onnx의 오프라인 화자 구분을 쓴다. pyannote 구간 분할 모델을 ONNX로 옮긴 것과
|
|
20
|
+
* 화자 임베딩 모델을 쓰고 둘 다 Hugging Face 토큰 없이 받는다. 오디오는 faster-whisper의 디코더로 16kHz로 읽는다
|
|
21
|
+
*/
|
|
22
|
+
export declare const DIARIZE_PY = "\nimport json, sys\nimport sherpa_onnx\nfrom faster_whisper import decode_audio\naudio, seg_model, emb_model, threshold = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])\ncfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(\n segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(\n pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg_model)),\n embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb_model),\n clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1, threshold=threshold),\n min_duration_on=0.3, min_duration_off=0.5)\nsd = sherpa_onnx.OfflineSpeakerDiarization(cfg)\nsamples = decode_audio(audio, sampling_rate=sd.sample_rate)\nturns = sd.process(samples).sort_by_start_time()\nsys.stdout.write(\"\\n\" + json.dumps([{\"start\": t.start, \"end\": t.end, \"speaker\": t.speaker} for t in turns]) + \"\\n\")\n";
|
|
23
|
+
export interface SpeakerTurn {
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
speaker: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 받아쓴 문장마다 가장 오래 겹치는 화자 구간의 화자를 단다. 이름은 처음 나온 순서대로 "화자 1", "화자 2"다.
|
|
30
|
+
* 사람 이름은 받아쓰기에서 짐작하지 않는다. 겹치는 구간이 없는 문장은 화자를 비운다
|
|
31
|
+
*/
|
|
32
|
+
export declare function assignSpeakers(segments: Array<{
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
}>, turns: SpeakerTurn[]): Array<string | undefined>;
|
|
36
|
+
//# sourceMappingURL=podcast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"podcast.d.ts","sourceRoot":"","sources":["../../../src/lens/podcast.ts"],"names":[],"mappings":"AAGA,mBAAmB;AACnB,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAiBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,WAAW,CAkB/D;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,4gBAQ9B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,w4BAetB,CAAC;AAEF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,EAC/C,KAAK,EAAE,WAAW,EAAE,GACnB,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,CAgB3B"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { AUDIO_EXTS } from './kind.js';
|
|
3
|
+
const decode = (s) => s
|
|
4
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'|'/g, "'")
|
|
10
|
+
.trim();
|
|
11
|
+
const tag = (xml, name) => {
|
|
12
|
+
const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i').exec(xml);
|
|
13
|
+
return m ? decode(m[1]) : undefined;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* RSS 피드에서 n번째(0부터, 최신순) 에피소드의 오디오 주소를 꺼낸다. XML 파서를 들이지 않으려고
|
|
17
|
+
* item과 enclosure 태그만 정규식으로 읽는다. 주소는 http(s)만 받는다
|
|
18
|
+
*/
|
|
19
|
+
export function pickEpisode(xml, index = 0) {
|
|
20
|
+
const channel = /<channel[^>]*>([\s\S]*?)<item[\s>]/i.exec(xml)?.[1] ?? '';
|
|
21
|
+
const items = [...xml.matchAll(/<item[\s>][\s\S]*?<\/item>/gi)].map((m) => m[0]);
|
|
22
|
+
const item = items[index];
|
|
23
|
+
if (!item)
|
|
24
|
+
throw new Error(`피드에 ${index + 1}번째 에피소드가 없어요(에피소드 ${items.length}개)`);
|
|
25
|
+
const enclosure = /<enclosure\b[^>]*\burl=["']([^"']+)["']/i.exec(item)?.[1];
|
|
26
|
+
if (!enclosure)
|
|
27
|
+
throw new Error('에피소드에 오디오 주소(enclosure)가 없어요');
|
|
28
|
+
const audioUrl = decode(enclosure);
|
|
29
|
+
if (!/^https?:\/\//i.test(audioUrl))
|
|
30
|
+
throw new Error(`오디오 주소가 http(s)가 아니에요: ${audioUrl}`);
|
|
31
|
+
return {
|
|
32
|
+
show: tag(channel, 'title') ?? '',
|
|
33
|
+
title: tag(item, 'title') ?? '',
|
|
34
|
+
audioUrl,
|
|
35
|
+
...(tag(item, 'pubDate') ? { pubDate: tag(item, 'pubDate') } : {}),
|
|
36
|
+
...(tag(item, 'link') ? { link: tag(item, 'link') } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** 오디오 확장자. 주소에 없으면 mp3로 둔다 */
|
|
40
|
+
export function audioExt(url) {
|
|
41
|
+
let path = url;
|
|
42
|
+
try {
|
|
43
|
+
path = new URL(url).pathname;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 로컬 경로다
|
|
47
|
+
}
|
|
48
|
+
const ext = extname(path).toLowerCase();
|
|
49
|
+
return AUDIO_EXTS.includes(ext) ? ext : '.mp3';
|
|
50
|
+
}
|
|
51
|
+
/** 받아쓰기 스크립트. 진행 로그가 섞일 수 있어 결과는 마지막 줄 한 줄로 낸다 */
|
|
52
|
+
export const PODCAST_WHISPER_PY = `
|
|
53
|
+
import json, sys
|
|
54
|
+
from faster_whisper import WhisperModel
|
|
55
|
+
audio, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3] or None
|
|
56
|
+
model = WhisperModel(model_name, device="auto", compute_type="auto")
|
|
57
|
+
segments, info = model.transcribe(audio, language=lang, vad_filter=True)
|
|
58
|
+
out = [{"start": s.start, "end": s.end, "text": s.text.strip()} for s in segments if s.text.strip()]
|
|
59
|
+
sys.stdout.write("\\n" + json.dumps({"language": info.language, "duration": info.duration, "segments": out}) + "\\n")
|
|
60
|
+
`;
|
|
61
|
+
/**
|
|
62
|
+
* 화자 구분 스크립트. sherpa-onnx의 오프라인 화자 구분을 쓴다. pyannote 구간 분할 모델을 ONNX로 옮긴 것과
|
|
63
|
+
* 화자 임베딩 모델을 쓰고 둘 다 Hugging Face 토큰 없이 받는다. 오디오는 faster-whisper의 디코더로 16kHz로 읽는다
|
|
64
|
+
*/
|
|
65
|
+
export const DIARIZE_PY = `
|
|
66
|
+
import json, sys
|
|
67
|
+
import sherpa_onnx
|
|
68
|
+
from faster_whisper import decode_audio
|
|
69
|
+
audio, seg_model, emb_model, threshold = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
|
|
70
|
+
cfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(
|
|
71
|
+
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
|
|
72
|
+
pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg_model)),
|
|
73
|
+
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb_model),
|
|
74
|
+
clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1, threshold=threshold),
|
|
75
|
+
min_duration_on=0.3, min_duration_off=0.5)
|
|
76
|
+
sd = sherpa_onnx.OfflineSpeakerDiarization(cfg)
|
|
77
|
+
samples = decode_audio(audio, sampling_rate=sd.sample_rate)
|
|
78
|
+
turns = sd.process(samples).sort_by_start_time()
|
|
79
|
+
sys.stdout.write("\\n" + json.dumps([{"start": t.start, "end": t.end, "speaker": t.speaker} for t in turns]) + "\\n")
|
|
80
|
+
`;
|
|
81
|
+
/**
|
|
82
|
+
* 받아쓴 문장마다 가장 오래 겹치는 화자 구간의 화자를 단다. 이름은 처음 나온 순서대로 "화자 1", "화자 2"다.
|
|
83
|
+
* 사람 이름은 받아쓰기에서 짐작하지 않는다. 겹치는 구간이 없는 문장은 화자를 비운다
|
|
84
|
+
*/
|
|
85
|
+
export function assignSpeakers(segments, turns) {
|
|
86
|
+
const names = new Map();
|
|
87
|
+
return segments.map((seg) => {
|
|
88
|
+
let best = -1;
|
|
89
|
+
let bestOverlap = 0;
|
|
90
|
+
for (const t of turns) {
|
|
91
|
+
const overlap = Math.min(seg.end, t.end) - Math.max(seg.start, t.start);
|
|
92
|
+
if (overlap > bestOverlap) {
|
|
93
|
+
bestOverlap = overlap;
|
|
94
|
+
best = t.speaker;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (best < 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!names.has(best))
|
|
100
|
+
names.set(best, `화자 ${names.size + 1}`);
|
|
101
|
+
return names.get(best);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=podcast.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"podcast.js","sourceRoot":"","sources":["../../../src/lens/podcast.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** 렌즈 전용 가상환경. 시스템 파이썬의 pip가 오래돼 새 휠을 못 받는 머신에서 여기에 설치한다 */
|
|
2
|
+
export declare const LENS_VENV: string;
|
|
3
|
+
export declare const LENS_PY_INSTALL: string;
|
|
4
|
+
/**
|
|
5
|
+
* 렌즈 추출에 쓸 파이썬. GESTALT_LENS_PYTHON이 있으면 그 값을, 없으면 렌즈 가상환경을, 그것도 없으면 python3를 쓴다.
|
|
6
|
+
* 환경 변수는 절대 경로만 받는다. 상대 경로면 남의 레포 안 같은 이름 파일이 대신 돌 수 있다
|
|
7
|
+
*/
|
|
8
|
+
export declare function lensPython(env?: NodeJS.ProcessEnv): string;
|
|
9
|
+
/** 모듈이 없을 때 설치 안내. 어떤 모듈이 빠졌는지 stderr에서 읽는다 */
|
|
10
|
+
export declare function missingModuleHint(stderr: string): string | null;
|
|
11
|
+
/** 화자 구분 모델 자리. GESTALT_LENS_MODELS가 있으면 그 절대 경로를 쓴다 */
|
|
12
|
+
export declare function lensModelsDir(env?: NodeJS.ProcessEnv): string;
|
|
13
|
+
/**
|
|
14
|
+
* 화자 구분에 쓰는 두 모델. sherpa-onnx가 GitHub 릴리스로 내준 파일이라 토큰 없이 받는다.
|
|
15
|
+
* 구간 분할은 pyannote segmentation 3.0을 ONNX로 옮긴 것이고 화자 임베딩은 wespeaker ResNet34(VoxCeleb)다.
|
|
16
|
+
* 릴리스 태그가 버전으로 고정돼 있지 않아 받은 파일의 sha256을 여기 박아 둔 값과 맞춰 본다
|
|
17
|
+
*/
|
|
18
|
+
export declare const DIARIZE_MODELS: {
|
|
19
|
+
readonly segmentation: {
|
|
20
|
+
readonly archive: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2";
|
|
21
|
+
/** 압축 파일의 해시다. 풀린 model.onnx가 아니다 */
|
|
22
|
+
readonly sha256: "24615ee884c897d9d2ba09bb4d30da6bb1b15e685065962db5b02e76e4996488";
|
|
23
|
+
readonly bytes: 6958444;
|
|
24
|
+
readonly file: string;
|
|
25
|
+
};
|
|
26
|
+
readonly embedding: {
|
|
27
|
+
readonly url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34.onnx";
|
|
28
|
+
readonly sha256: "5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94";
|
|
29
|
+
readonly bytes: 26534365;
|
|
30
|
+
readonly file: "wespeaker_en_voxceleb_resnet34.onnx";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=python.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../../../src/lens/python.ts"],"names":[],"mappings":"AAIA,4DAA4D;AAC5D,eAAO,MAAM,SAAS,QAA6D,CAAC;AAEpF,eAAO,MAAM,eAAe,QAE4E,CAAC;AAEzG;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAUvE;AAED,+CAA+C;AAC/C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI/D;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAS1E;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;QAIvB,qCAAqC;;;;;;;;;;;CAW/B,CAAC"}
|