offerprinter 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (73) hide show
  1. {offerprinter-0.3.0 → offerprinter-0.4.0}/CHANGELOG.md +38 -0
  2. {offerprinter-0.3.0 → offerprinter-0.4.0}/PKG-INFO +71 -1
  3. {offerprinter-0.3.0 → offerprinter-0.4.0}/README.md +70 -0
  4. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/cli.py +130 -5
  5. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/config.py +1 -0
  6. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/controllers/pipeline.py +6 -1
  7. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/models/schemas.py +2 -0
  8. offerprinter-0.4.0/offerprinter/services/linkedin.py +267 -0
  9. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/pdf_writer.py +109 -95
  10. offerprinter-0.4.0/offerprinter/services/themes.py +232 -0
  11. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/writer.py +5 -4
  12. {offerprinter-0.3.0 → offerprinter-0.4.0}/pyproject.toml +1 -1
  13. offerprinter-0.4.0/tests/test_templates_and_linkedin.py +329 -0
  14. {offerprinter-0.3.0 → offerprinter-0.4.0}/.gitignore +0 -0
  15. {offerprinter-0.3.0 → offerprinter-0.4.0}/LICENSE +0 -0
  16. {offerprinter-0.3.0 → offerprinter-0.4.0}/app.py +0 -0
  17. {offerprinter-0.3.0 → offerprinter-0.4.0}/cli.py +0 -0
  18. {offerprinter-0.3.0 → offerprinter-0.4.0}/config.example.toml +0 -0
  19. {offerprinter-0.3.0 → offerprinter-0.4.0}/deploy/README.md +0 -0
  20. {offerprinter-0.3.0 → offerprinter-0.4.0}/deploy/huggingface/README.md +0 -0
  21. {offerprinter-0.3.0 → offerprinter-0.4.0}/docs/skill/SKILL.md +0 -0
  22. {offerprinter-0.3.0 → offerprinter-0.4.0}/evals/README.md +0 -0
  23. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/README.md +0 -0
  24. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/ats-keyword-report.docx +0 -0
  25. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/ats-keyword-report.md +0 -0
  26. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/cover-letter.docx +0 -0
  27. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/cover-letter.md +0 -0
  28. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/fit-memo.docx +0 -0
  29. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/fit-memo.md +0 -0
  30. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/full-package.docx +0 -0
  31. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/full-package.md +0 -0
  32. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/interview-prep-pack.docx +0 -0
  33. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/interview-prep-pack.md +0 -0
  34. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/tailored-cv.docx +0 -0
  35. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/output/northbank-senior-product-analyst/tailored-cv.md +0 -0
  36. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/sample_cv.md +0 -0
  37. {offerprinter-0.3.0 → offerprinter-0.4.0}/examples/sample_jd.md +0 -0
  38. {offerprinter-0.3.0 → offerprinter-0.4.0}/extension/README.md +0 -0
  39. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/__init__.py +0 -0
  40. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/controllers/__init__.py +0 -0
  41. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/__init__.py +0 -0
  42. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/anthropic_provider.py +0 -0
  43. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/base.py +0 -0
  44. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/factory.py +0 -0
  45. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/gemini_provider.py +0 -0
  46. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/kimi_provider.py +0 -0
  47. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/ollama_provider.py +0 -0
  48. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/llm/openai_provider.py +0 -0
  49. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/mcp_server.py +0 -0
  50. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/models/__init__.py +0 -0
  51. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/pricing.py +0 -0
  52. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/prompts/__init__.py +0 -0
  53. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/prompts/templates.py +0 -0
  54. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/__init__.py +0 -0
  55. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/cache.py +0 -0
  56. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/cv_parser.py +0 -0
  57. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/differ.py +0 -0
  58. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/generator.py +0 -0
  59. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/jd_fetcher.py +0 -0
  60. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/ranker.py +0 -0
  61. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/redactor.py +0 -0
  62. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/tracker.py +0 -0
  63. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/services/verifier.py +0 -0
  64. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/ui/__init__.py +0 -0
  65. {offerprinter-0.3.0 → offerprinter-0.4.0}/offerprinter/ui/printer.py +0 -0
  66. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/__init__.py +0 -0
  67. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/conftest.py +0 -0
  68. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_new_services.py +0 -0
  69. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_offerprinter.py +0 -0
  70. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_pdf_writer.py +0 -0
  71. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_pricing_and_retries.py +0 -0
  72. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_tracker.py +0 -0
  73. {offerprinter-0.3.0 → offerprinter-0.4.0}/tests/test_verifier.py +0 -0
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-08-25
11
+
12
+ The "it has to look like something" release. v0.3.0 made the output provable;
13
+ this one makes it presentable, and makes getting your history in a two-minute job
14
+ rather than an afternoon.
15
+
16
+ ### Added
17
+
18
+ - **Six PDF templates**: `classic`, `compact`, `serif`, `rule`, `accent`, `mono`.
19
+ Choose with `--template`, list with `offerprinter templates`, and see them with
20
+ `offerprinter templates --sample ./samples`. Set a default under `[output]` in
21
+ `config.toml`, or `OFFERPRINTER_TEMPLATE`.
22
+ - **LinkedIn import** (`--linkedin`). Takes either export LinkedIn offers: the PDF
23
+ from *More → Save to PDF*, or the CSV archive from *Get a copy of your data*.
24
+ Nothing is scraped and nothing is fetched — you export your own data and this
25
+ reads the file.
26
+
27
+ ### Notes on the templates
28
+
29
+ Every template stays inside what an ATS can parse: one of the 14 PDF base fonts,
30
+ no columns, no tables, no images, no text boxes. What varies is typeface, spacing,
31
+ rules, capitalisation and one accent colour. The test suite checks, per template,
32
+ that the PDF embeds no font program, contains no `/Image` or `/XObject`, and that
33
+ a naive text extraction still returns the name, the email and every bullet.
34
+
35
+ `accent` is labelled *email only*: it extracts cleanly, but carries enough colour
36
+ that a strict parser could lose a heading.
37
+
38
+ The LinkedIn importer is held to the no-fabrication guarantee too. A test takes
39
+ every word of its output and asserts it appeared in the input, because an importer
40
+ that quietly tidied up your history would break the guarantee before the generator
41
+ ever ran.
42
+
43
+ ### Unchanged
44
+
45
+ `classic` renders byte-for-byte what v0.3.0 rendered. There is a test for that, so
46
+ upgrading changes nothing unless you ask it to.
47
+
10
48
  ## [0.3.0] — 2026-08-20
