codebase-receipts-cli 1.0.0__py3-none-any.whl
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.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Parse a LaTeX resume into structured sections, entries, and bullets.
|
|
2
|
+
|
|
3
|
+
Default configuration matches the ``twocolentry`` / ``onecolentry`` /
|
|
4
|
+
``highlights`` template family (the "rendercv"-style layout). Override
|
|
5
|
+
``TexParserConfig`` to handle other templates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_TILDE_SENTINEL = "\x00TILDE\x00"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TexParserConfig:
|
|
19
|
+
"""Environment names the parser looks for. Override for a different template."""
|
|
20
|
+
|
|
21
|
+
heading_env: str = "twocolentry"
|
|
22
|
+
content_env: str = "onecolentry"
|
|
23
|
+
bullet_env: str = "highlights"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ResumeBullet:
|
|
28
|
+
text: str
|
|
29
|
+
raw_tex: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ResumeEntry:
|
|
34
|
+
heading: str
|
|
35
|
+
date: str
|
|
36
|
+
bullets: list[ResumeBullet]
|
|
37
|
+
heading_raw: str = "" # raw TeX of the heading/content line, when known
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ParsedResume:
|
|
42
|
+
name: str
|
|
43
|
+
sections: list[ResumeSection] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def all_bullets(self) -> list[tuple[str, str, ResumeBullet]]:
|
|
47
|
+
"""(section_name, entry_heading, bullet) for every bullet."""
|
|
48
|
+
out: list[tuple[str, str, ResumeBullet]] = []
|
|
49
|
+
for sec in self.sections:
|
|
50
|
+
for entry in sec.entries:
|
|
51
|
+
for b in entry.bullets:
|
|
52
|
+
out.append((sec.name, entry.heading, b))
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def all_items(self) -> list[tuple[str, str, ResumeBullet]]:
|
|
57
|
+
"""Like ``all_bullets`` but also yields heading-only entries.
|
|
58
|
+
|
|
59
|
+
Entries without bullets (common in Skills, Certifications, Education)
|
|
60
|
+
produce a synthetic bullet from the heading text so the claim
|
|
61
|
+
extractor can process every line of the resume.
|
|
62
|
+
"""
|
|
63
|
+
out: list[tuple[str, str, ResumeBullet]] = []
|
|
64
|
+
for sec in self.sections:
|
|
65
|
+
for entry in sec.entries:
|
|
66
|
+
if entry.bullets:
|
|
67
|
+
for b in entry.bullets:
|
|
68
|
+
out.append((sec.name, entry.heading, b))
|
|
69
|
+
elif entry.heading:
|
|
70
|
+
out.append(
|
|
71
|
+
(
|
|
72
|
+
sec.name,
|
|
73
|
+
entry.heading,
|
|
74
|
+
ResumeBullet(
|
|
75
|
+
text=entry.heading,
|
|
76
|
+
raw_tex=entry.heading_raw or entry.heading,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ResumeSection:
|
|
85
|
+
name: str
|
|
86
|
+
entries: list[ResumeEntry]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# LaTeX cleaning
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clean_tex(text: str) -> str:
|
|
95
|
+
"""Strip LaTeX formatting, returning readable plain text."""
|
|
96
|
+
s = text
|
|
97
|
+
|
|
98
|
+
# 1. Protect \(\sim\) → tilde (must happen before ~ → space)
|
|
99
|
+
s = s.replace("\\(\\sim\\)", _TILDE_SENTINEL)
|
|
100
|
+
|
|
101
|
+
# 2. Literal character escapes
|
|
102
|
+
s = s.replace("\\%", "%")
|
|
103
|
+
s = s.replace("\\_", "_")
|
|
104
|
+
s = s.replace("\\&", "&")
|
|
105
|
+
s = s.replace("\\$", "$")
|
|
106
|
+
s = s.replace("\\#", "#")
|
|
107
|
+
s = s.replace("\\textbar", "|")
|
|
108
|
+
|
|
109
|
+
# 3. Line breaks (\\, \\[3pt])
|
|
110
|
+
s = re.sub(r"\\\\(?:\[[^\]]*\])?", " ", s)
|
|
111
|
+
|
|
112
|
+
# 4. Spacing commands → space
|
|
113
|
+
s = re.sub(r"\\(?:enspace|quad|qquad|hfill|vfill)\b", " ", s)
|
|
114
|
+
s = re.sub(r"\\[,;!]", " ", s)
|
|
115
|
+
s = s.replace("~", " ")
|
|
116
|
+
|
|
117
|
+
# 5. Remove icon commands
|
|
118
|
+
s = re.sub(r"\\faIcon\{[^}]*\}", "", s)
|
|
119
|
+
|
|
120
|
+
# 6. Remove sizing / spacing with arguments
|
|
121
|
+
s = re.sub(r"\\(?:vspace|hspace)\{[^}]*\}", "", s)
|
|
122
|
+
s = re.sub(r"\\fontsize\{[^}]*\}\{[^}]*\}", "", s)
|
|
123
|
+
s = re.sub(r"\\selectfont", "", s)
|
|
124
|
+
s = re.sub(r"\\(?:par|kern)\\?[a-z]*", "", s)
|
|
125
|
+
|
|
126
|
+
# 7. \href{url}{text} → text (text arg may have 2 levels of nesting)
|
|
127
|
+
_NESTED2 = r"[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\}[^{}]*)*"
|
|
128
|
+
for _ in range(3):
|
|
129
|
+
prev = s
|
|
130
|
+
s = re.sub(
|
|
131
|
+
r"\\(?:href|hrefWithoutArrow)\{[^}]*\}\{(" + _NESTED2 + r")\}",
|
|
132
|
+
r"\1",
|
|
133
|
+
s,
|
|
134
|
+
)
|
|
135
|
+
if s == prev:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
# 8. \textcolor{color}{text} → text (iterate for nesting)
|
|
139
|
+
for _ in range(5):
|
|
140
|
+
prev = s
|
|
141
|
+
s = re.sub(r"\\textcolor\{[^{}]*\}\{(" + _NESTED2 + r")\}", r"\1", s)
|
|
142
|
+
if s == prev:
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
# 9. \textbf / \textit / \texttt / … → text (iterate for nesting)
|
|
146
|
+
for _ in range(5):
|
|
147
|
+
prev = s
|
|
148
|
+
s = re.sub(
|
|
149
|
+
r"\\text(?:bf|it|tt|sc|rm|sf)\{(" + _NESTED2 + r")\}",
|
|
150
|
+
r"\1",
|
|
151
|
+
s,
|
|
152
|
+
)
|
|
153
|
+
if s == prev:
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
# 10. Font switches: {\small text}, {\bf text}
|
|
157
|
+
s = re.sub(
|
|
158
|
+
r"\{\\(?:small|bf|it|tt|sc|em|rm|sf|bfseries|itshape)\s+([^}]*)\}",
|
|
159
|
+
r"\1",
|
|
160
|
+
s,
|
|
161
|
+
)
|
|
162
|
+
s = re.sub(
|
|
163
|
+
r"\\(?:small|large|Large|LARGE|normalsize|footnotesize|tiny"
|
|
164
|
+
r"|scriptsize|bfseries|itshape|centering|raggedright|raggedleft)\b",
|
|
165
|
+
"",
|
|
166
|
+
s,
|
|
167
|
+
)
|
|
168
|
+
s = re.sub(r"\\linespread\{[^}]*\}", "", s)
|
|
169
|
+
|
|
170
|
+
# 11. Inline math: $…$ → content
|
|
171
|
+
s = re.sub(r"\$([^$]*)\$", r"\1", s)
|
|
172
|
+
s = re.sub(r"\\vcenter\{\\hbox\{([^}]*)\}\}", r"\1", s)
|
|
173
|
+
|
|
174
|
+
# 12. Strip remaining braces
|
|
175
|
+
s = s.replace("{", "").replace("}", "")
|
|
176
|
+
|
|
177
|
+
# 13. Restore tilde sentinel
|
|
178
|
+
s = s.replace(_TILDE_SENTINEL, "~")
|
|
179
|
+
|
|
180
|
+
# 14. En-dash cleanup: -- → –
|
|
181
|
+
s = s.replace("--", "–")
|
|
182
|
+
|
|
183
|
+
# 15. Collapse whitespace
|
|
184
|
+
s = re.sub(r"[ \t]+", " ", s)
|
|
185
|
+
s = s.strip()
|
|
186
|
+
|
|
187
|
+
return s
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Low-level helpers
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _find_brace_group(text: str, start: int) -> tuple[str, int]:
|
|
196
|
+
"""Return (content, end_index) for the brace group starting at *start*."""
|
|
197
|
+
if start >= len(text) or text[start] != "{":
|
|
198
|
+
return "", start
|
|
199
|
+
depth = 0
|
|
200
|
+
i = start
|
|
201
|
+
while i < len(text):
|
|
202
|
+
if text[i] == "{":
|
|
203
|
+
depth += 1
|
|
204
|
+
elif text[i] == "}":
|
|
205
|
+
depth -= 1
|
|
206
|
+
if depth == 0:
|
|
207
|
+
return text[start + 1 : i], i + 1
|
|
208
|
+
i += 1
|
|
209
|
+
return text[start + 1 :], len(text)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_items(highlights_text: str) -> list[ResumeBullet]:
|
|
213
|
+
"""Split ``\\item`` entries from a highlights block."""
|
|
214
|
+
parts = re.split(r"\\item\s+", highlights_text)
|
|
215
|
+
bullets: list[ResumeBullet] = []
|
|
216
|
+
for raw in parts:
|
|
217
|
+
raw = raw.strip()
|
|
218
|
+
if not raw:
|
|
219
|
+
continue
|
|
220
|
+
cleaned = clean_tex(raw)
|
|
221
|
+
if cleaned:
|
|
222
|
+
bullets.append(ResumeBullet(text=cleaned, raw_tex=raw))
|
|
223
|
+
return bullets
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Section splitting
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
_SECTION_RE = re.compile(r"\\section\{", re.DOTALL)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _split_sections(body: str) -> list[tuple[str, str]]:
|
|
234
|
+
"""Return (section_name, section_body_text) pairs."""
|
|
235
|
+
hits = list(_SECTION_RE.finditer(body))
|
|
236
|
+
if not hits:
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
sections: list[tuple[str, str]] = []
|
|
240
|
+
for idx, m in enumerate(hits):
|
|
241
|
+
name_raw, after = _find_brace_group(body, m.start() + len("\\section"))
|
|
242
|
+
name = clean_tex(name_raw)
|
|
243
|
+
end = hits[idx + 1].start() if idx + 1 < len(hits) else len(body)
|
|
244
|
+
sections.append((name, body[after:end]))
|
|
245
|
+
return sections
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Entry parsing within a section
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_section_content(text: str, cfg: TexParserConfig) -> list[ResumeEntry]:
|
|
254
|
+
entries: list[ResumeEntry] = []
|
|
255
|
+
pos = 0
|
|
256
|
+
|
|
257
|
+
h_tag = f"\\begin{{{cfg.heading_env}}}"
|
|
258
|
+
h_end_tag = f"\\end{{{cfg.heading_env}}}"
|
|
259
|
+
c_tag = f"\\begin{{{cfg.content_env}}}"
|
|
260
|
+
c_end_tag = f"\\end{{{cfg.content_env}}}"
|
|
261
|
+
b_tag = f"\\begin{{{cfg.bullet_env}}}"
|
|
262
|
+
b_end_tag = f"\\end{{{cfg.bullet_env}}}"
|
|
263
|
+
|
|
264
|
+
while pos < len(text):
|
|
265
|
+
h_pos = text.find(h_tag, pos)
|
|
266
|
+
c_pos = text.find(c_tag, pos)
|
|
267
|
+
|
|
268
|
+
if h_pos == -1 and c_pos == -1:
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if h_pos != -1 and (c_pos == -1 or h_pos < c_pos):
|
|
272
|
+
# --- heading entry (twocolentry) ---
|
|
273
|
+
brace = text.find("{", h_pos + len(h_tag))
|
|
274
|
+
if brace == -1:
|
|
275
|
+
pos = h_pos + len(h_tag)
|
|
276
|
+
continue
|
|
277
|
+
date_raw, after_brace = _find_brace_group(text, brace)
|
|
278
|
+
|
|
279
|
+
end_marker = text.find(h_end_tag, after_brace)
|
|
280
|
+
if end_marker == -1:
|
|
281
|
+
pos = after_brace
|
|
282
|
+
continue
|
|
283
|
+
heading_raw = text[after_brace:end_marker].strip()
|
|
284
|
+
pos = end_marker + len(h_end_tag)
|
|
285
|
+
|
|
286
|
+
# look for a following content env (skip whitespace / vspace)
|
|
287
|
+
bullets: list[ResumeBullet] = []
|
|
288
|
+
rest = text[pos:]
|
|
289
|
+
stripped = re.sub(r"^[\s]*(?:\\vspace\{[^}]*\}[\s]*)*", "", rest)
|
|
290
|
+
offset = len(rest) - len(stripped)
|
|
291
|
+
|
|
292
|
+
if stripped.startswith(c_tag):
|
|
293
|
+
c_start = pos + offset
|
|
294
|
+
c_end = text.find(c_end_tag, c_start)
|
|
295
|
+
if c_end != -1:
|
|
296
|
+
inner = text[c_start + len(c_tag) : c_end]
|
|
297
|
+
hl_start = inner.find(b_tag)
|
|
298
|
+
if hl_start != -1:
|
|
299
|
+
hl_end = inner.find(b_end_tag, hl_start)
|
|
300
|
+
if hl_end != -1:
|
|
301
|
+
bullets = _extract_items(
|
|
302
|
+
inner[hl_start + len(b_tag) : hl_end]
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
cleaned = clean_tex(inner.strip())
|
|
306
|
+
if cleaned:
|
|
307
|
+
bullets = [
|
|
308
|
+
ResumeBullet(text=cleaned, raw_tex=inner.strip())
|
|
309
|
+
]
|
|
310
|
+
pos = c_end + len(c_end_tag)
|
|
311
|
+
|
|
312
|
+
entries.append(
|
|
313
|
+
ResumeEntry(
|
|
314
|
+
heading=clean_tex(heading_raw),
|
|
315
|
+
date=clean_tex(date_raw),
|
|
316
|
+
bullets=bullets,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
else:
|
|
321
|
+
# --- standalone content env (skills, certs without heading) ---
|
|
322
|
+
c_end = text.find(c_end_tag, c_pos)
|
|
323
|
+
if c_end == -1:
|
|
324
|
+
pos = c_pos + len(c_tag)
|
|
325
|
+
continue
|
|
326
|
+
inner = text[c_pos + len(c_tag) : c_end]
|
|
327
|
+
|
|
328
|
+
hl_start = inner.find(b_tag)
|
|
329
|
+
if hl_start != -1:
|
|
330
|
+
hl_end = inner.find(b_end_tag, hl_start)
|
|
331
|
+
if hl_end != -1:
|
|
332
|
+
bullets = _extract_items(inner[hl_start + len(b_tag) : hl_end])
|
|
333
|
+
entries.append(ResumeEntry(heading="", date="", bullets=bullets))
|
|
334
|
+
else:
|
|
335
|
+
cleaned = clean_tex(inner.strip())
|
|
336
|
+
if cleaned:
|
|
337
|
+
entries.append(
|
|
338
|
+
ResumeEntry(
|
|
339
|
+
heading=cleaned,
|
|
340
|
+
date="",
|
|
341
|
+
bullets=[],
|
|
342
|
+
heading_raw=inner.strip(),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
pos = c_end + len(c_end_tag)
|
|
347
|
+
|
|
348
|
+
return entries
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Name extraction
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _extract_name(body: str) -> str:
|
|
357
|
+
m = re.search(r"\\textbf\{\\textcolor\{[^}]*\}\{([^}]*)\}\}", body)
|
|
358
|
+
if m:
|
|
359
|
+
return m.group(1).strip()
|
|
360
|
+
header_m = re.search(r"\\begin\{header\}(.*?)\\end\{header\}", body, re.DOTALL)
|
|
361
|
+
if header_m:
|
|
362
|
+
bf = re.search(r"\\textbf\{([^}]*)\}", header_m.group(1))
|
|
363
|
+
if bf:
|
|
364
|
+
return clean_tex(bf.group(1))
|
|
365
|
+
return ""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Public API
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def parse_resume(
|
|
374
|
+
tex_source: str, config: TexParserConfig | None = None
|
|
375
|
+
) -> ParsedResume:
|
|
376
|
+
"""Parse a LaTeX resume source into a structured ``ParsedResume``."""
|
|
377
|
+
cfg = config or TexParserConfig()
|
|
378
|
+
|
|
379
|
+
doc = re.search(r"\\begin\{document\}(.*?)\\end\{document\}", tex_source, re.DOTALL)
|
|
380
|
+
body = doc.group(1) if doc else tex_source
|
|
381
|
+
|
|
382
|
+
name = _extract_name(body)
|
|
383
|
+
sections: list[ResumeSection] = []
|
|
384
|
+
for sec_name, sec_text in _split_sections(body):
|
|
385
|
+
entries = _parse_section_content(sec_text, cfg)
|
|
386
|
+
if entries:
|
|
387
|
+
sections.append(ResumeSection(name=sec_name, entries=entries))
|
|
388
|
+
|
|
389
|
+
return ParsedResume(name=name, sections=sections)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_file(path: Path, config: TexParserConfig | None = None) -> ParsedResume:
|
|
393
|
+
"""Read a ``.tex`` file and parse it."""
|
|
394
|
+
return parse_resume(path.read_text(encoding="utf-8"), config)
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Compile a .tex file to PDF if a TeX toolchain is available."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CompileResult:
|
|
15
|
+
success: bool
|
|
16
|
+
pdf_path: Path | None
|
|
17
|
+
tool_used: str | None
|
|
18
|
+
error: str | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Well-known per-platform binary locations that may not be on PATH yet
|
|
22
|
+
# (e.g. MiKTeX installs to AppData on Windows; MacTeX to /Library).
|
|
23
|
+
_EXTRA_SEARCH_PATHS: list[str] = []
|
|
24
|
+
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
_local = os.environ.get("LOCALAPPDATA", "")
|
|
27
|
+
_program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
|
|
28
|
+
_EXTRA_SEARCH_PATHS = [
|
|
29
|
+
os.path.join(_local, "Programs", "MiKTeX", "miktex", "bin", "x64"),
|
|
30
|
+
os.path.join(_program_files, "MiKTeX", "miktex", "bin", "x64"),
|
|
31
|
+
os.path.join(_program_files, "MiKTeX 2.9", "miktex", "bin", "x64"),
|
|
32
|
+
]
|
|
33
|
+
elif sys.platform == "darwin":
|
|
34
|
+
_EXTRA_SEARCH_PATHS = [
|
|
35
|
+
"/Library/TeX/texbin",
|
|
36
|
+
"/usr/local/texlive/2024/bin/universal-darwin",
|
|
37
|
+
"/usr/local/texlive/2023/bin/universal-darwin",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _which_extended(cmd: str) -> str | None:
|
|
42
|
+
"""Like shutil.which but also searches known TeX install locations."""
|
|
43
|
+
found = shutil.which(cmd)
|
|
44
|
+
if found:
|
|
45
|
+
return found
|
|
46
|
+
exe = cmd if sys.platform != "win32" else cmd + ".exe"
|
|
47
|
+
for extra in _EXTRA_SEARCH_PATHS:
|
|
48
|
+
candidate = os.path.join(extra, exe)
|
|
49
|
+
if os.path.isfile(candidate):
|
|
50
|
+
return candidate
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_tex_compiler() -> str | None:
|
|
55
|
+
"""Return the NAME of the first available TeX compiler, or None.
|
|
56
|
+
|
|
57
|
+
Preference order is platform-aware: on Windows latexmk needs Perl (rarely
|
|
58
|
+
installed), so pdflatex leads; on Unix latexmk leads. Searches PATH plus
|
|
59
|
+
known install locations (MiKTeX on Windows, MacTeX on macOS).
|
|
60
|
+
"""
|
|
61
|
+
# On Windows latexmk requires Perl which is rarely installed; prefer
|
|
62
|
+
# pdflatex directly and fall back to latexmk only on Unix where Perl ships.
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
order = ("pdflatex", "xelatex", "lualatex", "latexmk", "tectonic")
|
|
65
|
+
else:
|
|
66
|
+
order = ("latexmk", "pdflatex", "xelatex", "lualatex", "tectonic")
|
|
67
|
+
for cmd in order:
|
|
68
|
+
if _which_extended(cmd):
|
|
69
|
+
return cmd
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compile_tex(tex_path: Path, *, compiler: str | None = None) -> CompileResult:
|
|
74
|
+
"""Compile a .tex file to PDF.
|
|
75
|
+
|
|
76
|
+
Returns a CompileResult with the PDF path on success, or a descriptive
|
|
77
|
+
error message on failure. Checks for the PDF by path (not exit code) so
|
|
78
|
+
MiKTeX's non-zero-on-warning behaviour doesn't cause false negatives.
|
|
79
|
+
"""
|
|
80
|
+
if compiler is None:
|
|
81
|
+
compiler = find_tex_compiler()
|
|
82
|
+
|
|
83
|
+
if compiler is None:
|
|
84
|
+
return CompileResult(
|
|
85
|
+
success=False,
|
|
86
|
+
pdf_path=None,
|
|
87
|
+
tool_used=None,
|
|
88
|
+
error=(
|
|
89
|
+
"No TeX compiler found. Install one of:\n"
|
|
90
|
+
" • MiKTeX (Windows): https://miktex.org/download\n"
|
|
91
|
+
" • MacTeX (macOS): https://www.tug.org/mactex/\n"
|
|
92
|
+
" • TeX Live (Linux): sudo apt install texlive-full\n"
|
|
93
|
+
" • tectonic (any): https://tectonic-typesetting.github.io\n"
|
|
94
|
+
"Or paste your .tex into https://overleaf.com to compile online."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Resolve the compiler to a full path (may live off PATH, e.g. MiKTeX).
|
|
99
|
+
compiler_path = _which_extended(compiler)
|
|
100
|
+
if compiler_path is None:
|
|
101
|
+
return CompileResult(
|
|
102
|
+
success=False,
|
|
103
|
+
pdf_path=None,
|
|
104
|
+
tool_used=compiler,
|
|
105
|
+
error=f"{compiler} not found in PATH.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
tex_dir = tex_path.parent
|
|
109
|
+
|
|
110
|
+
if compiler == "latexmk":
|
|
111
|
+
cmd = [
|
|
112
|
+
compiler_path,
|
|
113
|
+
"-pdf",
|
|
114
|
+
"-interaction=nonstopmode",
|
|
115
|
+
"-output-directory=" + str(tex_dir),
|
|
116
|
+
str(tex_path),
|
|
117
|
+
]
|
|
118
|
+
elif compiler == "tectonic":
|
|
119
|
+
cmd = [compiler_path, "--outdir", str(tex_dir), str(tex_path)]
|
|
120
|
+
else:
|
|
121
|
+
# pdflatex / xelatex / lualatex
|
|
122
|
+
cmd = [
|
|
123
|
+
compiler_path,
|
|
124
|
+
"-interaction=nonstopmode",
|
|
125
|
+
f"-output-directory={tex_dir}",
|
|
126
|
+
str(tex_path),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
cmd,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=120,
|
|
135
|
+
cwd=tex_dir,
|
|
136
|
+
)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return CompileResult(
|
|
139
|
+
success=False,
|
|
140
|
+
pdf_path=None,
|
|
141
|
+
tool_used=compiler,
|
|
142
|
+
error=f"{compiler} disappeared from PATH mid-run.",
|
|
143
|
+
)
|
|
144
|
+
except subprocess.TimeoutExpired:
|
|
145
|
+
return CompileResult(
|
|
146
|
+
success=False,
|
|
147
|
+
pdf_path=None,
|
|
148
|
+
tool_used=compiler,
|
|
149
|
+
error=f"{compiler} timed out after 120 seconds.",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
pdf_path = tex_path.with_suffix(".pdf")
|
|
153
|
+
|
|
154
|
+
# Check the file — MiKTeX exits non-zero even on success (update nag).
|
|
155
|
+
if pdf_path.exists():
|
|
156
|
+
return CompileResult(
|
|
157
|
+
success=True,
|
|
158
|
+
pdf_path=pdf_path,
|
|
159
|
+
tool_used=compiler,
|
|
160
|
+
error=None,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Pull the most useful tail of output for the error message.
|
|
164
|
+
combined = (result.stderr or "") + (result.stdout or "")
|
|
165
|
+
# Strip the MiKTeX update nag — it's not an error.
|
|
166
|
+
lines = [
|
|
167
|
+
ln
|
|
168
|
+
for ln in combined.splitlines()
|
|
169
|
+
if "check for MiKTeX updates" not in ln and "major issue" not in ln
|
|
170
|
+
]
|
|
171
|
+
error_text = "\n".join(lines[-20:]) or (
|
|
172
|
+
f"{compiler} exited with code {result.returncode}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return CompileResult(
|
|
176
|
+
success=False,
|
|
177
|
+
pdf_path=None,
|
|
178
|
+
tool_used=compiler,
|
|
179
|
+
error=error_text,
|
|
180
|
+
)
|