@phuthuycoding/markcv 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ta Manh Quyen
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.
package/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # markcv
2
+
3
+ [![CI](https://github.com/phuthuycoding/markcv/actions/workflows/ci.yml/badge.svg)](https://github.com/phuthuycoding/markcv/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@phuthuycoding/markcv.svg)](https://www.npmjs.com/package/@phuthuycoding/markcv)
5
+
6
+ <p align="center">
7
+ <img src="docs/screenshots/engineering-lead.png" width="640" alt="A CV rendered by markcv">
8
+ <br>
9
+ <sub><code>examples/engineering-lead.md</code> — plain Markdown in, two-page A4 PDF out</sub>
10
+ </p>
11
+
12
+ Build a CV from Markdown — a CLI **and** an MCP server for AI agents.
13
+
14
+ Two things set it apart from ordinary markdown→PDF tools:
15
+
16
+ - **`fit`** tells you *why* your CV does not fit on 2 pages. "Content too long" and "bad page break" are different illnesses with opposite cures — trimming words while the real culprit is a heading sitting 31px from the bottom of page 1 just wastes your time.
17
+ - **`lint`** checks *content*, not formatting. It catches overselling **and underselling** — claiming less than you did is a mistake too, and it costs you something while gaining nothing.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ git clone https://github.com/phuthuycoding/markcv.git
23
+ cd markcv
24
+ npm install # builds dist/ via the prepare script
25
+ npm link # puts `markcv` and `markcv-mcp` on your PATH
26
+ ```
27
+
28
+ Or from npm:
29
+
30
+ ```bash
31
+ npm install -g @phuthuycoding/markcv
32
+ ```
33
+
34
+ The package is scoped, but the commands are not — you still type `markcv` and `markcv-mcp`.
35
+
36
+ Requires a Chromium-based browser already on your machine (Chrome, Chromium, Edge, Brave). markcv deliberately does **not** download its own Chromium — it uses `puppeteer-core`, so the install stays small. If your browser lives somewhere unusual, point at it with `MARKCV_CHROME=/path/to/chrome`.
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ markcv render cv.md -o Output.pdf # build the PDF, report the page count
42
+ markcv fit cv.md --pages 2 # why it does not fit yet
43
+ markcv lint cv.md # audit the content
44
+ markcv tailor cv.md --jd jd.txt # compare against a job description
45
+ markcv new techlead --from cv-master.md
46
+ markcv list # every CV in the folder
47
+ markcv diff cv-a.md cv-b.md # what a tailored copy dropped
48
+ markcv build --pages 2 # build every cv-*.md
49
+ ```
50
+
51
+ ### `fit` — page-break diagnosis
52
+
53
+ ```
54
+ ✗ 3 pages (target 2)
55
+ · content 1986px / 2080px available
56
+ · 31px wasted by page breaks
57
+
58
+ Blocks pushed to a new page:
59
+ PROJECT HIGHLIGHTS (H2, y=1009) → wastes 31px at the end of page 1
60
+
61
+ Suggestions:
62
+ Content HAS ROOM (94px to spare) — length is not the problem, the page break is.
63
+ "PROJECT HIGHLIGHTS" at y=1009 has only 31px left before the end of page 1, so the whole block moved down.
64
+ Fix: reorder sections, cut ~3 lines above it, or use --theme compact.
65
+ ```
66
+
67
+ ### `lint` — rules
68
+
69
+ | Rule | What it catches |
70
+ |---|---|
71
+ | `over-claim` | `spearheaded`, `rigorous`, `comprehensive`, `excellence`… — self-praise nobody can verify |
72
+ | `under-claim` | `advised on` / `worked on` next to real scale — you are probably selling yourself short |
73
+ | `tense` | a finished job still described in the present tense |
74
+ | `unsupported-skill` | a skill listed under SKILLS with no experience line backing it |
75
+ | `duplicate` | two bullets saying the same thing |
76
+ | `role-mismatch` | an IC job title paired with people-management language |
77
+ | `no-metric` | a long bullet with no number in it |
78
+ | `ats-emoji` | emoji in a heading — risky for ATS parsers |
79
+ | `long-bullet` | a bullet long enough that skimmers will skip it |
80
+
81
+ ## Examples
82
+
83
+ Three complete CVs in [`examples/`](examples), written for different roles so you can see
84
+ how the same format stretches. Each one builds with the default theme and no photo.
85
+
86
+ <table>
87
+ <tr>
88
+ <td width="33%"><a href="docs/screenshots/backend-engineer.png"><img src="docs/screenshots/backend-engineer.png" alt="Backend engineer CV"></a></td>
89
+ <td width="33%"><a href="docs/screenshots/engineering-lead.png"><img src="docs/screenshots/engineering-lead.png" alt="Engineering lead CV"></a></td>
90
+ <td width="33%"><a href="docs/screenshots/data-scientist.png"><img src="docs/screenshots/data-scientist.png" alt="Data scientist CV"></a></td>
91
+ </tr>
92
+ <tr>
93
+ <td align="center"><a href="examples/backend-engineer.md">backend-engineer.md</a><br><sub>1 page</sub></td>
94
+ <td align="center"><a href="examples/engineering-lead.md">engineering-lead.md</a><br><sub>2 pages</sub></td>
95
+ <td align="center"><a href="examples/data-scientist.md">data-scientist.md</a><br><sub>1 page</sub></td>
96
+ </tr>
97
+ </table>
98
+
99
+ ### With a portrait photo
100
+
101
+ Markets differ: a CV in Berlin or Toronto normally carries no photo, while one in
102
+ Vietnam, Japan or Germany's more traditional employers usually does. Drop a
103
+ `photo.jpg` next to the markdown file and it lands in the top-right corner.
104
+
105
+ <p align="center">
106
+ <a href="docs/screenshots/fullstack-engineer-photo.png">
107
+ <img src="docs/screenshots/fullstack-engineer-photo.png" width="440" alt="CV with a portrait photo">
108
+ </a>
109
+ <br>
110
+ <sub><a href="examples/with-photo/fullstack-engineer.md">examples/with-photo/fullstack-engineer.md</a> — one page, photo auto-detected</sub>
111
+ </p>
112
+
113
+ The photo lives in its own folder because detection is per-directory: any `.md`
114
+ file next to a `photo.*` picks it up. Keep photo-less CVs in a separate folder,
115
+ or pass `--no-photo`.
116
+
117
+ Build them yourself:
118
+
119
+ ```bash
120
+ markcv build examples --pages 2
121
+ markcv render examples/with-photo/fullstack-engineer.md --pages 1
122
+ ```
123
+
124
+ Note what the bullets in those samples have in common: a number, or a before and after.
125
+ `lint` exists to push a CV in that direction — the samples are what it is aiming at, and
126
+ [`test/fixtures/bad-cv.md`](test/fixtures/bad-cv.md) is what it is aiming away from.
127
+
128
+ ## MCP server
129
+
130
+ Lets an AI agent (Claude Code, Claude Desktop, Cursor…) build and audit CVs on its own.
131
+
132
+ Install it first (see [Install](#install) above), then point a client at `markcv-mcp`.
133
+
134
+ Check the server starts (it waits for JSON-RPC on stdin and prints nothing — that is
135
+ correct; Ctrl+C to quit):
136
+
137
+ ```bash
138
+ markcv-mcp
139
+ ```
140
+
141
+ ### Register it with a client
142
+
143
+ After `npm link` the command is simply `markcv-mcp`. Without it, use an **absolute path**
144
+ to `dist/mcp/server.js`. The repo ships `.mcp.json.example` to copy from.
145
+
146
+ **Claude Code** — add to `.mcp.json` in your project (shared with the team), or `~/.claude.json` (just you):
147
+
148
+ ```json
149
+ {
150
+ "mcpServers": {
151
+ "markcv": {
152
+ "command": "markcv-mcp",
153
+ "cwd": "/path/to/your/cv/folder"
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ Or add it from the command line:
160
+
161
+ ```bash
162
+ claude mcp add markcv -- markcv-mcp
163
+ claude mcp list # confirm it connected
164
+ ```
165
+
166
+ Restart the client afterwards so it picks the server up.
167
+
168
+ **Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows), same `mcpServers` shape, then restart the app.
169
+
170
+ **Cursor** — `.cursor/mcp.json` in the project, same shape.
171
+
172
+ ### File paths in tool arguments
173
+
174
+ Every tool takes a file path. Relative paths resolve against the **server process's working directory**, so either pass absolute paths or give the server a `cwd`:
175
+
176
+ ```json
177
+ {
178
+ "mcpServers": {
179
+ "markcv": {
180
+ "command": "node",
181
+ "args": ["/path/to/markcv/dist/mcp/server.js"],
182
+ "cwd": "/path/to/your/cv/folder"
183
+ }
184
+ }
185
+ }
186
+ ```
187
+
188
+ ### Tools
189
+
190
+ | Tool | Purpose |
191
+ |---|---|
192
+ | `render_cv` | build the PDF, return the real page count plus layout numbers |
193
+ | `check_fit` | why it does not fit in N pages: too long, or bad page breaks |
194
+ | `lint_cv` | audit content (overselling, underselling, tense, unbacked claims…) |
195
+ | `tailor_to_jd` | compare against a job description (`jd_file` or `jd_text`) |
196
+ | `list_variants` | list every CV in a folder |
197
+ | `new_variant` | start a tailored copy from a master file |
198
+ | `diff_variants` | compare two versions, see what a tailored copy dropped |
199
+
200
+ Every tool returns **structured JSON**, not prose — so an agent can loop on it: edit the markdown → `check_fit` → read `slackPx` and `culprits` → edit again, until it fits.
201
+
202
+ ### Requirements
203
+
204
+ Node >= 18 and a Chromium-based browser. If it is in a non-standard location:
205
+
206
+ ```json
207
+ { "mcpServers": { "markcv": { "command": "node", "args": ["..."],
208
+ "env": { "MARKCV_CHROME": "/path/to/chrome" } } } }
209
+ ```
210
+
211
+ ## CV format
212
+
213
+ Plain Markdown. The only convention lives in the header:
214
+
215
+ ```markdown
216
+ # Your Name
217
+
218
+ **Email:** you@example.com
219
+ **Phone:** +84 9xx xxx xxx
220
+
221
+ ***
222
+
223
+ ## OBJECTIVE
224
+ ...
225
+
226
+ ## WORK EXPERIENCE
227
+
228
+ ### Company Name
229
+
230
+ **Job Title** | Jan 2020 - Dec 2023
231
+
232
+ * Bullet...
233
+ ```
234
+
235
+ `**Label:** value` lines directly under `# Your Name` become the contact block. A line containing `|` is read as job title + dates — and `lint` uses those dates to know whether a job has ended.
236
+
237
+ **Portrait photo:** drop `photo.jpg` (or `photo.png`, `avatar.jpg`) next to the `.md` file and it is embedded in the top-right corner. Without one you get an empty placeholder box. Photos over 400KB trigger a warning, because they push the PDF past the upload limit many job portals enforce.
238
+
239
+ **Skip a file in `markcv build`:** put `<!-- markcv:no-build -->` near the top. Useful for a master file that is a content store rather than something you submit.
240
+
241
+ ## Themes
242
+
243
+ `classic` (default) and `compact`. Both are single-column, emoji-free, with a real text layer — safe for ATS parsers.
244
+
245
+ ## License
246
+
247
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { resolve, basename, join } from "node:path";
5
+ import { render } from "./core/render.js";
6
+ import { analyseFit } from "./core/fit.js";
7
+ import { lint } from "./core/lint.js";
8
+ import { tailor } from "./core/tailor.js";
9
+ import { newVariant, listVariants, diffVariants } from "./core/variants.js";
10
+ import { c, ok, bad, warn, info } from "./ui.js";
11
+ const program = new Command();
12
+ program.name("markcv").description("Build, fit and audit a CV written in Markdown").version("0.1.0");
13
+ const pdfNameFor = (md) => md.replace(/\.md$/, ".pdf");
14
+ function printFit(r) {
15
+ const head = r.fits ? ok(`${r.pages} page(s)`) : bad(`${r.pages} pages (target ${r.targetPages})`);
16
+ console.log(head);
17
+ console.log(info(`content ${r.contentHeight}px / ${r.usablePerPage * (r.targetPages ?? r.pages)}px available`));
18
+ if (r.wastedByBreaksPx > 0)
19
+ console.log(info(`${r.wastedByBreaksPx}px wasted by page breaks`));
20
+ if (r.culprits.length) {
21
+ console.log(c.bold("\nBlocks pushed to a new page:"));
22
+ for (const cu of r.culprits) {
23
+ console.log(` ${c.yellow(cu.title)} ${c.dim(`(${cu.tag}, y=${cu.top})`)} → wastes ${c.red(String(cu.wastedPx) + "px")} at the end of page ${cu.page}`);
24
+ }
25
+ }
26
+ if (r.suggestions.length) {
27
+ console.log(c.bold("\nSuggestions:"));
28
+ for (const s of r.suggestions)
29
+ console.log(` ${s}`);
30
+ }
31
+ }
32
+ function printLint(fs) {
33
+ if (!fs.length)
34
+ return console.log(ok("no issues found"));
35
+ const bySeverity = { error: 0, warn: 0, info: 0 };
36
+ for (const f of fs) {
37
+ bySeverity[f.severity]++;
38
+ const tag = f.severity === "error" ? c.red("error") : f.severity === "warn" ? c.yellow("warn ") : c.dim("info ");
39
+ console.log(`${tag} ${c.dim(`L${String(f.line).padStart(3)}`)} ${c.cyan(f.rule)} ${f.message}`);
40
+ if (f.excerpt)
41
+ console.log(` ${c.dim("→ " + f.excerpt)}`);
42
+ if (f.suggestion)
43
+ console.log(` ${c.green("fix:")} ${f.suggestion}`);
44
+ }
45
+ console.log(`\n${bySeverity.error} error · ${bySeverity.warn} warn · ${bySeverity.info} info`);
46
+ }
47
+ program
48
+ .command("render <file>")
49
+ .description("Build a PDF (and HTML) from a markdown file")
50
+ .option("-o, --pdf <path>", "output PDF path")
51
+ .option("--html <path>", "keep the intermediate HTML")
52
+ .option("-t, --theme <name>", "theme: classic | compact", "classic")
53
+ .option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10))
54
+ .option("--no-photo", "do not embed the portrait photo")
55
+ .action(async (file, o) => {
56
+ const res = await render({
57
+ input: file,
58
+ pdf: o.pdf ?? pdfNameFor(file),
59
+ html: o.html,
60
+ theme: o.theme,
61
+ photo: o.photo === false ? null : undefined,
62
+ targetPages: o.pages,
63
+ });
64
+ console.log(ok(`PDF ${res.pdfPath}`));
65
+ if (o.html)
66
+ console.log(info(`HTML ${res.htmlPath}`));
67
+ const photoNote = o.photo === false ? "disabled" : res.photoFile ? basename(res.photoFile) : "none (placeholder box)";
68
+ console.log(info(`photo ${photoNote}`));
69
+ if (res.photoWarning)
70
+ console.log(warn(res.photoWarning));
71
+ const fit = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
72
+ printFit(fit);
73
+ });
74
+ program
75
+ .command("fit <file>")
76
+ .description("Why the CV does not fit the target page count")
77
+ .option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10), 2)
78
+ .option("-t, --theme <name>", "theme", "classic")
79
+ .option("--json", "output JSON")
80
+ .action(async (file, o) => {
81
+ const tmpPdf = join(process.env.TMPDIR ?? "/tmp", `markcv-fit-${Date.now()}.pdf`);
82
+ const res = await render({ input: file, pdf: tmpPdf, theme: o.theme, targetPages: o.pages });
83
+ const report = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
84
+ if (o.json)
85
+ return console.log(JSON.stringify(report, null, 2));
86
+ printFit(report);
87
+ process.exitCode = report.fits ? 0 : 1;
88
+ });
89
+ program
90
+ .command("lint <file>")
91
+ .description("Audit content: overselling, underselling, tense, unbacked claims, duplicates")
92
+ .option("--json", "output JSON")
93
+ .option("--strict", "treat warnings as failures")
94
+ .action((file, o) => {
95
+ const findings = lint(readFileSync(resolve(file), "utf8"));
96
+ if (o.json)
97
+ return console.log(JSON.stringify(findings, null, 2));
98
+ printLint(findings);
99
+ const fail = findings.some((f) => f.severity === "error" || (o.strict && f.severity === "warn"));
100
+ process.exitCode = fail ? 1 : 0;
101
+ });
102
+ program
103
+ .command("tailor <file>")
104
+ .description("Compare the CV against a job description")
105
+ .requiredOption("--jd <path>", "job description file (txt/md)")
106
+ .option("--json", "output JSON")
107
+ .action((file, o) => {
108
+ const report = tailor(readFileSync(resolve(file), "utf8"), readFileSync(resolve(o.jd), "utf8"));
109
+ if (o.json)
110
+ return console.log(JSON.stringify(report, null, 2));
111
+ console.log(c.bold(`Match: ${report.score}% (${report.covered.length}/${report.covered.length + report.missing.length + report.unsupported.length} JD keywords)`));
112
+ if (report.missing.length) {
113
+ console.log(c.bold("\nAsked for by the JD, missing from the CV:"));
114
+ for (const m of report.missing)
115
+ console.log(` ${bad(m.keyword)} ${c.dim(m.hint)}`);
116
+ }
117
+ if (report.unsupported.length) {
118
+ console.log(c.bold("\nOnly under SKILLS, with no evidence:"));
119
+ for (const u of report.unsupported)
120
+ console.log(` ${warn(u.keyword)} ${c.dim(u.hint)}`);
121
+ }
122
+ if (report.irrelevant.length) {
123
+ console.log(c.bold("\nBullets unrelated to the JD (cut these first):"));
124
+ for (const i of report.irrelevant.slice(0, 8))
125
+ console.log(` ${c.dim(`L${i.line}`)} ${i.excerpt}`);
126
+ }
127
+ if (report.covered.length) {
128
+ console.log(c.bold("\nBacked by evidence:"));
129
+ console.log(" " + report.covered.map((x) => c.green(x.keyword)).join(", "));
130
+ }
131
+ });
132
+ program
133
+ .command("new <name>")
134
+ .description("Start a tailored version from a master file")
135
+ .requiredOption("--from <master>", "master CV file")
136
+ .action((name, o) => console.log(ok(`created ${newVariant(o.from, name)}`)));
137
+ program
138
+ .command("list [dir]")
139
+ .description("List every CV in a folder")
140
+ .action((dir = ".") => {
141
+ for (const v of listVariants(dir)) {
142
+ console.log(`${c.bold(v.name.padEnd(28))} ${String(v.lines).padStart(4)} lines ${String(v.bullets).padStart(3)} bullets ${c.dim(v.sections.join(" · "))}`);
143
+ }
144
+ });
145
+ program
146
+ .command("diff <a> <b>")
147
+ .description("Compare two CVs bullet by bullet")
148
+ .action((a, b) => {
149
+ const d = diffVariants(a, b);
150
+ console.log(c.bold(`${d.shared} bullets in common`));
151
+ if (d.onlyInA.length) {
152
+ console.log(c.bold(`\nOnly in ${basename(a)}:`));
153
+ for (const l of d.onlyInA)
154
+ console.log(` ${c.green("+")} ${l.slice(0, 100)}`);
155
+ }
156
+ if (d.onlyInB.length) {
157
+ console.log(c.bold(`\nOnly in ${basename(b)}:`));
158
+ for (const l of d.onlyInB)
159
+ console.log(` ${c.blue("+")} ${l.slice(0, 100)}`);
160
+ }
161
+ });
162
+ program
163
+ .command("build [dir]")
164
+ .description("Build a PDF for every cv-*.md in a folder")
165
+ .option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10), 2)
166
+ .option("-t, --theme <name>", "theme", "classic")
167
+ .action(async (dir = ".", o) => {
168
+ const all = readdirSync(resolve(dir)).filter((f) => f.startsWith("cv-") && f.endsWith(".md"));
169
+ // A file marked <!-- markcv:no-build --> is a content store, not something you
170
+ // submit — skip it in batch builds.
171
+ const files = all.filter((f) => {
172
+ const head = readFileSync(join(resolve(dir), f), "utf8").slice(0, 400);
173
+ const skip = /markcv:no-build/i.test(head);
174
+ if (skip)
175
+ console.log(`${c.dim("–")} ${f.padEnd(34)} ${c.dim("skipped (content store)")}`);
176
+ return !skip;
177
+ });
178
+ if (!files.length)
179
+ return console.log(warn("nothing to build"));
180
+ for (const f of files) {
181
+ const src = join(resolve(dir), f);
182
+ const res = await render({ input: src, pdf: pdfNameFor(src), theme: o.theme, targetPages: o.pages });
183
+ const fit = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
184
+ const mark = fit.fits ? c.green("✓") : c.red("✗");
185
+ console.log(`${mark} ${f.padEnd(34)} ${fit.pages} page(s) ${c.dim(fit.fits ? "" : fit.suggestions[0] ?? "")}`);
186
+ }
187
+ });
188
+ program.parseAsync(process.argv);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Locate an installed Chrome. markcv deliberately does NOT ship its own Chromium
3
+ * (puppeteer-core, not puppeteer) so the install stays small.
4
+ */
5
+ export declare function findChrome(explicit?: string): string;
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import { platform } from "node:os";
3
+ /** Common Chrome/Edge/Chromium locations, per platform. */
4
+ const PATHS = {
5
+ darwin: [
6
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
7
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
8
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
9
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
10
+ ],
11
+ linux: [
12
+ "/usr/bin/google-chrome",
13
+ "/usr/bin/chromium",
14
+ "/usr/bin/chromium-browser",
15
+ "/usr/bin/microsoft-edge",
16
+ "/snap/bin/chromium",
17
+ ],
18
+ win32: [
19
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
20
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
21
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
22
+ ],
23
+ };
24
+ /**
25
+ * Locate an installed Chrome. markcv deliberately does NOT ship its own Chromium
26
+ * (puppeteer-core, not puppeteer) so the install stays small.
27
+ */
28
+ export function findChrome(explicit) {
29
+ if (explicit) {
30
+ if (!existsSync(explicit))
31
+ throw new Error(`No browser found at: ${explicit}`);
32
+ return explicit;
33
+ }
34
+ const fromEnv = process.env.MARKCV_CHROME ?? process.env.CHROME_PATH;
35
+ if (fromEnv && existsSync(fromEnv))
36
+ return fromEnv;
37
+ const found = (PATHS[platform()] ?? []).find(existsSync);
38
+ if (!found) {
39
+ throw new Error("No Chrome/Chromium/Edge found on this machine.\n" +
40
+ "Install Chrome, or point at it with MARKCV_CHROME=/path/to/chrome");
41
+ }
42
+ return found;
43
+ }
@@ -0,0 +1,11 @@
1
+ import type { FitReport } from "../types.js";
2
+ import type { RawMeasure, PageBox } from "./render.js";
3
+ /**
4
+ * Explain why a CV does not fit the target page count.
5
+ *
6
+ * The key distinction: "content too long" and "bad page break" are TWO different
7
+ * problems with opposite cures. Trimming words while the real culprit is a heading
8
+ * sitting just above a page boundary wastes effort — that one needs a reorder or
9
+ * tighter spacing instead.
10
+ */
11
+ export declare function analyseFit(measure: RawMeasure, pageBox: PageBox, pdfPages: number, targetPages?: number): FitReport;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Explain why a CV does not fit the target page count.
3
+ *
4
+ * The key distinction: "content too long" and "bad page break" are TWO different
5
+ * problems with opposite cures. Trimming words while the real culprit is a heading
6
+ * sitting just above a page boundary wastes effort — that one needs a reorder or
7
+ * tighter spacing instead.
8
+ */
9
+ export function analyseFit(measure, pageBox, pdfPages, targetPages) {
10
+ const usable = pageBox.heightPx;
11
+ const { contentHeight } = measure;
12
+ const culprits = [];
13
+ for (const b of measure.blocks) {
14
+ // Bottom edge of the page this block sits on. floor+1, not ceil: a block at
15
+ // y=0 belongs to page 1 and still has the whole page below it.
16
+ const pageEnd = (Math.floor(b.top / usable) + 1) * usable;
17
+ const room = pageEnd - b.top;
18
+ // The cluster (heading + the element glued to it) does not fit -> it gets pushed down.
19
+ if (b.clusterHeight > room) {
20
+ culprits.push({
21
+ title: b.title,
22
+ tag: b.tag,
23
+ top: b.top,
24
+ wastedPx: Math.round(room),
25
+ page: Math.floor(b.top / usable) + 1,
26
+ });
27
+ }
28
+ }
29
+ culprits.sort((a, b) => b.wastedPx - a.wastedPx);
30
+ const wastedByBreaksPx = culprits.reduce((sum, c) => sum + c.wastedPx, 0);
31
+ const target = targetPages ?? pdfPages;
32
+ const budget = usable * target;
33
+ const slackPx = Math.round(budget - contentHeight);
34
+ const contentWouldFit = contentHeight <= budget;
35
+ const fits = pdfPages <= target;
36
+ const suggestions = [];
37
+ if (!fits) {
38
+ if (contentWouldFit) {
39
+ const worst = culprits[0];
40
+ suggestions.push(`Content HAS ROOM (${slackPx}px to spare) - length is not the problem, the page break is.`);
41
+ if (worst) {
42
+ suggestions.push(`"${worst.title}" at y=${worst.top} has only ${worst.wastedPx}px left before the end of ` +
43
+ `page ${worst.page}, so the whole block moved down.`);
44
+ suggestions.push(`Fix: reorder sections, cut ~${Math.ceil(worst.wastedPx / 15)} lines above it, ` +
45
+ `or use --theme compact.`);
46
+ }
47
+ }
48
+ else {
49
+ const over = Math.abs(slackPx);
50
+ suggestions.push(`Content is ~${over}px longer than ${target} page(s) (~${Math.ceil(over / 15)} lines).`);
51
+ suggestions.push(`Cut ~${Math.ceil(over / 15)} lines, or use --theme compact.`);
52
+ if (wastedByBreaksPx > 0) {
53
+ suggestions.push(`On top of that, ${wastedByBreaksPx}px is wasted by page breaks - see culprits.`);
54
+ }
55
+ }
56
+ }
57
+ return {
58
+ pages: pdfPages,
59
+ targetPages,
60
+ fits,
61
+ contentHeight,
62
+ usablePerPage: usable,
63
+ slackPx,
64
+ contentWouldFit,
65
+ wastedByBreaksPx,
66
+ culprits: culprits.slice(0, 5),
67
+ suggestions,
68
+ };
69
+ }
@@ -0,0 +1,2 @@
1
+ import type { LintFinding } from "../types.js";
2
+ export declare function lint(raw: string): LintFinding[];