11
49
 
12
50
  The "prove it" release. v0.2.0 made OfferPrinter installable; this one makes its
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: offerprinter
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Free, local-first AI job application generator: one CV + one job description, and it prints a tailored CV, cover letter, fit memo, ATS keyword report and interview prep pack — without fabricating anything.
5
5
  Project-URL: Homepage, https://github.com/mohitagw15856/OfferPrinter
6
6
  Project-URL: Repository, https://github.com/mohitagw15856/OfferPrinter
@@ -177,6 +177,76 @@ have hand-edited it, say — and makes no API calls at all, so it is free.
177
177
 
178
178
  ---
179
179
 
180
+ ## I have not updated my CV in three years
181
+
182
+ Neither has anyone. Your LinkedIn is current though, and LinkedIn will give it
183
+ back to you:
184
+
185
+ ```sh
186
+ # More > Save to PDF on your own profile
187
+ offerprinter --linkedin ~/Downloads/Profile.pdf --jd https://…
188
+
189
+ # or the full archive: Settings > Data privacy > Get a copy of your data
190
+ offerprinter --linkedin ~/Downloads/Basic_LinkedInDataExport.zip --jd https://…
191
+ ```
192
+
193
+ Both shapes are normalised into the Markdown the rest of the pipeline expects.
194
+ Nothing is scraped — you export your own data from your own account and this reads
195
+ the file.
196
+
197
+ The importer holds to the same rule as the generator: **it invents nothing.** A
198
+ role with no description gets no description; an empty section stays empty. There
199
+ is a test that takes every word of the output and asserts it appeared in the
200
+ input, because an importer that quietly "tidied up" your history would break the
201
+ no-fabrication guarantee before the generator ever ran.
202
+
203
+ ## Can I make it look like something other than every other CV?
204
+
205
+ Six templates, all of which still parse:
206
+
207
+ ```sh
208
+ offerprinter templates # what they are
209
+ offerprinter templates --sample ./samples # one PDF of each, to look at
210
+ offerprinter --cv cv.pdf --jd URL --template serif
211
+ ```
212
+
213
+ | Template | Typeface | Portal-safe | For |
214
+ |---|---|---|---|
215
+ | `classic` | Helvetica | yes | the default; nothing to go wrong |
216
+ | `compact` | Helvetica | yes | when two pages need to be one |
217
+ | `serif` | Times | yes | law, academia, policy |
218
+ | `rule` | Helvetica | yes | a hairline under each section, for skim-readers |
219
+ | `accent` | Helvetica | email only | one colour, headings only |
220
+ | `mono` | Courier | yes | reads as engineering |
221
+
222
+ **Why only six, and why so restrained.** A CV is read twice: once by a parser that
223
+ wants plain text in a standard font, and once by a person with forty tabs open. The
224
+ compromise most builders make is to serve the human and quietly fail the machine —
225
+ two columns, an icon font, a text box for the sidebar, and an ATS that returns your
226
+ name and nothing else.
227
+
228
+ So every template here stays on the safe side of that line. All of them use one of
229
+ the 14 PDF base fonts, so nothing is embedded and the text always extracts; none
230
+ use columns, tables, images or text boxes. What varies is typeface, spacing, rules,
231
+ capitalisation and one accent colour — the parts a human notices and a parser
232
+ ignores.
233
+
234
+ `accent` is labelled *email only* rather than being left off the list: it extracts
235
+ cleanly, but carries enough colour that a strict parser could lose a heading. Send
236
+ that one to a person, not a portal.
237
+
238
+ The test suite asserts, for every template, that the rendered PDF contains no
239
+ embedded font program, no `/Image` or `/XObject`, and that the name, email and
240
+ every bullet come back out of a naive text extraction. That is the claim, and it
241
+ is checked rather than asserted.
242
+
243
+ Set a default in `config.toml`:
244
+
245
+ ```toml
246
+ [output]
247
+ template = "rule"
248
+ ```
249
+
180
250
  ## How do I install OfferPrinter?
