prismcv 0.1.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.
prismcv/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PrismCV: one source, every CV."""
2
+
3
+ __version__ = "0.1.0"
prismcv/build.py ADDED
@@ -0,0 +1,353 @@
1
+ """Build CV PDFs from a CV folder (cv.yaml, design.yaml, content/, targets/) with RenderCV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import functools
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+ from pypdf import PdfReader
16
+
17
+ ROOT = Path.cwd()
18
+ CV_FILE = ROOT / "cv.yaml"
19
+ CONTENT = ROOT / "content"
20
+ TARGETS = ROOT / "targets"
21
+ OUT = ROOT / "out"
22
+ DESIGN = ROOT / "design.yaml"
23
+
24
+
25
+ def use_root(root: Path) -> None:
26
+ """Point every path at the CV folder to build."""
27
+ global ROOT, CV_FILE, CONTENT, TARGETS, OUT, DESIGN
28
+ ROOT = root.resolve()
29
+ CV_FILE, DESIGN = ROOT / "cv.yaml", ROOT / "design.yaml"
30
+ CONTENT, TARGETS, OUT = ROOT / "content", ROOT / "targets", ROOT / "out"
31
+ load_design.cache_clear()
32
+ if not all(p.is_file() for p in (CV_FILE, DESIGN)) or not all(p.is_dir() for p in (CONTENT, TARGETS)):
33
+ raise BuildError(f"{ROOT} is not a CV folder: it needs cv.yaml, design.yaml, content/ and targets/ "
34
+ "(create one with prismcv init)")
35
+
36
+ SCHEMA = "https://raw.githubusercontent.com/rendercv/rendercv/refs/tags/v2.8/schema.json"
37
+
38
+ # Fields a RenderCV entry can have. A one-key item in a target whose key is
39
+ # neither an id nor one of these is reported as an unknown id.
40
+ ENTRY_FIELDS = {
41
+ "text", "bullet", "label", "details", "number", "reversed_number",
42
+ "company", "position", "institution", "area", "degree", "name",
43
+ "title", "authors", "journal", "doi", "url", "summary", "highlights",
44
+ "date", "start_date", "end_date", "location",
45
+ }
46
+
47
+ # Marks text explicitly set to null for one language, so it is left out.
48
+ OMIT = object()
49
+
50
+
51
+ class BuildError(Exception):
52
+ pass
53
+
54
+
55
+ def load_yaml(path: Path):
56
+ with open(path, encoding="utf-8") as f:
57
+ return yaml.safe_load(f)
58
+
59
+
60
+ def rel(path: Path) -> str:
61
+ return str(path.relative_to(ROOT))
62
+
63
+
64
+ @functools.cache
65
+ def load_design() -> tuple[dict, dict, dict]:
66
+ """Read design.yaml: languages, the shared settings, and per-language settings."""
67
+ data = load_yaml(DESIGN) or {}
68
+ langs = data.pop("languages", None) or {"en": "english"}
69
+ if not isinstance(langs, dict) or not all(isinstance(v, str) for v in langs.values()):
70
+ raise BuildError("design.yaml: languages must map language codes to RenderCV locales, e.g. en: english")
71
+ per_lang = {code: data.pop(code) or {} for code in list(langs) if code in data}
72
+ return langs, data, per_lang
73
+
74
+
75
+ def languages() -> tuple[str, ...]:
76
+ return tuple(load_design()[0])
77
+
78
+
79
+ def merge(base: dict, extra: dict) -> dict:
80
+ """Merge extra into base, recursing into nested mappings."""
81
+ for k, v in extra.items():
82
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
83
+ merge(base[k], v)
84
+ else:
85
+ base[k] = v
86
+ return base
87
+
88
+
89
+ def design_for(lang: str) -> dict:
90
+ """RenderCV design, locale and settings for one language."""
91
+ langs, shared, per_lang = load_design()
92
+ doc = copy.deepcopy(shared)
93
+ merge(doc.setdefault("design", {}), copy.deepcopy(per_lang.get(lang, {})))
94
+ doc.setdefault("locale", {})["language"] = langs[lang]
95
+ return localize(doc, lang, "design.yaml")
96
+
97
+
98
+ def load_pool() -> dict:
99
+ """Merge every file in content/ into one id -> entry pool."""
100
+ pool, where = {}, {}
101
+ for path in sorted(CONTENT.rglob("*.yaml")):
102
+ data = load_yaml(path) or {}
103
+ if not isinstance(data, dict):
104
+ raise BuildError(f"{rel(path)}: top level must map ids to entries")
105
+ for eid, entry in data.items():
106
+ if eid in pool:
107
+ raise BuildError(f"duplicate id '{eid}' in {rel(where[eid])} and {rel(path)}")
108
+ pool[eid], where[eid] = entry, path
109
+ return pool
110
+
111
+
112
+ def load_cv() -> tuple[dict, dict]:
113
+ """Read cv.yaml: the header (name and contact details) and the section titles."""
114
+ data = load_yaml(CV_FILE) or {}
115
+ if not isinstance(data, dict):
116
+ raise BuildError("cv.yaml: top level must be a mapping with header and sections")
117
+ return dict(data.get("header") or {}), dict(data.get("sections") or {})
118
+
119
+
120
+ # Languages
121
+
122
+ def is_localized(value) -> bool:
123
+ return isinstance(value, dict) and bool(value) and set(value) <= set(languages())
124
+
125
+
126
+ def localize(value, lang: str, where: str):
127
+ """Replace every {en: ..., zh: ...} mapping inside value with the text for lang."""
128
+ if is_localized(value):
129
+ if lang not in value:
130
+ raise BuildError(f"{where}: missing '{lang}' text")
131
+ if value[lang] is None:
132
+ return OMIT
133
+ return localize(value[lang], lang, where)
134
+ if isinstance(value, dict):
135
+ if set(value) & set(languages()):
136
+ extra = sorted(set(value) - set(languages()))
137
+ raise BuildError(f"{where}: unexpected keys {extra} next to language keys "
138
+ "- put text containing commas in quotes, e.g. en: \"London, UK\"")
139
+ out = {}
140
+ for k, v in value.items():
141
+ v = localize(v, lang, f"{where}.{k}")
142
+ if v is not OMIT:
143
+ out[k] = v
144
+ return out
145
+ if isinstance(value, list):
146
+ out = []
147
+ for i, v in enumerate(value):
148
+ v = localize(v, lang, f"{where}[{i}]")
149
+ if v is not OMIT:
150
+ out.append(v)
151
+ return out
152
+ return value
153
+
154
+
155
+ # Entries
156
+
157
+ def resolve_id(eid: str, pool: dict, stack: tuple = ()):
158
+ """Return a copy of an entry with its base entry (for variants) applied."""
159
+ if eid not in pool:
160
+ raise BuildError(f"unknown id '{eid}'")
161
+ if eid in stack:
162
+ raise BuildError(f"circular base: {' -> '.join(stack + (eid,))}")
163
+ entry = copy.deepcopy(pool[eid])
164
+ if not isinstance(entry, dict):
165
+ return entry
166
+ base = entry.pop("base", None)
167
+ if base is None and "@" in eid:
168
+ base = eid.split("@", 1)[0]
169
+ if base is None:
170
+ return entry
171
+ parent = resolve_id(base, pool, stack + (eid,))
172
+ if not isinstance(parent, dict):
173
+ raise BuildError(f"'{eid}': base '{base}' is not a single entry")
174
+ parent.update(entry)
175
+ return parent
176
+
177
+
178
+ def resolve_item(item, pool: dict) -> list:
179
+ """Turn one item of a target section into a list of entries.
180
+
181
+ An item is an id, an id with fields to override, or an entry written inline.
182
+ An id may stand for a list of entries, such as a group of skills.
183
+ """
184
+ if isinstance(item, str):
185
+ entry = resolve_id(item, pool)
186
+ elif isinstance(item, dict) and len(item) == 1 and next(iter(item)) in pool:
187
+ eid, override = next(iter(item.items()))
188
+ entry = resolve_id(eid, pool)
189
+ if override:
190
+ if not isinstance(entry, dict):
191
+ raise BuildError(f"'{eid}' is a list and cannot be overridden field by field")
192
+ entry.update(copy.deepcopy(override))
193
+ elif isinstance(item, dict):
194
+ if len(item) == 1 and next(iter(item)) not in ENTRY_FIELDS:
195
+ raise BuildError(f"unknown id '{next(iter(item))}'")
196
+ entry = copy.deepcopy(item)
197
+ else:
198
+ raise BuildError(f"cannot understand item {item!r}")
199
+ entries = entry if isinstance(entry, list) else [entry]
200
+ return [simplify(e) for e in entries]
201
+
202
+
203
+ def simplify(entry):
204
+ """An entry with only a text field becomes a plain paragraph."""
205
+ if isinstance(entry, dict) and set(entry) == {"text"}:
206
+ return entry["text"]
207
+ return entry
208
+
209
+
210
+ def entry_kind(entry) -> str:
211
+ if isinstance(entry, str):
212
+ return "text"
213
+ keys = set(entry)
214
+ for kind, marker in (
215
+ ("experience", {"company", "position"}), ("education", {"institution"}),
216
+ ("publication", {"authors"}), ("one-line", {"label"}), ("bullet", {"bullet"}),
217
+ ("numbered", {"number"}), ("reversed-numbered", {"reversed_number"}),
218
+ ("normal", {"name"}),
219
+ ):
220
+ if marker <= keys:
221
+ return kind
222
+ return "unknown"
223
+
224
+
225
+ # Assembling
226
+
227
+ def assemble(target_name: str, target: dict, lang: str, pool: dict) -> dict:
228
+ """Build the complete RenderCV input for one target in one language."""
229
+ header, titles = load_cv()
230
+ header.update(target.get("header") or {})
231
+
232
+ cv = localize(header, lang, "header")
233
+ cv["sections"] = {}
234
+ for key, spec in (target.get("sections") or {}).items():
235
+ where = f"targets/{target_name}.yaml: {key}"
236
+ if isinstance(spec, dict):
237
+ items, title = spec.get("entries") or [], spec.get("title")
238
+ else:
239
+ items, title = spec or [], None
240
+ title = title or titles.get(key)
241
+ if title is None:
242
+ raise BuildError(f"{where}: no title (add '{key}' to sections in cv.yaml)")
243
+ title = localize(title, lang, f"{where}.title")
244
+
245
+ entries = []
246
+ for i, item in enumerate(items):
247
+ try:
248
+ resolved = resolve_item(item, pool)
249
+ except BuildError as e:
250
+ raise BuildError(f"{where}[{i}]: {e}") from None
251
+ for e in resolved:
252
+ e = localize(e, lang, f"{where}[{i}]")
253
+ if e is not OMIT:
254
+ entries.append(e)
255
+ if not entries:
256
+ continue
257
+ kinds = {entry_kind(e) for e in entries}
258
+ if len(kinds) > 1:
259
+ raise BuildError(f"{where}: mixed entry types {sorted(kinds)} in one section")
260
+ cv["sections"][title] = entries
261
+
262
+ doc = {"cv": cv}
263
+ doc.update(design_for(lang))
264
+ return doc
265
+
266
+
267
+ def write_input(doc: dict, folder: Path, stem: str) -> Path:
268
+ settings = doc.setdefault("settings", {})
269
+ settings.setdefault("render_command", {}).update({
270
+ "output_folder": ".",
271
+ "pdf_path": f"{stem}.pdf",
272
+ "typst_path": f"{stem}.typ",
273
+ "dont_generate_markdown": True,
274
+ "dont_generate_html": True,
275
+ "dont_generate_png": True,
276
+ })
277
+ path = folder / f"{stem}.yaml"
278
+ with open(path, "w", encoding="utf-8") as f:
279
+ f.write(f"# yaml-language-server: $schema={SCHEMA}\n")
280
+ f.write("# Generated by build.py. Edit content/ and targets/ instead.\n")
281
+ yaml.safe_dump(doc, f, allow_unicode=True, sort_keys=False, width=1000)
282
+ return path
283
+
284
+
285
+ def render(yaml_path: Path) -> None:
286
+ exe = shutil.which("rendercv", path=str(Path(sys.executable).parent)) or shutil.which("rendercv")
287
+ if not exe:
288
+ raise BuildError("rendercv not found - install the requirements: pip install -r requirements.txt")
289
+ proc = subprocess.run([exe, "render", yaml_path.name], cwd=yaml_path.parent,
290
+ capture_output=True, text=True)
291
+ if proc.returncode != 0:
292
+ raise BuildError(f"RenderCV failed (run with --debug to keep its input):\n{proc.stdout}{proc.stderr}")
293
+
294
+
295
+ # Targets
296
+
297
+ def all_targets() -> list[str]:
298
+ return sorted(str(p.relative_to(TARGETS).with_suffix("")) for p in TARGETS.rglob("*.yaml"))
299
+
300
+
301
+ def normalise(name: str) -> str:
302
+ name = name.removeprefix("targets/").removesuffix(".yaml")
303
+ if not (TARGETS / f"{name}.yaml").exists():
304
+ raise BuildError(f"no such target: targets/{name}.yaml")
305
+ return name
306
+
307
+
308
+ def file_stem(lang: str) -> str:
309
+ """File name for a PDF, from the name in the first configured language."""
310
+ name = load_cv()[0].get("name", "CV")
311
+ name = localize(name, languages()[0], "header.name")
312
+ stem = re.sub(r"\W+", "_", str(name)).strip("_") or "CV"
313
+ return f"{stem}_CV_{lang}"
314
+
315
+
316
+ def build(name: str, pool: dict, only_lang: str | None, debug: bool) -> list[str]:
317
+ """Build one target and return any warnings."""
318
+ target = load_yaml(TARGETS / f"{name}.yaml") or {}
319
+ langs = target.get("langs") or list(languages())
320
+ limit = target.get("max_pages")
321
+ if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool) or limit < 1):
322
+ raise BuildError(f"targets/{name}.yaml: max_pages must be a positive whole number (or left out for no limit)")
323
+ if only_lang:
324
+ langs = [l for l in langs if l == only_lang]
325
+ if not langs:
326
+ print(f" - skipped: this target is not built in '{only_lang}'")
327
+
328
+ warnings = []
329
+ for lang in langs:
330
+ if lang not in languages():
331
+ raise BuildError(f"targets/{name}.yaml: language '{lang}' is not listed in design.yaml")
332
+ doc = assemble(name, target, lang, pool)
333
+ out_folder = OUT / name
334
+ out_folder.mkdir(parents=True, exist_ok=True)
335
+ stem = file_stem(lang)
336
+ pdf = out_folder / f"{stem}.pdf"
337
+ with tempfile.TemporaryDirectory(prefix="cv-") as tmp:
338
+ work = Path(tmp)
339
+ path = write_input(doc, work, stem)
340
+ try:
341
+ render(path)
342
+ finally:
343
+ if debug:
344
+ for suffix in (".yaml", ".typ"):
345
+ if (work / f"{stem}{suffix}").exists():
346
+ shutil.copy2(work / f"{stem}{suffix}", out_folder / f"{stem}{suffix}")
347
+ shutil.copy2(work / f"{stem}.pdf", pdf)
348
+
349
+ pages = len(PdfReader(pdf).pages)
350
+ print(f" ✓ {rel(pdf)} ({pages} page{'s' if pages != 1 else ''})")
351
+ if limit and pages > limit:
352
+ warnings.append(f"{name} ({lang}): {pages} pages, exceeds max_pages: {limit}")
353
+ return warnings
prismcv/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """The prismcv command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import build as b
10
+ from .init import InitError, create
11
+
12
+
13
+ def run_build(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
14
+ try:
15
+ b.use_root(args.root)
16
+ if not args.targets and not args.all:
17
+ parser.print_help()
18
+ print("\navailable targets:\n " + "\n ".join(b.all_targets()))
19
+ return 1
20
+ if args.lang and args.lang not in b.languages():
21
+ raise b.BuildError(f"unknown language '{args.lang}' (configured: {', '.join(b.languages())})")
22
+ pool = b.load_pool()
23
+ names = b.all_targets() if args.all else [b.normalise(t) for t in args.targets]
24
+ except b.BuildError as e:
25
+ print(f"✗ {e}", file=sys.stderr)
26
+ return 1
27
+
28
+ failed, warnings = [], []
29
+ for name in names:
30
+ print(name)
31
+ try:
32
+ warnings += b.build(name, pool, args.lang, args.debug)
33
+ except b.BuildError as e:
34
+ print(f" ✗ {e}", file=sys.stderr)
35
+ failed.append(name)
36
+ for w in warnings:
37
+ print(f"⚠ {w}")
38
+ if failed:
39
+ print(f"✗ failed: {', '.join(failed)}", file=sys.stderr)
40
+ return 1
41
+ return 0
42
+
43
+
44
+ def run_init(args: argparse.Namespace) -> int:
45
+ try:
46
+ created = create(args.path, example=args.example)
47
+ except InitError as e:
48
+ print(f"✗ {e}", file=sys.stderr)
49
+ return 1
50
+ root = args.path.resolve()
51
+ for path in created:
52
+ print(f" + {path.relative_to(root)}")
53
+ print(f"Created a CV in {root}. Edit cv.yaml and content/, then run: prismcv build --all")
54
+ return 0
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser(prog="prismcv", description="One source, every CV.")
59
+ commands = ap.add_subparsers(dest="command", required=True)
60
+
61
+ bp = commands.add_parser("build", help="build CV PDFs with RenderCV")
62
+ bp.add_argument("targets", nargs="*", help="target names, e.g. industry or jobs/2026-10-15_acme")
63
+ bp.add_argument("--all", action="store_true", help="build every target")
64
+ bp.add_argument("--lang", help="only build this language")
65
+ bp.add_argument("--debug", action="store_true", help="keep the generated RenderCV input next to the PDF")
66
+ bp.add_argument("--root", type=Path, default=Path.cwd(), help="CV folder to build (default: current folder)")
67
+
68
+ ip = commands.add_parser("init", help="create a new CV folder")
69
+ ip.add_argument("path", nargs="?", type=Path, default=Path.cwd(), help="folder to create it in (default: current folder)")
70
+ ip.add_argument("--example", action="store_true", help="start from the full bilingual example instead of the minimal template")
71
+
72
+ args = ap.parse_args()
73
+ if args.command == "init":
74
+ return run_init(args)
75
+ return run_build(args, bp)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ meng:
2
+ institution: { en: Northfield Institute of Technology, zh: 北原理工学院 }
3
+ degree: { en: MEng, zh: 硕士 }
4
+ area: { en: Electrical Engineering, zh: 电气工程 }
5
+ location: { en: "Toronto, Canada", zh: 加拿大多伦多 }
6
+ start_date: 2024-09
7
+ end_date: present
8
+
9
+ bsc:
10
+ institution: { en: Lakeside University, zh: 湖畔大学 }
11
+ degree: { en: BSc, zh: 学士 }
12
+ area: { en: Physics, zh: 物理学 }
13
+ location: { en: "Vancouver, Canada", zh: 加拿大温哥华 }
14
+ start_date: 2020-09
15
+ end_date: 2024-05
@@ -0,0 +1,60 @@
1
+ # Work, research and activities. Each target decides which section an entry goes in.
2
+
3
+ photonics-lab:
4
+ company: { en: "Applied Photonics Lab, Lakeside University", zh: 湖畔大学应用光子学实验室 }
5
+ position: { en: Undergraduate Researcher, zh: 本科生研究员 }
6
+ location: { en: "Vancouver, Canada", zh: 加拿大温哥华 }
7
+ start_date: 2023-05
8
+ end_date: 2024-04
9
+ highlights:
10
+ - en: Calibrated a LiDAR array and wrote Python tools to clean its output
11
+ zh: 校准激光雷达阵列,并编写 Python 工具清洗其输出数据
12
+ - en: Results contributed to a conference paper on sensor fusion
13
+ zh: 研究结果用于一篇多传感器融合方向的会议论文
14
+
15
+ brightwave-intern:
16
+ company: { en: Brightwave Robotics, zh: 明波机器人 }
17
+ position: { en: Embedded Software Intern, zh: 嵌入式软件实习生 }
18
+ location: { en: "Montreal, Canada", zh: 加拿大蒙特利尔 }
19
+ start_date: 2025-05
20
+ end_date: 2025-08
21
+ highlights:
22
+ - en: Wrote C++ drivers for an IMU on a warehouse robot platform
23
+ zh: 为仓储机器人平台的惯性测量单元编写 C++ 驱动
24
+ - en: Cut sensor read latency by 30% by moving polling to DMA
25
+ zh: 将轮询改为 DMA 传输,传感器读取延迟降低 30%
26
+ - en: Added hardware-in-the-loop tests to the CI pipeline
27
+ zh: 在持续集成流程中加入硬件在环测试
28
+
29
+ # A variant: inherits brightwave-intern and replaces the fields written here.
30
+ brightwave-intern@data:
31
+ highlights:
32
+ - en: Built a pipeline to collect and visualise sensor logs from test runs
33
+ zh: 搭建测试运行中传感器日志的采集与可视化流程
34
+ - en: Analysed failure data to find the root cause of drift in navigation
35
+ zh: 分析故障数据,定位导航漂移问题的根本原因
36
+
37
+ greenleaf-analyst:
38
+ company: { en: Greenleaf Energy, zh: 绿叶能源 }
39
+ position: { en: Part-time Data Analyst, zh: 兼职数据分析师 }
40
+ location: { en: "Toronto, Canada", zh: 加拿大多伦多 }
41
+ start_date: 2025-10
42
+ end_date: present
43
+ highlights:
44
+ - en: Build weekly dashboards on solar panel output for the operations team
45
+ zh: 为运营团队制作太阳能板发电量的周报看板
46
+
47
+ robotics-club:
48
+ company: { en: Lakeside University Robotics Club, zh: 湖畔大学机器人社 }
49
+ position: { en: Team Captain, zh: 队长 }
50
+ start_date: 2022-09
51
+ end_date: 2024-05
52
+ highlights:
53
+ - en: Led a 12-person team through two national competitions
54
+ zh: 带领 12 人团队参加两届全国比赛
55
+
56
+ hackathon:
57
+ company: { en: Northfield Hardware Hackathon, zh: 北原硬件黑客松 }
58
+ position: { en: Co-organiser, zh: 联合组织者 }
59
+ start_date: 2025-01
60
+ end_date: 2025-03
@@ -0,0 +1,9 @@
1
+ # A target can also write its own paragraph instead.
2
+ profile-industry:
3
+ text:
4
+ en: Electrical engineer with hands-on experience in embedded software and sensor data pipelines.
5
+ zh: 电气工程背景,有嵌入式软件与传感器数据处理的实践经验。
6
+ profile-academic:
7
+ text:
8
+ en: Electrical engineering graduate researching sensor fusion for low-cost robotics.
9
+ zh: 电气工程硕士,研究方向为低成本机器人的多传感器融合。
@@ -0,0 +1,15 @@
1
+ weather-station:
2
+ name: { en: Solar Weather Station, zh: 太阳能气象站 }
3
+ start_date: 2024-01
4
+ end_date: 2024-04
5
+ highlights:
6
+ - en: Off-grid weather station on an ESP32, reporting over LoRa
7
+ zh: 基于 ESP32 的离网气象站,通过 LoRa 上报数据
8
+
9
+ line-follower:
10
+ name: { en: Line-following Robot, zh: 循迹机器人 }
11
+ start_date: 2023-02
12
+ end_date: 2023-03
13
+ highlights:
14
+ - en: PID-controlled robot that placed 2nd in a regional competition
15
+ zh: 采用 PID 控制,在地区比赛中获得第二名
@@ -0,0 +1,6 @@
1
+ # Mark yourself in the author list with *italics*.
2
+ paper-fusion:
3
+ title: Low-cost LiDAR and IMU Fusion for Indoor Mapping
4
+ authors: [Maria Santos, "*Nathan Lee*", Wei Chen]
5
+ journal: Example Robotics Conference 2024
6
+ date: 2024-06
@@ -0,0 +1,6 @@
1
+ # An id can hold a list of entries; a target that uses it gets them all.
2
+ skills-general:
3
+ - label: { en: Languages, zh: 编程语言 }
4
+ details: C++, Python, MATLAB
5
+ - label: { en: Tools, zh: 工具 }
6
+ details: ROS 2, Git, KiCad, Docker
@@ -0,0 +1,19 @@
1
+ # Plain text is shared by all languages; {en: ..., zh: ...} gives one text per language.
2
+ header:
3
+ name: { en: Nathan Lee, zh: 李南森 }
4
+ location: { en: "Toronto, Canada", zh: 加拿大多伦多 }
5
+ email: nathan.lee@example.com
6
+ phone: { en: "+1 416 555 0123", zh: "+86 138 0013 8000" }
7
+ social_networks:
8
+ - network: GitHub
9
+ username: example
10
+
11
+ sections:
12
+ profile: { en: Profile, zh: 个人简介 }
13
+ education: { en: Education, zh: 教育背景 }
14
+ work: { en: Work Experience, zh: 工作经历 }
15
+ research: { en: Research Experience, zh: 科研经历 }
16
+ projects: { en: Projects, zh: 项目经历 }
17
+ publications: { en: Publications, zh: 发表论文 }
18
+ activities: { en: Leadership & Activities, zh: 校园活动 }
19
+ skills: { en: Skills, zh: 技能 }
@@ -0,0 +1,25 @@
1
+ # Language codes and their RenderCV locales. The first names the PDF files.
2
+ languages:
3
+ en: english
4
+ zh: mandarin_chinese
5
+
6
+ # RenderCV design shared by every language. Options: https://docs.rendercv.com
7
+ design:
8
+ theme: classic
9
+ page:
10
+ size: a4
11
+ show_footer: false
12
+ show_top_note: false
13
+ sections:
14
+ show_time_spans_in: []
15
+
16
+ # Settings for one language, added on top of the shared design.
17
+ zh:
18
+ typography:
19
+ font_family: Noto Sans SC
20
+ templates:
21
+ single_date: YEAR年MONTH月
22
+ experience_entry:
23
+ main_column: "**COMPANY**,POSITION\nSUMMARY\nHIGHLIGHTS"
24
+ education_entry:
25
+ main_column: "**INSTITUTION**,AREA\nSUMMARY\nHIGHLIGHTS"
@@ -0,0 +1,11 @@
1
+ # Academic: research and publications first.
2
+ langs: [en, zh]
3
+ max_pages: 2
4
+ sections:
5
+ profile: [profile-academic]
6
+ education: [meng, bsc]
7
+ research: [photonics-lab]
8
+ publications: [paper-fusion]
9
+ work: [brightwave-intern]
10
+ activities: [robotics-club, hackathon]
11
+ skills: [skills-general]
@@ -0,0 +1,11 @@
1
+ # Full: everything, no page limit.
2
+ langs: [en, zh]
3
+ sections:
4
+ profile: [profile-industry]
5
+ education: [meng, bsc]
6
+ work: [brightwave-intern, greenleaf-analyst]
7
+ research: [photonics-lab]
8
+ projects: [weather-station, line-follower]
9
+ publications: [paper-fusion]
10
+ activities: [robotics-club, hackathon]
11
+ skills: [skills-general]
@@ -0,0 +1,9 @@
1
+ # Industry: work and projects first, no publications.
2
+ langs: [en, zh]
3
+ max_pages: 1
4
+ sections:
5
+ profile: [profile-industry]
6
+ education: [meng, bsc]
7
+ work: [brightwave-intern, greenleaf-analyst]
8
+ projects: [weather-station, line-follower]
9
+ skills: [skills-general]
@@ -0,0 +1,20 @@
1
+ # A target tailored to one job application, named date_company_role.yaml.
2
+ langs: [en]
3
+ max_pages: 1
4
+ sections:
5
+ # A paragraph written just for this job
6
+ profile:
7
+ - text: Engineer who enjoys turning messy sensor data into reliable pipelines.
8
+ education: [meng, bsc]
9
+ work:
10
+ # A different title for this target only
11
+ title: Experience
12
+ entries:
13
+ # A variant defined in content/
14
+ - brightwave-intern@data
15
+ # Override some fields; the rest come from content/
16
+ - greenleaf-analyst:
17
+ highlights:
18
+ - Rewritten bullet for this role only
19
+ projects: [weather-station]
20
+ skills: [skills-general]
prismcv/init.py ADDED
@@ -0,0 +1,36 @@
1
+ """Create a new CV folder from the template or the example."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ HERE = Path(__file__).resolve().parent
9
+ TEMPLATE = HERE / "template"
10
+ EXAMPLE = HERE / "example"
11
+ CV_PARTS = ("cv.yaml", "design.yaml", "content", "targets")
12
+ GITIGNORE = "# Generated PDFs\nout/\n"
13
+
14
+
15
+ class InitError(Exception):
16
+ pass
17
+
18
+
19
+ def create(root: Path, example: bool = False) -> list[Path]:
20
+ """Copy the template (or the example) into root without overwriting a CV. Returns the files created."""
21
+ root = root.resolve()
22
+ existing = [name for name in CV_PARTS if (root / name).exists()]
23
+ if existing:
24
+ raise InitError(f"{root} already contains {', '.join(existing)}")
25
+ source = EXAMPLE if example else TEMPLATE
26
+ created = []
27
+ for src in sorted(source.rglob("*.yaml")):
28
+ dest = root / src.relative_to(source)
29
+ dest.parent.mkdir(parents=True, exist_ok=True)
30
+ shutil.copyfile(src, dest)
31
+ created.append(dest)
32
+ gitignore = root / ".gitignore"
33
+ if not gitignore.exists():
34
+ gitignore.write_text(GITIGNORE, encoding="utf-8")
35
+ created.append(gitignore)
36
+ return created
@@ -0,0 +1,6 @@
1
+ university:
2
+ institution: Example University
3
+ degree: BSc
4
+ area: Computer Science
5
+ start_date: 2022-09
6
+ end_date: 2026-06
@@ -0,0 +1,10 @@
1
+ # Work, research and activities. Each target decides which section an entry goes in.
2
+ example-job:
3
+ company: Example Company
4
+ position: Software Engineer Intern
5
+ location: City, Country
6
+ start_date: 2025-06
7
+ end_date: 2025-09
8
+ highlights:
9
+ - What you did and what came of it.
10
+ - Another achievement, with a number if you have one.
@@ -0,0 +1,2 @@
1
+ profile:
2
+ text: A sentence or two about who you are and what you are looking for.
@@ -0,0 +1,6 @@
1
+ example-project:
2
+ name: Example Project
3
+ start_date: 2024-01
4
+ end_date: present
5
+ highlights:
6
+ - What it does and how you built it.
@@ -0,0 +1,6 @@
1
+ # An id can hold a list of entries; a target that uses it gets them all.
2
+ skills:
3
+ - label: Languages
4
+ details: Python, Java
5
+ - label: Tools
6
+ details: Git, Docker
@@ -0,0 +1,14 @@
1
+ header:
2
+ name: Your Name
3
+ location: City, Country
4
+ email: you@example.com
5
+ social_networks:
6
+ - network: GitHub
7
+ username: your-username
8
+
9
+ sections:
10
+ profile: Profile
11
+ education: Education
12
+ experience: Experience
13
+ projects: Projects
14
+ skills: Skills
@@ -0,0 +1,13 @@
1
+ # Language codes and their RenderCV locales. The first names the PDF files.
2
+ languages:
3
+ en: english
4
+
5
+ # RenderCV design shared by every language. Options: https://docs.rendercv.com
6
+ design:
7
+ theme: classic
8
+ page:
9
+ size: a4
10
+ show_footer: false
11
+ show_top_note: false
12
+ sections:
13
+ show_time_spans_in: []
@@ -0,0 +1,7 @@
1
+ # One version of the CV: which entries go in which section, in order.
2
+ sections:
3
+ profile: [profile]
4
+ education: [university]
5
+ experience: [example-job]
6
+ projects: [example-project]
7
+ skills: [skills]
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: prismcv
3
+ Version: 0.1.0
4
+ Summary: One source, every CV: build multilingual, tailored CVs with RenderCV.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: pypdf
9
+ Requires-Dist: pyyaml
10
+ Requires-Dist: rendercv[full]==2.8
11
+ Description-Content-Type: text/markdown
12
+
13
+ <!-- markdownlint-disable MD033 MD041 -->
14
+
15
+ <div align="center">
16
+
17
+ <h1>Prism CV</h1>
18
+
19
+ <p><strong>One source, every CV.</strong></p>
20
+
21
+ <p>
22
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
23
+ <img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-3776AB?logo=python&logoColor=white">
24
+ <a href="https://github.com/rendercv/rendercv"><img alt="RenderCV 2.8" src="https://img.shields.io/badge/RenderCV-2.8-blue"></a>
25
+ <a href="README.zh-CN.md"><img alt="中文" src="https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87-blue"></a>
26
+ </p>
27
+
28
+ </div>
29
+
30
+ Keep your whole career in one place and build every version of your CV from it: each language, academic and industry, and one tailored to each job. Edit an entry once and every version picks it up.
31
+
32
+ ## How it works
33
+
34
+ ```mermaid
35
+ flowchart LR
36
+ C["content/<br/>every entry, written once,<br/>languages side by side"]
37
+ T["targets/x.yaml<br/>which entries, in what order,<br/>what is reworded"]
38
+ B["PrismCV<br/>assembles one YAML per language"]
39
+ R["RenderCV<br/>typesets it into PDF"]
40
+ P["out/x/*.pdf"]
41
+
42
+ C --> B
43
+ T --> B
44
+ B --> R --> P
45
+ ```
46
+
47
+ [RenderCV](https://github.com/rendercv/rendercv) typesets one YAML file into a PDF. PrismCV assembles that file from your content and a target; all layout is RenderCV's.
48
+
49
+ ## Quick start
50
+
51
+ Requires Python 3.12+.
52
+
53
+ ```sh
54
+ pip install -e .
55
+ prismcv init my-cv # create a CV folder from a minimal template
56
+ cd my-cv
57
+ prismcv build --all # every target
58
+ prismcv build main --lang en # one target, one language
59
+ prismcv build main --debug # keep the generated RenderCV input
60
+ ```
61
+
62
+ A CV folder holds `cv.yaml`, `design.yaml`, `content/` and `targets/`. Run `prismcv build` inside one, or point to one with `--root`. PDFs go to `out/<target>/`.
63
+
64
+ `prismcv init --example` creates a full bilingual example instead, using every feature below. Its Chinese version needs the [Noto Sans SC](https://fonts.google.com/noto/specimen/Noto+Sans+SC) font.
65
+
66
+ ## Themes
67
+
68
+ Set `theme` in `design.yaml`.
69
+
70
+ ![The example CV in nine themes: classic, engineeringresumes, sb2nov, moderncv, engineeringclassic, harvard, ink, opal and ember](docs/themes.en.jpg)
71
+
72
+ ## Content
73
+
74
+ `cv.yaml` holds your name and contact details and the section titles. Every file in `content/` holds entries keyed by an id; how you split them into files is up to you. Fields use [RenderCV's names](https://docs.rendercv.com).
75
+
76
+ Text that differs between languages maps language codes to text; anything else is written once. An excerpt from the example's [`content/experience.yaml`](src/prismcv/example/content/experience.yaml):
77
+
78
+ ```yaml
79
+ brightwave-intern:
80
+ company: { en: Brightwave Robotics, zh: 明波机器人 }
81
+ location: { en: "Montreal, Canada", zh: 蒙特利尔 }
82
+ start_date: 2025-05
83
+ end_date: present
84
+ highlights:
85
+ - en: Wrote C++ drivers for an IMU
86
+ zh: 编写惯性测量单元的 C++ 驱动
87
+ ```
88
+
89
+ Use `zh: null` to leave a bullet out of one language. An id can also hold a list of entries (a group of skills), and an entry with only `text` is a paragraph (a profile).
90
+
91
+ ## Targets
92
+
93
+ Each file in `targets/` is one version of the CV. `langs` defaults to every language, and `max_pages` warns when a PDF is longer. An excerpt from the example's [target tailored to one job](src/prismcv/example/targets/jobs/2026-10-15_example-data-engineer.yaml):
94
+
95
+ ```yaml
96
+ langs: [en]
97
+ max_pages: 1
98
+ sections:
99
+ profile:
100
+ - text: Engineer who enjoys turning messy sensor data into reliable pipelines.
101
+ work:
102
+ title: Experience
103
+ entries:
104
+ - brightwave-intern@data
105
+ - greenleaf-analyst:
106
+ highlights:
107
+ - Rewritten bullet for this role only
108
+ ```
109
+
110
+ An item is an id, an id with fields to override, or an entry written inline. A variant such as `brightwave-intern@data` lives in `content/` and changes only the fields it lists, so it suits rewording you will reuse. `title` renames a section for this target.
111
+
112
+ ## Languages
113
+
114
+ `languages` in `design.yaml` maps each code to a [RenderCV locale](https://docs.rendercv.com). To add one, add it there, put any settings it needs under its code (a font, a date format), and add its text to the content you use; the build lists anything missing.
115
+
116
+ ## License
117
+
118
+ ```text
119
+ MIT License
120
+
121
+ Copyright (c) 2026 Andy Zhang
122
+
123
+ Permission is hereby granted, free of charge, to any person obtaining a copy
124
+ of this software and associated documentation files (the "Software"), to deal
125
+ in the Software without restriction, including without limitation the rights
126
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
127
+ copies of the Software, and to permit persons to whom the Software is
128
+ furnished to do so, subject to the following conditions:
129
+
130
+ The above copyright notice and this permission notice shall be included in all
131
+ copies or substantial portions of the Software.
132
+
133
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
134
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
135
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
136
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
137
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
138
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
139
+ SOFTWARE.
140
+ ```
@@ -0,0 +1,29 @@
1
+ prismcv/__init__.py,sha256=meSwNrmhCDATJGcOMAIJ68J4Y8MqaULKC1lhPG8i7FQ,60
2
+ prismcv/build.py,sha256=gryCuth2Tf4ftFINCbCyfHCdA2YdLUrgGjMkg4bV3SE,13118
3
+ prismcv/cli.py,sha256=1h4tr4vI3gLtQJsbyG0mo-pEMMVpuNIpFEdqgsBL61w,2901
4
+ prismcv/init.py,sha256=vQcDb2kw24tHXv2Fv4RWTg9LyilNB5GFKzGcKG8xuf8,1183
5
+ prismcv/example/cv.yaml,sha256=PV--bNGjc66EHslZ_l3kLVAy-d0ps6_AQHROv7vMZvA,806
6
+ prismcv/example/design.yaml,sha256=Y5f1chbYvSoqqiIkhSeLivBKyOaHHOKdktFB9Khk2lc,669
7
+ prismcv/example/content/education.yaml,sha256=yFkCf6F9An30MXicOhySXuROf7mUOVrM4MLxsQXjsVI,530
8
+ prismcv/example/content/experience.yaml,sha256=NpsJ3XU_wfWmSlPBPkS7nz7_C0ucXDZUe5Alc5qAzkQ,2708
9
+ prismcv/example/content/profile.yaml,sha256=JRIUTdpCSiAa30SYN-aNqfpXalqHUHGys7FRD-lO-50,472
10
+ prismcv/example/content/projects.yaml,sha256=kPT8FwolEvf-5gatXNHfGntlVh_0pgfdB6lF5opI6eE,539
11
+ prismcv/example/content/publications.yaml,sha256=mS7XJlSu9LrS_Waly2eZNONImhna2cfpFYfQ6TMoc7E,235
12
+ prismcv/example/content/skills.yaml,sha256=unFfXgTLB7bB8tLPNjC2TgGe0-7JhJZFU51kSjSPwY0,245
13
+ prismcv/example/targets/academic.yaml,sha256=_z8Z9UJpAqJRzINE3trZdvXf6KAvbA2IsuNA700zx6A,294
14
+ prismcv/example/targets/full.yaml,sha256=D6JQzkMhnboLbLxCI0bO_vwGboNc28I11wE17TOuq00,335
15
+ prismcv/example/targets/industry.yaml,sha256=tL_7eUXhCepno5oz_PpJshrq7i-I08ozQ6ktNe7WbqE,267
16
+ prismcv/example/targets/jobs/2026-10-15_example-data-engineer.yaml,sha256=dfdB1cw7FrxcyU2-WJ_e1HYtwRv3BPx9BcxWp4aUiYo,641
17
+ prismcv/template/cv.yaml,sha256=ufX_CVlEMHm0uc7n3xjIKxoe6CGwxH-haGSXrH65W8c,264
18
+ prismcv/template/design.yaml,sha256=8mbXKcdEgpdxWXgyyFODjSdD9veEMCbruTj_S65T0zE,314
19
+ prismcv/template/content/education.yaml,sha256=Xx8UaDv58Dv-jR9ybs8TIjAjwvfEmTFtKtnfLDBQA9A,127
20
+ prismcv/template/content/experience.yaml,sha256=XWT8eGIMW5ZSXwsCMEM-NYrhAsK0hWaUT8XODMlIL8E,342
21
+ prismcv/template/content/profile.yaml,sha256=dBU8fJVaBkNzKuKm8WYpPySg_skx8EoI_stPGHYSnWA,83
22
+ prismcv/template/content/projects.yaml,sha256=ZrzEj8lx8ehj0HCYMfoXjd3n816JURKg7UMtmEIAgVE,138
23
+ prismcv/template/content/skills.yaml,sha256=3j-npp1o-mXSTzj7kAMBbFEYUExwByDrKrFxNgMmR18,170
24
+ prismcv/template/targets/main.yaml,sha256=jVmXuH6isNnWwtuElhb9J4gQhFKOh9p1owTFyz3s7Qo,204
25
+ prismcv-0.1.0.dist-info/METADATA,sha256=4CSh-BqUDcK3jAz6e_uRwcgJn6vYWqM6q2M6hGM22MA,5687
26
+ prismcv-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
+ prismcv-0.1.0.dist-info/entry_points.txt,sha256=YhlKJnCiJn3hDTXA0BsAEF-YlX6FAgl9BBqMl8XsC1I,45
28
+ prismcv-0.1.0.dist-info/licenses/LICENSE,sha256=QMZbwDYC9eAxAa5Q8szAVK9bdeHBs1FJm4xEY5B2vi8,1067
29
+ prismcv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prismcv = prismcv.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.