181
251
 
182
252
  <!-- AEO Answer Capsule — 57 words -->
@@ -130,6 +130,76 @@ have hand-edited it, say — and makes no API calls at all, so it is free.
130
130
 
131
131
  ---
132
132
 
133
+ ## I have not updated my CV in three years
134
+
135
+ Neither has anyone. Your LinkedIn is current though, and LinkedIn will give it
136
+ back to you:
137
+
138
+ ```sh
139
+ # More > Save to PDF on your own profile
140
+ offerprinter --linkedin ~/Downloads/Profile.pdf --jd https://…
141
+
142
+ # or the full archive: Settings > Data privacy > Get a copy of your data
143
+ offerprinter --linkedin ~/Downloads/Basic_LinkedInDataExport.zip --jd https://…
144
+ ```
145
+
146
+ Both shapes are normalised into the Markdown the rest of the pipeline expects.
147
+ Nothing is scraped — you export your own data from your own account and this reads
148
+ the file.
149
+
150
+ The importer holds to the same rule as the generator: **it invents nothing.** A
151
+ role with no description gets no description; an empty section stays empty. There
152
+ is a test that takes every word of the output and asserts it appeared in the
153
+ input, because an importer that quietly "tidied up" your history would break the
154
+ no-fabrication guarantee before the generator ever ran.
155
+
156
+ ## Can I make it look like something other than every other CV?
157
+
158
+ Six templates, all of which still parse:
159
+
160
+ ```sh
161
+ offerprinter templates # what they are
162
+ offerprinter templates --sample ./samples # one PDF of each, to look at
163
+ offerprinter --cv cv.pdf --jd URL --template serif
164
+ ```
165
+
166
+ | Template | Typeface | Portal-safe | For |
167
+ |---|---|---|---|
168
+ | `classic` | Helvetica | yes | the default; nothing to go wrong |
169
+ | `compact` | Helvetica | yes | when two pages need to be one |
170
+ | `serif` | Times | yes | law, academia, policy |
171
+ | `rule` | Helvetica | yes | a hairline under each section, for skim-readers |
172
+ | `accent` | Helvetica | email only | one colour, headings only |
173
+ | `mono` | Courier | yes | reads as engineering |
174
+
175
+ **Why only six, and why so restrained.** A CV is read twice: once by a parser that
176
+ wants plain text in a standard font, and once by a person with forty tabs open. The
177
+ compromise most builders make is to serve the human and quietly fail the machine —
178
+ two columns, an icon font, a text box for the sidebar, and an ATS that returns your
179
+ name and nothing else.
180
+
181
+ So every template here stays on the safe side of that line. All of them use one of
182
+ the 14 PDF base fonts, so nothing is embedded and the text always extracts; none
183
+ use columns, tables, images or text boxes. What varies is typeface, spacing, rules,
184
+ capitalisation and one accent colour — the parts a human notices and a parser
185
+ ignores.
186
+
187
+ `accent` is labelled *email only* rather than being left off the list: it extracts
188
+ cleanly, but carries enough colour that a strict parser could lose a heading. Send
189
+ that one to a person, not a portal.
190
+
191
+ The test suite asserts, for every template, that the rendered PDF contains no
192
+ embedded font program, no `/Image` or `/XObject`, and that the name, email and
193
+ every bullet come back out of a naive text extraction. That is the claim, and it
194
+ is checked rather than asserted.
195
+
196
+ Set a default in `config.toml`:
197
+
198
+ ```toml
199
+ [output]
200
+ template = "rule"
201
+ ```
202
+
133
203
  ## How do I install OfferPrinter?
134
204
 
135
205
  <!-- AEO Answer Capsule — 57 words -->
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  from pathlib import Path
31
31
 
32
32
  import typer
33
+ from rich import box
33
34
  from rich.console import Console
34
35
  from rich.panel import Panel
35
36
  from rich.table import Table
@@ -80,7 +81,22 @@ def _version_cb(value: bool) -> None:
80
81
  raise typer.Exit()
81
82
 
82
83
 
83
- def _load_cv(cv: Path | None, cv_text: str | None) -> ResumeInput:
84
+ def _load_cv(
85
+ cv: Path | None, cv_text: str | None, linkedin: Path | None = None
86
+ ) -> ResumeInput:
87
+ if linkedin:
88
+ from offerprinter.services.linkedin import import_profile
89
+
90
+ try:
91
+ markdown = import_profile(linkedin)
92
+ except Exception as exc: # noqa: BLE001
93
+ console.print(f"[red]Could not read that LinkedIn export:[/red] {exc}")
94
+ raise typer.Exit(code=1) from exc
95
+ console.print(
96
+ f"[grey62]Imported {len(markdown):,} characters from {linkedin.name}. "
97
+ "Nothing was added that was not in the export.[/grey62]"
98
+ )
99
+ return cv_from_text(markdown)
84
100
  try:
85
101
  return cv_from_text(cv_text) if cv_text else extract_cv(cv) # type: ignore[arg-type]
86
102
  except Exception as exc: # noqa: BLE001
@@ -121,6 +137,7 @@ def _apply_overrides(
121
137
  redact: bool = False,
122
138
  no_verify: bool = False,
123
139
  no_cache: bool = False,
140
+ template: str | None = None,
124
141
  ) -> None:
125
142
  if provider:
126
143
  config.llm.provider = provider
@@ -135,6 +152,14 @@ def _apply_overrides(
135
152
  config.output.formats = [
136
153
  f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()
137
154
  ]
155
+ if template:
156
+ from offerprinter.services.themes import get_theme
157
+
158
+ try:
159
+ config.output.template = get_theme(template).name
160
+ except KeyError as exc:
161
+ console.print(f"[red]{exc.args[0]}[/red]")
162
+ raise typer.Exit(code=2) from exc
138
163
  if no_track:
139
164
  config.output.track = False
140
165
  if sequential:
@@ -299,6 +324,14 @@ def run(
299
324
  cv_text: str | None = typer.Option(
300
325
  None, "--cv-text", help="Paste your CV as raw text instead of a file."
301
326
  ),
327
+ linkedin: Path | None = typer.Option(
328
+ None,
329
+ "--linkedin",
330
+ help=(
331
+ "A LinkedIn export instead of a CV: the .pdf from More > Save to PDF, "
332
+ "or the .zip from Get a copy of your data."
333
+ ),
334
+ ),
302
335
  jd: str | None = typer.Option(
303
336
  None,
304
337
  "--jd",
@@ -327,6 +360,11 @@ def run(
327
360
  output_dir: Path | None = typer.Option(
328
361
  None, "--output-dir", "-o", help="Where to write the package (default ./output)."
329
362
  ),
363
+ template: str | None = typer.Option(
364
+ None,
365
+ "--template",
366
+ help="PDF template: classic, compact, serif, rule, accent, mono. See `offerprinter templates`.",
367
+ ),
330
368
  formats: str | None = typer.Option(
331
369
  None, "--formats", help="Comma-separated output formats: md, docx, pdf."
332
370
  ),
@@ -373,17 +411,22 @@ def run(
373
411
  return
374
412
 
375
413
  # ---- resolve inputs ---------------------------------------------------
376
- if not cv and not cv_text:
377
- console.print("[red]Error:[/red] provide a CV with --cv or --cv-text.")
414
+ if not cv and not cv_text and not linkedin:
415
+ console.print(
416
+ "[red]Error:[/red] provide a CV with --cv or --cv-text, "
417
+ "or a LinkedIn export with --linkedin."
418
+ )
378
419
  raise typer.Exit(code=2)
379
420
  if not jd and not jd_file and not jd_dir:
380
421
  console.print("[red]Error:[/red] provide a job with --jd, --jd-file, or --jd-dir.")
381
422
  raise typer.Exit(code=2)
382
423
 
383
424
  config = load_config(config_file)
384
- _apply_overrides(config, provider, model, locale, output_dir, formats, no_track, sequential)
425
+ _apply_overrides(
426
+ config, provider, model, locale, output_dir, formats, no_track, sequential, template=template
427
+ )
385
428
 
386
- resume = _load_cv(cv, cv_text)
429
+ resume = _load_cv(cv, cv_text, linkedin)
387
430
 
388
431
  # ---- batch mode -------------------------------------------------------
389
432
  if jd_dir:
@@ -899,6 +942,88 @@ def practice_command(
899
942
  console.print(f"[grey62]Saved to {path}[/grey62]")
900
943
 
901
944
 
945
+ @app.command("templates")
946
+ def templates_command(
947
+ sample: Path | None = typer.Option(
948
+ None, "--sample", help="Render one PDF per template into this folder."
949
+ ),
950
+ ) -> None:
951
+ """List the PDF templates, and optionally print a sample of each.
952
+
953
+ Every one of them stays inside what an ATS can parse -- base-14 fonts, no
954
+ columns, no tables, no images. What varies is typeface, spacing, rules and
955
+ one accent colour, which is the part a human notices and a parser ignores.
956
+ """
957
+ from offerprinter.services.pdf_writer import write_pdf
958
+ from offerprinter.services.themes import THEMES
959
+
960
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
961
+ table.add_column("Template")
962
+ table.add_column("Typeface")
963
+ table.add_column("Portal-safe")
964
+ table.add_column("What it is for")
965
+ for theme in THEMES.values():
966
+ table.add_row(
967
+ theme.name,
968
+ theme.typeface.regular,
969
+ "[green]yes[/green]" if theme.ats else "[yellow]email only[/yellow]",
970
+ theme.description,
971
+ )
972
+ console.print()
973
+ console.print(table)
974
+ console.print(
975
+ "[grey62]'email only' still extracts cleanly; it just carries enough colour that a "
976
+ "strict parser could lose a heading. Send it to a person, not a portal.[/grey62]"
977
+ )
978
+
979
+ if sample:
980
+ sample.mkdir(parents=True, exist_ok=True)
981
+ for theme in THEMES.values():
982
+ write_pdf(_SAMPLE_CV, sample / f"sample-{theme.name}.pdf", theme)
983
+ console.print(f"\n[green]Wrote {len(THEMES)} samples to[/green] {sample}")
984
+ else:
985
+ console.print("[grey62]offerprinter templates --sample ./samples prints one of each.[/grey62]")
986
+ console.print()
987
+
988
+
989
+ #: Used only by `templates --sample`. Invented person, so nothing real is written.
990
+ _SAMPLE_CV = """# Ada Lovelace
991
+
992
+ Analytical Engines - London - ada@example.com - linkedin.com/in/example
993
+
994
+ ## Summary
995
+
996
+ Engineer with ten years turning mechanical specifications into working programs.
997
+ Published the first algorithm intended for a machine.
998
+
999
+ ## Experience
1000
+
1001
+ ### Principal Engineer - Difference Engine Co
1002
+ *Jan 2019 - Present*
1003
+
1004
+ - Shipped the **first published algorithm**, six weeks ahead of schedule
1005
+ - Cut crank turns per calculation by 40%, which halved operator fatigue
1006
+ - Wrote the notes that three generations of engineers learned the machine from
1007
+
1008
+ ### Analyst - Royal Society
1009
+ *2016 - 2019*
1010
+
1011
+ - Translated Menabrea's memoir and added notes three times its length
1012
+
1013
+ ## Education
1014
+
1015
+ - University of London, Mathematics
1016
+
1017
+ ## Skills
1018
+
1019
+ Analytical engines, algorithm design, technical writing, translation
1020
+
1021
+ ---
1022
+
1023
+ > Every line above came from a source document. Nothing was invented.
1024
+ """
1025
+
1026
+
902
1027
  @app.command("verify")
903
1028
  def verify_command(
904
1029
  package_dir: Path = typer.Argument(..., help="An output/<company>-<role> folder to check."),
@@ -162,6 +162,7 @@ def load_config(config_path: str | os.PathLike[str] | None = None) -> Config:
162
162
  ),
163
163
  track=_env_bool("OFFERPRINTER_TRACK", bool(out_raw.get("track", True))),
164
164
  redact=_env_bool("OFFERPRINTER_REDACT", bool(out_raw.get("redact", False))),
165
+ template=_first_env("OFFERPRINTER_TEMPLATE") or out_raw.get("template", "classic"),
165
166
  )
166
167
 
167
168
  # ---- generation --------------------------------------------------------
@@ -115,7 +115,12 @@ class Pipeline:
115
115
  package.usage = Usage(**self.provider.usage.model_dump())
116
116
 
117
117
  # 7. Write to disk.
118
- written = write_package(package, self.config.output.dir, self.config.output.formats)
118
+ written = write_package(
119
+ package,
120
+ self.config.output.dir,
121
+ self.config.output.formats,
122
+ self.config.output.template,
123
+ )
119
124
  out_dir = Path(self.config.output.dir) / slug
120
125
  yield PipelineEvent(
121
126
  kind="written",
@@ -53,6 +53,8 @@ class OutputConfig(BaseModel):
53
53
  track: bool = True
54
54
  #: Strip name, email, phone and links before the provider ever sees the CV.
55
55
  redact: bool = False
56
+ #: PDF template name. See `offerprinter templates`.
57
+ template: str = "classic"
56
58
 
57
59
 
58
60
  class GenerationConfig(BaseModel):